diff --git a/.claude/agents/outline-importer.md b/.claude/agents/outline-importer.md new file mode 100644 index 0000000..509b19d --- /dev/null +++ b/.claude/agents/outline-importer.md @@ -0,0 +1,205 @@ +--- +name: outline-importer +description: Imports an author's existing novel outline (chapters + character dossiers, in the Kingdom Sleeps folder format) into a Novelly project over the MCP server. Invoke explicitly with a source folder path — this agent does not run proactively. +tools: Read, Glob, Grep, Write, mcp__novelly__list_projects, mcp__novelly__get_project_brief, mcp__novelly__create_project, mcp__novelly__update_project_brief, mcp__novelly__list_chapters, mcp__novelly__get_chapter, mcp__novelly__create_chapter, mcp__novelly__update_chapter, mcp__novelly__get_chapter_outline, mcp__novelly__create_beat, mcp__novelly__update_beat, mcp__novelly__list_characters, mcp__novelly__get_character, mcp__novelly__create_character, mcp__novelly__update_character, mcp__novelly__get_character_arc, mcp__novelly__add_arc_stage, mcp__novelly__list_tags +model: inherit +--- + +You import a novel outline that already exists as markdown files on disk into Novelly, via the +`novelly` MCP server. You never touch the source files — read-only against them, always. You never +reach the database directly — every write goes through an `mcp__novelly__*` tool, same as the web +client uses. + +## Source folder shape + +You are given a source root (e.g. `/home/james/Documents/Novels/kingdom-sleeps/` or its +`examples/blade-itself/` subfolder). Expect: + +``` +/ + outline.md # title/author heading, blurb paragraph(s), table: | Chapter | Name | Summary | + outlines/NN-slug.md # one file per chapter (or chapters/NN-slug.md — check both names) + characters/.md # one file per character dossier + characters.md # optional index — informational only, do not import from it directly + story-bible.md # optional worldbuilding doc — out of scope this iteration, do not import +``` + +Chapter file shape: + +``` +# Chapter 01 +### The End + +**Thread:** Logen | **Part:** Part I + + + +| Beat | Character | What | Why | +|---|---|---|---| +| Cold open, mid-flight | Logen | | | +... + +## Notes +- +``` + +`**Thread:**` may name one character ("Logen"), several ("Rotating (Glokta, Jezal, Logen)"), or a +character plus a qualifier ("Logen (interleaved with brief Jezal cutaways)"). Only treat it as a POV +character when it names exactly one. + +Character dossier shape: + +``` +# Arch Lector Sult + +*Head of the King's Inquisition; one of the most powerful men in the Union.* + +## Appearance +... +## Background +... +## Motivation +... +## Events +- *(Ch. 3)* +- *(Ch. 6)* ... + +## Notes +- ... +``` + +`## Events` may be absent (most walk-on characters won't have one). Some bullets may lack a +`(Ch. N)` marker — carry those into the arc stage without a `chapterId`. + +## The ledger + +Before writing anything, look for `/.novelly-import.json`. If present, load it — it tells you +what a previous run of this agent already created, so you can resume without duplicating writes. +Shape: + +```json +{ + "projectId": "guid", + "characters": { "Arch Lector Sult": "guid", "Sult": "guid" }, + "chapters": { "1": "guid", "2": "guid" }, + "completedPasses": ["project", "characters"], + "completedChapters": [1, 2, 3] +} +``` + +Update it after **every** successful write (Write tool, full rewrite of the file — it's small). +If a tool call fails partway through a chapter, the ledger's `completedChapters` will not include +it, so re-running retries that chapter cleanly; do not re-run `create_chapter` for a chapter number +already recorded as complete. + +There is no server-side dedupe key — if the ledger is deleted or you skip consulting it, re-running +this agent will create duplicate projects/chapters/characters. Always check it first, and say so +in your final report. + +## Passes, strictly in order + +Do not skip ahead — each pass depends on ids the previous one minted. If you're picking up a +resumed run, jump straight to the first incomplete pass. + +**0. Preflight.** Call `list_projects` to confirm the API is reachable at all — if this fails, stop +and tell the user to start the API (`ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`) +and that `.mcp.json` must point at a published `Novelly.Mcp` binary. Glob the source root for +`outline.md`, `outlines/*.md` or `chapters/*.md`, and `characters/*.md`. If `outline.md` is +missing, stop — that's the one file every pass depends on. Report the file counts found before +proceeding. + +**1. Project.** Skip if `completedPasses` already has `"project"`. Read `outline.md`. Its heading is +`# Outline — (<Author>)` or similar — parse title and author out of it; if there's no +author, leave it null. The paragraph(s) before the chapter table are the blurb — pass as `notes` +argument to `create_project` (there's no dedicated blurb field; `synopsis` may be filled in later +by the author). Record `projectId` in the ledger, mark `"project"` complete. + +**2. Characters — dossier fields only, not arcs yet.** Skip files whose name (matched +case-insensitively against dossier `#` headings already recorded in the ledger) is already a key +in `characters`. For each `characters/*.md`: +- `name` from the `# ` heading +- `occupation` from the italic tagline right under the heading (strip the `*...*`) +- `appearance` ← `## Appearance` body +- `backstory` ← `## Background` body +- `want` ← `## Motivation` body (this section usually blends desire, need and conflict — all of it + goes in `want` this pass; don't try to split it into Need/InternalConflict/ExternalConflict, that + would be guessing) +- `notes` ← `## Notes` body, if present +- leave `role` and `importance` at their defaults (`Supporting`) — pass 4 promotes the ones with + `## Events` +Call `create_character`. Record the returned id under the exact dossier name **and** under any +shorter alias worth matching later (surname alone, most-used short form — e.g. both +"Arch Lector Sult" and "Sult" for the same id) so beat/thread name matching in pass 3 hits. Mark +`"characters"` complete once every dossier file has been processed. + +**3. Chapters + beats.** Process source chapter files in ascending number order (parse the number +from the filename prefix, e.g. `01-the-end.md` → 1). Skip any chapter number already in +`completedChapters`. Do a batch of roughly 10 chapters, then stop and report progress — don't try +to push all 46 through one turn; the user can re-invoke you to continue. + +For each chapter file: +1. Parse `### <Title>` for the title, the `**Thread:** X | **Part:** Y` line, the prose paragraph(s) + as the summary, the beat table, and `## Notes`. +2. Resolve `povCharacterId`: only when Thread names exactly one character (case-insensitive match + against the ledger's character map, including aliases). If that single name has no dossier and + isn't in the ledger yet, auto-create it first (see "Auto-created characters" below), then + resolve to the new id. Multi-name or qualified threads (parenthetical asides, "Rotating (...)") + leave this null — that's an unambiguous-name gate, not a dossier gate. +3. `create_chapter(projectId, title, number, summary, povCharacterId, status: "Outlined", tags: [<Part value>, "thread:<raw Thread text>"])`. + Keep the Part tag exactly as written ("Part I", "Part II"); keep the thread tag as the raw + Thread text so multi-POV chapters aren't lossy even though `povCharacterId` had to pick one or + none. +4. For each beat table row, resolve the Character column the same way: a single clear name gets + auto-created if it isn't in the ledger yet; a list ("Glokta, West, Jezal") or vague reference + stays unresolved rather than guessing which one the beat belongs to. Then + `create_beat(chapterId, title: <Beat column>, whatHappened: <What column>, whatsNext: <Why column>, characterId: <resolved id, else omit>)`, + in table order (the API appends in call order, so no explicit `sortOrder` needed). +5. If `## Notes` is present, `update_chapter(chapterId, notes: ...)`. +6. Record `chapters[number] = chapterId`, append `number` to `completedChapters`. + +**Auto-created characters.** A name is a good auto-create candidate when it's a single, unqualified +proper name — the same bar as POV resolution's "exactly one" rule. When you hit one that isn't in +the ledger's character map yet: `create_character(projectId, name)` with nothing else filled in +(role/importance stay at defaults — an outline mention alone isn't the "worth an arc" signal that +`## Events` is), then record the id under that exact name in the ledger's `characters` map so later +chapters and beats reuse it instead of creating a duplicate. This runs inline as you hit new names +during pass 3, not as a separate pass — order within a chapter file doesn't matter, but do it +before the `create_chapter`/`create_beat` call that needs the id. Still never guess which of +several candidate names a vague or list-form reference means; that gate is unchanged, only the +"no dossier = leave it unresolved" default is gone. + +Mark `"chapters"` complete only once every chapter file has been processed across however many +turns it takes. + +**4. Arc stages.** Skip if `completedPasses` has `"arcs"`. Re-scan the character dossiers for +`## Events`. For each one: +1. `update_character(characterId, importance: "Main")` — Events is exactly the "worth tracking an + arc for" signal the app's own tool descriptions point at. +2. For each `- *(Ch. N)* <text>` bullet, in order: `add_arc_stage(characterId, title: <3-5 word handle you synthesize from the bullet — don't just truncate it>, description: <the bullet text>, chapterId: <ledger lookup of chapter N, if the chapter has been imported>)`. + Bullets without a `(Ch. N)` marker still get a stage, just no `chapterId`. + +Mark `"arcs"` complete when every dossier with an `## Events` section has been processed. + +## Final report + +Always end with: +- Counts: project created (or resumed), characters created from a dossier, characters auto-created + from outline mentions alone (no dossier), chapters created, beats created, arc stages created +- Character names mentioned in Thread lines or beat Character columns that stayed unresolved — + now only the genuinely ambiguous ones (lists like "Glokta, West, Jezal", vague references) since + clear single names get auto-created rather than left as a gap +- Chapters left with no `povCharacterId` and why (multi-POV/rotating chapters, by design) +- Whether this was a fresh run or resumed from an existing ledger, and the ledger's path +- If a batch of chapters remains, say how many and that re-invoking you continues from there + +## Constraints + +- Never invent plot content, character detail, or resolve an ambiguous name by guessing which + character was meant — when a Thread or beat Character reference names more than one candidate or + is otherwise vague, leave it unresolved and report it, don't pick the closest-sounding one. A + clear single name with no dossier is not ambiguous — auto-create it per "Auto-created characters" + above rather than leaving it unresolved. +- Never edit or delete anything under the source root except `.novelly-import.json`. +- Never call a create tool for something already recorded in the ledger. +- If a tool call returns an error, stop that item, leave the ledger as of the last successful + write, and surface the exact error in your report rather than retrying blindly. diff --git a/README.md b/README.md index 28340a5..80a6b49 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,15 @@ dotnet publish src/Novelly.Mcp -c Release -o ./mcp-server The API must be running. If it is not, the tools say so in a message the model can act on rather than failing opaquely. +### Importing an existing outline + +`.claude/agents/outline-importer.md` is a Claude Code subagent that reads an author's outline +already on disk — chapter files and character dossiers in the folder shape described in that +file — and writes it into a new Novelly project over the MCP server above. It's read-only against +the source files and keeps a resumable ledger (`.novelly-import.json`) next to them, since a full +book is more tool calls than fit in one turn. Invoke it with the source folder path once the API is +running and `.mcp.json` is set up. + ## API surface `GET /api/health`, plus: diff --git a/docs/plans/api/outline_import_agent_output.md b/docs/plans/api/outline_import_agent_output.md new file mode 100644 index 0000000..8391f3b --- /dev/null +++ b/docs/plans/api/outline_import_agent_output.md @@ -0,0 +1,25 @@ +# Web-triggered outline import — implementation summary + +Implements the plan in `outline_import_agent_plan.md`. + +## Backend (`src/Novelly.Api/Imports/`) + +- `ImportJob` (+ EF migration `AddImportJobs`), `ImportPaths` (ledger read/write, root-containment path resolution, chapter-file counting — shared by the service and the toolset), `ImportDtos`, `ImportService`, `ImportEndpoints` (`POST /api/imports/inspect`, `POST /api/imports`, `GET /api/imports/{id}`). +- `ImportAgentToolset` — root-scoped `list_source_files`/`read_source_file`/`read_ledger`/`write_ledger` (the only write capability, enforced at the tool layer, not just the prompt) plus `create_project`/`update_project_brief`/`create_character`/`update_character`/`create_chapter`/`update_chapter`/`create_beat`/`add_arc_stage` wrapping the same application services the REST API and chat agent use. +- `ImportAgentService` — system prompt ported from `.claude/agents/outline-importer.md`'s passes; runs bounded turns (`AgentOptions.ImportMaxIterationsPerTurn` = 40 tool calls/turn, `ImportMaxTurns` = 8 turns), re-reading the ledger after each turn as ground truth for "done" rather than trusting the model. +- `ImportJobRunner : BackgroundService` — the app's first background-job infra, a `Channel<Guid>`-backed queue drained in its own DI scope per job. + +Wired into `NovellyServiceRegistration`/`Program.cs`. All 98 backend tests pass, including 14 new ones (`ImportServiceTests`, `ImportAgentToolsetTests`) covering inspect states, job dedup, force-restart's project+ledger deletion, path-traversal rejection, and the ledger-only write scope. + +## Frontend (`src/Novelly.Web`) + +- `api/types.ts` / `api/hooks.ts`: `ImportJob`/`ImportInspection` types, `useInspectImport`/`useStartImport`/`useImportJob` (polling, stops on terminal status). +- `components/ImportDialog.tsx`: path input → Check → Start/Resume/Delete-and-reimport → progress polling → done. Wired into `OverviewPage.tsx` (new aside card) and `ProjectsPage.tsx` (button next to "New novel"). + +**Scope note:** import always creates its own project (it never populates the project you're already viewing) — the Overview card's copy says so explicitly and navigates to the new project on completion, since threading an "import into this existing project" mode through the toolset/ledger format wasn't part of the approved plan. + +## Verified live (not just tests) + +- Restarted the Aspire AppHost to pick up the migration + new code. +- `POST /api/imports/inspect` against the real `examples/blade-itself` folder (partially imported by the CLI subagent earlier this session) correctly reported `Resumable`, 5/46 chapters, `["project","characters"]` passes — matches the CLI's own ledger exactly, confirming ledger-format compatibility between the two entry points. +- `POST /api/imports` + polling against a synthetic one-chapter outline exercised the full endpoint → queue → background-job → status-transition path for real over HTTP; no `ANTHROPIC_API_KEY` is configured in this environment, so it terminated as `Failed` with the expected `AgentNotConfiguredException` message rather than a real import — this is the correct behavior for an unconfigured key, but it means the model-driven happy path (actually calling Claude and writing chapters/characters) has **not** been verified live. That needs a configured key and is worth a manual pass before considering this done-done. diff --git a/docs/plans/api/outline_import_agent_plan.md b/docs/plans/api/outline_import_agent_plan.md new file mode 100644 index 0000000..63d0e3b --- /dev/null +++ b/docs/plans/api/outline_import_agent_plan.md @@ -0,0 +1,47 @@ +# Web-triggered outline import + +## Context + +Outline import currently only runs as a Claude Code subagent (`.claude/agents/outline-importer.md`) driven by a human typing `/import` in this CLI. James wants to kick off / resume the same import from the Novelly web app itself — a button on the Novel Overview tab and on the New Novel dialog, where the user pastes an absolute folder path (app is local-first; API and browser share a filesystem, confirmed with James). Parsing needs the same LLM judgment the subagent uses (title/blurb extraction, name-alias matching, arc-stage title synthesis, auto-creating characters mentioned in outlines without a dossier) — so this must run through the app's **embedded agent** (already wired to Anthropic via `ANTHROPIC_API_KEY`/`Agent:ApiKey`, same billing as existing agent features, no new credential). + +Two capabilities don't exist yet and both are required: +1. **Filesystem access for the agent** — today's `NovelAgentToolset` is 100% DB-mediated; nothing reads files off disk. +2. **Background execution** — the existing agent endpoint (`POST /api/projects/{id}/agent/messages`) is fully synchronous and capped at 12 tool-call iterations per request. A 46-chapter import needs hundreds of tool calls over minutes — that has to run off the request thread, with a pollable status the UI can watch. + +## Design + +### New `Imports` feature folder (`src/Novelly.Api/Imports/`) + +- **`ImportJob` entity** — `Id`, `SourceRoot`, `ProjectId?`, `Status` (`Pending|Running|Completed|Failed|Paused`), `StatusMessage`, `ChaptersCompleted`, `ChaptersTotal`, `CreatedAt`, `UpdatedAt`. New DbSet on `NovelDbContext`, new EF migration (`dotnet ef migrations add AddImportJobs`). +- **`ImportService`**: + - `InspectAsync(sourceRoot)` — read-only. Validates the path exists/is a directory, looks for `<sourceRoot>/.novelly-import.json`, reports one of: *no ledger* (fresh), *ledger incomplete* (resumable, with counts), *ledger complete* (all chapters + passes done). This backs the "check if there is work to do" requirement before anything starts. + - `StartOrResumeAsync(sourceRoot, forceRestart)` — fresh/resume: create-or-reuse an `ImportJob` row, enqueue its id, return the job. `forceRestart` (used for "complete → delete and reimport"): deletes the ledger file **and** the project the ledger points at (via existing `ProjectService.DeleteAsync` — cascades chapters/characters/beats like any other project delete), then starts clean. This is destructive — the UI must confirm before sending `forceRestart: true`. + - `GetStatusAsync(jobId)` — for polling. +- **`ImportEndpoints`**: `POST /api/imports/inspect`, `POST /api/imports` (`{ sourceRoot, forceRestart? }`), `GET /api/imports/{id}`. Same `RequestLoggingEndpointFilter`/`ValidationEndpointFilter` pattern as every other `MapGroup`. +- **`ImportJobRunner : BackgroundService`** — the only background-job infra in the app; there's currently none (no Hangfire/Quartz/hosted workers), so this is new. Backed by a singleton `Channel<Guid>`. `ImportService` writes job ids to the channel; the runner dequeues, opens a DI scope, resolves `ImportAgentService`, runs the import, updates the `ImportJob` row (progress + terminal status), catches exceptions into `Status = Failed`. + +### New `ImportAgentToolset` + `ImportAgentService` (mirrors `NovelAgentService`/`NovelAgentToolset`, not extends) + +Kept separate from the chat agent's toolset deliberately — filesystem access must never leak into normal chat conversations. + +- **Filesystem tools, root-scoped to one validated `sourceRoot` per run** (constructed per job, not shared DI singleton): + - `list_source_files(pattern)` — enumerate under root only. + - `read_source_file(relativePath)` — read text; canonicalize and reject anything resolving outside root (path traversal guard); size cap. + - `read_ledger()` / `write_ledger(json)` — the **only** write capability given to this agent, scoped to exactly `.novelly-import.json`. This enforces "never write anything under the source root except the ledger" at the tool-permission layer instead of trusting prompt text alone (matches the "promote to a dedicated tool for a security boundary" principle). +- **Domain tools**: same application services `NovelAgentToolset` already wraps (`ProjectService`, `CharacterService`, `CharacterArcService`, `ChapterService`, `BeatService`) — reuse those services, add thin tool wrappers for `create_project`, `update_project_brief`, `create_character`, `update_character`, `add_arc_stage`, `create_chapter`, `update_chapter`, `create_beat`. Same architecture rule as everywhere else: new capability = same service method, just a different surface. +- **System prompt**: the outline-importer's passes (preflight → project → characters (with auto-create for undossiered names, per the just-shipped agent update) → chapters+beats → arcs), ported from `.claude/agents/outline-importer.md` into a template string, since there's no separate "subagent instructions" concept server-side — it's one system prompt. +- **Multi-turn driving loop** (this is what replaces the CLI's "batch 10 chapters, human re-invokes" pattern): each call to the model loop runs with a higher `MaxIterations` (e.g. 40) than the chat agent's 12. After the model stops calling tools, the **runner** — not the model — reads the ledger file directly and checks ground truth: are all chapters in `completedChapters`, are `completedPasses` complete? If not, send a synthetic "Continue the import from the ledger" user turn and loop again. Cap total driver-turns/tool-calls as a cost safety net; hitting the cap without completion sets `Status = Paused` (not `Failed`) with a message telling the user calling start again will resume — same ledger-driven resumability the CLI subagent already has. + +### Web client (`src/Novelly.Web`) + +- `api/types.ts` — add `ImportJob`, `ImportInspection`. +- `api/hooks.ts` — `useInspectImport()`, `useStartImport()`, `useImportJob(jobId)` (poll via `refetchInterval`, stop on terminal status — this is the first polling pattern in the app; no SSE/websocket infra exists to reuse), `useRestartImport()`. +- New `components/ImportDialog.tsx`: text input for the absolute path → "Check" calls inspect → renders one of: *Start import* (fresh) / *Resume import* (incomplete, shows counts) / *Import complete — Delete & reimport* (complete, behind a `confirm()`-style guard same as the existing project-delete Danger Zone button). Once a job is running, show progress (`Spinner` + chapters completed/total, reusing the existing progress-bar markup from `OverviewPage.tsx`'s Progress card) via the polling hook; on completion, invalidate project/chapter/character queries. +- Wire the dialog into two places: + - `OverviewPage.tsx` — new aside card ("Import outline") between the existing Progress and Danger zone cards, same `card p-5` shape. + - `ProjectsPage.tsx`'s `CreateProjectModal` area — a secondary "Import from outline" button next to Create, since import mints its own project and bypasses the manual create form. + +## Verification + +- `dotnet test` — add `ImportServiceTests` (inspect: no ledger / incomplete / complete; start enqueues a job; forceRestart deletes project + ledger) and `ImportAgentToolsetTests` (path-traversal rejection on `read_source_file`, ledger-only write scope) following the existing `ServiceTestFixture`/`ScriptedModelClient` patterns — model calls stay faked, never hit the real Anthropic API from tests. +- Run the app for real per CLAUDE.md ("build+tests passing ≠ working" — this touches an endpoint and a background loop): `dotnet run --project src/Novelly.AppHost`, use the new Overview card against `examples/blade-itself` (already has a partial ledger from the CLI run) to confirm resume works, then against a fresh folder to confirm a clean run, then re-run against the completed one to confirm the delete-and-reimport path. diff --git a/src/Novelly.Api/Agent/AgentContracts.cs b/src/Novelly.Api/Agent/AgentContracts.cs index ee5d04a..f3f3f34 100644 --- a/src/Novelly.Api/Agent/AgentContracts.cs +++ b/src/Novelly.Api/Agent/AgentContracts.cs @@ -57,4 +57,19 @@ public class AgentOptions /// <summary>Falls back to the ANTHROPIC_API_KEY environment variable when unset.</summary> public string? ApiKey { get; set; } + + /// <summary> + /// Ceiling on model round-trips per <em>turn</em> of an outline import — higher than + /// <see cref="MaxIterations"/> because a batch of chapters needs far more tool calls + /// than a chat reply, but still bounded so a confused run can't spin forever. + /// </summary> + public int ImportMaxIterationsPerTurn { get; set; } = 40; + + /// <summary> + /// Ceiling on synthetic "continue" turns per import run. The run driver — not the + /// model — decides whether to keep going, by re-reading the ledger after each turn; this + /// is the safety net if it never reports done. Hitting it pauses the job rather than + /// failing it: re-starting the same source root resumes from the ledger. + /// </summary> + public int ImportMaxTurns { get; set; } = 8; } diff --git a/src/Novelly.Api/Agent/AgentDtos.cs b/src/Novelly.Api/Agent/AgentDtos.cs deleted file mode 100644 index 38a0050..0000000 --- a/src/Novelly.Api/Agent/AgentDtos.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Novelly.Api.Common.Validation; - -namespace Novelly.Api.Agent; - -public record ConversationSummaryDto( - Guid Id, - Guid ProjectId, - string Title, - int MessageCount, - DateTimeOffset UpdatedAt); - -public record ConversationDto( - Guid Id, - Guid ProjectId, - string Title, - IReadOnlyList<AgentMessageDto> Messages, - DateTimeOffset UpdatedAt); - -public record AgentMessageDto( - Guid Id, - AgentRole Role, - string Content, - IReadOnlyList<ToolCallDto> ToolCalls, - DateTimeOffset CreatedAt); - -/// <summary>A record of one tool the agent invoked, surfaced so the writer can audit changes.</summary> -public record ToolCallDto(string Name, string Input, string Result); - -public record SendAgentMessageRequest(string Message, Guid? ConversationId = null); - -public class SendAgentMessageRequestValidator : IModelValidator<SendAgentMessageRequest> -{ - public ValidationResult Validate(SendAgentMessageRequest model) - { - var result = new ValidationResult(); - - if (string.IsNullOrWhiteSpace(model.Message)) - result.AddError("Message", "'Message' must not be empty."); - else if (model.Message.Length > 20000) - result.AddError("Message", "'Message' must be 20,000 characters or fewer."); - - return result; - } -} - -public record AgentTurnDto(Guid ConversationId, AgentMessageDto Message); diff --git a/src/Novelly.Api/Agent/AgentEndpoints.cs b/src/Novelly.Api/Agent/AgentEndpoints.cs index addf1b0..67b31dd 100644 --- a/src/Novelly.Api/Agent/AgentEndpoints.cs +++ b/src/Novelly.Api/Agent/AgentEndpoints.cs @@ -21,7 +21,10 @@ public static class AgentEndpoints SendAgentMessageRequest request, NovelAgentService agent, CancellationToken ct) => - Results.Ok(await agent.SendMessageAsync(projectId, request, ct))) + { + var reply = await agent.SendMessageAsync(projectId, request, ct); + return Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse())); + }) .WithSummary("Send a message to the writing agent and run it to completion."); var conversations = app.MapGroup("/api/conversations").WithTags("Agent") @@ -29,7 +32,7 @@ public static class AgentEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) => - (await agent.GetConversationAsync(id, ct)).ToApiResult()) + (await agent.GetConversationAsync(id, ct))?.ToResponse().ToApiResult()) .WithSummary("Read a conversation's full transcript."); conversations.MapDelete("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) => diff --git a/src/Novelly.Api/Agent/AgentHttpContracts.cs b/src/Novelly.Api/Agent/AgentHttpContracts.cs new file mode 100644 index 0000000..9c3265a --- /dev/null +++ b/src/Novelly.Api/Agent/AgentHttpContracts.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Novelly.Api.Common.Validation; + +namespace Novelly.Api.Agent; + +public record ConversationSummaryResponse(Guid Id, Guid ProjectId, string Title, int MessageCount, DateTimeOffset UpdatedAt); + +public record ConversationResponse(Guid Id, Guid ProjectId, string Title, IReadOnlyList<AgentMessageResponse> Messages, DateTimeOffset UpdatedAt); + +public record AgentMessageResponse(Guid Id, AgentRole Role, string Content, IReadOnlyList<ToolCallResponse> ToolCalls, DateTimeOffset CreatedAt); + +public record ToolCallResponse(string Name, string Input, string Result); + +public record SendAgentMessageRequest(string Message, Guid? ConversationId = null); + +public class SendAgentMessageRequestValidator : IModelValidator<SendAgentMessageRequest> +{ + public ValidationResult Validate(SendAgentMessageRequest model) + { + var result = new ValidationResult(); + + if (string.IsNullOrWhiteSpace(model.Message)) + result.AddError("Message", "'Message' must not be empty."); + else if (model.Message.Length > 20000) + result.AddError("Message", "'Message' must be 20,000 characters or fewer."); + + return result; + } +} + +public record AgentTurnResponse(Guid ConversationId, AgentMessageResponse Message); + +public static class AgentMapping +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + Converters = { new JsonStringEnumConverter() } + }; + + public static AgentMessageResponse ToResponse(this AgentMessage message) => new( + message.Id, + message.Role, + message.Content, + message.ToolCallsJson is null + ? [] + : JsonSerializer.Deserialize<List<ToolCallResponse>>(message.ToolCallsJson, JsonOptions) ?? [], + message.CreatedAt); + + public static ConversationResponse ToResponse(this AgentConversation conversation) => new( + conversation.Id, + conversation.ProjectId, + conversation.Title, + [.. conversation.Messages.OrderBy(m => m.Sequence).Select(m => m.ToResponse())], + conversation.UpdatedAt); +} diff --git a/src/Novelly.Api/Agent/NovelAgentService.cs b/src/Novelly.Api/Agent/NovelAgentService.cs index 383e0e0..ad073ef 100644 --- a/src/Novelly.Api/Agent/NovelAgentService.cs +++ b/src/Novelly.Api/Agent/NovelAgentService.cs @@ -29,7 +29,7 @@ public class NovelAgentService( private readonly AgentOptions _options = options.Value; - public async Task<IReadOnlyList<ConversationSummaryDto>> ListConversationsAsync( + public async Task<IReadOnlyList<ConversationSummaryResponse>> ListConversationsAsync( Guid projectId, CancellationToken ct = default) { logger.LogInformation("Listing agent conversations for project {ProjectId}", projectId); @@ -37,29 +37,18 @@ public class NovelAgentService( return await db.Conversations .Where(c => c.ProjectId == projectId) .OrderByDescending(c => c.UpdatedAt) - .Select(c => new ConversationSummaryDto(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt)) + .Select(c => new ConversationSummaryResponse(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt)) .ToListAsync(ct); } /// <summary>Null when no conversation has this id — a lookup miss is expected, not exceptional.</summary> - public async Task<ConversationDto?> GetConversationAsync(Guid conversationId, CancellationToken ct = default) + public async Task<AgentConversation?> GetConversationAsync(Guid conversationId, CancellationToken ct = default) { Guard.Default(conversationId, nameof(conversationId)); logger.LogInformation("Getting agent conversation {ConversationId}", conversationId); - var conversation = await FindConversationAsync(conversationId, ct); - if (conversation is null) - { - return null; - } - - return new ConversationDto( - conversation.Id, - conversation.ProjectId, - conversation.Title, - [.. conversation.Messages.OrderBy(m => m.Sequence).Select(ToDto)], - conversation.UpdatedAt); + return await FindConversationAsync(conversationId, ct); } /// <summary>True if a conversation was deleted; false if no conversation had this id.</summary> @@ -84,7 +73,7 @@ public class NovelAgentService( /// Sends a message to the agent and runs it to completion, executing any tools it /// calls along the way. Returns the assistant's final turn. /// </summary> - public async Task<AgentTurnDto> SendMessageAsync( + public async Task<AgentMessage> SendMessageAsync( Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) { Guard.Default(projectId, nameof(projectId)); @@ -109,7 +98,7 @@ public class NovelAgentService( var systemPrompt = await BuildSystemPromptAsync(projectId, ct); var transcript = BuildTranscript(conversation); - var toolCalls = new List<ToolCallDto>(); + var toolCalls = new List<ToolCallResponse>(); var text = new StringBuilder(); for (var iteration = 0; iteration < _options.MaxIterations; iteration++) @@ -146,7 +135,7 @@ public class NovelAgentService( "Agent tool {Tool} on project {ProjectId} {Outcome}", call.Name, projectId, outcome.IsError ? "failed" : "succeeded"); - toolCalls.Add(new ToolCallDto(call.Name, call.Input.ToString(), outcome.Content)); + toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content)); results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError)); } @@ -170,7 +159,7 @@ public class NovelAgentService( toolCalls.Count > 0 ? JsonSerializer.Serialize(toolCalls, JsonOptions) : null, ct); - return new AgentTurnDto(conversation.Id, ToDto(reply)); + return reply; } /// <summary> @@ -307,15 +296,6 @@ public class NovelAgentService( """; } - private static AgentMessageDto ToDto(AgentMessage message) => new( - message.Id, - message.Role, - message.Content, - message.ToolCallsJson is null - ? [] - : JsonSerializer.Deserialize<List<ToolCallDto>>(message.ToolCallsJson, JsonOptions) ?? [], - message.CreatedAt); - /// <summary>Derives a conversation title from its opening message.</summary> private static string Summarise(string message) { diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index c1c017c..787b007 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -104,6 +104,11 @@ public class NovelAgentToolset( private static async Task<object> OrNotFound<T>(Task<T?> lookup, string entity, Guid id) where T : class => await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found."); + /// <summary>Turns a nullable lookup into either the mapped response or a <see cref="ToolNotFound"/> the model can read.</summary> + private static async Task<object> OrNotFound<TEntity, TResponse>( + Task<TEntity?> lookup, Func<TEntity, TResponse> map, string entity, Guid id) where TEntity : class => + await lookup is { } value ? map(value)! : new ToolNotFound($"{entity} '{id}' was not found."); + /// <summary>Turns a delete's success flag into either a confirmation or a <see cref="ToolNotFound"/>.</summary> private static async Task<object> DeletedOrNotFound(Task<bool> delete, string entity, Guid id) => await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found."); @@ -117,7 +122,7 @@ public class NovelAgentToolset( "Read the project's title, logline, synopsis, genre, notes and word-count target. " + "Call this first in a conversation to ground yourself in what the book is.", new JsonSchemaBuilder().Build(), - async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), "Project", projectId)); + async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(), "Project", projectId)); yield return new AgentTool( "update_project_brief", @@ -139,20 +144,20 @@ public class NovelAgentToolset( JsonInput.String(input, "logline"), JsonInput.String(input, "synopsis"), JsonInput.String(input, "notes"), - JsonInput.Int(input, "target_word_count")), ct), "Project", projectId)); + JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(), "Project", projectId)); yield return new AgentTool( "list_characters", "List every character in the project with their full dossiers.", new JsonSchemaBuilder().Build(), - async (projectId, _, ct) => await characters.ListAsync(projectId, ct)); + async (projectId, _, ct) => (await characters.ListAsync(projectId, ct)).Select(c => c.ToResponse())); yield return new AgentTool( "create_character", "Add a character dossier. Name is the only requirement — leave fields blank when " + "the writer has not decided them yet rather than inventing detail.", CharacterSchema(includeName: true, nameRequired: true).Build(), - async (projectId, input, ct) => await characters.CreateAsync(projectId, new CreateCharacterRequest( + async (projectId, input, ct) => (await characters.CreateAsync(projectId, new CreateCharacterRequest( JsonInput.RequiredString(input, "name"), JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting, JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting, @@ -169,7 +174,7 @@ public class NovelAgentToolset( JsonInput.String(input, "arc_summary"), JsonInput.String(input, "voice"), JsonInput.String(input, "notes"), - JsonInput.Strings(input, "tags")), ct)); + JsonInput.Strings(input, "tags")), ct)).ToResponse()); yield return new AgentTool( "update_character", @@ -199,7 +204,7 @@ public class NovelAgentToolset( JsonInput.String(input, "arc_summary"), JsonInput.String(input, "voice"), JsonInput.String(input, "notes"), - JsonInput.Strings(input, "tags")), ct), "Character", characterId); + JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Character", characterId); }); yield return new AgentTool( @@ -209,7 +214,7 @@ public class NovelAgentToolset( new JsonSchemaBuilder() .Str("chapter_id", "Id of the chapter whose outline to read.", required: true) .Build(), - async (_, input, ct) => await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct)); + async (_, input, ct) => (await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct)).Select(b => b.ToResponse())); yield return new AgentTool( "create_beat", @@ -219,7 +224,7 @@ public class NovelAgentToolset( .Str("chapter_id", "Id of the chapter the beat belongs to.", required: true) .Str("title", "Three to five words naming the beat.", required: true) .Build(), - async (_, input, ct) => await beats.CreateAsync( + async (_, input, ct) => (await beats.CreateAsync( JsonInput.RequiredGuid(input, "chapter_id"), new CreateBeatRequest( JsonInput.RequiredString(input, "title"), @@ -228,7 +233,7 @@ public class NovelAgentToolset( JsonInput.String(input, "what_happened"), JsonInput.String(input, "whats_next"), JsonInput.Guid(input, "scene_id"), - JsonInput.Strings(input, "tags")), ct)); + JsonInput.Strings(input, "tags")), ct)).ToResponse()); yield return new AgentTool( "update_beat", @@ -250,7 +255,7 @@ public class NovelAgentToolset( JsonInput.String(input, "what_happened"), JsonInput.String(input, "whats_next"), JsonInput.Guid(input, "scene_id"), - JsonInput.Strings(input, "tags")), ct), "Beat", beatId); + JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId); }); yield return new AgentTool( @@ -273,12 +278,12 @@ public class NovelAgentToolset( .Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true) .StringArray("beat_ids", "Beat ids in their new order.", required: true) .Build(), - async (_, input, ct) => await beats.ReorderAsync( + async (_, input, ct) => (await beats.ReorderAsync( JsonInput.RequiredGuid(input, "chapter_id"), new ReorderBeatsRequest( [.. (JsonInput.Strings(input, "beat_ids") ?? []) .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) - .Where(g => g != Guid.Empty)]), ct)); + .Where(g => g != Guid.Empty)]), ct)).Select(b => b.ToResponse())); yield return new AgentTool( "list_tags", @@ -297,14 +302,14 @@ public class NovelAgentToolset( async (_, input, ct) => { var tagId = JsonInput.RequiredGuid(input, "tag_id"); - return await OrNotFound(tags.GetReferencesAsync(tagId, ct), "Tag", tagId); + return await OrNotFound(tags.GetReferencesAsync(tagId, ct), t => t.ToReferencesResponse(), "Tag", tagId); }); yield return new AgentTool( "list_chapters", "List the project's chapters in manuscript order with scene and word counts.", new JsonSchemaBuilder().Build(), - async (projectId, _, ct) => await chapters.ListAsync(projectId, ct)); + async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())); yield return new AgentTool( "get_chapter", @@ -315,7 +320,7 @@ public class NovelAgentToolset( async (_, input, ct) => { var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); - return await OrNotFound(chapters.GetAsync(chapterId, ct), "Chapter", chapterId); + return await OrNotFound(chapters.GetAsync(chapterId, ct), c => c.ToResponse(), "Chapter", chapterId); }); yield return new AgentTool( @@ -332,7 +337,7 @@ public class NovelAgentToolset( .Int("target_word_count", "Target length in words.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .Build(), - async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest( + async (projectId, input, ct) => (await chapters.CreateAsync(projectId, new CreateChapterRequest( JsonInput.RequiredString(input, "title"), JsonInput.Int(input, "number"), JsonInput.String(input, "summary"), @@ -341,7 +346,7 @@ public class NovelAgentToolset( JsonInput.String(input, "notes"), JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned, JsonInput.Int(input, "target_word_count"), - JsonInput.Strings(input, "tags")), ct)); + JsonInput.Strings(input, "tags")), ct)).ToResponse()); yield return new AgentTool( "update_chapter", @@ -372,7 +377,7 @@ public class NovelAgentToolset( JsonInput.String(input, "notes"), JsonInput.Enum<DraftStatus>(input, "status"), JsonInput.Int(input, "target_word_count"), - JsonInput.Strings(input, "tags")), ct), "Chapter", chapterId); + JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId); }); yield return new AgentTool( @@ -383,7 +388,7 @@ public class NovelAgentToolset( .Str("chapter_id", "Id of the chapter the scene belongs to.", required: true) .Str("title", "Scene title.", required: true) .Build(), - async (_, input, ct) => await scenes.CreateAsync( + async (_, input, ct) => (await scenes.CreateAsync( JsonInput.RequiredGuid(input, "chapter_id"), new CreateSceneRequest( JsonInput.RequiredString(input, "title"), @@ -395,7 +400,7 @@ public class NovelAgentToolset( JsonInput.Guid(input, "pov_character_id"), JsonInput.String(input, "location"), JsonInput.String(input, "prose"), - JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct)); + JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct)).ToResponse()); yield return new AgentTool( "update_scene", @@ -420,7 +425,7 @@ public class NovelAgentToolset( JsonInput.Guid(input, "pov_character_id"), JsonInput.String(input, "location"), JsonInput.String(input, "prose"), - JsonInput.Enum<DraftStatus>(input, "status")), ct), "Scene", sceneId); + JsonInput.Enum<DraftStatus>(input, "status")), ct), s => s.ToResponse(), "Scene", sceneId); }); yield return new AgentTool( @@ -434,7 +439,11 @@ public class NovelAgentToolset( async (_, input, ct) => { var characterId = JsonInput.RequiredGuid(input, "character_id"); - return await OrNotFound(beats.ListForCharacterAsync(characterId, ct), "Character", characterId); + return await OrNotFound( + beats.ListForCharacterAsync(characterId, ct), + list => list.Select(b => b.ToCharacterBeatResponse()), + "Character", + characterId); }); yield return new AgentTool( @@ -444,8 +453,8 @@ public class NovelAgentToolset( new JsonSchemaBuilder() .Str("character_id", "Id of the character.", required: true) .Build(), - async (_, input, ct) => await arcs.ListAsync( - JsonInput.RequiredGuid(input, "character_id"), ct)); + async (_, input, ct) => (await arcs.ListAsync( + JsonInput.RequiredGuid(input, "character_id"), ct)).Select(s => s.ToResponse())); yield return new AgentTool( "add_arc_stage", @@ -455,13 +464,13 @@ public class NovelAgentToolset( .Str("character_id", "Id of the character whose arc to add to.", required: true) .Str("title", "A short handle for the change, three to five words.", required: true) .Build(), - async (_, input, ct) => await arcs.CreateAsync( + async (_, input, ct) => (await arcs.CreateAsync( JsonInput.RequiredGuid(input, "character_id"), new CreateArcStageRequest( JsonInput.RequiredString(input, "title"), JsonInput.Int(input, "sort_order"), JsonInput.String(input, "description"), - JsonInput.Guid(input, "chapter_id")), ct)); + JsonInput.Guid(input, "chapter_id")), ct)).ToResponse()); yield return new AgentTool( "update_arc_stage", @@ -479,7 +488,7 @@ public class NovelAgentToolset( JsonInput.String(input, "title"), JsonInput.Int(input, "sort_order"), JsonInput.String(input, "description"), - JsonInput.Guid(input, "chapter_id")), ct), "CharacterArcStage", arcStageId); + JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId); }); yield return new AgentTool( @@ -502,10 +511,10 @@ public class NovelAgentToolset( .Str("character_id", "Id of the character whose arc to reorder.", required: true) .StringArray("stage_ids", "Arc stage ids in the order wanted.", required: true) .Build(), - async (_, input, ct) => await arcs.ReorderAsync( + async (_, input, ct) => (await arcs.ReorderAsync( JsonInput.RequiredGuid(input, "character_id"), new ReorderArcStagesRequest( - [.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct)); + [.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct)).Select(s => s.ToResponse())); yield return new AgentTool( "list_open_questions", @@ -516,12 +525,12 @@ public class NovelAgentToolset( .Str("character_id", "Narrow to questions about one character.") .Bool("include_resolved", "Include questions already settled. Defaults to false.") .Build(), - async (projectId, input, ct) => await questions.ListAsync( + async (projectId, input, ct) => (await questions.ListAsync( projectId, JsonInput.Guid(input, "chapter_id"), JsonInput.Guid(input, "character_id"), JsonInput.Bool(input, "include_resolved") ?? false, - ct)); + ct)).Select(q => q.ToResponse())); yield return new AgentTool( "raise_open_question", @@ -534,13 +543,13 @@ public class NovelAgentToolset( .Str("chapter_id", "The chapter outline this is about, if any.") .Str("character_id", "The character this is about, if any.") .Build(), - async (projectId, input, ct) => await questions.CreateAsync( + async (projectId, input, ct) => (await questions.CreateAsync( projectId, new CreateOpenQuestionRequest( JsonInput.RequiredString(input, "question"), JsonInput.String(input, "detail"), JsonInput.Guid(input, "chapter_id"), - JsonInput.Guid(input, "character_id")), ct)); + JsonInput.Guid(input, "character_id")), ct)).ToResponse()); yield return new AgentTool( "resolve_open_question", @@ -558,7 +567,7 @@ public class NovelAgentToolset( questionId, new ResolveOpenQuestionRequest( JsonInput.RequiredString(input, "resolution"), - JsonInput.Bool(input, "append_to_notes") ?? false), ct), "OpenQuestion", questionId); + JsonInput.Bool(input, "append_to_notes") ?? false), ct), q => q.ToResponse(), "OpenQuestion", questionId); }); yield return new AgentTool( @@ -570,7 +579,7 @@ public class NovelAgentToolset( async (_, input, ct) => { var questionId = JsonInput.RequiredGuid(input, "question_id"); - return await OrNotFound(questions.ReopenAsync(questionId, ct), "OpenQuestion", questionId); + return await OrNotFound(questions.ReopenAsync(questionId, ct), q => q.ToResponse(), "OpenQuestion", questionId); }); yield return new AgentTool( diff --git a/src/Novelly.Api/Beats/BeatDtos.cs b/src/Novelly.Api/Beats/BeatContracts.cs similarity index 82% rename from src/Novelly.Api/Beats/BeatDtos.cs rename to src/Novelly.Api/Beats/BeatContracts.cs index fc199fc..53757a8 100644 --- a/src/Novelly.Api/Beats/BeatDtos.cs +++ b/src/Novelly.Api/Beats/BeatContracts.cs @@ -3,7 +3,7 @@ using Novelly.Api.Tags; namespace Novelly.Api.Beats; -public record BeatDto( +public record BeatResponse( Guid Id, Guid ChapterId, int SortOrder, @@ -14,7 +14,7 @@ public record BeatDto( string? WhatsNext, Guid? SceneId, string? SceneTitle, - IReadOnlyList<TagDto> Tags, + IReadOnlyList<TagResponse> Tags, DateTimeOffset UpdatedAt); public record CreateBeatRequest( @@ -45,7 +45,9 @@ public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest> /// <summary> /// Patch-style update. A null field is left alone; an empty string clears it. Passing a -/// <see cref="Tags"/> list replaces the beat's tags outright. +/// <see cref="Tags"/> list replaces the beat's tags outright. Use <see cref="ClearCharacter"/> / +/// <see cref="ClearScene"/> to detach a reference, since a null id already means "leave the +/// association alone". /// </summary> public record UpdateBeatRequest( string? Title = null, @@ -54,7 +56,9 @@ public record UpdateBeatRequest( string? WhatHappened = null, string? WhatsNext = null, Guid? SceneId = null, - IReadOnlyList<string>? Tags = null); + IReadOnlyList<string>? Tags = null, + bool ClearCharacter = false, + bool ClearScene = false); public class UpdateBeatRequestValidator : IModelValidator<UpdateBeatRequest> { @@ -98,7 +102,7 @@ file static class BeatValidation /// A beat this character appears in, carrying enough of its chapter to link straight to /// the row in that chapter's outline. /// </summary> -public record CharacterBeatDto( +public record CharacterBeatResponse( Guid Id, Guid ChapterId, int ChapterNumber, @@ -128,7 +132,7 @@ public class ReorderBeatsRequestValidator : IModelValidator<ReorderBeatsRequest> public static class BeatMapping { - public static BeatDto ToDto(this Beat b) => new( + public static BeatResponse ToResponse(this Beat b) => new( b.Id, b.ChapterId, b.SortOrder, @@ -139,6 +143,18 @@ public static class BeatMapping b.WhatsNext, b.SceneId, b.Scene?.Title, - [.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())], + [.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], b.UpdatedAt); + + public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b) => new( + b.Id, + b.ChapterId, + b.Chapter?.Number ?? 0, + b.Chapter?.Title ?? "(unknown chapter)", + b.SortOrder, + b.Title, + b.WhatHappened, + b.WhatsNext, + b.SceneId, + b.Scene?.Title); } diff --git a/src/Novelly.Api/Beats/BeatEndpoints.cs b/src/Novelly.Api/Beats/BeatEndpoints.cs index 319f620..a638d59 100644 --- a/src/Novelly.Api/Beats/BeatEndpoints.cs +++ b/src/Novelly.Api/Beats/BeatEndpoints.cs @@ -12,25 +12,25 @@ public static class BeatEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) => - Results.Ok(await service.ListAsync(chapterId, ct))) + Results.Ok((await service.ListAsync(chapterId, ct)).Select(b => b.ToResponse()))) .WithSummary("Read a chapter's outline: its beats, in order."); chapterScoped.MapPost("/", async ( Guid chapterId, CreateBeatRequest request, BeatService service, CancellationToken ct) => { - var created = await service.CreateAsync(chapterId, request, ct); + var created = (await service.CreateAsync(chapterId, request, ct)).ToResponse(); return Results.Created($"/api/beats/{created.Id}", created); }) .WithSummary("Add a beat to a chapter's outline."); chapterScoped.MapPost("/reorder", async ( Guid chapterId, ReorderBeatsRequest request, BeatService service, CancellationToken ct) => - Results.Ok(await service.ReorderAsync(chapterId, request, ct))) + Results.Ok((await service.ReorderAsync(chapterId, request, ct)).Select(b => b.ToResponse()))) .WithSummary("Renumber a chapter's beats to match the order given."); app.MapGet("/api/characters/{characterId:guid}/beats", async ( Guid characterId, BeatService service, CancellationToken ct) => - (await service.ListForCharacterAsync(characterId, ct)).ToApiResult()) + (await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse()).ToList().ToApiResult()) .WithTags("Beats") .WithSummary("Every beat this character appears in, in manuscript order."); @@ -39,12 +39,12 @@ public static class BeatEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) => - (await service.GetAsync(id, ct)).ToApiResult()) + (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) .WithSummary("Read one beat."); beats.MapPatch("/{id:guid}", async ( Guid id, UpdateBeatRequest request, BeatService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct)).ToApiResult()) + (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) .WithSummary("Update a beat. Sending a tag list replaces the beat's tags."); beats.MapDelete("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) => diff --git a/src/Novelly.Api/Beats/BeatService.cs b/src/Novelly.Api/Beats/BeatService.cs index 6f9a5a8..778131a 100644 --- a/src/Novelly.Api/Beats/BeatService.cs +++ b/src/Novelly.Api/Beats/BeatService.cs @@ -20,27 +20,25 @@ public class BeatService( IModelValidator<UpdateBeatRequest> updateValidator, IModelValidator<ReorderBeatsRequest> reorderValidator) { - public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default) + public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default) { Guard.Default(chapterId, nameof(chapterId)); logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId); - var beats = await Query() + return await Query() .Where(b => b.ChapterId == chapterId) .OrderBy(b => b.SortOrder) .ToListAsync(ct); - - return [.. beats.Select(b => b.ToDto())]; } /// <summary>Null when no beat has this id — a lookup miss is expected, not exceptional.</summary> - public async Task<BeatDto?> GetAsync(Guid id, CancellationToken ct = default) + public async Task<Beat?> GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Getting beat {BeatId}", id); - return (await FindAsync(id, ct))?.ToDto(); + return await FindAsync(id, ct); } /// <summary> @@ -49,7 +47,7 @@ public class BeatService( /// straight to the beat in that chapter's outline. Null when no character has this id; /// an empty list means the character exists but has no beats yet. /// </summary> - public async Task<IReadOnlyList<CharacterBeatDto>?> ListForCharacterAsync( + public async Task<IReadOnlyList<Beat>?> ListForCharacterAsync( Guid characterId, CancellationToken ct = default) { Guard.Default(characterId, nameof(characterId)); @@ -73,21 +71,10 @@ public class BeatService( .. beats .OrderBy(b => b.Chapter?.Number ?? 0) .ThenBy(b => b.SortOrder) - .Select(b => new CharacterBeatDto( - b.Id, - b.ChapterId, - b.Chapter?.Number ?? 0, - b.Chapter?.Title ?? "(unknown chapter)", - b.SortOrder, - b.Title, - b.WhatHappened, - b.WhatsNext, - b.SceneId, - b.Scene?.Title)) ]; } - public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default) + public async Task<Beat> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default) { Guard.Default(chapterId, nameof(chapterId)); Guard.Null(request, nameof(request)); @@ -124,10 +111,10 @@ public class BeatService( await db.SaveChangesAsync(ct); // Just created it — the reload is only to pick up includes, not to check existence. - return (await FindAsync(beat.Id, ct))!.ToDto(); + return (await FindAsync(beat.Id, ct))!; } - public async Task<BeatDto?> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default) + public async Task<Beat?> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default) { Guard.Default(id, nameof(id)); Guard.Null(request, nameof(request)); @@ -154,10 +141,10 @@ public class BeatService( beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title; beat.SortOrder = request.SortOrder ?? beat.SortOrder; - beat.CharacterId = request.CharacterId ?? beat.CharacterId; + beat.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? beat.CharacterId; beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened); beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext); - beat.SceneId = request.SceneId ?? beat.SceneId; + beat.SceneId = request.ClearScene ? null : request.SceneId ?? beat.SceneId; beat.UpdatedAt = DateTimeOffset.UtcNow; if (request.Tags is { } names) @@ -166,7 +153,7 @@ public class BeatService( } await db.SaveChangesAsync(ct); - return (await FindAsync(id, ct))!.ToDto(); + return (await FindAsync(id, ct))!; } /// <summary>True if a beat was deleted; false if no beat had this id.</summary> @@ -191,7 +178,7 @@ public class BeatService( /// Renumbers a chapter's beats to match the order given. Sending the whole list beats /// patching sort orders one at a time, which is fiddly to get right from a drag handle. /// </summary> - public async Task<IReadOnlyList<BeatDto>> ReorderAsync( + public async Task<IReadOnlyList<Beat>> ReorderAsync( Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default) { Guard.Default(chapterId, nameof(chapterId)); diff --git a/src/Novelly.Api/Chapters/ChapterDtos.cs b/src/Novelly.Api/Chapters/ChapterContracts.cs similarity index 86% rename from src/Novelly.Api/Chapters/ChapterDtos.cs rename to src/Novelly.Api/Chapters/ChapterContracts.cs index d342ba8..f14dccc 100644 --- a/src/Novelly.Api/Chapters/ChapterDtos.cs +++ b/src/Novelly.Api/Chapters/ChapterContracts.cs @@ -6,7 +6,7 @@ using Novelly.Api.Tags; namespace Novelly.Api.Chapters; -public record ChapterSummaryDto( +public record ChapterSummaryResponse( Guid Id, Guid ProjectId, int Number, @@ -20,13 +20,13 @@ public record ChapterSummaryDto( int BeatCount, int SceneCount, int WordCount, - IReadOnlyList<TagDto> Tags); + IReadOnlyList<TagResponse> Tags); /// <summary> /// A chapter in full: the outline (a paragraph of summary plus an ordered beat table) /// and the prose layer (scenes). /// </summary> -public record ChapterDto( +public record ChapterResponse( Guid Id, Guid ProjectId, int Number, @@ -38,9 +38,9 @@ public record ChapterDto( string? Notes, DraftStatus Status, int? TargetWordCount, - IReadOnlyList<BeatDto> Beats, - IReadOnlyList<SceneDto> Scenes, - IReadOnlyList<TagDto> Tags, + IReadOnlyList<BeatResponse> Beats, + IReadOnlyList<SceneResponse> Scenes, + IReadOnlyList<TagResponse> Tags, DateTimeOffset UpdatedAt); public record CreateChapterRequest( @@ -133,18 +133,18 @@ file static class ChapterValidation public static class ChapterMapping { - public static ChapterDto ToDto(this Chapter c) => new( + public static ChapterResponse ToResponse(this Chapter c) => new( c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes, c.Status, c.TargetWordCount, - [.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToDto())], - [.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToDto())], - [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())], + [.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())], + [.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())], + [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], c.UpdatedAt); - public static ChapterSummaryDto ToSummaryDto(this Chapter c) => new( + public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new( c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount, c.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount), - [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())]); + [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())]); } diff --git a/src/Novelly.Api/Chapters/ChapterEndpoints.cs b/src/Novelly.Api/Chapters/ChapterEndpoints.cs index a880d69..bded637 100644 --- a/src/Novelly.Api/Chapters/ChapterEndpoints.cs +++ b/src/Novelly.Api/Chapters/ChapterEndpoints.cs @@ -12,13 +12,13 @@ public static class ChapterEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) => - Results.Ok(await service.ListAsync(projectId, ct))) + Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()))) .WithSummary("List a project's chapters in manuscript order."); projectScoped.MapPost("/", async ( Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) => { - var created = await service.CreateAsync(projectId, request, ct); + var created = (await service.CreateAsync(projectId, request, ct)).ToResponse(); return Results.Created($"/api/chapters/{created.Id}", created); }) .WithSummary("Add a chapter."); @@ -28,12 +28,12 @@ public static class ChapterEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => - (await service.GetAsync(id, ct)).ToApiResult()) + (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) .WithSummary("Read a chapter with all of its scenes."); chapters.MapPatch("/{id:guid}", async ( Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct)).ToApiResult()) + (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) .WithSummary("Update a chapter."); chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => diff --git a/src/Novelly.Api/Chapters/ChapterService.cs b/src/Novelly.Api/Chapters/ChapterService.cs index 34f79e9..13a5c43 100644 --- a/src/Novelly.Api/Chapters/ChapterService.cs +++ b/src/Novelly.Api/Chapters/ChapterService.cs @@ -14,13 +14,13 @@ public class ChapterService( IModelValidator<CreateChapterRequest> createValidator, IModelValidator<UpdateChapterRequest> updateValidator) { - public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) + public async Task<IReadOnlyList<Chapter>> ListAsync(Guid projectId, CancellationToken ct = default) { Guard.Default(projectId, nameof(projectId)); logger.LogInformation("Listing chapters for project {ProjectId}", projectId); - var chapters = await db.Chapters + return await db.Chapters .Include(c => c.PovCharacter) .Include(c => c.Beats) .Include(c => c.Scenes) @@ -28,20 +28,18 @@ public class ChapterService( .Where(c => c.ProjectId == projectId) .OrderBy(c => c.Number) .ToListAsync(ct); - - return [.. chapters.Select(c => c.ToSummaryDto())]; } /// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary> - public async Task<ChapterDto?> GetAsync(Guid id, CancellationToken ct = default) + public async Task<Chapter?> GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Getting chapter {ChapterId}", id); - return (await FindAsync(id, ct))?.ToDto(); + return await FindAsync(id, ct); } - public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default) + public async Task<Chapter> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default) { Guard.Default(projectId, nameof(projectId)); Guard.Null(request, nameof(request)); @@ -77,10 +75,10 @@ public class ChapterService( await db.SaveChangesAsync(ct); // Just created it — the reload is only to pick up includes, not to check existence. - return (await FindAsync(chapter.Id, ct))!.ToDto(); + return (await FindAsync(chapter.Id, ct))!; } - public async Task<ChapterDto?> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default) + public async Task<Chapter?> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default) { Guard.Default(id, nameof(id)); Guard.Null(request, nameof(request)); @@ -110,7 +108,7 @@ public class ChapterService( } await db.SaveChangesAsync(ct); - return (await FindAsync(id, ct))!.ToDto(); + return (await FindAsync(id, ct))!; } /// <summary>True if a chapter was deleted; false if no chapter had this id.</summary> diff --git a/src/Novelly.Api/Characters/CharacterArcService.cs b/src/Novelly.Api/Characters/CharacterArcService.cs index 3619be7..2c1b74d 100644 --- a/src/Novelly.Api/Characters/CharacterArcService.cs +++ b/src/Novelly.Api/Characters/CharacterArcService.cs @@ -21,7 +21,7 @@ public class CharacterArcService( IModelValidator<UpdateArcStageRequest> updateValidator, IModelValidator<ReorderArcStagesRequest> reorderValidator) { - public async Task<IReadOnlyList<ArcStageDto>> ListAsync(Guid characterId, CancellationToken ct = default) + public async Task<IReadOnlyList<CharacterArcStage>> ListAsync(Guid characterId, CancellationToken ct = default) { Guard.Default(characterId, nameof(characterId)); @@ -32,19 +32,19 @@ public class CharacterArcService( .OrderBy(s => s.SortOrder) .ToListAsync(ct); - return [.. stages.Select(s => s.ToDto())]; + return stages; } /// <summary>Null when no arc stage has this id — a lookup miss is expected, not exceptional.</summary> - public async Task<ArcStageDto?> GetAsync(Guid id, CancellationToken ct = default) + public async Task<CharacterArcStage?> GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Getting arc stage {ArcStageId}", id); - return (await FindAsync(id, ct))?.ToDto(); + return await FindAsync(id, ct); } - public async Task<ArcStageDto> CreateAsync( + public async Task<CharacterArcStage> CreateAsync( Guid characterId, CreateArcStageRequest request, CancellationToken ct = default) { Guard.Default(characterId, nameof(characterId)); @@ -75,10 +75,10 @@ public class CharacterArcService( await db.SaveChangesAsync(ct); // Just created it — the reload is only to pick up includes, not to check existence. - return (await FindAsync(stage.Id, ct))!.ToDto(); + return (await FindAsync(stage.Id, ct))!; } - public async Task<ArcStageDto?> UpdateAsync( + public async Task<CharacterArcStage?> UpdateAsync( Guid id, UpdateArcStageRequest request, CancellationToken ct = default) { Guard.Default(id, nameof(id)); @@ -111,7 +111,7 @@ public class CharacterArcService( stage.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); - return (await FindAsync(id, ct))!.ToDto(); + return (await FindAsync(id, ct))!; } /// <summary>True if an arc stage was deleted; false if no stage had this id.</summary> @@ -136,7 +136,7 @@ public class CharacterArcService( /// Renumbers a character's arc to match the order given. Stages left out keep their /// relative position after the ones listed, exactly as beat reordering works. /// </summary> - public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync( + public async Task<IReadOnlyList<CharacterArcStage>> ReorderAsync( Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default) { Guard.Default(characterId, nameof(characterId)); diff --git a/src/Novelly.Api/Characters/CharacterDtos.cs b/src/Novelly.Api/Characters/CharacterContracts.cs similarity index 94% rename from src/Novelly.Api/Characters/CharacterDtos.cs rename to src/Novelly.Api/Characters/CharacterContracts.cs index 96a70f7..a814de6 100644 --- a/src/Novelly.Api/Characters/CharacterDtos.cs +++ b/src/Novelly.Api/Characters/CharacterContracts.cs @@ -3,7 +3,7 @@ using Novelly.Api.Tags; namespace Novelly.Api.Characters; -public record CharacterDto( +public record CharacterResponse( Guid Id, Guid ProjectId, string Name, @@ -22,12 +22,12 @@ public record CharacterDto( string? ArcSummary, string? Voice, string? Notes, - IReadOnlyList<RelationshipDto> Relationships, - IReadOnlyList<TagDto> Tags, - IReadOnlyList<ArcStageDto> ArcStages, + IReadOnlyList<RelationshipResponse> Relationships, + IReadOnlyList<TagResponse> Tags, + IReadOnlyList<ArcStageResponse> ArcStages, DateTimeOffset UpdatedAt); -public record RelationshipDto( +public record RelationshipResponse( Guid Id, Guid RelatedCharacterId, string RelatedCharacterName, @@ -177,7 +177,7 @@ public class CreateRelationshipRequestValidator : IModelValidator<CreateRelation } } -public record ArcStageDto( +public record ArcStageResponse( Guid Id, Guid CharacterId, int SortOrder, @@ -269,21 +269,21 @@ public class ReorderArcStagesRequestValidator : IModelValidator<ReorderArcStages public static class CharacterMapping { - public static CharacterDto ToDto(this Character c) => new( + public static CharacterResponse ToResponse(this Character c) => new( c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation, c.Appearance, c.Personality, c.Backstory, c.Want, c.Need, c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes, - [.. c.Relationships.Select(r => new RelationshipDto( + [.. c.Relationships.Select(r => new RelationshipResponse( r.Id, r.RelatedCharacterId, r.RelatedCharacter?.Name ?? "(unknown)", r.RelationshipType, r.Description))], - [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())], - [.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToDto())], + [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], + [.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())], c.UpdatedAt); - public static ArcStageDto ToDto(this CharacterArcStage s) => new( + public static ArcStageResponse ToResponse(this CharacterArcStage s) => new( s.Id, s.CharacterId, s.SortOrder, diff --git a/src/Novelly.Api/Characters/CharacterEndpoints.cs b/src/Novelly.Api/Characters/CharacterEndpoints.cs index d214696..aba119e 100644 --- a/src/Novelly.Api/Characters/CharacterEndpoints.cs +++ b/src/Novelly.Api/Characters/CharacterEndpoints.cs @@ -12,13 +12,13 @@ public static class CharacterEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) => - Results.Ok(await service.ListAsync(projectId, ct))) + Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToResponse()))) .WithSummary("List a project's character dossiers."); projectScoped.MapPost("/", async ( Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) => { - var created = await service.CreateAsync(projectId, request, ct); + var created = (await service.CreateAsync(projectId, request, ct)).ToResponse(); return Results.Created($"/api/characters/{created.Id}", created); }) .WithSummary("Add a character dossier."); @@ -28,12 +28,12 @@ public static class CharacterEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => - (await service.GetAsync(id, ct)).ToApiResult()) + (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) .WithSummary("Read a character dossier."); characters.MapPatch("/{id:guid}", async ( Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct)).ToApiResult()) + (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) .WithSummary("Update a character dossier."); characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => @@ -42,7 +42,7 @@ public static class CharacterEndpoints characters.MapPost("/{id:guid}/relationships", async ( Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) => - (await service.AddRelationshipAsync(id, request, ct)).ToApiResult()) + (await service.AddRelationshipAsync(id, request, ct))?.ToResponse().ToApiResult()) .WithSummary("Relate this character to another in the same project."); characters.MapDelete("/relationships/{relationshipId:guid}", async ( @@ -52,20 +52,20 @@ public static class CharacterEndpoints characters.MapGet("/{id:guid}/arc", async ( Guid id, CharacterArcService service, CancellationToken ct) => - Results.Ok(await service.ListAsync(id, ct))) + Results.Ok((await service.ListAsync(id, ct)).Select(s => s.ToResponse()))) .WithSummary("Read a character's arc: its stages, in order."); characters.MapPost("/{id:guid}/arc", async ( Guid id, CreateArcStageRequest request, CharacterArcService service, CancellationToken ct) => { - var created = await service.CreateAsync(id, request, ct); + var created = (await service.CreateAsync(id, request, ct)).ToResponse(); return Results.Created($"/api/arc-stages/{created.Id}", created); }) .WithSummary("Add a stage to a character's arc."); characters.MapPost("/{id:guid}/arc/reorder", async ( Guid id, ReorderArcStagesRequest request, CharacterArcService service, CancellationToken ct) => - Results.Ok(await service.ReorderAsync(id, request, ct))) + Results.Ok((await service.ReorderAsync(id, request, ct)).Select(s => s.ToResponse()))) .WithSummary("Renumber a character's arc to match the order given."); var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters") @@ -73,12 +73,12 @@ public static class CharacterEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => - (await service.GetAsync(id, ct)).ToApiResult()) + (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) .WithSummary("Read one arc stage."); arcStages.MapPatch("/{id:guid}", async ( Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct)).ToApiResult()) + (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) .WithSummary("Update an arc stage."); arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => diff --git a/src/Novelly.Api/Characters/CharacterService.cs b/src/Novelly.Api/Characters/CharacterService.cs index 8f65979..299f4f4 100644 --- a/src/Novelly.Api/Characters/CharacterService.cs +++ b/src/Novelly.Api/Characters/CharacterService.cs @@ -25,7 +25,7 @@ public class CharacterService( /// order, which is the significance order these enums are written in. A project's cast is /// small enough that this costs nothing. /// </remarks> - public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default) + public async Task<IReadOnlyList<Character>> ListAsync(Guid projectId, CancellationToken ct = default) { Guard.Default(projectId, nameof(projectId)); @@ -41,20 +41,19 @@ public class CharacterService( .OrderBy(c => c.Importance) .ThenBy(c => c.Role) .ThenBy(c => c.Name) - .Select(c => c.ToDto()) ]; } /// <summary>Null when no character has this id — a lookup miss is expected, not exceptional.</summary> - public async Task<CharacterDto?> GetAsync(Guid id, CancellationToken ct = default) + public async Task<Character?> GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Getting character {CharacterId}", id); - return (await FindAsync(id, ct))?.ToDto(); + return await FindAsync(id, ct); } - public async Task<CharacterDto> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default) + public async Task<Character> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default) { Guard.Default(projectId, nameof(projectId)); Guard.Null(request, nameof(request)); @@ -94,10 +93,10 @@ public class CharacterService( await db.SaveChangesAsync(ct); // Just created it — the reload is only to pick up includes, not to check existence. - return (await FindAsync(character.Id, ct))!.ToDto(); + return (await FindAsync(character.Id, ct))!; } - public async Task<CharacterDto?> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default) + public async Task<Character?> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default) { Guard.Default(id, nameof(id)); Guard.Null(request, nameof(request)); @@ -135,7 +134,7 @@ public class CharacterService( } await db.SaveChangesAsync(ct); - return (await FindAsync(id, ct))!.ToDto(); + return (await FindAsync(id, ct))!; } /// <summary>True if a character was deleted; false if no character had this id.</summary> @@ -157,7 +156,7 @@ public class CharacterService( } /// <summary>Null when the subject character (<paramref name="characterId"/>) doesn't exist.</summary> - public async Task<CharacterDto?> AddRelationshipAsync( + public async Task<Character?> AddRelationshipAsync( Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default) { Guard.Default(characterId, nameof(characterId)); @@ -194,7 +193,7 @@ public class CharacterService( }); await db.SaveChangesAsync(ct); - return (await FindAsync(characterId, ct))!.ToDto(); + return (await FindAsync(characterId, ct))!; } /// <summary>True if a relationship was removed; false if no relationship had this id.</summary> diff --git a/src/Novelly.Api/Common/ApiResultExtensions.cs b/src/Novelly.Api/Common/ApiResultExtensions.cs index 2c58fdb..611f5be 100644 --- a/src/Novelly.Api/Common/ApiResultExtensions.cs +++ b/src/Novelly.Api/Common/ApiResultExtensions.cs @@ -2,11 +2,5 @@ namespace Novelly.Api.Common; public static class ApiResultExtensions { - /// <summary> - /// A missing entity is not exceptional, so lookups return null instead of throwing. - /// This is where that null finally becomes an HTTP 404 — the one place the API layer - /// needs to know about it. - /// </summary> - public static IResult ToApiResult<T>(this T? value) where T : class => - value is null ? Results.NotFound() : Results.Ok(value); + public static IResult ToApiResult<T>(this T? value) where T : class => value is null ? Results.NotFound() : Results.Ok(value); } diff --git a/src/Novelly.Api/Common/NotFoundException.cs b/src/Novelly.Api/Common/NotFoundException.cs index dc5f9e1..55e0993 100644 --- a/src/Novelly.Api/Common/NotFoundException.cs +++ b/src/Novelly.Api/Common/NotFoundException.cs @@ -4,8 +4,7 @@ namespace Novelly.Api.Common; /// Thrown when a service is asked for an entity that does not exist. The API translates /// this into a 404 so services never have to know about HTTP. /// </summary> -public class NotFoundException(string entity, Guid id) - : Exception($"{entity} '{id}' was not found.") +public class NotFoundException(string entity, Guid id) : Exception($"{entity} '{id}' was not found.") { public string Entity { get; } = entity; public Guid Id { get; } = id; diff --git a/src/Novelly.Api/Common/NovellyServiceRegistration.cs b/src/Novelly.Api/Common/NovellyServiceRegistration.cs index a1884cc..4c06184 100644 --- a/src/Novelly.Api/Common/NovellyServiceRegistration.cs +++ b/src/Novelly.Api/Common/NovellyServiceRegistration.cs @@ -1,3 +1,4 @@ +using System.Threading.Channels; using Microsoft.EntityFrameworkCore; using Novelly.Api.Agent; using Novelly.Api.Beats; @@ -5,6 +6,7 @@ using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common.Validation; using Novelly.Api.Data; +using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; using Novelly.Api.Scenes; @@ -41,6 +43,14 @@ public static class NovellyServiceRegistration services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName)); services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>(); + // A single unbounded queue shared by the request path (writer, in ImportService) + // and the background runner (reader) — the only background-job infra in the app. + services.AddSingleton(Channel.CreateUnbounded<Guid>()); + services.AddScoped<ImportService>(); + services.AddScoped<ImportAgentToolset>(); + services.AddScoped<ImportAgentService>(); + services.AddHostedService<ImportJobRunner>(); + services.AddModelValidatorsFromAssemblyContaining<Program>(); return services; diff --git a/src/Novelly.Api/Data/INovelDbContext.cs b/src/Novelly.Api/Data/INovelDbContext.cs index 9d6ba42..8071242 100644 --- a/src/Novelly.Api/Data/INovelDbContext.cs +++ b/src/Novelly.Api/Data/INovelDbContext.cs @@ -3,6 +3,7 @@ using Novelly.Api.Agent; using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; +using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; using Novelly.Api.Scenes; @@ -27,6 +28,7 @@ public interface INovelDbContext DbSet<OpenQuestion> OpenQuestions { get; } DbSet<AgentConversation> Conversations { get; } DbSet<AgentMessage> AgentMessages { get; } + DbSet<ImportJob> ImportJobs { get; } Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Novelly.Api/Data/Migrations/20260807001613_AddImportJobs.Designer.cs b/src/Novelly.Api/Data/Migrations/20260807001613_AddImportJobs.Designer.cs new file mode 100644 index 0000000..282ce20 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260807001613_AddImportJobs.Designer.cs @@ -0,0 +1,832 @@ +// <auto-generated /> +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Novelly.Api.Data; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + [DbContext(typeof(NovelDbContext))] + [Migration("20260807001613_AddImportJobs")] + partial class AddImportJobs + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatTag", b => + { + b.Property<Guid>("BeatsId") + .HasColumnType("TEXT"); + + b.Property<Guid>("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property<Guid>("ChaptersId") + .HasColumnType("TEXT"); + + b.Property<Guid>("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property<Guid>("CharactersId") + .HasColumnType("TEXT"); + + b.Property<Guid>("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ProjectId") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property<long>("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("ConversationId") + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property<int>("Sequence") + .HasColumnType("INTEGER"); + + b.Property<string>("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<Guid>("ChapterId") + .HasColumnType("TEXT"); + + b.Property<Guid?>("CharacterId") + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("SceneId") + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<string>("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property<long>("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("WhatHappened") + .HasColumnType("TEXT"); + + b.Property<string>("WhatsNext") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("SceneId"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("Notes") + .HasColumnType("TEXT"); + + b.Property<int>("Number") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ProjectId") + .HasColumnType("TEXT"); + + b.Property<string>("Setting") + .HasColumnType("TEXT"); + + b.Property<string>("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("Summary") + .HasColumnType("TEXT"); + + b.Property<int?>("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property<string>("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property<long>("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ProjectId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Age") + .HasColumnType("TEXT"); + + b.Property<string>("Appearance") + .HasColumnType("TEXT"); + + b.Property<string>("ArcSummary") + .HasColumnType("TEXT"); + + b.Property<string>("Backstory") + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("ExternalConflict") + .HasColumnType("TEXT"); + + b.Property<string>("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("InternalConflict") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property<string>("Need") + .HasColumnType("TEXT"); + + b.Property<string>("Notes") + .HasColumnType("TEXT"); + + b.Property<string>("Occupation") + .HasColumnType("TEXT"); + + b.Property<string>("Personality") + .HasColumnType("TEXT"); + + b.Property<Guid>("ProjectId") + .HasColumnType("TEXT"); + + b.Property<string>("Pronouns") + .HasColumnType("TEXT"); + + b.Property<string>("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<long>("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("Voice") + .HasColumnType("TEXT"); + + b.Property<string>("Want") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<Guid?>("ChapterId") + .HasColumnType("TEXT"); + + b.Property<Guid>("CharacterId") + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("Description") + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<string>("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property<long>("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<Guid>("CharacterId") + .HasColumnType("TEXT"); + + b.Property<string>("Description") + .HasColumnType("TEXT"); + + b.Property<Guid>("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property<string>("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<int>("ChaptersCompleted") + .HasColumnType("INTEGER"); + + b.Property<int>("ChaptersTotal") + .HasColumnType("INTEGER"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("ProjectId") + .HasColumnType("TEXT"); + + b.Property<string>("SourceRoot") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property<string>("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property<string>("StatusMessage") + .HasColumnType("TEXT"); + + b.Property<long>("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceRoot"); + + b.ToTable("ImportJobs"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Author") + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("Genre") + .HasColumnType("TEXT"); + + b.Property<string>("Logline") + .HasColumnType("TEXT"); + + b.Property<string>("Notes") + .HasColumnType("TEXT"); + + b.Property<string>("Synopsis") + .HasColumnType("TEXT"); + + b.Property<int?>("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property<string>("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property<long>("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<Guid?>("ChapterId") + .HasColumnType("TEXT"); + + b.Property<Guid?>("CharacterId") + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("Detail") + .HasColumnType("TEXT"); + + b.Property<Guid>("ProjectId") + .HasColumnType("TEXT"); + + b.Property<string>("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property<string>("Resolution") + .HasColumnType("TEXT"); + + b.Property<long?>("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property<long>("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ProjectId"); + + b.ToTable("OpenQuestions"); + }); + + modelBuilder.Entity("Novelly.Api.Scenes.Scene", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<Guid>("ChapterId") + .HasColumnType("TEXT"); + + b.Property<string>("Conflict") + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("Goal") + .HasColumnType("TEXT"); + + b.Property<string>("Location") + .HasColumnType("TEXT"); + + b.Property<string>("Outcome") + .HasColumnType("TEXT"); + + b.Property<Guid?>("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property<string>("Prose") + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<string>("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("Summary") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property<long>("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property<int>("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Scenes"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<Guid>("ProjectId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Conversations") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Beats") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Scenes.Scene", "Scene") + .WithMany() + .HasForeignKey("SceneId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Scene"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.HasOne("Novelly.Api.Characters.Character", "PovCharacter") + .WithMany() + .HasForeignKey("PovCharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Chapters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PovCharacter"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Characters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("ArcStages") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("RelatedCharacter"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Scenes.Scene", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Scenes") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "PovCharacter") + .WithMany() + .HasForeignKey("PovCharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Chapter"); + + b.Navigation("PovCharacter"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Tags") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Navigation("Beats"); + + b.Navigation("Scenes"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Navigation("ArcStages"); + + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/20260807001613_AddImportJobs.cs b/src/Novelly.Api/Data/Migrations/20260807001613_AddImportJobs.cs new file mode 100644 index 0000000..8af4dc4 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260807001613_AddImportJobs.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + /// <inheritdoc /> + public partial class AddImportJobs : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ImportJobs", + columns: table => new + { + Id = table.Column<Guid>(type: "TEXT", nullable: false), + SourceRoot = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false), + ProjectId = table.Column<Guid>(type: "TEXT", nullable: true), + Status = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false), + StatusMessage = table.Column<string>(type: "TEXT", nullable: true), + ChaptersCompleted = table.Column<int>(type: "INTEGER", nullable: false), + ChaptersTotal = table.Column<int>(type: "INTEGER", nullable: false), + CreatedAt = table.Column<long>(type: "INTEGER", nullable: false), + UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ImportJobs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ImportJobs_SourceRoot", + table: "ImportJobs", + column: "SourceRoot"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ImportJobs"); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index 90b4458..49fbec8 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -365,6 +365,47 @@ namespace Novelly.Api.Data.Migrations b.ToTable("CharacterRelationships"); }); + modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<int>("ChaptersCompleted") + .HasColumnType("INTEGER"); + + b.Property<int>("ChaptersTotal") + .HasColumnType("INTEGER"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("ProjectId") + .HasColumnType("TEXT"); + + b.Property<string>("SourceRoot") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property<string>("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property<string>("StatusMessage") + .HasColumnType("TEXT"); + + b.Property<long>("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceRoot"); + + b.ToTable("ImportJobs"); + }); + modelBuilder.Entity("Novelly.Api.Projects.Project", b => { b.Property<Guid>("Id") diff --git a/src/Novelly.Api/Data/NovelDbContext.cs b/src/Novelly.Api/Data/NovelDbContext.cs index d8b99f2..bcb980d 100644 --- a/src/Novelly.Api/Data/NovelDbContext.cs +++ b/src/Novelly.Api/Data/NovelDbContext.cs @@ -4,6 +4,7 @@ using Novelly.Api.Agent; using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; +using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; using Novelly.Api.Scenes; @@ -36,6 +37,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>(); public DbSet<AgentConversation> Conversations => Set<AgentConversation>(); public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>(); + public DbSet<ImportJob> ImportJobs => Set<ImportJob>(); Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken); @@ -183,5 +185,15 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) entity.Property(m => m.Role).HasConversion<string>().HasMaxLength(16); entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique(); }); + + builder.Entity<ImportJob>(entity => + { + entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000); + entity.Property(j => j.Status).HasConversion<string>().HasMaxLength(16); + + // No FK to Project: a job outlives the project it created, including the + // force-restart path where that project is deleted out from under it. + entity.HasIndex(j => j.SourceRoot); + }); } } diff --git a/src/Novelly.Api/Imports/ImportAgentService.cs b/src/Novelly.Api/Imports/ImportAgentService.cs new file mode 100644 index 0000000..bd11380 --- /dev/null +++ b/src/Novelly.Api/Imports/ImportAgentService.cs @@ -0,0 +1,210 @@ +using Microsoft.Extensions.Options; +using Novelly.Api.Agent; + +namespace Novelly.Api.Imports; + +/// <summary>What one import run produced, for <see cref="ImportJobRunner"/> to persist onto the job.</summary> +public record ImportRunResult(bool Completed, Guid? ProjectId, int ChaptersCompleted, string? Message); + +/// <summary> +/// Drives the outline-import agent to completion (or to its per-run safety limit) against +/// one source folder. Structurally like <see cref="NovelAgentService"/>'s tool-use loop, but +/// with two differences that matter: it runs many turns per call rather than one, and after +/// each turn it re-reads the ledger itself to decide whether to continue — the model saying +/// it's done is not trusted, the file it wrote is. +/// </summary> +public class ImportAgentService( + IAgentModelClient model, + ImportAgentToolset toolset, + IOptions<AgentOptions> options, + ILogger<ImportAgentService> logger) +{ + private readonly AgentOptions _options = options.Value; + + public async Task<ImportRunResult> RunAsync( + string sourceRoot, Guid? existingProjectId, int chaptersTotal, CancellationToken ct = default) + { + toolset.Initialize(sourceRoot, existingProjectId); + + var startingLedger = toolset.ReadLedgerOrNull(); + var systemPrompt = BuildSystemPrompt(sourceRoot); + + var transcript = new List<AgentChatMessage> + { + AgentChatMessage.User(new AgentTextBlock( + startingLedger is null + ? "Start the import. No ledger exists yet — this is a fresh run." + : "Resume the import. Read the ledger first to see what's already done.")) + }; + + for (var turn = 0; turn < _options.ImportMaxTurns; turn++) + { + logger.LogInformation( + "Import run turn {Turn} for {SourceRoot}", turn, sourceRoot); + + await RunOneTurnAsync(systemPrompt, transcript, ct); + + var ledger = toolset.ReadLedgerOrNull(); + if (ImportPaths.IsComplete(ledger, chaptersTotal)) + { + logger.LogInformation("Import for {SourceRoot} completed after {Turns} turns", sourceRoot, turn + 1); + return new ImportRunResult( + Completed: true, + toolset.ProjectId, + ledger?.CompletedChapters?.Count ?? 0, + null); + } + + transcript.Add(AgentChatMessage.User(new AgentTextBlock( + "Continue the import from the ledger. If a batch of chapters remains, keep going."))); + } + + var finalLedger = toolset.ReadLedgerOrNull(); + logger.LogWarning("Import for {SourceRoot} hit its {MaxTurns}-turn safety limit without finishing", sourceRoot, _options.ImportMaxTurns); + + return new ImportRunResult( + Completed: false, + toolset.ProjectId, + finalLedger?.CompletedChapters?.Count ?? 0, + "Reached the safety limit for this run without finishing. Starting the import " + + "again for the same folder will resume from the ledger."); + } + + /// <summary> + /// One bounded round of model calls and tool execution — the same shape as + /// <see cref="NovelAgentService.SendMessageAsync"/>'s inner loop, just against the import + /// toolset and with a higher iteration ceiling, since a batch of chapters needs far more + /// tool calls than a chat reply. + /// </summary> + private async Task RunOneTurnAsync(string systemPrompt, List<AgentChatMessage> transcript, CancellationToken ct) + { + for (var iteration = 0; iteration < _options.ImportMaxIterationsPerTurn; iteration++) + { + var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct); + + var requestedTools = response.Content.OfType<AgentToolUseBlock>().ToList(); + if (requestedTools.Count == 0) + { + return; + } + + transcript.Add(AgentChatMessage.Assistant(response.Content)); + + var results = new List<AgentContentBlock>(); + foreach (var call in requestedTools) + { + var outcome = await toolset.ExecuteAsync(call.Name, call.Input, ct); + + logger.LogInformation( + "Import tool {Tool} {Outcome}", call.Name, outcome.IsError ? "failed" : "succeeded"); + + results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError)); + } + + transcript.Add(AgentChatMessage.User([.. results])); + } + + logger.LogWarning( + "Import turn hit its {Max}-iteration ceiling; will re-check the ledger and, if incomplete, start another turn", + _options.ImportMaxIterationsPerTurn); + } + + // Not a raw interpolated string: the ledger example below is full of JSON braces, and + // escaping every one of them for $"""...""" is more error-prone than a single Replace. + private static string BuildSystemPrompt(string sourceRoot) => SystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot); + + private const string SystemPromptTemplate = """ + You import a novel outline that already exists as markdown files on disk into this + app's project data. You are running unattended — nobody will read your replies or + answer questions mid-run, so make the judgment calls the instructions below call + for yourself and record anything genuinely ambiguous rather than stalling on it. + + Your tools give you exactly two things: read-only access to files under the import + source folder, and application tools that create the project's chapters, characters, + beats and arcs — the same ones the writer's own UI uses. You cannot write or edit + anything on disk except the resume ledger, and you cannot read anything outside the + source folder. + + ## Source folder + + The source root is `{{SOURCE_ROOT}}`. Use list_source_files and read_source_file to + explore it. Expect: + + - `outline.md` — title/author heading, blurb paragraph(s), a chapter table. + - `outlines/NN-slug.md` (or `chapters/NN-slug.md`) — one file per chapter: + `# Chapter NN`, `### Title`, a `**Thread:** X | **Part:** Y` line, one or more + prose summary paragraphs, a beat table (`| Beat | Character | What | Why |`), and + an optional `## Notes` section. + - `characters/<slug>.md` — one file per character dossier: `# Name`, an italic + tagline, `## Appearance`, `## Background`, `## Motivation`, an optional `## Events` + section (bulleted, each optionally marked `*(Ch. N)*`), and an optional `## Notes`. + + `**Thread:**` may name one character, several, or a character plus a qualifier — + only treat it as a POV character, and only auto-create an undossiered name from it, + when it names exactly one clear proper name. A list or vague reference stays + unresolved; never guess which one was meant. + + ## The ledger + + Before writing anything, call read_ledger. If it returns `{{}}`, this is a fresh run. + Otherwise it tells you what a previous run already created — do not re-create + anything whose id is already recorded. Shape: + + ```json + {{ + "projectId": "guid", + "characters": {{ "Name": "guid", "Alias": "guid" }}, + "chapters": {{ "1": "guid" }}, + "completedPasses": ["project", "characters"], + "completedChapters": [1, 2, 3] + }} + ``` + + Call write_ledger with the full, updated ledger after every successful write — it's + small, send the whole thing each time. There is no server-side dedupe: if you skip + the ledger, a resumed run will duplicate everything. + + ## Passes, strictly in order + + Skip a pass whose completion is already recorded. Jump straight to the first + incomplete one. + + 1. **Project** — skip if `completedPasses` has "project". Parse title and author from + `outline.md`'s heading. The paragraph(s) before the chapter table are the blurb — + pass them as `notes` to create_project. Record `projectId`, mark "project" done. + 2. **Characters (dossiers)** — skip if "characters" is complete. For each + `characters/*.md` not already in the ledger's `characters` map: name from the `#` + heading, occupation from the tagline, appearance/backstory/want from + Appearance/Background/Motivation. Record the id under the exact name and any + shorter alias worth matching later. Mark "characters" done once every dossier is + processed. + 3. **Chapters + beats** — process chapter files in ascending number order, skipping + any chapter number already in `completedChapters`. Do roughly 10 chapters, then + stop this pass for now — the run driver will call you again to continue if more + remain, so there is no need to force the rest into one turn. + For each: resolve `pov_character_id` only when Thread names exactly one known + character; **auto-create** a character stub (name only, via create_character) for + any single, unqualified name — in the Thread or in a beat's Character column — + that isn't in the ledger yet, then use its id. create_chapter with title, number, + summary, pov_character_id, tags [Part value, "thread:<raw Thread text>"]. Then + create_beat for each table row, in order. If `## Notes` is present, call + update_chapter with notes. Record `chapters[number]`, append to + `completedChapters`. Mark "chapters" done only once every chapter file is + processed, across however many turns that takes. + 4. **Arc stages** — skip if "arcs" is complete. Re-scan character dossiers for + `## Events`. For each: update_character(importance: "Main"), then for each bullet + add_arc_stage with a synthesized 3-5 word title (not a truncation) and the + bullet's text as description, with chapter_id when a `(Ch. N)` marker resolves to + an already-imported chapter. Mark "arcs" done once every dossier with `## Events` + is processed. + + ## Constraints + + - Never invent plot content or character detail, and never guess which of several + candidate names an ambiguous reference means. + - Never write to disk except via write_ledger. + - Never call a create tool for something the ledger already records. + - If a tool call fails, stop that item and move on rather than retrying blindly — + the ledger stays at the last successful write either way. + """; +} diff --git a/src/Novelly.Api/Imports/ImportAgentToolset.cs b/src/Novelly.Api/Imports/ImportAgentToolset.cs new file mode 100644 index 0000000..9d5268a --- /dev/null +++ b/src/Novelly.Api/Imports/ImportAgentToolset.cs @@ -0,0 +1,352 @@ +using System.Text.Json; +using Novelly.Api.Agent; +using Novelly.Api.Beats; +using Novelly.Api.Chapters; +using Novelly.Api.Characters; +using Novelly.Api.Common; +using Novelly.Api.Projects; + +namespace Novelly.Api.Imports; + +/// <summary>A tool the import agent can call, bound to a handler that runs against this run's state.</summary> +internal record ImportAgentTool( + string Name, + string Description, + JsonElement InputSchema, + Func<JsonElement, CancellationToken, Task<object?>> Handler); + +/// <summary> +/// The tools the outline-import agent can reach for: read-only, root-scoped filesystem +/// access to the source folder, a write capability limited to exactly the resume ledger, +/// and the same application services the chat agent and REST API use for everything else. +/// +/// Deliberately a separate toolset from <see cref="NovelAgentToolset"/> rather than an +/// extension of it — filesystem access must never be reachable from a normal chat +/// conversation. One instance is built per import run (see <see cref="Initialize"/>), so +/// the current project id lives here rather than being threaded through every call. +/// </summary> +public class ImportAgentToolset( + ProjectService projects, + CharacterService characters, + CharacterArcService arcs, + ChapterService chapters, + BeatService beats, + ILogger<ImportAgentToolset> logger) +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = false, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + }; + + private string _sourceRoot = string.Empty; + private Dictionary<string, ImportAgentTool>? _byName; + + public Guid? ProjectId { get; private set; } + + public IReadOnlyList<AgentToolDefinition> Definitions => + [.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; + + /// <summary>Binds this instance to one run. Must be called before any tool executes.</summary> + public void Initialize(string sourceRoot, Guid? existingProjectId) + { + _sourceRoot = sourceRoot; + ProjectId = existingProjectId; + } + + /// <summary>Reads the ledger directly — the run driver's ground truth for "is this done", not the model's say-so.</summary> + public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot); + + /// <summary>Runs a tool and serialises its result. Failures come back as text so the model can read and self-correct.</summary> + public async Task<AgentToolResult> ExecuteAsync(string name, JsonElement input, CancellationToken ct = default) + { + if (!ByName.TryGetValue(name, out var tool)) + { + logger.LogWarning("Import agent requested unknown tool {Tool}", name); + return new AgentToolResult($"No such tool: '{name}'.", true); + } + + logger.LogDebug("Running import tool {Tool}", name); + + try + { + var result = await tool.Handler(input, ct); + logger.LogDebug("Import tool {Tool} succeeded", name); + return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false); + } + catch (NotFoundException ex) + { + logger.LogWarning(ex, "Import tool {Tool} failed: not found", name); + return new AgentToolResult(ex.Message, true); + } + catch (ArgumentException ex) + { + logger.LogWarning(ex, "Import tool {Tool} failed: invalid argument", name); + return new AgentToolResult(ex.Message, true); + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Import tool {Tool} failed: malformed JSON input", name); + return new AgentToolResult($"Malformed JSON: {ex.Message}", true); + } + catch (InvalidOperationException ex) + { + logger.LogWarning(ex, "Import tool {Tool} failed: invalid operation", name); + return new AgentToolResult(ex.Message, true); + } + catch (IOException ex) + { + logger.LogWarning(ex, "Import tool {Tool} failed: I/O error", name); + return new AgentToolResult(ex.Message, true); + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Import tool {Tool} failed: access denied", name); + return new AgentToolResult(ex.Message, true); + } + } + + private Guid RequireProjectId() => + ProjectId ?? throw new InvalidOperationException( + "No project exists yet for this import — call create_project first."); + + private Dictionary<string, ImportAgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name); + + private IEnumerable<ImportAgentTool> Build() + { + yield return new ImportAgentTool( + "list_source_files", + "List markdown files under the import source folder, optionally narrowed to one " + + "subfolder (e.g. 'outlines', 'characters'). Returns paths relative to the source " + + "folder, for use with read_source_file.", + new JsonSchemaBuilder() + .Str("subfolder", "Subfolder to list, relative to the source root. Omit to list the root.") + .Build(), + (input, ct) => + { + var subfolder = JsonInput.String(input, "subfolder"); + var dir = string.IsNullOrWhiteSpace(subfolder) + ? _sourceRoot + : ImportPaths.ResolveWithin(_sourceRoot, subfolder); + + if (!Directory.Exists(dir)) + { + return Task.FromResult<object?>(Array.Empty<string>()); + } + + var files = Directory.EnumerateFiles(dir, "*.md", SearchOption.TopDirectoryOnly) + .Select(f => Path.GetRelativePath(_sourceRoot, f).Replace(Path.DirectorySeparatorChar, '/')) + .OrderBy(f => f, StringComparer.Ordinal) + .ToArray(); + + return Task.FromResult<object?>(files); + }); + + yield return new ImportAgentTool( + "read_source_file", + "Read one markdown file from the import source folder, by its path relative to " + + "the source root (as returned by list_source_files, or a known name like " + + "'outline.md'). Read-only — this tool never writes.", + new JsonSchemaBuilder() + .Str("path", "Path relative to the import source root.", required: true) + .Build(), + (input, ct) => + { + var path = ImportPaths.ResolveWithin(_sourceRoot, JsonInput.RequiredString(input, "path")); + if (!File.Exists(path)) + { + throw new ArgumentException($"'{JsonInput.RequiredString(input, "path")}' does not exist."); + } + + var content = File.ReadAllText(path); + logger.LogDebug("Read source file, length {Length}", content.Length); + return Task.FromResult<object?>(content); + }); + + yield return new ImportAgentTool( + "read_ledger", + "Read the resume ledger (.novelly-import.json) at the root of the source folder. " + + "Returns an empty object if none exists yet — this is a fresh import.", + new JsonSchemaBuilder().Build(), + (_, ct) => + { + var path = ImportPaths.LedgerPath(_sourceRoot); + return Task.FromResult<object?>(File.Exists(path) ? File.ReadAllText(path) : "{}"); + }); + + yield return new ImportAgentTool( + "write_ledger", + "Overwrite the resume ledger (.novelly-import.json) with the given JSON. This is " + + "the only file this tool can write anywhere under the source folder — call it " + + "after every successful write so a resumed run doesn't repeat it. Pass the full " + + "ledger, not a diff; it's small.", + new JsonSchemaBuilder() + .Str("json", "The full ledger contents to write, as a JSON string.", required: true) + .Build(), + (input, ct) => + { + var json = JsonInput.RequiredString(input, "json"); + + // Fail loudly on malformed JSON now rather than writing garbage the next + // run's read_ledger can't parse. + using var _ = JsonDocument.Parse(json); + + File.WriteAllText(ImportPaths.LedgerPath(_sourceRoot), json); + return Task.FromResult<object?>(new { written = true }); + }); + + yield return new ImportAgentTool( + "create_project", + "Create the novel project this import populates. Call once, in the first pass.", + new JsonSchemaBuilder() + .Str("title", "The book's title.", required: true) + .Str("author", "Author name, if known.") + .Str("notes", "The blurb/summary paragraph(s) from outline.md.") + .Build(), + async (input, ct) => + { + var created = await projects.CreateAsync(new CreateProjectRequest( + JsonInput.RequiredString(input, "title"), + JsonInput.String(input, "author"), + Notes: JsonInput.String(input, "notes")), ct); + + ProjectId = created.Id; + return created.ToResponse(); + }); + + yield return new ImportAgentTool( + "update_project_brief", + "Revise the project's top-level fields. Only the fields you supply change.", + new JsonSchemaBuilder() + .Str("title", "New title.") + .Str("author", "Author name.") + .Str("genre", "Genre or category.") + .Str("notes", "Free-form notes — the blurb, if not already set.") + .Build(), + async (input, ct) => (await projects.UpdateAsync(RequireProjectId(), new UpdateProjectRequest( + JsonInput.String(input, "title"), + JsonInput.String(input, "author"), + JsonInput.String(input, "genre"), + Notes: JsonInput.String(input, "notes")), ct) + ?? throw new NotFoundException(nameof(Project), RequireProjectId())).ToResponse()); + + yield return new ImportAgentTool( + "create_character", + "Add a character dossier, parsed from a characters/*.md file.", + CharacterSchema(nameRequired: true).Build(), + async (input, ct) => (await characters.CreateAsync(RequireProjectId(), new CreateCharacterRequest( + JsonInput.RequiredString(input, "name"), + Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting, + Occupation: JsonInput.String(input, "occupation"), + Appearance: JsonInput.String(input, "appearance"), + Backstory: JsonInput.String(input, "backstory"), + Want: JsonInput.String(input, "want"), + Notes: JsonInput.String(input, "notes")), ct)).ToResponse()); + + yield return new ImportAgentTool( + "update_character", + "Revise an existing character dossier. Only the fields you supply change.", + CharacterSchema(nameRequired: false) + .Str("character_id", "Id of the character to update.", required: true) + .Build(), + async (input, ct) => + { + var characterId = JsonInput.RequiredGuid(input, "character_id"); + return (await characters.UpdateAsync(characterId, new UpdateCharacterRequest( + JsonInput.String(input, "name"), + Importance: JsonInput.Enum<CharacterImportance>(input, "importance"), + Occupation: JsonInput.String(input, "occupation"), + Appearance: JsonInput.String(input, "appearance"), + Backstory: JsonInput.String(input, "backstory"), + Want: JsonInput.String(input, "want"), + Notes: JsonInput.String(input, "notes")), ct) + ?? throw new NotFoundException("Character", characterId)).ToResponse(); + }); + + yield return new ImportAgentTool( + "create_chapter", + "Add a chapter. Its number is appended to the end of the manuscript unless you supply one.", + new JsonSchemaBuilder() + .Str("title", "Chapter title.", required: true) + .Int("number", "Position in the manuscript, 1-based, matching the outline's chapter number.") + .Str("summary", "The chapter's prose summary paragraph(s).") + .Str("pov_character_id", "Id of the point-of-view character, only when the Thread names exactly one.") + .Str("notes", "The chapter file's ## Notes section, if present.") + .StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].") + .Build(), + async (input, ct) => (await chapters.CreateAsync(RequireProjectId(), new CreateChapterRequest( + JsonInput.RequiredString(input, "title"), + JsonInput.Int(input, "number"), + JsonInput.String(input, "summary"), + JsonInput.Guid(input, "pov_character_id"), + Notes: JsonInput.String(input, "notes"), + Tags: JsonInput.Strings(input, "tags")), ct)).ToResponse()); + + yield return new ImportAgentTool( + "update_chapter", + "Revise a chapter's summary, POV or notes.", + new JsonSchemaBuilder() + .Str("chapter_id", "Id of the chapter to update.", required: true) + .Str("summary", "The chapter's prose summary paragraph(s).") + .Str("pov_character_id", "Id of the point-of-view character.") + .Str("notes", "The chapter file's ## Notes section.") + .Build(), + async (input, ct) => + { + var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); + return (await chapters.UpdateAsync(chapterId, new UpdateChapterRequest( + Summary: JsonInput.String(input, "summary"), + PovCharacterId: JsonInput.Guid(input, "pov_character_id"), + Notes: JsonInput.String(input, "notes")), ct) + ?? throw new NotFoundException("Chapter", chapterId)).ToResponse(); + }); + + yield return new ImportAgentTool( + "create_beat", + "Add a beat to a chapter's outline, from one row of its beat table.", + new JsonSchemaBuilder() + .Str("chapter_id", "Id of the chapter the beat belongs to.", required: true) + .Str("title", "The Beat column — three to five words.", required: true) + .Str("character_id", "Id of the character named in the Character column, if it resolves.") + .Str("what_happened", "The What column.") + .Str("whats_next", "The Why column.") + .Build(), + async (input, ct) => (await beats.CreateAsync( + JsonInput.RequiredGuid(input, "chapter_id"), + new CreateBeatRequest( + JsonInput.RequiredString(input, "title"), + CharacterId: JsonInput.Guid(input, "character_id"), + WhatHappened: JsonInput.String(input, "what_happened"), + WhatsNext: JsonInput.String(input, "whats_next")), ct)).ToResponse()); + + yield return new ImportAgentTool( + "add_arc_stage", + "Add a stage to a character's arc, from one bullet under a dossier's ## Events section.", + new JsonSchemaBuilder() + .Str("character_id", "Id of the character whose arc to add to.", required: true) + .Str("title", "A 3-5 word handle for the change, synthesized from the bullet.", required: true) + .Str("description", "The bullet's text.") + .Str("chapter_id", "The chapter this stage is pinned to, if the (Ch. N) marker resolves to an imported chapter.") + .Build(), + async (input, ct) => (await arcs.CreateAsync( + JsonInput.RequiredGuid(input, "character_id"), + new CreateArcStageRequest( + JsonInput.RequiredString(input, "title"), + Description: JsonInput.String(input, "description"), + ChapterId: JsonInput.Guid(input, "chapter_id")), ct)).ToResponse()); + } + + private static JsonSchemaBuilder CharacterSchema(bool nameRequired) => + new JsonSchemaBuilder() + .Str("name", "The character's name.", nameRequired) + .Enum( + "importance", + "How much of the book they carry. Only characters with a dossier ## Events " + + "section should be promoted to Main.", + System.Enum.GetNames<CharacterImportance>()) + .Str("occupation", "The italic tagline under the heading.") + .Str("appearance", "The ## Appearance section.") + .Str("backstory", "The ## Background section.") + .Str("want", "The ## Motivation section.") + .Str("notes", "The ## Notes section, if present."); +} diff --git a/src/Novelly.Api/Imports/ImportContracts.cs b/src/Novelly.Api/Imports/ImportContracts.cs new file mode 100644 index 0000000..50684e5 --- /dev/null +++ b/src/Novelly.Api/Imports/ImportContracts.cs @@ -0,0 +1,78 @@ +using Novelly.Api.Common.Validation; + +namespace Novelly.Api.Imports; + +public record ImportJobResponse( + Guid Id, + string SourceRoot, + Guid? ProjectId, + ImportJobStatus Status, + string? StatusMessage, + int ChaptersCompleted, + int ChaptersTotal, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +/// <summary>Whether a source folder is ready for a fresh import, has one to resume, or is already done.</summary> +public enum ImportReadiness +{ + Fresh, + Resumable, + Complete +} + +public record ImportInspectionResponse( + ImportReadiness Readiness, + Guid? ProjectId, + int ChaptersCompleted, + int ChaptersTotal, + IReadOnlyList<string> CompletedPasses); + +public record InspectImportRequest(string SourceRoot); + +public class InspectImportRequestValidator : IModelValidator<InspectImportRequest> +{ + public ValidationResult Validate(InspectImportRequest model) + { + var result = new ValidationResult(); + + if (string.IsNullOrWhiteSpace(model.SourceRoot)) + result.AddError("SourceRoot", "'Source Root' must not be empty."); + + return result; + } +} + +/// <summary> +/// Starts a fresh import, resumes an incomplete one, or — with <see cref="ForceRestart"/> — +/// deletes the ledger and the project it points at before starting clean. Resuming needs no +/// flag: the importer always continues from the ledger it finds unless told to wipe it. +/// </summary> +public record StartImportRequest(string SourceRoot, bool ForceRestart = false); + +public class StartImportRequestValidator : IModelValidator<StartImportRequest> +{ + public ValidationResult Validate(StartImportRequest model) + { + var result = new ValidationResult(); + + if (string.IsNullOrWhiteSpace(model.SourceRoot)) + result.AddError("SourceRoot", "'Source Root' must not be empty."); + + return result; + } +} + +public static class ImportMapping +{ + public static ImportJobResponse ToResponse(this ImportJob job) => new( + job.Id, + job.SourceRoot, + job.ProjectId, + job.Status, + job.StatusMessage, + job.ChaptersCompleted, + job.ChaptersTotal, + job.CreatedAt, + job.UpdatedAt); +} diff --git a/src/Novelly.Api/Imports/ImportEndpoints.cs b/src/Novelly.Api/Imports/ImportEndpoints.cs new file mode 100644 index 0000000..bdc7386 --- /dev/null +++ b/src/Novelly.Api/Imports/ImportEndpoints.cs @@ -0,0 +1,33 @@ +using Novelly.Api.Common; +using Novelly.Api.Common.Validation; + +namespace Novelly.Api.Imports; + +public static class ImportEndpoints +{ + public static IEndpointRouteBuilder MapImportEndpoints(this IEndpointRouteBuilder app) + { + var imports = app.MapGroup("/api/imports").WithTags("Imports") + .AddEndpointFilter<RequestLoggingEndpointFilter>() + .AddEndpointFilter<ValidationEndpointFilter>(); + + imports.MapPost("/inspect", async ( + InspectImportRequest request, ImportService service, CancellationToken ct) => + Results.Ok(await service.InspectAsync(request, ct))) + .WithSummary("Check whether a source folder is a fresh import, has one to resume, or is already complete."); + + imports.MapPost("/", async ( + StartImportRequest request, ImportService service, CancellationToken ct) => + { + var job = (await service.StartOrResumeAsync(request, ct)).ToResponse(); + return Results.Created($"/api/imports/{job.Id}", job); + }) + .WithSummary("Start, resume, or (with forceRestart) wipe and restart an outline import."); + + imports.MapGet("/{id:guid}", async (Guid id, ImportService service, CancellationToken ct) => + (await service.GetStatusAsync(id, ct))?.ToResponse().ToApiResult()) + .WithSummary("Poll an import job's progress."); + + return app; + } +} diff --git a/src/Novelly.Api/Imports/ImportJob.cs b/src/Novelly.Api/Imports/ImportJob.cs new file mode 100644 index 0000000..7d605f6 --- /dev/null +++ b/src/Novelly.Api/Imports/ImportJob.cs @@ -0,0 +1,41 @@ +namespace Novelly.Api.Imports; + +/// <summary> +/// Where an import run stands. <see cref="Paused"/> means it hit its safety limit for a +/// single run without finishing — not an error, just more work than fit in one pass — +/// and re-starting the same source root resumes it from the ledger. +/// </summary> +public enum ImportJobStatus +{ + Pending, + Running, + Completed, + Failed, + Paused +} + +/// <summary> +/// One run of the outline importer against a source folder, tracked so the web client can +/// poll progress while the embedded agent works through it in the background. +/// </summary> +public class ImportJob +{ + public Guid Id { get; init; } = Guid.NewGuid(); + + /// <summary>Absolute, canonicalised path to the outline folder this job reads from.</summary> + public string SourceRoot { get; init; } = string.Empty; + + /// <summary>Set once the import creates (or resumes) the project it's populating.</summary> + public Guid? ProjectId { get; set; } + + public ImportJobStatus Status { get; set; } = ImportJobStatus.Pending; + + /// <summary>Human-readable detail for <see cref="Paused"/> or <see cref="Failed"/> — null otherwise.</summary> + public string? StatusMessage { get; set; } + + public int ChaptersCompleted { get; set; } + public int ChaptersTotal { get; set; } + + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/Novelly.Api/Imports/ImportJobRunner.cs b/src/Novelly.Api/Imports/ImportJobRunner.cs new file mode 100644 index 0000000..196cf1f --- /dev/null +++ b/src/Novelly.Api/Imports/ImportJobRunner.cs @@ -0,0 +1,79 @@ +using System.Threading.Channels; +using Microsoft.EntityFrameworkCore; +using Novelly.Api.Data; + +namespace Novelly.Api.Imports; + +/// <summary> +/// The only background-job infrastructure in the app. Drains import job ids off a queue +/// and runs each one to completion (or its safety limit) in its own DI scope, persisting +/// progress and the terminal status onto the <see cref="ImportJob"/> row the web client +/// polls. Everything else in Novelly runs synchronously on the request thread; imports are +/// the first thing long enough that it can't. +/// </summary> +public class ImportJobRunner( + Channel<Guid> queue, + IServiceScopeFactory scopeFactory, + ILogger<ImportJobRunner> logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var jobId in queue.Reader.ReadAllAsync(stoppingToken)) + { + try + { + await RunJobAsync(jobId, stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A failure here means the job row itself couldn't be updated (e.g. the + // scope's DbContext failed) — RunJobAsync already turns ordinary import + // failures into a Failed status rather than throwing. + logger.LogError(ex, "Import job {JobId} runner failed unexpectedly", jobId); + } + } + } + + private async Task RunJobAsync(Guid jobId, CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService<INovelDbContext>(); + var agent = scope.ServiceProvider.GetRequiredService<ImportAgentService>(); + + var job = await db.ImportJobs.FirstOrDefaultAsync(j => j.Id == jobId, ct); + if (job is null) + { + logger.LogWarning("Import job {JobId} not found when the runner picked it up", jobId); + return; + } + + logger.LogInformation("Import job {JobId} starting for {SourceRoot}", job.Id, job.SourceRoot); + + job.Status = ImportJobStatus.Running; + job.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + try + { + var existingProjectId = ImportPaths.ReadLedger(job.SourceRoot)?.ProjectId; + + var result = await agent.RunAsync(job.SourceRoot, existingProjectId, job.ChaptersTotal, ct); + + job.ProjectId = result.ProjectId; + job.ChaptersCompleted = result.ChaptersCompleted; + job.Status = result.Completed ? ImportJobStatus.Completed : ImportJobStatus.Paused; + job.StatusMessage = result.Message; + } + catch (Exception ex) + { + logger.LogError(ex, "Import job {JobId} failed", job.Id); + job.Status = ImportJobStatus.Failed; + job.StatusMessage = ex.Message; + } + + job.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + logger.LogInformation("Import job {JobId} finished as {Status}", job.Id, job.Status); + } +} diff --git a/src/Novelly.Api/Imports/ImportPaths.cs b/src/Novelly.Api/Imports/ImportPaths.cs new file mode 100644 index 0000000..d180379 --- /dev/null +++ b/src/Novelly.Api/Imports/ImportPaths.cs @@ -0,0 +1,136 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Novelly.Api.Imports; + +/// <summary> +/// The resume ledger an import run writes to <c><sourceRoot>/.novelly-import.json</c>. +/// Shape matches the one the <c>outline-importer</c> Claude Code subagent already writes, +/// so a partially-completed CLI import can be finished from the web app and vice versa. +/// </summary> +public record ImportLedger( + Guid? ProjectId, + Dictionary<string, Guid>? Characters, + Dictionary<string, Guid>? Chapters, + List<string>? CompletedPasses, + List<int>? CompletedChapters); + +/// <summary> +/// Path resolution and ledger I/O shared by <see cref="ImportService"/> (which only ever +/// peeks at the ledger to report status) and <see cref="ImportAgentToolset"/> (which reads +/// and writes it as the agent's only file-write capability). Centralising the containment +/// check here means there is exactly one place that decides whether a path is inside the +/// import root, rather than one per caller. +/// </summary> +internal static class ImportPaths +{ + private const string LedgerFileName = ".novelly-import.json"; + + private static readonly JsonSerializerOptions LedgerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + /// <summary> + /// Canonicalises a source root and confirms it's a directory that exists. Throws + /// <see cref="ArgumentException"/> on anything else — bad input from the request, not + /// an exceptional server condition. + /// </summary> + public static string ResolveRoot(string sourceRoot) + { + if (string.IsNullOrWhiteSpace(sourceRoot)) + throw new ArgumentException("'Source Root' must not be empty.", nameof(sourceRoot)); + + string full; + try + { + full = Path.GetFullPath(sourceRoot); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + throw new ArgumentException($"'{sourceRoot}' is not a valid path.", nameof(sourceRoot)); + } + + if (!Directory.Exists(full)) + throw new ArgumentException($"'{full}' does not exist or is not a directory.", nameof(sourceRoot)); + + return full; + } + + /// <summary> + /// Resolves a path the agent supplied relative to the import root, rejecting anything + /// that would escape it (`..`, absolute paths, symlink traversal). This is the tool + /// layer's actual security boundary — the system prompt asking nicely is not. + /// </summary> + public static string ResolveWithin(string root, string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + throw new ArgumentException("Path must not be empty."); + + var combined = Path.GetFullPath(Path.Combine(root, relativePath)); + var relativeToRoot = Path.GetRelativePath(root, combined); + + if (relativeToRoot.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relativeToRoot)) + throw new ArgumentException($"'{relativePath}' escapes the import source folder."); + + return combined; + } + + public static string LedgerPath(string root) => Path.Combine(root, LedgerFileName); + + /// <summary>Null when no ledger exists yet — a fresh import, not an error.</summary> + public static ImportLedger? ReadLedger(string root) + { + var path = LedgerPath(root); + if (!File.Exists(path)) + { + return null; + } + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize<ImportLedger>(json, LedgerOptions); + } + + public static void DeleteLedger(string root) + { + var path = LedgerPath(root); + if (File.Exists(path)) + { + File.Delete(path); + } + } + + /// <summary> + /// Counts chapter source files as a stand-in for "how many chapters does this outline + /// have" — good enough to drive a progress bar without parsing <c>outline.md</c>'s + /// chapter table in C#. + /// </summary> + public static int CountChapterFiles(string root) + { + foreach (var folder in new[] { "outlines", "chapters" }) + { + var path = Path.Combine(root, folder); + if (Directory.Exists(path)) + { + return Directory.EnumerateFiles(path, "*.md").Count(); + } + } + + return 0; + } + + public static bool IsComplete(ImportLedger? ledger, int chaptersTotal) + { + if (ledger is null) + { + return false; + } + + var passes = ledger.CompletedPasses ?? []; + var requiredPasses = new[] { "project", "characters", "chapters", "arcs" }; + var chaptersDone = ledger.CompletedChapters?.Count ?? 0; + + return requiredPasses.All(passes.Contains) && (chaptersTotal == 0 || chaptersDone >= chaptersTotal); + } +} diff --git a/src/Novelly.Api/Imports/ImportService.cs b/src/Novelly.Api/Imports/ImportService.cs new file mode 100644 index 0000000..ae27c20 --- /dev/null +++ b/src/Novelly.Api/Imports/ImportService.cs @@ -0,0 +1,110 @@ +using System.Threading.Channels; +using Microsoft.EntityFrameworkCore; +using Novelly.Api.Common; +using Novelly.Api.Common.Validation; +using Novelly.Api.Data; +using Novelly.Api.Projects; + +namespace Novelly.Api.Imports; + +/// <summary> +/// Read-only inspection and job creation for outline imports. The actual import — reading +/// source files, calling the model, writing project data — runs in <see cref="ImportAgentService"/>, +/// driven off the request thread by <see cref="ImportJobRunner"/>; this service only ever +/// touches the filesystem to peek at a ledger, never to import anything itself. +/// </summary> +public class ImportService( + INovelDbContext db, + ProjectService projects, + Channel<Guid> queue, + ILogger<ImportService> logger, + IModelValidator<InspectImportRequest> inspectValidator, + IModelValidator<StartImportRequest> startValidator) +{ + /// <summary> + /// Reports whether a folder is a fresh import, one to resume, or already complete — + /// so the UI can offer the right action before committing to anything. + /// </summary> + public Task<ImportInspectionResponse> InspectAsync(InspectImportRequest request, CancellationToken ct = default) + { + Guard.Null(request, nameof(request)); + inspectValidator.Validate(request).ThrowIfInvalid(); + + logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot); + + var root = ImportPaths.ResolveRoot(request.SourceRoot); + var ledger = ImportPaths.ReadLedger(root); + var total = ImportPaths.CountChapterFiles(root); + + if (ledger is null) + { + return Task.FromResult(new ImportInspectionResponse(ImportReadiness.Fresh, null, 0, total, [])); + } + + var chaptersDone = ledger.CompletedChapters?.Count ?? 0; + var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable; + + return Task.FromResult(new ImportInspectionResponse( + readiness, ledger.ProjectId, chaptersDone, total, ledger.CompletedPasses ?? [])); + } + + /// <summary> + /// Creates (or reuses) an <see cref="ImportJob"/> for this source root and enqueues it + /// for the background runner. <see cref="StartImportRequest.ForceRestart"/> deletes the + /// ledger and the project it points at first — the "complete, delete and reimport" path — + /// so make sure the caller has confirmed with the writer before setting it. + /// </summary> + public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default) + { + Guard.Null(request, nameof(request)); + startValidator.Validate(request).ThrowIfInvalid(); + + logger.LogInformation( + "Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart); + + var root = ImportPaths.ResolveRoot(request.SourceRoot); + + if (request.ForceRestart) + { + var ledger = ImportPaths.ReadLedger(root); + if (ledger?.ProjectId is { } existingProjectId) + { + logger.LogWarning( + "Force-restarting import for {SourceRoot}: deleting project {ProjectId}", root, existingProjectId); + await projects.DeleteAsync(existingProjectId, ct); + } + + ImportPaths.DeleteLedger(root); + } + + var existing = await db.ImportJobs + .Where(j => j.SourceRoot == root + && (j.Status == ImportJobStatus.Pending || j.Status == ImportJobStatus.Running)) + .FirstOrDefaultAsync(ct); + + if (existing is not null) + { + logger.LogInformation("Import for {SourceRoot} is already {Status} as job {JobId}", root, existing.Status, existing.Id); + return existing; + } + + var job = new ImportJob { SourceRoot = root, ChaptersTotal = ImportPaths.CountChapterFiles(root) }; + db.ImportJobs.Add(job); + await db.SaveChangesAsync(ct); + + await queue.Writer.WriteAsync(job.Id, ct); + + return job; + } + + /// <summary>Null when no job has this id — a lookup miss is expected, not exceptional.</summary> + public async Task<ImportJob?> GetStatusAsync(Guid id, CancellationToken ct = default) + { + Guard.Default(id, nameof(id)); + + logger.LogInformation("Getting import job {JobId}", id); + + var job = await db.ImportJobs.FirstOrDefaultAsync(j => j.Id == id, ct); + return job; + } +} diff --git a/src/Novelly.Api/Program.cs b/src/Novelly.Api/Program.cs index d6b5433..60f69dc 100644 --- a/src/Novelly.Api/Program.cs +++ b/src/Novelly.Api/Program.cs @@ -7,6 +7,7 @@ using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common; using Novelly.Api.Data; +using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; using Novelly.Api.Scenes; @@ -98,7 +99,8 @@ app.MapProjectEndpoints() .MapSceneEndpoints() .MapTagEndpoints() .MapOpenQuestionEndpoints() - .MapAgentEndpoints(); + .MapAgentEndpoints() + .MapImportEndpoints(); app.Run(); diff --git a/src/Novelly.Api/Projects/ProjectDtos.cs b/src/Novelly.Api/Projects/ProjectContracts.cs similarity index 96% rename from src/Novelly.Api/Projects/ProjectDtos.cs rename to src/Novelly.Api/Projects/ProjectContracts.cs index 335bfab..b1a8434 100644 --- a/src/Novelly.Api/Projects/ProjectDtos.cs +++ b/src/Novelly.Api/Projects/ProjectContracts.cs @@ -2,7 +2,7 @@ using Novelly.Api.Common.Validation; namespace Novelly.Api.Projects; -public record ProjectSummaryDto( +public record ProjectSummaryResponse( Guid Id, string Title, string? Author, @@ -14,7 +14,7 @@ public record ProjectSummaryDto( int WordCount, DateTimeOffset UpdatedAt); -public record ProjectDto( +public record ProjectResponse( Guid Id, string Title, string? Author, @@ -116,7 +116,7 @@ file static class ProjectValidation public static class ProjectMapping { - public static ProjectDto ToDto(this Project p) => new( + public static ProjectResponse ToResponse(this Project p) => new( p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes, p.TargetWordCount, p.CreatedAt, p.UpdatedAt); } diff --git a/src/Novelly.Api/Projects/ProjectEndpoints.cs b/src/Novelly.Api/Projects/ProjectEndpoints.cs index 0888d08..167220e 100644 --- a/src/Novelly.Api/Projects/ProjectEndpoints.cs +++ b/src/Novelly.Api/Projects/ProjectEndpoints.cs @@ -16,19 +16,19 @@ public static class ProjectEndpoints .WithSummary("List all novel projects."); group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) => - (await service.GetAsync(id, ct)).ToApiResult()) + (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) .WithSummary("Read a project's brief."); group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) => { - var created = await service.CreateAsync(request, ct); + var created = (await service.CreateAsync(request, ct)).ToResponse(); return Results.Created($"/api/projects/{created.Id}", created); }) .WithSummary("Create a novel project."); group.MapPatch("/{id:guid}", async ( Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct)).ToApiResult()) + (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) .WithSummary("Update a project's brief."); group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) => diff --git a/src/Novelly.Api/Projects/ProjectService.cs b/src/Novelly.Api/Projects/ProjectService.cs index 5cbfa0b..6aa0c72 100644 --- a/src/Novelly.Api/Projects/ProjectService.cs +++ b/src/Novelly.Api/Projects/ProjectService.cs @@ -11,13 +11,13 @@ public class ProjectService( IModelValidator<CreateProjectRequest> createValidator, IModelValidator<UpdateProjectRequest> updateValidator) { - public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default) + public async Task<IReadOnlyList<ProjectSummaryResponse>> ListAsync(CancellationToken ct = default) { logger.LogInformation("Listing projects"); return await db.Projects .OrderByDescending(p => p.UpdatedAt) - .Select(p => new ProjectSummaryDto( + .Select(p => new ProjectSummaryResponse( p.Id, p.Title, p.Author, @@ -32,15 +32,15 @@ public class ProjectService( } /// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary> - public async Task<ProjectDto?> GetAsync(Guid id, CancellationToken ct = default) + public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Getting project {ProjectId}", id); - return (await FindAsync(id, ct))?.ToDto(); + return await FindAsync(id, ct); } - public async Task<ProjectDto> CreateAsync(CreateProjectRequest request, CancellationToken ct = default) + public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default) { Guard.Null(request, nameof(request)); createValidator.Validate(request).ThrowIfInvalid(); @@ -60,10 +60,10 @@ public class ProjectService( db.Projects.Add(project); await db.SaveChangesAsync(ct); - return project.ToDto(); + return project; } - public async Task<ProjectDto?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default) + public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default) { Guard.Default(id, nameof(id)); Guard.Null(request, nameof(request)); @@ -87,7 +87,7 @@ public class ProjectService( project.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); - return project.ToDto(); + return project; } /// <summary>True if a project was deleted; false if no project had this id.</summary> diff --git a/src/Novelly.Api/Questions/OpenQuestionDtos.cs b/src/Novelly.Api/Questions/OpenQuestionContracts.cs similarity index 96% rename from src/Novelly.Api/Questions/OpenQuestionDtos.cs rename to src/Novelly.Api/Questions/OpenQuestionContracts.cs index 6aefc58..466dd98 100644 --- a/src/Novelly.Api/Questions/OpenQuestionDtos.cs +++ b/src/Novelly.Api/Questions/OpenQuestionContracts.cs @@ -2,7 +2,7 @@ using Novelly.Api.Common.Validation; namespace Novelly.Api.Questions; -public record OpenQuestionDto( +public record OpenQuestionResponse( Guid Id, Guid ProjectId, string Question, @@ -100,7 +100,7 @@ public class ResolveOpenQuestionRequestValidator : IModelValidator<ResolveOpenQu public static class OpenQuestionMapping { - public static OpenQuestionDto ToDto(this OpenQuestion q) => new( + public static OpenQuestionResponse ToResponse(this OpenQuestion q) => new( q.Id, q.ProjectId, q.Question, diff --git a/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs b/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs index 8152480..a3755b6 100644 --- a/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs +++ b/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs @@ -18,13 +18,13 @@ public static class OpenQuestionEndpoints Guid? chapterId = null, Guid? characterId = null, bool includeResolved = false) => - Results.Ok(await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct))) + Results.Ok((await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse()))) .WithSummary("List a project's open questions, optionally narrowed to one chapter or character."); projectScoped.MapPost("/", async ( Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => { - var created = await service.CreateAsync(projectId, request, ct); + var created = (await service.CreateAsync(projectId, request, ct)).ToResponse(); return Results.Created($"/api/questions/{created.Id}", created); }) .WithSummary("Raise an open question, optionally against a chapter outline and/or a character."); @@ -34,21 +34,21 @@ public static class OpenQuestionEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) => - (await service.GetAsync(id, ct)).ToApiResult()) + (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) .WithSummary("Read one question."); questions.MapPatch("/{id:guid}", async ( Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct)).ToApiResult()) + (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) .WithSummary("Update a question or change what it is attached to."); questions.MapPost("/{id:guid}/resolve", async ( Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => - (await service.ResolveAsync(id, request, ct)).ToApiResult()) + (await service.ResolveAsync(id, request, ct))?.ToResponse().ToApiResult()) .WithSummary("Settle a question, optionally appending the resolution to the notes it hangs off."); questions.MapPost("/{id:guid}/reopen", async (Guid id, OpenQuestionService service, CancellationToken ct) => - (await service.ReopenAsync(id, ct)).ToApiResult()) + (await service.ReopenAsync(id, ct))?.ToResponse().ToApiResult()) .WithSummary("Put a resolved question back on the list."); questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) => diff --git a/src/Novelly.Api/Questions/OpenQuestionService.cs b/src/Novelly.Api/Questions/OpenQuestionService.cs index 540a5e2..0b54980 100644 --- a/src/Novelly.Api/Questions/OpenQuestionService.cs +++ b/src/Novelly.Api/Questions/OpenQuestionService.cs @@ -24,7 +24,7 @@ public class OpenQuestionService( /// Filters narrow to what one page cares about; resolved questions are left out /// unless asked for, since the point of the list is what is still undecided. /// </summary> - public async Task<IReadOnlyList<OpenQuestionDto>> ListAsync( + public async Task<IReadOnlyList<OpenQuestion>> ListAsync( Guid projectId, Guid? chapterId = null, Guid? characterId = null, @@ -61,20 +61,19 @@ public class OpenQuestionService( .. questions .OrderBy(q => q.ResolvedAt is not null) .ThenByDescending(q => q.CreatedAt) - .Select(q => q.ToDto()) ]; } /// <summary>Null when no open question has this id — a lookup miss is expected, not exceptional.</summary> - public async Task<OpenQuestionDto?> GetAsync(Guid id, CancellationToken ct = default) + public async Task<OpenQuestion?> GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Getting open question {QuestionId}", id); - return (await FindAsync(id, ct))?.ToDto(); + return await FindAsync(id, ct); } - public async Task<OpenQuestionDto> CreateAsync( + public async Task<OpenQuestion> CreateAsync( Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default) { Guard.Default(projectId, nameof(projectId)); @@ -104,10 +103,10 @@ public class OpenQuestionService( await db.SaveChangesAsync(ct); // Just created it — the reload is only to pick up includes, not to check existence. - return (await FindAsync(question.Id, ct))!.ToDto(); + return (await FindAsync(question.Id, ct))!; } - public async Task<OpenQuestionDto?> UpdateAsync( + public async Task<OpenQuestion?> UpdateAsync( Guid id, UpdateOpenQuestionRequest request, CancellationToken ct = default) { Guard.Default(id, nameof(id)); @@ -131,7 +130,7 @@ public class OpenQuestionService( question.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); - return (await FindAsync(id, ct))!.ToDto(); + return (await FindAsync(id, ct))!; } /// <summary> @@ -140,7 +139,7 @@ public class OpenQuestionService( /// writer reads rather than only in a list they have stopped looking at. Null when no /// open question has this id. /// </summary> - public async Task<OpenQuestionDto?> ResolveAsync( + public async Task<OpenQuestion?> ResolveAsync( Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default) { Guard.Default(id, nameof(id)); @@ -189,11 +188,11 @@ public class OpenQuestionService( } await db.SaveChangesAsync(ct); - return (await FindAsync(id, ct))!.ToDto(); + return (await FindAsync(id, ct))!; } /// <summary>Puts a question back on the list. The resolution goes; anything already appended to notes stays. Null when no open question has this id.</summary> - public async Task<OpenQuestionDto?> ReopenAsync(Guid id, CancellationToken ct = default) + public async Task<OpenQuestion?> ReopenAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); @@ -210,7 +209,7 @@ public class OpenQuestionService( question.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); - return (await FindAsync(id, ct))!.ToDto(); + return (await FindAsync(id, ct))!; } /// <summary>True if an open question was deleted; false if no question had this id.</summary> diff --git a/src/Novelly.Api/Scenes/SceneDtos.cs b/src/Novelly.Api/Scenes/SceneContracts.cs similarity index 97% rename from src/Novelly.Api/Scenes/SceneDtos.cs rename to src/Novelly.Api/Scenes/SceneContracts.cs index 994faeb..e717212 100644 --- a/src/Novelly.Api/Scenes/SceneDtos.cs +++ b/src/Novelly.Api/Scenes/SceneContracts.cs @@ -3,7 +3,7 @@ using Novelly.Api.Common.Validation; namespace Novelly.Api.Scenes; -public record SceneDto( +public record SceneResponse( Guid Id, Guid ChapterId, int SortOrder, @@ -111,7 +111,7 @@ file static class SceneValidation public static class SceneMapping { - public static SceneDto ToDto(this Scene s) => new( + public static SceneResponse ToResponse(this Scene s) => new( s.Id, s.ChapterId, s.SortOrder, s.Title, s.Summary, s.Goal, s.Conflict, s.Outcome, s.PovCharacterId, s.PovCharacter?.Name, s.Location, diff --git a/src/Novelly.Api/Scenes/SceneEndpoints.cs b/src/Novelly.Api/Scenes/SceneEndpoints.cs index 2784b9a..42619fb 100644 --- a/src/Novelly.Api/Scenes/SceneEndpoints.cs +++ b/src/Novelly.Api/Scenes/SceneEndpoints.cs @@ -12,13 +12,13 @@ public static class SceneEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) => - Results.Ok(await service.ListAsync(chapterId, ct))) + Results.Ok((await service.ListAsync(chapterId, ct)).Select(s => s.ToResponse()))) .WithSummary("List a chapter's scenes in order."); chapterScoped.MapPost("/", async ( Guid chapterId, CreateSceneRequest request, SceneService service, CancellationToken ct) => { - var created = await service.CreateAsync(chapterId, request, ct); + var created = (await service.CreateAsync(chapterId, request, ct)).ToResponse(); return Results.Created($"/api/scenes/{created.Id}", created); }) .WithSummary("Add a scene to a chapter."); @@ -28,12 +28,12 @@ public static class SceneEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) => - (await service.GetAsync(id, ct)).ToApiResult()) + (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) .WithSummary("Read a scene, including its prose."); scenes.MapPatch("/{id:guid}", async ( Guid id, UpdateSceneRequest request, SceneService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct)).ToApiResult()) + (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) .WithSummary("Update a scene. Sending prose recomputes the word count."); scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) => diff --git a/src/Novelly.Api/Scenes/SceneService.cs b/src/Novelly.Api/Scenes/SceneService.cs index 79ae9ed..9a0f1cf 100644 --- a/src/Novelly.Api/Scenes/SceneService.cs +++ b/src/Novelly.Api/Scenes/SceneService.cs @@ -12,30 +12,28 @@ public class SceneService( IModelValidator<CreateSceneRequest> createValidator, IModelValidator<UpdateSceneRequest> updateValidator) { - public async Task<IReadOnlyList<SceneDto>> ListAsync(Guid chapterId, CancellationToken ct = default) + public async Task<IReadOnlyList<Scene>> ListAsync(Guid chapterId, CancellationToken ct = default) { Guard.Default(chapterId, nameof(chapterId)); logger.LogInformation("Listing scenes for chapter {ChapterId}", chapterId); - var scenes = await Query() + return await Query() .Where(s => s.ChapterId == chapterId) .OrderBy(s => s.SortOrder) .ToListAsync(ct); - - return [.. scenes.Select(s => s.ToDto())]; } /// <summary>Null when no scene has this id — a lookup miss is expected, not exceptional.</summary> - public async Task<SceneDto?> GetAsync(Guid id, CancellationToken ct = default) + public async Task<Scene?> GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Getting scene {SceneId}", id); - return (await FindAsync(id, ct))?.ToDto(); + return await FindAsync(id, ct); } - public async Task<SceneDto> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default) + public async Task<Scene> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default) { Guard.Default(chapterId, nameof(chapterId)); Guard.Null(request, nameof(request)); @@ -69,10 +67,10 @@ public class SceneService( await db.SaveChangesAsync(ct); // Just created it — the reload is only to pick up includes, not to check existence. - return (await FindAsync(scene.Id, ct))!.ToDto(); + return (await FindAsync(scene.Id, ct))!; } - public async Task<SceneDto?> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default) + public async Task<Scene?> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default) { Guard.Default(id, nameof(id)); Guard.Null(request, nameof(request)); @@ -105,7 +103,7 @@ public class SceneService( scene.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); - return (await FindAsync(id, ct))!.ToDto(); + return (await FindAsync(id, ct))!; } /// <summary>True if a scene was deleted; false if no scene had this id.</summary> diff --git a/src/Novelly.Api/Tags/TagDtos.cs b/src/Novelly.Api/Tags/TagContracts.cs similarity index 62% rename from src/Novelly.Api/Tags/TagDtos.cs rename to src/Novelly.Api/Tags/TagContracts.cs index fb9c26e..b496905 100644 --- a/src/Novelly.Api/Tags/TagDtos.cs +++ b/src/Novelly.Api/Tags/TagContracts.cs @@ -2,9 +2,9 @@ using Novelly.Api.Common.Validation; namespace Novelly.Api.Tags; -public record TagDto(Guid Id, string Name, string? Color); +public record TagResponse(Guid Id, string Name, string? Color); -public record TagSummaryDto( +public record TagSummaryResponse( Guid Id, string Name, string? Color, @@ -63,17 +63,17 @@ public class UpdateTagRequestValidator : IModelValidator<UpdateTagRequest> /// tags — seeing that a motif touches two characters, a chapter and four beats is what /// makes them worth maintaining. /// </summary> -public record TagReferencesDto( - TagDto Tag, - IReadOnlyList<TaggedCharacterDto> Characters, - IReadOnlyList<TaggedChapterDto> Chapters, - IReadOnlyList<TaggedBeatDto> Beats); +public record TagReferencesResponse( + TagResponse Tag, + IReadOnlyList<TaggedCharacterResponse> Characters, + IReadOnlyList<TaggedChapterResponse> Chapters, + IReadOnlyList<TaggedBeatResponse> Beats); -public record TaggedCharacterDto(Guid Id, string Name, string Role); +public record TaggedCharacterResponse(Guid Id, string Name, string Role); -public record TaggedChapterDto(Guid Id, int Number, string Title, string? Summary); +public record TaggedChapterResponse(Guid Id, int Number, string Title, string? Summary); -public record TaggedBeatDto( +public record TaggedBeatResponse( Guid Id, Guid ChapterId, int ChapterNumber, @@ -85,7 +85,28 @@ public record TaggedBeatDto( public static class TagMapping { - public static TagDto ToDto(this Tag t) => new(t.Id, t.Name, t.Color); + public static TagResponse ToResponse(this Tag t) => new(t.Id, t.Name, t.Color); + + public static TagReferencesResponse ToReferencesResponse(this Tag tag) => new( + tag.ToResponse(), + [.. tag.Characters + .OrderBy(c => c.Name) + .Select(c => new TaggedCharacterResponse(c.Id, c.Name, c.Role.ToString()))], + [.. tag.Chapters + .OrderBy(c => c.Number) + .Select(c => new TaggedChapterResponse(c.Id, c.Number, c.Title, c.Summary))], + [.. tag.Beats + .OrderBy(b => b.Chapter?.Number ?? 0) + .ThenBy(b => b.SortOrder) + .Select(b => new TaggedBeatResponse( + b.Id, + b.ChapterId, + b.Chapter?.Number ?? 0, + b.Chapter?.Title ?? "(unknown chapter)", + b.SortOrder, + b.Title, + b.Character?.Name, + b.WhatHappened))]); /// <summary> /// Tags are matched case-insensitively but stored as first typed, so "Betrayal" and diff --git a/src/Novelly.Api/Tags/TagEndpoints.cs b/src/Novelly.Api/Tags/TagEndpoints.cs index be8f229..d703515 100644 --- a/src/Novelly.Api/Tags/TagEndpoints.cs +++ b/src/Novelly.Api/Tags/TagEndpoints.cs @@ -18,7 +18,7 @@ public static class TagEndpoints projectScoped.MapPost("/", async ( Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) => { - var created = await service.CreateAsync(projectId, request, ct); + var created = (await service.CreateAsync(projectId, request, ct)).ToResponse(); return Results.Created($"/api/tags/{created.Id}", created); }) .WithSummary("Create a tag. Tags are also created on demand when applied by name."); @@ -28,12 +28,12 @@ public static class TagEndpoints .AddEndpointFilter<ValidationEndpointFilter>(); tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) => - (await service.GetReferencesAsync(id, ct)).ToApiResult()) + (await service.GetReferencesAsync(id, ct))?.ToReferencesResponse().ToApiResult()) .WithSummary("Cross-reference: every character, chapter and beat carrying this tag."); tags.MapPatch("/{id:guid}", async ( Guid id, UpdateTagRequest request, TagService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct)).ToApiResult()) + (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) .WithSummary("Rename or recolour a tag."); tags.MapDelete("/{id:guid}", async (Guid id, TagService service, CancellationToken ct) => diff --git a/src/Novelly.Api/Tags/TagService.cs b/src/Novelly.Api/Tags/TagService.cs index ebe8ffe..ce32076 100644 --- a/src/Novelly.Api/Tags/TagService.cs +++ b/src/Novelly.Api/Tags/TagService.cs @@ -12,7 +12,7 @@ public class TagService( IModelValidator<CreateTagRequest> createValidator, IModelValidator<UpdateTagRequest> updateValidator) { - public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) + public async Task<IReadOnlyList<TagSummaryResponse>> ListAsync(Guid projectId, CancellationToken ct = default) { Guard.Default(projectId, nameof(projectId)); @@ -21,14 +21,14 @@ public class TagService( return await db.Tags .Where(t => t.ProjectId == projectId) .OrderBy(t => t.Name) - .Select(t => new TagSummaryDto( + .Select(t => new TagSummaryResponse( t.Id, t.Name, t.Color, t.Characters.Count, t.Chapters.Count, t.Beats.Count)) .ToListAsync(ct); } /// <summary>Everything in the project carrying this tag. Null when no tag has this id.</summary> - public async Task<TagReferencesDto?> GetReferencesAsync(Guid tagId, CancellationToken ct = default) + public async Task<Tag?> GetReferencesAsync(Guid tagId, CancellationToken ct = default) { Guard.Default(tagId, nameof(tagId)); @@ -42,34 +42,12 @@ public class TagService( .FirstOrDefaultAsync(t => t.Id == tagId, ct); if (tag is null) - { logger.LogInformation("Tag {TagId} not found", tagId); - return null; - } - return new TagReferencesDto( - tag.ToDto(), - [.. tag.Characters - .OrderBy(c => c.Name) - .Select(c => new TaggedCharacterDto(c.Id, c.Name, c.Role.ToString()))], - [.. tag.Chapters - .OrderBy(c => c.Number) - .Select(c => new TaggedChapterDto(c.Id, c.Number, c.Title, c.Summary))], - [.. tag.Beats - .OrderBy(b => b.Chapter?.Number ?? 0) - .ThenBy(b => b.SortOrder) - .Select(b => new TaggedBeatDto( - b.Id, - b.ChapterId, - b.Chapter?.Number ?? 0, - b.Chapter?.Title ?? "(unknown chapter)", - b.SortOrder, - b.Title, - b.Character?.Name, - b.WhatHappened))]); + return tag; } - public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default) + public async Task<Tag> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default) { Guard.Default(projectId, nameof(projectId)); Guard.Null(request, nameof(request)); @@ -95,10 +73,10 @@ public class TagService( var tag = new Tag { ProjectId = projectId, Name = name, Color = request.Color }; db.Tags.Add(tag); await db.SaveChangesAsync(ct); - return tag.ToDto(); + return tag; } - public async Task<TagDto?> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default) + public async Task<Tag?> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default) { Guard.Default(tagId, nameof(tagId)); Guard.Null(request, nameof(request)); @@ -129,7 +107,7 @@ public class TagService( tag.Color = Patch.Apply(tag.Color, request.Color); await db.SaveChangesAsync(ct); - return tag.ToDto(); + return tag; } /// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes. True if deleted.</summary> diff --git a/src/Novelly.Mcp/Tools/BeatTools.cs b/src/Novelly.Mcp/Tools/BeatTools.cs index 028eb52..9c41fdb 100644 --- a/src/Novelly.Mcp/Tools/BeatTools.cs +++ b/src/Novelly.Mcp/Tools/BeatTools.cs @@ -36,7 +36,9 @@ public static class BeatTools [McpServerTool(Name = "update_beat")] [Description("Revise a beat. Only the fields you supply change. Supplying a tag list replaces " - + "the beat's tags outright, so include the ones you want to keep.")] + + "the beat's tags outright, so include the ones you want to keep. A character or " + + "scene id already set stays put unless you pass clearCharacter/clearScene — " + + "leaving the id null means 'don't touch it', not 'remove it'.")] public static Task<CallToolResult> UpdateBeat( NovelApiClient api, [Description("The beat's id.")] Guid beatId, @@ -47,9 +49,11 @@ public static class BeatTools [Description("The event itself.")] string? whatHappened = null, [Description("What it sets in motion.")] string? whatsNext = null, [Description("Id of the scene this beat will be written into.")] Guid? sceneId = null, - [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) => + [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null, + [Description("Detach this beat's character, leaving it unassigned.")] bool clearCharacter = false, + [Description("Detach this beat's scene, leaving it ungrouped.")] bool clearScene = false) => api.PatchAsync($"/api/beats/{beatId}", - new { title, sortOrder, characterId, whatHappened, whatsNext, sceneId, tags }, ct); + new { title, sortOrder, characterId, whatHappened, whatsNext, sceneId, tags, clearCharacter, clearScene }, ct); [McpServerTool(Name = "delete_beat")] [Description("Remove a beat from a chapter's outline. Confirm with the writer first.")] diff --git a/src/Novelly.Mcp/Tools/ProjectTools.cs b/src/Novelly.Mcp/Tools/ProjectTools.cs index 3545612..5bf625c 100644 --- a/src/Novelly.Mcp/Tools/ProjectTools.cs +++ b/src/Novelly.Mcp/Tools/ProjectTools.cs @@ -31,8 +31,9 @@ public static class ProjectTools [Description("Genre or category.")] string? genre = null, [Description("One-sentence pitch.")] string? logline = null, [Description("Paragraph-length summary of the whole book.")] string? synopsis = null, + [Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null, [Description("Target manuscript length in words.")] int? targetWordCount = null) => - api.PostAsync("/api/projects", new { title, author, genre, logline, synopsis, targetWordCount }, ct); + api.PostAsync("/api/projects", new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct); [McpServerTool(Name = "update_project_brief")] [Description("Revise a project's top-level fields. Only the fields you supply change; " diff --git a/src/Novelly.ServiceDefaults/Extensions.cs b/src/Novelly.ServiceDefaults/Extensions.cs index 97642a1..18e7da3 100755 --- a/src/Novelly.ServiceDefaults/Extensions.cs +++ b/src/Novelly.ServiceDefaults/Extensions.cs @@ -44,7 +44,7 @@ public static class Extensions return builder; } - public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder + private static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.Logging.AddOpenTelemetry(logging => { @@ -97,12 +97,9 @@ public static class Extensions return builder; } - public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder + private static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - + builder.Services.AddHealthChecks().AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); return builder; } @@ -110,17 +107,16 @@ public static class Extensions { // Adding health checks endpoints to applications in non-development environments has security implications. // See https://aka.ms/aspire/healthchecks for details before enabling these endpoints in non-development environments. - if (app.Environment.IsDevelopment()) - { - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks(HealthEndpointPath); + if (!app.Environment.IsDevelopment()) return app; - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); return app; } diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index d88ae9e..ac529f9 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -10,6 +10,9 @@ import type { Conversation, ConversationSummary, Beat, + ImportInspection, + ImportJob, + ImportJobStatus, OpenQuestion, Project, ProjectSummary, @@ -30,6 +33,7 @@ export const keys = { chapter: (id: string) => ['chapters', id] as const, conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const, conversation: (id: string) => ['conversations', id] as const, + importJob: (id: string) => ['imports', id] as const, } // --- Projects --------------------------------------------------------------- @@ -412,3 +416,35 @@ export function useSendAgentMessage(projectId: string) { }, }) } + +// --- Outline import ---------------------------------------------------------- + +export function useInspectImport() { + return useMutation({ + mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }), + }) +} + +export function useStartImport() { + return useMutation({ + mutationFn: (body: { sourceRoot: string; forceRestart?: boolean }) => + api.post<ImportJob>('/api/imports', body), + }) +} + +const terminalImportStatuses: ImportJobStatus[] = ['Completed', 'Failed', 'Paused'] + +/** + * Polls a running import job. This is the app's first polling hook — there's no + * SSE/websocket infrastructure to reuse — so it stops on its own once the job reaches a + * terminal status rather than depending on the caller to unmount it in time. + */ +export function useImportJob(jobId: string | undefined) { + return useQuery({ + queryKey: keys.importJob(jobId ?? ''), + queryFn: () => api.get<ImportJob>(`/api/imports/${jobId}`), + enabled: Boolean(jobId), + refetchInterval: (query) => + query.state.data && terminalImportStatuses.includes(query.state.data.status) ? false : 1500, + }) +} diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index f1d45bf..8f8885e 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -251,3 +251,30 @@ export interface AgentTurn { conversationId: string message: AgentMessage } + +// --- Outline import ----------------------------------------------------------- + +export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | 'Paused' + +export interface ImportJob { + id: string + sourceRoot: string + projectId: string | null + status: ImportJobStatus + statusMessage: string | null + chaptersCompleted: number + chaptersTotal: number + createdAt: string + updatedAt: string +} + +/** Whether a source folder is ready for a fresh import, has one to resume, or is already done. */ +export type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete' + +export interface ImportInspection { + readiness: ImportReadiness + projectId: string | null + chaptersCompleted: number + chaptersTotal: number + completedPasses: string[] +} diff --git a/src/Novelly.Web/src/components/ImportDialog.tsx b/src/Novelly.Web/src/components/ImportDialog.tsx new file mode 100644 index 0000000..6cff577 --- /dev/null +++ b/src/Novelly.Web/src/components/ImportDialog.tsx @@ -0,0 +1,278 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useImportJob, useInspectImport, useStartImport } from '../api/hooks' +import type { ImportInspection, ImportJob } from '../api/types' +import { ErrorNote, Modal, Spinner } from './ui' + +/** + * Kicks off (or resumes) an outline import against an absolute folder path. The app runs + * locally with the API and browser on the same machine, so a pasted path is meaningful — + * there's no browser folder picker that can hand back one instead. + * + * State machine: type a path → Check (inspects the folder without starting anything) → + * Start/Resume/Delete-and-reimport → poll until the background job finishes. + */ +export function ImportDialog({ + onClose, + onImported, +}: { + onClose: () => void + onImported?: (projectId: string) => void +}) { + const [sourceRoot, setSourceRoot] = useState('') + const [inspection, setInspection] = useState<ImportInspection | null>(null) + const [jobId, setJobId] = useState<string>() + const [confirmingRestart, setConfirmingRestart] = useState(false) + + const inspect = useInspectImport() + const start = useStartImport() + const job = useImportJob(jobId) + const qc = useQueryClient() + + useEffect(() => { + if (job.data?.status !== 'Completed') return + // The import writes project data through the same services the UI uses to edit it — + // everything on screen may be stale once it finishes. + qc.invalidateQueries() + if (job.data.projectId) onImported?.(job.data.projectId) + }, [job.data?.status, job.data?.projectId, qc, onImported]) + + const check = (e: FormEvent) => { + e.preventDefault() + if (!sourceRoot.trim()) return + inspect.mutate(sourceRoot.trim(), { onSuccess: setInspection }) + } + + const beginImport = (forceRestart = false) => { + start.mutate({ sourceRoot: sourceRoot.trim(), forceRestart }, { onSuccess: (created) => setJobId(created.id) }) + } + + const changeFolder = () => { + setInspection(null) + setJobId(undefined) + setConfirmingRestart(false) + } + + if (jobId && job.data) { + return ( + <Modal title="Import outline" onClose={onClose}> + <ImportProgress + job={job.data} + onRetry={() => beginImport(false)} + onDone={onClose} + /> + </Modal> + ) + } + + return ( + <Modal title="Import outline" onClose={onClose}> + <form onSubmit={check} className="grid gap-3"> + <label className="block"> + <span className="label">Source folder</span> + <input + className="input" + autoFocus + value={sourceRoot} + onChange={(e) => { + setSourceRoot(e.target.value) + setInspection(null) + }} + placeholder="/home/you/Documents/Novels/my-outline" + disabled={inspect.isPending || start.isPending} + /> + </label> + <p className="text-sm muted"> + Absolute path to the folder holding outline.md, its chapter files and character + dossiers. + </p> + + {inspect.error && <ErrorNote error={inspect.error} />} + {start.error && <ErrorNote error={start.error} />} + + {inspection && ( + <ImportReadinessSummary + inspection={inspection} + confirmingRestart={confirmingRestart} + onStart={() => beginImport(false)} + onConfirmRestart={() => setConfirmingRestart(true)} + onCancelRestart={() => setConfirmingRestart(false)} + onRestart={() => beginImport(true)} + starting={start.isPending} + /> + )} + + <div className="mt-1 flex justify-end gap-2"> + <button type="button" className="btn" onClick={onClose}> + Cancel + </button> + {!inspection && ( + <button + type="submit" + className="btn btn-primary" + disabled={!sourceRoot.trim() || inspect.isPending} + > + {inspect.isPending ? 'Checking…' : 'Check'} + </button> + )} + {inspection && ( + <button type="button" className="btn" onClick={changeFolder}> + Change folder + </button> + )} + </div> + </form> + </Modal> + ) +} + +function ImportReadinessSummary({ + inspection, + confirmingRestart, + onStart, + onConfirmRestart, + onCancelRestart, + onRestart, + starting, +}: { + inspection: ImportInspection + confirmingRestart: boolean + onStart: () => void + onConfirmRestart: () => void + onCancelRestart: () => void + onRestart: () => void + starting: boolean +}) { + if (inspection.readiness === 'Fresh') { + return ( + <div className="rounded-md px-3 py-2 text-sm" style={{ background: 'var(--surface-sunken)' }}> + <p>No previous import found here — this will start fresh.</p> + <button type="button" className="btn btn-primary mt-2" onClick={onStart} disabled={starting}> + {starting ? 'Starting…' : 'Start import'} + </button> + </div> + ) + } + + if (inspection.readiness === 'Resumable') { + return ( + <div className="rounded-md px-3 py-2 text-sm" style={{ background: 'var(--surface-sunken)' }}> + <p> + A previous import is partway through: {inspection.chaptersCompleted} of{' '} + {inspection.chaptersTotal || '?'} chapters done. + </p> + <button type="button" className="btn btn-primary mt-2" onClick={onStart} disabled={starting}> + {starting ? 'Resuming…' : 'Resume import'} + </button> + </div> + ) + } + + return ( + <div className="rounded-md px-3 py-2 text-sm" style={{ background: 'var(--surface-sunken)' }}> + <p>This folder has already been fully imported.</p> + {!confirmingRestart ? ( + <button + type="button" + className="btn mt-2" + style={{ color: 'var(--accent)' }} + onClick={onConfirmRestart} + > + Delete & reimport + </button> + ) : ( + <div className="mt-2"> + <p className="mb-2" style={{ color: 'var(--accent)' }}> + This permanently deletes the project this import created — its chapters, + characters, everything — then starts over. This cannot be undone. + </p> + <div className="flex gap-2"> + <button type="button" className="btn" onClick={onCancelRestart}> + Cancel + </button> + <button + type="button" + className="btn" + style={{ color: 'var(--accent)' }} + onClick={onRestart} + disabled={starting} + > + {starting ? 'Deleting…' : 'Delete and start over'} + </button> + </div> + </div> + )} + </div> + ) +} + +function ImportProgress({ + job, + onRetry, + onDone, +}: { + job: ImportJob + onRetry: () => void + onDone: () => void +}) { + if (job.status === 'Pending' || job.status === 'Running') { + return ( + <div> + <Spinner label={job.status === 'Pending' ? 'Queued' : 'Importing'} /> + {job.chaptersTotal > 0 && ( + <p className="text-sm muted"> + {job.chaptersCompleted} of {job.chaptersTotal} chapters + </p> + )} + </div> + ) + } + + if (job.status === 'Completed') { + return ( + <div className="grid gap-3"> + <p> + Import complete — {job.chaptersCompleted} chapter{job.chaptersCompleted === 1 ? '' : 's'} imported. + </p> + <div className="flex justify-end"> + <button type="button" className="btn btn-primary" onClick={onDone}> + Done + </button> + </div> + </div> + ) + } + + if (job.status === 'Paused') { + return ( + <div className="grid gap-3"> + <p className="text-sm muted">{job.statusMessage ?? 'Paused — more work remains.'}</p> + <p className="text-sm muted"> + {job.chaptersCompleted} of {job.chaptersTotal || '?'} chapters so far. + </p> + <div className="flex justify-end gap-2"> + <button type="button" className="btn" onClick={onDone}> + Close + </button> + <button type="button" className="btn btn-primary" onClick={onRetry}> + Continue import + </button> + </div> + </div> + ) + } + + return ( + <div className="grid gap-3"> + <ErrorNote error={job.statusMessage ?? 'The import failed.'} /> + <div className="flex justify-end gap-2"> + <button type="button" className="btn" onClick={onDone}> + Close + </button> + <button type="button" className="btn btn-primary" onClick={onRetry}> + Try again + </button> + </div> + </div> + ) +} diff --git a/src/Novelly.Web/src/pages/OverviewPage.tsx b/src/Novelly.Web/src/pages/OverviewPage.tsx index a96d09c..a55c94d 100644 --- a/src/Novelly.Web/src/pages/OverviewPage.tsx +++ b/src/Novelly.Web/src/pages/OverviewPage.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useChapters, useCharacters, useDeleteProject, useProject, useUpdateProject } from '../api/hooks' +import { ImportDialog } from '../components/ImportDialog' import { AutoField, ErrorNote, Spinner } from '../components/ui' export default function OverviewPage() { @@ -10,6 +12,7 @@ export default function OverviewPage() { const { data: chapters } = useChapters(projectId) const update = useUpdateProject(projectId) const remove = useDeleteProject() + const [importing, setImporting] = useState(false) if (isPending || !project) return <Spinner label="Loading brief" /> @@ -114,6 +117,18 @@ export default function OverviewPage() { </dl> </div> + <div className="card p-5"> + <h2 className="mb-3 text-sm font-semibold tracking-wide uppercase muted">Import outline</h2> + <p className="mb-3 text-sm muted"> + Start a new novel from an author's existing outline folder — chapters, beats and + character dossiers. This doesn't touch the novel you're viewing; it creates + another one. + </p> + <button className="btn w-full" onClick={() => setImporting(true)}> + Import a new novel from outline + </button> + </div> + <div className="card p-5"> <h2 className="mb-2 text-sm font-semibold tracking-wide uppercase muted">Danger zone</h2> <p className="mb-3 text-sm muted"> @@ -132,6 +147,13 @@ export default function OverviewPage() { </button> </div> </aside> + + {importing && ( + <ImportDialog + onClose={() => setImporting(false)} + onImported={(newProjectId) => navigate(`/projects/${newProjectId}`)} + /> + )} </div> ) } diff --git a/src/Novelly.Web/src/pages/ProjectsPage.tsx b/src/Novelly.Web/src/pages/ProjectsPage.tsx index 2badf00..b6707c1 100644 --- a/src/Novelly.Web/src/pages/ProjectsPage.tsx +++ b/src/Novelly.Web/src/pages/ProjectsPage.tsx @@ -1,11 +1,14 @@ import { useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { useCreateProject, useProjects } from '../api/hooks' +import { ImportDialog } from '../components/ImportDialog' import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui' export default function ProjectsPage() { const { data: projects, isPending, error } = useProjects() const [creating, setCreating] = useState(false) + const [importing, setImporting] = useState(false) + const navigate = useNavigate() return ( <div className="mx-auto max-w-4xl px-6 py-12"> @@ -16,9 +19,14 @@ export default function ProjectsPage() { Outlines, character dossiers, and a writing partner that knows the book. </p> </div> - <button className="btn btn-primary" onClick={() => setCreating(true)}> - New novel - </button> + <div className="flex gap-2"> + <button className="btn" onClick={() => setImporting(true)}> + Import from outline + </button> + <button className="btn btn-primary" onClick={() => setCreating(true)}> + New novel + </button> + </div> </header> {error && <ErrorNote error={error} />} @@ -61,6 +69,12 @@ export default function ProjectsPage() { </div> {creating && <CreateProjectModal onClose={() => setCreating(false)} />} + {importing && ( + <ImportDialog + onClose={() => setImporting(false)} + onImported={(projectId) => navigate(`/projects/${projectId}`)} + /> + )} </div> ) } diff --git a/tests/Novelly.Api.Tests/BeatServiceTests.cs b/tests/Novelly.Api.Tests/BeatServiceTests.cs index 64f5824..134604f 100644 --- a/tests/Novelly.Api.Tests/BeatServiceTests.cs +++ b/tests/Novelly.Api.Tests/BeatServiceTests.cs @@ -91,8 +91,8 @@ public class BeatServiceTests : ServiceTestFixture Assert.Multiple(() => { - Assert.That(beat.CharacterName, Is.EqualTo("Ines")); - Assert.That(beat.SceneTitle, Is.EqualTo("The dock at dawn")); + Assert.That(beat.Character!.Name, Is.EqualTo("Ines")); + Assert.That(beat.Scene!.Title, Is.EqualTo("The dock at dawn")); Assert.That(beat.WhatHappened, Does.Contain("faster than she expected")); }); } @@ -164,4 +164,25 @@ public class BeatServiceTests : ServiceTestFixture Assert.That(cleared.WhatHappened, Is.EqualTo("Behind the lining of the case.")); }); } + + [Test] + public async Task ClearCharacter_and_ClearScene_detach_the_reference_since_a_null_id_means_leave_it_alone() + { + var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn")); + var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest( + "She burns the atlas", CharacterId: ines.Id, SceneId: scene.Id)); + + var untouched = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She burns it")))!; + Assert.That(untouched.Character!.Name, Is.EqualTo("Ines")); + + var cleared = (await Beats.UpdateAsync( + beat.Id, new UpdateBeatRequest(ClearCharacter: true, ClearScene: true)))!; + + Assert.Multiple(() => + { + Assert.That(cleared.Character, Is.Null); + Assert.That(cleared.Scene, Is.Null); + }); + } } diff --git a/tests/Novelly.Api.Tests/CharacterArcTests.cs b/tests/Novelly.Api.Tests/CharacterArcTests.cs index 3f636d7..8f9b816 100644 --- a/tests/Novelly.Api.Tests/CharacterArcTests.cs +++ b/tests/Novelly.Api.Tests/CharacterArcTests.cs @@ -136,8 +136,8 @@ public class CharacterArcTests : ServiceTestFixture Assert.Multiple(() => { - Assert.That(stage.ChapterTitle, Is.EqualTo("Landfall")); - Assert.That(stage.ChapterNumber, Is.EqualTo(1)); + Assert.That(stage.Chapter!.Title, Is.EqualTo("Landfall")); + Assert.That(stage.Chapter!.Number, Is.EqualTo(1)); }); } @@ -206,8 +206,8 @@ public class CharacterArcTests : ServiceTestFixture Assert.Multiple(() => { Assert.That(beats.Select(b => b.Title), Is.EqualTo(new[] { "She finds the map", "She boards anyway" })); - Assert.That(beats[0].ChapterNumber, Is.EqualTo(1)); - Assert.That(beats[0].ChapterTitle, Is.EqualTo("First")); + Assert.That(beats[0].Chapter!.Number, Is.EqualTo(1)); + Assert.That(beats[0].Chapter!.Title, Is.EqualTo("First")); Assert.That(beats[1].ChapterId, Is.EqualTo(second.Id)); }); } diff --git a/tests/Novelly.Api.Tests/ImportAgentToolsetTests.cs b/tests/Novelly.Api.Tests/ImportAgentToolsetTests.cs new file mode 100644 index 0000000..fb74bf9 --- /dev/null +++ b/tests/Novelly.Api.Tests/ImportAgentToolsetTests.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using Novelly.Api.Imports; + +namespace Novelly.Api.Tests; + +[TestFixture] +public class ImportAgentToolsetTests : ServiceTestFixture +{ + private string _root = null!; + private ImportAgentToolset _toolset = null!; + + protected override void OnSetUp() + { + _root = Directory.CreateTempSubdirectory("novelly-import-toolset-test-").FullName; + _toolset = new ImportAgentToolset( + Projects, Characters, Arcs, Chapters, Beats, new CapturingLogger<ImportAgentToolset>()); + _toolset.Initialize(_root, existingProjectId: null); + } + + [TearDown] + public void CleanUpTempFolder() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Test] + public async Task Read_source_file_refuses_a_path_that_escapes_the_source_root() + { + var outsideFile = Path.Combine(Path.GetTempPath(), "novelly-outside-root.md"); + await File.WriteAllTextAsync(outsideFile, "secret"); + try + { + var result = await _toolset.ExecuteAsync("read_source_file", Input(new { path = "../novelly-outside-root.md" })); + + Assert.Multiple(() => + { + Assert.That(result.IsError, Is.True); + Assert.That(result.Content, Does.Contain("escapes")); + }); + } + finally + { + File.Delete(outsideFile); + } + } + + [Test] + public async Task Read_source_file_reads_a_file_inside_the_source_root() + { + await File.WriteAllTextAsync(Path.Combine(_root, "outline.md"), "# The Blade Itself"); + + var result = await _toolset.ExecuteAsync("read_source_file", Input(new { path = "outline.md" })); + + Assert.Multiple(() => + { + Assert.That(result.IsError, Is.False); + Assert.That(result.Content, Does.Contain("The Blade Itself")); + }); + } + + [Test] + public async Task Write_ledger_can_only_ever_touch_the_ledger_file_no_matter_what_path_is_asked_for() + { + // The tool takes no path argument at all — this is the enforcement, not a check + // against a supplied path. Confirm the write always lands at exactly the ledger name. + await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["project"]}""" })); + + Assert.Multiple(() => + { + Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.True); + Assert.That(Directory.GetFiles(_root), Has.Length.EqualTo(1)); + }); + } + + [Test] + public async Task Write_ledger_rejects_malformed_json_without_touching_the_file() + { + var result = await _toolset.ExecuteAsync("write_ledger", Input(new { json = "{not valid json" })); + + Assert.Multiple(() => + { + Assert.That(result.IsError, Is.True); + Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.False); + }); + } + + [Test] + public async Task Create_project_binds_the_toolsets_project_id_for_later_calls() + { + await _toolset.ExecuteAsync("create_project", Input(new { title = "The Blade Itself", author = "Joe Abercrombie" })); + + Assert.That(_toolset.ProjectId, Is.Not.Null); + + var project = await Projects.GetAsync(_toolset.ProjectId!.Value); + Assert.That(project!.Title, Is.EqualTo("The Blade Itself")); + } + + [Test] + public async Task Domain_tools_refuse_to_run_before_a_project_exists() + { + var result = await _toolset.ExecuteAsync("create_character", Input(new { name = "Logen" })); + + Assert.Multiple(() => + { + Assert.That(result.IsError, Is.True); + Assert.That(result.Content, Does.Contain("create_project first")); + }); + } + + [Test] + public void Every_tool_declares_an_object_schema_and_a_description() + { + Assert.That(_toolset.Definitions, Is.Not.Empty); + + Assert.Multiple(() => + { + foreach (var tool in _toolset.Definitions) + { + Assert.That(string.IsNullOrWhiteSpace(tool.Description), Is.False, tool.Name); + Assert.That(tool.InputSchema.GetProperty("type").GetString(), Is.EqualTo("object"), tool.Name); + } + + Assert.That(_toolset.Definitions.Select(t => t.Name), Is.Unique); + }); + } + + private static JsonElement Input(object value) => JsonSerializer.SerializeToElement(value); +} diff --git a/tests/Novelly.Api.Tests/ImportServiceTests.cs b/tests/Novelly.Api.Tests/ImportServiceTests.cs new file mode 100644 index 0000000..1e0934d --- /dev/null +++ b/tests/Novelly.Api.Tests/ImportServiceTests.cs @@ -0,0 +1,140 @@ +using System.Threading.Channels; +using Novelly.Api.Imports; +using Novelly.Api.Projects; + +namespace Novelly.Api.Tests; + +[TestFixture] +public class ImportServiceTests : ServiceTestFixture +{ + private string _root = null!; + private Channel<Guid> _queue = null!; + private ImportService _imports = null!; + + protected override void OnSetUp() + { + _root = Directory.CreateTempSubdirectory("novelly-import-test-").FullName; + _queue = Channel.CreateUnbounded<Guid>(); + _imports = new ImportService( + Db.Context, + Projects, + _queue, + new CapturingLogger<ImportService>(), + new InspectImportRequestValidator(), + new StartImportRequestValidator()); + } + + [TearDown] + public void CleanUpTempFolder() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Test] + public async Task Inspecting_a_folder_with_no_ledger_reports_fresh() + { + WriteChapterFiles(3); + + var inspection = await _imports.InspectAsync(new InspectImportRequest(_root)); + + Assert.Multiple(() => + { + Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Fresh)); + Assert.That(inspection.ProjectId, Is.Null); + Assert.That(inspection.ChaptersTotal, Is.EqualTo(3)); + Assert.That(inspection.ChaptersCompleted, Is.EqualTo(0)); + }); + } + + [Test] + public async Task Inspecting_a_folder_with_an_incomplete_ledger_reports_resumable() + { + WriteChapterFiles(3); + WriteLedger("""{"projectId": "11111111-1111-1111-1111-111111111111", "completedPasses": ["project"], "completedChapters": [1]}"""); + + var inspection = await _imports.InspectAsync(new InspectImportRequest(_root)); + + Assert.Multiple(() => + { + Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Resumable)); + Assert.That(inspection.ChaptersCompleted, Is.EqualTo(1)); + Assert.That(inspection.ChaptersTotal, Is.EqualTo(3)); + }); + } + + [Test] + public async Task Inspecting_a_folder_whose_ledger_covers_every_pass_and_chapter_reports_complete() + { + WriteChapterFiles(2); + WriteLedger(""" + { + "projectId": "11111111-1111-1111-1111-111111111111", + "completedPasses": ["project", "characters", "chapters", "arcs"], + "completedChapters": [1, 2] + } + """); + + var inspection = await _imports.InspectAsync(new InspectImportRequest(_root)); + + Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Complete)); + } + + [Test] + public async Task Starting_an_import_enqueues_a_pending_job() + { + var job = await _imports.StartOrResumeAsync(new StartImportRequest(_root)); + + Assert.Multiple(() => + { + Assert.That(job.Status, Is.EqualTo(ImportJobStatus.Pending)); + Assert.That(job.SourceRoot, Is.EqualTo(_root)); + }); + + Assert.That(_queue.Reader.TryRead(out var queued), Is.True); + Assert.That(queued, Is.EqualTo(job.Id)); + } + + [Test] + public async Task Starting_an_import_a_second_time_reuses_the_pending_job_instead_of_duplicating_it() + { + var first = await _imports.StartOrResumeAsync(new StartImportRequest(_root)); + var second = await _imports.StartOrResumeAsync(new StartImportRequest(_root)); + + Assert.That(second.Id, Is.EqualTo(first.Id)); + + // Only one job was ever queued. + Assert.That(_queue.Reader.TryRead(out _), Is.True); + Assert.That(_queue.Reader.TryRead(out _), Is.False); + } + + [Test] + public async Task Force_restarting_a_completed_import_deletes_the_ledger_and_its_project() + { + var project = await Projects.CreateAsync(new CreateProjectRequest("The Blade Itself")); + WriteLedger($$"""{"projectId": "{{project.Id}}", "completedPasses": ["project", "characters", "chapters", "arcs"], "completedChapters": [1]}"""); + + await _imports.StartOrResumeAsync(new StartImportRequest(_root, ForceRestart: true)); + + Assert.Multiple(() => + { + Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.False); + Assert.That(Projects.GetAsync(project.Id).Result, Is.Null); + }); + } + + private void WriteChapterFiles(int count) + { + var outlines = Path.Combine(_root, "outlines"); + Directory.CreateDirectory(outlines); + for (var i = 1; i <= count; i++) + { + File.WriteAllText(Path.Combine(outlines, $"{i:D2}-chapter.md"), $"# Chapter {i}"); + } + } + + private void WriteLedger(string json) => + File.WriteAllText(Path.Combine(_root, ".novelly-import.json"), json); +} diff --git a/tests/Novelly.Api.Tests/ListingTests.cs b/tests/Novelly.Api.Tests/ListingTests.cs index 6f781f5..f162f6c 100644 --- a/tests/Novelly.Api.Tests/ListingTests.cs +++ b/tests/Novelly.Api.Tests/ListingTests.cs @@ -84,9 +84,9 @@ public class ListingTests : ServiceTestFixture Assert.Multiple(() => { Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" })); - Assert.That(listed.Single(c => c.Id == second.Id).SceneCount, Is.EqualTo(2)); - Assert.That(listed.Single(c => c.Id == second.Id).WordCount, Is.EqualTo(3)); - Assert.That(listed.Single(c => c.Id == first.Id).WordCount, Is.EqualTo(0)); + Assert.That(listed.Single(c => c.Id == second.Id).Scenes, Has.Count.EqualTo(2)); + Assert.That(listed.Single(c => c.Id == second.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(3)); + Assert.That(listed.Single(c => c.Id == first.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(0)); }); } diff --git a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs index 872827f..cdcea6e 100644 --- a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs +++ b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs @@ -31,7 +31,7 @@ public class NovelAgentServiceTests : ServiceTestFixture var turn = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Where do I start?")); - Assert.That(turn.Message.Content, Is.EqualTo("Tell me about the ending.")); + Assert.That(turn.Content, Is.EqualTo("Tell me about the ending.")); var conversation = (await agent.GetConversationAsync(turn.ConversationId))!; @@ -62,9 +62,9 @@ public class NovelAgentServiceTests : ServiceTestFixture { Assert.That(characters, Has.Count.EqualTo(1)); Assert.That(characters[0].Name, Is.EqualTo("Ines")); - Assert.That(turn.Message.Content, Is.EqualTo("Added Ines as the protagonist.")); - Assert.That(turn.Message.ToolCalls, Has.Count.EqualTo(1)); - Assert.That(turn.Message.ToolCalls[0].Name, Is.EqualTo("create_character")); + Assert.That(turn.Content, Is.EqualTo("Added Ines as the protagonist.")); + Assert.That(turn.ToResponse().ToolCalls, Has.Count.EqualTo(1)); + Assert.That(turn.ToResponse().ToolCalls[0].Name, Is.EqualTo("create_character")); }); } @@ -113,7 +113,7 @@ public class NovelAgentServiceTests : ServiceTestFixture { Assert.That(errorResult.IsError, Is.True); Assert.That(errorResult.Content, Does.Contain("was not found")); - Assert.That(turn.Message.Content, Does.Contain("does not exist yet")); + Assert.That(turn.Content, Does.Contain("does not exist yet")); }); } @@ -154,7 +154,7 @@ public class NovelAgentServiceTests : ServiceTestFixture Assert.Multiple(() => { Assert.That(model.Transcripts, Has.Count.EqualTo(4)); - Assert.That(turn.Message.Content, Does.Contain("tool-call limit")); + Assert.That(turn.Content, Does.Contain("tool-call limit")); }); } diff --git a/tests/Novelly.Api.Tests/OpenQuestionTests.cs b/tests/Novelly.Api.Tests/OpenQuestionTests.cs index 9c1d663..43620f3 100644 --- a/tests/Novelly.Api.Tests/OpenQuestionTests.cs +++ b/tests/Novelly.Api.Tests/OpenQuestionTests.cs @@ -30,9 +30,9 @@ public class OpenQuestionTests : ServiceTestFixture Assert.Multiple(() => { - Assert.That(question.ChapterTitle, Is.EqualTo("Landfall")); - Assert.That(question.ChapterNumber, Is.EqualTo(1)); - Assert.That(question.CharacterName, Is.EqualTo("Ines")); + Assert.That(question.Chapter!.Title, Is.EqualTo("Landfall")); + Assert.That(question.Chapter!.Number, Is.EqualTo(1)); + Assert.That(question.Character!.Name, Is.EqualTo("Ines")); Assert.That(question.IsResolved, Is.False); }); } diff --git a/tests/Novelly.Api.Tests/ProjectDataTests.cs b/tests/Novelly.Api.Tests/ProjectDataTests.cs index 9310822..7eebe5f 100644 --- a/tests/Novelly.Api.Tests/ProjectDataTests.cs +++ b/tests/Novelly.Api.Tests/ProjectDataTests.cs @@ -144,7 +144,7 @@ public class ProjectDataTests : ServiceTestFixture Assert.Multiple(() => { Assert.That(updated.Relationships, Has.Count.EqualTo(1)); - Assert.That(updated.Relationships[0].RelatedCharacterName, Is.EqualTo("Mara")); + Assert.That(updated.Relationships[0].RelatedCharacter!.Name, Is.EqualTo("Mara")); }); } diff --git a/tests/Novelly.Api.Tests/TagServiceTests.cs b/tests/Novelly.Api.Tests/TagServiceTests.cs index 9b85d2f..6b54c70 100644 --- a/tests/Novelly.Api.Tests/TagServiceTests.cs +++ b/tests/Novelly.Api.Tests/TagServiceTests.cs @@ -98,8 +98,8 @@ public class TagServiceTests : ServiceTestFixture Assert.That(references.Chapters[0].Title, Is.EqualTo("Landfall")); Assert.That(references.Beats, Has.Count.EqualTo(1)); Assert.That(references.Beats[0].Title, Is.EqualTo("She burns the atlas")); - Assert.That(references.Beats[0].ChapterTitle, Is.EqualTo("Landfall")); - Assert.That(references.Beats[0].ChapterNumber, Is.EqualTo(1)); + Assert.That(references.Beats[0].Chapter!.Title, Is.EqualTo("Landfall")); + Assert.That(references.Beats[0].Chapter!.Number, Is.EqualTo(1)); }); }