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:
James Wampler
2026-08-06 18:36:40 -07:00
parent 40f93e40a8
commit 189ebf3237
66 changed files with 3310 additions and 364 deletions
+205
View File
@@ -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.
+9
View File
@@ -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.
+15
View File
@@ -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;
}
-46
View File
@@ -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);
+5 -2
View File
@@ -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);
}
+8 -28
View File
@@ -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)
{
+44 -35
View File
@@ -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);
}
+6 -6
View File
@@ -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) =>
+12 -25
View File
@@ -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));
@@ -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())]);
}
+4 -4
View File
@@ -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) =>
+8 -10
View File
@@ -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));
@@ -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) =>
+9 -10
View File
@@ -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);
}
+1 -2
View File
@@ -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;
+2
View File
@@ -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")
+12
View File
@@ -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;
}
}
+41
View File
@@ -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);
}
}
+136
View File
@@ -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>&lt;sourceRoot&gt;/.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);
}
}
+110
View File
@@ -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;
}
}
+3 -1
View File
@@ -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();
@@ -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);
}
+3 -3
View File
@@ -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) =>
+8 -8
View File
@@ -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,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,
+4 -4
View File
@@ -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) =>
+8 -10
View File
@@ -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
+3 -3
View File
@@ -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) =>
+8 -30
View File
@@ -12,7 +12,7 @@ public class TagService(
IModelValidator<CreateTagRequest> createValidator,
IModelValidator<UpdateTagRequest> updateValidator)
{
public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
public async Task<IReadOnlyList<TagSummaryResponse>> ListAsync(Guid projectId, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
@@ -21,14 +21,14 @@ public class TagService(
return await db.Tags
.Where(t => t.ProjectId == projectId)
.OrderBy(t => t.Name)
.Select(t => new TagSummaryDto(
.Select(t => new TagSummaryResponse(
t.Id, t.Name, t.Color,
t.Characters.Count, t.Chapters.Count, t.Beats.Count))
.ToListAsync(ct);
}
/// <summary>Everything in the project carrying this tag. Null when no tag has this id.</summary>
public async Task<TagReferencesDto?> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
public async Task<Tag?> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
{
Guard.Default(tagId, nameof(tagId));
@@ -42,34 +42,12 @@ public class TagService(
.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
{
logger.LogInformation("Tag {TagId} not found", tagId);
return null;
}
return new TagReferencesDto(
tag.ToDto(),
[.. tag.Characters
.OrderBy(c => c.Name)
.Select(c => new TaggedCharacterDto(c.Id, c.Name, c.Role.ToString()))],
[.. tag.Chapters
.OrderBy(c => c.Number)
.Select(c => new TaggedChapterDto(c.Id, c.Number, c.Title, c.Summary))],
[.. tag.Beats
.OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder)
.Select(b => new TaggedBeatDto(
b.Id,
b.ChapterId,
b.Chapter?.Number ?? 0,
b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder,
b.Title,
b.Character?.Name,
b.WhatHappened))]);
return tag;
}
public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
public async Task<Tag> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
@@ -95,10 +73,10 @@ public class TagService(
var tag = new Tag { ProjectId = projectId, Name = name, Color = request.Color };
db.Tags.Add(tag);
await db.SaveChangesAsync(ct);
return tag.ToDto();
return tag;
}
public async Task<TagDto?> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
public async Task<Tag?> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
{
Guard.Default(tagId, nameof(tagId));
Guard.Null(request, nameof(request));
@@ -129,7 +107,7 @@ public class TagService(
tag.Color = Patch.Apply(tag.Color, request.Color);
await db.SaveChangesAsync(ct);
return tag.ToDto();
return tag;
}
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes. True if deleted.</summary>
+7 -3
View File
@@ -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.")]
+2 -1
View File
@@ -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; "
+12 -16
View File
@@ -44,7 +44,7 @@ public static class Extensions
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
private static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
@@ -97,12 +97,9 @@ public static class Extensions
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
private static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
builder.Services.AddHealthChecks().AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
@@ -110,17 +107,16 @@ public static class Extensions
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);
if (!app.Environment.IsDevelopment()) return app;
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
return app;
}
+36
View File
@@ -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,
})
}
+27
View File
@@ -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 &amp; 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>
)
}
+18 -4
View File
@@ -1,11 +1,14 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { useCreateProject, useProjects } from '../api/hooks'
import { ImportDialog } from '../components/ImportDialog'
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
export default function ProjectsPage() {
const { data: projects, isPending, error } = useProjects()
const [creating, setCreating] = useState(false)
const [importing, setImporting] = useState(false)
const navigate = useNavigate()
return (
<div className="mx-auto max-w-4xl px-6 py-12">
@@ -16,9 +19,14 @@ export default function ProjectsPage() {
Outlines, character dossiers, and a writing partner that knows the book.
</p>
</div>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
<div className="flex gap-2">
<button className="btn" onClick={() => setImporting(true)}>
Import from outline
</button>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
</div>
</header>
{error && <ErrorNote error={error} />}
@@ -61,6 +69,12 @@ export default function ProjectsPage() {
</div>
{creating && <CreateProjectModal onClose={() => setCreating(false)} />}
{importing && (
<ImportDialog
onClose={() => setImporting(false)}
onImported={(projectId) => navigate(`/projects/${projectId}`)}
/>
)}
</div>
)
}
+23 -2
View File
@@ -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);
});
}
}
+4 -4
View File
@@ -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);
}
+3 -3
View File
@@ -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"));
});
}
+3 -3
View File
@@ -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);
});
}
+1 -1
View File
@@ -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"));
});
}
+2 -2
View File
@@ -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));
});
}