From 7c609e045bd3d5c31be62192ad5ed49f8bc4069b Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sat, 4 Apr 2026 19:28:43 +0100 Subject: [PATCH 01/26] feat: US-001 - Solution scaffolding and project structure Co-Authored-By: Claude Opus 4.6 --- Writegeist.slnx | 10 + prd.json | 445 ++++++++++++++++++ scripts/ralph/CLAUDE.md | 104 ++++ scripts/ralph/prd.json | 445 ++++++++++++++++++ scripts/ralph/progress.txt | 3 + .../Actions/PlaceholderAction.cs | 13 + src/Writegeist.Cli/Menus/MainMenu.cs | 12 + src/Writegeist.Cli/Program.cs | 11 + src/Writegeist.Cli/Writegeist.Cli.csproj | 20 + src/Writegeist.Core/Writegeist.Core.csproj | 9 + .../Writegeist.Infrastructure.csproj | 18 + .../Writegeist.Tests/Writegeist.Tests.csproj | 28 ++ 12 files changed, 1118 insertions(+) create mode 100644 Writegeist.slnx create mode 100644 prd.json create mode 100644 scripts/ralph/CLAUDE.md create mode 100644 scripts/ralph/prd.json create mode 100644 scripts/ralph/progress.txt create mode 100644 src/Writegeist.Cli/Actions/PlaceholderAction.cs create mode 100644 src/Writegeist.Cli/Menus/MainMenu.cs create mode 100644 src/Writegeist.Cli/Program.cs create mode 100644 src/Writegeist.Cli/Writegeist.Cli.csproj create mode 100644 src/Writegeist.Core/Writegeist.Core.csproj create mode 100644 src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj create mode 100644 tests/Writegeist.Tests/Writegeist.Tests.csproj diff --git a/Writegeist.slnx b/Writegeist.slnx new file mode 100644 index 0000000..b16396c --- /dev/null +++ b/Writegeist.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/prd.json b/prd.json new file mode 100644 index 0000000..84b1066 --- /dev/null +++ b/prd.json @@ -0,0 +1,445 @@ +{ + "project": "Writegeist", + "branchName": "ralph/writegeist-v1", + "description": "Interactive .NET CLI tool that clones a person's writing style from social media posts and generates platform-specific content on demand", + "userStories": [ + { + "id": "US-001", + "title": "Solution scaffolding and project structure", + "description": "As a developer, I need the .NET solution with all projects, references, and NuGet packages so that I can start building features.", + "acceptanceCriteria": [ + "Writegeist.sln with four projects: Writegeist.Cli, Writegeist.Core, Writegeist.Infrastructure, Writegeist.Tests", + "All projects target net10.0", + "Project references wired: Cli references Core + Infrastructure, Infrastructure references Core, Tests references Core + Infrastructure", + "Writegeist.Cli references DevJonny.InteractiveCli NuGet package", + "Writegeist.Infrastructure references Microsoft.Data.Sqlite and AngleSharp", + "Writegeist.Tests references xunit, FluentAssertions, and NSubstitute", + "Program.cs bootstraps InteractiveCli with a MainMenu that displays menu items and quits cleanly", + "MainMenu is a top-level menu (isTopLevel: true) with a placeholder menu item", + "dotnet build succeeds with no errors", + "Typecheck passes" + ], + "priority": 1, + "passes": true, + "notes": "" + }, + { + "id": "US-002", + "title": "Domain models", + "description": "As a developer, I need the core domain models defined so that repositories and services can use them.", + "acceptanceCriteria": [ + "Person model in Writegeist.Core/Models with Id (int), Name (string), CreatedAt (DateTime)", + "RawPost model with Id, PersonId, Platform, Content, ContentHash, SourceUrl, FetchedAt", + "Platform enum with values: LinkedIn, X, Instagram, Facebook", + "StyleProfile model with Id, PersonId, ProfileJson, Provider, Model, CreatedAt", + "GeneratedDraft model with Id, PersonId, StyleProfileId, Platform, Topic, Content, ParentDraftId (nullable), Feedback (nullable), Provider, Model, CreatedAt", + "FetchRequest record with nullable Url, Handle, FilePath properties", + "FetchedPost record with Content, nullable SourceUrl, nullable PublishedAt", + "dotnet build succeeds", + "Typecheck passes" + ], + "priority": 2, + "passes": false, + "notes": "" + }, + { + "id": "US-003", + "title": "Core interfaces", + "description": "As a developer, I need the core interfaces defined so that infrastructure implementations can be built against them.", + "acceptanceCriteria": [ + "IPersonRepository interface in Writegeist.Core/Interfaces with CreateAsync, GetByNameAsync, GetAllAsync, GetOrCreateAsync", + "IPostRepository interface with AddAsync, GetByPersonIdAsync, GetCountByPersonIdAsync, ExistsByHashAsync", + "IStyleProfileRepository interface with SaveAsync, GetLatestByPersonIdAsync, GetAllByPersonIdAsync", + "IDraftRepository interface with SaveAsync, GetLatestAsync, GetByIdAsync", + "IContentFetcher interface with Platform property and FetchPostsAsync method", + "ILlmProvider interface with ProviderName, ModelName properties and AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync methods", + "dotnet build succeeds", + "Typecheck passes" + ], + "priority": 3, + "passes": false, + "notes": "" + }, + { + "id": "US-004", + "title": "SQLite database and schema initialisation", + "description": "As a developer, I need the SQLite database created with the correct schema so that data persists locally.", + "acceptanceCriteria": [ + "SqliteDatabase class in Writegeist.Infrastructure/Persistence with EnsureCreated method", + "Creates persons table with id (autoincrement PK), name (unique, not null), created_at (default now)", + "Creates raw_posts table with FK to persons, platform, content, content_hash, source_url, fetched_at, unique constraint on (person_id, content_hash)", + "Creates style_profiles table with FK to persons, profile_json, provider, model, created_at", + "Creates generated_drafts table with FK to persons and style_profiles, parent_draft_id self-ref FK, platform, topic, content, feedback, provider, model, created_at", + "Schema creation is idempotent (uses CREATE TABLE IF NOT EXISTS)", + "Database path configurable via IConfiguration (Writegeist:DatabasePath key)", + "Unit tests verify all four tables are created using in-memory SQLite", + "Typecheck passes" + ], + "priority": 4, + "passes": false, + "notes": "" + }, + { + "id": "US-005", + "title": "Person repository implementation", + "description": "As a developer, I need the person repository so that persons can be stored and retrieved.", + "acceptanceCriteria": [ + "SqlitePersonRepository implements IPersonRepository", + "CreateAsync inserts a new person and returns the model with the generated Id", + "GetByNameAsync performs case-insensitive lookup and returns null if not found", + "GetAllAsync returns all persons ordered by name", + "GetOrCreateAsync returns existing person if name matches (case-insensitive), creates new one if not", + "Unit tests with in-memory SQLite cover create, get by name, get all, get-or-create (existing), get-or-create (new)", + "Typecheck passes", + "Tests pass" + ], + "priority": 5, + "passes": false, + "notes": "" + }, + { + "id": "US-006", + "title": "Post repository implementation", + "description": "As a developer, I need the post repository so that ingested posts can be stored and deduplicated.", + "acceptanceCriteria": [ + "SqlitePostRepository implements IPostRepository", + "AddAsync inserts a post and returns a result indicating whether it was new or a duplicate", + "Content hash is SHA-256 of normalised (trimmed, lowercased) content text", + "Duplicate detection uses the unique constraint on (person_id, content_hash)", + "GetByPersonIdAsync returns all posts for a person ordered by fetched_at", + "GetCountByPersonIdAsync returns the count of posts for a person", + "Unit tests cover add new, add duplicate, retrieval, and count", + "Typecheck passes", + "Tests pass" + ], + "priority": 6, + "passes": false, + "notes": "" + }, + { + "id": "US-007", + "title": "Style profile repository implementation", + "description": "As a developer, I need the style profile repository so that analysed profiles can be stored and retrieved.", + "acceptanceCriteria": [ + "SqliteStyleProfileRepository implements IStyleProfileRepository", + "SaveAsync inserts a profile and returns the model with generated Id", + "GetLatestByPersonIdAsync returns the most recent profile by created_at, or null if none exist", + "GetAllByPersonIdAsync returns all profiles for a person ordered by created_at descending", + "Unit tests cover save, retrieve latest, retrieve all, and null case", + "Typecheck passes", + "Tests pass" + ], + "priority": 7, + "passes": false, + "notes": "" + }, + { + "id": "US-008", + "title": "Draft repository implementation", + "description": "As a developer, I need the draft repository so that generated and refined drafts can be stored and chained.", + "acceptanceCriteria": [ + "SqliteDraftRepository implements IDraftRepository", + "SaveAsync inserts a draft and returns the model with generated Id", + "GetLatestAsync returns the most recently created draft across all persons, or null if none exist", + "GetByIdAsync returns a draft by Id, or null if not found", + "parent_draft_id correctly links refined drafts to their predecessors", + "Unit tests cover save, get latest, get by id, parent-child linking, and null cases", + "Typecheck passes", + "Tests pass" + ], + "priority": 8, + "passes": false, + "notes": "" + }, + { + "id": "US-009", + "title": "Manual fetcher — file import", + "description": "As a user, I want to import posts from a text file so that I can feed Writegeist content without API access.", + "acceptanceCriteria": [ + "ManualFetcher class in Writegeist.Infrastructure/Fetchers implements IContentFetcher", + "Platform property returns a configurable platform value (set at construction)", + "FetchPostsAsync reads a file path from FetchRequest.FilePath", + "Splits file content on lines containing only --- as separator", + "Trims whitespace from each post and skips empty entries", + "Returns a list of FetchedPost records with content populated", + "Unit tests cover: normal file with multiple posts, empty file, file with no separators (single post), file with consecutive separators", + "Typecheck passes", + "Tests pass" + ], + "priority": 9, + "passes": false, + "notes": "" + }, + { + "id": "US-010", + "title": "Platform conventions", + "description": "As a developer, I need platform-specific rules so that generated posts follow each platform's norms.", + "acceptanceCriteria": [ + "PlatformRules record in Writegeist.Core with Name, MaxCharacters (nullable int), RecommendedMaxLength, SupportsHashtags, RecommendedHashtagCount, HashtagPlacement, SupportsEmoji, EmojiGuidance, ToneGuidance, FormattingNotes", + "PlatformConventions class with static GetRules(Platform) method", + "LinkedIn: 3000 max, 1500 recommended, hashtags end, 3-5 count, emoji sparingly", + "X: 280 max, 280 recommended, hashtags inline, 1-2 count, emoji moderate", + "Instagram: 2200 max, 750 recommended, hashtags end in separate block, up to 30, emoji freely", + "Facebook: 63206 max, 600 recommended, hashtags end, 1-3 count, emoji moderate", + "Unit tests verify rules for all four platforms", + "Typecheck passes", + "Tests pass" + ], + "priority": 10, + "passes": false, + "notes": "" + }, + { + "id": "US-011", + "title": "Anthropic LLM provider", + "description": "As a user, I want to use Claude as the LLM backend so that I can analyse style and generate posts.", + "acceptanceCriteria": [ + "AnthropicProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider", + "Uses official Anthropic NuGet SDK if available, otherwise raw HttpClient to https://api.anthropic.com/v1/messages", + "API key read from ANTHROPIC_API_KEY environment variable via IConfiguration", + "Model name configurable via appsettings.json (Writegeist:Anthropic:Model), defaults to claude-sonnet-4-20250514", + "ProviderName returns 'anthropic', ModelName returns the configured model", + "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response", + "Throws a descriptive exception if API key is not configured", + "Handles HTTP errors (401, 429, 500) with user-friendly exception messages", + "Typecheck passes" + ], + "priority": 11, + "passes": false, + "notes": "" + }, + { + "id": "US-012", + "title": "OpenAI LLM provider", + "description": "As a user, I want to use OpenAI as an alternative LLM backend.", + "acceptanceCriteria": [ + "OpenAiProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider", + "Uses official OpenAI NuGet package", + "API key read from OPENAI_API_KEY environment variable via IConfiguration", + "Model name configurable via appsettings.json (Writegeist:OpenAi:Model), defaults to gpt-4o", + "ProviderName returns 'openai', ModelName returns the configured model", + "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response", + "Throws a descriptive exception if API key is not configured", + "Handles HTTP errors with user-friendly exception messages", + "Typecheck passes" + ], + "priority": 12, + "passes": false, + "notes": "" + }, + { + "id": "US-013", + "title": "Style analyser service", + "description": "As a developer, I need the style analysis orchestration so that ingested posts can be turned into a style profile.", + "acceptanceCriteria": [ + "StyleAnalyser class in Writegeist.Core/Services", + "Constructor takes IPostRepository, IStyleProfileRepository, and ILlmProvider", + "AnalyseAsync(int personId) loads all posts for the person, builds the style analysis prompt with all post contents, sends to LLM, and stores the result", + "Uses the style analysis prompt template from the implementation plan (section 7.1)", + "Returns the saved StyleProfile model", + "Throws if no posts exist for the person", + "Unit tests with mocked dependencies verify prompt construction and profile storage", + "Typecheck passes", + "Tests pass" + ], + "priority": 13, + "passes": false, + "notes": "" + }, + { + "id": "US-014", + "title": "Post generator service", + "description": "As a developer, I need the post generation orchestration so that drafts can be created from a style profile and topic.", + "acceptanceCriteria": [ + "PostGenerator class in Writegeist.Core/Services", + "Constructor takes IStyleProfileRepository, IDraftRepository, and ILlmProvider", + "GenerateAsync(int personId, Platform platform, string topic) loads latest style profile, gets platform conventions, builds generation prompt, sends to LLM, stores draft", + "Uses the generation prompt template from the implementation plan (section 7.2)", + "RefineAsync(int draftId, string feedback) loads draft, its style profile, builds refinement prompt, sends to LLM, stores new draft linked to previous", + "Uses the refinement prompt template from the implementation plan (section 7.3)", + "Returns the saved GeneratedDraft model", + "Throws if no style profile exists for the person", + "Unit tests with mocked dependencies verify prompt construction and draft storage", + "Typecheck passes", + "Tests pass" + ], + "priority": 14, + "passes": false, + "notes": "" + }, + { + "id": "US-015", + "title": "DI registration and appsettings.json", + "description": "As a developer, I need all services, repositories, and providers registered in DI so that actions can resolve them.", + "acceptanceCriteria": [ + "Program.cs configureServices lambda registers: SqliteDatabase, all four repositories, StyleAnalyser, PostGenerator, both LLM providers, ManualFetcher", + "LLM providers registered as keyed services or via a factory that resolves by provider name string", + "appsettings.json contains Writegeist section with DefaultProvider, DatabasePath, Anthropic:Model, OpenAi:Model", + "SqliteDatabase.EnsureCreated() called during startup", + "dotnet build succeeds", + "Typecheck passes" + ], + "priority": 15, + "passes": false, + "notes": "" + }, + { + "id": "US-016", + "title": "Ingest menu and file import action", + "description": "As a user, I want an Ingest Posts sub-menu with a From File action so that I can import posts from a text file.", + "acceptanceCriteria": [ + "IngestMenu class extends Menu (quitable: false, isTopLevel: false) with BuildMenu adding From File, Interactive Paste, From URL / Handle menu items", + "MainMenu adds IngestMenu as 'Ingest Posts' menu item", + "IngestFromFileAction extends SingleActionAsync", + "Prompts for person name via Spectre.Console TextPrompt", + "Prompts for platform via Spectre.Console SelectionPrompt (LinkedIn, X, Instagram, Facebook)", + "Prompts for file path via TextPrompt", + "Uses IPersonRepository.GetOrCreateAsync to ensure person exists", + "Uses ManualFetcher to read the file, then stores each post via IPostRepository.AddAsync", + "Displays a Spectre.Console Panel summary: count of new posts (green), duplicates skipped (yellow)", + "Typecheck passes" + ], + "priority": 16, + "passes": false, + "notes": "" + }, + { + "id": "US-017", + "title": "Interactive paste action", + "description": "As a user, I want to paste posts one at a time in an interactive session.", + "acceptanceCriteria": [ + "IngestInteractiveAction extends RepeatableActionAsync", + "On first iteration, prompts for person name and platform via Spectre.Console prompts", + "Each iteration prompts for post content via TextPrompt", + "Stores each post via IPostRepository.AddAsync with content hash dedup", + "After each post, asks 'Add another post?' — returns false to continue, true to stop", + "On completion, displays Panel summary with total new posts and duplicates skipped", + "Typecheck passes" + ], + "priority": 17, + "passes": false, + "notes": "" + }, + { + "id": "US-018", + "title": "Analyse style action", + "description": "As a user, I want a menu action to analyse my posts and build a style profile.", + "acceptanceCriteria": [ + "AnalyseAction extends SingleActionAsync, added to MainMenu as 'Analyse Style'", + "Prompts user to select a person from existing persons via SelectionPrompt (shows error if none exist)", + "Prompts user to select LLM provider via SelectionPrompt (Anthropic, OpenAI) with default from config", + "Calls StyleAnalyser.AnalyseAsync with the selected person", + "Displays spinner via AnsiConsole.Status() while LLM processes", + "Displays the profile summary in a styled Panel after completion", + "Catches and displays errors in red markup without crashing the menu loop", + "Typecheck passes" + ], + "priority": 18, + "passes": false, + "notes": "" + }, + { + "id": "US-019", + "title": "Generate post action", + "description": "As a user, I want a menu action to generate a new post in my style for a specific platform.", + "acceptanceCriteria": [ + "GenerateAction extends SingleActionAsync, added to MainMenu as 'Generate Post'", + "Prompts user to select a person (only persons with a style profile) via SelectionPrompt", + "Prompts user to select target platform via SelectionPrompt", + "Prompts user to enter topic/key points via TextPrompt", + "Calls PostGenerator.GenerateAsync with person, platform, and topic", + "Displays spinner via AnsiConsole.Status() while LLM processes", + "Displays the generated post in a styled Panel with border and title", + "Catches and displays errors in red markup without crashing", + "Typecheck passes" + ], + "priority": 19, + "passes": false, + "notes": "" + }, + { + "id": "US-020", + "title": "Refine draft action", + "description": "As a user, I want a menu action to iteratively refine the last generated draft with feedback.", + "acceptanceCriteria": [ + "RefineAction extends RepeatableActionAsync, added to MainMenu as 'Refine Last Draft'", + "On first iteration, loads the most recent GeneratedDraft and displays it in a Panel (shows error if none exist)", + "Prompts for feedback via TextPrompt", + "Calls PostGenerator.RefineAsync with the draft ID and feedback", + "Displays spinner via AnsiConsole.Status() while LLM processes", + "Displays the refined post in a styled Panel", + "Asks 'Refine again?' — returns false to continue looping, true to stop and return to menu", + "Catches and displays errors in red markup without crashing", + "Typecheck passes" + ], + "priority": 20, + "passes": false, + "notes": "" + }, + { + "id": "US-021", + "title": "Profile menu and actions", + "description": "As a user, I want to view and list style profiles from the menu.", + "acceptanceCriteria": [ + "ProfileMenu extends Menu (quitable: false, isTopLevel: false) with Show Profile and List All Profiles items", + "MainMenu adds ProfileMenu as 'Profiles' menu item", + "ShowProfileAction extends SingleActionAsync, prompts for person selection, displays full style profile in a formatted Spectre.Console Table with grouped sections (vocabulary, tone, formatting, etc.)", + "ListProfilesAction extends SingleActionAsync, displays a Table of all persons with profiles showing name, provider, model, and created date", + "Both actions show a message if no profiles exist", + "Typecheck passes" + ], + "priority": 21, + "passes": false, + "notes": "" + }, + { + "id": "US-022", + "title": "Stub fetchers for unsupported platforms", + "description": "As a developer, I need stub fetchers for LinkedIn, Instagram, and Facebook that guide users to manual input.", + "acceptanceCriteria": [ + "LinkedInFetcher, InstagramFetcher, FacebookFetcher each implement IContentFetcher in Writegeist.Infrastructure/Fetchers", + "Each returns the correct Platform enum value from the Platform property", + "FetchPostsAsync throws a descriptive exception explaining automated fetching is not available for this platform", + "Exception message suggests using From File or Interactive Paste instead", + "Typecheck passes" + ], + "priority": 22, + "passes": false, + "notes": "" + }, + { + "id": "US-023", + "title": "Ingest from URL action and fetcher dispatch", + "description": "As a user, I want to ingest posts from a URL or handle, with clear error messages for unsupported platforms.", + "acceptanceCriteria": [ + "IngestFromUrlAction extends SingleActionAsync, wired into IngestMenu as 'From URL / Handle'", + "Prompts for person name, platform, and URL or handle via Spectre.Console prompts", + "Resolves the correct IContentFetcher by platform (using DI factory or keyed services)", + "Calls FetchPostsAsync and stores results via IPostRepository.AddAsync", + "Catches fetcher exceptions for unsupported platforms and displays the message in yellow markup", + "Displays Panel summary for successful fetches", + "Typecheck passes" + ], + "priority": 23, + "passes": false, + "notes": "" + }, + { + "id": "US-024", + "title": "X/Twitter API fetcher", + "description": "As a user, I want to fetch recent tweets automatically via the X API.", + "acceptanceCriteria": [ + "XTwitterFetcher in Writegeist.Infrastructure/Fetchers implements IContentFetcher for Platform.X", + "Uses HttpClient to call X API v2 GET /2/users/{id}/tweets endpoint", + "Bearer token read from X_BEARER_TOKEN environment variable", + "Fetches up to 100 recent tweets per request", + "Handles 429 rate limit responses with a user-friendly message suggesting to wait and retry", + "Throws descriptive exception if no bearer token is configured, suggesting manual input instead", + "Typecheck passes" + ], + "priority": 24, + "passes": false, + "notes": "" + } + ] +} diff --git a/scripts/ralph/CLAUDE.md b/scripts/ralph/CLAUDE.md new file mode 100644 index 0000000..c5668b7 --- /dev/null +++ b/scripts/ralph/CLAUDE.md @@ -0,0 +1,104 @@ +# Ralph Agent Instructions + +You are an autonomous coding agent working on a software project. + +## Your Task + +1. Read the PRD at `scripts/ralph/prd.json` +2. Read the progress log at `scripts/ralph/progress.txt` (check Codebase Patterns section first) +3. Check you're on the correct branch from PRD `branchName`. If not, check it out or create from main. +4. Pick the **highest priority** user story where `passes: false` +5. Implement that single user story +6. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires) +7. Update CLAUDE.md files if you discover reusable patterns (see below) +8. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]` +9. Update the PRD to set `passes: true` for the completed story +10. Append your progress to `scripts/ralph/progress.txt` + +## Progress Report Format + +APPEND to progress.txt (never replace, always append): +``` +## [Date/Time] - [Story ID] +- What was implemented +- Files changed +- **Learnings for future iterations:** + - Patterns discovered (e.g., "this codebase uses X for Y") + - Gotchas encountered (e.g., "don't forget to update Z when changing W") + - Useful context (e.g., "the evaluation panel is in component X") +--- +``` + +The learnings section is critical - it helps future iterations avoid repeating mistakes and understand the codebase better. + +## Consolidate Patterns + +If you discover a **reusable pattern** that future iterations should know, add it to the `## Codebase Patterns` section at the TOP of progress.txt (create it if it doesn't exist). This section should consolidate the most important learnings: + +``` +## Codebase Patterns +- Example: Use `sql` template for aggregations +- Example: Always use `IF NOT EXISTS` for migrations +- Example: Export types from actions.ts for UI components +``` + +Only add patterns that are **general and reusable**, not story-specific details. + +## Update CLAUDE.md Files + +Before committing, check if any edited files have learnings worth preserving in nearby CLAUDE.md files: + +1. **Identify directories with edited files** - Look at which directories you modified +2. **Check for existing CLAUDE.md** - Look for CLAUDE.md in those directories or parent directories +3. **Add valuable learnings** - If you discovered something future developers/agents should know: + - API patterns or conventions specific to that module + - Gotchas or non-obvious requirements + - Dependencies between files + - Testing approaches for that area + - Configuration or environment requirements + +**Examples of good CLAUDE.md additions:** +- "When modifying X, also update Y to keep them in sync" +- "This module uses pattern Z for all API calls" +- "Tests require the dev server running on PORT 3000" +- "Field names must match the template exactly" + +**Do NOT add:** +- Story-specific implementation details +- Temporary debugging notes +- Information already in progress.txt + +Only update CLAUDE.md if you have **genuinely reusable knowledge** that would help future work in that directory. + +## Quality Requirements + +- ALL commits must pass your project's quality checks (typecheck, lint, test) +- Do NOT commit broken code +- Keep changes focused and minimal +- Follow existing code patterns + +## Browser Testing (If Available) + +For any story that changes UI, verify it works in the browser if you have browser testing tools configured (e.g., via MCP): + +1. Navigate to the relevant page +2. Verify the UI changes work as expected +3. Take a screenshot if helpful for the progress log + +If no browser tools are available, note in your progress report that manual browser verification is needed. + +## Stop Condition + +After completing a user story, check if ALL stories have `passes: true`. + +If ALL stories are complete and passing, reply with: +COMPLETE + +If there are still stories with `passes: false`, end your response normally (another iteration will pick up the next story). + +## Important + +- Work on ONE story per iteration +- Commit frequently +- Keep CI green +- Read the Codebase Patterns section in progress.txt before starting diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json new file mode 100644 index 0000000..84b1066 --- /dev/null +++ b/scripts/ralph/prd.json @@ -0,0 +1,445 @@ +{ + "project": "Writegeist", + "branchName": "ralph/writegeist-v1", + "description": "Interactive .NET CLI tool that clones a person's writing style from social media posts and generates platform-specific content on demand", + "userStories": [ + { + "id": "US-001", + "title": "Solution scaffolding and project structure", + "description": "As a developer, I need the .NET solution with all projects, references, and NuGet packages so that I can start building features.", + "acceptanceCriteria": [ + "Writegeist.sln with four projects: Writegeist.Cli, Writegeist.Core, Writegeist.Infrastructure, Writegeist.Tests", + "All projects target net10.0", + "Project references wired: Cli references Core + Infrastructure, Infrastructure references Core, Tests references Core + Infrastructure", + "Writegeist.Cli references DevJonny.InteractiveCli NuGet package", + "Writegeist.Infrastructure references Microsoft.Data.Sqlite and AngleSharp", + "Writegeist.Tests references xunit, FluentAssertions, and NSubstitute", + "Program.cs bootstraps InteractiveCli with a MainMenu that displays menu items and quits cleanly", + "MainMenu is a top-level menu (isTopLevel: true) with a placeholder menu item", + "dotnet build succeeds with no errors", + "Typecheck passes" + ], + "priority": 1, + "passes": true, + "notes": "" + }, + { + "id": "US-002", + "title": "Domain models", + "description": "As a developer, I need the core domain models defined so that repositories and services can use them.", + "acceptanceCriteria": [ + "Person model in Writegeist.Core/Models with Id (int), Name (string), CreatedAt (DateTime)", + "RawPost model with Id, PersonId, Platform, Content, ContentHash, SourceUrl, FetchedAt", + "Platform enum with values: LinkedIn, X, Instagram, Facebook", + "StyleProfile model with Id, PersonId, ProfileJson, Provider, Model, CreatedAt", + "GeneratedDraft model with Id, PersonId, StyleProfileId, Platform, Topic, Content, ParentDraftId (nullable), Feedback (nullable), Provider, Model, CreatedAt", + "FetchRequest record with nullable Url, Handle, FilePath properties", + "FetchedPost record with Content, nullable SourceUrl, nullable PublishedAt", + "dotnet build succeeds", + "Typecheck passes" + ], + "priority": 2, + "passes": false, + "notes": "" + }, + { + "id": "US-003", + "title": "Core interfaces", + "description": "As a developer, I need the core interfaces defined so that infrastructure implementations can be built against them.", + "acceptanceCriteria": [ + "IPersonRepository interface in Writegeist.Core/Interfaces with CreateAsync, GetByNameAsync, GetAllAsync, GetOrCreateAsync", + "IPostRepository interface with AddAsync, GetByPersonIdAsync, GetCountByPersonIdAsync, ExistsByHashAsync", + "IStyleProfileRepository interface with SaveAsync, GetLatestByPersonIdAsync, GetAllByPersonIdAsync", + "IDraftRepository interface with SaveAsync, GetLatestAsync, GetByIdAsync", + "IContentFetcher interface with Platform property and FetchPostsAsync method", + "ILlmProvider interface with ProviderName, ModelName properties and AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync methods", + "dotnet build succeeds", + "Typecheck passes" + ], + "priority": 3, + "passes": false, + "notes": "" + }, + { + "id": "US-004", + "title": "SQLite database and schema initialisation", + "description": "As a developer, I need the SQLite database created with the correct schema so that data persists locally.", + "acceptanceCriteria": [ + "SqliteDatabase class in Writegeist.Infrastructure/Persistence with EnsureCreated method", + "Creates persons table with id (autoincrement PK), name (unique, not null), created_at (default now)", + "Creates raw_posts table with FK to persons, platform, content, content_hash, source_url, fetched_at, unique constraint on (person_id, content_hash)", + "Creates style_profiles table with FK to persons, profile_json, provider, model, created_at", + "Creates generated_drafts table with FK to persons and style_profiles, parent_draft_id self-ref FK, platform, topic, content, feedback, provider, model, created_at", + "Schema creation is idempotent (uses CREATE TABLE IF NOT EXISTS)", + "Database path configurable via IConfiguration (Writegeist:DatabasePath key)", + "Unit tests verify all four tables are created using in-memory SQLite", + "Typecheck passes" + ], + "priority": 4, + "passes": false, + "notes": "" + }, + { + "id": "US-005", + "title": "Person repository implementation", + "description": "As a developer, I need the person repository so that persons can be stored and retrieved.", + "acceptanceCriteria": [ + "SqlitePersonRepository implements IPersonRepository", + "CreateAsync inserts a new person and returns the model with the generated Id", + "GetByNameAsync performs case-insensitive lookup and returns null if not found", + "GetAllAsync returns all persons ordered by name", + "GetOrCreateAsync returns existing person if name matches (case-insensitive), creates new one if not", + "Unit tests with in-memory SQLite cover create, get by name, get all, get-or-create (existing), get-or-create (new)", + "Typecheck passes", + "Tests pass" + ], + "priority": 5, + "passes": false, + "notes": "" + }, + { + "id": "US-006", + "title": "Post repository implementation", + "description": "As a developer, I need the post repository so that ingested posts can be stored and deduplicated.", + "acceptanceCriteria": [ + "SqlitePostRepository implements IPostRepository", + "AddAsync inserts a post and returns a result indicating whether it was new or a duplicate", + "Content hash is SHA-256 of normalised (trimmed, lowercased) content text", + "Duplicate detection uses the unique constraint on (person_id, content_hash)", + "GetByPersonIdAsync returns all posts for a person ordered by fetched_at", + "GetCountByPersonIdAsync returns the count of posts for a person", + "Unit tests cover add new, add duplicate, retrieval, and count", + "Typecheck passes", + "Tests pass" + ], + "priority": 6, + "passes": false, + "notes": "" + }, + { + "id": "US-007", + "title": "Style profile repository implementation", + "description": "As a developer, I need the style profile repository so that analysed profiles can be stored and retrieved.", + "acceptanceCriteria": [ + "SqliteStyleProfileRepository implements IStyleProfileRepository", + "SaveAsync inserts a profile and returns the model with generated Id", + "GetLatestByPersonIdAsync returns the most recent profile by created_at, or null if none exist", + "GetAllByPersonIdAsync returns all profiles for a person ordered by created_at descending", + "Unit tests cover save, retrieve latest, retrieve all, and null case", + "Typecheck passes", + "Tests pass" + ], + "priority": 7, + "passes": false, + "notes": "" + }, + { + "id": "US-008", + "title": "Draft repository implementation", + "description": "As a developer, I need the draft repository so that generated and refined drafts can be stored and chained.", + "acceptanceCriteria": [ + "SqliteDraftRepository implements IDraftRepository", + "SaveAsync inserts a draft and returns the model with generated Id", + "GetLatestAsync returns the most recently created draft across all persons, or null if none exist", + "GetByIdAsync returns a draft by Id, or null if not found", + "parent_draft_id correctly links refined drafts to their predecessors", + "Unit tests cover save, get latest, get by id, parent-child linking, and null cases", + "Typecheck passes", + "Tests pass" + ], + "priority": 8, + "passes": false, + "notes": "" + }, + { + "id": "US-009", + "title": "Manual fetcher — file import", + "description": "As a user, I want to import posts from a text file so that I can feed Writegeist content without API access.", + "acceptanceCriteria": [ + "ManualFetcher class in Writegeist.Infrastructure/Fetchers implements IContentFetcher", + "Platform property returns a configurable platform value (set at construction)", + "FetchPostsAsync reads a file path from FetchRequest.FilePath", + "Splits file content on lines containing only --- as separator", + "Trims whitespace from each post and skips empty entries", + "Returns a list of FetchedPost records with content populated", + "Unit tests cover: normal file with multiple posts, empty file, file with no separators (single post), file with consecutive separators", + "Typecheck passes", + "Tests pass" + ], + "priority": 9, + "passes": false, + "notes": "" + }, + { + "id": "US-010", + "title": "Platform conventions", + "description": "As a developer, I need platform-specific rules so that generated posts follow each platform's norms.", + "acceptanceCriteria": [ + "PlatformRules record in Writegeist.Core with Name, MaxCharacters (nullable int), RecommendedMaxLength, SupportsHashtags, RecommendedHashtagCount, HashtagPlacement, SupportsEmoji, EmojiGuidance, ToneGuidance, FormattingNotes", + "PlatformConventions class with static GetRules(Platform) method", + "LinkedIn: 3000 max, 1500 recommended, hashtags end, 3-5 count, emoji sparingly", + "X: 280 max, 280 recommended, hashtags inline, 1-2 count, emoji moderate", + "Instagram: 2200 max, 750 recommended, hashtags end in separate block, up to 30, emoji freely", + "Facebook: 63206 max, 600 recommended, hashtags end, 1-3 count, emoji moderate", + "Unit tests verify rules for all four platforms", + "Typecheck passes", + "Tests pass" + ], + "priority": 10, + "passes": false, + "notes": "" + }, + { + "id": "US-011", + "title": "Anthropic LLM provider", + "description": "As a user, I want to use Claude as the LLM backend so that I can analyse style and generate posts.", + "acceptanceCriteria": [ + "AnthropicProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider", + "Uses official Anthropic NuGet SDK if available, otherwise raw HttpClient to https://api.anthropic.com/v1/messages", + "API key read from ANTHROPIC_API_KEY environment variable via IConfiguration", + "Model name configurable via appsettings.json (Writegeist:Anthropic:Model), defaults to claude-sonnet-4-20250514", + "ProviderName returns 'anthropic', ModelName returns the configured model", + "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response", + "Throws a descriptive exception if API key is not configured", + "Handles HTTP errors (401, 429, 500) with user-friendly exception messages", + "Typecheck passes" + ], + "priority": 11, + "passes": false, + "notes": "" + }, + { + "id": "US-012", + "title": "OpenAI LLM provider", + "description": "As a user, I want to use OpenAI as an alternative LLM backend.", + "acceptanceCriteria": [ + "OpenAiProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider", + "Uses official OpenAI NuGet package", + "API key read from OPENAI_API_KEY environment variable via IConfiguration", + "Model name configurable via appsettings.json (Writegeist:OpenAi:Model), defaults to gpt-4o", + "ProviderName returns 'openai', ModelName returns the configured model", + "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response", + "Throws a descriptive exception if API key is not configured", + "Handles HTTP errors with user-friendly exception messages", + "Typecheck passes" + ], + "priority": 12, + "passes": false, + "notes": "" + }, + { + "id": "US-013", + "title": "Style analyser service", + "description": "As a developer, I need the style analysis orchestration so that ingested posts can be turned into a style profile.", + "acceptanceCriteria": [ + "StyleAnalyser class in Writegeist.Core/Services", + "Constructor takes IPostRepository, IStyleProfileRepository, and ILlmProvider", + "AnalyseAsync(int personId) loads all posts for the person, builds the style analysis prompt with all post contents, sends to LLM, and stores the result", + "Uses the style analysis prompt template from the implementation plan (section 7.1)", + "Returns the saved StyleProfile model", + "Throws if no posts exist for the person", + "Unit tests with mocked dependencies verify prompt construction and profile storage", + "Typecheck passes", + "Tests pass" + ], + "priority": 13, + "passes": false, + "notes": "" + }, + { + "id": "US-014", + "title": "Post generator service", + "description": "As a developer, I need the post generation orchestration so that drafts can be created from a style profile and topic.", + "acceptanceCriteria": [ + "PostGenerator class in Writegeist.Core/Services", + "Constructor takes IStyleProfileRepository, IDraftRepository, and ILlmProvider", + "GenerateAsync(int personId, Platform platform, string topic) loads latest style profile, gets platform conventions, builds generation prompt, sends to LLM, stores draft", + "Uses the generation prompt template from the implementation plan (section 7.2)", + "RefineAsync(int draftId, string feedback) loads draft, its style profile, builds refinement prompt, sends to LLM, stores new draft linked to previous", + "Uses the refinement prompt template from the implementation plan (section 7.3)", + "Returns the saved GeneratedDraft model", + "Throws if no style profile exists for the person", + "Unit tests with mocked dependencies verify prompt construction and draft storage", + "Typecheck passes", + "Tests pass" + ], + "priority": 14, + "passes": false, + "notes": "" + }, + { + "id": "US-015", + "title": "DI registration and appsettings.json", + "description": "As a developer, I need all services, repositories, and providers registered in DI so that actions can resolve them.", + "acceptanceCriteria": [ + "Program.cs configureServices lambda registers: SqliteDatabase, all four repositories, StyleAnalyser, PostGenerator, both LLM providers, ManualFetcher", + "LLM providers registered as keyed services or via a factory that resolves by provider name string", + "appsettings.json contains Writegeist section with DefaultProvider, DatabasePath, Anthropic:Model, OpenAi:Model", + "SqliteDatabase.EnsureCreated() called during startup", + "dotnet build succeeds", + "Typecheck passes" + ], + "priority": 15, + "passes": false, + "notes": "" + }, + { + "id": "US-016", + "title": "Ingest menu and file import action", + "description": "As a user, I want an Ingest Posts sub-menu with a From File action so that I can import posts from a text file.", + "acceptanceCriteria": [ + "IngestMenu class extends Menu (quitable: false, isTopLevel: false) with BuildMenu adding From File, Interactive Paste, From URL / Handle menu items", + "MainMenu adds IngestMenu as 'Ingest Posts' menu item", + "IngestFromFileAction extends SingleActionAsync", + "Prompts for person name via Spectre.Console TextPrompt", + "Prompts for platform via Spectre.Console SelectionPrompt (LinkedIn, X, Instagram, Facebook)", + "Prompts for file path via TextPrompt", + "Uses IPersonRepository.GetOrCreateAsync to ensure person exists", + "Uses ManualFetcher to read the file, then stores each post via IPostRepository.AddAsync", + "Displays a Spectre.Console Panel summary: count of new posts (green), duplicates skipped (yellow)", + "Typecheck passes" + ], + "priority": 16, + "passes": false, + "notes": "" + }, + { + "id": "US-017", + "title": "Interactive paste action", + "description": "As a user, I want to paste posts one at a time in an interactive session.", + "acceptanceCriteria": [ + "IngestInteractiveAction extends RepeatableActionAsync", + "On first iteration, prompts for person name and platform via Spectre.Console prompts", + "Each iteration prompts for post content via TextPrompt", + "Stores each post via IPostRepository.AddAsync with content hash dedup", + "After each post, asks 'Add another post?' — returns false to continue, true to stop", + "On completion, displays Panel summary with total new posts and duplicates skipped", + "Typecheck passes" + ], + "priority": 17, + "passes": false, + "notes": "" + }, + { + "id": "US-018", + "title": "Analyse style action", + "description": "As a user, I want a menu action to analyse my posts and build a style profile.", + "acceptanceCriteria": [ + "AnalyseAction extends SingleActionAsync, added to MainMenu as 'Analyse Style'", + "Prompts user to select a person from existing persons via SelectionPrompt (shows error if none exist)", + "Prompts user to select LLM provider via SelectionPrompt (Anthropic, OpenAI) with default from config", + "Calls StyleAnalyser.AnalyseAsync with the selected person", + "Displays spinner via AnsiConsole.Status() while LLM processes", + "Displays the profile summary in a styled Panel after completion", + "Catches and displays errors in red markup without crashing the menu loop", + "Typecheck passes" + ], + "priority": 18, + "passes": false, + "notes": "" + }, + { + "id": "US-019", + "title": "Generate post action", + "description": "As a user, I want a menu action to generate a new post in my style for a specific platform.", + "acceptanceCriteria": [ + "GenerateAction extends SingleActionAsync, added to MainMenu as 'Generate Post'", + "Prompts user to select a person (only persons with a style profile) via SelectionPrompt", + "Prompts user to select target platform via SelectionPrompt", + "Prompts user to enter topic/key points via TextPrompt", + "Calls PostGenerator.GenerateAsync with person, platform, and topic", + "Displays spinner via AnsiConsole.Status() while LLM processes", + "Displays the generated post in a styled Panel with border and title", + "Catches and displays errors in red markup without crashing", + "Typecheck passes" + ], + "priority": 19, + "passes": false, + "notes": "" + }, + { + "id": "US-020", + "title": "Refine draft action", + "description": "As a user, I want a menu action to iteratively refine the last generated draft with feedback.", + "acceptanceCriteria": [ + "RefineAction extends RepeatableActionAsync, added to MainMenu as 'Refine Last Draft'", + "On first iteration, loads the most recent GeneratedDraft and displays it in a Panel (shows error if none exist)", + "Prompts for feedback via TextPrompt", + "Calls PostGenerator.RefineAsync with the draft ID and feedback", + "Displays spinner via AnsiConsole.Status() while LLM processes", + "Displays the refined post in a styled Panel", + "Asks 'Refine again?' — returns false to continue looping, true to stop and return to menu", + "Catches and displays errors in red markup without crashing", + "Typecheck passes" + ], + "priority": 20, + "passes": false, + "notes": "" + }, + { + "id": "US-021", + "title": "Profile menu and actions", + "description": "As a user, I want to view and list style profiles from the menu.", + "acceptanceCriteria": [ + "ProfileMenu extends Menu (quitable: false, isTopLevel: false) with Show Profile and List All Profiles items", + "MainMenu adds ProfileMenu as 'Profiles' menu item", + "ShowProfileAction extends SingleActionAsync, prompts for person selection, displays full style profile in a formatted Spectre.Console Table with grouped sections (vocabulary, tone, formatting, etc.)", + "ListProfilesAction extends SingleActionAsync, displays a Table of all persons with profiles showing name, provider, model, and created date", + "Both actions show a message if no profiles exist", + "Typecheck passes" + ], + "priority": 21, + "passes": false, + "notes": "" + }, + { + "id": "US-022", + "title": "Stub fetchers for unsupported platforms", + "description": "As a developer, I need stub fetchers for LinkedIn, Instagram, and Facebook that guide users to manual input.", + "acceptanceCriteria": [ + "LinkedInFetcher, InstagramFetcher, FacebookFetcher each implement IContentFetcher in Writegeist.Infrastructure/Fetchers", + "Each returns the correct Platform enum value from the Platform property", + "FetchPostsAsync throws a descriptive exception explaining automated fetching is not available for this platform", + "Exception message suggests using From File or Interactive Paste instead", + "Typecheck passes" + ], + "priority": 22, + "passes": false, + "notes": "" + }, + { + "id": "US-023", + "title": "Ingest from URL action and fetcher dispatch", + "description": "As a user, I want to ingest posts from a URL or handle, with clear error messages for unsupported platforms.", + "acceptanceCriteria": [ + "IngestFromUrlAction extends SingleActionAsync, wired into IngestMenu as 'From URL / Handle'", + "Prompts for person name, platform, and URL or handle via Spectre.Console prompts", + "Resolves the correct IContentFetcher by platform (using DI factory or keyed services)", + "Calls FetchPostsAsync and stores results via IPostRepository.AddAsync", + "Catches fetcher exceptions for unsupported platforms and displays the message in yellow markup", + "Displays Panel summary for successful fetches", + "Typecheck passes" + ], + "priority": 23, + "passes": false, + "notes": "" + }, + { + "id": "US-024", + "title": "X/Twitter API fetcher", + "description": "As a user, I want to fetch recent tweets automatically via the X API.", + "acceptanceCriteria": [ + "XTwitterFetcher in Writegeist.Infrastructure/Fetchers implements IContentFetcher for Platform.X", + "Uses HttpClient to call X API v2 GET /2/users/{id}/tweets endpoint", + "Bearer token read from X_BEARER_TOKEN environment variable", + "Fetches up to 100 recent tweets per request", + "Handles 429 rate limit responses with a user-friendly message suggesting to wait and retry", + "Throws descriptive exception if no bearer token is configured, suggesting manual input instead", + "Typecheck passes" + ], + "priority": 24, + "passes": false, + "notes": "" + } + ] +} diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt new file mode 100644 index 0000000..61b6428 --- /dev/null +++ b/scripts/ralph/progress.txt @@ -0,0 +1,3 @@ +# Ralph Progress Log +Started: Sat 4 Apr 2026 19:22:24 BST +--- diff --git a/src/Writegeist.Cli/Actions/PlaceholderAction.cs b/src/Writegeist.Cli/Actions/PlaceholderAction.cs new file mode 100644 index 0000000..b7a538d --- /dev/null +++ b/src/Writegeist.Cli/Actions/PlaceholderAction.cs @@ -0,0 +1,13 @@ +using InteractiveCLI.Actions; +using Spectre.Console; + +namespace Writegeist.Cli.Actions; + +public class PlaceholderAction : SingleActionAsync +{ + protected override Task SingleAsyncAction() + { + AnsiConsole.MarkupLine("[grey]No actions configured yet.[/]"); + return Task.CompletedTask; + } +} diff --git a/src/Writegeist.Cli/Menus/MainMenu.cs b/src/Writegeist.Cli/Menus/MainMenu.cs new file mode 100644 index 0000000..0f077d3 --- /dev/null +++ b/src/Writegeist.Cli/Menus/MainMenu.cs @@ -0,0 +1,12 @@ +using InteractiveCLI.Menus; +using Writegeist.Cli.Actions; + +namespace Writegeist.Cli.Menus; + +public class MainMenu() : Menu(quitable: true, isTopLevel: true) +{ + protected override void BuildMenu() + { + MenuBuilder.AddMenuItem("Placeholder", "Placeholder action"); + } +} diff --git a/src/Writegeist.Cli/Program.cs b/src/Writegeist.Cli/Program.cs new file mode 100644 index 0000000..0ea8d4e --- /dev/null +++ b/src/Writegeist.Cli/Program.cs @@ -0,0 +1,11 @@ +using InteractiveCLI; +using InteractiveCLI.Options; +using Microsoft.Extensions.Hosting; +using Writegeist.Cli.Menus; + +var host = Host.CreateDefaultBuilder(args) + .AddInteractiveCli() + .Build() + .UseInteractiveCli((EmptyOptions _) => new MainMenu(), args); + +await host.RunAsync(); diff --git a/src/Writegeist.Cli/Writegeist.Cli.csproj b/src/Writegeist.Cli/Writegeist.Cli.csproj new file mode 100644 index 0000000..51c73ee --- /dev/null +++ b/src/Writegeist.Cli/Writegeist.Cli.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/src/Writegeist.Core/Writegeist.Core.csproj b/src/Writegeist.Core/Writegeist.Core.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/Writegeist.Core/Writegeist.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj b/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj new file mode 100644 index 0000000..5f0b48a --- /dev/null +++ b/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/tests/Writegeist.Tests/Writegeist.Tests.csproj b/tests/Writegeist.Tests/Writegeist.Tests.csproj new file mode 100644 index 0000000..a51d459 --- /dev/null +++ b/tests/Writegeist.Tests/Writegeist.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 9bce5e5587375f600a6f79f2e48f32904cc49d0e Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sat, 4 Apr 2026 19:37:26 +0100 Subject: [PATCH 02/26] feat: US-002 - Domain models Co-Authored-By: Claude Opus 4.6 --- src/Writegeist.Core/Models/FetchRequest.cs | 3 +++ src/Writegeist.Core/Models/FetchedPost.cs | 3 +++ src/Writegeist.Core/Models/GeneratedDraft.cs | 16 ++++++++++++++++ src/Writegeist.Core/Models/Person.cs | 8 ++++++++ src/Writegeist.Core/Models/Platform.cs | 9 +++++++++ src/Writegeist.Core/Models/RawPost.cs | 12 ++++++++++++ src/Writegeist.Core/Models/StyleProfile.cs | 11 +++++++++++ 7 files changed, 62 insertions(+) create mode 100644 src/Writegeist.Core/Models/FetchRequest.cs create mode 100644 src/Writegeist.Core/Models/FetchedPost.cs create mode 100644 src/Writegeist.Core/Models/GeneratedDraft.cs create mode 100644 src/Writegeist.Core/Models/Person.cs create mode 100644 src/Writegeist.Core/Models/Platform.cs create mode 100644 src/Writegeist.Core/Models/RawPost.cs create mode 100644 src/Writegeist.Core/Models/StyleProfile.cs diff --git a/src/Writegeist.Core/Models/FetchRequest.cs b/src/Writegeist.Core/Models/FetchRequest.cs new file mode 100644 index 0000000..2a14a69 --- /dev/null +++ b/src/Writegeist.Core/Models/FetchRequest.cs @@ -0,0 +1,3 @@ +namespace Writegeist.Core.Models; + +public record FetchRequest(string? Url = null, string? Handle = null, string? FilePath = null); diff --git a/src/Writegeist.Core/Models/FetchedPost.cs b/src/Writegeist.Core/Models/FetchedPost.cs new file mode 100644 index 0000000..134b7f6 --- /dev/null +++ b/src/Writegeist.Core/Models/FetchedPost.cs @@ -0,0 +1,3 @@ +namespace Writegeist.Core.Models; + +public record FetchedPost(string Content, string? SourceUrl = null, DateTime? PublishedAt = null); diff --git a/src/Writegeist.Core/Models/GeneratedDraft.cs b/src/Writegeist.Core/Models/GeneratedDraft.cs new file mode 100644 index 0000000..fe1b8d7 --- /dev/null +++ b/src/Writegeist.Core/Models/GeneratedDraft.cs @@ -0,0 +1,16 @@ +namespace Writegeist.Core.Models; + +public class GeneratedDraft +{ + public int Id { get; set; } + public int PersonId { get; set; } + public int StyleProfileId { get; set; } + public Platform Platform { get; set; } + public string Topic { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public int? ParentDraftId { get; set; } + public string? Feedback { get; set; } + public string Provider { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} diff --git a/src/Writegeist.Core/Models/Person.cs b/src/Writegeist.Core/Models/Person.cs new file mode 100644 index 0000000..42b7138 --- /dev/null +++ b/src/Writegeist.Core/Models/Person.cs @@ -0,0 +1,8 @@ +namespace Writegeist.Core.Models; + +public class Person +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} diff --git a/src/Writegeist.Core/Models/Platform.cs b/src/Writegeist.Core/Models/Platform.cs new file mode 100644 index 0000000..0637f23 --- /dev/null +++ b/src/Writegeist.Core/Models/Platform.cs @@ -0,0 +1,9 @@ +namespace Writegeist.Core.Models; + +public enum Platform +{ + LinkedIn, + X, + Instagram, + Facebook +} diff --git a/src/Writegeist.Core/Models/RawPost.cs b/src/Writegeist.Core/Models/RawPost.cs new file mode 100644 index 0000000..cba7280 --- /dev/null +++ b/src/Writegeist.Core/Models/RawPost.cs @@ -0,0 +1,12 @@ +namespace Writegeist.Core.Models; + +public class RawPost +{ + public int Id { get; set; } + public int PersonId { get; set; } + public Platform Platform { get; set; } + public string Content { get; set; } = string.Empty; + public string ContentHash { get; set; } = string.Empty; + public string? SourceUrl { get; set; } + public DateTime FetchedAt { get; set; } +} diff --git a/src/Writegeist.Core/Models/StyleProfile.cs b/src/Writegeist.Core/Models/StyleProfile.cs new file mode 100644 index 0000000..8aabb58 --- /dev/null +++ b/src/Writegeist.Core/Models/StyleProfile.cs @@ -0,0 +1,11 @@ +namespace Writegeist.Core.Models; + +public class StyleProfile +{ + public int Id { get; set; } + public int PersonId { get; set; } + public string ProfileJson { get; set; } = string.Empty; + public string Provider { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} From beb195fbf611b758ea1b952e69f1a1955b93909e Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sat, 4 Apr 2026 19:38:38 +0100 Subject: [PATCH 03/26] feat: US-003 - Core interfaces Co-Authored-By: Claude Opus 4.6 --- src/Writegeist.Core/Interfaces/IContentFetcher.cs | 9 +++++++++ src/Writegeist.Core/Interfaces/IDraftRepository.cs | 10 ++++++++++ src/Writegeist.Core/Interfaces/ILlmProvider.cs | 10 ++++++++++ src/Writegeist.Core/Interfaces/IPersonRepository.cs | 11 +++++++++++ src/Writegeist.Core/Interfaces/IPostRepository.cs | 11 +++++++++++ .../Interfaces/IStyleProfileRepository.cs | 10 ++++++++++ 6 files changed, 61 insertions(+) create mode 100644 src/Writegeist.Core/Interfaces/IContentFetcher.cs create mode 100644 src/Writegeist.Core/Interfaces/IDraftRepository.cs create mode 100644 src/Writegeist.Core/Interfaces/ILlmProvider.cs create mode 100644 src/Writegeist.Core/Interfaces/IPersonRepository.cs create mode 100644 src/Writegeist.Core/Interfaces/IPostRepository.cs create mode 100644 src/Writegeist.Core/Interfaces/IStyleProfileRepository.cs diff --git a/src/Writegeist.Core/Interfaces/IContentFetcher.cs b/src/Writegeist.Core/Interfaces/IContentFetcher.cs new file mode 100644 index 0000000..435662a --- /dev/null +++ b/src/Writegeist.Core/Interfaces/IContentFetcher.cs @@ -0,0 +1,9 @@ +using Writegeist.Core.Models; + +namespace Writegeist.Core.Interfaces; + +public interface IContentFetcher +{ + Platform Platform { get; } + Task> FetchPostsAsync(FetchRequest request); +} diff --git a/src/Writegeist.Core/Interfaces/IDraftRepository.cs b/src/Writegeist.Core/Interfaces/IDraftRepository.cs new file mode 100644 index 0000000..636ae1a --- /dev/null +++ b/src/Writegeist.Core/Interfaces/IDraftRepository.cs @@ -0,0 +1,10 @@ +using Writegeist.Core.Models; + +namespace Writegeist.Core.Interfaces; + +public interface IDraftRepository +{ + Task SaveAsync(GeneratedDraft draft); + Task GetLatestAsync(); + Task GetByIdAsync(int id); +} diff --git a/src/Writegeist.Core/Interfaces/ILlmProvider.cs b/src/Writegeist.Core/Interfaces/ILlmProvider.cs new file mode 100644 index 0000000..5d97c66 --- /dev/null +++ b/src/Writegeist.Core/Interfaces/ILlmProvider.cs @@ -0,0 +1,10 @@ +namespace Writegeist.Core.Interfaces; + +public interface ILlmProvider +{ + string ProviderName { get; } + string ModelName { get; } + Task AnalyseStyleAsync(string prompt); + Task GeneratePostAsync(string prompt); + Task RefinePostAsync(string prompt); +} diff --git a/src/Writegeist.Core/Interfaces/IPersonRepository.cs b/src/Writegeist.Core/Interfaces/IPersonRepository.cs new file mode 100644 index 0000000..8d615bc --- /dev/null +++ b/src/Writegeist.Core/Interfaces/IPersonRepository.cs @@ -0,0 +1,11 @@ +using Writegeist.Core.Models; + +namespace Writegeist.Core.Interfaces; + +public interface IPersonRepository +{ + Task CreateAsync(string name); + Task GetByNameAsync(string name); + Task> GetAllAsync(); + Task GetOrCreateAsync(string name); +} diff --git a/src/Writegeist.Core/Interfaces/IPostRepository.cs b/src/Writegeist.Core/Interfaces/IPostRepository.cs new file mode 100644 index 0000000..5f38a63 --- /dev/null +++ b/src/Writegeist.Core/Interfaces/IPostRepository.cs @@ -0,0 +1,11 @@ +using Writegeist.Core.Models; + +namespace Writegeist.Core.Interfaces; + +public interface IPostRepository +{ + Task AddAsync(RawPost post); + Task> GetByPersonIdAsync(int personId); + Task GetCountByPersonIdAsync(int personId); + Task ExistsByHashAsync(int personId, string contentHash); +} diff --git a/src/Writegeist.Core/Interfaces/IStyleProfileRepository.cs b/src/Writegeist.Core/Interfaces/IStyleProfileRepository.cs new file mode 100644 index 0000000..9324d4e --- /dev/null +++ b/src/Writegeist.Core/Interfaces/IStyleProfileRepository.cs @@ -0,0 +1,10 @@ +using Writegeist.Core.Models; + +namespace Writegeist.Core.Interfaces; + +public interface IStyleProfileRepository +{ + Task SaveAsync(StyleProfile profile); + Task GetLatestByPersonIdAsync(int personId); + Task> GetAllByPersonIdAsync(int personId); +} From 8bbcd6a1fcfb39fcc984e6e2a619609dba0510aa Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sat, 4 Apr 2026 19:40:50 +0100 Subject: [PATCH 04/26] feat: US-004 - SQLite database and schema initialisation Co-Authored-By: Claude Opus 4.6 --- .../Persistence/SqliteDatabase.cs | 73 ++++++++++ .../Writegeist.Infrastructure.csproj | 1 + .../Persistence/SqliteDatabaseTests.cs | 133 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 src/Writegeist.Infrastructure/Persistence/SqliteDatabase.cs create mode 100644 tests/Writegeist.Tests/Persistence/SqliteDatabaseTests.cs diff --git a/src/Writegeist.Infrastructure/Persistence/SqliteDatabase.cs b/src/Writegeist.Infrastructure/Persistence/SqliteDatabase.cs new file mode 100644 index 0000000..5fb4f01 --- /dev/null +++ b/src/Writegeist.Infrastructure/Persistence/SqliteDatabase.cs @@ -0,0 +1,73 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; + +namespace Writegeist.Infrastructure.Persistence; + +public class SqliteDatabase +{ + private readonly string _connectionString; + + public SqliteDatabase(IConfiguration configuration) + { + var dbPath = configuration["Writegeist:DatabasePath"] ?? "writegeist.db"; + _connectionString = $"Data Source={dbPath}"; + } + + public string ConnectionString => _connectionString; + + public void EnsureCreated() + { + using var connection = new SqliteConnection(_connectionString); + connection.Open(); + + using var command = connection.CreateCommand(); + command.CommandText = """ + CREATE TABLE IF NOT EXISTS persons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS raw_posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL, + platform TEXT NOT NULL, + content TEXT NOT NULL, + content_hash TEXT NOT NULL, + source_url TEXT, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (person_id) REFERENCES persons(id), + UNIQUE (person_id, content_hash) + ); + + CREATE TABLE IF NOT EXISTS style_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL, + profile_json TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (person_id) REFERENCES persons(id) + ); + + CREATE TABLE IF NOT EXISTS generated_drafts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL, + style_profile_id INTEGER NOT NULL, + platform TEXT NOT NULL, + topic TEXT NOT NULL, + content TEXT NOT NULL, + parent_draft_id INTEGER, + feedback TEXT, + provider TEXT NOT NULL, + model TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (person_id) REFERENCES persons(id), + FOREIGN KEY (style_profile_id) REFERENCES style_profiles(id), + FOREIGN KEY (parent_draft_id) REFERENCES generated_drafts(id) + ); + """; + + command.ExecuteNonQuery(); + } +} diff --git a/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj b/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj index 5f0b48a..f80ad2a 100644 --- a/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj +++ b/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/Writegeist.Tests/Persistence/SqliteDatabaseTests.cs b/tests/Writegeist.Tests/Persistence/SqliteDatabaseTests.cs new file mode 100644 index 0000000..e0b6b42 --- /dev/null +++ b/tests/Writegeist.Tests/Persistence/SqliteDatabaseTests.cs @@ -0,0 +1,133 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using NSubstitute; +using Writegeist.Infrastructure.Persistence; + +namespace Writegeist.Tests.Persistence; + +public class SqliteDatabaseTests : IDisposable +{ + private readonly string _dbName; + private readonly SqliteDatabase _database; + private readonly SqliteConnection _verifyConnection; + + public SqliteDatabaseTests() + { + _dbName = $"file:test_{Guid.NewGuid():N}?mode=memory&cache=shared"; + var config = Substitute.For(); + config["Writegeist:DatabasePath"].Returns(_dbName); + _database = new SqliteDatabase(config); + + // Keep a connection open so the shared in-memory DB persists + _verifyConnection = new SqliteConnection($"Data Source={_dbName}"); + _verifyConnection.Open(); + } + + public void Dispose() + { + _verifyConnection.Dispose(); + } + + private List GetTableNames() + { + using var cmd = _verifyConnection.CreateCommand(); + cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"; + using var reader = cmd.ExecuteReader(); + var tables = new List(); + while (reader.Read()) + tables.Add(reader.GetString(0)); + return tables; + } + + private List GetColumnNames(string tableName) + { + using var cmd = _verifyConnection.CreateCommand(); + cmd.CommandText = $"PRAGMA table_info({tableName});"; + using var reader = cmd.ExecuteReader(); + var columns = new List(); + while (reader.Read()) + columns.Add(reader.GetString(1)); + return columns; + } + + [Fact] + public void EnsureCreated_CreatesAllFourTables() + { + _database.EnsureCreated(); + + var tables = GetTableNames(); + tables.Should().Contain("persons"); + tables.Should().Contain("raw_posts"); + tables.Should().Contain("style_profiles"); + tables.Should().Contain("generated_drafts"); + } + + [Fact] + public void EnsureCreated_IsIdempotent() + { + _database.EnsureCreated(); + _database.EnsureCreated(); // should not throw + + GetTableNames().Should().Contain("persons"); + } + + [Fact] + public void EnsureCreated_PersonsTable_HasExpectedColumns() + { + _database.EnsureCreated(); + + var columns = GetColumnNames("persons"); + columns.Should().Contain("id"); + columns.Should().Contain("name"); + columns.Should().Contain("created_at"); + } + + [Fact] + public void EnsureCreated_RawPostsTable_HasExpectedColumns() + { + _database.EnsureCreated(); + + var columns = GetColumnNames("raw_posts"); + columns.Should().Contain("id"); + columns.Should().Contain("person_id"); + columns.Should().Contain("platform"); + columns.Should().Contain("content"); + columns.Should().Contain("content_hash"); + columns.Should().Contain("source_url"); + columns.Should().Contain("fetched_at"); + } + + [Fact] + public void EnsureCreated_StyleProfilesTable_HasExpectedColumns() + { + _database.EnsureCreated(); + + var columns = GetColumnNames("style_profiles"); + columns.Should().Contain("id"); + columns.Should().Contain("person_id"); + columns.Should().Contain("profile_json"); + columns.Should().Contain("provider"); + columns.Should().Contain("model"); + columns.Should().Contain("created_at"); + } + + [Fact] + public void EnsureCreated_GeneratedDraftsTable_HasExpectedColumns() + { + _database.EnsureCreated(); + + var columns = GetColumnNames("generated_drafts"); + columns.Should().Contain("id"); + columns.Should().Contain("person_id"); + columns.Should().Contain("style_profile_id"); + columns.Should().Contain("platform"); + columns.Should().Contain("topic"); + columns.Should().Contain("content"); + columns.Should().Contain("parent_draft_id"); + columns.Should().Contain("feedback"); + columns.Should().Contain("provider"); + columns.Should().Contain("model"); + columns.Should().Contain("created_at"); + } +} From acc897d7e1f44617f56a5d445400b1ca0e5e1c90 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sat, 4 Apr 2026 21:05:52 +0100 Subject: [PATCH 05/26] feat: US-005 - Person repository implementation Co-Authored-By: Claude Opus 4.6 --- .../Persistence/SqlitePersonRepository.cs | 90 ++++++++++++++ .../SqlitePersonRepositoryTests.cs | 111 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/Writegeist.Infrastructure/Persistence/SqlitePersonRepository.cs create mode 100644 tests/Writegeist.Tests/Persistence/SqlitePersonRepositoryTests.cs diff --git a/src/Writegeist.Infrastructure/Persistence/SqlitePersonRepository.cs b/src/Writegeist.Infrastructure/Persistence/SqlitePersonRepository.cs new file mode 100644 index 0000000..41cf759 --- /dev/null +++ b/src/Writegeist.Infrastructure/Persistence/SqlitePersonRepository.cs @@ -0,0 +1,90 @@ +using Microsoft.Data.Sqlite; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; + +namespace Writegeist.Infrastructure.Persistence; + +public class SqlitePersonRepository : IPersonRepository +{ + private readonly string _connectionString; + + public SqlitePersonRepository(SqliteDatabase database) + { + _connectionString = database.ConnectionString; + } + + public async Task CreateAsync(string name) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO persons (name) VALUES (@name); + SELECT last_insert_rowid(); + """; + command.Parameters.AddWithValue("@name", name); + + var id = (long)(await command.ExecuteScalarAsync())!; + + await using var readCmd = connection.CreateCommand(); + readCmd.CommandText = "SELECT id, name, created_at FROM persons WHERE id = @id;"; + readCmd.Parameters.AddWithValue("@id", id); + + await using var reader = await readCmd.ExecuteReaderAsync(); + await reader.ReadAsync(); + + return ReadPerson(reader); + } + + public async Task GetByNameAsync(string name) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT id, name, created_at FROM persons WHERE name = @name COLLATE NOCASE;"; + command.Parameters.AddWithValue("@name", name); + + await using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + return null; + + return ReadPerson(reader); + } + + public async Task> GetAllAsync() + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT id, name, created_at FROM persons ORDER BY name;"; + + await using var reader = await command.ExecuteReaderAsync(); + var persons = new List(); + while (await reader.ReadAsync()) + persons.Add(ReadPerson(reader)); + + return persons; + } + + public async Task GetOrCreateAsync(string name) + { + var existing = await GetByNameAsync(name); + if (existing is not null) + return existing; + + return await CreateAsync(name); + } + + private static Person ReadPerson(SqliteDataReader reader) + { + return new Person + { + Id = reader.GetInt32(0), + Name = reader.GetString(1), + CreatedAt = DateTime.Parse(reader.GetString(2)) + }; + } +} diff --git a/tests/Writegeist.Tests/Persistence/SqlitePersonRepositoryTests.cs b/tests/Writegeist.Tests/Persistence/SqlitePersonRepositoryTests.cs new file mode 100644 index 0000000..8750693 --- /dev/null +++ b/tests/Writegeist.Tests/Persistence/SqlitePersonRepositoryTests.cs @@ -0,0 +1,111 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using NSubstitute; +using Writegeist.Infrastructure.Persistence; + +namespace Writegeist.Tests.Persistence; + +public class SqlitePersonRepositoryTests : IDisposable +{ + private readonly SqlitePersonRepository _repository; + private readonly SqliteConnection _keepAlive; + + public SqlitePersonRepositoryTests() + { + var dbName = $"file:test_{Guid.NewGuid():N}?mode=memory&cache=shared"; + var config = Substitute.For(); + config["Writegeist:DatabasePath"].Returns(dbName); + + var database = new SqliteDatabase(config); + database.EnsureCreated(); + + _keepAlive = new SqliteConnection($"Data Source={dbName}"); + _keepAlive.Open(); + + _repository = new SqlitePersonRepository(database); + } + + public void Dispose() + { + _keepAlive.Dispose(); + } + + [Fact] + public async Task CreateAsync_ReturnsPersonWithGeneratedId() + { + var person = await _repository.CreateAsync("Alice"); + + person.Id.Should().BeGreaterThan(0); + person.Name.Should().Be("Alice"); + person.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(5)); + } + + [Fact] + public async Task GetByNameAsync_ExistingPerson_ReturnsPerson() + { + await _repository.CreateAsync("Bob"); + + var result = await _repository.GetByNameAsync("Bob"); + + result.Should().NotBeNull(); + result!.Name.Should().Be("Bob"); + } + + [Fact] + public async Task GetByNameAsync_CaseInsensitive() + { + await _repository.CreateAsync("Charlie"); + + var result = await _repository.GetByNameAsync("charlie"); + + result.Should().NotBeNull(); + result!.Name.Should().Be("Charlie"); + } + + [Fact] + public async Task GetByNameAsync_NotFound_ReturnsNull() + { + var result = await _repository.GetByNameAsync("Nobody"); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetAllAsync_ReturnsAllOrderedByName() + { + await _repository.CreateAsync("Zara"); + await _repository.CreateAsync("Alice"); + await _repository.CreateAsync("Mike"); + + var all = await _repository.GetAllAsync(); + + all.Should().HaveCount(3); + all[0].Name.Should().Be("Alice"); + all[1].Name.Should().Be("Mike"); + all[2].Name.Should().Be("Zara"); + } + + [Fact] + public async Task GetOrCreateAsync_ExistingPerson_ReturnsExisting() + { + var created = await _repository.CreateAsync("Diana"); + + var result = await _repository.GetOrCreateAsync("diana"); + + result.Id.Should().Be(created.Id); + result.Name.Should().Be("Diana"); + } + + [Fact] + public async Task GetOrCreateAsync_NewPerson_CreatesAndReturns() + { + var result = await _repository.GetOrCreateAsync("Eve"); + + result.Id.Should().BeGreaterThan(0); + result.Name.Should().Be("Eve"); + + var verify = await _repository.GetByNameAsync("Eve"); + verify.Should().NotBeNull(); + } +} From 4dd249e44b95314b83167dc5a07432a68eb222bb Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sat, 4 Apr 2026 21:07:41 +0100 Subject: [PATCH 06/26] feat: US-006 - Post repository implementation Co-Authored-By: Claude Opus 4.6 --- .../Persistence/SqlitePostRepository.cs | 112 +++++++++++++ .../Persistence/SqlitePostRepositoryTests.cs | 158 ++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 src/Writegeist.Infrastructure/Persistence/SqlitePostRepository.cs create mode 100644 tests/Writegeist.Tests/Persistence/SqlitePostRepositoryTests.cs diff --git a/src/Writegeist.Infrastructure/Persistence/SqlitePostRepository.cs b/src/Writegeist.Infrastructure/Persistence/SqlitePostRepository.cs new file mode 100644 index 0000000..34ff269 --- /dev/null +++ b/src/Writegeist.Infrastructure/Persistence/SqlitePostRepository.cs @@ -0,0 +1,112 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Data.Sqlite; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; + +namespace Writegeist.Infrastructure.Persistence; + +public class SqlitePostRepository : IPostRepository +{ + private readonly string _connectionString; + + public SqlitePostRepository(SqliteDatabase database) + { + _connectionString = database.ConnectionString; + } + + public async Task AddAsync(RawPost post) + { + post.ContentHash = ComputeHash(post.Content); + + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT OR IGNORE INTO raw_posts (person_id, platform, content, content_hash, source_url) + VALUES (@personId, @platform, @content, @contentHash, @sourceUrl); + """; + command.Parameters.AddWithValue("@personId", post.PersonId); + command.Parameters.AddWithValue("@platform", post.Platform.ToString()); + command.Parameters.AddWithValue("@content", post.Content); + command.Parameters.AddWithValue("@contentHash", post.ContentHash); + command.Parameters.AddWithValue("@sourceUrl", (object?)post.SourceUrl ?? DBNull.Value); + + var rowsAffected = await command.ExecuteNonQueryAsync(); + return rowsAffected > 0; + } + + public async Task> GetByPersonIdAsync(int personId) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT id, person_id, platform, content, content_hash, source_url, fetched_at + FROM raw_posts + WHERE person_id = @personId + ORDER BY fetched_at; + """; + command.Parameters.AddWithValue("@personId", personId); + + await using var reader = await command.ExecuteReaderAsync(); + var posts = new List(); + while (await reader.ReadAsync()) + posts.Add(ReadPost(reader)); + + return posts; + } + + public async Task GetCountByPersonIdAsync(int personId) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM raw_posts WHERE person_id = @personId;"; + command.Parameters.AddWithValue("@personId", personId); + + var count = (long)(await command.ExecuteScalarAsync())!; + return (int)count; + } + + public async Task ExistsByHashAsync(int personId, string contentHash) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COUNT(*) FROM raw_posts + WHERE person_id = @personId AND content_hash = @contentHash; + """; + command.Parameters.AddWithValue("@personId", personId); + command.Parameters.AddWithValue("@contentHash", contentHash); + + var count = (long)(await command.ExecuteScalarAsync())!; + return count > 0; + } + + public static string ComputeHash(string content) + { + var normalised = content.Trim().ToLowerInvariant(); + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalised)); + return Convert.ToHexStringLower(bytes); + } + + private static RawPost ReadPost(SqliteDataReader reader) + { + return new RawPost + { + Id = reader.GetInt32(0), + PersonId = reader.GetInt32(1), + Platform = Enum.Parse(reader.GetString(2)), + Content = reader.GetString(3), + ContentHash = reader.GetString(4), + SourceUrl = reader.IsDBNull(5) ? null : reader.GetString(5), + FetchedAt = DateTime.Parse(reader.GetString(6)) + }; + } +} diff --git a/tests/Writegeist.Tests/Persistence/SqlitePostRepositoryTests.cs b/tests/Writegeist.Tests/Persistence/SqlitePostRepositoryTests.cs new file mode 100644 index 0000000..cbdd8cb --- /dev/null +++ b/tests/Writegeist.Tests/Persistence/SqlitePostRepositoryTests.cs @@ -0,0 +1,158 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using NSubstitute; +using Writegeist.Core.Models; +using Writegeist.Infrastructure.Persistence; + +namespace Writegeist.Tests.Persistence; + +public class SqlitePostRepositoryTests : IDisposable +{ + private readonly SqlitePostRepository _repository; + private readonly SqliteConnection _keepAlive; + private readonly SqliteDatabase _database; + + public SqlitePostRepositoryTests() + { + var dbName = $"file:test_{Guid.NewGuid():N}?mode=memory&cache=shared"; + var config = Substitute.For(); + config["Writegeist:DatabasePath"].Returns(dbName); + + _database = new SqliteDatabase(config); + _database.EnsureCreated(); + + _keepAlive = new SqliteConnection($"Data Source={dbName}"); + _keepAlive.Open(); + + // Insert a test person + using var cmd = _keepAlive.CreateCommand(); + cmd.CommandText = "INSERT INTO persons (name) VALUES ('TestPerson');"; + cmd.ExecuteNonQuery(); + + _repository = new SqlitePostRepository(_database); + } + + public void Dispose() + { + _keepAlive.Dispose(); + } + + [Fact] + public async Task AddAsync_NewPost_ReturnsTrueAndStoresPost() + { + var post = new RawPost + { + PersonId = 1, + Platform = Platform.LinkedIn, + Content = "Hello world" + }; + + var result = await _repository.AddAsync(post); + + result.Should().BeTrue(); + post.ContentHash.Should().NotBeNullOrEmpty(); + + var posts = await _repository.GetByPersonIdAsync(1); + posts.Should().HaveCount(1); + posts[0].Content.Should().Be("Hello world"); + } + + [Fact] + public async Task AddAsync_DuplicateContent_ReturnsFalse() + { + var post1 = new RawPost + { + PersonId = 1, + Platform = Platform.X, + Content = "Same content" + }; + var post2 = new RawPost + { + PersonId = 1, + Platform = Platform.X, + Content = "Same content" + }; + + var first = await _repository.AddAsync(post1); + var second = await _repository.AddAsync(post2); + + first.Should().BeTrue(); + second.Should().BeFalse(); + } + + [Fact] + public async Task AddAsync_ContentHashIsNormalised() + { + var post1 = new RawPost + { + PersonId = 1, + Platform = Platform.LinkedIn, + Content = " Hello World " + }; + var post2 = new RawPost + { + PersonId = 1, + Platform = Platform.LinkedIn, + Content = "hello world" + }; + + await _repository.AddAsync(post1); + var isDuplicate = await _repository.AddAsync(post2); + + isDuplicate.Should().BeFalse(); + } + + [Fact] + public async Task GetByPersonIdAsync_ReturnsAllPostsForPerson() + { + await _repository.AddAsync(new RawPost { PersonId = 1, Platform = Platform.LinkedIn, Content = "First" }); + await _repository.AddAsync(new RawPost { PersonId = 1, Platform = Platform.X, Content = "Second" }); + + var posts = await _repository.GetByPersonIdAsync(1); + + posts.Should().HaveCount(2); + posts.Select(p => p.Content).Should().Contain("First").And.Contain("Second"); + } + + [Fact] + public async Task GetCountByPersonIdAsync_ReturnsCorrectCount() + { + await _repository.AddAsync(new RawPost { PersonId = 1, Platform = Platform.LinkedIn, Content = "One" }); + await _repository.AddAsync(new RawPost { PersonId = 1, Platform = Platform.X, Content = "Two" }); + await _repository.AddAsync(new RawPost { PersonId = 1, Platform = Platform.Instagram, Content = "Three" }); + + var count = await _repository.GetCountByPersonIdAsync(1); + + count.Should().Be(3); + } + + [Fact] + public async Task ExistsByHashAsync_ExistingHash_ReturnsTrue() + { + var post = new RawPost { PersonId = 1, Platform = Platform.LinkedIn, Content = "Check me" }; + await _repository.AddAsync(post); + + var exists = await _repository.ExistsByHashAsync(1, post.ContentHash); + + exists.Should().BeTrue(); + } + + [Fact] + public async Task ExistsByHashAsync_NonExistingHash_ReturnsFalse() + { + var exists = await _repository.ExistsByHashAsync(1, "nonexistenthash"); + + exists.Should().BeFalse(); + } + + [Fact] + public void ComputeHash_IsSha256OfNormalisedContent() + { + var hash1 = SqlitePostRepository.ComputeHash(" Hello World "); + var hash2 = SqlitePostRepository.ComputeHash("hello world"); + + hash1.Should().Be(hash2); + hash1.Should().HaveLength(64); // SHA-256 hex string + } +} From e4208062986e1fb0354def0e6ee667c5f9ded2f1 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sat, 4 Apr 2026 21:12:54 +0100 Subject: [PATCH 07/26] feat: US-007 - Style profile repository implementation Co-Authored-By: Claude Opus 4.6 --- .../SqliteStyleProfileRepository.cs | 100 +++++++++++++++ .../SqliteStyleProfileRepositoryTests.cs | 114 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/Writegeist.Infrastructure/Persistence/SqliteStyleProfileRepository.cs create mode 100644 tests/Writegeist.Tests/Persistence/SqliteStyleProfileRepositoryTests.cs diff --git a/src/Writegeist.Infrastructure/Persistence/SqliteStyleProfileRepository.cs b/src/Writegeist.Infrastructure/Persistence/SqliteStyleProfileRepository.cs new file mode 100644 index 0000000..40206d6 --- /dev/null +++ b/src/Writegeist.Infrastructure/Persistence/SqliteStyleProfileRepository.cs @@ -0,0 +1,100 @@ +using Microsoft.Data.Sqlite; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; + +namespace Writegeist.Infrastructure.Persistence; + +public class SqliteStyleProfileRepository : IStyleProfileRepository +{ + private readonly string _connectionString; + + public SqliteStyleProfileRepository(SqliteDatabase database) + { + _connectionString = database.ConnectionString; + } + + public async Task SaveAsync(StyleProfile profile) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO style_profiles (person_id, profile_json, provider, model) + VALUES (@personId, @profileJson, @provider, @model); + SELECT last_insert_rowid(); + """; + command.Parameters.AddWithValue("@personId", profile.PersonId); + command.Parameters.AddWithValue("@profileJson", profile.ProfileJson); + command.Parameters.AddWithValue("@provider", profile.Provider); + command.Parameters.AddWithValue("@model", profile.Model); + + var id = (long)(await command.ExecuteScalarAsync())!; + + await using var readCmd = connection.CreateCommand(); + readCmd.CommandText = "SELECT id, person_id, profile_json, provider, model, created_at FROM style_profiles WHERE id = @id;"; + readCmd.Parameters.AddWithValue("@id", id); + + await using var reader = await readCmd.ExecuteReaderAsync(); + await reader.ReadAsync(); + + return ReadProfile(reader); + } + + public async Task GetLatestByPersonIdAsync(int personId) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT id, person_id, profile_json, provider, model, created_at + FROM style_profiles + WHERE person_id = @personId + ORDER BY created_at DESC, id DESC + LIMIT 1; + """; + command.Parameters.AddWithValue("@personId", personId); + + await using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + return null; + + return ReadProfile(reader); + } + + public async Task> GetAllByPersonIdAsync(int personId) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT id, person_id, profile_json, provider, model, created_at + FROM style_profiles + WHERE person_id = @personId + ORDER BY created_at DESC, id DESC; + """; + command.Parameters.AddWithValue("@personId", personId); + + await using var reader = await command.ExecuteReaderAsync(); + var profiles = new List(); + while (await reader.ReadAsync()) + profiles.Add(ReadProfile(reader)); + + return profiles; + } + + private static StyleProfile ReadProfile(SqliteDataReader reader) + { + return new StyleProfile + { + Id = reader.GetInt32(0), + PersonId = reader.GetInt32(1), + ProfileJson = reader.GetString(2), + Provider = reader.GetString(3), + Model = reader.GetString(4), + CreatedAt = DateTime.Parse(reader.GetString(5)) + }; + } +} diff --git a/tests/Writegeist.Tests/Persistence/SqliteStyleProfileRepositoryTests.cs b/tests/Writegeist.Tests/Persistence/SqliteStyleProfileRepositoryTests.cs new file mode 100644 index 0000000..d70d70e --- /dev/null +++ b/tests/Writegeist.Tests/Persistence/SqliteStyleProfileRepositoryTests.cs @@ -0,0 +1,114 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using NSubstitute; +using Writegeist.Core.Models; +using Writegeist.Infrastructure.Persistence; + +namespace Writegeist.Tests.Persistence; + +public class SqliteStyleProfileRepositoryTests : IDisposable +{ + private readonly SqliteStyleProfileRepository _repository; + private readonly SqliteConnection _keepAlive; + + public SqliteStyleProfileRepositoryTests() + { + var dbName = $"file:test_{Guid.NewGuid():N}?mode=memory&cache=shared"; + var config = Substitute.For(); + config["Writegeist:DatabasePath"].Returns(dbName); + + var database = new SqliteDatabase(config); + database.EnsureCreated(); + + _keepAlive = new SqliteConnection($"Data Source={dbName}"); + _keepAlive.Open(); + + using var cmd = _keepAlive.CreateCommand(); + cmd.CommandText = "INSERT INTO persons (name) VALUES ('TestPerson');"; + cmd.ExecuteNonQuery(); + + _repository = new SqliteStyleProfileRepository(database); + } + + public void Dispose() + { + _keepAlive.Dispose(); + } + + [Fact] + public async Task SaveAsync_ReturnsProfileWithGeneratedId() + { + var profile = new StyleProfile + { + PersonId = 1, + ProfileJson = "{\"tone\": \"casual\"}", + Provider = "anthropic", + Model = "claude-sonnet-4-20250514" + }; + + var saved = await _repository.SaveAsync(profile); + + saved.Id.Should().BeGreaterThan(0); + saved.PersonId.Should().Be(1); + saved.ProfileJson.Should().Be("{\"tone\": \"casual\"}"); + saved.Provider.Should().Be("anthropic"); + saved.Model.Should().Be("claude-sonnet-4-20250514"); + saved.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(5)); + } + + [Fact] + public async Task GetLatestByPersonIdAsync_ReturnsLatestProfile() + { + await _repository.SaveAsync(new StyleProfile + { + PersonId = 1, ProfileJson = "{\"v\": 1}", Provider = "anthropic", Model = "m1" + }); + var latest = await _repository.SaveAsync(new StyleProfile + { + PersonId = 1, ProfileJson = "{\"v\": 2}", Provider = "openai", Model = "m2" + }); + + var result = await _repository.GetLatestByPersonIdAsync(1); + + result.Should().NotBeNull(); + result!.Id.Should().Be(latest.Id); + result.ProfileJson.Should().Be("{\"v\": 2}"); + } + + [Fact] + public async Task GetLatestByPersonIdAsync_NoProfiles_ReturnsNull() + { + var result = await _repository.GetLatestByPersonIdAsync(1); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetAllByPersonIdAsync_ReturnsAllProfilesDescending() + { + var first = await _repository.SaveAsync(new StyleProfile + { + PersonId = 1, ProfileJson = "{\"v\": 1}", Provider = "anthropic", Model = "m1" + }); + var second = await _repository.SaveAsync(new StyleProfile + { + PersonId = 1, ProfileJson = "{\"v\": 2}", Provider = "openai", Model = "m2" + }); + + var all = await _repository.GetAllByPersonIdAsync(1); + + all.Should().HaveCount(2); + // Ordered by created_at DESC, id DESC — second should come first + all[0].Id.Should().Be(second.Id); + all[1].Id.Should().Be(first.Id); + } + + [Fact] + public async Task GetAllByPersonIdAsync_NoProfiles_ReturnsEmpty() + { + var all = await _repository.GetAllByPersonIdAsync(999); + + all.Should().BeEmpty(); + } +} From 52675647c89b18b0991ed6a954e315d2cc03650d Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sat, 4 Apr 2026 21:14:22 +0100 Subject: [PATCH 08/26] feat: US-008 - Draft repository implementation Co-Authored-By: Claude Opus 4.6 --- .../Persistence/SqliteDraftRepository.cs | 100 +++++++++++++ .../Persistence/SqliteDraftRepositoryTests.cs | 134 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 src/Writegeist.Infrastructure/Persistence/SqliteDraftRepository.cs create mode 100644 tests/Writegeist.Tests/Persistence/SqliteDraftRepositoryTests.cs diff --git a/src/Writegeist.Infrastructure/Persistence/SqliteDraftRepository.cs b/src/Writegeist.Infrastructure/Persistence/SqliteDraftRepository.cs new file mode 100644 index 0000000..7c63ceb --- /dev/null +++ b/src/Writegeist.Infrastructure/Persistence/SqliteDraftRepository.cs @@ -0,0 +1,100 @@ +using Microsoft.Data.Sqlite; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; + +namespace Writegeist.Infrastructure.Persistence; + +public class SqliteDraftRepository : IDraftRepository +{ + private readonly string _connectionString; + + public SqliteDraftRepository(SqliteDatabase database) + { + _connectionString = database.ConnectionString; + } + + public async Task SaveAsync(GeneratedDraft draft) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO generated_drafts (person_id, style_profile_id, platform, topic, content, parent_draft_id, feedback, provider, model) + VALUES (@personId, @styleProfileId, @platform, @topic, @content, @parentDraftId, @feedback, @provider, @model); + SELECT last_insert_rowid(); + """; + command.Parameters.AddWithValue("@personId", draft.PersonId); + command.Parameters.AddWithValue("@styleProfileId", draft.StyleProfileId); + command.Parameters.AddWithValue("@platform", draft.Platform.ToString()); + command.Parameters.AddWithValue("@topic", draft.Topic); + command.Parameters.AddWithValue("@content", draft.Content); + command.Parameters.AddWithValue("@parentDraftId", (object?)draft.ParentDraftId ?? DBNull.Value); + command.Parameters.AddWithValue("@feedback", (object?)draft.Feedback ?? DBNull.Value); + command.Parameters.AddWithValue("@provider", draft.Provider); + command.Parameters.AddWithValue("@model", draft.Model); + + var id = (long)(await command.ExecuteScalarAsync())!; + + await using var readCmd = connection.CreateCommand(); + readCmd.CommandText = SelectColumns + " WHERE id = @id;"; + readCmd.Parameters.AddWithValue("@id", id); + + await using var reader = await readCmd.ExecuteReaderAsync(); + await reader.ReadAsync(); + + return ReadDraft(reader); + } + + public async Task GetLatestAsync() + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = SelectColumns + " ORDER BY created_at DESC, id DESC LIMIT 1;"; + + await using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + return null; + + return ReadDraft(reader); + } + + public async Task GetByIdAsync(int id) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = SelectColumns + " WHERE id = @id;"; + command.Parameters.AddWithValue("@id", id); + + await using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + return null; + + return ReadDraft(reader); + } + + private const string SelectColumns = + "SELECT id, person_id, style_profile_id, platform, topic, content, parent_draft_id, feedback, provider, model, created_at FROM generated_drafts"; + + private static GeneratedDraft ReadDraft(SqliteDataReader reader) + { + return new GeneratedDraft + { + Id = reader.GetInt32(0), + PersonId = reader.GetInt32(1), + StyleProfileId = reader.GetInt32(2), + Platform = Enum.Parse(reader.GetString(3)), + Topic = reader.GetString(4), + Content = reader.GetString(5), + ParentDraftId = reader.IsDBNull(6) ? null : reader.GetInt32(6), + Feedback = reader.IsDBNull(7) ? null : reader.GetString(7), + Provider = reader.GetString(8), + Model = reader.GetString(9), + CreatedAt = DateTime.Parse(reader.GetString(10)) + }; + } +} diff --git a/tests/Writegeist.Tests/Persistence/SqliteDraftRepositoryTests.cs b/tests/Writegeist.Tests/Persistence/SqliteDraftRepositoryTests.cs new file mode 100644 index 0000000..d083cab --- /dev/null +++ b/tests/Writegeist.Tests/Persistence/SqliteDraftRepositoryTests.cs @@ -0,0 +1,134 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using NSubstitute; +using Writegeist.Core.Models; +using Writegeist.Infrastructure.Persistence; + +namespace Writegeist.Tests.Persistence; + +public class SqliteDraftRepositoryTests : IDisposable +{ + private readonly SqliteDraftRepository _repository; + private readonly SqliteConnection _keepAlive; + + public SqliteDraftRepositoryTests() + { + var dbName = $"file:test_{Guid.NewGuid():N}?mode=memory&cache=shared"; + var config = Substitute.For(); + config["Writegeist:DatabasePath"].Returns(dbName); + + var database = new SqliteDatabase(config); + database.EnsureCreated(); + + _keepAlive = new SqliteConnection($"Data Source={dbName}"); + _keepAlive.Open(); + + // Insert prerequisite person and style profile + using var cmd = _keepAlive.CreateCommand(); + cmd.CommandText = """ + INSERT INTO persons (name) VALUES ('TestPerson'); + INSERT INTO style_profiles (person_id, profile_json, provider, model) VALUES (1, '{}', 'anthropic', 'test'); + """; + cmd.ExecuteNonQuery(); + + _repository = new SqliteDraftRepository(database); + } + + public void Dispose() + { + _keepAlive.Dispose(); + } + + private GeneratedDraft MakeDraft(string content = "Test post", int? parentDraftId = null, string? feedback = null) + { + return new GeneratedDraft + { + PersonId = 1, + StyleProfileId = 1, + Platform = Platform.LinkedIn, + Topic = "testing", + Content = content, + ParentDraftId = parentDraftId, + Feedback = feedback, + Provider = "anthropic", + Model = "test-model" + }; + } + + [Fact] + public async Task SaveAsync_ReturnsDraftWithGeneratedId() + { + var saved = await _repository.SaveAsync(MakeDraft()); + + saved.Id.Should().BeGreaterThan(0); + saved.Content.Should().Be("Test post"); + saved.Platform.Should().Be(Platform.LinkedIn); + saved.Provider.Should().Be("anthropic"); + saved.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(5)); + } + + [Fact] + public async Task GetLatestAsync_ReturnsMostRecentDraft() + { + await _repository.SaveAsync(MakeDraft("First")); + var second = await _repository.SaveAsync(MakeDraft("Second")); + + var latest = await _repository.GetLatestAsync(); + + latest.Should().NotBeNull(); + latest!.Id.Should().Be(second.Id); + latest.Content.Should().Be("Second"); + } + + [Fact] + public async Task GetLatestAsync_NoDrafts_ReturnsNull() + { + var result = await _repository.GetLatestAsync(); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetByIdAsync_ExistingDraft_ReturnsDraft() + { + var saved = await _repository.SaveAsync(MakeDraft()); + + var result = await _repository.GetByIdAsync(saved.Id); + + result.Should().NotBeNull(); + result!.Id.Should().Be(saved.Id); + result.Content.Should().Be("Test post"); + } + + [Fact] + public async Task GetByIdAsync_NonExisting_ReturnsNull() + { + var result = await _repository.GetByIdAsync(999); + + result.Should().BeNull(); + } + + [Fact] + public async Task SaveAsync_ParentChildLinking_WorksCorrectly() + { + var parent = await _repository.SaveAsync(MakeDraft("Original")); + var child = await _repository.SaveAsync(MakeDraft("Refined", parentDraftId: parent.Id, feedback: "Make it shorter")); + + child.ParentDraftId.Should().Be(parent.Id); + child.Feedback.Should().Be("Make it shorter"); + + var retrieved = await _repository.GetByIdAsync(child.Id); + retrieved!.ParentDraftId.Should().Be(parent.Id); + retrieved.Feedback.Should().Be("Make it shorter"); + } + + [Fact] + public async Task SaveAsync_NullableFieldsAreNull_WhenNotSet() + { + var saved = await _repository.SaveAsync(MakeDraft()); + + saved.ParentDraftId.Should().BeNull(); + saved.Feedback.Should().BeNull(); + } +} From 918e69008ea4c260f25f656e21a744c0da2e126d Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sat, 4 Apr 2026 21:57:50 +0100 Subject: [PATCH 09/26] =?UTF-8?q?feat:=20US-009=20-=20Manual=20fetcher=20?= =?UTF-8?q?=E2=80=94=20file=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../Fetchers/ManualFetcher.cs | 30 ++++++ .../Fetchers/ManualFetcherTests.cs | 100 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/Writegeist.Infrastructure/Fetchers/ManualFetcher.cs create mode 100644 tests/Writegeist.Tests/Fetchers/ManualFetcherTests.cs diff --git a/src/Writegeist.Infrastructure/Fetchers/ManualFetcher.cs b/src/Writegeist.Infrastructure/Fetchers/ManualFetcher.cs new file mode 100644 index 0000000..35dbaa7 --- /dev/null +++ b/src/Writegeist.Infrastructure/Fetchers/ManualFetcher.cs @@ -0,0 +1,30 @@ +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; + +namespace Writegeist.Infrastructure.Fetchers; + +public class ManualFetcher : IContentFetcher +{ + public Platform Platform { get; } + + public ManualFetcher(Platform platform) + { + Platform = platform; + } + + public async Task> FetchPostsAsync(FetchRequest request) + { + if (string.IsNullOrWhiteSpace(request.FilePath)) + throw new ArgumentException("FilePath is required for file import.", nameof(request)); + + var content = await File.ReadAllTextAsync(request.FilePath); + var posts = content + .Split(["---"], StringSplitOptions.None) + .Select(p => p.Trim()) + .Where(p => !string.IsNullOrEmpty(p)) + .Select(p => new FetchedPost(p)) + .ToList(); + + return posts; + } +} diff --git a/tests/Writegeist.Tests/Fetchers/ManualFetcherTests.cs b/tests/Writegeist.Tests/Fetchers/ManualFetcherTests.cs new file mode 100644 index 0000000..2b8be05 --- /dev/null +++ b/tests/Writegeist.Tests/Fetchers/ManualFetcherTests.cs @@ -0,0 +1,100 @@ +using FluentAssertions; +using Writegeist.Core.Models; +using Writegeist.Infrastructure.Fetchers; + +namespace Writegeist.Tests.Fetchers; + +public class ManualFetcherTests : IDisposable +{ + private readonly string _tempDir; + private readonly ManualFetcher _fetcher; + + public ManualFetcherTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"writegeist_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _fetcher = new ManualFetcher(Platform.LinkedIn); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + private string WriteFile(string content) + { + var path = Path.Combine(_tempDir, $"{Guid.NewGuid():N}.txt"); + File.WriteAllText(path, content); + return path; + } + + [Fact] + public async Task FetchPostsAsync_MultiplePosts_SplitsOnSeparator() + { + var path = WriteFile("First post\n---\nSecond post\n---\nThird post"); + var request = new FetchRequest(FilePath: path); + + var posts = await _fetcher.FetchPostsAsync(request); + + posts.Should().HaveCount(3); + posts[0].Content.Should().Be("First post"); + posts[1].Content.Should().Be("Second post"); + posts[2].Content.Should().Be("Third post"); + } + + [Fact] + public async Task FetchPostsAsync_EmptyFile_ReturnsEmpty() + { + var path = WriteFile(""); + var request = new FetchRequest(FilePath: path); + + var posts = await _fetcher.FetchPostsAsync(request); + + posts.Should().BeEmpty(); + } + + [Fact] + public async Task FetchPostsAsync_NoSeparators_ReturnsSinglePost() + { + var path = WriteFile("Just one post with no separators"); + var request = new FetchRequest(FilePath: path); + + var posts = await _fetcher.FetchPostsAsync(request); + + posts.Should().HaveCount(1); + posts[0].Content.Should().Be("Just one post with no separators"); + } + + [Fact] + public async Task FetchPostsAsync_ConsecutiveSeparators_SkipsEmpty() + { + var path = WriteFile("First\n---\n---\n---\nSecond"); + var request = new FetchRequest(FilePath: path); + + var posts = await _fetcher.FetchPostsAsync(request); + + posts.Should().HaveCount(2); + posts[0].Content.Should().Be("First"); + posts[1].Content.Should().Be("Second"); + } + + [Fact] + public async Task FetchPostsAsync_TrimsWhitespace() + { + var path = WriteFile(" First post \n---\n Second post "); + var request = new FetchRequest(FilePath: path); + + var posts = await _fetcher.FetchPostsAsync(request); + + posts[0].Content.Should().Be("First post"); + posts[1].Content.Should().Be("Second post"); + } + + [Fact] + public void Platform_ReturnsConfiguredValue() + { + var fetcher = new ManualFetcher(Platform.X); + fetcher.Platform.Should().Be(Platform.X); + } +} From 01e6ba9f44d55723b13b275cfd051b90b0b62052 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sat, 4 Apr 2026 21:59:25 +0100 Subject: [PATCH 10/26] feat: US-010 - Platform conventions Co-Authored-By: Claude Opus 4.6 --- src/Writegeist.Core/Models/PlatformRules.cs | 13 ++++ src/Writegeist.Core/PlatformConventions.cs | 59 ++++++++++++++++ .../PlatformConventionsTests.cs | 68 +++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 src/Writegeist.Core/Models/PlatformRules.cs create mode 100644 src/Writegeist.Core/PlatformConventions.cs create mode 100644 tests/Writegeist.Tests/PlatformConventionsTests.cs diff --git a/src/Writegeist.Core/Models/PlatformRules.cs b/src/Writegeist.Core/Models/PlatformRules.cs new file mode 100644 index 0000000..6b14677 --- /dev/null +++ b/src/Writegeist.Core/Models/PlatformRules.cs @@ -0,0 +1,13 @@ +namespace Writegeist.Core.Models; + +public record PlatformRules( + string Name, + int? MaxCharacters, + int RecommendedMaxLength, + bool SupportsHashtags, + int RecommendedHashtagCount, + string HashtagPlacement, + bool SupportsEmoji, + string EmojiGuidance, + string ToneGuidance, + string FormattingNotes); diff --git a/src/Writegeist.Core/PlatformConventions.cs b/src/Writegeist.Core/PlatformConventions.cs new file mode 100644 index 0000000..c7411c5 --- /dev/null +++ b/src/Writegeist.Core/PlatformConventions.cs @@ -0,0 +1,59 @@ +using Writegeist.Core.Models; + +namespace Writegeist.Core; + +public static class PlatformConventions +{ + public static PlatformRules GetRules(Platform platform) => platform switch + { + Platform.LinkedIn => new PlatformRules( + Name: "LinkedIn", + MaxCharacters: 3000, + RecommendedMaxLength: 1500, + SupportsHashtags: true, + RecommendedHashtagCount: 4, + HashtagPlacement: "end", + SupportsEmoji: true, + EmojiGuidance: "Use sparingly for professional tone", + ToneGuidance: "Professional, insightful, conversational", + FormattingNotes: "Line breaks for readability; short paragraphs; hook in first line"), + + Platform.X => new PlatformRules( + Name: "X", + MaxCharacters: 280, + RecommendedMaxLength: 280, + SupportsHashtags: true, + RecommendedHashtagCount: 2, + HashtagPlacement: "inline", + SupportsEmoji: true, + EmojiGuidance: "Moderate use to add personality", + ToneGuidance: "Concise, punchy, opinionated", + FormattingNotes: "Single tweet; no threading assumed"), + + Platform.Instagram => new PlatformRules( + Name: "Instagram", + MaxCharacters: 2200, + RecommendedMaxLength: 750, + SupportsHashtags: true, + RecommendedHashtagCount: 30, + HashtagPlacement: "end in separate block", + SupportsEmoji: true, + EmojiGuidance: "Use freely to match visual culture", + ToneGuidance: "Casual, authentic, visually descriptive", + FormattingNotes: "Caption format; line breaks between sections; hashtag block at end"), + + Platform.Facebook => new PlatformRules( + Name: "Facebook", + MaxCharacters: 63206, + RecommendedMaxLength: 600, + SupportsHashtags: true, + RecommendedHashtagCount: 2, + HashtagPlacement: "end", + SupportsEmoji: true, + EmojiGuidance: "Moderate use for engagement", + ToneGuidance: "Conversational, personal, community-oriented", + FormattingNotes: "Short paragraphs; question at end to drive engagement"), + + _ => throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unsupported platform") + }; +} diff --git a/tests/Writegeist.Tests/PlatformConventionsTests.cs b/tests/Writegeist.Tests/PlatformConventionsTests.cs new file mode 100644 index 0000000..073cd0a --- /dev/null +++ b/tests/Writegeist.Tests/PlatformConventionsTests.cs @@ -0,0 +1,68 @@ +using FluentAssertions; +using Writegeist.Core; +using Writegeist.Core.Models; + +namespace Writegeist.Tests; + +public class PlatformConventionsTests +{ + [Fact] + public void GetRules_LinkedIn_ReturnsCorrectRules() + { + var rules = PlatformConventions.GetRules(Platform.LinkedIn); + + rules.Name.Should().Be("LinkedIn"); + rules.MaxCharacters.Should().Be(3000); + rules.RecommendedMaxLength.Should().Be(1500); + rules.SupportsHashtags.Should().BeTrue(); + rules.RecommendedHashtagCount.Should().BeInRange(3, 5); + rules.HashtagPlacement.Should().Be("end"); + rules.SupportsEmoji.Should().BeTrue(); + rules.EmojiGuidance.Should().ContainEquivalentOf("sparingly"); + } + + [Fact] + public void GetRules_X_ReturnsCorrectRules() + { + var rules = PlatformConventions.GetRules(Platform.X); + + rules.Name.Should().Be("X"); + rules.MaxCharacters.Should().Be(280); + rules.RecommendedMaxLength.Should().Be(280); + rules.SupportsHashtags.Should().BeTrue(); + rules.RecommendedHashtagCount.Should().BeInRange(1, 2); + rules.HashtagPlacement.Should().Be("inline"); + rules.SupportsEmoji.Should().BeTrue(); + rules.EmojiGuidance.Should().ContainEquivalentOf("moderate"); + } + + [Fact] + public void GetRules_Instagram_ReturnsCorrectRules() + { + var rules = PlatformConventions.GetRules(Platform.Instagram); + + rules.Name.Should().Be("Instagram"); + rules.MaxCharacters.Should().Be(2200); + rules.RecommendedMaxLength.Should().Be(750); + rules.SupportsHashtags.Should().BeTrue(); + rules.RecommendedHashtagCount.Should().BeGreaterThanOrEqualTo(30); + rules.HashtagPlacement.Should().Contain("end"); + rules.SupportsEmoji.Should().BeTrue(); + rules.EmojiGuidance.Should().ContainEquivalentOf("freely"); + } + + [Fact] + public void GetRules_Facebook_ReturnsCorrectRules() + { + var rules = PlatformConventions.GetRules(Platform.Facebook); + + rules.Name.Should().Be("Facebook"); + rules.MaxCharacters.Should().Be(63206); + rules.RecommendedMaxLength.Should().Be(600); + rules.SupportsHashtags.Should().BeTrue(); + rules.RecommendedHashtagCount.Should().BeInRange(1, 3); + rules.HashtagPlacement.Should().Be("end"); + rules.SupportsEmoji.Should().BeTrue(); + rules.EmojiGuidance.Should().ContainEquivalentOf("moderate"); + } +} From 18bf5a3765756fe418cbf4d7cd21e43d206911cf Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sat, 4 Apr 2026 22:00:52 +0100 Subject: [PATCH 11/26] feat: US-011 - Anthropic LLM provider Co-Authored-By: Claude Opus 4.6 --- .../LlmProviders/AnthropicProvider.cs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/Writegeist.Infrastructure/LlmProviders/AnthropicProvider.cs diff --git a/src/Writegeist.Infrastructure/LlmProviders/AnthropicProvider.cs b/src/Writegeist.Infrastructure/LlmProviders/AnthropicProvider.cs new file mode 100644 index 0000000..57f4af4 --- /dev/null +++ b/src/Writegeist.Infrastructure/LlmProviders/AnthropicProvider.cs @@ -0,0 +1,82 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Writegeist.Core.Interfaces; + +namespace Writegeist.Infrastructure.LlmProviders; + +public class AnthropicProvider : ILlmProvider +{ + private const string ApiBaseUrl = "https://api.anthropic.com/v1/messages"; + private const string DefaultModel = "claude-sonnet-4-20250514"; + private const string ApiVersion = "2023-06-01"; + + private readonly HttpClient _httpClient; + private readonly string _apiKey; + private readonly string _model; + + public AnthropicProvider(HttpClient httpClient, IConfiguration configuration) + { + _httpClient = httpClient; + _apiKey = configuration["ANTHROPIC_API_KEY"] + ?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") + ?? throw new InvalidOperationException( + "Anthropic API key is not configured. Set the ANTHROPIC_API_KEY environment variable."); + + _model = configuration["Writegeist:Anthropic:Model"] ?? DefaultModel; + } + + public string ProviderName => "anthropic"; + public string ModelName => _model; + + public Task AnalyseStyleAsync(string prompt) => SendMessageAsync(prompt); + + public Task GeneratePostAsync(string prompt) => SendMessageAsync(prompt); + + public Task RefinePostAsync(string prompt) => SendMessageAsync(prompt); + + private async Task SendMessageAsync(string prompt) + { + var requestBody = new + { + model = _model, + max_tokens = 4096, + messages = new[] + { + new { role = "user", content = prompt } + } + }; + + var json = JsonSerializer.Serialize(requestBody); + using var request = new HttpRequestMessage(HttpMethod.Post, ApiBaseUrl); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + request.Headers.Add("x-api-key", _apiKey); + request.Headers.Add("anthropic-version", ApiVersion); + + using var response = await _httpClient.SendAsync(request); + + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + var statusCode = (int)response.StatusCode; + var message = statusCode switch + { + 401 => "Invalid Anthropic API key. Please check your ANTHROPIC_API_KEY.", + 429 => "Anthropic rate limit exceeded. Please wait a moment and try again.", + >= 500 => $"Anthropic API server error ({statusCode}). Please try again later.", + _ => $"Anthropic API request failed with status {statusCode}: {responseBody}" + }; + throw new HttpRequestException(message); + } + + using var doc = JsonDocument.Parse(responseBody); + var content = doc.RootElement + .GetProperty("content")[0] + .GetProperty("text") + .GetString(); + + return content ?? throw new InvalidOperationException("Anthropic API returned empty content."); + } +} From f69c35b7501d58772d2e92a0cd8139bc6fbdad03 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 09:54:56 +0100 Subject: [PATCH 12/26] feat: US-012 - OpenAI LLM provider Co-Authored-By: Claude Opus 4.6 --- .../LlmProviders/OpenAiProvider.cs | 57 +++++++++++++++++++ .../Writegeist.Infrastructure.csproj | 1 + 2 files changed, 58 insertions(+) create mode 100644 src/Writegeist.Infrastructure/LlmProviders/OpenAiProvider.cs diff --git a/src/Writegeist.Infrastructure/LlmProviders/OpenAiProvider.cs b/src/Writegeist.Infrastructure/LlmProviders/OpenAiProvider.cs new file mode 100644 index 0000000..8558fba --- /dev/null +++ b/src/Writegeist.Infrastructure/LlmProviders/OpenAiProvider.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Configuration; +using OpenAI.Chat; +using Writegeist.Core.Interfaces; + +namespace Writegeist.Infrastructure.LlmProviders; + +public class OpenAiProvider : ILlmProvider +{ + private const string DefaultModel = "gpt-4o"; + + private readonly ChatClient _chatClient; + private readonly string _model; + + public OpenAiProvider(IConfiguration configuration) + { + var apiKey = configuration["OPENAI_API_KEY"] + ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY") + ?? throw new InvalidOperationException( + "OpenAI API key is not configured. Set the OPENAI_API_KEY environment variable."); + + _model = configuration["Writegeist:OpenAi:Model"] ?? DefaultModel; + _chatClient = new ChatClient(_model, apiKey); + } + + public string ProviderName => "openai"; + public string ModelName => _model; + + public Task AnalyseStyleAsync(string prompt) => SendMessageAsync(prompt); + + public Task GeneratePostAsync(string prompt) => SendMessageAsync(prompt); + + public Task RefinePostAsync(string prompt) => SendMessageAsync(prompt); + + private async Task SendMessageAsync(string prompt) + { + try + { + var completion = await _chatClient.CompleteChatAsync( + [new UserChatMessage(prompt)]); + + return completion.Value.Content[0].Text + ?? throw new InvalidOperationException("OpenAI API returned empty content."); + } + catch (Exception ex) when (ex is not InvalidOperationException) + { + var message = ex.Message switch + { + var m when m.Contains("401") => "Invalid OpenAI API key. Please check your OPENAI_API_KEY.", + var m when m.Contains("429") => "OpenAI rate limit exceeded. Please wait a moment and try again.", + var m when m.Contains("500") || m.Contains("502") || m.Contains("503") => + "OpenAI API server error. Please try again later.", + _ => $"OpenAI API request failed: {ex.Message}" + }; + throw new HttpRequestException(message, ex); + } + } +} diff --git a/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj b/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj index f80ad2a..185414b 100644 --- a/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj +++ b/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj @@ -8,6 +8,7 @@ + From b11d2781f933557c9cd23a7b237c9d8505974552 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:01:10 +0100 Subject: [PATCH 13/26] feat: US-013 - Style analyser service Co-Authored-By: Claude Opus 4.6 --- src/Writegeist.Core/Services/StyleAnalyser.cs | 112 ++++++++++++++++++ src/Writegeist.Core/Writegeist.Core.csproj | 4 + .../Services/StyleAnalyserTests.cs | 112 ++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 src/Writegeist.Core/Services/StyleAnalyser.cs create mode 100644 tests/Writegeist.Tests/Services/StyleAnalyserTests.cs diff --git a/src/Writegeist.Core/Services/StyleAnalyser.cs b/src/Writegeist.Core/Services/StyleAnalyser.cs new file mode 100644 index 0000000..59b2aaa --- /dev/null +++ b/src/Writegeist.Core/Services/StyleAnalyser.cs @@ -0,0 +1,112 @@ +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; + +namespace Writegeist.Core.Services; + +public class StyleAnalyser +{ + private readonly IPostRepository _postRepository; + private readonly IStyleProfileRepository _styleProfileRepository; + private readonly ILlmProvider _llmProvider; + + public StyleAnalyser( + IPostRepository postRepository, + IStyleProfileRepository styleProfileRepository, + ILlmProvider llmProvider) + { + _postRepository = postRepository; + _styleProfileRepository = styleProfileRepository; + _llmProvider = llmProvider; + } + + public async Task AnalyseAsync(int personId) + { + var posts = await _postRepository.GetByPersonIdAsync(personId); + + if (posts.Count == 0) + throw new InvalidOperationException( + "No posts found for this person. Ingest some posts before analysing."); + + var prompt = BuildStyleAnalysisPrompt(posts); + var profileJson = await _llmProvider.AnalyseStyleAsync(prompt); + + var profile = new StyleProfile + { + PersonId = personId, + ProfileJson = profileJson, + Provider = _llmProvider.ProviderName, + Model = _llmProvider.ModelName, + CreatedAt = DateTime.UtcNow + }; + + return await _styleProfileRepository.SaveAsync(profile); + } + + internal static string BuildStyleAnalysisPrompt(IReadOnlyList posts) + { + var postsBlock = string.Join("\n\n---\n\n", posts.Select(p => p.Content)); + + return """ + You are a writing style analyst. Analyse the following social media posts by the same author and produce a detailed, structured style profile in JSON format. + + ## Posts to analyse: + + """ + postsBlock + """ + + + ## Output format (JSON): + + { + "vocabulary": { + "complexity_level": "simple | moderate | advanced", + "jargon_domains": ["tech", "marketing", ...], + "favourite_words": ["word1", "word2", ...], + "words_to_avoid": ["word1", ...], + "filler_phrases": ["to be honest", "at the end of the day", ...] + }, + "sentence_structure": { + "average_length": "short | medium | long", + "variety": "low | moderate | high", + "fragment_usage": true/false, + "question_usage": "none | rare | frequent", + "exclamation_usage": "none | rare | frequent" + }, + "paragraph_structure": { + "average_length_sentences": 1-5, + "uses_single_sentence_paragraphs": true/false, + "uses_line_breaks_for_emphasis": true/false + }, + "tone": { + "formality": "casual | conversational | professional | formal", + "warmth": "cold | neutral | warm | enthusiastic", + "humour": "none | dry | playful | frequent", + "confidence": "hedging | balanced | assertive | provocative" + }, + "rhetorical_patterns": { + "typical_opening": "description of how they start posts", + "typical_closing": "description of how they end posts", + "storytelling_tendency": "none | sometimes | often", + "uses_lists": true/false, + "uses_rhetorical_questions": true/false, + "call_to_action_style": "none | soft | direct" + }, + "formatting": { + "emoji_usage": "none | rare | moderate | heavy", + "emoji_types": ["🚀", "💡", ...], + "hashtag_style": "none | minimal | moderate | heavy", + "capitalisation_quirks": "none | ALL CAPS for emphasis | Title Case headers", + "punctuation_quirks": "description of any unusual punctuation habits" + }, + "content_patterns": { + "typical_topics": ["topic1", "topic2"], + "perspective": "first_person | third_person | mixed", + "self_reference_style": "I | we | the team | name", + "audience_address": "none | you | we | community" + }, + "overall_voice_summary": "A 2-3 sentence summary of this person's writing voice that captures their essence." + } + + Respond with only the JSON object, no other text. + """; + } +} diff --git a/src/Writegeist.Core/Writegeist.Core.csproj b/src/Writegeist.Core/Writegeist.Core.csproj index b760144..7f76689 100644 --- a/src/Writegeist.Core/Writegeist.Core.csproj +++ b/src/Writegeist.Core/Writegeist.Core.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/tests/Writegeist.Tests/Services/StyleAnalyserTests.cs b/tests/Writegeist.Tests/Services/StyleAnalyserTests.cs new file mode 100644 index 0000000..c8b939f --- /dev/null +++ b/tests/Writegeist.Tests/Services/StyleAnalyserTests.cs @@ -0,0 +1,112 @@ +using FluentAssertions; +using NSubstitute; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; +using Writegeist.Core.Services; + +namespace Writegeist.Tests.Services; + +public class StyleAnalyserTests +{ + private readonly IPostRepository _postRepository = Substitute.For(); + private readonly IStyleProfileRepository _styleProfileRepository = Substitute.For(); + private readonly ILlmProvider _llmProvider = Substitute.For(); + private readonly StyleAnalyser _sut; + + public StyleAnalyserTests() + { + _sut = new StyleAnalyser(_postRepository, _styleProfileRepository, _llmProvider); + } + + [Fact] + public async Task AnalyseAsync_WithPosts_SendsPromptToLlmAndStoresProfile() + { + // Arrange + var personId = 1; + var posts = new List + { + new() { Id = 1, PersonId = personId, Content = "First post content", Platform = Platform.LinkedIn }, + new() { Id = 2, PersonId = personId, Content = "Second post content", Platform = Platform.LinkedIn } + }; + + _postRepository.GetByPersonIdAsync(personId).Returns(posts); + _llmProvider.AnalyseStyleAsync(Arg.Any()).Returns("{\"overall_voice_summary\": \"test\"}"); + _llmProvider.ProviderName.Returns("anthropic"); + _llmProvider.ModelName.Returns("claude-sonnet-4-20250514"); + + _styleProfileRepository.SaveAsync(Arg.Any()) + .Returns(callInfo => + { + var profile = callInfo.Arg(); + profile.Id = 42; + return profile; + }); + + // Act + var result = await _sut.AnalyseAsync(personId); + + // Assert + result.Id.Should().Be(42); + result.PersonId.Should().Be(personId); + result.ProfileJson.Should().Be("{\"overall_voice_summary\": \"test\"}"); + result.Provider.Should().Be("anthropic"); + result.Model.Should().Be("claude-sonnet-4-20250514"); + + await _llmProvider.Received(1).AnalyseStyleAsync(Arg.Is(p => + p.Contains("First post content") && p.Contains("Second post content"))); + await _styleProfileRepository.Received(1).SaveAsync(Arg.Any()); + } + + [Fact] + public async Task AnalyseAsync_WithNoPosts_ThrowsInvalidOperationException() + { + // Arrange + _postRepository.GetByPersonIdAsync(1).Returns(new List()); + + // Act + var act = () => _sut.AnalyseAsync(1); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*No posts found*"); + } + + [Fact] + public void BuildStyleAnalysisPrompt_IncludesAllPostContent() + { + // Arrange + var posts = new List + { + new() { Content = "Post one" }, + new() { Content = "Post two" }, + new() { Content = "Post three" } + }; + + // Act + var prompt = StyleAnalyser.BuildStyleAnalysisPrompt(posts); + + // Assert + prompt.Should().Contain("Post one"); + prompt.Should().Contain("Post two"); + prompt.Should().Contain("Post three"); + prompt.Should().Contain("writing style analyst"); + prompt.Should().Contain("overall_voice_summary"); + } + + [Fact] + public void BuildStyleAnalysisPrompt_SeparatesPostsWithDividers() + { + // Arrange + var posts = new List + { + new() { Content = "Alpha" }, + new() { Content = "Beta" } + }; + + // Act + var prompt = StyleAnalyser.BuildStyleAnalysisPrompt(posts); + + // Assert + prompt.Should().Contain("Alpha\n\n---\n\nBeta"); + } +} From e66dc0ddcc9d4bfb62c4644febcb02a7b7ed9500 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:03:30 +0100 Subject: [PATCH 14/26] feat: US-014 - Post generator service Co-Authored-By: Claude Opus 4.6 --- src/Writegeist.Core/Services/PostGenerator.cs | 132 ++++++++++++++ .../Services/PostGeneratorTests.cs | 161 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 src/Writegeist.Core/Services/PostGenerator.cs create mode 100644 tests/Writegeist.Tests/Services/PostGeneratorTests.cs diff --git a/src/Writegeist.Core/Services/PostGenerator.cs b/src/Writegeist.Core/Services/PostGenerator.cs new file mode 100644 index 0000000..aeb7170 --- /dev/null +++ b/src/Writegeist.Core/Services/PostGenerator.cs @@ -0,0 +1,132 @@ +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; + +namespace Writegeist.Core.Services; + +public class PostGenerator +{ + private readonly IStyleProfileRepository _styleProfileRepository; + private readonly IDraftRepository _draftRepository; + private readonly ILlmProvider _llmProvider; + + public PostGenerator( + IStyleProfileRepository styleProfileRepository, + IDraftRepository draftRepository, + ILlmProvider llmProvider) + { + _styleProfileRepository = styleProfileRepository; + _draftRepository = draftRepository; + _llmProvider = llmProvider; + } + + public async Task GenerateAsync(int personId, Platform platform, string topic) + { + var profile = await _styleProfileRepository.GetLatestByPersonIdAsync(personId) + ?? throw new InvalidOperationException( + "No style profile found for this person. Run 'Analyse Style' first."); + + var rules = PlatformConventions.GetRules(platform); + var prompt = BuildGenerationPrompt(profile.ProfileJson, rules, topic); + var content = await _llmProvider.GeneratePostAsync(prompt); + + var draft = new GeneratedDraft + { + PersonId = personId, + StyleProfileId = profile.Id, + Platform = platform, + Topic = topic, + Content = content, + Provider = _llmProvider.ProviderName, + Model = _llmProvider.ModelName, + CreatedAt = DateTime.UtcNow + }; + + return await _draftRepository.SaveAsync(draft); + } + + public async Task RefineAsync(int draftId, string feedback) + { + var previousDraft = await _draftRepository.GetByIdAsync(draftId) + ?? throw new InvalidOperationException("Draft not found."); + + var profile = await _styleProfileRepository.GetLatestByPersonIdAsync(previousDraft.PersonId) + ?? throw new InvalidOperationException("Style profile not found."); + + var rules = PlatformConventions.GetRules(previousDraft.Platform); + var prompt = BuildRefinementPrompt(profile.ProfileJson, rules, previousDraft.Content, feedback); + var content = await _llmProvider.RefinePostAsync(prompt); + + var refined = new GeneratedDraft + { + PersonId = previousDraft.PersonId, + StyleProfileId = profile.Id, + Platform = previousDraft.Platform, + Topic = previousDraft.Topic, + Content = content, + ParentDraftId = draftId, + Feedback = feedback, + Provider = _llmProvider.ProviderName, + Model = _llmProvider.ModelName, + CreatedAt = DateTime.UtcNow + }; + + return await _draftRepository.SaveAsync(refined); + } + + internal static string BuildGenerationPrompt(string styleProfileJson, PlatformRules rules, string topic) + { + return $""" + You are a ghostwriter. Write a {rules.Name} post in the exact writing style described by the style profile below. The post should be about the topic/points provided. + + ## Style Profile: + {styleProfileJson} + + ## Platform Conventions: + - Platform: {rules.Name} + - Character limit: {rules.MaxCharacters?.ToString() ?? "none"} + - Recommended length: {rules.RecommendedMaxLength} characters + - Hashtags: {rules.RecommendedHashtagCount} hashtags, placement: {rules.HashtagPlacement} + - Emoji: {rules.EmojiGuidance} + - Formatting: {rules.FormattingNotes} + + ## Topic / Key Points: + {topic} + + ## Instructions: + - Match the voice, tone, vocabulary, and structural patterns from the style profile exactly + - Follow the platform conventions for formatting, length, and hashtag/emoji usage + - Do NOT add disclaimers, meta-commentary, or explain what you're doing + - Output ONLY the post text, ready to copy and paste + """; + } + + internal static string BuildRefinementPrompt( + string styleProfileJson, PlatformRules rules, string currentDraft, string feedback) + { + return $""" + You are a ghostwriter refining a draft. Adjust the post below according to the feedback while maintaining the original writing style. + + ## Style Profile: + {styleProfileJson} + + ## Platform: {rules.Name} + ## Platform Conventions: + - Character limit: {rules.MaxCharacters?.ToString() ?? "none"} + - Recommended length: {rules.RecommendedMaxLength} characters + - Hashtags: {rules.RecommendedHashtagCount} hashtags, placement: {rules.HashtagPlacement} + - Emoji: {rules.EmojiGuidance} + - Formatting: {rules.FormattingNotes} + + ## Current Draft: + {currentDraft} + + ## Feedback: + {feedback} + + ## Instructions: + - Apply the feedback while staying true to the style profile + - Maintain platform conventions + - Output ONLY the revised post text + """; + } +} diff --git a/tests/Writegeist.Tests/Services/PostGeneratorTests.cs b/tests/Writegeist.Tests/Services/PostGeneratorTests.cs new file mode 100644 index 0000000..f4b461e --- /dev/null +++ b/tests/Writegeist.Tests/Services/PostGeneratorTests.cs @@ -0,0 +1,161 @@ +using FluentAssertions; +using NSubstitute; +using Writegeist.Core; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; +using Writegeist.Core.Services; + +namespace Writegeist.Tests.Services; + +public class PostGeneratorTests +{ + private readonly IStyleProfileRepository _styleProfileRepository = Substitute.For(); + private readonly IDraftRepository _draftRepository = Substitute.For(); + private readonly ILlmProvider _llmProvider = Substitute.For(); + private readonly PostGenerator _sut; + + public PostGeneratorTests() + { + _sut = new PostGenerator(_styleProfileRepository, _draftRepository, _llmProvider); + } + + [Fact] + public async Task GenerateAsync_WithProfile_BuildsPromptAndStoresDraft() + { + // Arrange + var personId = 1; + var profile = new StyleProfile + { + Id = 10, + PersonId = personId, + ProfileJson = "{\"tone\": \"professional\"}" + }; + + _styleProfileRepository.GetLatestByPersonIdAsync(personId).Returns(profile); + _llmProvider.GeneratePostAsync(Arg.Any()).Returns("Generated post content"); + _llmProvider.ProviderName.Returns("anthropic"); + _llmProvider.ModelName.Returns("claude-sonnet-4-20250514"); + + _draftRepository.SaveAsync(Arg.Any()) + .Returns(callInfo => + { + var draft = callInfo.Arg(); + draft.Id = 99; + return draft; + }); + + // Act + var result = await _sut.GenerateAsync(personId, Platform.LinkedIn, "My topic"); + + // Assert + result.Id.Should().Be(99); + result.PersonId.Should().Be(personId); + result.StyleProfileId.Should().Be(10); + result.Platform.Should().Be(Platform.LinkedIn); + result.Topic.Should().Be("My topic"); + result.Content.Should().Be("Generated post content"); + result.ParentDraftId.Should().BeNull(); + result.Provider.Should().Be("anthropic"); + + await _llmProvider.Received(1).GeneratePostAsync(Arg.Is(p => + p.Contains("My topic") && p.Contains("professional") && p.Contains("LinkedIn"))); + } + + [Fact] + public async Task GenerateAsync_WithNoProfile_ThrowsInvalidOperationException() + { + _styleProfileRepository.GetLatestByPersonIdAsync(1).Returns((StyleProfile?)null); + + var act = () => _sut.GenerateAsync(1, Platform.LinkedIn, "topic"); + + await act.Should().ThrowAsync() + .WithMessage("*No style profile found*"); + } + + [Fact] + public async Task RefineAsync_WithExistingDraft_BuildsPromptAndStoresLinkedDraft() + { + // Arrange + var previousDraft = new GeneratedDraft + { + Id = 5, + PersonId = 1, + StyleProfileId = 10, + Platform = Platform.X, + Topic = "Original topic", + Content = "Original draft content" + }; + + var profile = new StyleProfile + { + Id = 10, + PersonId = 1, + ProfileJson = "{\"tone\": \"casual\"}" + }; + + _draftRepository.GetByIdAsync(5).Returns(previousDraft); + _styleProfileRepository.GetLatestByPersonIdAsync(1).Returns(profile); + _llmProvider.RefinePostAsync(Arg.Any()).Returns("Refined post content"); + _llmProvider.ProviderName.Returns("openai"); + _llmProvider.ModelName.Returns("gpt-4o"); + + _draftRepository.SaveAsync(Arg.Any()) + .Returns(callInfo => + { + var draft = callInfo.Arg(); + draft.Id = 6; + return draft; + }); + + // Act + var result = await _sut.RefineAsync(5, "Make it shorter"); + + // Assert + result.Id.Should().Be(6); + result.ParentDraftId.Should().Be(5); + result.Feedback.Should().Be("Make it shorter"); + result.Content.Should().Be("Refined post content"); + result.Platform.Should().Be(Platform.X); + result.Topic.Should().Be("Original topic"); + + await _llmProvider.Received(1).RefinePostAsync(Arg.Is(p => + p.Contains("Original draft content") && p.Contains("Make it shorter") && p.Contains("casual"))); + } + + [Fact] + public async Task RefineAsync_WithNonExistentDraft_ThrowsInvalidOperationException() + { + _draftRepository.GetByIdAsync(999).Returns((GeneratedDraft?)null); + + var act = () => _sut.RefineAsync(999, "feedback"); + + await act.Should().ThrowAsync() + .WithMessage("*Draft not found*"); + } + + [Fact] + public void BuildGenerationPrompt_IncludesAllComponents() + { + var rules = PlatformConventions.GetRules(Platform.LinkedIn); + var prompt = PostGenerator.BuildGenerationPrompt("{\"style\":true}", rules, "My topic here"); + + prompt.Should().Contain("ghostwriter"); + prompt.Should().Contain("{\"style\":true}"); + prompt.Should().Contain("LinkedIn"); + prompt.Should().Contain("My topic here"); + prompt.Should().Contain("3000"); + } + + [Fact] + public void BuildRefinementPrompt_IncludesAllComponents() + { + var rules = PlatformConventions.GetRules(Platform.X); + var prompt = PostGenerator.BuildRefinementPrompt("{\"style\":true}", rules, "Draft text", "Make punchier"); + + prompt.Should().Contain("refining a draft"); + prompt.Should().Contain("{\"style\":true}"); + prompt.Should().Contain("Draft text"); + prompt.Should().Contain("Make punchier"); + prompt.Should().Contain("280"); + } +} From 369cbba5eb61872033f6ec21af7bc3c56ef44883 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:05:16 +0100 Subject: [PATCH 15/26] feat: US-015 - DI registration and appsettings.json Co-Authored-By: Claude Opus 4.6 --- src/Writegeist.Cli/Program.cs | 52 ++++++++++++++++++++++-- src/Writegeist.Cli/Writegeist.Cli.csproj | 6 +++ src/Writegeist.Cli/appsettings.json | 12 ++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/Writegeist.Cli/appsettings.json diff --git a/src/Writegeist.Cli/Program.cs b/src/Writegeist.Cli/Program.cs index 0ea8d4e..f348ff5 100644 --- a/src/Writegeist.Cli/Program.cs +++ b/src/Writegeist.Cli/Program.cs @@ -1,11 +1,57 @@ using InteractiveCLI; using InteractiveCLI.Options; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Writegeist.Cli.Menus; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Services; +using Writegeist.Infrastructure.Fetchers; +using Writegeist.Infrastructure.LlmProviders; +using Writegeist.Infrastructure.Persistence; + +var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); var host = Host.CreateDefaultBuilder(args) - .AddInteractiveCli() - .Build() - .UseInteractiveCli((EmptyOptions _) => new MainMenu(), args); + .AddInteractiveCli(configuration, services => + { + // Database + services.AddSingleton(); + + // Repositories + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // LLM providers + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + { + var config = sp.GetRequiredService(); + var defaultProvider = config["Writegeist:DefaultProvider"] ?? "anthropic"; + return defaultProvider.ToLowerInvariant() switch + { + "openai" => sp.GetRequiredService(), + _ => sp.GetRequiredService() + }; + }); + + // Services + services.AddSingleton(); + services.AddSingleton(); + }) + .Build(); + +// Ensure database is created +var db = InteractiveCliBootstrapper.ServiceProvider.GetRequiredService(); +db.EnsureCreated(); + +host.UseInteractiveCli((EmptyOptions _) => new MainMenu(), args); await host.RunAsync(); diff --git a/src/Writegeist.Cli/Writegeist.Cli.csproj b/src/Writegeist.Cli/Writegeist.Cli.csproj index 51c73ee..a4377d3 100644 --- a/src/Writegeist.Cli/Writegeist.Cli.csproj +++ b/src/Writegeist.Cli/Writegeist.Cli.csproj @@ -17,4 +17,10 @@ enable + + + PreserveNewest + + + diff --git a/src/Writegeist.Cli/appsettings.json b/src/Writegeist.Cli/appsettings.json new file mode 100644 index 0000000..49a3d39 --- /dev/null +++ b/src/Writegeist.Cli/appsettings.json @@ -0,0 +1,12 @@ +{ + "Writegeist": { + "DefaultProvider": "anthropic", + "DatabasePath": "writegeist.db", + "Anthropic": { + "Model": "claude-sonnet-4-20250514" + }, + "OpenAi": { + "Model": "gpt-4o" + } + } +} From 5c7a579f5a5243af1588466a16839ff532844b45 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:06:54 +0100 Subject: [PATCH 16/26] feat: US-016 - Ingest menu and file import action Co-Authored-By: Claude Opus 4.6 --- .../Actions/IngestFromFileAction.cs | 68 +++++++++++++++++++ .../Actions/IngestFromUrlAction.cs | 13 ++++ .../Actions/IngestInteractiveAction.cs | 13 ++++ src/Writegeist.Cli/Menus/IngestMenu.cs | 15 ++++ src/Writegeist.Cli/Menus/MainMenu.cs | 4 +- 5 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 src/Writegeist.Cli/Actions/IngestFromFileAction.cs create mode 100644 src/Writegeist.Cli/Actions/IngestFromUrlAction.cs create mode 100644 src/Writegeist.Cli/Actions/IngestInteractiveAction.cs create mode 100644 src/Writegeist.Cli/Menus/IngestMenu.cs diff --git a/src/Writegeist.Cli/Actions/IngestFromFileAction.cs b/src/Writegeist.Cli/Actions/IngestFromFileAction.cs new file mode 100644 index 0000000..7e5a32f --- /dev/null +++ b/src/Writegeist.Cli/Actions/IngestFromFileAction.cs @@ -0,0 +1,68 @@ +using InteractiveCLI.Actions; +using Spectre.Console; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; +using Writegeist.Infrastructure.Fetchers; + +namespace Writegeist.Cli.Actions; + +public class IngestFromFileAction( + IPersonRepository personRepository, + IPostRepository postRepository) : SingleActionAsync +{ + protected override async Task SingleAsyncAction() + { + var personName = AnsiConsole.Prompt( + new TextPrompt("Person name:")); + + var platform = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Platform:") + .AddChoices(Enum.GetValues())); + + var filePath = AnsiConsole.Prompt( + new TextPrompt("File path:")); + + if (!File.Exists(filePath)) + { + AnsiConsole.MarkupLine("[red]File not found.[/]"); + return; + } + + var person = await personRepository.GetOrCreateAsync(personName); + var fetcher = new ManualFetcher(platform); + var request = new FetchRequest(FilePath: filePath); + + try + { + var posts = await fetcher.FetchPostsAsync(request); + var newCount = 0; + var dupCount = 0; + + foreach (var fetchedPost in posts) + { + var rawPost = new RawPost + { + PersonId = person.Id, + Platform = platform, + Content = fetchedPost.Content, + SourceUrl = fetchedPost.SourceUrl, + FetchedAt = DateTime.UtcNow + }; + + var isNew = await postRepository.AddAsync(rawPost); + if (isNew) newCount++; + else dupCount++; + } + + AnsiConsole.Write(new Panel( + $"[green]Ingested {newCount} new post(s)[/] for [bold]{personName}[/] from {platform}" + + (dupCount > 0 ? $" ([yellow]{dupCount} duplicate(s) skipped[/])" : "")) + .Header("Ingest Complete")); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); + } + } +} diff --git a/src/Writegeist.Cli/Actions/IngestFromUrlAction.cs b/src/Writegeist.Cli/Actions/IngestFromUrlAction.cs new file mode 100644 index 0000000..34b7595 --- /dev/null +++ b/src/Writegeist.Cli/Actions/IngestFromUrlAction.cs @@ -0,0 +1,13 @@ +using InteractiveCLI.Actions; +using Spectre.Console; + +namespace Writegeist.Cli.Actions; + +public class IngestFromUrlAction : SingleActionAsync +{ + protected override Task SingleAsyncAction() + { + AnsiConsole.MarkupLine("[grey]URL/Handle ingestion — not yet implemented.[/]"); + return Task.CompletedTask; + } +} diff --git a/src/Writegeist.Cli/Actions/IngestInteractiveAction.cs b/src/Writegeist.Cli/Actions/IngestInteractiveAction.cs new file mode 100644 index 0000000..13696af --- /dev/null +++ b/src/Writegeist.Cli/Actions/IngestInteractiveAction.cs @@ -0,0 +1,13 @@ +using InteractiveCLI.Actions; +using Spectre.Console; + +namespace Writegeist.Cli.Actions; + +public class IngestInteractiveAction : RepeatableActionAsync +{ + protected override Task RepeatableAsyncAction() + { + AnsiConsole.MarkupLine("[grey]Interactive paste — not yet implemented.[/]"); + return Task.FromResult(true); + } +} diff --git a/src/Writegeist.Cli/Menus/IngestMenu.cs b/src/Writegeist.Cli/Menus/IngestMenu.cs new file mode 100644 index 0000000..07304c7 --- /dev/null +++ b/src/Writegeist.Cli/Menus/IngestMenu.cs @@ -0,0 +1,15 @@ +using InteractiveCLI.Menus; +using Writegeist.Cli.Actions; + +namespace Writegeist.Cli.Menus; + +public class IngestMenu() : Menu(quitable: false, isTopLevel: false) +{ + protected override void BuildMenu() + { + MenuBuilder + .AddMenuItem("From File", "Import posts from a text file (separated by ---)") + .AddMenuItem("Interactive Paste", "Paste posts one at a time") + .AddMenuItem("From URL / Handle", "Fetch posts from a social media URL or handle"); + } +} diff --git a/src/Writegeist.Cli/Menus/MainMenu.cs b/src/Writegeist.Cli/Menus/MainMenu.cs index 0f077d3..423bfd0 100644 --- a/src/Writegeist.Cli/Menus/MainMenu.cs +++ b/src/Writegeist.Cli/Menus/MainMenu.cs @@ -1,5 +1,4 @@ using InteractiveCLI.Menus; -using Writegeist.Cli.Actions; namespace Writegeist.Cli.Menus; @@ -7,6 +6,7 @@ public class MainMenu() : Menu(quitable: true, isTopLevel: true) { protected override void BuildMenu() { - MenuBuilder.AddMenuItem("Placeholder", "Placeholder action"); + MenuBuilder + .AddMenuItem("Ingest Posts", "Import posts from files, URLs, or paste interactively"); } } From bb5bf66cd40d9480bfb5ee6c2f956e9baed0a3c1 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:08:23 +0100 Subject: [PATCH 17/26] feat: US-017 - Interactive paste action Co-Authored-By: Claude Opus 4.6 --- .../Actions/IngestInteractiveAction.cs | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/src/Writegeist.Cli/Actions/IngestInteractiveAction.cs b/src/Writegeist.Cli/Actions/IngestInteractiveAction.cs index 13696af..2e8b5b6 100644 --- a/src/Writegeist.Cli/Actions/IngestInteractiveAction.cs +++ b/src/Writegeist.Cli/Actions/IngestInteractiveAction.cs @@ -1,13 +1,65 @@ using InteractiveCLI.Actions; using Spectre.Console; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; namespace Writegeist.Cli.Actions; -public class IngestInteractiveAction : RepeatableActionAsync +public class IngestInteractiveAction( + IPersonRepository personRepository, + IPostRepository postRepository) : RepeatableActionAsync { - protected override Task RepeatableAsyncAction() + private Person? _person; + private Platform _platform; + private int _newCount; + private int _dupCount; + private bool _isFirstIteration = true; + + protected override async Task RepeatableAsyncAction() { - AnsiConsole.MarkupLine("[grey]Interactive paste — not yet implemented.[/]"); - return Task.FromResult(true); + if (_isFirstIteration) + { + var personName = AnsiConsole.Prompt( + new TextPrompt("Person name:")); + + _platform = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Platform:") + .AddChoices(Enum.GetValues())); + + _person = await personRepository.GetOrCreateAsync(personName); + _isFirstIteration = false; + } + + var content = AnsiConsole.Prompt( + new TextPrompt("Paste post content:")); + + var rawPost = new RawPost + { + PersonId = _person!.Id, + Platform = _platform, + Content = content, + FetchedAt = DateTime.UtcNow + }; + + var isNew = await postRepository.AddAsync(rawPost); + if (isNew) _newCount++; + else _dupCount++; + + var addAnother = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Add another post?") + .AddChoices("Yes", "No")); + + if (addAnother == "No") + { + AnsiConsole.Write(new Panel( + $"[green]Ingested {_newCount} new post(s)[/] for [bold]{_person.Name}[/] from {_platform}" + + (_dupCount > 0 ? $" ([yellow]{_dupCount} duplicate(s) skipped[/])" : "")) + .Header("Ingest Complete")); + return true; + } + + return false; } } From 0cfdfec69c2c09b0ee5ab735cc1217ec71115bc8 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:10:13 +0100 Subject: [PATCH 18/26] feat: US-018 - Analyse style action Co-Authored-By: Claude Opus 4.6 --- src/Writegeist.Cli/Actions/AnalyseAction.cs | 67 +++++++++++++++++++++ src/Writegeist.Cli/Menus/MainMenu.cs | 4 +- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 src/Writegeist.Cli/Actions/AnalyseAction.cs diff --git a/src/Writegeist.Cli/Actions/AnalyseAction.cs b/src/Writegeist.Cli/Actions/AnalyseAction.cs new file mode 100644 index 0000000..6c7d48d --- /dev/null +++ b/src/Writegeist.Cli/Actions/AnalyseAction.cs @@ -0,0 +1,67 @@ +using InteractiveCLI.Actions; +using Microsoft.Extensions.Configuration; +using Spectre.Console; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Services; +using Writegeist.Infrastructure.LlmProviders; + +namespace Writegeist.Cli.Actions; + +public class AnalyseAction( + IPersonRepository personRepository, + IPostRepository postRepository, + IStyleProfileRepository styleProfileRepository, + AnthropicProvider anthropicProvider, + OpenAiProvider openAiProvider, + IConfiguration configuration) : SingleActionAsync +{ + protected override async Task SingleAsyncAction() + { + try + { + var persons = await personRepository.GetAllAsync(); + if (persons.Count == 0) + { + AnsiConsole.MarkupLine("[red]No persons found. Ingest some posts first.[/]"); + return; + } + + var selectedPerson = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select a person:") + .AddChoices(persons.Select(p => p.Name))); + + var person = persons.First(p => p.Name == selectedPerson); + + var defaultProvider = configuration["Writegeist:DefaultProvider"] ?? "anthropic"; + var providerChoices = new[] { "Anthropic", "OpenAI" }; + var defaultChoice = defaultProvider.Equals("openai", StringComparison.OrdinalIgnoreCase) + ? "OpenAI" + : "Anthropic"; + + var selectedProvider = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select LLM provider:") + .AddChoices(providerChoices)); + + ILlmProvider llmProvider = selectedProvider == "OpenAI" + ? openAiProvider + : anthropicProvider; + + var analyser = new StyleAnalyser(postRepository, styleProfileRepository, llmProvider); + + var profile = await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Analysing style...", async _ => + await analyser.AnalyseAsync(person.Id)); + + AnsiConsole.Write(new Panel(profile.ProfileJson) + .Header($"Style Profile for {person.Name}") + .Border(BoxBorder.Rounded)); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); + } + } +} diff --git a/src/Writegeist.Cli/Menus/MainMenu.cs b/src/Writegeist.Cli/Menus/MainMenu.cs index 423bfd0..ba8a535 100644 --- a/src/Writegeist.Cli/Menus/MainMenu.cs +++ b/src/Writegeist.Cli/Menus/MainMenu.cs @@ -1,4 +1,5 @@ using InteractiveCLI.Menus; +using Writegeist.Cli.Actions; namespace Writegeist.Cli.Menus; @@ -7,6 +8,7 @@ public class MainMenu() : Menu(quitable: true, isTopLevel: true) protected override void BuildMenu() { MenuBuilder - .AddMenuItem("Ingest Posts", "Import posts from files, URLs, or paste interactively"); + .AddMenuItem("Ingest Posts", "Import posts from files, URLs, or paste interactively") + .AddMenuItem("Analyse Style", "Analyse posts and build a style profile"); } } From 0eb2d1a0ff1718f7bedd1c973fbaa9b0449b3e82 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:11:51 +0100 Subject: [PATCH 19/26] feat: US-019 - Generate post action Co-Authored-By: Claude Opus 4.6 --- src/Writegeist.Cli/Actions/GenerateAction.cs | 88 ++++++++++++++++++++ src/Writegeist.Cli/Menus/MainMenu.cs | 3 +- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/Writegeist.Cli/Actions/GenerateAction.cs diff --git a/src/Writegeist.Cli/Actions/GenerateAction.cs b/src/Writegeist.Cli/Actions/GenerateAction.cs new file mode 100644 index 0000000..83ac188 --- /dev/null +++ b/src/Writegeist.Cli/Actions/GenerateAction.cs @@ -0,0 +1,88 @@ +using InteractiveCLI.Actions; +using Microsoft.Extensions.Configuration; +using Spectre.Console; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; +using Writegeist.Core.Services; +using Writegeist.Infrastructure.LlmProviders; + +namespace Writegeist.Cli.Actions; + +public class GenerateAction( + IPersonRepository personRepository, + IStyleProfileRepository styleProfileRepository, + IDraftRepository draftRepository, + AnthropicProvider anthropicProvider, + OpenAiProvider openAiProvider, + IConfiguration configuration) : SingleActionAsync +{ + protected override async Task SingleAsyncAction() + { + try + { + var persons = await personRepository.GetAllAsync(); + if (persons.Count == 0) + { + AnsiConsole.MarkupLine("[red]No persons found. Ingest some posts first.[/]"); + return; + } + + // Filter to only persons with a style profile + var personsWithProfiles = new List(); + foreach (var person in persons) + { + var profile = await styleProfileRepository.GetLatestByPersonIdAsync(person.Id); + if (profile is not null) + personsWithProfiles.Add(person); + } + + if (personsWithProfiles.Count == 0) + { + AnsiConsole.MarkupLine("[red]No persons with style profiles found. Run 'Analyse Style' first.[/]"); + return; + } + + var selectedPerson = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select a person:") + .AddChoices(personsWithProfiles.Select(p => p.Name))); + + var person1 = personsWithProfiles.First(p => p.Name == selectedPerson); + + var selectedPlatform = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select target platform:") + .AddChoices(Enum.GetValues())); + + var topic = AnsiConsole.Prompt( + new TextPrompt("Enter topic/key points:")); + + var defaultProvider = configuration["Writegeist:DefaultProvider"] ?? "anthropic"; + var providerChoices = new[] { "Anthropic", "OpenAI" }; + + var selectedProvider = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select LLM provider:") + .AddChoices(providerChoices)); + + ILlmProvider llmProvider = selectedProvider == "OpenAI" + ? openAiProvider + : anthropicProvider; + + var generator = new PostGenerator(styleProfileRepository, draftRepository, llmProvider); + + var draft = await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Generating post...", async _ => + await generator.GenerateAsync(person1.Id, selectedPlatform, topic)); + + AnsiConsole.Write(new Panel(Markup.Escape(draft.Content)) + .Header($"Generated {selectedPlatform} Post") + .Border(BoxBorder.Rounded)); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); + } + } +} diff --git a/src/Writegeist.Cli/Menus/MainMenu.cs b/src/Writegeist.Cli/Menus/MainMenu.cs index ba8a535..0c1bfa3 100644 --- a/src/Writegeist.Cli/Menus/MainMenu.cs +++ b/src/Writegeist.Cli/Menus/MainMenu.cs @@ -9,6 +9,7 @@ protected override void BuildMenu() { MenuBuilder .AddMenuItem("Ingest Posts", "Import posts from files, URLs, or paste interactively") - .AddMenuItem("Analyse Style", "Analyse posts and build a style profile"); + .AddMenuItem("Analyse Style", "Analyse posts and build a style profile") + .AddMenuItem("Generate Post", "Generate a new post in your style"); } } From fa39b3acb452314457f802e67d406f3db6374482 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:13:15 +0100 Subject: [PATCH 20/26] feat: US-020 - Refine draft action Co-Authored-By: Claude Opus 4.6 --- src/Writegeist.Cli/Actions/RefineAction.cs | 75 ++++++++++++++++++++++ src/Writegeist.Cli/Menus/MainMenu.cs | 3 +- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/Writegeist.Cli/Actions/RefineAction.cs diff --git a/src/Writegeist.Cli/Actions/RefineAction.cs b/src/Writegeist.Cli/Actions/RefineAction.cs new file mode 100644 index 0000000..4ad1c9b --- /dev/null +++ b/src/Writegeist.Cli/Actions/RefineAction.cs @@ -0,0 +1,75 @@ +using InteractiveCLI.Actions; +using Spectre.Console; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; +using Writegeist.Core.Services; +using Writegeist.Infrastructure.LlmProviders; + +namespace Writegeist.Cli.Actions; + +public class RefineAction( + IDraftRepository draftRepository, + IStyleProfileRepository styleProfileRepository, + AnthropicProvider anthropicProvider, + OpenAiProvider openAiProvider) : RepeatableActionAsync +{ + private GeneratedDraft? _currentDraft; + private PostGenerator? _generator; + private bool _isFirstIteration = true; + + protected override async Task RepeatableAsyncAction() + { + try + { + if (_isFirstIteration) + { + _currentDraft = await draftRepository.GetLatestAsync(); + if (_currentDraft is null) + { + AnsiConsole.MarkupLine("[red]No drafts found. Generate a post first.[/]"); + return true; + } + + AnsiConsole.Write(new Panel(Markup.Escape(_currentDraft.Content)) + .Header($"Current Draft ({_currentDraft.Platform})") + .Border(BoxBorder.Rounded)); + + var selectedProvider = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select LLM provider:") + .AddChoices("Anthropic", "OpenAI")); + + ILlmProvider llmProvider = selectedProvider == "OpenAI" + ? openAiProvider + : anthropicProvider; + + _generator = new PostGenerator(styleProfileRepository, draftRepository, llmProvider); + _isFirstIteration = false; + } + + var feedback = AnsiConsole.Prompt( + new TextPrompt("Enter feedback:")); + + _currentDraft = await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Refining post...", async _ => + await _generator!.RefineAsync(_currentDraft!.Id, feedback)); + + AnsiConsole.Write(new Panel(Markup.Escape(_currentDraft.Content)) + .Header($"Refined Draft ({_currentDraft.Platform})") + .Border(BoxBorder.Rounded)); + + var refineAgain = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Refine again?") + .AddChoices("Yes", "No")); + + return refineAgain == "No"; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); + return true; + } + } +} diff --git a/src/Writegeist.Cli/Menus/MainMenu.cs b/src/Writegeist.Cli/Menus/MainMenu.cs index 0c1bfa3..b3c105e 100644 --- a/src/Writegeist.Cli/Menus/MainMenu.cs +++ b/src/Writegeist.Cli/Menus/MainMenu.cs @@ -10,6 +10,7 @@ protected override void BuildMenu() MenuBuilder .AddMenuItem("Ingest Posts", "Import posts from files, URLs, or paste interactively") .AddMenuItem("Analyse Style", "Analyse posts and build a style profile") - .AddMenuItem("Generate Post", "Generate a new post in your style"); + .AddMenuItem("Generate Post", "Generate a new post in your style") + .AddMenuItem("Refine Last Draft", "Iteratively refine the last generated draft with feedback"); } } From f87f4c300d7df4507487a30cbbc1856bb1043541 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:15:02 +0100 Subject: [PATCH 21/26] feat: US-021 - Profile menu and actions Co-Authored-By: Claude Opus 4.6 --- .../Actions/ListProfilesAction.cs | 53 ++++++++++++ .../Actions/ShowProfileAction.cs | 84 +++++++++++++++++++ src/Writegeist.Cli/Menus/MainMenu.cs | 3 +- src/Writegeist.Cli/Menus/ProfileMenu.cs | 14 ++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/Writegeist.Cli/Actions/ListProfilesAction.cs create mode 100644 src/Writegeist.Cli/Actions/ShowProfileAction.cs create mode 100644 src/Writegeist.Cli/Menus/ProfileMenu.cs diff --git a/src/Writegeist.Cli/Actions/ListProfilesAction.cs b/src/Writegeist.Cli/Actions/ListProfilesAction.cs new file mode 100644 index 0000000..e6da160 --- /dev/null +++ b/src/Writegeist.Cli/Actions/ListProfilesAction.cs @@ -0,0 +1,53 @@ +using InteractiveCLI.Actions; +using Spectre.Console; +using Writegeist.Core.Interfaces; + +namespace Writegeist.Cli.Actions; + +public class ListProfilesAction( + IPersonRepository personRepository, + IStyleProfileRepository styleProfileRepository) : SingleActionAsync +{ + protected override async Task SingleAsyncAction() + { + try + { + var persons = await personRepository.GetAllAsync(); + var table = new Table() + .Border(TableBorder.Rounded) + .Title("Style Profiles") + .AddColumn("Name") + .AddColumn("Provider") + .AddColumn("Model") + .AddColumn("Created"); + + var hasProfiles = false; + + foreach (var person in persons) + { + var profile = await styleProfileRepository.GetLatestByPersonIdAsync(person.Id); + if (profile is not null) + { + hasProfiles = true; + table.AddRow( + Markup.Escape(person.Name), + Markup.Escape(profile.Provider), + Markup.Escape(profile.Model), + profile.CreatedAt.ToString("g")); + } + } + + if (!hasProfiles) + { + AnsiConsole.MarkupLine("[yellow]No style profiles found. Run 'Analyse Style' first.[/]"); + return; + } + + AnsiConsole.Write(table); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); + } + } +} diff --git a/src/Writegeist.Cli/Actions/ShowProfileAction.cs b/src/Writegeist.Cli/Actions/ShowProfileAction.cs new file mode 100644 index 0000000..3dcbb93 --- /dev/null +++ b/src/Writegeist.Cli/Actions/ShowProfileAction.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using InteractiveCLI.Actions; +using Spectre.Console; +using Writegeist.Core.Interfaces; + +namespace Writegeist.Cli.Actions; + +public class ShowProfileAction( + IPersonRepository personRepository, + IStyleProfileRepository styleProfileRepository) : SingleActionAsync +{ + protected override async Task SingleAsyncAction() + { + try + { + var persons = await personRepository.GetAllAsync(); + if (persons.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No persons found. Ingest some posts first.[/]"); + return; + } + + // Filter to persons with profiles + var personsWithProfiles = new List<(string Name, int Id)>(); + foreach (var person in persons) + { + var profile = await styleProfileRepository.GetLatestByPersonIdAsync(person.Id); + if (profile is not null) + personsWithProfiles.Add((person.Name, person.Id)); + } + + if (personsWithProfiles.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No style profiles found. Run 'Analyse Style' first.[/]"); + return; + } + + var selectedName = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select a person:") + .AddChoices(personsWithProfiles.Select(p => p.Name))); + + var personId = personsWithProfiles.First(p => p.Name == selectedName).Id; + var latestProfile = (await styleProfileRepository.GetLatestByPersonIdAsync(personId))!; + + // Try to parse and display as structured table + try + { + var json = JsonDocument.Parse(latestProfile.ProfileJson); + var table = new Table() + .Border(TableBorder.Rounded) + .Title($"Style Profile for {selectedName}") + .AddColumn("Section") + .AddColumn("Details"); + + foreach (var property in json.RootElement.EnumerateObject()) + { + var value = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() ?? "" + : property.Value.GetRawText(); + + table.AddRow( + Markup.Escape(property.Name), + Markup.Escape(value)); + } + + AnsiConsole.Write(table); + } + catch (JsonException) + { + // Not valid JSON — display as raw text in a panel + AnsiConsole.Write(new Panel(Markup.Escape(latestProfile.ProfileJson)) + .Header($"Style Profile for {selectedName}") + .Border(BoxBorder.Rounded)); + } + + AnsiConsole.MarkupLine($"[dim]Provider: {Markup.Escape(latestProfile.Provider)} | Model: {Markup.Escape(latestProfile.Model)} | Created: {latestProfile.CreatedAt:g}[/]"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); + } + } +} diff --git a/src/Writegeist.Cli/Menus/MainMenu.cs b/src/Writegeist.Cli/Menus/MainMenu.cs index b3c105e..962e26e 100644 --- a/src/Writegeist.Cli/Menus/MainMenu.cs +++ b/src/Writegeist.Cli/Menus/MainMenu.cs @@ -11,6 +11,7 @@ protected override void BuildMenu() .AddMenuItem("Ingest Posts", "Import posts from files, URLs, or paste interactively") .AddMenuItem("Analyse Style", "Analyse posts and build a style profile") .AddMenuItem("Generate Post", "Generate a new post in your style") - .AddMenuItem("Refine Last Draft", "Iteratively refine the last generated draft with feedback"); + .AddMenuItem("Refine Last Draft", "Iteratively refine the last generated draft with feedback") + .AddMenuItem("Profiles", "View and list style profiles"); } } diff --git a/src/Writegeist.Cli/Menus/ProfileMenu.cs b/src/Writegeist.Cli/Menus/ProfileMenu.cs new file mode 100644 index 0000000..43b5495 --- /dev/null +++ b/src/Writegeist.Cli/Menus/ProfileMenu.cs @@ -0,0 +1,14 @@ +using InteractiveCLI.Menus; +using Writegeist.Cli.Actions; + +namespace Writegeist.Cli.Menus; + +public class ProfileMenu() : Menu(quitable: false, isTopLevel: false) +{ + protected override void BuildMenu() + { + MenuBuilder + .AddMenuItem("Show Profile", "View the full style profile for a person") + .AddMenuItem("List All Profiles", "List all persons with style profiles"); + } +} From 2a59adcc6ad22f8d5ede621845e3a8510200cdf1 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:16:16 +0100 Subject: [PATCH 22/26] feat: US-022 - Stub fetchers for unsupported platforms Co-Authored-By: Claude Opus 4.6 --- .../Fetchers/FacebookFetcher.cs | 16 ++++++++++++++++ .../Fetchers/InstagramFetcher.cs | 16 ++++++++++++++++ .../Fetchers/LinkedInFetcher.cs | 16 ++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 src/Writegeist.Infrastructure/Fetchers/FacebookFetcher.cs create mode 100644 src/Writegeist.Infrastructure/Fetchers/InstagramFetcher.cs create mode 100644 src/Writegeist.Infrastructure/Fetchers/LinkedInFetcher.cs diff --git a/src/Writegeist.Infrastructure/Fetchers/FacebookFetcher.cs b/src/Writegeist.Infrastructure/Fetchers/FacebookFetcher.cs new file mode 100644 index 0000000..4ede532 --- /dev/null +++ b/src/Writegeist.Infrastructure/Fetchers/FacebookFetcher.cs @@ -0,0 +1,16 @@ +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; + +namespace Writegeist.Infrastructure.Fetchers; + +public class FacebookFetcher : IContentFetcher +{ + public Platform Platform => Platform.Facebook; + + public Task> FetchPostsAsync(FetchRequest request) + { + throw new NotSupportedException( + "Automated fetching is not available for Facebook. " + + "Please use 'From File' or 'Interactive Paste' to import your Facebook posts manually."); + } +} diff --git a/src/Writegeist.Infrastructure/Fetchers/InstagramFetcher.cs b/src/Writegeist.Infrastructure/Fetchers/InstagramFetcher.cs new file mode 100644 index 0000000..91f5b4f --- /dev/null +++ b/src/Writegeist.Infrastructure/Fetchers/InstagramFetcher.cs @@ -0,0 +1,16 @@ +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; + +namespace Writegeist.Infrastructure.Fetchers; + +public class InstagramFetcher : IContentFetcher +{ + public Platform Platform => Platform.Instagram; + + public Task> FetchPostsAsync(FetchRequest request) + { + throw new NotSupportedException( + "Automated fetching is not available for Instagram. " + + "Please use 'From File' or 'Interactive Paste' to import your Instagram posts manually."); + } +} diff --git a/src/Writegeist.Infrastructure/Fetchers/LinkedInFetcher.cs b/src/Writegeist.Infrastructure/Fetchers/LinkedInFetcher.cs new file mode 100644 index 0000000..37a7e42 --- /dev/null +++ b/src/Writegeist.Infrastructure/Fetchers/LinkedInFetcher.cs @@ -0,0 +1,16 @@ +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; + +namespace Writegeist.Infrastructure.Fetchers; + +public class LinkedInFetcher : IContentFetcher +{ + public Platform Platform => Platform.LinkedIn; + + public Task> FetchPostsAsync(FetchRequest request) + { + throw new NotSupportedException( + "Automated fetching is not available for LinkedIn. " + + "Please use 'From File' or 'Interactive Paste' to import your LinkedIn posts manually."); + } +} From e8c18a679528f6c70914becc0997813bd1b323d5 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:23:55 +0100 Subject: [PATCH 23/26] feat: US-023 - Ingest from URL action and fetcher dispatch Co-Authored-By: Claude Opus 4.6 --- .../Actions/IngestFromUrlAction.cs | 68 +++++++++++++++++-- src/Writegeist.Cli/Program.cs | 5 ++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/Writegeist.Cli/Actions/IngestFromUrlAction.cs b/src/Writegeist.Cli/Actions/IngestFromUrlAction.cs index 34b7595..e6af102 100644 --- a/src/Writegeist.Cli/Actions/IngestFromUrlAction.cs +++ b/src/Writegeist.Cli/Actions/IngestFromUrlAction.cs @@ -1,13 +1,73 @@ using InteractiveCLI.Actions; using Spectre.Console; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; namespace Writegeist.Cli.Actions; -public class IngestFromUrlAction : SingleActionAsync +public class IngestFromUrlAction( + IPersonRepository personRepository, + IPostRepository postRepository, + IEnumerable fetchers) : SingleActionAsync { - protected override Task SingleAsyncAction() + protected override async Task SingleAsyncAction() { - AnsiConsole.MarkupLine("[grey]URL/Handle ingestion — not yet implemented.[/]"); - return Task.CompletedTask; + var personName = AnsiConsole.Prompt( + new TextPrompt("Person name:")); + + var platform = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Platform:") + .AddChoices(Enum.GetValues())); + + var urlOrHandle = AnsiConsole.Prompt( + new TextPrompt("URL or handle:")); + + var person = await personRepository.GetOrCreateAsync(personName); + + var fetcher = fetchers.FirstOrDefault(f => f.Platform == platform); + if (fetcher is null) + { + AnsiConsole.MarkupLine($"[yellow]No fetcher available for {platform}.[/]"); + return; + } + + var request = new FetchRequest(Url: urlOrHandle, Handle: urlOrHandle); + + try + { + var posts = await fetcher.FetchPostsAsync(request); + var newCount = 0; + var dupCount = 0; + + foreach (var fetchedPost in posts) + { + var rawPost = new RawPost + { + PersonId = person.Id, + Platform = platform, + Content = fetchedPost.Content, + SourceUrl = fetchedPost.SourceUrl, + FetchedAt = DateTime.UtcNow + }; + + var isNew = await postRepository.AddAsync(rawPost); + if (isNew) newCount++; + else dupCount++; + } + + AnsiConsole.Write(new Panel( + $"[green]Ingested {newCount} new post(s)[/] for [bold]{Markup.Escape(personName)}[/] from {platform}" + + (dupCount > 0 ? $" ([yellow]{dupCount} duplicate(s) skipped[/])" : "")) + .Header("Ingest Complete")); + } + catch (NotSupportedException ex) + { + AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(ex.Message)}[/]"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); + } } } diff --git a/src/Writegeist.Cli/Program.cs b/src/Writegeist.Cli/Program.cs index f348ff5..7484f81 100644 --- a/src/Writegeist.Cli/Program.cs +++ b/src/Writegeist.Cli/Program.cs @@ -42,6 +42,11 @@ }; }); + // Fetchers + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + // Services services.AddSingleton(); services.AddSingleton(); From 2c62eb6163d8817207b2de059de05c7e7fa6ad96 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:26:23 +0100 Subject: [PATCH 24/26] feat: US-024 - X/Twitter API fetcher Co-Authored-By: Claude Opus 4.6 --- .../Fetchers/XTwitterFetcher.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/Writegeist.Infrastructure/Fetchers/XTwitterFetcher.cs diff --git a/src/Writegeist.Infrastructure/Fetchers/XTwitterFetcher.cs b/src/Writegeist.Infrastructure/Fetchers/XTwitterFetcher.cs new file mode 100644 index 0000000..ab8b8ef --- /dev/null +++ b/src/Writegeist.Infrastructure/Fetchers/XTwitterFetcher.cs @@ -0,0 +1,88 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Writegeist.Core.Interfaces; +using Writegeist.Core.Models; + +namespace Writegeist.Infrastructure.Fetchers; + +public class XTwitterFetcher(HttpClient httpClient, IConfiguration configuration) : IContentFetcher +{ + private const string BaseUrl = "https://api.x.com/2"; + private const int MaxResults = 100; + + public Platform Platform => Platform.X; + + public async Task> FetchPostsAsync(FetchRequest request) + { + var bearerToken = configuration["X_BEARER_TOKEN"] + ?? throw new InvalidOperationException( + "X/Twitter Bearer Token is not configured. " + + "Set the X_BEARER_TOKEN environment variable, or use 'From File' or 'Interactive Paste' to import posts manually."); + + var handle = (request.Handle ?? request.Url)?.TrimStart('@') + ?? throw new ArgumentException("A handle or URL is required to fetch tweets.", nameof(request)); + + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + + var userId = await ResolveUserIdAsync(handle); + return await FetchTweetsAsync(userId); + } + + private async Task ResolveUserIdAsync(string handle) + { + var response = await httpClient.GetAsync($"{BaseUrl}/users/by/username/{handle}"); + + if ((int)response.StatusCode == 429) + throw new HttpRequestException( + "X API rate limit exceeded. Please wait a few minutes and try again."); + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + if (doc.RootElement.TryGetProperty("errors", out var errors)) + { + var message = errors[0].GetProperty("detail").GetString(); + throw new HttpRequestException($"X API error: {message}"); + } + + return doc.RootElement.GetProperty("data").GetProperty("id").GetString() + ?? throw new HttpRequestException("Could not resolve X user ID."); + } + + private async Task> FetchTweetsAsync(string userId) + { + var url = $"{BaseUrl}/users/{userId}/tweets?max_results={MaxResults}&tweet.fields=created_at"; + var response = await httpClient.GetAsync(url); + + if ((int)response.StatusCode == 429) + throw new HttpRequestException( + "X API rate limit exceeded. Please wait a few minutes and try again."); + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("data", out var data)) + return []; + + var posts = new List(); + foreach (var tweet in data.EnumerateArray()) + { + var content = tweet.GetProperty("text").GetString() ?? string.Empty; + var tweetId = tweet.GetProperty("id").GetString(); + var sourceUrl = tweetId is not null ? $"https://x.com/i/status/{tweetId}" : null; + + DateTime? publishedAt = null; + if (tweet.TryGetProperty("created_at", out var createdAt)) + publishedAt = DateTime.Parse(createdAt.GetString()!); + + posts.Add(new FetchedPost(content, sourceUrl, publishedAt)); + } + + return posts; + } +} From 3108575a783e2455544eab30e15df02522cc4837 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:33:22 +0100 Subject: [PATCH 25/26] chore: add gitignore rules, ralph scripts, docs, and PRD - Add .DS_Store and .last-branch to gitignore - Include ralph scripts, prompt, CLAUDE.md, AGENTS.md, and archive - Add implementation plan and PRD docs - Update progress log and prd.json with completed stories - Register fetchers in DI (Program.cs) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 6 + docs/writegeist-implementation-plan.md | 668 ++++++++++++++++++ scripts/ralph/AGENTS.md | 30 + scripts/ralph/CLAUDE.md | 6 +- .../2026-04-04-colorado-pain-scale/prd.json | 445 ++++++++++++ .../progress.txt | 118 ++++ scripts/ralph/prd.json | 46 +- scripts/ralph/progress.txt | 184 ++++- scripts/ralph/prompt.md | 108 +++ scripts/ralph/ralph.sh | 146 ++++ src/Writegeist.Cli/Program.cs | 1 + tasks/prd-writegeist.md | 277 ++++++++ 12 files changed, 2008 insertions(+), 27 deletions(-) create mode 100644 docs/writegeist-implementation-plan.md create mode 100644 scripts/ralph/AGENTS.md create mode 100644 scripts/ralph/archive/2026-04-04-colorado-pain-scale/prd.json create mode 100644 scripts/ralph/archive/2026-04-04-colorado-pain-scale/progress.txt create mode 100644 scripts/ralph/prompt.md create mode 100755 scripts/ralph/ralph.sh create mode 100644 tasks/prd-writegeist.md diff --git a/.gitignore b/.gitignore index ce89292..065916a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,12 @@ *.sln.docstates *.env +# macOS +.DS_Store + +# Ralph +scripts/ralph/.last-branch + # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/docs/writegeist-implementation-plan.md b/docs/writegeist-implementation-plan.md new file mode 100644 index 0000000..bbc7520 --- /dev/null +++ b/docs/writegeist-implementation-plan.md @@ -0,0 +1,668 @@ +# Writegeist — Implementation Plan + +> A .NET CLI tool that clones a person's writing style from public social media posts and generates platform-specific content on demand. + +--- + +## 1. Overview + +Writegeist ingests public social media posts (LinkedIn, X/Twitter, Instagram, Facebook), analyses the author's writing style into a reusable profile, and generates new posts in that style for any supported platform. It supports both Anthropic (Claude) and OpenAI APIs as the LLM backend. + +### Core Workflow + +1. **Ingest** — Fetch public posts from a social profile URL/handle, or manually paste/import from a file. +2. **Analyse** — Send all ingested posts for a person to the LLM to extract a structured style profile. Store the profile in SQLite. +3. **Generate** — Given bullet points or a topic + a target platform, produce a single draft post using the style profile and platform-specific conventions. +4. **Refine** — Iterate on the last generated draft with natural language feedback, preserving style consistency. + +--- + +## 2. Tech Stack + +| Concern | Choice | +|---------|--------| +| Runtime | .NET 10 (latest preview or stable, whichever is current) | +| Project type | Console app using `DevJonny.InteractiveCli` (interactive menu-driven CLI) | +| CLI framework | `DevJonny.InteractiveCli` — menu/action based interactive CLI built on Spectre.Console + CommandLineParser | +| Database | SQLite via `Microsoft.Data.Sqlite` + Dapper or raw ADO.NET | +| HTTP | `HttpClient` via `IHttpClientFactory` | +| HTML parsing (scraping) | `AngleSharp` | +| JSON serialisation | `System.Text.Json` (STJ) | +| LLM: Anthropic | `Anthropic` NuGet package (official C# SDK) — verify this exists; if not, use raw HTTP to `https://api.anthropic.com/v1/messages` | +| LLM: OpenAI | `OpenAI` NuGet package (official) or `Azure.AI.OpenAI` | +| DI | `Microsoft.Extensions.DependencyInjection` + `Microsoft.Extensions.Hosting` (Generic Host) — provided by InteractiveCli | +| Configuration | `Microsoft.Extensions.Configuration` (appsettings.json + env vars + user-secrets) | +| Logging | `Serilog` (provided by InteractiveCli) | + +--- + +## 3. Solution Structure + +``` +Writegeist/ +├── Writegeist.sln +├── src/ +│ ├── Writegeist.Cli/ # Console app entry point +│ │ ├── Program.cs # Host builder + InteractiveCli bootstrapping +│ │ ├── Menus/ +│ │ │ ├── MainMenu.cs # Top-level menu (isTopLevel: true) +│ │ │ ├── IngestMenu.cs # Sub-menu for ingestion options +│ │ │ └── ProfileMenu.cs # Sub-menu for profile management +│ │ ├── Actions/ +│ │ │ ├── IngestFromFileAction.cs # SingleActionAsync — ingest from file +│ │ │ ├── IngestInteractiveAction.cs # RepeatableActionAsync — paste posts interactively +│ │ │ ├── IngestFromUrlAction.cs # SingleActionAsync — ingest from URL/handle +│ │ │ ├── AnalyseAction.cs # SingleActionAsync — analyse style +│ │ │ ├── GenerateAction.cs # SingleActionAsync — generate a post +│ │ │ ├── RefineAction.cs # RepeatableActionAsync — refine loop +│ │ │ ├── ShowProfileAction.cs # SingleActionAsync — display a profile +│ │ │ └── ListProfilesAction.cs # SingleActionAsync — list all profiles +│ │ └── appsettings.json +│ │ +│ ├── Writegeist.Core/ # Domain logic, interfaces, models +│ │ ├── Models/ +│ │ │ ├── Person.cs +│ │ │ ├── RawPost.cs +│ │ │ ├── StyleProfile.cs +│ │ │ ├── GeneratedDraft.cs +│ │ │ └── Platform.cs # enum: LinkedIn, X, Instagram, Facebook +│ │ ├── Interfaces/ +│ │ │ ├── IContentFetcher.cs # Per-platform content ingestion +│ │ │ ├── ILlmProvider.cs # Analyse / Generate / Refine +│ │ │ ├── IStyleProfileRepository.cs +│ │ │ ├── IPostRepository.cs +│ │ │ └── IPersonRepository.cs +│ │ └── Services/ +│ │ ├── StyleAnalyser.cs # Orchestrates style profile creation +│ │ ├── PostGenerator.cs # Orchestrates post generation +│ │ └── PlatformConventions.cs # Platform-specific rules +│ │ +│ ├── Writegeist.Infrastructure/ # Implementations +│ │ ├── Fetchers/ +│ │ │ ├── LinkedInFetcher.cs +│ │ │ ├── XTwitterFetcher.cs +│ │ │ ├── InstagramFetcher.cs +│ │ │ ├── FacebookFetcher.cs +│ │ │ └── ManualFetcher.cs # File/interactive paste +│ │ ├── LlmProviders/ +│ │ │ ├── AnthropicProvider.cs +│ │ │ └── OpenAiProvider.cs +│ │ └── Persistence/ +│ │ ├── SqliteDatabase.cs # Schema init + migrations +│ │ ├── SqlitePersonRepository.cs +│ │ ├── SqlitePostRepository.cs +│ │ └── SqliteStyleProfileRepository.cs +│ │ +│ └── Writegeist.Tests/ # xUnit test project +│ ├── Services/ +│ │ ├── StyleAnalyserTests.cs +│ │ ├── PostGeneratorTests.cs +│ │ └── PlatformConventionsTests.cs +│ ├── Fetchers/ +│ │ └── ManualFetcherTests.cs +│ └── Persistence/ +│ └── SqliteRepositoryTests.cs +``` + +--- + +## 4. Interactive CLI Design + +Writegeist uses `DevJonny.InteractiveCli` (NuGet: `DevJonny.InteractiveCli`) which provides a menu-driven interactive experience built on Spectre.Console. Instead of traditional CLI commands with flags, each workflow step is an **action** presented via interactive menus. User input (person name, platform, topic, etc.) is collected via Spectre.Console prompts within each action. + +### 4.1 Menu Structure + +``` +Main Menu +├── Ingest Posts → IngestMenu +│ ├── From File → IngestFromFileAction +│ ├── From URL / Handle → IngestFromUrlAction +│ ├── Interactive Paste → IngestInteractiveAction +│ └── Back +├── Analyse Style → AnalyseAction +├── Generate Post → GenerateAction +├── Refine Last Draft → RefineAction +├── Profiles → ProfileMenu +│ ├── Show Profile → ShowProfileAction +│ ├── List All Profiles → ListProfilesAction +│ └── Back +└── Quit +``` + +### 4.2 Program.cs (Entry Point) + +```csharp +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using InteractiveCLI; +using Serilog; +using Writegeist.Cli.Menus; + +var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .AddEnvironmentVariables() + .Build(); + +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .CreateBootstrapLogger(); + +var host = + Host.CreateDefaultBuilder() + .AddInteractiveCli(configuration, services => + { + // Register Writegeist-specific services (repositories, LLM providers, fetchers, etc.) + }) + .Build(); + +host.UseInteractiveCli(_ => new MainMenu(), args); +``` + +### 4.3 Actions Detail + +Each action extends `SingleActionAsync` or `RepeatableActionAsync` from InteractiveCLI and collects input via Spectre.Console prompts. + +#### IngestFromFileAction (SingleActionAsync) + +Prompts for: +- Person name (text prompt) +- Platform (selection prompt: LinkedIn, X, Instagram, Facebook) +- File path (text prompt) + +Behaviour: +- Creates the person in SQLite if they don't exist. +- Reads the file, splits on `---` separators. +- Stores each post as a `RawPost` with SHA-256 content hash for dedup. +- Prints summary: "Ingested 14 new posts for John Smith from LinkedIn (3 duplicates skipped)." + +#### IngestInteractiveAction (RepeatableActionAsync) + +Prompts for: +- Person name (text prompt, on first iteration) +- Platform (selection prompt, on first iteration) +- Post content (multi-line text prompt) + +Returns `false` to keep looping, `true` when user confirms they're done. Stores each post as it's entered. + +#### IngestFromUrlAction (SingleActionAsync) + +Prompts for: +- Person name (text prompt) +- Platform (selection prompt) +- URL or handle (text prompt) + +Dispatches to the appropriate `IContentFetcher`. If the platform doesn't support automated fetching, displays a warning and suggests using File or Interactive instead. + +#### AnalyseAction (SingleActionAsync) + +Prompts for: +- Person name (selection prompt from existing persons) +- LLM provider (selection prompt: Anthropic, OpenAI — defaults from config) + +Behaviour: +- Loads all `RawPost` records for the person. +- Sends them to the LLM with the style analysis prompt (see §7.1). +- Stores the resulting `StyleProfile` JSON in SQLite, versioned with a timestamp. +- Prints the profile summary to the console. + +#### GenerateAction (SingleActionAsync) + +Prompts for: +- Person name (selection prompt from persons with a style profile) +- Target platform (selection prompt) +- Topic/key points (multi-line text prompt, or file path option) +- LLM provider (selection prompt, optional override) + +Behaviour: +- Loads the latest `StyleProfile` for the person. +- Loads platform conventions for the target platform (see §6). +- Sends the generation prompt (see §7.2) to the LLM. +- Stores the result as a `GeneratedDraft` in SQLite. +- Prints the generated post to stdout. + +#### RefineAction (RepeatableActionAsync) + +Prompts for: +- Feedback (text prompt) + +Behaviour: +- Loads the most recent `GeneratedDraft` (and its associated style profile + platform). +- Sends the refinement prompt (see §7.3) with the previous draft + feedback. +- Stores the new version as a `GeneratedDraft` linked to the previous one. +- Prints the refined post. +- Asks "Refine again?" — returns `true` to stop, `false` to continue looping. + +#### ShowProfileAction (SingleActionAsync) + +Prompts for: +- Person name (selection prompt) + +Displays the full style profile in a formatted table. + +#### ListProfilesAction (SingleActionAsync) + +Lists all persons with style profiles in a table. + +--- + +## 5. Data Model (SQLite) + +### Schema + +```sql +CREATE TABLE IF NOT EXISTS persons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS raw_posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL REFERENCES persons(id), + platform TEXT NOT NULL, -- 'linkedin', 'x', 'instagram', 'facebook' + content TEXT NOT NULL, + content_hash TEXT NOT NULL, -- SHA-256 for dedup + source_url TEXT, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(person_id, content_hash) +); + +CREATE TABLE IF NOT EXISTS style_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL REFERENCES persons(id), + profile_json TEXT NOT NULL, -- Structured style profile as JSON + provider TEXT NOT NULL, -- 'anthropic' or 'openai' + model TEXT NOT NULL, -- e.g. 'claude-sonnet-4-20250514' + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS generated_drafts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL REFERENCES persons(id), + style_profile_id INTEGER NOT NULL REFERENCES style_profiles(id), + platform TEXT NOT NULL, + topic TEXT NOT NULL, + content TEXT NOT NULL, + parent_draft_id INTEGER REFERENCES generated_drafts(id), -- NULL for first draft, set for refinements + feedback TEXT, -- refinement feedback (NULL for first draft) + provider TEXT NOT NULL, + model TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +Run schema creation on first use via `SqliteDatabase.EnsureCreated()`. + +--- + +## 6. Platform Conventions + +Encapsulate in `PlatformConventions.cs` as a static lookup. Each platform defines: + +```csharp +public record PlatformRules( + string Name, + int? MaxCharacters, // null = no hard limit + int RecommendedMaxLength, // soft target in characters + bool SupportsHashtags, + int RecommendedHashtagCount, // 0 if not typical + string HashtagPlacement, // "inline", "end", "none" + bool SupportsEmoji, + string EmojiGuidance, // "sparingly", "freely", "none" + string ToneGuidance, // platform-typical tone advice + string FormattingNotes // line breaks, paragraphs, etc. +); +``` + +### Platform defaults: + +**LinkedIn:** +- Max: 3,000 characters +- Recommended: 1,200–1,800 chars +- Hashtags: 3–5 at the end +- Emoji: sparingly (bullet replacements, single opener) +- Tone: professional but personal, thought-leadership +- Formatting: short paragraphs (1–2 sentences), liberal line breaks, hook in first line + +**X/Twitter:** +- Max: 280 characters (single tweet) or 25,000 (long-form post) +- Default to 280 unless topic clearly needs a thread +- Hashtags: 1–2 inline +- Emoji: moderate +- Tone: conversational, concise, punchy +- Formatting: no paragraph breaks in single tweets + +**Instagram:** +- Max: 2,200 characters +- Recommended: 500–1,000 chars +- Hashtags: up to 30, in a separate block at the end (after line breaks) +- Emoji: freely +- Tone: casual, visual-language, storytelling +- Formatting: line breaks for readability, can use dot separators for spacing + +**Facebook:** +- Max: 63,206 characters +- Recommended: 400–800 chars +- Hashtags: 1–3 or none +- Emoji: moderate +- Tone: conversational, community-oriented +- Formatting: short paragraphs, conversational + +--- + +## 7. LLM Prompts + +### 7.1 Style Analysis Prompt + +``` +You are a writing style analyst. Analyse the following social media posts by the same author and produce a detailed, structured style profile in JSON format. + +## Posts to analyse: + +{posts_block} + +## Output format (JSON): + +{ + "vocabulary": { + "complexity_level": "simple | moderate | advanced", + "jargon_domains": ["tech", "marketing", ...], + "favourite_words": ["word1", "word2", ...], + "words_to_avoid": ["word1", ...], + "filler_phrases": ["to be honest", "at the end of the day", ...] + }, + "sentence_structure": { + "average_length": "short | medium | long", + "variety": "low | moderate | high", + "fragment_usage": true/false, + "question_usage": "none | rare | frequent", + "exclamation_usage": "none | rare | frequent" + }, + "paragraph_structure": { + "average_length_sentences": 1-5, + "uses_single_sentence_paragraphs": true/false, + "uses_line_breaks_for_emphasis": true/false + }, + "tone": { + "formality": "casual | conversational | professional | formal", + "warmth": "cold | neutral | warm | enthusiastic", + "humour": "none | dry | playful | frequent", + "confidence": "hedging | balanced | assertive | provocative" + }, + "rhetorical_patterns": { + "typical_opening": "description of how they start posts", + "typical_closing": "description of how they end posts", + "storytelling_tendency": "none | sometimes | often", + "uses_lists": true/false, + "uses_rhetorical_questions": true/false, + "call_to_action_style": "none | soft | direct" + }, + "formatting": { + "emoji_usage": "none | rare | moderate | heavy", + "emoji_types": ["🚀", "💡", ...], + "hashtag_style": "none | minimal | moderate | heavy", + "capitalisation_quirks": "none | ALL CAPS for emphasis | Title Case headers", + "punctuation_quirks": "description of any unusual punctuation habits" + }, + "content_patterns": { + "typical_topics": ["topic1", "topic2"], + "perspective": "first_person | third_person | mixed", + "self_reference_style": "I | we | the team | name", + "audience_address": "none | you | we | community" + }, + "overall_voice_summary": "A 2-3 sentence summary of this person's writing voice that captures their essence." +} + +Respond with only the JSON object, no other text. +``` + +### 7.2 Generation Prompt + +``` +You are a ghostwriter. Write a {platform} post in the exact writing style described by the style profile below. The post should be about the topic/points provided. + +## Style Profile: +{style_profile_json} + +## Platform Conventions: +- Platform: {platform_name} +- Character limit: {max_chars} +- Recommended length: {recommended_length} +- Hashtags: {hashtag_guidance} +- Emoji: {emoji_guidance} +- Formatting: {formatting_notes} + +## Topic / Key Points: +{topic} + +## Instructions: +- Match the voice, tone, vocabulary, and structural patterns from the style profile exactly +- Follow the platform conventions for formatting, length, and hashtag/emoji usage +- Do NOT add disclaimers, meta-commentary, or explain what you're doing +- Output ONLY the post text, ready to copy and paste +``` + +### 7.3 Refinement Prompt + +``` +You are a ghostwriter refining a draft. Adjust the post below according to the feedback while maintaining the original writing style. + +## Style Profile: +{style_profile_json} + +## Platform: {platform_name} +## Platform Conventions: +{platform_conventions} + +## Current Draft: +{current_draft} + +## Feedback: +{feedback} + +## Instructions: +- Apply the feedback while staying true to the style profile +- Maintain platform conventions +- Output ONLY the revised post text +``` + +--- + +## 8. Key Interfaces + +### IContentFetcher + +```csharp +public interface IContentFetcher +{ + Platform Platform { get; } + Task> FetchPostsAsync( + FetchRequest request, + CancellationToken cancellationToken = default); +} + +public record FetchRequest( + string? Url = null, + string? Handle = null, + string? FilePath = null); + +public record FetchedPost( + string Content, + string? SourceUrl = null, + DateTimeOffset? PublishedAt = null); +``` + +### ILlmProvider + +```csharp +public interface ILlmProvider +{ + string ProviderName { get; } // "anthropic" or "openai" + string ModelName { get; } + + Task AnalyseStyleAsync( + string prompt, + CancellationToken cancellationToken = default); + + Task GeneratePostAsync( + string prompt, + CancellationToken cancellationToken = default); + + Task RefinePostAsync( + string prompt, + CancellationToken cancellationToken = default); +} +``` + +--- + +## 9. Configuration + +### appsettings.json + +```json +{ + "Writegeist": { + "DefaultProvider": "anthropic", + "DatabasePath": "writegeist.db", + "Anthropic": { + "Model": "claude-sonnet-4-20250514" + }, + "OpenAi": { + "Model": "gpt-4o" + } + } +} +``` + +### Environment Variables + +``` +ANTHROPIC_API_KEY=sk-ant-... +OPENAI_API_KEY=sk-... +``` + +Use `IConfiguration` with the standard provider chain: appsettings.json → environment variables → user secrets (dev). + +--- + +## 10. Content Fetcher Implementation Notes + +### General Architecture + +Each fetcher implements `IContentFetcher`. Register all of them in DI alongside the InteractiveCli action auto-registration. Resolve by `Platform` enum using a factory or keyed services (.NET 8+). + +### LinkedIn + +- **API**: LinkedIn's API requires OAuth2 and an approved app; post content is generally NOT available via the standard API. Not viable for v1. +- **Scraping**: LinkedIn aggressively blocks unauthenticated scraping (login walls, bot detection). +- **v1 approach**: `LinkedInFetcher` should log a warning that automated fetching is not available and suggest using File or Interactive paste instead. Implement as a stub that throws a user-friendly exception. +- **Future**: Could integrate a headless browser (Playwright) with cookie-based auth, but this is fragile and potentially ToS-violating. + +### X/Twitter + +- **API**: X API v2 (free tier) allows fetching recent tweets by user ID. Requires a bearer token. +- **Endpoint**: `GET /2/users/{id}/tweets` — returns up to 3,200 most recent tweets. +- **Auth**: Bearer token via `X_BEARER_TOKEN` env var. +- **v1 approach**: Implement API-based fetching. Fall back to manual if no token configured. +- **Rate limits**: 1 request per 15 minutes on free tier (returns up to 100 tweets per request). Handle 429 gracefully. + +### Instagram + +- **API**: Meta Graph API requires a Facebook app + Instagram Business/Creator account connection. Not viable for fetching arbitrary public profiles. +- **Scraping**: Instagram blocks most scraping. Public profile JSON endpoints have been locked down. +- **v1 approach**: Stub with manual fallback, similar to LinkedIn. +- **Future**: Playwright-based approach or third-party services. + +### Facebook + +- **API**: Graph API can access public Page posts (not personal profiles) with a Page Access Token. +- **Scraping**: Heavily blocked. +- **v1 approach**: Stub with manual fallback for personal profiles. Could implement Page post fetching if a Page Access Token is configured. + +### ManualFetcher + +- Reads from a file where posts are separated by `---` on its own line. +- Or runs an interactive loop: user pastes a post, presses Enter twice (blank line) to submit, types `done` to finish. +- Always available as fallback regardless of platform. + +--- + +## 11. Build & Run + +```bash +dotnet new sln -n Writegeist +dotnet new console -n Writegeist.Cli -o src/Writegeist.Cli +dotnet new classlib -n Writegeist.Core -o src/Writegeist.Core +dotnet new classlib -n Writegeist.Infrastructure -o src/Writegeist.Infrastructure +dotnet new xunit -n Writegeist.Tests -o src/Writegeist.Tests + +dotnet sln add src/Writegeist.Cli +dotnet sln add src/Writegeist.Core +dotnet sln add src/Writegeist.Infrastructure +dotnet sln add src/Writegeist.Tests + +# Project references +cd src/Writegeist.Cli && dotnet add reference ../Writegeist.Core ../Writegeist.Infrastructure +cd ../Writegeist.Infrastructure && dotnet add reference ../Writegeist.Core +cd ../Writegeist.Tests && dotnet add reference ../Writegeist.Core ../Writegeist.Infrastructure +``` + +### NuGet Packages + +**Writegeist.Cli:** +- `DevJonny.InteractiveCli` (brings in Spectre.Console, CommandLineParser, Serilog, Microsoft.Extensions.Hosting) + +**Writegeist.Infrastructure:** +- `Microsoft.Data.Sqlite` +- `Dapper` (optional, or use raw ADO.NET) +- `AngleSharp` (HTML parsing for scraping) +- `Anthropic` (if official SDK exists; otherwise raw HttpClient) +- `OpenAI` + +**Writegeist.Tests:** +- `xunit` +- `NSubstitute` or `Moq` +- `FluentAssertions` + +--- + +## 12. Implementation Order + +Build in this sequence — each step is independently testable: + +1. **Solution scaffolding** — Create projects, references, NuGet packages. Wire up InteractiveCli bootstrapper with a MainMenu that has placeholder actions. +2. **Data model + SQLite persistence** — `Models/`, `SqliteDatabase.cs`, repositories. Write tests. +3. **ManualFetcher** — File and interactive ingestion. Write tests. +4. **Ingest actions** — Wire up IngestMenu → IngestFromFileAction, IngestInteractiveAction → ManualFetcher → SQLite. +5. **ILlmProvider + AnthropicProvider** — Implement API calls with the style analysis prompt. +6. **StyleAnalyser service + AnalyseAction** — Orchestrate analysis, store profile. +7. **PlatformConventions** — Static platform rules. +8. **PostGenerator service + GenerateAction** — Generation with style profile + platform rules. +9. **RefineAction** — Refinement loop using RepeatableActionAsync. +10. **ProfileMenu + actions** — ShowProfileAction, ListProfilesAction. +11. **OpenAiProvider** — Second LLM backend. +12. **XTwitterFetcher + IngestFromUrlAction** — API-based tweet fetching (if X API access is available). +13. **Stub fetchers** — LinkedIn, Instagram, Facebook stubs with helpful error messages. +14. **Polish** — Error handling, logging, help text, README. + +--- + +## 13. Testing Strategy + +- **Unit tests**: Style analyser, post generator, platform conventions, repositories (in-memory SQLite). +- **Integration tests**: Full action execution with a mock LLM provider (return canned responses). +- **Manual smoke tests**: End-to-end with real API keys against both providers. +- Mock `ILlmProvider` and `IContentFetcher` in tests — never call real APIs in automated tests. + +--- + +## 14. Future Enhancements (Out of Scope for v1) + +- Headless browser fetching (Playwright) for LinkedIn/Instagram +- Thread generation for X (multi-tweet) +- Image caption generation for Instagram +- A/B style comparison (generate from two different people's styles) +- Export to clipboard / direct posting via APIs +- Web UI wrapper +- Style drift detection (re-analyse and compare profiles over time) diff --git a/scripts/ralph/AGENTS.md b/scripts/ralph/AGENTS.md new file mode 100644 index 0000000..17cfedf --- /dev/null +++ b/scripts/ralph/AGENTS.md @@ -0,0 +1,30 @@ +# Ralph Agent Instructions + +## Overview + +Ralph is an autonomous AI agent loop that runs AI coding tools (Amp or Claude Code) repeatedly until all PRD items are complete. Each iteration is a fresh instance with clean context. + +## Commands + +```bash +# Run Ralph with Amp (default) +./scripts/ralph/ralph.sh [max_iterations] + +# Run Ralph with Claude Code +./scripts/ralph/ralph.sh --tool claude [max_iterations] +``` + +## Key Files + +- `scripts/ralph/ralph.sh` - The bash loop that spawns fresh AI instances (supports `--tool amp` or `--tool claude`) +- `scripts/ralph/prompt.md` - Instructions given to each Amp instance +- `scripts/ralph/CLAUDE.md` - Instructions given to each Claude Code instance +- `scripts/ralph/prd.json` - PRD with user stories +- `scripts/ralph/progress.txt` - Progress log across iterations + +## Patterns + +- Each iteration spawns a fresh AI instance (Amp or Claude Code) with clean context +- Memory persists via git history, `progress.txt`, and `prd.json` +- Stories should be small enough to complete in one context window +- Always update AGENTS.md with discovered patterns for future iterations diff --git a/scripts/ralph/CLAUDE.md b/scripts/ralph/CLAUDE.md index c5668b7..f95bb92 100644 --- a/scripts/ralph/CLAUDE.md +++ b/scripts/ralph/CLAUDE.md @@ -4,8 +4,8 @@ You are an autonomous coding agent working on a software project. ## Your Task -1. Read the PRD at `scripts/ralph/prd.json` -2. Read the progress log at `scripts/ralph/progress.txt` (check Codebase Patterns section first) +1. Read the PRD at `prd.json` (in the same directory as this file) +2. Read the progress log at `progress.txt` (check Codebase Patterns section first) 3. Check you're on the correct branch from PRD `branchName`. If not, check it out or create from main. 4. Pick the **highest priority** user story where `passes: false` 5. Implement that single user story @@ -13,7 +13,7 @@ You are an autonomous coding agent working on a software project. 7. Update CLAUDE.md files if you discover reusable patterns (see below) 8. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]` 9. Update the PRD to set `passes: true` for the completed story -10. Append your progress to `scripts/ralph/progress.txt` +10. Append your progress to `progress.txt` ## Progress Report Format diff --git a/scripts/ralph/archive/2026-04-04-colorado-pain-scale/prd.json b/scripts/ralph/archive/2026-04-04-colorado-pain-scale/prd.json new file mode 100644 index 0000000..4a3598c --- /dev/null +++ b/scripts/ralph/archive/2026-04-04-colorado-pain-scale/prd.json @@ -0,0 +1,445 @@ +{ + "project": "Writegeist", + "branchName": "ralph/writegeist-v1", + "description": "Interactive .NET CLI tool that clones a person's writing style from social media posts and generates platform-specific content on demand", + "userStories": [ + { + "id": "US-001", + "title": "Solution scaffolding and project structure", + "description": "As a developer, I need the .NET solution with all projects, references, and NuGet packages so that I can start building features.", + "acceptanceCriteria": [ + "Writegeist.sln with four projects: Writegeist.Cli, Writegeist.Core, Writegeist.Infrastructure, Writegeist.Tests", + "All projects target net10.0", + "Project references wired: Cli references Core + Infrastructure, Infrastructure references Core, Tests references Core + Infrastructure", + "Writegeist.Cli references DevJonny.InteractiveCli NuGet package", + "Writegeist.Infrastructure references Microsoft.Data.Sqlite and AngleSharp", + "Writegeist.Tests references xunit, FluentAssertions, and NSubstitute", + "Program.cs bootstraps InteractiveCli with a MainMenu that displays menu items and quits cleanly", + "MainMenu is a top-level menu (isTopLevel: true) with a placeholder menu item", + "dotnet build succeeds with no errors", + "Typecheck passes" + ], + "priority": 1, + "passes": true, + "notes": "" + }, + { + "id": "US-002", + "title": "Domain models", + "description": "As a developer, I need the core domain models defined so that repositories and services can use them.", + "acceptanceCriteria": [ + "Person model in Writegeist.Core/Models with Id (int), Name (string), CreatedAt (DateTime)", + "RawPost model with Id, PersonId, Platform, Content, ContentHash, SourceUrl, FetchedAt", + "Platform enum with values: LinkedIn, X, Instagram, Facebook", + "StyleProfile model with Id, PersonId, ProfileJson, Provider, Model, CreatedAt", + "GeneratedDraft model with Id, PersonId, StyleProfileId, Platform, Topic, Content, ParentDraftId (nullable), Feedback (nullable), Provider, Model, CreatedAt", + "FetchRequest record with nullable Url, Handle, FilePath properties", + "FetchedPost record with Content, nullable SourceUrl, nullable PublishedAt", + "dotnet build succeeds", + "Typecheck passes" + ], + "priority": 2, + "passes": true, + "notes": "" + }, + { + "id": "US-003", + "title": "Core interfaces", + "description": "As a developer, I need the core interfaces defined so that infrastructure implementations can be built against them.", + "acceptanceCriteria": [ + "IPersonRepository interface in Writegeist.Core/Interfaces with CreateAsync, GetByNameAsync, GetAllAsync, GetOrCreateAsync", + "IPostRepository interface with AddAsync, GetByPersonIdAsync, GetCountByPersonIdAsync, ExistsByHashAsync", + "IStyleProfileRepository interface with SaveAsync, GetLatestByPersonIdAsync, GetAllByPersonIdAsync", + "IDraftRepository interface with SaveAsync, GetLatestAsync, GetByIdAsync", + "IContentFetcher interface with Platform property and FetchPostsAsync method", + "ILlmProvider interface with ProviderName, ModelName properties and AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync methods", + "dotnet build succeeds", + "Typecheck passes" + ], + "priority": 3, + "passes": true, + "notes": "" + }, + { + "id": "US-004", + "title": "SQLite database and schema initialisation", + "description": "As a developer, I need the SQLite database created with the correct schema so that data persists locally.", + "acceptanceCriteria": [ + "SqliteDatabase class in Writegeist.Infrastructure/Persistence with EnsureCreated method", + "Creates persons table with id (autoincrement PK), name (unique, not null), created_at (default now)", + "Creates raw_posts table with FK to persons, platform, content, content_hash, source_url, fetched_at, unique constraint on (person_id, content_hash)", + "Creates style_profiles table with FK to persons, profile_json, provider, model, created_at", + "Creates generated_drafts table with FK to persons and style_profiles, parent_draft_id self-ref FK, platform, topic, content, feedback, provider, model, created_at", + "Schema creation is idempotent (uses CREATE TABLE IF NOT EXISTS)", + "Database path configurable via IConfiguration (Writegeist:DatabasePath key)", + "Unit tests verify all four tables are created using in-memory SQLite", + "Typecheck passes" + ], + "priority": 4, + "passes": true, + "notes": "" + }, + { + "id": "US-005", + "title": "Person repository implementation", + "description": "As a developer, I need the person repository so that persons can be stored and retrieved.", + "acceptanceCriteria": [ + "SqlitePersonRepository implements IPersonRepository", + "CreateAsync inserts a new person and returns the model with the generated Id", + "GetByNameAsync performs case-insensitive lookup and returns null if not found", + "GetAllAsync returns all persons ordered by name", + "GetOrCreateAsync returns existing person if name matches (case-insensitive), creates new one if not", + "Unit tests with in-memory SQLite cover create, get by name, get all, get-or-create (existing), get-or-create (new)", + "Typecheck passes", + "Tests pass" + ], + "priority": 5, + "passes": true, + "notes": "" + }, + { + "id": "US-006", + "title": "Post repository implementation", + "description": "As a developer, I need the post repository so that ingested posts can be stored and deduplicated.", + "acceptanceCriteria": [ + "SqlitePostRepository implements IPostRepository", + "AddAsync inserts a post and returns a result indicating whether it was new or a duplicate", + "Content hash is SHA-256 of normalised (trimmed, lowercased) content text", + "Duplicate detection uses the unique constraint on (person_id, content_hash)", + "GetByPersonIdAsync returns all posts for a person ordered by fetched_at", + "GetCountByPersonIdAsync returns the count of posts for a person", + "Unit tests cover add new, add duplicate, retrieval, and count", + "Typecheck passes", + "Tests pass" + ], + "priority": 6, + "passes": true, + "notes": "" + }, + { + "id": "US-007", + "title": "Style profile repository implementation", + "description": "As a developer, I need the style profile repository so that analysed profiles can be stored and retrieved.", + "acceptanceCriteria": [ + "SqliteStyleProfileRepository implements IStyleProfileRepository", + "SaveAsync inserts a profile and returns the model with generated Id", + "GetLatestByPersonIdAsync returns the most recent profile by created_at, or null if none exist", + "GetAllByPersonIdAsync returns all profiles for a person ordered by created_at descending", + "Unit tests cover save, retrieve latest, retrieve all, and null case", + "Typecheck passes", + "Tests pass" + ], + "priority": 7, + "passes": true, + "notes": "" + }, + { + "id": "US-008", + "title": "Draft repository implementation", + "description": "As a developer, I need the draft repository so that generated and refined drafts can be stored and chained.", + "acceptanceCriteria": [ + "SqliteDraftRepository implements IDraftRepository", + "SaveAsync inserts a draft and returns the model with generated Id", + "GetLatestAsync returns the most recently created draft across all persons, or null if none exist", + "GetByIdAsync returns a draft by Id, or null if not found", + "parent_draft_id correctly links refined drafts to their predecessors", + "Unit tests cover save, get latest, get by id, parent-child linking, and null cases", + "Typecheck passes", + "Tests pass" + ], + "priority": 8, + "passes": true, + "notes": "" + }, + { + "id": "US-009", + "title": "Manual fetcher — file import", + "description": "As a user, I want to import posts from a text file so that I can feed Writegeist content without API access.", + "acceptanceCriteria": [ + "ManualFetcher class in Writegeist.Infrastructure/Fetchers implements IContentFetcher", + "Platform property returns a configurable platform value (set at construction)", + "FetchPostsAsync reads a file path from FetchRequest.FilePath", + "Splits file content on lines containing only --- as separator", + "Trims whitespace from each post and skips empty entries", + "Returns a list of FetchedPost records with content populated", + "Unit tests cover: normal file with multiple posts, empty file, file with no separators (single post), file with consecutive separators", + "Typecheck passes", + "Tests pass" + ], + "priority": 9, + "passes": false, + "notes": "" + }, + { + "id": "US-010", + "title": "Platform conventions", + "description": "As a developer, I need platform-specific rules so that generated posts follow each platform's norms.", + "acceptanceCriteria": [ + "PlatformRules record in Writegeist.Core with Name, MaxCharacters (nullable int), RecommendedMaxLength, SupportsHashtags, RecommendedHashtagCount, HashtagPlacement, SupportsEmoji, EmojiGuidance, ToneGuidance, FormattingNotes", + "PlatformConventions class with static GetRules(Platform) method", + "LinkedIn: 3000 max, 1500 recommended, hashtags end, 3-5 count, emoji sparingly", + "X: 280 max, 280 recommended, hashtags inline, 1-2 count, emoji moderate", + "Instagram: 2200 max, 750 recommended, hashtags end in separate block, up to 30, emoji freely", + "Facebook: 63206 max, 600 recommended, hashtags end, 1-3 count, emoji moderate", + "Unit tests verify rules for all four platforms", + "Typecheck passes", + "Tests pass" + ], + "priority": 10, + "passes": false, + "notes": "" + }, + { + "id": "US-011", + "title": "Anthropic LLM provider", + "description": "As a user, I want to use Claude as the LLM backend so that I can analyse style and generate posts.", + "acceptanceCriteria": [ + "AnthropicProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider", + "Uses official Anthropic NuGet SDK if available, otherwise raw HttpClient to https://api.anthropic.com/v1/messages", + "API key read from ANTHROPIC_API_KEY environment variable via IConfiguration", + "Model name configurable via appsettings.json (Writegeist:Anthropic:Model), defaults to claude-sonnet-4-20250514", + "ProviderName returns 'anthropic', ModelName returns the configured model", + "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response", + "Throws a descriptive exception if API key is not configured", + "Handles HTTP errors (401, 429, 500) with user-friendly exception messages", + "Typecheck passes" + ], + "priority": 11, + "passes": false, + "notes": "" + }, + { + "id": "US-012", + "title": "OpenAI LLM provider", + "description": "As a user, I want to use OpenAI as an alternative LLM backend.", + "acceptanceCriteria": [ + "OpenAiProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider", + "Uses official OpenAI NuGet package", + "API key read from OPENAI_API_KEY environment variable via IConfiguration", + "Model name configurable via appsettings.json (Writegeist:OpenAi:Model), defaults to gpt-4o", + "ProviderName returns 'openai', ModelName returns the configured model", + "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response", + "Throws a descriptive exception if API key is not configured", + "Handles HTTP errors with user-friendly exception messages", + "Typecheck passes" + ], + "priority": 12, + "passes": false, + "notes": "" + }, + { + "id": "US-013", + "title": "Style analyser service", + "description": "As a developer, I need the style analysis orchestration so that ingested posts can be turned into a style profile.", + "acceptanceCriteria": [ + "StyleAnalyser class in Writegeist.Core/Services", + "Constructor takes IPostRepository, IStyleProfileRepository, and ILlmProvider", + "AnalyseAsync(int personId) loads all posts for the person, builds the style analysis prompt with all post contents, sends to LLM, and stores the result", + "Uses the style analysis prompt template from the implementation plan (section 7.1)", + "Returns the saved StyleProfile model", + "Throws if no posts exist for the person", + "Unit tests with mocked dependencies verify prompt construction and profile storage", + "Typecheck passes", + "Tests pass" + ], + "priority": 13, + "passes": false, + "notes": "" + }, + { + "id": "US-014", + "title": "Post generator service", + "description": "As a developer, I need the post generation orchestration so that drafts can be created from a style profile and topic.", + "acceptanceCriteria": [ + "PostGenerator class in Writegeist.Core/Services", + "Constructor takes IStyleProfileRepository, IDraftRepository, and ILlmProvider", + "GenerateAsync(int personId, Platform platform, string topic) loads latest style profile, gets platform conventions, builds generation prompt, sends to LLM, stores draft", + "Uses the generation prompt template from the implementation plan (section 7.2)", + "RefineAsync(int draftId, string feedback) loads draft, its style profile, builds refinement prompt, sends to LLM, stores new draft linked to previous", + "Uses the refinement prompt template from the implementation plan (section 7.3)", + "Returns the saved GeneratedDraft model", + "Throws if no style profile exists for the person", + "Unit tests with mocked dependencies verify prompt construction and draft storage", + "Typecheck passes", + "Tests pass" + ], + "priority": 14, + "passes": false, + "notes": "" + }, + { + "id": "US-015", + "title": "DI registration and appsettings.json", + "description": "As a developer, I need all services, repositories, and providers registered in DI so that actions can resolve them.", + "acceptanceCriteria": [ + "Program.cs configureServices lambda registers: SqliteDatabase, all four repositories, StyleAnalyser, PostGenerator, both LLM providers, ManualFetcher", + "LLM providers registered as keyed services or via a factory that resolves by provider name string", + "appsettings.json contains Writegeist section with DefaultProvider, DatabasePath, Anthropic:Model, OpenAi:Model", + "SqliteDatabase.EnsureCreated() called during startup", + "dotnet build succeeds", + "Typecheck passes" + ], + "priority": 15, + "passes": false, + "notes": "" + }, + { + "id": "US-016", + "title": "Ingest menu and file import action", + "description": "As a user, I want an Ingest Posts sub-menu with a From File action so that I can import posts from a text file.", + "acceptanceCriteria": [ + "IngestMenu class extends Menu (quitable: false, isTopLevel: false) with BuildMenu adding From File, Interactive Paste, From URL / Handle menu items", + "MainMenu adds IngestMenu as 'Ingest Posts' menu item", + "IngestFromFileAction extends SingleActionAsync", + "Prompts for person name via Spectre.Console TextPrompt", + "Prompts for platform via Spectre.Console SelectionPrompt (LinkedIn, X, Instagram, Facebook)", + "Prompts for file path via TextPrompt", + "Uses IPersonRepository.GetOrCreateAsync to ensure person exists", + "Uses ManualFetcher to read the file, then stores each post via IPostRepository.AddAsync", + "Displays a Spectre.Console Panel summary: count of new posts (green), duplicates skipped (yellow)", + "Typecheck passes" + ], + "priority": 16, + "passes": false, + "notes": "" + }, + { + "id": "US-017", + "title": "Interactive paste action", + "description": "As a user, I want to paste posts one at a time in an interactive session.", + "acceptanceCriteria": [ + "IngestInteractiveAction extends RepeatableActionAsync", + "On first iteration, prompts for person name and platform via Spectre.Console prompts", + "Each iteration prompts for post content via TextPrompt", + "Stores each post via IPostRepository.AddAsync with content hash dedup", + "After each post, asks 'Add another post?' — returns false to continue, true to stop", + "On completion, displays Panel summary with total new posts and duplicates skipped", + "Typecheck passes" + ], + "priority": 17, + "passes": false, + "notes": "" + }, + { + "id": "US-018", + "title": "Analyse style action", + "description": "As a user, I want a menu action to analyse my posts and build a style profile.", + "acceptanceCriteria": [ + "AnalyseAction extends SingleActionAsync, added to MainMenu as 'Analyse Style'", + "Prompts user to select a person from existing persons via SelectionPrompt (shows error if none exist)", + "Prompts user to select LLM provider via SelectionPrompt (Anthropic, OpenAI) with default from config", + "Calls StyleAnalyser.AnalyseAsync with the selected person", + "Displays spinner via AnsiConsole.Status() while LLM processes", + "Displays the profile summary in a styled Panel after completion", + "Catches and displays errors in red markup without crashing the menu loop", + "Typecheck passes" + ], + "priority": 18, + "passes": false, + "notes": "" + }, + { + "id": "US-019", + "title": "Generate post action", + "description": "As a user, I want a menu action to generate a new post in my style for a specific platform.", + "acceptanceCriteria": [ + "GenerateAction extends SingleActionAsync, added to MainMenu as 'Generate Post'", + "Prompts user to select a person (only persons with a style profile) via SelectionPrompt", + "Prompts user to select target platform via SelectionPrompt", + "Prompts user to enter topic/key points via TextPrompt", + "Calls PostGenerator.GenerateAsync with person, platform, and topic", + "Displays spinner via AnsiConsole.Status() while LLM processes", + "Displays the generated post in a styled Panel with border and title", + "Catches and displays errors in red markup without crashing", + "Typecheck passes" + ], + "priority": 19, + "passes": false, + "notes": "" + }, + { + "id": "US-020", + "title": "Refine draft action", + "description": "As a user, I want a menu action to iteratively refine the last generated draft with feedback.", + "acceptanceCriteria": [ + "RefineAction extends RepeatableActionAsync, added to MainMenu as 'Refine Last Draft'", + "On first iteration, loads the most recent GeneratedDraft and displays it in a Panel (shows error if none exist)", + "Prompts for feedback via TextPrompt", + "Calls PostGenerator.RefineAsync with the draft ID and feedback", + "Displays spinner via AnsiConsole.Status() while LLM processes", + "Displays the refined post in a styled Panel", + "Asks 'Refine again?' — returns false to continue looping, true to stop and return to menu", + "Catches and displays errors in red markup without crashing", + "Typecheck passes" + ], + "priority": 20, + "passes": false, + "notes": "" + }, + { + "id": "US-021", + "title": "Profile menu and actions", + "description": "As a user, I want to view and list style profiles from the menu.", + "acceptanceCriteria": [ + "ProfileMenu extends Menu (quitable: false, isTopLevel: false) with Show Profile and List All Profiles items", + "MainMenu adds ProfileMenu as 'Profiles' menu item", + "ShowProfileAction extends SingleActionAsync, prompts for person selection, displays full style profile in a formatted Spectre.Console Table with grouped sections (vocabulary, tone, formatting, etc.)", + "ListProfilesAction extends SingleActionAsync, displays a Table of all persons with profiles showing name, provider, model, and created date", + "Both actions show a message if no profiles exist", + "Typecheck passes" + ], + "priority": 21, + "passes": false, + "notes": "" + }, + { + "id": "US-022", + "title": "Stub fetchers for unsupported platforms", + "description": "As a developer, I need stub fetchers for LinkedIn, Instagram, and Facebook that guide users to manual input.", + "acceptanceCriteria": [ + "LinkedInFetcher, InstagramFetcher, FacebookFetcher each implement IContentFetcher in Writegeist.Infrastructure/Fetchers", + "Each returns the correct Platform enum value from the Platform property", + "FetchPostsAsync throws a descriptive exception explaining automated fetching is not available for this platform", + "Exception message suggests using From File or Interactive Paste instead", + "Typecheck passes" + ], + "priority": 22, + "passes": false, + "notes": "" + }, + { + "id": "US-023", + "title": "Ingest from URL action and fetcher dispatch", + "description": "As a user, I want to ingest posts from a URL or handle, with clear error messages for unsupported platforms.", + "acceptanceCriteria": [ + "IngestFromUrlAction extends SingleActionAsync, wired into IngestMenu as 'From URL / Handle'", + "Prompts for person name, platform, and URL or handle via Spectre.Console prompts", + "Resolves the correct IContentFetcher by platform (using DI factory or keyed services)", + "Calls FetchPostsAsync and stores results via IPostRepository.AddAsync", + "Catches fetcher exceptions for unsupported platforms and displays the message in yellow markup", + "Displays Panel summary for successful fetches", + "Typecheck passes" + ], + "priority": 23, + "passes": false, + "notes": "" + }, + { + "id": "US-024", + "title": "X/Twitter API fetcher", + "description": "As a user, I want to fetch recent tweets automatically via the X API.", + "acceptanceCriteria": [ + "XTwitterFetcher in Writegeist.Infrastructure/Fetchers implements IContentFetcher for Platform.X", + "Uses HttpClient to call X API v2 GET /2/users/{id}/tweets endpoint", + "Bearer token read from X_BEARER_TOKEN environment variable", + "Fetches up to 100 recent tweets per request", + "Handles 429 rate limit responses with a user-friendly message suggesting to wait and retry", + "Throws descriptive exception if no bearer token is configured, suggesting manual input instead", + "Typecheck passes" + ], + "priority": 24, + "passes": false, + "notes": "" + } + ] +} diff --git a/scripts/ralph/archive/2026-04-04-colorado-pain-scale/progress.txt b/scripts/ralph/archive/2026-04-04-colorado-pain-scale/progress.txt new file mode 100644 index 0000000..6889bab --- /dev/null +++ b/scripts/ralph/archive/2026-04-04-colorado-pain-scale/progress.txt @@ -0,0 +1,118 @@ +# Ralph Progress Log +Started: Sat 4 Apr 2026 19:22:24 BST + +## Codebase Patterns +- Test in-memory SQLite with shared cache: use `file:test_{guid}?mode=memory&cache=shared` as DB path so SqliteDatabase and verification connection share the same DB +- SqliteDatabase.ConnectionString is public — use it for repository constructors that need to create their own connections +- Repository pattern: constructor takes SqliteDatabase, stores ConnectionString, creates new SqliteConnection per method call +- Use COLLATE NOCASE in WHERE clauses for case-insensitive text matching in SQLite +- dotnet build/test require sandbox disabled (NuGet restore needs network + global cache access) +- SQLite datetime('now') default has second granularity — don't rely on insertion order in tests when rows are inserted rapidly +- Use INSERT OR IGNORE + unique constraint for dedup; check rowsAffected > 0 to distinguish new vs duplicate +- Use secondary ORDER BY id DESC as tiebreaker for "latest" queries when datetime('now') produces same-second timestamps +- Core interfaces live in Writegeist.Core/Interfaces: IPersonRepository, IPostRepository, IStyleProfileRepository, IDraftRepository, IContentFetcher, ILlmProvider +- Domain models live in Writegeist.Core/Models: Person, RawPost, StyleProfile, GeneratedDraft (classes), FetchRequest, FetchedPost (records), Platform (enum) +- DevJonny.InteractiveCli v2.4.0: Menu(quitable, isTopLevel) base class with abstract BuildMenu() using protected MenuBuilder field +- MenuBuilder.AddMenuItem(name, description) where TAction : IAmAnAction — name is display text, description is tooltip +- Actions: SingleActionAsync (override SingleAsyncAction), RepeatableActionAsync (override RepeatableAsyncAction returning bool to stop) +- Bootstrapping: Host.CreateDefaultBuilder(args).AddInteractiveCli().Build().UseInteractiveCli((EmptyOptions _) => new MainMenu(), args) +- Solution uses .slnx format (net10.0), projects under src/ and tests/ +- Cli references Core + Infrastructure; Infrastructure references Core; Tests references Core + Infrastructure +--- + +## 2026-04-04 - US-001 +- Scaffolded Writegeist.sln with four projects: Cli, Core, Infrastructure, Tests +- All projects target net10.0 with project references wired correctly +- NuGet packages: DevJonny.InteractiveCli, Microsoft.Extensions.Hosting (Cli); Microsoft.Data.Sqlite, AngleSharp (Infra); xunit, FluentAssertions, NSubstitute (Tests) +- Program.cs bootstraps InteractiveCli with MainMenu (isTopLevel: true) and a PlaceholderAction +- Files changed: Writegeist.slnx, src/Writegeist.Cli/*, src/Writegeist.Core/*.csproj, src/Writegeist.Infrastructure/*.csproj, tests/Writegeist.Tests/*.csproj +- **Learnings for future iterations:** + - dotnet new commands need sandbox disabled for ~/.templateengine access + - InteractiveCli MenuBuilder.AddMenuItem is generic: AddMenuItem(name, desc) — T must implement IAmAnAction + - UseInteractiveCli needs explicit type arg: UseInteractiveCli((EmptyOptions _) => new MainMenu(), args) + - Solution created as .slnx format by default on net10.0 +--- + +## 2026-04-04 - US-002 +- Created all domain models in Writegeist.Core/Models +- Person, RawPost, StyleProfile, GeneratedDraft as classes with properties +- Platform enum with LinkedIn, X, Instagram, Facebook +- FetchRequest record with nullable Url, Handle, FilePath +- FetchedPost record with Content, nullable SourceUrl, nullable PublishedAt +- Files changed: src/Writegeist.Core/Models/{Person,RawPost,StyleProfile,GeneratedDraft,Platform,FetchRequest,FetchedPost}.cs +- **Learnings for future iterations:** + - Classes used for entities with mutable Id (set by DB), records for immutable value objects + - All nullable properties use C# nullable reference types (string?, int?, DateTime?) +--- + +## 2026-04-04 - US-003 +- Created all core interfaces in Writegeist.Core/Interfaces +- IPersonRepository: CreateAsync, GetByNameAsync, GetAllAsync, GetOrCreateAsync +- IPostRepository: AddAsync (returns bool for new vs duplicate), GetByPersonIdAsync, GetCountByPersonIdAsync, ExistsByHashAsync +- IStyleProfileRepository: SaveAsync, GetLatestByPersonIdAsync, GetAllByPersonIdAsync +- IDraftRepository: SaveAsync, GetLatestAsync, GetByIdAsync +- IContentFetcher: Platform property, FetchPostsAsync method +- ILlmProvider: ProviderName, ModelName properties, AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync methods +- Files changed: src/Writegeist.Core/Interfaces/{IPersonRepository,IPostRepository,IStyleProfileRepository,IDraftRepository,IContentFetcher,ILlmProvider}.cs +- **Learnings for future iterations:** + - IPostRepository.AddAsync returns bool (true = new post, false = duplicate) — simplifies dedup tracking in actions + - ILlmProvider methods all take a single string prompt and return string — keeps interface simple +--- + +## 2026-04-04 - US-004 +- Created SqliteDatabase class in Writegeist.Infrastructure/Persistence +- Schema creates 4 tables: persons, raw_posts, style_profiles, generated_drafts with correct FKs and constraints +- Idempotent via CREATE TABLE IF NOT EXISTS +- Database path configurable via IConfiguration["Writegeist:DatabasePath"], defaults to writegeist.db +- Added Microsoft.Extensions.Configuration.Abstractions package to Infrastructure +- 6 unit tests verify table creation, idempotency, and column presence for all tables +- Files changed: src/Writegeist.Infrastructure/{Writegeist.Infrastructure.csproj,Persistence/SqliteDatabase.cs}, tests/Writegeist.Tests/Persistence/SqliteDatabaseTests.cs +- **Learnings for future iterations:** + - Use shared cache in-memory SQLite for tests: `file:test_{guid}?mode=memory&cache=shared` + - SqliteDatabase exposes ConnectionString property for repos to create connections + - SQLite stores dates as TEXT with datetime('now') default +--- + +## 2026-04-04 - US-005 +- Implemented SqlitePersonRepository with CreateAsync, GetByNameAsync, GetAllAsync, GetOrCreateAsync +- 7 unit tests: create, get by name, case-insensitive lookup, not found returns null, get all ordered, get-or-create existing, get-or-create new +- Files changed: src/Writegeist.Infrastructure/Persistence/SqlitePersonRepository.cs, tests/Writegeist.Tests/Persistence/SqlitePersonRepositoryTests.cs +- **Learnings for future iterations:** + - Repository constructor takes SqliteDatabase, extracts ConnectionString, creates new SqliteConnection per method + - Use COLLATE NOCASE for case-insensitive matching in SQLite queries + - ReadPerson helper maps SqliteDataReader columns by ordinal position + - dotnet build/test commands need sandbox disabled for NuGet/global cache access +--- + +## 2026-04-04 - US-006 +- Implemented SqlitePostRepository with AddAsync (INSERT OR IGNORE for dedup), GetByPersonIdAsync, GetCountByPersonIdAsync, ExistsByHashAsync +- SHA-256 hash computed from normalised (trimmed, lowercased) content via static ComputeHash method +- 8 unit tests: add new, add duplicate, normalised hash dedup, retrieval, count, exists by hash (true/false), hash format +- Files changed: src/Writegeist.Infrastructure/Persistence/SqlitePostRepository.cs, tests/Writegeist.Tests/Persistence/SqlitePostRepositoryTests.cs +- **Learnings for future iterations:** + - Use INSERT OR IGNORE with unique constraint for dedup — returns rowsAffected=0 for duplicates + - SQLite datetime('now') has second granularity — don't test ordering when rows inserted in same second + - ComputeHash is public static for testability and reuse in fetchers +--- + +## 2026-04-04 - US-007 +- Implemented SqliteStyleProfileRepository with SaveAsync, GetLatestByPersonIdAsync, GetAllByPersonIdAsync +- ORDER BY created_at DESC, id DESC used for "latest" queries to handle same-second inserts +- 5 unit tests: save, get latest, get latest null, get all descending, get all empty +- Files changed: src/Writegeist.Infrastructure/Persistence/SqliteStyleProfileRepository.cs, tests/Writegeist.Tests/Persistence/SqliteStyleProfileRepositoryTests.cs +- **Learnings for future iterations:** + - Use secondary ORDER BY id DESC as tiebreaker when datetime('now') produces same timestamps + - All repos follow identical pattern: constructor(SqliteDatabase), store ConnectionString, new connection per method +--- + +## 2026-04-04 - US-008 +- Implemented SqliteDraftRepository with SaveAsync, GetLatestAsync, GetByIdAsync +- Handles nullable ParentDraftId and Feedback with DBNull.Value +- Uses shared SelectColumns constant to avoid repeating column list +- 7 unit tests: save, get latest, get latest null, get by id, get by id null, parent-child linking, nullable fields +- Files changed: src/Writegeist.Infrastructure/Persistence/SqliteDraftRepository.cs, tests/Writegeist.Tests/Persistence/SqliteDraftRepositoryTests.cs +- **Learnings for future iterations:** + - Use DBNull.Value for nullable parameters in SQLite commands: (object?)value ?? DBNull.Value + - Test setup for drafts requires inserting person AND style_profile first (FK constraints) + - All four repository implementations are now complete and follow the same pattern +--- diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index 84b1066..6c6504f 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -39,7 +39,7 @@ "Typecheck passes" ], "priority": 2, - "passes": false, + "passes": true, "notes": "" }, { @@ -57,7 +57,7 @@ "Typecheck passes" ], "priority": 3, - "passes": false, + "passes": true, "notes": "" }, { @@ -76,7 +76,7 @@ "Typecheck passes" ], "priority": 4, - "passes": false, + "passes": true, "notes": "" }, { @@ -94,7 +94,7 @@ "Tests pass" ], "priority": 5, - "passes": false, + "passes": true, "notes": "" }, { @@ -113,7 +113,7 @@ "Tests pass" ], "priority": 6, - "passes": false, + "passes": true, "notes": "" }, { @@ -130,7 +130,7 @@ "Tests pass" ], "priority": 7, - "passes": false, + "passes": true, "notes": "" }, { @@ -148,7 +148,7 @@ "Tests pass" ], "priority": 8, - "passes": false, + "passes": true, "notes": "" }, { @@ -167,7 +167,7 @@ "Tests pass" ], "priority": 9, - "passes": false, + "passes": true, "notes": "" }, { @@ -186,7 +186,7 @@ "Tests pass" ], "priority": 10, - "passes": false, + "passes": true, "notes": "" }, { @@ -205,7 +205,7 @@ "Typecheck passes" ], "priority": 11, - "passes": false, + "passes": true, "notes": "" }, { @@ -224,7 +224,7 @@ "Typecheck passes" ], "priority": 12, - "passes": false, + "passes": true, "notes": "" }, { @@ -243,7 +243,7 @@ "Tests pass" ], "priority": 13, - "passes": false, + "passes": true, "notes": "" }, { @@ -264,7 +264,7 @@ "Tests pass" ], "priority": 14, - "passes": false, + "passes": true, "notes": "" }, { @@ -280,7 +280,7 @@ "Typecheck passes" ], "priority": 15, - "passes": false, + "passes": true, "notes": "" }, { @@ -300,7 +300,7 @@ "Typecheck passes" ], "priority": 16, - "passes": false, + "passes": true, "notes": "" }, { @@ -317,7 +317,7 @@ "Typecheck passes" ], "priority": 17, - "passes": false, + "passes": true, "notes": "" }, { @@ -335,7 +335,7 @@ "Typecheck passes" ], "priority": 18, - "passes": false, + "passes": true, "notes": "" }, { @@ -354,7 +354,7 @@ "Typecheck passes" ], "priority": 19, - "passes": false, + "passes": true, "notes": "" }, { @@ -373,7 +373,7 @@ "Typecheck passes" ], "priority": 20, - "passes": false, + "passes": true, "notes": "" }, { @@ -389,7 +389,7 @@ "Typecheck passes" ], "priority": 21, - "passes": false, + "passes": true, "notes": "" }, { @@ -404,7 +404,7 @@ "Typecheck passes" ], "priority": 22, - "passes": false, + "passes": true, "notes": "" }, { @@ -421,7 +421,7 @@ "Typecheck passes" ], "priority": 23, - "passes": false, + "passes": true, "notes": "" }, { @@ -438,7 +438,7 @@ "Typecheck passes" ], "priority": 24, - "passes": false, + "passes": true, "notes": "" } ] diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt index 61b6428..6e55b38 100644 --- a/scripts/ralph/progress.txt +++ b/scripts/ralph/progress.txt @@ -1,3 +1,185 @@ # Ralph Progress Log -Started: Sat 4 Apr 2026 19:22:24 BST +Started: Sat 4 Apr 2026 21:15:40 BST + +## Codebase Patterns +- Use `dotnet test` with `--filter` to run specific test classes (e.g., `--filter "ManualFetcher"`) +- `dotnet build`/`dotnet test` commands need sandbox disabled (`dangerouslyDisableSandbox`) due to MSBuild named pipe permissions +- Infrastructure project references Core; Tests references Core + Infrastructure (no Cli dependency) +- Test projects use in-memory SQLite for repository tests, temp directories for file-based tests +- Records are used for DTOs (FetchRequest, FetchedPost), classes for domain models (Person, RawPost, etc.) +- Fetcher tests implement IDisposable to clean up temp directories +- LLM providers use HttpClient injection + IConfiguration for API keys and model names +- All ILlmProvider methods (AnalyseStyle, GeneratePost, RefinePost) delegate to a single API call method +- For actions needing user-selected LLM provider: inject both AnthropicProvider and OpenAiProvider concrete types, construct service manually +- AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync() for spinner during LLM calls +- RepeatableActionAsync: return `true` to stop, `false` to continue; use instance fields for cross-iteration state +- Platform-specific fetchers registered as IContentFetcher in DI; resolve via IEnumerable and filter by Platform +--- + +## 2026-04-04 21:55 BST - US-009 +- ManualFetcher and ManualFetcherTests were already implemented but not committed +- Files committed: src/Writegeist.Infrastructure/Fetchers/ManualFetcher.cs, tests/Writegeist.Tests/Fetchers/ManualFetcherTests.cs +- All 39 tests pass, full solution builds successfully +- **Learnings for future iterations:** + - Previous iterations may leave files uncommitted — always check `git status` for untracked files + - The Cli project restore can hang in sandbox mode; build/test individual projects when possible + - ManualFetcher splits on `---` string separator and trims/filters empty entries +--- + +## 2026-04-04 22:00 BST - US-010 +- Implemented PlatformRules record in Writegeist.Core/Models +- Implemented PlatformConventions static class in Writegeist.Core with GetRules(Platform) method +- Rules for LinkedIn (3000/1500), X (280/280), Instagram (2200/750), Facebook (63206/600) +- Files created: src/Writegeist.Core/Models/PlatformRules.cs, src/Writegeist.Core/PlatformConventions.cs, tests/Writegeist.Tests/PlatformConventionsTests.cs +- All 43 tests pass +- **Learnings for future iterations:** + - PlatformConventions is a static class at Writegeist.Core namespace level (not in Models) + - RecommendedHashtagCount for Instagram is up to 30 (much higher than other platforms) + - Tests use range assertions (BeInRange) for hashtag counts to match AC ranges like "3-5" +--- + +## 2026-04-04 22:05 BST - US-011 +- Implemented AnthropicProvider in Writegeist.Infrastructure/LlmProviders using raw HttpClient +- Sends requests to https://api.anthropic.com/v1/messages with x-api-key and anthropic-version headers +- API key from ANTHROPIC_API_KEY env var, model configurable via Writegeist:Anthropic:Model (default claude-sonnet-4-20250514) +- Handles 401, 429, 500+ errors with user-friendly messages +- Files created: src/Writegeist.Infrastructure/LlmProviders/AnthropicProvider.cs +- All 43 tests pass, typecheck passes (no unit tests required per AC — only "Typecheck passes") +- **Learnings for future iterations:** + - LLM providers live in Writegeist.Infrastructure/LlmProviders directory + - HttpClient is injected (for DI/testability), IConfiguration used for settings + - Anthropic API requires x-api-key header (not Bearer auth) and anthropic-version header + - All three ILlmProvider methods delegate to the same SendMessageAsync — the interface distinction is for future prompt differentiation +--- + +## 2026-04-05 - US-012 +- OpenAiProvider was implemented by a previous iteration but left uncommitted — committed it +- Uses official OpenAI NuGet package (v2.10.0) with ChatClient +- API key from OPENAI_API_KEY env var, model configurable via Writegeist:OpenAi:Model (default gpt-4o) +- Error handling catches exceptions and maps to user-friendly HttpRequestException messages +- Files committed: src/Writegeist.Infrastructure/LlmProviders/OpenAiProvider.cs, Writegeist.Infrastructure.csproj +- All 43 tests pass, build succeeds +- **Learnings for future iterations:** + - OpenAI SDK v2.x uses ChatClient(model, apiKey) constructor and CompleteChatAsync with UserChatMessage + - Error handling uses string matching on exception messages (SDK doesn't expose typed status codes cleanly) + - Pattern matches AnthropicProvider: constructor with IConfiguration, three methods delegate to one private method +--- + +## 2026-04-05 - US-017 +- Implemented IngestInteractiveAction extending RepeatableActionAsync +- Prompts for person name and platform on first iteration, then prompts for post content each iteration +- Uses SelectionPrompt for "Add another post?" (Yes/No), displays Panel summary on completion +- Files changed: src/Writegeist.Cli/Actions/IngestInteractiveAction.cs +- All 53 tests pass, build succeeds +- **Learnings for future iterations:** + - RepeatableActionAsync: return `true` to stop repeating, `false` to continue + - Use instance fields to track state across iterations (e.g., `_isFirstIteration`, `_newCount`) + - Primary constructor injection works the same for RepeatableActionAsync as SingleActionAsync + - SelectionPrompt with "Yes"/"No" choices is the pattern for confirmation prompts +--- + +## 2026-04-05 - US-018 +- Implemented AnalyseAction extending SingleActionAsync, added to MainMenu as 'Analyse Style' +- Prompts for person selection (with empty check), LLM provider selection (Anthropic/OpenAI), runs analysis with spinner +- Displays profile JSON in a rounded-border Panel; catches errors in red markup +- Files created: src/Writegeist.Cli/Actions/AnalyseAction.cs +- Files changed: src/Writegeist.Cli/Menus/MainMenu.cs +- All 53 tests pass, build succeeds +- **Learnings for future iterations:** + - StyleAnalyser takes ILlmProvider in constructor — to allow user provider selection, inject both concrete providers (AnthropicProvider, OpenAiProvider) and construct StyleAnalyser manually with the chosen one + - AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync() is the pattern for spinner during async operations + - MainMenu uses `.AddMenuItem("Label", "Description")` chain pattern + - Wrap entire action body in try/catch for error display without crashing the menu loop +--- + +## 2026-04-05 - US-019 +- Implemented GenerateAction extending SingleActionAsync, added to MainMenu as 'Generate Post' +- Filters persons to only those with style profiles before presenting selection +- Prompts for person, target platform (via Platform enum), topic, and LLM provider +- Constructs PostGenerator with selected LLM provider, generates draft with spinner, displays in rounded Panel +- Escapes generated content via Markup.Escape() before rendering in Panel +- Files created: src/Writegeist.Cli/Actions/GenerateAction.cs +- Files changed: src/Writegeist.Cli/Menus/MainMenu.cs +- All 53 tests pass, build succeeds +- **Learnings for future iterations:** + - SelectionPrompt works directly with enum types — no need to convert to strings + - Filter persons by checking GetLatestByPersonIdAsync for each — no batch method available + - Generated content must be Markup.Escape()'d before displaying in Spectre Panel to avoid markup parsing errors +--- + +## 2026-04-05 - US-020 +- Implemented RefineAction extending RepeatableActionAsync, added to MainMenu as 'Refine Last Draft' +- On first iteration: loads latest draft via IDraftRepository.GetLatestAsync, displays in Panel, prompts for LLM provider +- Each iteration: prompts for feedback, calls PostGenerator.RefineAsync with spinner, displays refined draft +- Asks "Refine again?" via SelectionPrompt — "No" returns true (stop), "Yes" returns false (continue) +- Error handling wraps entire action in try/catch with red markup display +- Files created: src/Writegeist.Cli/Actions/RefineAction.cs +- Files changed: src/Writegeist.Cli/Menus/MainMenu.cs +- All 53 tests pass, build succeeds +- **Learnings for future iterations:** + - RefineAction needs both LLM provider concrete types injected for user selection (same pattern as GenerateAction/AnalyseAction) + - PostGenerator is constructed manually with the selected provider, same as GenerateAction + - _currentDraft tracks state across iterations — updated after each refinement so the next iteration refines the latest version + - LLM provider selection only happens on first iteration to avoid re-prompting during refinement loop +--- + +## 2026-04-05 - US-021 +- Implemented ProfileMenu extending Menu (quitable: false, isTopLevel: false) with Show Profile and List All Profiles items +- ShowProfileAction: prompts for person (filtered to those with profiles), displays profile as structured Table (parsing JSON properties as rows) with fallback to raw Panel if not valid JSON +- ListProfilesAction: displays a Table of all persons with profiles showing name, provider, model, and created date +- Both actions show yellow message if no profiles exist +- MainMenu updated to add ProfileMenu as 'Profiles' menu item +- Files created: src/Writegeist.Cli/Menus/ProfileMenu.cs, src/Writegeist.Cli/Actions/ShowProfileAction.cs, src/Writegeist.Cli/Actions/ListProfilesAction.cs +- Files changed: src/Writegeist.Cli/Menus/MainMenu.cs +- All 53 tests pass, build succeeds +- **Learnings for future iterations:** + - ProfileMenu follows same pattern as IngestMenu: Menu(quitable: false, isTopLevel: false) + - ShowProfileAction uses System.Text.Json to parse ProfileJson into a Table with Section/Details columns + - Fallback to raw Panel display when ProfileJson is not valid JSON (e.g., plain text LLM responses) + - Filter persons to only those with profiles by iterating and checking GetLatestByPersonIdAsync (same pattern as GenerateAction) +--- + +## 2026-04-05 - US-022 +- Implemented LinkedInFetcher, InstagramFetcher, FacebookFetcher as stub fetchers +- Each returns correct Platform enum value via expression-bodied property +- FetchPostsAsync throws NotSupportedException with descriptive message suggesting From File or Interactive Paste +- Files created: src/Writegeist.Infrastructure/Fetchers/LinkedInFetcher.cs, InstagramFetcher.cs, FacebookFetcher.cs +- All 53 tests pass, build succeeds +- **Learnings for future iterations:** + - Stub fetchers use expression-bodied Platform property (`=> Platform.X`) vs ManualFetcher's constructor-injected platform + - NotSupportedException is the appropriate exception type for "not implemented" platform fetchers + - These fetchers will be resolved by platform in US-023's fetcher dispatch +--- + +## 2026-04-05 - US-023 +- Implemented IngestFromUrlAction extending SingleActionAsync with full fetcher dispatch +- Prompts for person name, platform, and URL/handle via Spectre.Console prompts +- Resolves correct IContentFetcher by platform from IEnumerable injected via DI +- Catches NotSupportedException from stub fetchers and displays message in yellow markup +- Catches general exceptions in red markup without crashing +- Displays Panel summary for successful fetches (same pattern as IngestFromFileAction) +- Registered LinkedInFetcher, InstagramFetcher, FacebookFetcher as IContentFetcher in Program.cs DI +- Files changed: src/Writegeist.Cli/Actions/IngestFromUrlAction.cs, src/Writegeist.Cli/Program.cs +- All 53 tests pass, build succeeds +- **Learnings for future iterations:** + - Register fetchers as IContentFetcher (not concrete types) to enable IEnumerable injection + - Fetcher dispatch pattern: inject IEnumerable, filter by Platform property + - NotSupportedException caught separately from general exceptions for yellow vs red markup display + - FetchRequest populates both Url and Handle from the same user input (fetcher decides which to use) + - When US-024 (XTwitterFetcher) is implemented, just register it as another IContentFetcher — no action changes needed +--- + +## 2026-04-05 - US-024 +- XTwitterFetcher was already implemented by a previous iteration but left uncommitted — committed it +- Uses HttpClient to call X API v2: resolves username to user ID, then fetches up to 100 tweets +- Bearer token from X_BEARER_TOKEN env var; throws descriptive exception if missing +- Handles 429 rate limit with user-friendly message; parses tweet text, id, and created_at +- DI registration as IContentFetcher was already in place from US-023 +- Files committed: src/Writegeist.Infrastructure/Fetchers/XTwitterFetcher.cs +- All 53 tests pass, build succeeds +- **Learnings for future iterations:** + - XTwitterFetcher resolves handle to user ID via /2/users/by/username/{handle} before fetching tweets + - X API v2 uses Bearer token auth (not API key header like Anthropic) + - tweet.fields=created_at query param needed to get published dates + - Previous iterations may leave files untracked — always check git status for pending work --- diff --git a/scripts/ralph/prompt.md b/scripts/ralph/prompt.md new file mode 100644 index 0000000..cdebe90 --- /dev/null +++ b/scripts/ralph/prompt.md @@ -0,0 +1,108 @@ +# Ralph Agent Instructions + +You are an autonomous coding agent working on a software project. + +## Your Task + +1. Read the PRD at `prd.json` (in the same directory as this file) +2. Read the progress log at `progress.txt` (check Codebase Patterns section first) +3. Check you're on the correct branch from PRD `branchName`. If not, check it out or create from main. +4. Pick the **highest priority** user story where `passes: false` +5. Implement that single user story +6. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires) +7. Update AGENTS.md files if you discover reusable patterns (see below) +8. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]` +9. Update the PRD to set `passes: true` for the completed story +10. Append your progress to `progress.txt` + +## Progress Report Format + +APPEND to progress.txt (never replace, always append): +``` +## [Date/Time] - [Story ID] +Thread: https://ampcode.com/threads/$AMP_CURRENT_THREAD_ID +- What was implemented +- Files changed +- **Learnings for future iterations:** + - Patterns discovered (e.g., "this codebase uses X for Y") + - Gotchas encountered (e.g., "don't forget to update Z when changing W") + - Useful context (e.g., "the evaluation panel is in component X") +--- +``` + +Include the thread URL so future iterations can use the `read_thread` tool to reference previous work if needed. + +The learnings section is critical - it helps future iterations avoid repeating mistakes and understand the codebase better. + +## Consolidate Patterns + +If you discover a **reusable pattern** that future iterations should know, add it to the `## Codebase Patterns` section at the TOP of progress.txt (create it if it doesn't exist). This section should consolidate the most important learnings: + +``` +## Codebase Patterns +- Example: Use `sql` template for aggregations +- Example: Always use `IF NOT EXISTS` for migrations +- Example: Export types from actions.ts for UI components +``` + +Only add patterns that are **general and reusable**, not story-specific details. + +## Update AGENTS.md Files + +Before committing, check if any edited files have learnings worth preserving in nearby AGENTS.md files: + +1. **Identify directories with edited files** - Look at which directories you modified +2. **Check for existing AGENTS.md** - Look for AGENTS.md in those directories or parent directories +3. **Add valuable learnings** - If you discovered something future developers/agents should know: + - API patterns or conventions specific to that module + - Gotchas or non-obvious requirements + - Dependencies between files + - Testing approaches for that area + - Configuration or environment requirements + +**Examples of good AGENTS.md additions:** +- "When modifying X, also update Y to keep them in sync" +- "This module uses pattern Z for all API calls" +- "Tests require the dev server running on PORT 3000" +- "Field names must match the template exactly" + +**Do NOT add:** +- Story-specific implementation details +- Temporary debugging notes +- Information already in progress.txt + +Only update AGENTS.md if you have **genuinely reusable knowledge** that would help future work in that directory. + +## Quality Requirements + +- ALL commits must pass your project's quality checks (typecheck, lint, test) +- Do NOT commit broken code +- Keep changes focused and minimal +- Follow existing code patterns + +## Browser Testing (Required for Frontend Stories) + +For any story that changes UI, you MUST verify it works in the browser: + +1. Load the `dev-browser` skill +2. Navigate to the relevant page +3. Verify the UI changes work as expected +4. Take a screenshot if helpful for the progress log + +A frontend story is NOT complete until browser verification passes. + +## Stop Condition + +After completing a user story, check if ALL stories have `passes: true`. + +If ALL stories are complete and passing, reply with: +COMPLETE + +If there are still stories with `passes: false`, end your response normally (another iteration will pick up the next story). + +## Important + +- Work on ONE story per iteration +- Commit frequently +- Keep CI green +- Read the Codebase Patterns section in progress.txt before starting diff --git a/scripts/ralph/ralph.sh b/scripts/ralph/ralph.sh new file mode 100755 index 0000000..fffec0d --- /dev/null +++ b/scripts/ralph/ralph.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# Ralph Wiggum - Long-running AI agent loop +# Usage: ./ralph.sh [--tool amp|claude] [max_iterations] + +set -e + +# Parse arguments +TOOL="amp" # Default to amp for backwards compatibility +MAX_ITERATIONS=10 + +while [[ $# -gt 0 ]]; do + case $1 in + --tool) + TOOL="$2" + shift 2 + ;; + --tool=*) + TOOL="${1#*=}" + shift + ;; + *) + # Assume it's max_iterations if it's a number + if [[ "$1" =~ ^[0-9]+$ ]]; then + MAX_ITERATIONS="$1" + fi + shift + ;; + esac +done + +# Validate tool choice +if [[ "$TOOL" != "amp" && "$TOOL" != "claude" ]]; then + echo "Error: Invalid tool '$TOOL'. Must be 'amp' or 'claude'." + exit 1 +fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PRD_FILE="$SCRIPT_DIR/prd.json" +PROGRESS_FILE="$SCRIPT_DIR/progress.txt" +ARCHIVE_DIR="$SCRIPT_DIR/archive" +LAST_BRANCH_FILE="$SCRIPT_DIR/.last-branch" + +# Archive previous run if branch changed +if [ -f "$PRD_FILE" ] && [ -f "$LAST_BRANCH_FILE" ]; then + CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "") + LAST_BRANCH=$(cat "$LAST_BRANCH_FILE" 2>/dev/null || echo "") + + if [ -n "$CURRENT_BRANCH" ] && [ -n "$LAST_BRANCH" ] && [ "$CURRENT_BRANCH" != "$LAST_BRANCH" ]; then + # Archive the previous run + DATE=$(date +%Y-%m-%d) + # Strip "ralph/" prefix from branch name for folder + FOLDER_NAME=$(echo "$LAST_BRANCH" | sed 's|^ralph/||') + ARCHIVE_FOLDER="$ARCHIVE_DIR/$DATE-$FOLDER_NAME" + + echo "Archiving previous run: $LAST_BRANCH" + mkdir -p "$ARCHIVE_FOLDER" + [ -f "$PRD_FILE" ] && cp "$PRD_FILE" "$ARCHIVE_FOLDER/" + [ -f "$PROGRESS_FILE" ] && cp "$PROGRESS_FILE" "$ARCHIVE_FOLDER/" + echo " Archived to: $ARCHIVE_FOLDER" + + # Reset progress file for new run + echo "# Ralph Progress Log" > "$PROGRESS_FILE" + echo "Started: $(date)" >> "$PROGRESS_FILE" + echo "---" >> "$PROGRESS_FILE" + fi +fi + +# Track current branch +if [ -f "$PRD_FILE" ]; then + CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "") + if [ -n "$CURRENT_BRANCH" ]; then + echo "$CURRENT_BRANCH" > "$LAST_BRANCH_FILE" + fi +fi + +# Initialize progress file if it doesn't exist +if [ ! -f "$PROGRESS_FILE" ]; then + echo "# Ralph Progress Log" > "$PROGRESS_FILE" + echo "Started: $(date)" >> "$PROGRESS_FILE" + echo "---" >> "$PROGRESS_FILE" +fi + +echo "Starting Ralph - Tool: $TOOL - Max iterations: $MAX_ITERATIONS" + +for i in $(seq 1 $MAX_ITERATIONS); do + echo "" + echo "===============================================================" + echo " Ralph Iteration $i of $MAX_ITERATIONS ($TOOL)" + echo "===============================================================" + + # Run the selected tool with the ralph prompt + if [[ "$TOOL" == "amp" ]]; then + OUTPUT=$(cat "$SCRIPT_DIR/prompt.md" | amp --dangerously-allow-all 2>&1 | tee /dev/stderr) || true + else + # Claude Code: stream JSON events and display activity in real-time + STREAM_FILE=$(mktemp) + claude --dangerously-skip-permissions --print --output-format stream-json --verbose < "$SCRIPT_DIR/CLAUDE.md" 2>&1 \ + | while IFS= read -r line; do + echo "$line" >> "$STREAM_FILE" + # Parse each JSON line and display activity + type=$(echo "$line" | jq -r '.type // empty' 2>/dev/null) + case "$type" in + assistant) + # Show tool calls as they happen + echo "$line" | jq -r ' + .message.content[]? | + select(.type == "tool_use") | + " \u001b[36m⚡ " + .name + + (if .name == "Read" then " → " + (.input.file_path // "" | split("/") | last) + elif .name == "Edit" then " → " + (.input.file_path // "" | split("/") | last) + elif .name == "Write" then " → " + (.input.file_path // "" | split("/") | last) + elif .name == "Bash" then " → " + (.input.command // "" | .[0:80]) + elif .name == "Grep" then " → " + (.input.pattern // "") + elif .name == "Glob" then " → " + (.input.pattern // "") + else "" + end) + "\u001b[0m" + ' 2>/dev/null >&2 + ;; + result) + echo "$line" | jq -r ' + "\u001b[33m✓ Turn complete (" + (.num_turns // 0 | tostring) + " turns, $" + ((.total_cost_usd // 0 * 100 | round / 100) | tostring) + ")\u001b[0m" + ' 2>/dev/null >&2 + ;; + esac + done || true + + # Extract final result text for completion check + OUTPUT=$(jq -r 'select(.type == "result") | .result // empty' "$STREAM_FILE" 2>/dev/null | tail -1) + rm -f "$STREAM_FILE" + fi + + # Check for completion signal + if echo "$OUTPUT" | grep -q "COMPLETE"; then + echo "" + echo "Ralph completed all tasks!" + echo "Completed at iteration $i of $MAX_ITERATIONS" + exit 0 + fi + + echo "Iteration $i complete. Continuing..." + sleep 2 +done + +echo "" +echo "Ralph reached max iterations ($MAX_ITERATIONS) without completing all tasks." +echo "Check $PROGRESS_FILE for status." +exit 1 diff --git a/src/Writegeist.Cli/Program.cs b/src/Writegeist.Cli/Program.cs index 7484f81..8ee7c53 100644 --- a/src/Writegeist.Cli/Program.cs +++ b/src/Writegeist.Cli/Program.cs @@ -46,6 +46,7 @@ services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Services services.AddSingleton(); diff --git a/tasks/prd-writegeist.md b/tasks/prd-writegeist.md new file mode 100644 index 0000000..48e5a17 --- /dev/null +++ b/tasks/prd-writegeist.md @@ -0,0 +1,277 @@ +# PRD: Writegeist + +## Introduction + +Writegeist is a .NET interactive CLI tool that learns a person's writing style from their social media posts and generates new platform-specific content in that style. It solves the problem of maintaining authentic, consistent voice across social platforms — you feed it your existing posts, it builds a reusable style profile, and then ghostwrites new posts that sound like you. + +The tool uses an interactive menu-driven interface (via DevJonny.InteractiveCli) with polished Spectre.Console output, supports both Anthropic and OpenAI as LLM backends, and stores all data locally in SQLite. + +## Goals + +- Clone the user's personal writing style from existing social media posts into a reusable profile +- Generate platform-aware drafts (LinkedIn, X, Instagram, Facebook) that match the user's voice +- Support iterative refinement of generated drafts with natural language feedback +- Provide a polished interactive CLI experience with tables, panels, colours, and spinners +- Support both Anthropic (Claude) and OpenAI as LLM providers from day one +- Support all four platforms with automated fetching where possible and manual fallbacks where not + +## User Stories + +### US-001: Solution scaffolding +**Description:** As a developer, I need the .NET solution structure created with all projects, references, and NuGet packages so that I can start building features. + +**Acceptance Criteria:** +- [ ] `Writegeist.sln` with four projects: `Writegeist.Cli`, `Writegeist.Core`, `Writegeist.Infrastructure`, `Writegeist.Tests` +- [ ] Project references wired: Cli → Core + Infrastructure, Infrastructure → Core, Tests → Core + Infrastructure +- [ ] `Writegeist.Cli` references `DevJonny.InteractiveCli` NuGet package +- [ ] `Writegeist.Infrastructure` references `Microsoft.Data.Sqlite`, `AngleSharp`, `Anthropic` (or raw HTTP fallback), `OpenAI` +- [ ] `Writegeist.Tests` references `xunit`, `FluentAssertions`, and a mocking library +- [ ] `Program.cs` bootstraps InteractiveCli with a MainMenu that displays and quits cleanly +- [ ] `dotnet build` succeeds with no errors +- [ ] `dotnet run` launches the interactive menu + +### US-002: SQLite database and schema +**Description:** As a developer, I need a SQLite database with the schema for persons, raw posts, style profiles, and generated drafts so that all data persists locally. + +**Acceptance Criteria:** +- [ ] `SqliteDatabase.EnsureCreated()` creates the database file and all four tables (`persons`, `raw_posts`, `style_profiles`, `generated_drafts`) +- [ ] Schema matches the data model spec (auto-increment IDs, foreign keys, unique constraints, defaults) +- [ ] Database path is configurable via `appsettings.json` (`Writegeist:DatabasePath`) +- [ ] Tables are created idempotently (safe to call multiple times) +- [ ] Unit tests verify schema creation with in-memory SQLite + +### US-003: Person repository +**Description:** As a developer, I need CRUD operations for persons so that the ingest and analyse workflows can store and retrieve people. + +**Acceptance Criteria:** +- [ ] `IPersonRepository` interface in Core with methods: `CreateAsync`, `GetByNameAsync`, `GetAllAsync`, `GetOrCreateAsync` +- [ ] `SqlitePersonRepository` implementation in Infrastructure +- [ ] `GetOrCreateAsync` returns existing person if name matches (case-insensitive), creates if not +- [ ] Unit tests with in-memory SQLite cover create, get, get-all, and dedup scenarios + +### US-004: Post repository +**Description:** As a developer, I need CRUD operations for raw posts so that ingested content can be stored and retrieved per person. + +**Acceptance Criteria:** +- [ ] `IPostRepository` interface in Core with methods: `AddAsync`, `GetByPersonIdAsync`, `GetCountByPersonIdAsync`, `ExistsByHashAsync` +- [ ] `SqlitePostRepository` implementation in Infrastructure +- [ ] Posts are deduplicated by SHA-256 content hash per person (unique constraint on `person_id` + `content_hash`) +- [ ] `AddAsync` returns a result indicating whether the post was new or a duplicate +- [ ] Unit tests cover add, dedup, and retrieval + +### US-005: Style profile repository +**Description:** As a developer, I need storage for style profiles so that analysed profiles persist and can be retrieved for generation. + +**Acceptance Criteria:** +- [ ] `IStyleProfileRepository` interface in Core with methods: `SaveAsync`, `GetLatestByPersonIdAsync`, `GetAllByPersonIdAsync` +- [ ] `SqliteStyleProfileRepository` implementation in Infrastructure +- [ ] `GetLatestByPersonIdAsync` returns the most recent profile by `created_at` +- [ ] Profile JSON stored as text, provider and model recorded +- [ ] Unit tests cover save, retrieve latest, and retrieve all + +### US-006: Draft repository +**Description:** As a developer, I need storage for generated drafts so that the refine workflow can retrieve and chain drafts. + +**Acceptance Criteria:** +- [ ] Draft repository interface with methods: `SaveAsync`, `GetLatestAsync`, `GetByIdAsync` +- [ ] `GetLatestAsync` returns the most recently created draft (across all persons) +- [ ] `parent_draft_id` links refined drafts to their predecessors +- [ ] Unit tests cover save, retrieval, and parent-child linking + +### US-007: Manual fetcher — file import +**Description:** As a user, I want to import my posts from a text file so that I can feed Writegeist content from any platform without needing API access. + +**Acceptance Criteria:** +- [ ] `ManualFetcher` implements `IContentFetcher` +- [ ] Reads a text file and splits posts on `---` separator lines +- [ ] Trims whitespace from each post, skips empty entries +- [ ] Returns a list of `FetchedPost` records +- [ ] Unit tests cover normal file, empty file, file with no separators, and file with consecutive separators + +### US-008: Manual fetcher — interactive paste +**Description:** As a user, I want to paste posts one at a time in an interactive session so that I can quickly add content without creating a file. + +**Acceptance Criteria:** +- [ ] Interactive mode uses Spectre.Console `TextPrompt` for multi-line input +- [ ] User pastes a post, confirms, then is asked if they want to add another +- [ ] Session ends when user chooses to stop +- [ ] Each post is returned as a `FetchedPost` + +### US-009: Ingest menu and actions +**Description:** As a user, I want a sub-menu for ingesting posts with options for file import, URL/handle fetch, and interactive paste so that I can choose my preferred input method. + +**Acceptance Criteria:** +- [ ] `IngestMenu` appears as "Ingest Posts" in the MainMenu +- [ ] Sub-menu contains: "From File", "From URL / Handle", "Interactive Paste", "Back" +- [ ] `IngestFromFileAction` prompts for person name, platform (selection), and file path +- [ ] `IngestInteractiveAction` prompts for person name and platform, then loops for posts +- [ ] `IngestFromUrlAction` prompts for person name, platform, and URL/handle +- [ ] All actions display a Spectre.Console summary panel after ingestion: new posts count, duplicates skipped +- [ ] Person is created in SQLite if they don't exist + +### US-010: Platform conventions +**Description:** As a developer, I need platform-specific rules (character limits, hashtag guidance, formatting) so that generated posts follow each platform's norms. + +**Acceptance Criteria:** +- [ ] `PlatformConventions` class with a static lookup returning `PlatformRules` for each platform +- [ ] LinkedIn: 3,000 max chars, 1,200–1,800 recommended, 3–5 hashtags at end, sparingly emoji +- [ ] X/Twitter: 280 max chars (single tweet), 1–2 inline hashtags, moderate emoji +- [ ] Instagram: 2,200 max chars, 500–1,000 recommended, up to 30 hashtags in separate block, freely emoji +- [ ] Facebook: 63,206 max chars, 400–800 recommended, 1–3 hashtags or none, moderate emoji +- [ ] Unit tests verify rules for all four platforms + +### US-011: Anthropic LLM provider +**Description:** As a user, I want to use Claude as the LLM backend so that I can analyse my style and generate posts using Anthropic's models. + +**Acceptance Criteria:** +- [ ] `AnthropicProvider` implements `ILlmProvider` +- [ ] Uses the official `Anthropic` NuGet SDK if available, otherwise raw HTTP to `https://api.anthropic.com/v1/messages` +- [ ] API key read from `ANTHROPIC_API_KEY` environment variable +- [ ] Model configurable via `appsettings.json` (`Writegeist:Anthropic:Model`) +- [ ] Handles API errors gracefully with user-friendly messages +- [ ] Spinner displayed during API calls using Spectre.Console `AnsiConsole.Status()` + +### US-012: OpenAI LLM provider +**Description:** As a user, I want to use OpenAI as an alternative LLM backend so that I have a choice of providers. + +**Acceptance Criteria:** +- [ ] `OpenAiProvider` implements `ILlmProvider` +- [ ] Uses the official `OpenAI` NuGet package +- [ ] API key read from `OPENAI_API_KEY` environment variable +- [ ] Model configurable via `appsettings.json` (`Writegeist:OpenAi:Model`) +- [ ] Handles API errors gracefully with user-friendly messages +- [ ] Spinner displayed during API calls + +### US-013: Style analysis +**Description:** As a user, I want to analyse my ingested posts to build a style profile so that Writegeist can learn how I write. + +**Acceptance Criteria:** +- [ ] `AnalyseAction` appears as "Analyse Style" in the MainMenu +- [ ] Prompts user to select a person from existing persons (Spectre.Console selection prompt) +- [ ] Prompts user to select LLM provider (defaults from config) +- [ ] `StyleAnalyser` service loads all raw posts for the person and sends to the LLM with the style analysis prompt +- [ ] Resulting style profile JSON is stored in SQLite with provider and model metadata +- [ ] Profile summary is displayed in a styled Spectre.Console panel after analysis +- [ ] Shows an error if the person has no ingested posts +- [ ] Spinner displayed while LLM processes + +### US-014: Post generation +**Description:** As a user, I want to generate a new post in my style for a specific platform so that I can quickly create on-brand content. + +**Acceptance Criteria:** +- [ ] `GenerateAction` appears as "Generate Post" in the MainMenu +- [ ] Prompts user to select a person (only persons with a style profile) +- [ ] Prompts user to select a target platform (selection prompt) +- [ ] Prompts user to enter topic/key points (multi-line text prompt) +- [ ] `PostGenerator` service combines style profile + platform conventions + topic into the generation prompt +- [ ] Generated draft is stored in SQLite as a `GeneratedDraft` +- [ ] Generated post is displayed in a styled Spectre.Console panel +- [ ] Shows an error if no style profile exists for the selected person +- [ ] Spinner displayed while LLM processes + +### US-015: Draft refinement +**Description:** As a user, I want to refine the last generated draft with natural language feedback so that I can iterate towards the perfect post. + +**Acceptance Criteria:** +- [ ] `RefineAction` appears as "Refine Last Draft" in the MainMenu +- [ ] Loads the most recent `GeneratedDraft` and displays it +- [ ] Prompts for feedback (text prompt) +- [ ] Sends refinement prompt to LLM with current draft + feedback + style profile +- [ ] Stores refined draft linked to the previous one (`parent_draft_id`) +- [ ] Displays the refined post in a styled panel +- [ ] Asks "Refine again?" — loops if yes, returns to menu if no (uses `RepeatableActionAsync`) +- [ ] Shows an error if no drafts exist yet +- [ ] Spinner displayed while LLM processes + +### US-016: Profile viewing +**Description:** As a user, I want to view and list style profiles so that I can see what Writegeist has learned about my writing. + +**Acceptance Criteria:** +- [ ] `ProfileMenu` appears as "Profiles" in the MainMenu +- [ ] Sub-menu contains: "Show Profile", "List All Profiles", "Back" +- [ ] `ShowProfileAction` prompts for person selection and displays the full style profile in a formatted Spectre.Console table +- [ ] `ListProfilesAction` displays a table of all persons with profiles, showing name, provider, model, and date +- [ ] Shows a message if no profiles exist + +### US-017: X/Twitter API fetcher +**Description:** As a user, I want to fetch my recent tweets automatically so that I don't have to manually copy them. + +**Acceptance Criteria:** +- [ ] `XTwitterFetcher` implements `IContentFetcher` for platform `X` +- [ ] Uses X API v2 `GET /2/users/{id}/tweets` endpoint +- [ ] Bearer token read from `X_BEARER_TOKEN` environment variable +- [ ] Fetches up to 100 recent tweets per request +- [ ] Handles 429 rate limit responses gracefully with a user-friendly message +- [ ] Falls back to manual input suggestion if no bearer token is configured + +### US-018: Stub fetchers for unsupported platforms +**Description:** As a developer, I need stub fetchers for LinkedIn, Instagram, and Facebook that give clear guidance to use manual input instead. + +**Acceptance Criteria:** +- [ ] `LinkedInFetcher`, `InstagramFetcher`, `FacebookFetcher` each implement `IContentFetcher` +- [ ] Each throws a user-friendly exception explaining that automated fetching is not available for this platform +- [ ] Exception message suggests using "From File" or "Interactive Paste" instead +- [ ] `IngestFromUrlAction` catches these exceptions and displays the message via Spectre.Console markup + +### US-019: Polished UI +**Description:** As a user, I want a visually polished CLI experience with colours, panels, tables, and spinners so that the tool feels professional. + +**Acceptance Criteria:** +- [ ] All LLM calls wrapped in `AnsiConsole.Status()` with spinner animation +- [ ] Generated posts displayed in `Panel` with a border and title +- [ ] Style profiles displayed in formatted `Table` with grouped sections +- [ ] Ingestion summaries displayed in coloured markup (green for new, yellow for duplicates) +- [ ] Error messages displayed in red markup +- [ ] Menu descriptions are helpful and concise + +## Functional Requirements + +- FR-1: The application must launch into an interactive menu using DevJonny.InteractiveCli +- FR-2: The system must store persons, posts, style profiles, and drafts in a local SQLite database +- FR-3: The system must deduplicate ingested posts by SHA-256 content hash per person +- FR-4: The system must support ingesting posts from a text file (separated by `---`) or interactive paste +- FR-5: The system must support ingesting posts via X API v2 when a bearer token is configured +- FR-6: The system must display a user-friendly message when automated fetching is unavailable for a platform +- FR-7: The system must analyse ingested posts via an LLM and produce a structured JSON style profile +- FR-8: The system must generate platform-specific posts using a style profile and platform conventions +- FR-9: The system must support iterative refinement of the most recent draft with natural language feedback +- FR-10: The system must support both Anthropic (Claude) and OpenAI as LLM providers, selectable per action +- FR-11: The system must read API keys from environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `X_BEARER_TOKEN`) +- FR-12: The system must display spinners during LLM API calls and styled output for all results +- FR-13: The default LLM provider must be configurable via `appsettings.json` + +## Non-Goals + +- No web UI or API server — this is a local CLI tool only +- No direct posting to social platforms (no OAuth write access) +- No multi-tweet thread generation for X +- No image generation or caption writing for Instagram +- No headless browser scraping (Playwright) for LinkedIn/Instagram/Facebook +- No user authentication or multi-user access control +- No cloud storage — all data stays in local SQLite +- No automatic style drift detection or profile comparison +- No clipboard integration or copy-to-clipboard +- No scheduled or automated post generation + +## Technical Considerations + +- **DevJonny.InteractiveCli** provides the host builder, DI, Serilog logging, and Spectre.Console integration. Actions are auto-registered in DI by assembly scanning. +- **InteractiveCli supports .NET 10** — all projects target net10.0. +- LLM provider implementations should handle network failures and API errors without crashing the interactive session — catch exceptions and display errors, then return to the menu. +- The `Anthropic` NuGet package (official C# SDK) should be verified to exist. If not, implement raw HTTP calls to the Messages API. +- Style profile JSON schema should be well-defined in a C# model for serialisation/deserialisation, even though it's stored as raw JSON in SQLite. + +## Success Metrics + +- User can go from zero to generated post in under 5 minutes (ingest → analyse → generate) +- Generated posts are indistinguishable in style from the user's real posts (subjective, tested manually) +- Refinement loop produces noticeably improved output within 1–2 iterations +- Both LLM providers produce usable results with the same prompts +- The interactive CLI is intuitive enough to use without reading documentation + +## Open Questions + +- Should the style analysis prompt be tuneable/editable by the user, or fixed? +- Should there be a way to merge style profiles from multiple platforms into one? +- Should the tool support exporting generated posts to a file? +- What's the minimum number of posts needed for a reliable style analysis? Should we warn if too few? From 128dcd5ed750d366a13b6a1f69ad0caa46045be2 Mon Sep 17 00:00:00 2001 From: DevJonny Date: Sun, 5 Apr 2026 10:37:39 +0100 Subject: [PATCH 26/26] ci: add build and test workflow Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-and-test.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/build-and-test.yml diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..f15e673 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,28 @@ +name: Build and Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore + + - name: Test + run: dotnet test --no-build --verbosity normal