diff --git a/README.md b/README.md index 969a092..3982a10 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ same edit. | `Novelly.Web` | React 19, TypeScript, Vite, TanStack Query, Tailwind v4 | The back end is one project organised by feature, not by layer. Each feature folder — -`Projects/`, `Characters/`, `Chapters/`, `Beats/`, `Scenes/`, `Tags/`, `Agent/` — holds its +`Novels/`, `Characters/`, `Chapters/`, `Beats/`, `Tags/`, `Locations/`, `Agent/` — holds its entity, DTOs, service and endpoints together, so adding a capability means touching one folder rather than four. `Common/` holds what genuinely crosses features and `Data/` holds the `DbContext` and migrations. @@ -65,7 +65,7 @@ and everything else keeps working. ### Tests ```bash -dotnet test # 73 tests +dotnet test # 174 tests cd src/Novelly.Web && npm run build # typecheck + bundle ``` @@ -98,13 +98,13 @@ out of `appsettings.json` and use user-secrets or the environment. ## The data model ``` -Project ──┬── Character ──┬── CharacterRelationship - │ └── CharacterArcStage (the arc: flat, ordered) - ├── Chapter ──┬── Beat (the outline: flat, ordered) - │ └── Scene (the prose) - ├── Tag (applied to characters, chapters and beats) - ├── OpenQuestion (attached to a chapter and/or a character) - └── AgentConversation ── AgentMessage +Novel ──┬── Character ──┬── CharacterRelationship + │ └── CharacterArcStage (the arc: flat, ordered) + ├── Chapter ──── Beat (the outline: flat, ordered) + ├── Tag (applied to characters, chapters and beats) + ├── Location (applied to chapters) + ├── OpenQuestion (attached to a chapter and/or a character) + └── AgentConversation ── AgentMessage ``` **A chapter outline is a paragraph plus a table.** The paragraph is the chapter's @@ -113,18 +113,20 @@ Project ──┬── Character ──┬── CharacterRelationship | Column | What goes in it | |---|---| | Beat | A three-to-five word handle — "she burns the atlas", not a sentence | -| Character | Whose beat it is. Optional; not every beat belongs to one person | +| Characters | Who's in the beat. Optional; not every beat belongs to anyone, and a beat can name more than one | | What happened | The event itself | | What's next | What it sets in motion — the hook into the following beat | -| Scene | Optional grouping: which scene will carry this beat's prose | Beats are flat and ordered by `SortOrder` within their chapter. There is no nesting and -no tree — reordering is one call that takes the beat ids in the order wanted. +no tree — reordering is one call that takes the beat ids in the order wanted. The outline +tab also rolls up the distinct set of characters across a chapter's beats under the beat +and word counts, each linking to that character's page — a quick cast list without +opening every beat. -**Beats plan; scenes carry prose.** The two layers are deliberately separate: an outline -is for working out what happens, and a scene is where you write it. A beat's `SceneId` is -the optional link between them, and it is nullable in both directions — deleting a scene -ungroups its beats rather than deleting the plan. +**Beats plan; the chapter's `Prose` carries the draft.** A chapter has one prose field, +written and redrafted in place; `WordCount` is recomputed from it whenever it changes. +There is no separate scene entity — the outline (beats) and the draft (prose) are the +two views of the same chapter. **Main characters carry the book; supporting characters hold it up.** A character's `Importance` (`Main` or `Supporting`) is separate from their `Role` — role is the part they @@ -149,19 +151,26 @@ append the decision to the notes of whatever it was attached to — so a settled up where you re-read it rather than in a list you have stopped looking at. Resolved questions drop off the list unless you ask for them. -**Tags cross-reference the book.** A tag is scoped to one project, unique by name +**Tags cross-reference the book.** A tag is scoped to one novel, unique by name (case-insensitively), and can be attached to any character, chapter or beat. Applying an unknown tag by name creates it, so tagging is one action rather than two. `GET /api/tags/{id}/references` returns everything carrying a tag, which is how you trace a motif or a thread across all three kinds at once. +**A chapter can have several locations.** Locations replaced the old single free-text +`Setting` field: a chapter now carries a multi-select list of locations (where and when +it takes place), each a novel-scoped, name-deduped entity — applying an unknown location +by name creates it, same as tags. A "Locations" tab on the novel lists every location +with its chapter count, and `GET /api/locations/{id}/references` lists every chapter set +there. + ## The embedded agent `NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools Claude asks for, feeds every result back in a single user turn, and repeats until Claude -stops asking. It has 29 tools covering the brief, characters and their arcs, chapter outlines -(beats), scenes, tags and open questions — all of them going through the same application -services the REST API uses. +stops asking. It has 33 tools covering the brief, characters and their arcs, chapter +outlines (beats), tags, locations and open questions — all of them going through the same +application services the REST API uses. A few deliberate choices worth knowing about: @@ -179,7 +188,7 @@ A few deliberate choices worth knowing about: ## The MCP server -A stdio MCP server exposing 38 tools over the same REST API. It holds no domain logic of +A stdio MCP server exposing 45 tools over the same REST API. It holds no domain logic of its own — it is a second front end, not a second implementation. Build it, then point your MCP client at the produced binary: @@ -230,19 +239,20 @@ running and `.mcp.json` is set up. | Resource | Routes | |---|---| -| Projects | `GET\|POST /api/projects`, `GET\|PATCH\|DELETE /api/projects/{id}` | -| Characters | `GET\|POST /api/projects/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` | -| Chapters | `GET\|POST /api/projects/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` | +| Novels | `GET\|POST /api/novels`, `GET\|PATCH\|DELETE /api/novels/{id}` | +| Characters | `GET\|POST /api/novels/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` | +| Chapters | `GET\|POST /api/novels/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` | | Beats | `GET\|POST /api/chapters/{id}/beats`, `POST /api/chapters/{id}/beats/reorder`, `GET\|PATCH\|DELETE /api/beats/{id}` | -| Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{id}` | -| Tags | `GET\|POST /api/projects/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` | +| Tags | `GET\|POST /api/novels/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` | +| Locations | `GET\|POST /api/novels/{id}/locations`, `GET /api/locations/{id}/references`, `PATCH\|DELETE /api/locations/{id}` | | Arcs | `GET\|POST /api/characters/{id}/arc`, `POST /api/characters/{id}/arc/reorder`, `GET\|PATCH\|DELETE /api/arc-stages/{id}` | -| Questions | `GET\|POST /api/projects/{id}/questions`, `GET\|PATCH\|DELETE /api/questions/{id}`, `POST /api/questions/{id}/resolve`, `POST /api/questions/{id}/reopen` | -| Agent | `GET /api/projects/{id}/agent/conversations`, `POST /api/projects/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` | +| Questions | `GET\|POST /api/novels/{id}/questions`, `GET\|PATCH\|DELETE /api/questions/{id}`, `POST /api/questions/{id}/resolve`, `POST /api/questions/{id}/reopen` | +| Agent | `GET /api/novels/{id}/agent/conversations`, `POST /api/novels/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` | `PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. A -`tags` array replaces that item's tags outright and creates any names the project has not -seen; omitting it leaves tags untouched. +`tags` array replaces that item's tags outright and creates any names the novel has not +seen; the same is true of a chapter's `locations` array. Omitting either leaves it +untouched. Enums travel as names (`"Protagonist"`, `"Drafted"`), never ordinals. In development the OpenAPI document is at `/openapi/v1.json`.