Remove Scenes, group beats by multiple characters; strip comments repo-wide

Drop the Scene entity/grouping in favor of chapters carrying prose directly
and beats belonging to many characters. Add markdown editor + character
multi-select components to the web client. Remove all XML doc and inline
comments across the touched C#/TS/CSS files in favor of self-documenting
names, and record that convention in CLAUDE.md. Add .mcp.json (local MCP
server config, no secrets) and ignore .idea/.
This commit is contained in:
James Wampler
2026-08-11 21:05:13 -07:00
parent 1ce526019f
commit 23348327a9
57 changed files with 2600 additions and 1421 deletions
+4 -4
View File
@@ -149,10 +149,10 @@ For each chapter file:
Keep the Part tag exactly as written ("Part I", "Part II"); keep the thread tag as the raw 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 Thread text so multi-POV chapters aren't lossy even though `povCharacterId` had to pick one or
none. none.
4. For each beat table row, resolve the Character column the same way: a single clear name gets 4. For each beat table row, resolve the Character column the same way: a single clear name, or a
auto-created if it isn't in the ledger yet; a list ("Glokta, West, Jezal") or vague reference list ("Glokta, West, Jezal"), gets each name auto-created if it isn't in the ledger yet; a
stays unresolved rather than guessing which one the beat belongs to. Then vague reference stays unresolved rather than guessing who the beat belongs to. Then
`create_beat(chapterId, title: <Beat column>, whatHappened: <What column>, whatsNext: <Why column>, characterId: <resolved id, else omit>)`, `create_beat(chapterId, title: <Beat column>, whatHappened: <What column>, whatsNext: <Why column>, characterIds: <resolved ids, else omit>)`,
in table order (the API appends in call order, so no explicit `sortOrder` needed). in table order (the API appends in call order, so no explicit `sortOrder` needed).
5. If `## Notes` is present, `update_chapter(chapterId, notes: ...)`. 5. If `## Notes` is present, `update_chapter(chapterId, notes: ...)`.
6. Record `chapters[number] = chapterId`, append `number` to `completedChapters`. 6. Record `chapters[number] = chapterId`, append `number` to `completedChapters`.
+2
View File
@@ -439,3 +439,5 @@ mcp-server/
# Toolchains installed locally by scripts/ci/lib.sh # Toolchains installed locally by scripts/ci/lib.sh
.dotnet/ .dotnet/
.node/ .node/
.idea/
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"novelly": {
"command": "/home/james/src/novelly/mcp-server/Novelly.Mcp",
"env": {
"NOVELLY_API_URL": "http://localhost:5080",
"DOTNET_ROOT": "/home/james/.dotnet"
}
}
}
}
+2
View File
@@ -47,6 +47,8 @@ Serilog console via `AddSerilog` (not `UseSerilog` — keeps OTel provider for A
# Coding # Coding
- No comments — no `///` XML doc, no `//` line comments, no `/* */` blocks, in C#, TS, or CSS. Unclear code → rename for
clarity or extract a well-named method instead of explaining it.
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper - Descriptive names all classes/methods. No generic: Provider, Manager, Helper
- Match formatting/style from `.editorconfig` - Match formatting/style from `.editorconfig`
- Wrap lines at 220 chars, single line if fewer - Wrap lines at 220 chars, single line if fewer
+4 -14
View File
@@ -3,10 +3,6 @@ using System.Text.Json;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
/// <summary>
/// Small builder for the JSON Schema objects tool definitions need. Hand-writing these
/// as string literals is where tool definitions usually rot, so build them structurally.
/// </summary>
public class JsonSchemaBuilder public class JsonSchemaBuilder
{ {
private readonly JsonObject _properties = []; private readonly JsonObject _properties = [];
@@ -80,7 +76,6 @@ public class JsonSchemaBuilder
} }
} }
/// <summary>Lenient readers for tool input, which arrives as untyped JSON.</summary>
public static class JsonInput public static class JsonInput
{ {
public static string? String(JsonElement input, string name) => public static string? String(JsonElement input, string name) =>
@@ -114,10 +109,6 @@ public static class JsonInput
}; };
} }
/// <summary>
/// Reads a boolean flag. Models sometimes send <c>"true"</c> as a string even when the
/// schema says boolean, so both spellings are accepted.
/// </summary>
public static bool? Bool(JsonElement input, string name) public static bool? Bool(JsonElement input, string name)
{ {
if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty(name, out var value)) if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty(name, out var value))
@@ -134,11 +125,6 @@ public static class JsonInput
}; };
} }
/// <summary>
/// Reads an array of strings. Returns null when the property is absent, which the
/// services read as "leave the existing list alone" — distinct from an empty array,
/// which clears it.
/// </summary>
public static IReadOnlyList<string>? Strings(JsonElement input, string name) public static IReadOnlyList<string>? Strings(JsonElement input, string name)
{ {
if (input.ValueKind != JsonValueKind.Object if (input.ValueKind != JsonValueKind.Object
@@ -155,4 +141,8 @@ public static class JsonInput
public static TEnum? Enum<TEnum>(JsonElement input, string name) where TEnum : struct, System.Enum => public static TEnum? Enum<TEnum>(JsonElement input, string name) where TEnum : struct, System.Enum =>
System.Enum.TryParse<TEnum>(String(input, name), ignoreCase: true, out var parsed) ? parsed : null; System.Enum.TryParse<TEnum>(String(input, name), ignoreCase: true, out var parsed) ? parsed : null;
public static IReadOnlyList<Guid>? Guids(JsonElement input, string name) =>
Strings(input, name)?.Select(s => System.Guid.TryParse(s, out var id) ? id : (Guid?)null)
.Where(id => id is not null).Select(id => id!.Value).ToList();
} }
+4 -38
View File
@@ -10,10 +10,6 @@ using Novelly.Api.Projects;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
/// <summary>
/// The embedded writing agent. Runs the tool-use loop against the model, persists the
/// conversation, and returns the finished turn together with a record of what it changed.
/// </summary>
public class NovelAgentService( public class NovelAgentService(
INovelDbContext db, INovelDbContext db,
IAgentModelClient model, IAgentModelClient model,
@@ -41,7 +37,6 @@ public class NovelAgentService(
.ToListAsync(ct); .ToListAsync(ct);
} }
/// <summary>Null when no conversation has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<AgentConversation?> GetConversationAsync(Guid conversationId, CancellationToken ct = default) public async Task<AgentConversation?> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
{ {
Guard.Default(conversationId, nameof(conversationId)); Guard.Default(conversationId, nameof(conversationId));
@@ -51,7 +46,6 @@ public class NovelAgentService(
return await FindConversationAsync(conversationId, ct); return await FindConversationAsync(conversationId, ct);
} }
/// <summary>True if a conversation was deleted; false if no conversation had this id.</summary>
public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default) public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
{ {
Guard.Default(conversationId, nameof(conversationId)); Guard.Default(conversationId, nameof(conversationId));
@@ -69,12 +63,6 @@ public class NovelAgentService(
return true; return true;
} }
/// <summary>
/// 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. Null when no project has
/// this id, or the request names a conversation that does not exist — a lookup miss
/// is expected, not exceptional.
/// </summary>
public async Task<AgentMessage?> SendMessageAsync( public async Task<AgentMessage?> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{ {
@@ -96,8 +84,6 @@ public class NovelAgentService(
AgentConversation conversation; AgentConversation conversation;
if (request.ConversationId is { } id) if (request.ConversationId is { } id)
{ {
// The id came from the request body, not the route — an unknown id here
// is bad input to this call, not a direct "fetch conversation" lookup.
var found = await FindConversationAsync(id, ct); var found = await FindConversationAsync(id, ct);
if (found is null) if (found is null)
{ {
@@ -111,9 +97,6 @@ public class NovelAgentService(
conversation = StartConversation(projectId, request.Message); conversation = StartConversation(projectId, request.Message);
} }
// Persist the user's turn before running the loop. The tools save through the
// same DbContext, so leaving this pending would entangle it with their writes —
// and recording the question even if the model call fails is the behaviour we want.
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct); await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
var systemPrompt = BuildSystemPrompt(project); var systemPrompt = BuildSystemPrompt(project);
@@ -141,9 +124,6 @@ public class NovelAgentService(
break; break;
} }
// Echo the assistant's turn back verbatim, then answer every tool_use block in a
// single user turn — splitting the results would train the model out of
// requesting tools in parallel.
transcript.Add(AgentChatMessage.Assistant(response.Content)); transcript.Add(AgentChatMessage.Assistant(response.Content));
var results = new List<AgentContentBlock>(); var results = new List<AgentContentBlock>();
@@ -182,11 +162,6 @@ public class NovelAgentService(
return reply; return reply;
} }
/// <summary>
/// Appends a turn and commits it. Messages are added to the set directly rather than
/// through the parent's collection so their insert never depends on EF discovering
/// the graph change at an inconvenient moment.
/// </summary>
private async Task<AgentMessage> AppendMessageAsync( private async Task<AgentMessage> AppendMessageAsync(
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct) AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
{ {
@@ -206,9 +181,6 @@ public class NovelAgentService(
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
// EF's relationship fixup normally puts the message into the parent's collection
// once both are tracked. Guard rather than assume, since the sequence number of
// the next turn is derived from it.
if (!conversation.Messages.Contains(message)) if (!conversation.Messages.Contains(message))
{ {
conversation.Messages.Add(message); conversation.Messages.Add(message);
@@ -247,11 +219,6 @@ public class NovelAgentService(
return conversation; return conversation;
} }
/// <summary>
/// Replays the stored conversation as plain text turns. Tool calls are not replayed —
/// the agent re-reads current state through its tools, which is more reliable than
/// trusting a transcript of edits that may since have been changed in the UI.
/// </summary>
private static List<AgentChatMessage> BuildTranscript(AgentConversation conversation) => private static List<AgentChatMessage> BuildTranscript(AgentConversation conversation) =>
[ [
.. conversation.Messages .. conversation.Messages
@@ -273,8 +240,8 @@ public class NovelAgentService(
return $""" return $"""
You are a developmental editor and writing partner embedded in the software the You are a developmental editor and writing partner embedded in the software the
writer is using to plan their novel. You have tools that read and write the writer is using to plan their novel. You have tools that read and write the
project's real data: the brief, character dossiers, the outline tree, chapters project's real data: the brief, character dossiers, the outline (beats) and each
and scenes. chapter's drafted prose.
The project you are working on: The project you are working on:
{brief} {brief}
@@ -291,8 +258,8 @@ public class NovelAgentService(
- Prefer structural help — where a beat lands, whether a want and a need are - Prefer structural help — where a beat lands, whether a want and a need are
genuinely in tension, what the outline is missing — over line-level polish, genuinely in tension, what the outline is missing — over line-level polish,
unless the writer asks for prose. unless the writer asks for prose.
- When drafting prose into a scene, match the voice already established in the - When drafting a chapter's prose, match the voice already established in the
project. Write the scene, then stop; do not append notes about your choices. project. Write the chapter, then stop; do not append notes about your choices.
- Destructive operations (deleting outline nodes) need the writer's explicit - Destructive operations (deleting outline nodes) need the writer's explicit
go-ahead first. go-ahead first.
@@ -300,7 +267,6 @@ public class NovelAgentService(
"""; """;
} }
/// <summary>Derives a conversation title from its opening message.</summary>
private static string Summarise(string message) private static string Summarise(string message)
{ {
var trimmed = message.Trim().ReplaceLineEndings(" "); var trimmed = message.Trim().ReplaceLineEndings(" ");
+12 -94
View File
@@ -5,40 +5,26 @@ using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
/// <summary>The outcome of running a tool: what to hand back to the model, and whether it failed.</summary>
public record AgentToolResult(string Content, bool IsError); public record AgentToolResult(string Content, bool IsError);
/// <summary>
/// A lookup a tool performed came back empty. Not an exception — the underlying service
/// already said so by returning null/false — just a value <see cref="NovelAgentToolset.ExecuteAsync"/>
/// recognises and turns into the same error-result shape a caught exception would produce.
/// </summary>
internal record ToolNotFound(string Message); internal record ToolNotFound(string Message);
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
public record AgentTool( public record AgentTool(
string Name, string Name,
string Description, string Description,
JsonElement InputSchema, JsonElement InputSchema,
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler); Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
/// <summary>
/// The tools the writing agent can reach for. Everything here goes through the same
/// application services the REST API uses, so an edit made by the agent is
/// indistinguishable from one made in the UI.
/// </summary>
public class NovelAgentToolset( public class NovelAgentToolset(
ProjectService projects, ProjectService projects,
CharacterService characters, CharacterService characters,
CharacterArcService arcs, CharacterArcService arcs,
ChapterService chapters, ChapterService chapters,
BeatService beats, BeatService beats,
SceneService scenes,
TagService tags, TagService tags,
OpenQuestionService questions, OpenQuestionService questions,
ILogger<NovelAgentToolset> logger) ILogger<NovelAgentToolset> logger)
@@ -56,10 +42,6 @@ public class NovelAgentToolset(
public IReadOnlyList<AgentToolDefinition> Definitions => public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; [.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
/// <summary>
/// Runs a tool and serialises its result. Failures come back as text rather than
/// exceptions so the model can read the message and correct itself.
/// </summary>
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default) public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default)
{ {
if (!ByName.TryGetValue(name, out var tool)) if (!ByName.TryGetValue(name, out var tool))
@@ -95,16 +77,13 @@ public class NovelAgentToolset(
} }
} }
/// <summary>Turns a nullable lookup into either the value or a <see cref="ToolNotFound"/> the model can read.</summary>
private static async Task<object> OrNotFound<T>(Task<T?> lookup, string entity, Guid id) where T : class => 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."); 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>( private static async Task<object> OrNotFound<TEntity, TResponse>(
Task<TEntity?> lookup, Func<TEntity, TResponse> map, string entity, Guid id) where TEntity : class => 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."); 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) => 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."); await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found.");
@@ -227,10 +206,9 @@ public class NovelAgentToolset(
new CreateBeatRequest( new CreateBeatRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"), JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"), JsonInput.Guids(input, "character_ids"),
JsonInput.String(input, "what_happened"), JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"), JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId); JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId);
}); });
@@ -250,10 +228,9 @@ public class NovelAgentToolset(
new UpdateBeatRequest( new UpdateBeatRequest(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"), JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"), JsonInput.Guids(input, "character_ids"),
JsonInput.String(input, "what_happened"), JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"), JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId); JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
}); });
@@ -310,13 +287,13 @@ public class NovelAgentToolset(
yield return new AgentTool( yield return new AgentTool(
"list_chapters", "list_chapters",
"List the project's chapters in manuscript order with scene and word counts.", "List the project's chapters in manuscript order with beat and word counts.",
new JsonSchemaBuilder().Build(), new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())); async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()));
yield return new AgentTool( yield return new AgentTool(
"get_chapter", "get_chapter",
"Read one chapter in full, including all of its scenes and any drafted prose.", "Read one chapter in full: its outline (beats) and its drafted prose.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to read.", required: true) .Str("chapter_id", "Id of the chapter to read.", required: true)
.Build(), .Build(),
@@ -338,6 +315,7 @@ public class NovelAgentToolset(
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>()) .Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.") .Int("target_word_count", "Target length in words.")
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(), .Build(),
async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest( async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest(
@@ -349,11 +327,14 @@ public class NovelAgentToolset(
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned, JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"), JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId)); JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
yield return new AgentTool( yield return new AgentTool(
"update_chapter", "update_chapter",
"Revise a chapter's title, number, summary, POV, setting, notes or status.", "Revise a chapter's title, number, summary, POV, setting, notes, status or drafted "
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
+ "word count is recomputed automatically.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to update.", required: true) .Str("chapter_id", "Id of the chapter to update.", required: true)
.Str("title", "New title.") .Str("title", "New title.")
@@ -364,6 +345,7 @@ public class NovelAgentToolset(
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>()) .Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.") .Int("target_word_count", "Target length in words.")
.Str("prose", "The chapter's drafted text, in markdown.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(), .Build(),
async (_, input, ct) => async (_, input, ct) =>
@@ -380,61 +362,10 @@ public class NovelAgentToolset(
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"), JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"), JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId); JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId);
}); });
yield return new AgentTool(
"create_scene",
"Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene "
+ "draftable later, so fill those in when the writer has given you enough to work with.",
SceneSchema()
.Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
.Str("title", "Scene title.", required: true)
.Build(),
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(scenes.CreateAsync(
chapterId,
new CreateSceneRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "summary"),
JsonInput.String(input, "goal"),
JsonInput.String(input, "conflict"),
JsonInput.String(input, "outcome"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct), s => s.ToResponse(), "Chapter", chapterId);
});
yield return new AgentTool(
"update_scene",
"Revise a scene. Use the 'prose' argument to write or replace the scene's draft text; "
+ "the word count is recomputed automatically.",
SceneSchema()
.Str("scene_id", "Id of the scene to update.", required: true)
.Str("title", "New title.")
.Build(),
async (_, input, ct) =>
{
var sceneId = JsonInput.RequiredGuid(input, "scene_id");
return await OrNotFound(scenes.UpdateAsync(
sceneId,
new UpdateSceneRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "summary"),
JsonInput.String(input, "goal"),
JsonInput.String(input, "conflict"),
JsonInput.String(input, "outcome"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status")), ct), s => s.ToResponse(), "Scene", sceneId);
});
yield return new AgentTool( yield return new AgentTool(
"get_character_beats", "get_character_beats",
"Every beat this character appears in, across the whole book, in manuscript order. " "Every beat this character appears in, across the whole book, in manuscript order. "
@@ -651,21 +582,8 @@ public class NovelAgentToolset(
private static JsonSchemaBuilder BeatSchema() => private static JsonSchemaBuilder BeatSchema() =>
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Int("sort_order", "Position in the chapter. Appended to the end when omitted.") .Int("sort_order", "Position in the chapter. Appended to the end when omitted.")
.Str("character_id", "Id of the character whose beat this is.") .StringArray("character_ids", "Ids of the characters whose beat this is. Replaces the existing list.")
.Str("what_happened", "The event itself.") .Str("what_happened", "The event itself.")
.Str("whats_next", "What it sets in motion — the hook into the next beat.") .Str("whats_next", "What it sets in motion — the hook into the next beat.")
.Str("scene_id", "Id of the scene this beat will be written into, if decided.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags."); .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
private static JsonSchemaBuilder SceneSchema() =>
new JsonSchemaBuilder()
.Int("sort_order", "Position within the chapter. Appended to the end when omitted.")
.Str("summary", "What happens in the scene.")
.Str("goal", "What the POV character is trying to achieve.")
.Str("conflict", "What stands in the way.")
.Str("outcome", "How it lands, and what it costs.")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("location", "Where the scene takes place.")
.Str("prose", "The drafted prose for this scene.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>());
} }
+1 -18
View File
@@ -1,16 +1,9 @@
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Scenes;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Beats; namespace Novelly.Api.Beats;
/// <summary>
/// One row of a chapter's outline: a short label, who it belongs to, what happened, and
/// what it sets up. Beats are the planning layer — flat and ordered within a chapter,
/// with no nesting. A beat may optionally be grouped under the <see cref="Scene"/> that
/// will eventually carry its prose.
/// </summary>
public class Beat public class Beat
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -18,24 +11,14 @@ public class Beat
public Guid ChapterId { get; set; } public Guid ChapterId { get; set; }
public Chapter? Chapter { get; set; } public Chapter? Chapter { get; set; }
/// <summary>Optional grouping: the scene this beat will be written into.</summary>
public Guid? SceneId { get; set; }
public Scene? Scene { get; set; }
/// <summary>Position within the chapter. Gaps are allowed.</summary>
public int SortOrder { get; set; } public int SortOrder { get; set; }
/// <summary>A three-to-five word handle for the beat, not a sentence.</summary>
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
/// <summary>Whose beat this is. Optional — not every beat belongs to one person.</summary> public List<Character> Characters { get; set; } = [];
public Guid? CharacterId { get; set; }
public Character? Character { get; set; }
/// <summary>The event itself.</summary>
public string? WhatHappened { get; set; } public string? WhatHappened { get; set; }
/// <summary>What it sets in motion — the hook into the next beat.</summary>
public string? WhatsNext { get; set; } public string? WhatsNext { get; set; }
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
+9 -32
View File
@@ -3,27 +3,25 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Beats; namespace Novelly.Api.Beats;
public record BeatCharacterResponse(Guid Id, string Name);
public record BeatResponse( public record BeatResponse(
Guid Id, Guid Id,
Guid ChapterId, Guid ChapterId,
int SortOrder, int SortOrder,
string Title, string Title,
Guid? CharacterId, IReadOnlyList<BeatCharacterResponse> Characters,
string? CharacterName,
string? WhatHappened, string? WhatHappened,
string? WhatsNext, string? WhatsNext,
Guid? SceneId,
string? SceneTitle,
IReadOnlyList<TagResponse> Tags, IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record CreateBeatRequest( public record CreateBeatRequest(
string Title, string Title,
int? SortOrder = null, int? SortOrder = null,
Guid? CharacterId = null, IReadOnlyList<Guid>? CharacterIds = null,
string? WhatHappened = null, string? WhatHappened = null,
string? WhatsNext = null, string? WhatsNext = null,
Guid? SceneId = null,
IReadOnlyList<string>? Tags = null); IReadOnlyList<string>? Tags = null);
public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest> public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest>
@@ -43,22 +41,13 @@ 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. 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( public record UpdateBeatRequest(
string? Title = null, string? Title = null,
int? SortOrder = null, int? SortOrder = null,
Guid? CharacterId = null, IReadOnlyList<Guid>? CharacterIds = null,
string? WhatHappened = null, string? WhatHappened = null,
string? WhatsNext = 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> public class UpdateBeatRequestValidator : IModelValidator<UpdateBeatRequest>
{ {
@@ -98,10 +87,6 @@ file static class BeatValidation
} }
} }
/// <summary>
/// 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 CharacterBeatResponse( public record CharacterBeatResponse(
Guid Id, Guid Id,
Guid ChapterId, Guid ChapterId,
@@ -110,11 +95,8 @@ public record CharacterBeatResponse(
int SortOrder, int SortOrder,
string Title, string Title,
string? WhatHappened, string? WhatHappened,
string? WhatsNext, string? WhatsNext);
Guid? SceneId,
string? SceneTitle);
/// <summary>Reorders a chapter's beats in one call, by listing their ids in the order wanted.</summary>
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds); public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
public class ReorderBeatsRequestValidator : IModelValidator<ReorderBeatsRequest> public class ReorderBeatsRequestValidator : IModelValidator<ReorderBeatsRequest>
@@ -137,12 +119,9 @@ public static class BeatMapping
b.ChapterId, b.ChapterId,
b.SortOrder, b.SortOrder,
b.Title, b.Title,
b.CharacterId, [.. b.Characters.OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))],
b.Character?.Name,
b.WhatHappened, b.WhatHappened,
b.WhatsNext, b.WhatsNext,
b.SceneId,
b.Scene?.Title,
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
b.UpdatedAt); b.UpdatedAt);
@@ -154,7 +133,5 @@ public static class BeatMapping
b.SortOrder, b.SortOrder,
b.Title, b.Title,
b.WhatHappened, b.WhatHappened,
b.WhatsNext, b.WhatsNext);
b.SceneId,
b.Scene?.Title);
} }
+27 -59
View File
@@ -8,10 +8,6 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Beats; namespace Novelly.Api.Beats;
/// <summary>
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
/// here is scoped to one chapter.
/// </summary>
public class BeatService( public class BeatService(
INovelDbContext db, INovelDbContext db,
TagService tags, TagService tags,
@@ -32,7 +28,6 @@ public class BeatService(
.ToListAsync(ct); .ToListAsync(ct);
} }
/// <summary>Null when no beat has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Beat?> GetAsync(Guid id, CancellationToken ct = default) public async Task<Beat?> GetAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
@@ -41,12 +36,6 @@ public class BeatService(
return await FindAsync(id, ct); return await FindAsync(id, ct);
} }
/// <summary>
/// Every beat this character appears in, in manuscript order. This is the character
/// page's view onto the outlines: each row carries its chapter so the UI can link
/// 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<Beat>?> ListForCharacterAsync( public async Task<IReadOnlyList<Beat>?> ListForCharacterAsync(
Guid characterId, CancellationToken ct = default) Guid characterId, CancellationToken ct = default)
{ {
@@ -62,8 +51,7 @@ public class BeatService(
var beats = await db.Beats var beats = await db.Beats
.Include(b => b.Chapter) .Include(b => b.Chapter)
.Include(b => b.Scene) .Where(b => b.Characters.Any(c => c.Id == characterId))
.Where(b => b.CharacterId == characterId)
.ToListAsync(ct); .ToListAsync(ct);
return return
@@ -74,7 +62,6 @@ public class BeatService(
]; ];
} }
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Beat?> 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.Default(chapterId, nameof(chapterId));
@@ -90,19 +77,20 @@ public class BeatService(
return null; return null;
} }
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
var beat = new Beat var beat = new Beat
{ {
ChapterId = chapterId, ChapterId = chapterId,
Title = request.Title, Title = request.Title,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct), SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
CharacterId = request.CharacterId,
WhatHappened = request.WhatHappened, WhatHappened = request.WhatHappened,
WhatsNext = request.WhatsNext, WhatsNext = request.WhatsNext
SceneId = request.SceneId
}; };
if (request.CharacterIds is { } characterIds)
{
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
}
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
@@ -111,7 +99,6 @@ public class BeatService(
db.Beats.Add(beat); db.Beats.Add(beat);
await db.SaveChangesAsync(ct); 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))!; return (await FindAsync(beat.Id, ct))!;
} }
@@ -132,22 +119,21 @@ public class BeatService(
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct); var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is null) if (chapter is null)
{ {
// The beat's own chapter should always exist via the FK — an invariant
// failing, not a caller mistake, but still not found so still just null.
logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId); logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId);
return null; return null;
} }
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title; beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
beat.SortOrder = request.SortOrder ?? beat.SortOrder; beat.SortOrder = request.SortOrder ?? beat.SortOrder;
beat.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? beat.CharacterId;
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened); beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext); beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
beat.SceneId = request.ClearScene ? null : request.SceneId ?? beat.SceneId;
beat.UpdatedAt = DateTimeOffset.UtcNow; beat.UpdatedAt = DateTimeOffset.UtcNow;
if (request.CharacterIds is { } characterIds)
{
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
}
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
@@ -157,7 +143,6 @@ public class BeatService(
return (await FindAsync(id, ct))!; return (await FindAsync(id, ct))!;
} }
/// <summary>True if a beat was deleted; false if no beat had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default) public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
@@ -175,11 +160,6 @@ public class BeatService(
return true; return true;
} }
/// <summary>
/// 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>
/// <summary>Null when the chapter carries a beat id it does not own — a lookup miss is expected, not exceptional.</summary>
public async Task<IReadOnlyList<Beat>?> ReorderAsync( public async Task<IReadOnlyList<Beat>?> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default) Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{ {
@@ -198,8 +178,6 @@ public class BeatService(
return null; return null;
} }
// Listed beats take the order given; anything omitted keeps its relative position
// after them rather than silently jumping to the front.
var order = 1; var order = 1;
foreach (var id in request.BeatIds) foreach (var id in request.BeatIds)
{ {
@@ -215,35 +193,26 @@ public class BeatService(
return await ListAsync(chapterId, ct); return await ListAsync(chapterId, ct);
} }
private async Task ValidateReferencesAsync( private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
{ {
logger.LogDebug("Validating beat references for chapter {ChapterId}: character {CharacterId}, scene {SceneId}", chapter.Id, characterId, sceneId); var distinct = characterIds.Distinct().ToList();
if (distinct.Count == 0)
if (characterId is { } cid)
{ {
var belongs = await db.Characters return [];
.AnyAsync(c => c.Id == cid && c.ProjectId == chapter.ProjectId, ct);
if (!belongs)
{
logger.LogWarning("Rejected beat reference: character {CharacterId} does not belong to project {ProjectId}", cid, chapter.ProjectId);
throw new InvalidOperationException(
"A beat's character must belong to the same project as its chapter.");
}
} }
if (sceneId is { } sid) var found = await db.Characters
{ .Where(c => c.ProjectId == projectId && distinct.Contains(c.Id))
var belongs = await db.Scenes.AnyAsync(s => s.Id == sid && s.ChapterId == chapter.Id, ct); .ToListAsync(ct);
if (!belongs) if (found.Count != distinct.Count)
{ {
logger.LogWarning("Rejected beat reference: scene {SceneId} does not belong to chapter {ChapterId}", sid, chapter.Id); logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId);
throw new InvalidOperationException( throw new InvalidOperationException(
"A beat can only be grouped under a scene in the same chapter."); "A beat's characters must belong to the same project as its chapter.");
}
} }
return found;
} }
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct) private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
@@ -259,8 +228,7 @@ public class BeatService(
private IQueryable<Beat> Query() => private IQueryable<Beat> Query() =>
db.Beats db.Beats
.Include(b => b.Character) .Include(b => b.Characters)
.Include(b => b.Scene)
.Include(b => b.Tags); .Include(b => b.Tags);
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct) private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
+4 -11
View File
@@ -2,29 +2,22 @@ using Novelly.Api.Beats;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Scenes;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
/// <summary>A chapter: an ordered container of scenes plus its own planning fields.</summary>
public class Chapter public class Chapter
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid ProjectId { get; set; }
public Project? Project { get; set; } public Project? Project { get; set; }
/// <summary>Position in the manuscript, 1-based.</summary>
public int Number { get; set; } public int Number { get; set; }
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
/// <summary>
/// The paragraph that opens the chapter's outline, above the beat table.
/// </summary>
public string? Summary { get; set; } public string? Summary { get; set; }
/// <summary>Whose head we are in for this chapter.</summary>
public Guid? PovCharacterId { get; set; } public Guid? PovCharacterId { get; set; }
public Character? PovCharacter { get; set; } public Character? PovCharacter { get; set; }
@@ -34,14 +27,14 @@ public class Chapter
public DraftStatus Status { get; set; } = DraftStatus.Planned; public DraftStatus Status { get; set; } = DraftStatus.Planned;
public int? TargetWordCount { get; set; } public int? TargetWordCount { get; set; }
public string? Prose { get; set; }
public int WordCount { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>The chapter's outline: an ordered, flat list of beats.</summary>
public List<Beat> Beats { get; set; } = []; public List<Beat> Beats { get; set; } = [];
/// <summary>The prose layer. Beats may optionally be grouped under these.</summary>
public List<Scene> Scenes { get; set; } = [];
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
} }
+18 -16
View File
@@ -1,7 +1,6 @@
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Scenes;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
@@ -18,15 +17,10 @@ public record ChapterSummaryResponse(
DraftStatus Status, DraftStatus Status,
int? TargetWordCount, int? TargetWordCount,
int BeatCount, int BeatCount,
int SceneCount,
int WordCount, int WordCount,
IReadOnlyList<TagResponse> Tags, IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
/// <summary>
/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table)
/// and the prose layer (scenes).
/// </summary>
public record ChapterResponse( public record ChapterResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid ProjectId,
@@ -40,7 +34,8 @@ public record ChapterResponse(
DraftStatus Status, DraftStatus Status,
int? TargetWordCount, int? TargetWordCount,
IReadOnlyList<BeatResponse> Beats, IReadOnlyList<BeatResponse> Beats,
IReadOnlyList<SceneResponse> Scenes, string? Prose,
int WordCount,
IReadOnlyList<TagResponse> Tags, IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
@@ -53,6 +48,7 @@ public record CreateChapterRequest(
string? Notes = null, string? Notes = null,
DraftStatus Status = DraftStatus.Planned, DraftStatus Status = DraftStatus.Planned,
int? TargetWordCount = null, int? TargetWordCount = null,
string? Prose = null,
IReadOnlyList<string>? Tags = null); IReadOnlyList<string>? Tags = null);
public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest> public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest>
@@ -66,16 +62,12 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
else if (model.Title.Length > 200) else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer."); result.AddError("Title", "'Title' must be 200 characters or fewer.");
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Tags, result); ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result; return result;
} }
} }
/// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
/// <see cref="Tags"/> list replaces the chapter's tags outright.
/// </summary>
public record UpdateChapterRequest( public record UpdateChapterRequest(
string? Title = null, string? Title = null,
int? Number = null, int? Number = null,
@@ -85,6 +77,7 @@ public record UpdateChapterRequest(
string? Notes = null, string? Notes = null,
DraftStatus? Status = null, DraftStatus? Status = null,
int? TargetWordCount = null, int? TargetWordCount = null,
string? Prose = null,
IReadOnlyList<string>? Tags = null); IReadOnlyList<string>? Tags = null);
public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest> public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest>
@@ -101,7 +94,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
result.AddError("Title", "'Title' must be 200 characters or fewer."); result.AddError("Title", "'Title' must be 200 characters or fewer.");
} }
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Tags, result); ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result; return result;
} }
@@ -110,7 +103,8 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
file static class ChapterValidation file static class ChapterValidation
{ {
public static void OptionalFields( public static void OptionalFields(
int? number, string? summary, string? setting, string? notes, int? targetWordCount, IReadOnlyList<string>? tags, ValidationResult result) int? number, string? summary, string? setting, string? notes, int? targetWordCount, string? prose,
IReadOnlyList<string>? tags, ValidationResult result)
{ {
if (number is <= 0) if (number is <= 0)
result.AddError("Number", "'Number' must be greater than zero."); result.AddError("Number", "'Number' must be greater than zero.");
@@ -127,6 +121,9 @@ file static class ChapterValidation
if (targetWordCount is < 0) if (targetWordCount is < 0)
result.AddError("TargetWordCount", "'Target Word Count' must be zero or greater."); result.AddError("TargetWordCount", "'Target Word Count' must be zero or greater.");
if (prose is { Length: > 200000 })
result.AddError("Prose", "'Prose' must be 200,000 characters or fewer.");
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace)) if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
result.AddError("Tags", "'Tags' must not contain blank entries."); result.AddError("Tags", "'Tags' must not contain blank entries.");
} }
@@ -139,14 +136,19 @@ public static class ChapterMapping
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes, c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes,
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())], [.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
[.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())], c.Prose, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt); c.UpdatedAt);
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new( public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount, c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
c.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount), c.Beats.Count, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt); c.UpdatedAt);
public static int CountWords(string? prose) =>
string.IsNullOrWhiteSpace(prose)
? 0
: prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
} }
+2 -2
View File
@@ -35,7 +35,7 @@ public static class ChapterEndpoints
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) (await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Read a chapter with all of its scenes."); .WithSummary("Read a chapter with its beats and prose.");
chapters.MapPatch("/{id:guid}", async ( chapters.MapPatch("/{id:guid}", async (
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) => Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
@@ -44,7 +44,7 @@ public static class ChapterEndpoints
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a chapter and its scenes."); .WithSummary("Delete a chapter.");
return app; return app;
} }
+11 -9
View File
@@ -22,14 +22,12 @@ public class ChapterService(
return await db.Chapters return await db.Chapters
.Include(c => c.PovCharacter) .Include(c => c.PovCharacter)
.Include(c => c.Beats) .Include(c => c.Beats)
.Include(c => c.Scenes)
.Include(c => c.Tags) .Include(c => c.Tags)
.Where(c => c.ProjectId == projectId) .Where(c => c.ProjectId == projectId)
.OrderBy(c => c.Number) .OrderBy(c => c.Number)
.ToListAsync(ct); .ToListAsync(ct);
} }
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Chapter?> GetAsync(Guid id, CancellationToken ct = default) public async Task<Chapter?> GetAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
@@ -38,7 +36,6 @@ public class ChapterService(
return await FindAsync(id, ct); return await FindAsync(id, ct);
} }
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Chapter?> 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.Default(projectId, nameof(projectId));
@@ -63,7 +60,9 @@ public class ChapterService(
Setting = request.Setting, Setting = request.Setting,
Notes = request.Notes, Notes = request.Notes,
Status = request.Status, Status = request.Status,
TargetWordCount = request.TargetWordCount TargetWordCount = request.TargetWordCount,
Prose = request.Prose,
WordCount = ChapterMapping.CountWords(request.Prose)
}; };
if (request.Tags is { } names) if (request.Tags is { } names)
@@ -74,7 +73,6 @@ public class ChapterService(
db.Chapters.Add(chapter); db.Chapters.Add(chapter);
await db.SaveChangesAsync(ct); 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))!; return (await FindAsync(chapter.Id, ct))!;
} }
@@ -100,6 +98,13 @@ public class ChapterService(
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes); chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status; chapter.Status = request.Status ?? chapter.Status;
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount; chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
if (request.Prose is not null)
{
chapter.Prose = Patch.Apply(chapter.Prose, request.Prose);
chapter.WordCount = ChapterMapping.CountWords(chapter.Prose);
}
chapter.UpdatedAt = DateTimeOffset.UtcNow; chapter.UpdatedAt = DateTimeOffset.UtcNow;
if (request.Tags is { } names) if (request.Tags is { } names)
@@ -111,7 +116,6 @@ public class ChapterService(
return (await FindAsync(id, ct))!; return (await FindAsync(id, ct))!;
} }
/// <summary>True if a chapter was deleted; false if no chapter had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default) public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
@@ -148,10 +152,8 @@ public class ChapterService(
var chapter = await db.Chapters var chapter = await db.Chapters
.Include(c => c.PovCharacter) .Include(c => c.PovCharacter)
.Include(c => c.Beats).ThenInclude(b => b.Character) .Include(c => c.Beats).ThenInclude(b => b.Characters)
.Include(c => c.Beats).ThenInclude(b => b.Scene)
.Include(c => c.Beats).ThenInclude(b => b.Tags) .Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Scenes).ThenInclude(s => s.PovCharacter)
.Include(c => c.Tags) .Include(c => c.Tags)
.FirstOrDefaultAsync(c => c.Id == id, ct); .FirstOrDefaultAsync(c => c.Id == id, ct);
+3 -18
View File
@@ -1,12 +1,9 @@
using Novelly.Api.Beats;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
/// <summary>
/// A character dossier. Every field beyond <see cref="Name"/> is optional so a writer can
/// start with a name and fill the sheet in as the character comes into focus.
/// </summary>
public class Character public class Character
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -16,10 +13,6 @@ public class Character
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public CharacterRole Role { get; set; } = CharacterRole.Supporting; public CharacterRole Role { get; set; } = CharacterRole.Supporting;
/// <summary>
/// Whether this character carries the book or supports it. New characters start as
/// supporting — a writer promotes the few who turn out to be main.
/// </summary>
public CharacterImportance Importance { get; set; } = CharacterImportance.Supporting; public CharacterImportance Importance { get; set; } = CharacterImportance.Supporting;
public string? Age { get; set; } public string? Age { get; set; }
@@ -30,22 +23,15 @@ public class Character
public string? Personality { get; set; } public string? Personality { get; set; }
public string? Backstory { get; set; } public string? Backstory { get; set; }
/// <summary>What the character consciously wants.</summary>
public string? Want { get; set; } public string? Want { get; set; }
/// <summary>What the character actually needs — usually at odds with <see cref="Want"/>.</summary>
public string? Need { get; set; } public string? Need { get; set; }
public string? InternalConflict { get; set; } public string? InternalConflict { get; set; }
public string? ExternalConflict { get; set; } public string? ExternalConflict { get; set; }
/// <summary>
/// How the character changes over the course of the book, in a sentence or two.
/// <see cref="ArcStages"/> breaks the same change into ordered steps.
/// </summary>
public string? ArcSummary { get; set; } public string? ArcSummary { get; set; }
/// <summary>Speech patterns, verbal tics, register — anything that makes dialogue sound like them.</summary>
public string? Voice { get; set; } public string? Voice { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
@@ -56,11 +42,11 @@ public class Character
public List<CharacterRelationship> Relationships { get; set; } = []; public List<CharacterRelationship> Relationships { get; set; } = [];
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
/// <summary>The character's arc, in order. Kept mainly for main characters.</summary>
public List<CharacterArcStage> ArcStages { get; set; } = []; public List<CharacterArcStage> ArcStages { get; set; } = [];
public List<Beat> Beats { get; set; } = [];
} }
/// <summary>A directed relationship from one character to another.</summary>
public class CharacterRelationship public class CharacterRelationship
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -71,7 +57,6 @@ public class CharacterRelationship
public Guid RelatedCharacterId { get; set; } public Guid RelatedCharacterId { get; set; }
public Character? RelatedCharacter { get; set; } public Character? RelatedCharacter { get; set; }
/// <summary>e.g. "sister", "rival", "former mentor".</summary>
public string RelationshipType { get; set; } = string.Empty; public string RelationshipType { get; set; } = string.Empty;
public string? Description { get; set; } public string? Description { get; set; }
-1
View File
@@ -1,6 +1,5 @@
namespace Novelly.Api.Common; namespace Novelly.Api.Common;
/// <summary>How far along a chapter or scene is in the drafting pipeline.</summary>
public enum DraftStatus public enum DraftStatus
{ {
Planned, Planned,
@@ -9,16 +9,10 @@ using Novelly.Api.Data;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Common; namespace Novelly.Api.Common;
/// <summary>
/// Wires up every feature's services in one place. Endpoints, the embedded agent and the
/// MCP server all resolve the same instances, so a capability added here is available to
/// all three.
/// </summary>
public static class NovellyServiceRegistration public static class NovellyServiceRegistration
{ {
public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
@@ -35,7 +29,6 @@ public static class NovellyServiceRegistration
services.AddScoped<BeatService>(); services.AddScoped<BeatService>();
services.AddScoped<TagService>(); services.AddScoped<TagService>();
services.AddScoped<ChapterService>(); services.AddScoped<ChapterService>();
services.AddScoped<SceneService>();
services.AddScoped<OpenQuestionService>(); services.AddScoped<OpenQuestionService>();
services.AddScoped<NovelAgentToolset>(); services.AddScoped<NovelAgentToolset>();
services.AddScoped<NovelAgentService>(); services.AddScoped<NovelAgentService>();
@@ -43,8 +36,6 @@ public static class NovellyServiceRegistration
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName)); services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>(); 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.AddSingleton(Channel.CreateUnbounded<Guid>());
services.AddScoped<ImportService>(); services.AddScoped<ImportService>();
services.AddScoped<ImportAgentToolset>(); services.AddScoped<ImportAgentToolset>();
-6
View File
@@ -6,15 +6,10 @@ using Novelly.Api.Characters;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Data; namespace Novelly.Api.Data;
/// <summary>
/// The persistence surface the application services depend on. Infrastructure supplies
/// the EF Core implementation; tests can point it at an in-memory SQLite connection.
/// </summary>
public interface INovelDbContext public interface INovelDbContext
{ {
DbSet<Project> Projects { get; } DbSet<Project> Projects { get; }
@@ -24,7 +19,6 @@ public interface INovelDbContext
DbSet<Beat> Beats { get; } DbSet<Beat> Beats { get; }
DbSet<Tag> Tags { get; } DbSet<Tag> Tags { get; }
DbSet<Chapter> Chapters { get; } DbSet<Chapter> Chapters { get; }
DbSet<Scene> Scenes { get; }
DbSet<OpenQuestion> OpenQuestions { get; } DbSet<OpenQuestion> OpenQuestions { get; }
DbSet<AgentConversation> Conversations { get; } DbSet<AgentConversation> Conversations { get; }
DbSet<AgentMessage> AgentMessages { get; } DbSet<AgentMessage> AgentMessages { get; }
@@ -0,0 +1,768 @@
// <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("20260812035308_RemoveScenesAddChapterProseAndMultiCharacterBeats")]
partial class RemoveScenesAddChapterProseAndMultiCharacterBeats
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("BeatCharacter", b =>
{
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.Property<Guid>("CharactersId")
.HasColumnType("TEXT");
b.HasKey("BeatsId", "CharactersId");
b.HasIndex("CharactersId");
b.ToTable("BeatCharacters", (string)null);
});
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<long>("CreatedAt")
.HasColumnType("INTEGER");
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("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>("Prose")
.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.Property<int>("WordCount")
.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>("Phase")
.IsRequired()
.HasMaxLength(32)
.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.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("BeatCharacter", b =>
{
b.HasOne("Novelly.Api.Beats.Beat", null)
.WithMany()
.HasForeignKey("BeatsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", null)
.WithMany()
.HasForeignKey("CharactersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
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.Navigation("Chapter");
});
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.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");
});
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,184 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class RemoveScenesAddChapterProseAndMultiCharacterBeats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Beats_Characters_CharacterId",
table: "Beats");
migrationBuilder.DropForeignKey(
name: "FK_Beats_Scenes_SceneId",
table: "Beats");
migrationBuilder.DropTable(
name: "Scenes");
migrationBuilder.DropIndex(
name: "IX_Beats_CharacterId",
table: "Beats");
migrationBuilder.DropIndex(
name: "IX_Beats_SceneId",
table: "Beats");
migrationBuilder.DropColumn(
name: "CharacterId",
table: "Beats");
migrationBuilder.DropColumn(
name: "SceneId",
table: "Beats");
migrationBuilder.AddColumn<string>(
name: "Prose",
table: "Chapters",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WordCount",
table: "Chapters",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "BeatCharacters",
columns: table => new
{
BeatsId = table.Column<Guid>(type: "TEXT", nullable: false),
CharactersId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BeatCharacters", x => new { x.BeatsId, x.CharactersId });
table.ForeignKey(
name: "FK_BeatCharacters_Beats_BeatsId",
column: x => x.BeatsId,
principalTable: "Beats",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BeatCharacters_Characters_CharactersId",
column: x => x.CharactersId,
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_BeatCharacters_CharactersId",
table: "BeatCharacters",
column: "CharactersId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BeatCharacters");
migrationBuilder.DropColumn(
name: "Prose",
table: "Chapters");
migrationBuilder.DropColumn(
name: "WordCount",
table: "Chapters");
migrationBuilder.AddColumn<Guid>(
name: "CharacterId",
table: "Beats",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "SceneId",
table: "Beats",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "Scenes",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ChapterId = table.Column<Guid>(type: "TEXT", nullable: false),
PovCharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
Conflict = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
Goal = table.Column<string>(type: "TEXT", nullable: true),
Location = table.Column<string>(type: "TEXT", nullable: true),
Outcome = table.Column<string>(type: "TEXT", nullable: true),
Prose = table.Column<string>(type: "TEXT", nullable: true),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Summary = table.Column<string>(type: "TEXT", nullable: true),
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false),
WordCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Scenes", x => x.Id);
table.ForeignKey(
name: "FK_Scenes_Chapters_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Scenes_Characters_PovCharacterId",
column: x => x.PovCharacterId,
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_Beats_CharacterId",
table: "Beats",
column: "CharacterId");
migrationBuilder.CreateIndex(
name: "IX_Beats_SceneId",
table: "Beats",
column: "SceneId");
migrationBuilder.CreateIndex(
name: "IX_Scenes_ChapterId_SortOrder",
table: "Scenes",
columns: new[] { "ChapterId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_Scenes_PovCharacterId",
table: "Scenes",
column: "PovCharacterId");
migrationBuilder.AddForeignKey(
name: "FK_Beats_Characters_CharacterId",
table: "Beats",
column: "CharacterId",
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_Beats_Scenes_SceneId",
table: "Beats",
column: "SceneId",
principalTable: "Scenes",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
}
}
@@ -17,6 +17,21 @@ namespace Novelly.Api.Data.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("BeatCharacter", b =>
{
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.Property<Guid>("CharactersId")
.HasColumnType("TEXT");
b.HasKey("BeatsId", "CharactersId");
b.HasIndex("CharactersId");
b.ToTable("BeatCharacters", (string)null);
});
modelBuilder.Entity("BeatTag", b => modelBuilder.Entity("BeatTag", b =>
{ {
b.Property<Guid>("BeatsId") b.Property<Guid>("BeatsId")
@@ -133,15 +148,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<Guid>("ChapterId") b.Property<Guid>("ChapterId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid?>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid?>("SceneId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -161,10 +170,6 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SceneId");
b.HasIndex("ChapterId", "SortOrder"); b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Beats"); b.ToTable("Beats");
@@ -191,6 +196,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<Guid>("ProjectId") b.Property<Guid>("ProjectId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<string>("Setting") b.Property<string>("Setting")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -213,6 +221,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("UpdatedAt") b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("PovCharacterId"); b.HasIndex("PovCharacterId");
@@ -497,67 +508,6 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("OpenQuestions"); 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 => modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -587,6 +537,21 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("Tags"); b.ToTable("Tags");
}); });
modelBuilder.Entity("BeatCharacter", b =>
{
b.HasOne("Novelly.Api.Beats.Beat", null)
.WithMany()
.HasForeignKey("BeatsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", null)
.WithMany()
.HasForeignKey("CharactersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("BeatTag", b => modelBuilder.Entity("BeatTag", b =>
{ {
b.HasOne("Novelly.Api.Beats.Beat", null) b.HasOne("Novelly.Api.Beats.Beat", null)
@@ -662,21 +627,7 @@ namespace Novelly.Api.Data.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .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("Chapter");
b.Navigation("Character");
b.Navigation("Scene");
}); });
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
@@ -770,24 +721,6 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("Project"); 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 => modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Projects.Project", "Project")
@@ -807,8 +740,6 @@ namespace Novelly.Api.Data.Migrations
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{ {
b.Navigation("Beats"); b.Navigation("Beats");
b.Navigation("Scenes");
}); });
modelBuilder.Entity("Novelly.Api.Characters.Character", b => modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
+2 -41
View File
@@ -7,17 +7,10 @@ using Novelly.Api.Characters;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Data; namespace Novelly.Api.Data;
/// <summary>
/// Stores a <see cref="DateTimeOffset"/> as UTC ticks. SQLite has no native type for it
/// and refuses to ORDER BY the default text form, which every "most recently updated
/// first" listing depends on. The domain only ever writes UtcNow, so normalising to UTC
/// loses nothing.
/// </summary>
internal class UtcTicksConverter() internal class UtcTicksConverter()
: ValueConverter<DateTimeOffset, long>( : ValueConverter<DateTimeOffset, long>(
value => value.UtcTicks, value => value.UtcTicks,
@@ -33,7 +26,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
public DbSet<Beat> Beats => Set<Beat>(); public DbSet<Beat> Beats => Set<Beat>();
public DbSet<Tag> Tags => Set<Tag>(); public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Chapter> Chapters => Set<Chapter>(); public DbSet<Chapter> Chapters => Set<Chapter>();
public DbSet<Scene> Scenes => Set<Scene>();
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>(); public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
public DbSet<AgentConversation> Conversations => Set<AgentConversation>(); public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>(); public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
@@ -80,8 +72,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
entity.Property(s => s.Title).IsRequired().HasMaxLength(200); entity.Property(s => s.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(s => new { s.CharacterId, s.SortOrder }); entity.HasIndex(s => new { s.CharacterId, s.SortOrder });
// An arc stage outlives the chapter it was pinned to: deleting a chapter is a
// decision about the manuscript, not about how the character changes.
entity.HasOne(s => s.Chapter).WithMany() entity.HasOne(s => s.Chapter).WithMany()
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
}); });
@@ -90,9 +80,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
{ {
entity.Property(r => r.RelationshipType).IsRequired().HasMaxLength(120); entity.Property(r => r.RelationshipType).IsRequired().HasMaxLength(120);
// Restrict on the inverse side: deleting a character should not silently take
// the other character's relationship rows with it via a second cascade path,
// which SQLite rejects as a multiple-cascade cycle.
entity.HasOne(r => r.RelatedCharacter).WithMany() entity.HasOne(r => r.RelatedCharacter).WithMany()
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
}); });
@@ -105,13 +92,8 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats) entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
// A beat outlives the scene it was grouped under: deleting a scene is a entity.HasMany(b => b.Characters).WithMany(c => c.Beats)
// decision about prose, not about the plan. .UsingEntity(join => join.ToTable("BeatCharacters"));
entity.HasOne(b => b.Scene).WithMany()
.HasForeignKey(b => b.SceneId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(b => b.Character).WithMany()
.HasForeignKey(b => b.CharacterId).OnDelete(DeleteBehavior.SetNull);
}); });
builder.Entity<Tag>(entity => builder.Entity<Tag>(entity =>
@@ -119,8 +101,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
entity.Property(t => t.Name).IsRequired().HasMaxLength(64); entity.Property(t => t.Name).IsRequired().HasMaxLength(64);
entity.Property(t => t.Color).HasMaxLength(16); entity.Property(t => t.Color).HasMaxLength(16);
// One canonical tag per name per project, so "betrayal" always means the
// same tag no matter where it was typed.
entity.HasIndex(t => new { t.ProjectId, t.Name }).IsUnique(); entity.HasIndex(t => new { t.ProjectId, t.Name }).IsUnique();
entity.HasMany(t => t.Characters).WithMany(c => c.Tags) entity.HasMany(t => t.Characters).WithMany(c => c.Tags)
@@ -139,19 +119,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
entity.HasOne(c => c.PovCharacter).WithMany() entity.HasOne(c => c.PovCharacter).WithMany()
.HasForeignKey(c => c.PovCharacterId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(c => c.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
entity.HasMany(c => c.Scenes).WithOne(s => s.Chapter!)
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<Scene>(entity =>
{
entity.Property(s => s.Title).IsRequired().HasMaxLength(300);
entity.Property(s => s.Status).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(s => new { s.ChapterId, s.SortOrder });
entity.HasOne(s => s.PovCharacter).WithMany()
.HasForeignKey(s => s.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
}); });
builder.Entity<OpenQuestion>(entity => builder.Entity<OpenQuestion>(entity =>
@@ -159,15 +126,11 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
entity.Property(q => q.Question).IsRequired().HasMaxLength(500); entity.Property(q => q.Question).IsRequired().HasMaxLength(500);
entity.Ignore(q => q.IsResolved); entity.Ignore(q => q.IsResolved);
// Open questions are listed per project and filtered to a chapter or character,
// so index the project and let the filters narrow from there.
entity.HasIndex(q => q.ProjectId); entity.HasIndex(q => q.ProjectId);
entity.HasOne(q => q.Project).WithMany() entity.HasOne(q => q.Project).WithMany()
.HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade);
// A question survives what it was about. Deleting a chapter or character should
// not quietly take an unresolved decision with it.
entity.HasOne(q => q.Chapter).WithMany() entity.HasOne(q => q.Chapter).WithMany()
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(q => q.Character).WithMany() entity.HasOne(q => q.Character).WithMany()
@@ -192,8 +155,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000); entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000);
entity.Property(j => j.Status).HasConversion<string>().HasMaxLength(16); 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); entity.HasIndex(j => j.SourceRoot);
}); });
} }
+2 -23
View File
@@ -8,30 +8,14 @@ using Novelly.Api.Projects;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
/// <summary>
/// A lookup a tool performed came back empty. Not an exception — the underlying service
/// already said so by returning null — just a value <see cref="ImportAgentToolset.ExecuteAsync"/>
/// recognises and turns into the same error-result shape a caught exception would produce.
/// </summary>
internal record ImportToolNotFound(string Message); internal record ImportToolNotFound(string Message);
/// <summary>A tool the import agent can call, bound to a handler that runs against this run's state.</summary>
internal record ImportAgentTool( internal record ImportAgentTool(
string Name, string Name,
string Description, string Description,
JsonElement InputSchema, JsonElement InputSchema,
Func<JsonElement, CancellationToken, Task<object?>> Handler); 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( public class ImportAgentToolset(
ProjectService projects, ProjectService projects,
CharacterService characters, CharacterService characters,
@@ -54,17 +38,14 @@ public class ImportAgentToolset(
public IReadOnlyList<AgentToolDefinition> Definitions => public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; [.. 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) public void Initialize(string sourceRoot, Guid? existingProjectId)
{ {
_sourceRoot = sourceRoot; _sourceRoot = sourceRoot;
ProjectId = existingProjectId; 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); 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) public async Task<AgentToolResult> ExecuteAsync(string name, JsonElement input, CancellationToken ct = default)
{ {
if (!ByName.TryGetValue(name, out var tool)) if (!ByName.TryGetValue(name, out var tool))
@@ -196,8 +177,6 @@ public class ImportAgentToolset(
{ {
var json = JsonInput.RequiredString(input, "json"); 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); using var _ = JsonDocument.Parse(json);
File.WriteAllText(ImportPaths.LedgerPath(_sourceRoot), json); File.WriteAllText(ImportPaths.LedgerPath(_sourceRoot), json);
@@ -345,7 +324,7 @@ public class ImportAgentToolset(
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true) .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("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.") .StringArray("character_ids", "Ids of the characters named in the Character column, if they resolve.")
.Str("what_happened", "The What column.") .Str("what_happened", "The What column.")
.Str("whats_next", "The Why column.") .Str("whats_next", "The Why column.")
.Build(), .Build(),
@@ -356,7 +335,7 @@ public class ImportAgentToolset(
chapterId, chapterId,
new CreateBeatRequest( new CreateBeatRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
CharacterId: JsonInput.Guid(input, "character_id"), CharacterIds: JsonInput.Guids(input, "character_ids"),
WhatHappened: JsonInput.String(input, "what_happened"), WhatHappened: JsonInput.String(input, "what_happened"),
WhatsNext: JsonInput.String(input, "whats_next")), ct); WhatsNext: JsonInput.String(input, "whats_next")), ct);
-12
View File
@@ -10,14 +10,11 @@ using Novelly.Api.Data;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Serilog; using Serilog;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// AddSerilog (not UseSerilog) so it becomes an additional logging provider rather than
// replacing the one AddServiceDefaults wires up for the Aspire dashboard.
builder.Services.AddSerilog((services, config) => config builder.Services.AddSerilog((services, config) => config
.ReadFrom.Configuration(builder.Configuration) .ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services) .ReadFrom.Services(services)
@@ -28,8 +25,6 @@ builder.Services.AddNovelly(builder.Configuration);
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
// Enums travel as their names, so the React client and the MCP server both read
// "Protagonist" rather than an ordinal that shifts whenever the enum is reordered.
builder.Services.ConfigureHttpJsonOptions(options => builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
@@ -43,16 +38,11 @@ builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
var app = builder.Build(); var app = builder.Build();
// Local-first tool: bring the SQLite file up to date on boot rather than making the
// writer run a migration command before they can open the app.
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
await scope.ServiceProvider.GetRequiredService<NovelDbContext>().Database.MigrateAsync(); await scope.ServiceProvider.GetRequiredService<NovelDbContext>().Database.MigrateAsync();
} }
// Serilog's request logging wraps the exception handler (registered first = outermost)
// so it reads the status code the handler already resolved, rather than seeing the raw
// exception fly past and misreporting a handled 404 as a 500.
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
app.UseExceptionHandler(handler => handler.Run(async context => app.UseExceptionHandler(handler => handler.Run(async context =>
@@ -95,7 +85,6 @@ app.MapProjectEndpoints()
.MapCharacterEndpoints() .MapCharacterEndpoints()
.MapChapterEndpoints() .MapChapterEndpoints()
.MapBeatEndpoints() .MapBeatEndpoints()
.MapSceneEndpoints()
.MapTagEndpoints() .MapTagEndpoints()
.MapOpenQuestionEndpoints() .MapOpenQuestionEndpoints()
.MapAgentEndpoints() .MapAgentEndpoints()
@@ -103,5 +92,4 @@ app.MapProjectEndpoints()
app.Run(); app.Run();
/// <summary>Exposed so the tests can spin the API up with WebApplicationFactory.</summary>
public partial class Program; public partial class Program;
+1 -3
View File
@@ -27,12 +27,11 @@ public class ProjectService(
p.Phase, p.Phase,
p.Characters.Count, p.Characters.Count,
p.Chapters.Count, p.Chapters.Count,
p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0, p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
p.UpdatedAt)) p.UpdatedAt))
.ToListAsync(ct); .ToListAsync(ct);
} }
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default) public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
@@ -92,7 +91,6 @@ public class ProjectService(
return project; return project;
} }
/// <summary>True if a project was deleted; false if no project had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default) public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
-45
View File
@@ -1,45 +0,0 @@
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
namespace Novelly.Api.Scenes;
/// <summary>
/// A scene inside a chapter. The goal/conflict/outcome trio is the unit the agent
/// works with when turning an outline into prose.
/// </summary>
public class Scene
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ChapterId { get; set; }
public Chapter? Chapter { get; set; }
/// <summary>Position within the chapter, 1-based.</summary>
public int SortOrder { get; set; }
public string Title { get; set; } = string.Empty;
public string? Summary { get; set; }
/// <summary>What the POV character is trying to achieve.</summary>
public string? Goal { get; set; }
/// <summary>What stands in the way.</summary>
public string? Conflict { get; set; }
/// <summary>How it lands — and what it costs.</summary>
public string? Outcome { get; set; }
public Guid? PovCharacterId { get; set; }
public Character? PovCharacter { get; set; }
public string? Location { get; set; }
/// <summary>The drafted prose, if any.</summary>
public string? Prose { get; set; }
public int WordCount { get; set; }
public DraftStatus Status { get; set; } = DraftStatus.Planned;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
-128
View File
@@ -1,128 +0,0 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Scenes;
public record SceneResponse(
Guid Id,
Guid ChapterId,
int SortOrder,
string Title,
string? Summary,
string? Goal,
string? Conflict,
string? Outcome,
Guid? PovCharacterId,
string? PovCharacterName,
string? Location,
string? Prose,
int WordCount,
DraftStatus Status,
DateTimeOffset UpdatedAt);
public record CreateSceneRequest(
string Title,
int? SortOrder = null,
string? Summary = null,
string? Goal = null,
string? Conflict = null,
string? Outcome = null,
Guid? PovCharacterId = null,
string? Location = null,
string? Prose = null,
DraftStatus Status = DraftStatus.Planned);
public class CreateSceneRequestValidator : IModelValidator<CreateSceneRequest>
{
public ValidationResult Validate(CreateSceneRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Title))
result.AddError("Title", "'Title' must not be empty.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result);
return result;
}
}
public record UpdateSceneRequest(
string? Title = null,
int? SortOrder = null,
string? Summary = null,
string? Goal = null,
string? Conflict = null,
string? Outcome = null,
Guid? PovCharacterId = null,
string? Location = null,
string? Prose = null,
DraftStatus? Status = null);
public class UpdateSceneRequestValidator : IModelValidator<UpdateSceneRequest>
{
public ValidationResult Validate(UpdateSceneRequest model)
{
var result = new ValidationResult();
if (model.Title is not null)
{
if (model.Title.Length == 0)
result.AddError("Title", "'Title' can not be cleared — a scene always needs one.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result);
return result;
}
}
file static class SceneValidation
{
public static void OptionalFields(
int? sortOrder, string? summary, string? goal, string? conflict, string? outcome, string? location, string? prose, ValidationResult result)
{
if (sortOrder is < 0)
result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
if (summary is { Length: > 20000 })
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
if (goal is { Length: > 20000 })
result.AddError("Goal", "'Goal' must be 20,000 characters or fewer.");
if (conflict is { Length: > 20000 })
result.AddError("Conflict", "'Conflict' must be 20,000 characters or fewer.");
if (outcome is { Length: > 20000 })
result.AddError("Outcome", "'Outcome' must be 20,000 characters or fewer.");
if (location is { Length: > 500 })
result.AddError("Location", "'Location' must be 500 characters or fewer.");
if (prose is { Length: > 100000 })
result.AddError("Prose", "'Prose' must be 100,000 characters or fewer.");
}
}
public static class SceneMapping
{
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,
s.Prose, s.WordCount, s.Status, s.UpdatedAt);
/// <summary>
/// Whitespace-delimited word count. Good enough for progress tracking, and it costs
/// nothing to recompute on every save.
/// </summary>
public static int CountWords(string? prose) =>
string.IsNullOrWhiteSpace(prose)
? 0
: prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
}
-51
View File
@@ -1,51 +0,0 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Scenes;
public static class SceneEndpoints
{
public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app)
{
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken 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 scene = await service.CreateAsync(chapterId, request, ct);
if (scene is null)
{
return Results.NotFound();
}
var created = scene.ToResponse();
return Results.Created($"/api/scenes/{created.Id}", created);
})
.WithSummary("Add a scene to a chapter.");
var scenes = app.MapGroup("/api/scenes").WithTags("Scenes")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
(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))?.ToResponse().ToApiResult())
.WithSummary("Update a scene. Sending prose recomputes the word count.");
scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a scene.");
return app;
}
}
-156
View File
@@ -1,156 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
namespace Novelly.Api.Scenes;
public class SceneService(
INovelDbContext db,
ILogger<SceneService> logger,
IModelValidator<CreateSceneRequest> createValidator,
IModelValidator<UpdateSceneRequest> updateValidator)
{
public async Task<IReadOnlyList<Scene>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
logger.LogInformation("Listing scenes for chapter {ChapterId}", chapterId);
return await Query()
.Where(s => s.ChapterId == chapterId)
.OrderBy(s => s.SortOrder)
.ToListAsync(ct);
}
/// <summary>Null when no scene has this id — a lookup miss is expected, not exceptional.</summary>
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);
}
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Scene?> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Creating scene {Title} for chapter {ChapterId}, prose length {ProseLength}", request.Title, chapterId, request.Prose?.Length ?? 0);
if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct))
{
logger.LogInformation("Rejected scene creation: chapter {ChapterId} not found", chapterId);
return null;
}
var scene = new Scene
{
ChapterId = chapterId,
Title = request.Title,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
Summary = request.Summary,
Goal = request.Goal,
Conflict = request.Conflict,
Outcome = request.Outcome,
PovCharacterId = request.PovCharacterId,
Location = request.Location,
Prose = request.Prose,
WordCount = SceneMapping.CountWords(request.Prose),
Status = request.Status
};
db.Scenes.Add(scene);
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))!;
}
public async Task<Scene?> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating scene {SceneId}, prose length {ProseLength}", id, request.Prose?.Length ?? 0);
var scene = await FindAsync(id, ct);
if (scene is null)
{
return null;
}
scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title;
scene.SortOrder = request.SortOrder ?? scene.SortOrder;
scene.Summary = Patch.Apply(scene.Summary, request.Summary);
scene.Goal = Patch.Apply(scene.Goal, request.Goal);
scene.Conflict = Patch.Apply(scene.Conflict, request.Conflict);
scene.Outcome = Patch.Apply(scene.Outcome, request.Outcome);
scene.PovCharacterId = request.PovCharacterId ?? scene.PovCharacterId;
scene.Location = Patch.Apply(scene.Location, request.Location);
scene.Status = request.Status ?? scene.Status;
if (request.Prose is not null)
{
scene.Prose = Patch.Apply(scene.Prose, request.Prose);
scene.WordCount = SceneMapping.CountWords(scene.Prose);
}
scene.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!;
}
/// <summary>True if a scene was deleted; false if no scene had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting scene {SceneId}", id);
var scene = await FindAsync(id, ct);
if (scene is null)
{
return false;
}
db.Scenes.Remove(scene);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
{
logger.LogDebug("Computing next sort order for chapter {ChapterId}", chapterId);
var max = await db.Scenes
.Where(s => s.ChapterId == chapterId)
.MaxAsync(s => (int?)s.SortOrder, ct);
return (max ?? 0) + 1;
}
private IQueryable<Scene> Query() => db.Scenes.Include(s => s.PovCharacter);
private async Task<Scene?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding scene {SceneId}", id);
var scene = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
if (scene is null)
{
logger.LogInformation("Scene {SceneId} not found", id);
}
else
{
logger.LogDebug("Found scene {SceneId}", id);
}
return scene;
}
}
+1 -10
View File
@@ -58,11 +58,6 @@ public class UpdateTagRequestValidator : IModelValidator<UpdateTagRequest>
} }
} }
/// <summary>
/// Everything carrying one tag, gathered in a single response. This is the whole point of
/// tags — seeing that a motif touches two characters, a chapter and four beats is what
/// makes them worth maintaining.
/// </summary>
public record TagReferencesResponse( public record TagReferencesResponse(
TagResponse Tag, TagResponse Tag,
IReadOnlyList<TaggedCharacterResponse> Characters, IReadOnlyList<TaggedCharacterResponse> Characters,
@@ -105,12 +100,8 @@ public static class TagMapping
b.Chapter?.Title ?? "(unknown chapter)", b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder, b.SortOrder,
b.Title, b.Title,
b.Character?.Name, b.Characters.Count > 0 ? string.Join(", ", b.Characters.OrderBy(c => c.Name).Select(c => c.Name)) : null,
b.WhatHappened))]); b.WhatHappened))]);
/// <summary>
/// Tags are matched case-insensitively but stored as first typed, so "Betrayal" and
/// "betrayal" resolve to one tag rather than quietly becoming two.
/// </summary>
public static string Normalise(string name) => name.Trim(); public static string Normalise(string name) => name.Trim();
} }
+1 -9
View File
@@ -26,7 +26,6 @@ public class TagService(
.ToListAsync(ct); .ToListAsync(ct);
} }
/// <summary>Everything in the project carrying this tag. Null when no tag has this id.</summary>
public async Task<Tag?> GetReferencesAsync(Guid tagId, CancellationToken ct = default) public async Task<Tag?> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
{ {
Guard.Default(tagId, nameof(tagId)); Guard.Default(tagId, nameof(tagId));
@@ -36,7 +35,7 @@ public class TagService(
var tag = await db.Tags var tag = await db.Tags
.Include(t => t.Characters) .Include(t => t.Characters)
.Include(t => t.Chapters) .Include(t => t.Chapters)
.Include(t => t.Beats).ThenInclude(b => b.Character) .Include(t => t.Beats).ThenInclude(b => b.Characters)
.Include(t => t.Beats).ThenInclude(b => b.Chapter) .Include(t => t.Beats).ThenInclude(b => b.Chapter)
.FirstOrDefaultAsync(t => t.Id == tagId, ct); .FirstOrDefaultAsync(t => t.Id == tagId, ct);
@@ -46,7 +45,6 @@ public class TagService(
return tag; return tag;
} }
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Tag?> 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.Default(projectId, nameof(projectId));
@@ -110,7 +108,6 @@ public class TagService(
return tag; return tag;
} }
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes. True if deleted.</summary>
public async Task<bool> DeleteAsync(Guid tagId, CancellationToken ct = default) public async Task<bool> DeleteAsync(Guid tagId, CancellationToken ct = default)
{ {
Guard.Default(tagId, nameof(tagId)); Guard.Default(tagId, nameof(tagId));
@@ -129,11 +126,6 @@ public class TagService(
return true; return true;
} }
/// <summary>
/// Turns a list of names into tag entities, creating any the project has not seen
/// before. Typing a new tag on a beat should just work rather than being a two-step
/// "create the tag, then apply it".
/// </summary>
internal async Task<List<Tag>> ResolveAsync( internal async Task<List<Tag>> ResolveAsync(
Guid projectId, IReadOnlyList<string> names, CancellationToken ct) Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
{ {
+10 -15
View File
@@ -9,8 +9,8 @@ public static class BeatTools
{ {
[McpServerTool(Name = "get_chapter_outline")] [McpServerTool(Name = "get_chapter_outline")]
[Description("Read a chapter's outline: its beats in order. Each beat is one row — a short " [Description("Read a chapter's outline: its beats in order. Each beat is one row — a short "
+ "title, whose beat it is, what happened, and what it sets up. The chapter's " + "title, who it belongs to, what happened, and what it sets up. The chapter's "
+ "summary paragraph sits on the chapter itself, via get_chapter.")] + "summary paragraph and drafted prose sit on the chapter itself, via get_chapter.")]
public static Task<CallToolResult> GetChapterOutline( public static Task<CallToolResult> GetChapterOutline(
NovelApiClient api, NovelApiClient api,
[Description("The chapter's id.")] Guid chapterId, [Description("The chapter's id.")] Guid chapterId,
@@ -26,34 +26,29 @@ public static class BeatTools
[Description("Three to five words naming the beat.")] string title, [Description("Three to five words naming the beat.")] string title,
CancellationToken ct, CancellationToken ct,
[Description("Position in the chapter. Appended to the end when omitted.")] int? sortOrder = null, [Description("Position in the chapter. Appended to the end when omitted.")] int? sortOrder = null,
[Description("Id of the character whose beat this is.")] Guid? characterId = null, [Description("Ids of the characters whose beat this is.")] Guid[]? characterIds = null,
[Description("The event itself.")] string? whatHappened = null, [Description("The event itself.")] string? whatHappened = null,
[Description("What it sets in motion — the hook into the next beat.")] string? whatsNext = null, [Description("What it sets in motion — the hook into the next beat.")] string? whatsNext = null,
[Description("Id of the scene this beat will be written into, if decided.")] Guid? sceneId = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) => [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
api.PostAsync($"/api/chapters/{chapterId}/beats", api.PostAsync($"/api/chapters/{chapterId}/beats",
new { title, sortOrder, characterId, whatHappened, whatsNext, sceneId, tags }, ct); new { title, sortOrder, characterIds, whatHappened, whatsNext, tags }, ct);
[McpServerTool(Name = "update_beat")] [McpServerTool(Name = "update_beat")]
[Description("Revise a beat. Only the fields you supply change. Supplying a tag list replaces " [Description("Revise a beat. Only the fields you supply change. Supplying a characterIds or "
+ "the beat's tags outright, so include the ones you want to keep. A character or " + "tag list replaces the beat's characters or tags outright — pass an empty list "
+ "scene id already set stays put unless you pass clearCharacter/clearScene — " + "to clear one, and include everything you want to keep.")]
+ "leaving the id null means 'don't touch it', not 'remove it'.")]
public static Task<CallToolResult> UpdateBeat( public static Task<CallToolResult> UpdateBeat(
NovelApiClient api, NovelApiClient api,
[Description("The beat's id.")] Guid beatId, [Description("The beat's id.")] Guid beatId,
CancellationToken ct, CancellationToken ct,
[Description("Three to five words naming the beat.")] string? title = null, [Description("Three to five words naming the beat.")] string? title = null,
[Description("Position in the chapter.")] int? sortOrder = null, [Description("Position in the chapter.")] int? sortOrder = null,
[Description("Id of the character whose beat this is.")] Guid? characterId = null, [Description("Ids of the characters whose beat this is. Replaces the existing list.")] Guid[]? characterIds = null,
[Description("The event itself.")] string? whatHappened = null, [Description("The event itself.")] string? whatHappened = null,
[Description("What it sets in motion.")] string? whatsNext = 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}", api.PatchAsync($"/api/beats/{beatId}",
new { title, sortOrder, characterId, whatHappened, whatsNext, sceneId, tags, clearCharacter, clearScene }, ct); new { title, sortOrder, characterIds, whatHappened, whatsNext, tags }, ct);
[McpServerTool(Name = "delete_beat")] [McpServerTool(Name = "delete_beat")]
[Description("Remove a beat from a chapter's outline. Confirm with the writer first.")] [Description("Remove a beat from a chapter's outline. Confirm with the writer first.")]
+9 -74
View File
@@ -8,7 +8,7 @@ namespace Novelly.Mcp.Tools;
public static class ManuscriptTools public static class ManuscriptTools
{ {
[McpServerTool(Name = "list_chapters")] [McpServerTool(Name = "list_chapters")]
[Description("List a project's chapters in manuscript order, with scene and word counts.")] [Description("List a project's chapters in manuscript order, with beat and word counts.")]
public static Task<CallToolResult> ListChapters( public static Task<CallToolResult> ListChapters(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The project's id.")] Guid projectId,
@@ -16,7 +16,7 @@ public static class ManuscriptTools
api.GetAsync($"/api/projects/{projectId}/chapters", ct); api.GetAsync($"/api/projects/{projectId}/chapters", ct);
[McpServerTool(Name = "get_chapter")] [McpServerTool(Name = "get_chapter")]
[Description("Read one chapter in full, including every scene and any drafted prose.")] [Description("Read one chapter in full: its outline (beats) and its drafted prose.")]
public static Task<CallToolResult> GetChapter( public static Task<CallToolResult> GetChapter(
NovelApiClient api, NovelApiClient api,
[Description("The chapter's id.")] Guid chapterId, [Description("The chapter's id.")] Guid chapterId,
@@ -36,6 +36,7 @@ public static class ManuscriptTools
[Description("Where and when the chapter takes place.")] string? setting = null, [Description("Where and when the chapter takes place.")] string? setting = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
[Description("Target length in words.")] int? targetWordCount = null, [Description("Target length in words.")] int? targetWordCount = null,
[Description("The chapter's drafted text, in markdown, if you are writing it now.")] string? prose = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) => [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
api.PostAsync($"/api/projects/{projectId}/chapters", new api.PostAsync($"/api/projects/{projectId}/chapters", new
{ {
@@ -46,11 +47,14 @@ public static class ManuscriptTools
setting, setting,
status = status ?? "Planned", status = status ?? "Planned",
targetWordCount, targetWordCount,
prose,
tags tags
}, ct); }, ct);
[McpServerTool(Name = "update_chapter")] [McpServerTool(Name = "update_chapter")]
[Description("Revise a chapter's title, number, summary, POV character, setting, notes or status.")] [Description("Revise a chapter's title, number, summary, POV character, setting, notes, status "
+ "or drafted prose. Use 'prose' to write or replace the chapter's draft text in "
+ "markdown; the word count is recomputed automatically.")]
public static Task<CallToolResult> UpdateChapter( public static Task<CallToolResult> UpdateChapter(
NovelApiClient api, NovelApiClient api,
[Description("The chapter's id.")] Guid chapterId, [Description("The chapter's id.")] Guid chapterId,
@@ -63,77 +67,8 @@ public static class ManuscriptTools
[Description("Anything else worth recording.")] string? notes = null, [Description("Anything else worth recording.")] string? notes = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
[Description("Target length in words.")] int? targetWordCount = null, [Description("Target length in words.")] int? targetWordCount = null,
[Description("The chapter's drafted text, in markdown.")] string? prose = 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) =>
api.PatchAsync($"/api/chapters/{chapterId}", api.PatchAsync($"/api/chapters/{chapterId}",
new { title, number, summary, povCharacterId, setting, notes, status, targetWordCount, tags }, ct); new { title, number, summary, povCharacterId, setting, notes, status, targetWordCount, prose, tags }, ct);
[McpServerTool(Name = "list_scenes")]
[Description("List a chapter's scenes in order.")]
public static Task<CallToolResult> ListScenes(
NovelApiClient api,
[Description("The chapter's id.")] Guid chapterId,
CancellationToken ct) =>
api.GetAsync($"/api/chapters/{chapterId}/scenes", ct);
[McpServerTool(Name = "create_scene")]
[Description("Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene "
+ "draftable later, so fill those in when there is enough to work with.")]
public static Task<CallToolResult> CreateScene(
NovelApiClient api,
[Description("The chapter's id.")] Guid chapterId,
[Description("Scene title.")] string title,
CancellationToken ct,
[Description("Position within the chapter. Appended to the end when omitted.")] int? sortOrder = null,
[Description("What happens in the scene.")] string? summary = null,
[Description("What the POV character is trying to achieve.")] string? goal = null,
[Description("What stands in the way.")] string? conflict = null,
[Description("How it lands, and what it costs.")] string? outcome = null,
[Description("Id of the point-of-view character.")] Guid? povCharacterId = null,
[Description("Where the scene takes place.")] string? location = null,
[Description("Drafted prose for the scene, if you are writing it now.")] string? prose = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null) =>
api.PostAsync($"/api/chapters/{chapterId}/scenes", new
{
title,
sortOrder,
summary,
goal,
conflict,
outcome,
povCharacterId,
location,
prose,
status = status ?? "Planned"
}, ct);
[McpServerTool(Name = "update_scene")]
[Description("Revise a scene. Supplying 'prose' writes or replaces the scene's draft text and "
+ "recomputes its word count.")]
public static Task<CallToolResult> UpdateScene(
NovelApiClient api,
[Description("The scene's id.")] Guid sceneId,
CancellationToken ct,
[Description("New title.")] string? title = null,
[Description("Position within the chapter.")] int? sortOrder = null,
[Description("What happens in the scene.")] string? summary = null,
[Description("What the POV character is trying to achieve.")] string? goal = null,
[Description("What stands in the way.")] string? conflict = null,
[Description("How it lands, and what it costs.")] string? outcome = null,
[Description("Id of the point-of-view character.")] Guid? povCharacterId = null,
[Description("Where the scene takes place.")] string? location = null,
[Description("Drafted prose for the scene.")] string? prose = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null) =>
api.PatchAsync($"/api/scenes/{sceneId}", new
{
title,
sortOrder,
summary,
goal,
conflict,
outcome,
povCharacterId,
location,
prose,
status
}, ct);
} }
+1122 -2
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,6 +13,7 @@
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.2" "react-router-dom": "^7.18.2"
}, },
"devDependencies": { "devDependencies": {
+7 -70
View File
@@ -16,7 +16,6 @@ import type {
OpenQuestion, OpenQuestion,
Project, Project,
ProjectSummary, ProjectSummary,
Scene,
TagReferences, TagReferences,
TagSummary, TagSummary,
} from './types' } from './types'
@@ -36,8 +35,6 @@ export const keys = {
importJob: (id: string) => ['imports', id] as const, importJob: (id: string) => ['imports', id] as const,
} }
// --- Projects ---------------------------------------------------------------
export const useProjects = () => export const useProjects = () =>
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') }) useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') })
@@ -72,8 +69,6 @@ export function useDeleteProject() {
}) })
} }
// --- Characters -------------------------------------------------------------
export const useCharacters = (projectId: string) => export const useCharacters = (projectId: string) =>
useQuery({ useQuery({
queryKey: keys.characters(projectId), queryKey: keys.characters(projectId),
@@ -112,10 +107,6 @@ export function useDeleteCharacter(projectId: string) {
}) })
} }
/**
* Every beat this character appears in, across the whole book. Kept separate from the
* dossier because it is derived from the outlines — what they actually do on the page.
*/
export const useCharacterBeats = (characterId: string | undefined) => export const useCharacterBeats = (characterId: string | undefined) =>
useQuery({ useQuery({
queryKey: keys.characterBeats(characterId ?? ''), queryKey: keys.characterBeats(characterId ?? ''),
@@ -123,8 +114,6 @@ export const useCharacterBeats = (characterId: string | undefined) =>
enabled: Boolean(characterId), enabled: Boolean(characterId),
}) })
// --- Character arcs ----------------------------------------------------------
export function useCreateArcStage(projectId: string) { export function useCreateArcStage(projectId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
@@ -160,12 +149,6 @@ export function useReorderArcStages(projectId: string) {
}) })
} }
// --- Open questions ----------------------------------------------------------
/**
* The project's undecided questions. Filters narrow to one chapter outline or character;
* resolved ones are left out unless asked for, since the list is about what is still open.
*/
export const useOpenQuestions = ( export const useOpenQuestions = (
projectId: string, projectId: string,
filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {}, filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {},
@@ -201,10 +184,6 @@ export function useUpdateQuestion(projectId: string) {
}) })
} }
/**
* Resolving can append the decision to the notes of whatever the question hangs off, so
* this invalidates the chapter and character caches as well as the question list.
*/
export function useResolveQuestion(projectId: string) { export function useResolveQuestion(projectId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
@@ -234,8 +213,6 @@ export function useDeleteQuestion(projectId: string) {
}) })
} }
// --- Tags --------------------------------------------------------------------
export const useTags = (projectId: string) => export const useTags = (projectId: string) =>
useQuery({ useQuery({
queryKey: keys.tags(projectId), queryKey: keys.tags(projectId),
@@ -258,10 +235,6 @@ export function useUpdateTag(projectId: string) {
}) })
} }
/**
* Deleting a tag strips it from every character, chapter and beat that carried it, so
* this invalidates the whole cache rather than trying to enumerate what moved.
*/
export function useDeleteTag() { export function useDeleteTag() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
@@ -270,13 +243,12 @@ export function useDeleteTag() {
}) })
} }
// --- Beats (a chapter's outline) ---------------------------------------------
export function useCreateBeat(chapterId: string, projectId: string) { export function useCreateBeat(chapterId: string, projectId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: Partial<Beat> & { title: string }) => mutationFn: (
api.post<Beat>(`/api/chapters/${chapterId}/beats`, body), body: Partial<Omit<Beat, 'tags' | 'characters'>> & { title: string; tags?: string[]; characterIds?: string[] },
) => api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }) qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(projectId) })
@@ -287,7 +259,10 @@ export function useCreateBeat(chapterId: string, projectId: string) {
export function useUpdateBeat(chapterId: string, projectId: string) { export function useUpdateBeat(chapterId: string, projectId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: Partial<Omit<Beat, 'tags'>> & { id: string; tags?: string[] }) => mutationFn: ({
id,
...body
}: Partial<Omit<Beat, 'tags' | 'characters'>> & { id: string; tags?: string[]; characterIds?: string[] }) =>
api.patch<Beat>(`/api/beats/${id}`, body), api.patch<Beat>(`/api/beats/${id}`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }) qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
@@ -313,8 +288,6 @@ export function useReorderBeats(chapterId: string) {
}) })
} }
// --- Chapters and scenes ----------------------------------------------------
export const useChapters = (projectId: string) => export const useChapters = (projectId: string) =>
useQuery({ useQuery({
queryKey: keys.chapters(projectId), queryKey: keys.chapters(projectId),
@@ -358,34 +331,6 @@ export function useDeleteChapter(projectId: string) {
}) })
} }
export function useCreateScene(chapterId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Partial<Scene> & { title: string }) =>
api.post<Scene>(`/api/chapters/${chapterId}/scenes`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }),
})
}
export function useUpdateScene(chapterId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: Partial<Scene> & { id: string }) =>
api.patch<Scene>(`/api/scenes/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }),
})
}
export function useDeleteScene(chapterId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/scenes/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }),
})
}
// --- Agent ------------------------------------------------------------------
export const useConversations = (projectId: string) => export const useConversations = (projectId: string) =>
useQuery({ useQuery({
queryKey: keys.conversations(projectId), queryKey: keys.conversations(projectId),
@@ -407,7 +352,6 @@ export function useSendAgentMessage(projectId: string) {
onSuccess: (turn) => { onSuccess: (turn) => {
qc.invalidateQueries({ queryKey: keys.conversations(projectId) }) qc.invalidateQueries({ queryKey: keys.conversations(projectId) })
qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) }) qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) })
// The agent edits project data through its tools, so anything on screen may be stale.
qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
@@ -417,8 +361,6 @@ export function useSendAgentMessage(projectId: string) {
}) })
} }
// --- Outline import ----------------------------------------------------------
export function useInspectImport() { export function useInspectImport() {
return useMutation({ return useMutation({
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }), mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
@@ -434,11 +376,6 @@ export function useStartImport() {
const terminalImportStatuses: ImportJobStatus[] = ['Completed', 'Failed', 'Paused'] 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) { export function useImportJob(jobId: string | undefined) {
return useQuery({ return useQuery({
queryKey: keys.importJob(jobId ?? ''), queryKey: keys.importJob(jobId ?? ''),
+9 -37
View File
@@ -1,4 +1,3 @@
// Mirrors the DTOs in the Novelly.Api feature folders. Enums travel as their names.
export type CharacterRole = export type CharacterRole =
| 'Protagonist' | 'Protagonist'
@@ -21,7 +20,6 @@ export const characterRoles: CharacterRole[] = [
'Foil', 'Foil',
] ]
/** How much of the book a character carries. Separate from the part they play. */
export type CharacterImportance = 'Main' | 'Supporting' export type CharacterImportance = 'Main' | 'Supporting'
export const characterImportances: CharacterImportance[] = ['Main', 'Supporting'] export const characterImportances: CharacterImportance[] = ['Main', 'Supporting']
@@ -30,7 +28,6 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final'] export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
/** Where a novel is in its lifecycle, from first notes to a finished manuscript. */
export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete' export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete'] export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
@@ -92,18 +89,19 @@ export interface TagReferences {
}[] }[]
} }
/** One row of a chapter's outline. Flat and ordered — no nesting. */ export interface BeatCharacter {
id: string
name: string
}
export interface Beat { export interface Beat {
id: string id: string
chapterId: string chapterId: string
sortOrder: number sortOrder: number
title: string title: string
characterId: string | null characters: BeatCharacter[]
characterName: string | null
whatHappened: string | null whatHappened: string | null
whatsNext: string | null whatsNext: string | null
sceneId: string | null
sceneTitle: string | null
tags: Tag[] tags: Tag[]
updatedAt: string updatedAt: string
} }
@@ -116,7 +114,6 @@ export interface Relationship {
description: string | null description: string | null
} }
/** One step in a main character's arc. Flat and ordered, like a chapter's beats. */
export interface ArcStage { export interface ArcStage {
id: string id: string
characterId: string characterId: string
@@ -129,7 +126,6 @@ export interface ArcStage {
updatedAt: string updatedAt: string
} }
/** A beat a character appears in, carrying its chapter so the page can link into the outline. */
export interface CharacterBeat { export interface CharacterBeat {
id: string id: string
chapterId: string chapterId: string
@@ -139,8 +135,6 @@ export interface CharacterBeat {
title: string title: string
whatHappened: string | null whatHappened: string | null
whatsNext: string | null whatsNext: string | null
sceneId: string | null
sceneTitle: string | null
} }
export interface Character { export interface Character {
@@ -168,24 +162,6 @@ export interface Character {
updatedAt: string updatedAt: string
} }
export interface Scene {
id: string
chapterId: string
sortOrder: number
title: string
summary: string | null
goal: string | null
conflict: string | null
outcome: string | null
povCharacterId: string | null
povCharacterName: string | null
location: string | null
prose: string | null
wordCount: number
status: DraftStatus
updatedAt: string
}
export interface ChapterSummary { export interface ChapterSummary {
id: string id: string
projectId: string projectId: string
@@ -198,20 +174,19 @@ export interface ChapterSummary {
status: DraftStatus status: DraftStatus
targetWordCount: number | null targetWordCount: number | null
beatCount: number beatCount: number
sceneCount: number
wordCount: number wordCount: number
tags: Tag[] tags: Tag[]
updatedAt: string updatedAt: string
} }
export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'sceneCount' | 'wordCount'> { export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'wordCount'> {
notes: string | null notes: string | null
beats: Beat[] beats: Beat[]
scenes: Scene[] prose: string | null
wordCount: number
updatedAt: string updatedAt: string
} }
/** Something the writer has not decided yet, hung off a chapter outline and/or a character. */
export interface OpenQuestion { export interface OpenQuestion {
id: string id: string
projectId: string projectId: string
@@ -260,8 +235,6 @@ export interface AgentTurn {
message: AgentMessage message: AgentMessage
} }
// --- Outline import -----------------------------------------------------------
export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | 'Paused' export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | 'Paused'
export interface ImportJob { export interface ImportJob {
@@ -276,7 +249,6 @@ export interface ImportJob {
updatedAt: 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 type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete'
export interface ImportInspection { export interface ImportInspection {
@@ -0,0 +1,78 @@
import { useState } from 'react'
import type { BeatCharacter } from '../api/types'
export function CharacterChip({ character, onRemove }: { character: BeatCharacter; onRemove?: () => void }) {
return (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }}
>
{character.name}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="opacity-60 transition hover:opacity-100"
aria-label={`Remove ${character.name}`}
>
</button>
)}
</span>
)
}
export function CharacterMultiSelect({
selected,
options,
onChange,
}: {
selected: BeatCharacter[]
options: { id: string; name: string }[]
onChange: (ids: string[]) => void
}) {
const [draft, setDraft] = useState('')
const listId = 'character-multiselect-options'
const add = () => {
const name = draft.trim()
setDraft('')
if (!name) return
const match = options.find((o) => o.name.toLowerCase() === name.toLowerCase())
if (match && !selected.some((c) => c.id === match.id)) {
onChange([...selected.map((c) => c.id), match.id])
}
}
const remove = (id: string) => onChange(selected.filter((c) => c.id !== id).map((c) => c.id))
const unused = options.filter((o) => !selected.some((c) => c.id === o.id))
return (
<div className="flex flex-wrap items-center gap-1.5">
{selected.map((character) => (
<CharacterChip key={character.id} character={character} onRemove={() => remove(character.id)} />
))}
<input
className="input w-28 flex-1 px-2 py-0.5 text-xs"
value={draft}
list={listId}
placeholder="Add character…"
onChange={(e) => setDraft(e.target.value)}
onBlur={add}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add()
}
}}
/>
<datalist id={listId}>
{unused.map((o) => (
<option key={o.id} value={o.name} />
))}
</datalist>
</div>
)
}
@@ -0,0 +1,72 @@
import { useEffect, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
export function MarkdownEditor({
value,
onCommit,
placeholder,
rows = 24,
}: {
value: string | null
onCommit: (next: string) => void
placeholder?: string
rows?: number
}) {
const [draft, setDraft] = useState(value ?? '')
const [mode, setMode] = useState<'write' | 'preview'>('write')
const committed = useRef(value ?? '')
useEffect(() => {
const incoming = value ?? ''
if (incoming !== committed.current) {
committed.current = incoming
setDraft(incoming)
}
}, [value])
const commit = () => {
if (draft !== committed.current) {
committed.current = draft
onCommit(draft)
}
}
return (
<div>
<div className="mb-2 flex justify-end gap-1">
<button
type="button"
className={`btn px-2 py-1 text-xs ${mode === 'write' ? '' : 'opacity-60'}`}
onClick={() => setMode('write')}
>
Write
</button>
<button
type="button"
className={`btn px-2 py-1 text-xs ${mode === 'preview' ? '' : 'opacity-60'}`}
onClick={() => {
commit()
setMode('preview')
}}
>
Preview
</button>
</div>
{mode === 'write' ? (
<textarea
className="input font-mono text-sm"
rows={rows}
value={draft}
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
/>
) : (
<div className="markdown-preview card min-h-[20rem] p-4">
{draft.trim() ? <ReactMarkdown>{draft}</ReactMarkdown> : <p className="muted">Nothing written yet.</p>}
</div>
)}
</div>
)
}
+53 -4
View File
@@ -6,10 +6,6 @@
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
} }
/*
* Warm paper light theme, cool ink dark theme. Colours are declared as variables so
* every surface, border and accent moves together when the scheme flips.
*/
:root { :root {
--paper: #faf7f0; --paper: #faf7f0;
--surface: #ffffff; --surface: #ffffff;
@@ -132,4 +128,57 @@ body {
font-family: var(--font-sans); font-family: var(--font-sans);
@apply text-[1.0625rem] leading-relaxed; @apply text-[1.0625rem] leading-relaxed;
} }
.markdown-preview {
font-family: var(--font-sans);
@apply text-[1.0625rem] leading-relaxed;
}
.markdown-preview :is(h1, h2, h3, h4) {
@apply mt-5 mb-2 font-semibold first:mt-0;
}
.markdown-preview h1 {
@apply text-2xl;
}
.markdown-preview h2 {
@apply text-xl;
}
.markdown-preview h3 {
@apply text-lg;
}
.markdown-preview p {
@apply mb-4;
}
.markdown-preview :is(ul, ol) {
@apply mb-4 ml-5;
}
.markdown-preview ul {
@apply list-disc;
}
.markdown-preview ol {
@apply list-decimal;
}
.markdown-preview blockquote {
@apply my-4 border-l-2 pl-4 italic;
border-color: var(--line);
color: var(--ink-muted);
}
.markdown-preview code {
@apply rounded px-1 py-0.5 text-sm;
background: var(--surface-sunken);
}
.markdown-preview hr {
@apply my-6;
border-color: var(--line);
}
} }
+2 -2
View File
@@ -75,8 +75,8 @@ export default function AgentPage() {
<div className="card p-6"> <div className="card p-6">
<h2 className="text-lg font-semibold">Your writing partner</h2> <h2 className="text-lg font-semibold">Your writing partner</h2>
<p className="mt-1 text-sm muted"> <p className="mt-1 text-sm muted">
It can read and edit the brief, the outline, character dossiers, chapters and It can read and edit the brief, the outline, character dossiers and chapter
scenes the same data you see in the other tabs. prose the same data you see in the other tabs.
</p> </p>
<div className="mt-4 grid gap-2"> <div className="mt-4 grid gap-2">
{starters.map((starter) => ( {starters.map((starter) => (
+94 -151
View File
@@ -4,22 +4,23 @@ import {
useChapter, useChapter,
useCharacters, useCharacters,
useCreateBeat, useCreateBeat,
useCreateScene,
useDeleteBeat, useDeleteBeat,
useDeleteChapter, useDeleteChapter,
useDeleteScene,
useReorderBeats, useReorderBeats,
useTags, useTags,
useUpdateBeat, useUpdateBeat,
useUpdateChapter, useUpdateChapter,
useUpdateScene,
} from '../api/hooks' } from '../api/hooks'
import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types' import { draftStatuses, type Beat, type Chapter } from '../api/types'
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui' import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { TagChip, TagEditor } from '../components/TagEditor' import { TagChip, TagEditor } from '../components/TagEditor'
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
import { MarkdownEditor } from '../components/MarkdownEditor'
import { OpenQuestions } from '../components/OpenQuestions' import { OpenQuestions } from '../components/OpenQuestions'
import { useHotkey } from '../keyboard/HotkeysContext' import { useHotkey } from '../keyboard/HotkeysContext'
type ChapterTab = 'outline' | 'prose'
export default function ChapterPage() { export default function ChapterPage() {
const { projectId = '', chapterId = '' } = useParams() const { projectId = '', chapterId = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@@ -29,10 +30,9 @@ export default function ChapterPage() {
const update = useUpdateChapter(projectId) const update = useUpdateChapter(projectId)
const remove = useDeleteChapter(projectId) const remove = useDeleteChapter(projectId)
const createBeat = useCreateBeat(chapterId, projectId) const createBeat = useCreateBeat(chapterId, projectId)
const createScene = useCreateScene(chapterId) const [tab, setTab] = useState<ChapterTab>('outline')
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' }) useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
useHotkey('s', 'Add scene', () => createScene.mutate({ title: 'New scene' }), { group: 'Chapter' })
if (isPending) return <Spinner label="Loading chapter" /> if (isPending) return <Spinner label="Loading chapter" />
if (error) return <ErrorNote error={error} /> if (error) return <ErrorNote error={error} />
@@ -113,8 +113,7 @@ export default function ChapterPage() {
<div className="mt-4 flex items-end justify-between gap-4"> <div className="mt-4 flex items-end justify-between gap-4">
<div className="text-sm muted"> <div className="text-sm muted">
{chapter.beats.length} beats · {chapter.scenes.length} scenes ·{' '} {chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
{chapter.scenes.reduce((sum, s) => sum + s.wordCount, 0).toLocaleString()} words
</div> </div>
<button <button
className="btn" className="btn"
@@ -132,67 +131,74 @@ export default function ChapterPage() {
</div> </div>
</section> </section>
{/* The outline: a paragraph, then the beat table. */} <div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
<section className="mb-8"> {(
<h2 className="mb-1 text-lg font-semibold">Outline</h2> [
<p className="mb-3 text-sm muted"> ['outline', 'Outline'],
A paragraph on what the chapter does, then the beats that carry it. ['prose', 'Text'],
</p> ] as const
).map(([value, tabLabel]) => (
<div className="card mb-4 p-4">
<AutoField
value={chapter.summary}
multiline
rows={5}
serif
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
onCommit={(summary) => patch({ summary })}
/>
</div>
<BeatTable
chapter={chapter}
projectId={projectId}
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
suggestions={suggestions}
/>
<button
className="btn btn-primary mt-3"
onClick={() => createBeat.mutate({ title: 'New beat' })}
disabled={createBeat.isPending}
>
Add beat
</button>
{createBeat.error && (
<div className="mt-2">
<ErrorNote error={createBeat.error} />
</div>
)}
</section>
{/* The prose layer. */}
<section>
<div className="mb-3 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Scenes</h2>
<p className="text-sm muted">Where the prose lives. Beats can be grouped under these.</p>
</div>
<button <button
className="btn" key={value}
onClick={() => createScene.mutate({ title: 'New scene' })} onClick={() => setTab(value)}
disabled={createScene.isPending} className="border-b-2 px-3 py-2 text-sm font-medium transition"
style={{
borderColor: tab === value ? 'var(--accent)' : 'transparent',
color: tab === value ? 'var(--accent)' : 'var(--ink-muted)',
}}
> >
Add scene {tabLabel}
</button> </button>
</div> ))}
</div>
<ul className="grid gap-3"> {tab === 'outline' ? (
{chapter.scenes.map((scene) => ( <section className="mb-8">
<SceneCard key={scene.id} chapterId={chapter.id} scene={scene} /> <p className="mb-3 text-sm muted">
))} A paragraph on what the chapter does, then the beats that carry it.
</ul> </p>
</section>
<div className="card mb-4 p-4">
<AutoField
value={chapter.summary}
multiline
rows={5}
serif
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
onCommit={(summary) => patch({ summary })}
/>
</div>
<BeatTable
chapter={chapter}
projectId={projectId}
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
suggestions={suggestions}
/>
<button
className="btn btn-primary mt-3"
onClick={() => createBeat.mutate({ title: 'New beat' })}
disabled={createBeat.isPending}
>
Add beat
</button>
{createBeat.error && (
<div className="mt-2">
<ErrorNote error={createBeat.error} />
</div>
)}
</section>
) : (
<section className="mb-8">
<p className="mb-3 text-sm muted">The chapter's drafted text, in markdown.</p>
<MarkdownEditor
value={chapter.prose}
placeholder="Start writing the chapter."
onCommit={(prose) => patch({ prose })}
/>
</section>
)}
<section className="card mt-6 p-5"> <section className="card mt-6 p-5">
<h3 className="mb-1 text-sm font-semibold">Notes</h3> <h3 className="mb-1 text-sm font-semibold">Notes</h3>
@@ -247,7 +253,7 @@ function BeatTable({
reorder.mutate(ids) reorder.mutate(ids)
} }
const patch = (id: string, body: Partial<Omit<Beat, 'tags'>> & { tags?: string[] }) => const patch = (id: string, body: Partial<Omit<Beat, 'tags' | 'characters'>> & { tags?: string[]; characterIds?: string[] }) =>
update.mutate({ id, ...body }) update.mutate({ id, ...body })
return ( return (
@@ -256,15 +262,14 @@ function BeatTable({
<thead> <thead>
<tr style={{ borderBottom: '1px solid var(--line)' }}> <tr style={{ borderBottom: '1px solid var(--line)' }}>
<th className="w-10 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th> <th className="w-10 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
<th className="w-[14%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th> <th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
<th className="w-[10%] px-2 py-2 text-left text-xs font-semibold uppercase muted"> <th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
Character Characters
</th> </th>
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted"> <th className="w-[32%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
What happened What happened
</th> </th>
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted">What&apos;s next</th> <th className="w-[32%] px-2 py-2 text-left text-xs font-semibold uppercase muted">What&apos;s next</th>
<th className="w-[12%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Scene</th>
<th className="w-8" /> <th className="w-8" />
</tr> </tr>
</thead> </thead>
@@ -320,18 +325,11 @@ function BeatTable({
</td> </td>
<td className="px-2 py-2 align-top"> <td className="px-2 py-2 align-top">
<select <CharacterMultiSelect
className="input" selected={beat.characters}
value={beat.characterName ?? '—'} options={characters}
onChange={(e) => { onChange={(characterIds) => patch(beat.id, { characterIds })}
const match = characters.find((c) => c.name === e.target.value) />
patch(beat.id, { characterId: match?.id ?? null })
}}
>
{['—', ...characters.map((c) => c.name)].map((name) => (
<option key={name}>{name}</option>
))}
</select>
</td> </td>
<td className="px-2 py-2 align-top"> <td className="px-2 py-2 align-top">
@@ -356,21 +354,6 @@ function BeatTable({
/> />
</td> </td>
<td className="px-2 py-2 align-top">
<select
className="input"
value={beat.sceneTitle ?? '—'}
onChange={(e) => {
const match = chapter.scenes.find((s) => s.title === e.target.value)
patch(beat.id, { sceneId: match?.id ?? null })
}}
>
{['—', ...chapter.scenes.map((s) => s.title)].map((title) => (
<option key={title}>{title}</option>
))}
</select>
</td>
<td className="px-2 py-2 align-top"> <td className="px-2 py-2 align-top">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<button <button
@@ -413,7 +396,17 @@ function BeatTable({
)} )}
</td> </td>
<td className="px-2 py-2 align-top">{beat.characterName ?? <span className="muted"></span>}</td> <td className="px-2 py-2 align-top">
{beat.characters.length > 0 ? (
<div className="flex flex-wrap gap-1">
{beat.characters.map((character) => (
<CharacterChip key={character.id} character={character} />
))}
</div>
) : (
<span className="muted"></span>
)}
</td>
<td className="px-2 py-2 align-top whitespace-pre-wrap break-words"> <td className="px-2 py-2 align-top whitespace-pre-wrap break-words">
{beat.whatHappened ?? <span className="muted"></span>} {beat.whatHappened ?? <span className="muted"></span>}
@@ -423,8 +416,6 @@ function BeatTable({
{beat.whatsNext ?? <span className="muted"></span>} {beat.whatsNext ?? <span className="muted"></span>}
</td> </td>
<td className="px-2 py-2 align-top">{beat.sceneTitle ?? <span className="muted"></span>}</td>
<td className="px-2 py-2 align-top" /> <td className="px-2 py-2 align-top" />
</tr> </tr>
), ),
@@ -434,51 +425,3 @@ function BeatTable({
</div> </div>
) )
} }
function SceneCard({ chapterId, scene }: { chapterId: string; scene: Scene }) {
const [showProse, setShowProse] = useState(Boolean(scene.prose))
const update = useUpdateScene(chapterId)
const remove = useDeleteScene(chapterId)
const patch = (body: Partial<Scene>) => update.mutate({ id: scene.id, ...body })
return (
<li className="card p-4">
<div className="grid gap-3 sm:grid-cols-[1fr_9rem]">
<AutoField value={scene.title} onCommit={(title) => title.trim() && patch({ title })} />
<Select value={scene.status} options={draftStatuses} onChange={(status) => patch({ status })} />
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs muted">
<div className="flex items-center gap-3">
<StatusBadge status={scene.status} />
<span>{scene.wordCount.toLocaleString()} words</span>
</div>
<div className="flex gap-2">
<button className="btn px-2 py-1 text-xs" onClick={() => setShowProse((v) => !v)}>
{showProse ? 'Hide prose' : 'Write prose'}
</button>
<button
className="btn px-2 py-1 text-xs"
style={{ color: 'var(--accent)' }}
onClick={() => confirm(`Delete scene “${scene.title}”?`) && remove.mutate(scene.id)}
>
Delete
</button>
</div>
</div>
{showProse && (
<div className="mt-3">
<AutoField
value={scene.prose}
multiline
rows={16}
serif
placeholder="The scene itself. The beats grouped under it are the plan; this is the prose."
onCommit={(prose) => patch({ prose })}
/>
</div>
)}
</li>
)
}
@@ -59,7 +59,6 @@ export default function ChaptersPage() {
<div className="flex shrink-0 items-center gap-3 text-xs muted"> <div className="flex shrink-0 items-center gap-3 text-xs muted">
{chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>} {chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>}
<span>{chapter.beatCount} beats</span> <span>{chapter.beatCount} beats</span>
<span>{chapter.sceneCount} scenes</span>
<span>{chapter.wordCount.toLocaleString()} words</span> <span>{chapter.wordCount.toLocaleString()} words</span>
<StatusBadge status={chapter.status} /> <StatusBadge status={chapter.status} />
</div> </div>
@@ -10,9 +10,6 @@ public class AnthropicClientTests
{ {
[Test] [Test]
public void Constructing_without_a_key_does_not_throw() => public void Constructing_without_a_key_does_not_throw() =>
// The agent service takes this as a dependency and also serves read-only endpoints
// (listing conversations, reading a transcript). Throwing at construction would
// take those down on any install that has not configured a key yet.
Assert.That( Assert.That(
() => new AnthropicAgentModelClient(Options.Create(new AgentOptions()), NullLogger<AnthropicAgentModelClient>.Instance), () => new AnthropicAgentModelClient(Options.Create(new AgentOptions()), NullLogger<AnthropicAgentModelClient>.Instance),
Throws.Nothing); Throws.Nothing);
+11 -52
View File
@@ -1,9 +1,7 @@
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Scenes;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
@@ -75,22 +73,20 @@ public class BeatServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task A_beat_resolves_its_character_and_scene_names() public async Task A_beat_can_carry_several_characters()
{ {
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn")); var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest( var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas", "She burns the atlas",
CharacterId: ines.Id, CharacterIds: [ines.Id, mara.Id],
WhatHappened: "The pages go up faster than she expected.", WhatHappened: "The pages go up faster than she expected.",
WhatsNext: "Nothing to navigate by but memory.", WhatsNext: "Nothing to navigate by but memory."));
SceneId: scene.Id));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(beat.Character!.Name, Is.EqualTo("Ines")); Assert.That(beat.Characters.Select(c => c.Name), Is.EquivalentTo(new[] { "Ines", "Mara" }));
Assert.That(beat.Scene!.Title, Is.EqualTo("The dock at dawn"));
Assert.That(beat.WhatHappened, Does.Contain("faster than she expected")); Assert.That(beat.WhatHappened, Does.Contain("faster than she expected"));
}); });
} }
@@ -103,41 +99,10 @@ public class BeatServiceTests : ServiceTestFixture
Assert.That( Assert.That(
async () => await Beats.CreateAsync( async () => await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id)), _chapterId, new CreateBeatRequest("A beat", CharacterIds: [stranger.Id])),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project")); Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
} }
[Test]
public async Task A_beat_cannot_be_grouped_under_a_scene_from_another_chapter()
{
var elsewhere = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Elsewhere"));
var scene = await Scenes.CreateAsync(elsewhere.Id, new CreateSceneRequest("Another scene"));
Assert.That(
async () => await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same chapter"));
}
[Test]
public async Task Deleting_a_scene_leaves_its_beats_alone()
{
var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("She burns the atlas", SceneId: scene.Id));
await Scenes.DeleteAsync(scene.Id);
// The plan outlives a decision about prose — the beat is simply ungrouped.
var survivor = (await Beats.GetAsync(beat.Id))!;
Assert.Multiple(() =>
{
Assert.That(survivor.SceneId, Is.Null);
Assert.That(survivor.Title, Is.EqualTo("She burns the atlas"));
});
}
[Test] [Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{ {
@@ -164,23 +129,17 @@ public class BeatServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task ClearCharacter_and_ClearScene_detach_the_reference_since_a_null_id_means_leave_it_alone() public async Task An_empty_CharacterIds_list_clears_a_beats_characters_since_null_means_leave_it_alone()
{ {
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); 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( var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas", CharacterId: ines.Id, SceneId: scene.Id)); "She burns the atlas", CharacterIds: [ines.Id]));
var untouched = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She burns it")))!; var untouched = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She burns it")))!;
Assert.That(untouched.Character!.Name, Is.EqualTo("Ines")); Assert.That(untouched.Characters.Select(c => c.Name), Is.EqualTo(new[] { "Ines" }));
var cleared = (await Beats.UpdateAsync( var cleared = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(CharacterIds: [])))!;
beat.Id, new UpdateBeatRequest(ClearCharacter: true, ClearScene: true)))!;
Assert.Multiple(() => Assert.That(cleared.Characters, Is.Empty);
{
Assert.That(cleared.Character, Is.Null);
Assert.That(cleared.Scene, Is.Null);
});
} }
} }
@@ -2,14 +2,8 @@ using Microsoft.Extensions.Logging;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
/// <summary>Records every entry logged through it, so tests can assert on what a service logged.</summary>
public record CapturedLogEntry(LogLevel Level, string Message, Exception? Exception); public record CapturedLogEntry(LogLevel Level, string Message, Exception? Exception);
/// <summary>
/// A test double for <see cref="ILogger{TCategoryName}"/> that captures entries instead of
/// writing them anywhere, so tests can assert a service logged at the right level with the
/// right values without standing up a real sink.
/// </summary>
public class CapturingLogger<T> : ILogger<T> public class CapturingLogger<T> : ILogger<T>
{ {
public List<CapturedLogEntry> Entries { get; } = []; public List<CapturedLogEntry> Entries { get; } = [];
+3 -6
View File
@@ -64,8 +64,6 @@ public class CharacterArcTests : ServiceTestFixture
[Test] [Test]
public async Task Within_a_group_the_lead_comes_before_the_second_lead() public async Task Within_a_group_the_lead_comes_before_the_second_lead()
{ {
// Both enums are stored as text, so ordering them in SQL orders the spelling and
// "Deuteragonist" beats "Protagonist" — burying the character the book is about.
await Characters.CreateAsync(_projectId, new CreateCharacterRequest( await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
"Mara", CharacterRole.Deuteragonist, CharacterImportance.Main)); "Mara", CharacterRole.Deuteragonist, CharacterImportance.Main));
await Characters.CreateAsync(_projectId, new CreateCharacterRequest( await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
@@ -162,7 +160,6 @@ public class CharacterArcTests : ServiceTestFixture
await Chapters.DeleteAsync(chapter.Id); await Chapters.DeleteAsync(chapter.Id);
// How a character changes outlives a decision about where the chapter break falls.
var survivor = (await Arcs.GetAsync(stage.Id))!; var survivor = (await Arcs.GetAsync(stage.Id))!;
Assert.Multiple(() => Assert.Multiple(() =>
@@ -195,9 +192,9 @@ public class CharacterArcTests : ServiceTestFixture
var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("First", Number: 1)); var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("First", Number: 1));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterId: _characterId)); await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterIds: [_characterId]));
await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterId: _characterId)); await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterIds: [_characterId]));
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterId: mara.Id)); await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterIds: [mara.Id]));
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat")); await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat"));
var beats = (await Beats.ListForCharacterAsync(_characterId))!; var beats = (await Beats.ListForCharacterAsync(_characterId))!;
@@ -5,11 +5,6 @@ using Novelly.Api.Projects;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
/// <summary>
/// Covers the exception-handling rework: a missing entity is an ordinary result, not a
/// thrown exception; <see cref="Guard"/> rejects missing required arguments; and a
/// service re-validates a request even when a direct caller skips the API's own filter.
/// </summary>
[TestFixture] [TestFixture]
public class ExceptionHandlingTests : ServiceTestFixture public class ExceptionHandlingTests : ServiceTestFixture
{ {
@@ -64,8 +64,6 @@ public class ImportAgentToolsetTests : ServiceTestFixture
[Test] [Test]
public async Task Write_ledger_can_only_ever_touch_the_ledger_file_no_matter_what_path_is_asked_for() 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"]}""" })); await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["project"]}""" }));
Assert.Multiple(() => Assert.Multiple(() =>
@@ -105,7 +105,6 @@ public class ImportServiceTests : ServiceTestFixture
Assert.That(second.Id, Is.EqualTo(first.Id)); 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.True);
Assert.That(_queue.Reader.TryRead(out _), Is.False); Assert.That(_queue.Reader.TryRead(out _), Is.False);
} }
+7 -19
View File
@@ -4,15 +4,9 @@ using Novelly.Api.Agent;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Scenes;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
/// <summary>
/// Covers the list endpoints, which sort and aggregate in SQL rather than in memory.
/// SQLite is fussier than the in-memory provider about what it will translate — ordering
/// by a DateTimeOffset, for one — so these have to run against real SQLite to be worth anything.
/// </summary>
[TestFixture] [TestFixture]
public class ListingTests : ServiceTestFixture public class ListingTests : ServiceTestFixture
{ {
@@ -22,7 +16,6 @@ public class ListingTests : ServiceTestFixture
var older = await Projects.CreateAsync(new CreateProjectRequest("Older Book")); var older = await Projects.CreateAsync(new CreateProjectRequest("Older Book"));
var newer = await Projects.CreateAsync(new CreateProjectRequest("Newer Book")); var newer = await Projects.CreateAsync(new CreateProjectRequest("Newer Book"));
// Touching the older project should float it to the top.
await Projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised.")); await Projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised."));
var listed = await Projects.ListAsync(); var listed = await Projects.ListAsync();
@@ -41,10 +34,8 @@ public class ListingTests : ServiceTestFixture
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines")); await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara")); await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara"));
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall")); await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall", Prose: "One two three"));
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour")); await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour", Prose: "Four five"));
await Scenes.CreateAsync(first.Id, new CreateSceneRequest("Dawn", Prose: "One two three"));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("Dusk", Prose: "Four five"));
var summary = (await Projects.ListAsync()).Single(); var summary = (await Projects.ListAsync()).Single();
@@ -71,22 +62,19 @@ public class ListingTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Chapters_are_listed_in_manuscript_order_with_scene_totals() public async Task Chapters_are_listed_in_manuscript_order_with_word_counts()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2)); var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2, Prose: "One two three"));
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1)); var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("A", Prose: "One two"));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("B", Prose: "Three"));
var listed = await Chapters.ListAsync(project.Id); var listed = await Chapters.ListAsync(project.Id);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" })); Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" }));
Assert.That(listed.Single(c => c.Id == second.Id).Scenes, Has.Count.EqualTo(2)); Assert.That(listed.Single(c => c.Id == second.Id).WordCount, Is.EqualTo(3));
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).WordCount, Is.EqualTo(0));
Assert.That(listed.Single(c => c.Id == first.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(0));
}); });
} }
@@ -97,7 +85,7 @@ public class ListingTests : ServiceTestFixture
var agent = new NovelAgentService( var agent = new NovelAgentService(
Db.Context, Db.Context,
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance), new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance),
Options.Create(new AgentOptions()), Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance, NullLogger<NovelAgentService>.Instance,
new SendAgentMessageRequestValidator()); new SendAgentMessageRequestValidator());
+1 -5
View File
@@ -7,10 +7,6 @@ using Novelly.Api.Projects;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
/// <summary>
/// Covers the logging behaviour added across the services: a warning fires before a
/// not-found is thrown, and prose bodies never leak into a log message.
/// </summary>
[TestFixture] [TestFixture]
public class LoggingTests : ServiceTestFixture public class LoggingTests : ServiceTestFixture
{ {
@@ -82,7 +78,7 @@ public class LoggingTests : ServiceTestFixture
BeatLogs.Entries.Clear(); BeatLogs.Entries.Clear();
Assert.That( Assert.That(
() => Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Arrival", CharacterId: foreignCharacter.Id)), () => Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Arrival", CharacterIds: [foreignCharacter.Id])),
Throws.TypeOf<InvalidOperationException>()); Throws.TypeOf<InvalidOperationException>());
Assert.Multiple(() => Assert.Multiple(() =>
@@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
private NovelAgentToolset _toolset = null!; private NovelAgentToolset _toolset = null!;
protected override void OnSetUp() => protected override void OnSetUp() =>
_toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance); _toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance);
private NovelAgentService BuildAgent(ScriptedModelClient model) => new( private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
Db.Context, Db.Context,
@@ -143,7 +143,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
{ {
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
// A model that only ever asks for more tools would otherwise loop forever.
var model = new ScriptedModelClient( var model = new ScriptedModelClient(
Enumerable.Repeat<IReadOnlyList<AgentContentBlock>>( Enumerable.Repeat<IReadOnlyList<AgentContentBlock>>(
[ToolUse("t", "list_characters", new { })], 20).ToList()); [ToolUse("t", "list_characters", new { })], 20).ToList());
@@ -178,7 +177,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
{ {
Assert.That(second.ConversationId, Is.EqualTo(first.ConversationId)); Assert.That(second.ConversationId, Is.EqualTo(first.ConversationId));
// The second request replays the earlier turns so the model has the history.
Assert.That(model.Transcripts[1], Has.Count.EqualTo(3)); Assert.That(model.Transcripts[1], Has.Count.EqualTo(3));
Assert.That( Assert.That(
model.Transcripts[1].Select(m => m.Role), model.Transcripts[1].Select(m => m.Role),
@@ -209,10 +207,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
new(id, name, JsonSerializer.SerializeToElement(input)); new(id, name, JsonSerializer.SerializeToElement(input));
} }
/// <summary>
/// A model stand-in that returns a fixed script of turns and records every transcript it
/// was sent, so tests can assert on what the loop actually put in front of the model.
/// </summary>
internal class ScriptedModelClient(IReadOnlyList<IReadOnlyList<AgentContentBlock>> script) internal class ScriptedModelClient(IReadOnlyList<IReadOnlyList<AgentContentBlock>> script)
: IAgentModelClient : IAgentModelClient
{ {
@@ -88,7 +88,6 @@ public class OpenQuestionTests : ServiceTestFixture
Assert.That(open.Select(q => q.Question), Is.EqualTo(new[] { "Is this one book or two?" })); Assert.That(open.Select(q => q.Question), Is.EqualTo(new[] { "Is this one book or two?" }));
Assert.That(everything, Has.Count.EqualTo(2)); Assert.That(everything, Has.Count.EqualTo(2));
// Still-open questions come first, so the list stays about what is undecided.
Assert.That(everything[0].IsResolved, Is.False); Assert.That(everything[0].IsResolved, Is.False);
Assert.That(everything[1].IsResolved, Is.True); Assert.That(everything[1].IsResolved, Is.True);
}); });
@@ -128,7 +127,6 @@ public class OpenQuestionTests : ServiceTestFixture
Assert.Multiple(() => Assert.Multiple(() =>
{ {
// The existing note is kept and the decision lands underneath it.
Assert.That(chapter.Notes, Does.StartWith("Runs long.")); Assert.That(chapter.Notes, Does.StartWith("Runs long."));
Assert.That(chapter.Notes, Does.Contain("Where does the chapter break? — After the harbour burns.")); Assert.That(chapter.Notes, Does.Contain("Where does the chapter break? — After the harbour burns."));
Assert.That(character.Notes, Is.EqualTo("Where does the chapter break? — After the harbour burns.")); Assert.That(character.Notes, Is.EqualTo("Where does the chapter break? — After the harbour burns."));
@@ -179,7 +177,6 @@ public class OpenQuestionTests : ServiceTestFixture
{ {
Assert.That(detached.ChapterId, Is.Null); Assert.That(detached.ChapterId, Is.Null);
// Only the chapter was cleared — a null id means "leave alone", not "detach".
Assert.That(detached.CharacterId, Is.EqualTo(_characterId)); Assert.That(detached.CharacterId, Is.EqualTo(_characterId));
}); });
} }
+11 -16
View File
@@ -3,7 +3,6 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Scenes;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
@@ -69,19 +68,18 @@ public class ProjectDataTests : ServiceTestFixture
public async Task Word_count_is_recomputed_whenever_prose_changes() public async Task Word_count_is_recomputed_whenever_prose_changes()
{ {
var projectId = await NewProjectAsync(); var projectId = await NewProjectAsync();
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest( var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest(
"The dock at dawn", Prose: "Five words go right here")); "Landfall", Prose: "Five words go right here"));
Assert.That(scene.WordCount, Is.EqualTo(5)); Assert.That(chapter.WordCount, Is.EqualTo(5));
var rewritten = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest( var rewritten = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(
Prose: "Now\nthere are seven words in total")))!; Prose: "Now\nthere are seven words in total")))!;
Assert.That(rewritten.WordCount, Is.EqualTo(7)); Assert.That(rewritten.WordCount, Is.EqualTo(7));
var cleared = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: "")))!; var cleared = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Prose: "")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -91,14 +89,13 @@ public class ProjectDataTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Scene_updates_that_omit_prose_leave_the_draft_untouched() public async Task Chapter_updates_that_omit_prose_leave_the_draft_untouched()
{ {
var projectId = await NewProjectAsync(); var projectId = await NewProjectAsync();
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest(
var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest( "Landfall", Prose: "The tide came in slow."));
"The dock at dawn", Prose: "The tide came in slow."));
var updated = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Status: DraftStatus.Revised)))!; var updated = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Status: DraftStatus.Revised)))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -109,12 +106,11 @@ public class ProjectDataTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Deleting_a_project_takes_its_characters_chapters_and_scenes() public async Task Deleting_a_project_takes_its_characters_and_chapters()
{ {
var projectId = await NewProjectAsync(); var projectId = await NewProjectAsync();
await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines")); await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest("The dock at dawn"));
await Projects.DeleteAsync(projectId); await Projects.DeleteAsync(projectId);
@@ -125,7 +121,6 @@ public class ProjectDataTests : ServiceTestFixture
Assert.That(await verification.Projects.CountAsync(), Is.EqualTo(0)); Assert.That(await verification.Projects.CountAsync(), Is.EqualTo(0));
Assert.That(await verification.Characters.CountAsync(), Is.EqualTo(0)); Assert.That(await verification.Characters.CountAsync(), Is.EqualTo(0));
Assert.That(await verification.Chapters.CountAsync(), Is.EqualTo(0)); Assert.That(await verification.Chapters.CountAsync(), Is.EqualTo(0));
Assert.That(await verification.Scenes.CountAsync(), Is.EqualTo(0));
}); });
} }
@@ -3,20 +3,10 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
/// <summary>
/// Shared plumbing for the service tests: a fresh in-memory database and a matching set
/// of services per test.
/// </summary>
/// <remarks>
/// The setup lives in <c>[SetUp]</c> rather than a constructor or field initialisers
/// because NUnit builds one fixture instance for the whole class — anything created once
/// would leak state from one test into the next.
/// </remarks>
public abstract class ServiceTestFixture public abstract class ServiceTestFixture
{ {
protected TestDatabase Db { get; private set; } = null!; protected TestDatabase Db { get; private set; } = null!;
@@ -24,7 +14,6 @@ public abstract class ServiceTestFixture
protected ProjectService Projects { get; private set; } = null!; protected ProjectService Projects { get; private set; } = null!;
protected CharacterService Characters { get; private set; } = null!; protected CharacterService Characters { get; private set; } = null!;
protected ChapterService Chapters { get; private set; } = null!; protected ChapterService Chapters { get; private set; } = null!;
protected SceneService Scenes { get; private set; } = null!;
protected BeatService Beats { get; private set; } = null!; protected BeatService Beats { get; private set; } = null!;
protected CharacterArcService Arcs { get; private set; } = null!; protected CharacterArcService Arcs { get; private set; } = null!;
protected OpenQuestionService Questions { get; private set; } = null!; protected OpenQuestionService Questions { get; private set; } = null!;
@@ -32,7 +21,6 @@ public abstract class ServiceTestFixture
protected CapturingLogger<ProjectService> ProjectLogs { get; private set; } = null!; protected CapturingLogger<ProjectService> ProjectLogs { get; private set; } = null!;
protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!; protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!;
protected CapturingLogger<ChapterService> ChapterLogs { get; private set; } = null!; protected CapturingLogger<ChapterService> ChapterLogs { get; private set; } = null!;
protected CapturingLogger<SceneService> SceneLogs { get; private set; } = null!;
protected CapturingLogger<BeatService> BeatLogs { get; private set; } = null!; protected CapturingLogger<BeatService> BeatLogs { get; private set; } = null!;
protected CapturingLogger<TagService> TagLogs { get; private set; } = null!; protected CapturingLogger<TagService> TagLogs { get; private set; } = null!;
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!; protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
@@ -47,7 +35,6 @@ public abstract class ServiceTestFixture
ProjectLogs = new CapturingLogger<ProjectService>(); ProjectLogs = new CapturingLogger<ProjectService>();
CharacterLogs = new CapturingLogger<CharacterService>(); CharacterLogs = new CapturingLogger<CharacterService>();
ChapterLogs = new CapturingLogger<ChapterService>(); ChapterLogs = new CapturingLogger<ChapterService>();
SceneLogs = new CapturingLogger<SceneService>();
BeatLogs = new CapturingLogger<BeatService>(); BeatLogs = new CapturingLogger<BeatService>();
ArcLogs = new CapturingLogger<CharacterArcService>(); ArcLogs = new CapturingLogger<CharacterArcService>();
QuestionLogs = new CapturingLogger<OpenQuestionService>(); QuestionLogs = new CapturingLogger<OpenQuestionService>();
@@ -58,7 +45,6 @@ public abstract class ServiceTestFixture
Db.Context, Tags, CharacterLogs, Db.Context, Tags, CharacterLogs,
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator()); new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator());
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator()); Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
Scenes = new SceneService(Db.Context, SceneLogs, new CreateSceneRequestValidator(), new UpdateSceneRequestValidator());
Beats = new BeatService( Beats = new BeatService(
Db.Context, Tags, BeatLogs, Db.Context, Tags, BeatLogs,
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator()); new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator());
@@ -72,7 +58,6 @@ public abstract class ServiceTestFixture
OnSetUp(); OnSetUp();
} }
/// <summary>Runs after the services exist, for per-class seed data.</summary>
protected virtual void OnSetUp() protected virtual void OnSetUp()
{ {
} }
-11
View File
@@ -4,16 +4,6 @@ using Novelly.Api.Data;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
/// <summary>
/// A throwaway SQLite database held in memory. Using real SQLite rather than the
/// in-memory provider means the tests exercise the same relational behaviour the app
/// ships with — cascade deletes, foreign keys and all.
/// </summary>
/// <remarks>
/// NUnit reuses one fixture instance across every test in a class, so this must be built
/// in <c>[SetUp]</c> and disposed in <c>[TearDown]</c>. A field initialiser would share
/// one database for the whole class and let tests see each other's rows.
/// </remarks>
public class TestDatabase : IDisposable public class TestDatabase : IDisposable
{ {
private readonly SqliteConnection _connection; private readonly SqliteConnection _connection;
@@ -29,7 +19,6 @@ public class TestDatabase : IDisposable
public NovelDbContext Context { get; } public NovelDbContext Context { get; }
/// <summary>A second context over the same database, for asserting on persisted state.</summary>
public NovelDbContext CreateContext() => public NovelDbContext CreateContext() =>
new(new DbContextOptionsBuilder<NovelDbContext>().UseSqlite(_connection).Options); new(new DbContextOptionsBuilder<NovelDbContext>().UseSqlite(_connection).Options);