Add outline import feature; drop Dto naming, map entities at the API boundary
Services now return entities; endpoints (and the agent toolsets) map to *Response records instead of services building wire DTOs themselves. Also brings in the outline-import agent, MCP tool, ledger and web dialog that were already in progress on disk.
This commit is contained in:
@@ -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:
|
||||
|
||||
```
|
||||
<root>/
|
||||
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/<slug>.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
|
||||
|
||||
<one or more prose paragraphs — the chapter summary>
|
||||
|
||||
| Beat | Character | What | Why |
|
||||
|---|---|---|---|
|
||||
| Cold open, mid-flight | Logen | <what happens> | <why it matters> |
|
||||
...
|
||||
|
||||
## Notes
|
||||
- <freeform notes, may be absent>
|
||||
```
|
||||
|
||||
`**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)* <what happens, and why it matters>
|
||||
- *(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 `<root>/.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 — <Title> (<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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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));
|
||||
|
||||
+12
-12
@@ -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())]);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
+11
-11
@@ -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,
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
""";
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
-2
@@ -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,
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 tag;
|
||||
}
|
||||
|
||||
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))]);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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.")]
|
||||
|
||||
@@ -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; "
|
||||
|
||||
@@ -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,8 +107,8 @@ 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())
|
||||
{
|
||||
if (!app.Environment.IsDevelopment()) return app;
|
||||
|
||||
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||
app.MapHealthChecks(HealthEndpointPath);
|
||||
|
||||
@@ -120,7 +117,6 @@ public static class Extensions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user