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
Thread text so multi-POV chapters aren't lossy even though `povCharacterId` had to pick one or
none.
4. For each beat table row, resolve the Character column the same way: a single clear name gets
auto-created if it isn't in the ledger yet; a list ("Glokta, West, Jezal") or vague reference
stays unresolved rather than guessing which one the beat belongs to. Then
`create_beat(chapterId, title: <Beat column>, whatHappened: <What column>, whatsNext: <Why column>, characterId: <resolved id, else omit>)`,
4. For each beat table row, resolve the Character column the same way: a single clear name, or a
list ("Glokta, West, Jezal"), gets each name auto-created if it isn't in the ledger yet; a
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>, characterIds: <resolved ids, else omit>)`,
in table order (the API appends in call order, so no explicit `sortOrder` needed).
5. If `## Notes` is present, `update_chapter(chapterId, notes: ...)`.
6. Record `chapters[number] = chapterId`, append `number` to `completedChapters`.
+2
View File
@@ -439,3 +439,5 @@ mcp-server/
# Toolchains installed locally by scripts/ci/lib.sh
.dotnet/
.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
- 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
- Match formatting/style from `.editorconfig`
- 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;
/// <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
{
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 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)
{
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)
{
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 =>
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;
/// <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(
INovelDbContext db,
IAgentModelClient model,
@@ -41,7 +37,6 @@ public class NovelAgentService(
.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)
{
Guard.Default(conversationId, nameof(conversationId));
@@ -51,7 +46,6 @@ public class NovelAgentService(
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)
{
Guard.Default(conversationId, nameof(conversationId));
@@ -69,12 +63,6 @@ public class NovelAgentService(
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(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{
@@ -96,8 +84,6 @@ public class NovelAgentService(
AgentConversation conversation;
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);
if (found is null)
{
@@ -111,9 +97,6 @@ public class NovelAgentService(
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);
var systemPrompt = BuildSystemPrompt(project);
@@ -141,9 +124,6 @@ public class NovelAgentService(
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));
var results = new List<AgentContentBlock>();
@@ -182,11 +162,6 @@ public class NovelAgentService(
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(
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
{
@@ -206,9 +181,6 @@ public class NovelAgentService(
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))
{
conversation.Messages.Add(message);
@@ -247,11 +219,6 @@ public class NovelAgentService(
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) =>
[
.. conversation.Messages
@@ -273,8 +240,8 @@ public class NovelAgentService(
return $"""
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
project's real data: the brief, character dossiers, the outline tree, chapters
and scenes.
project's real data: the brief, character dossiers, the outline (beats) and each
chapter's drafted prose.
The project you are working on:
{brief}
@@ -291,8 +258,8 @@ public class NovelAgentService(
- 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,
unless the writer asks for prose.
- When drafting prose into a scene, match the voice already established in the
project. Write the scene, then stop; do not append notes about your choices.
- When drafting a chapter's prose, match the voice already established in the
project. Write the chapter, then stop; do not append notes about your choices.
- Destructive operations (deleting outline nodes) need the writer's explicit
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)
{
var trimmed = message.Trim().ReplaceLineEndings(" ");
+12 -94
View File
@@ -5,40 +5,26 @@ using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
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);
/// <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);
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
public record AgentTool(
string Name,
string Description,
JsonElement InputSchema,
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(
ProjectService projects,
CharacterService characters,
CharacterArcService arcs,
ChapterService chapters,
BeatService beats,
SceneService scenes,
TagService tags,
OpenQuestionService questions,
ILogger<NovelAgentToolset> logger)
@@ -56,10 +42,6 @@ public class NovelAgentToolset(
public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. 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)
{
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 =>
await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found.");
/// <summary>Turns a nullable lookup into either the mapped response or a <see cref="ToolNotFound"/> the model can read.</summary>
private static async Task<object> OrNotFound<TEntity, TResponse>(
Task<TEntity?> lookup, Func<TEntity, TResponse> map, string entity, Guid id) where TEntity : class =>
await lookup is { } value ? map(value)! : new ToolNotFound($"{entity} '{id}' was not found.");
/// <summary>Turns a delete's success flag into either a confirmation or a <see cref="ToolNotFound"/>.</summary>
private static async Task<object> DeletedOrNotFound(Task<bool> delete, string entity, Guid id) =>
await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found.");
@@ -227,10 +206,9 @@ public class NovelAgentToolset(
new CreateBeatRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"),
JsonInput.Guids(input, "character_ids"),
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId);
});
@@ -250,10 +228,9 @@ public class NovelAgentToolset(
new UpdateBeatRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"),
JsonInput.Guids(input, "character_ids"),
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
});
@@ -310,13 +287,13 @@ public class NovelAgentToolset(
yield return new AgentTool(
"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(),
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()));
yield return new AgentTool(
"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()
.Str("chapter_id", "Id of the chapter to read.", required: true)
.Build(),
@@ -338,6 +315,7 @@ public class NovelAgentToolset(
.Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.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.")
.Build(),
async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest(
@@ -349,11 +327,14 @@ public class NovelAgentToolset(
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
yield return new AgentTool(
"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()
.Str("chapter_id", "Id of the chapter to update.", required: true)
.Str("title", "New title.")
@@ -364,6 +345,7 @@ public class NovelAgentToolset(
.Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.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.")
.Build(),
async (_, input, ct) =>
@@ -380,61 +362,10 @@ public class NovelAgentToolset(
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"),
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(
"get_character_beats",
"Every beat this character appears in, across the whole book, in manuscript order. "
@@ -651,21 +582,8 @@ public class NovelAgentToolset(
private static JsonSchemaBuilder BeatSchema() =>
new JsonSchemaBuilder()
.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("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.");
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.Characters;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
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 Guid Id { get; set; } = Guid.NewGuid();
@@ -18,24 +11,14 @@ public class Beat
public Guid ChapterId { 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; }
/// <summary>A three-to-five word handle for the beat, not a sentence.</summary>
public string Title { get; set; } = string.Empty;
/// <summary>Whose beat this is. Optional — not every beat belongs to one person.</summary>
public Guid? CharacterId { get; set; }
public Character? Character { get; set; }
public List<Character> Characters { get; set; } = [];
/// <summary>The event itself.</summary>
public string? WhatHappened { get; set; }
/// <summary>What it sets in motion — the hook into the next beat.</summary>
public string? WhatsNext { get; set; }
public List<Tag> Tags { get; set; } = [];
+9 -32
View File
@@ -3,27 +3,25 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Beats;
public record BeatCharacterResponse(Guid Id, string Name);
public record BeatResponse(
Guid Id,
Guid ChapterId,
int SortOrder,
string Title,
Guid? CharacterId,
string? CharacterName,
IReadOnlyList<BeatCharacterResponse> Characters,
string? WhatHappened,
string? WhatsNext,
Guid? SceneId,
string? SceneTitle,
IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt);
public record CreateBeatRequest(
string Title,
int? SortOrder = null,
Guid? CharacterId = null,
IReadOnlyList<Guid>? CharacterIds = null,
string? WhatHappened = null,
string? WhatsNext = null,
Guid? SceneId = null,
IReadOnlyList<string>? Tags = null);
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(
string? Title = null,
int? SortOrder = null,
Guid? CharacterId = null,
IReadOnlyList<Guid>? CharacterIds = null,
string? WhatHappened = null,
string? WhatsNext = null,
Guid? SceneId = null,
IReadOnlyList<string>? Tags = null,
bool ClearCharacter = false,
bool ClearScene = false);
IReadOnlyList<string>? Tags = null);
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(
Guid Id,
Guid ChapterId,
@@ -110,11 +95,8 @@ public record CharacterBeatResponse(
int SortOrder,
string Title,
string? WhatHappened,
string? WhatsNext,
Guid? SceneId,
string? SceneTitle);
string? WhatsNext);
/// <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 class ReorderBeatsRequestValidator : IModelValidator<ReorderBeatsRequest>
@@ -137,12 +119,9 @@ public static class BeatMapping
b.ChapterId,
b.SortOrder,
b.Title,
b.CharacterId,
b.Character?.Name,
[.. b.Characters.OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))],
b.WhatHappened,
b.WhatsNext,
b.SceneId,
b.Scene?.Title,
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
b.UpdatedAt);
@@ -154,7 +133,5 @@ public static class BeatMapping
b.SortOrder,
b.Title,
b.WhatHappened,
b.WhatsNext,
b.SceneId,
b.Scene?.Title);
b.WhatsNext);
}
+26 -58
View File
@@ -8,10 +8,6 @@ using Novelly.Api.Tags;
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(
INovelDbContext db,
TagService tags,
@@ -32,7 +28,6 @@ public class BeatService(
.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)
{
Guard.Default(id, nameof(id));
@@ -41,12 +36,6 @@ public class BeatService(
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(
Guid characterId, CancellationToken ct = default)
{
@@ -62,8 +51,7 @@ public class BeatService(
var beats = await db.Beats
.Include(b => b.Chapter)
.Include(b => b.Scene)
.Where(b => b.CharacterId == characterId)
.Where(b => b.Characters.Any(c => c.Id == characterId))
.ToListAsync(ct);
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)
{
Guard.Default(chapterId, nameof(chapterId));
@@ -90,19 +77,20 @@ public class BeatService(
return null;
}
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
var beat = new Beat
{
ChapterId = chapterId,
Title = request.Title,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
CharacterId = request.CharacterId,
WhatHappened = request.WhatHappened,
WhatsNext = request.WhatsNext,
SceneId = request.SceneId
WhatsNext = request.WhatsNext
};
if (request.CharacterIds is { } characterIds)
{
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
}
if (request.Tags is { } names)
{
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
@@ -111,7 +99,6 @@ public class BeatService(
db.Beats.Add(beat);
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))!;
}
@@ -132,22 +119,21 @@ public class BeatService(
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
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);
return null;
}
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
beat.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? beat.CharacterId;
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
beat.SceneId = request.ClearScene ? null : request.SceneId ?? beat.SceneId;
beat.UpdatedAt = DateTimeOffset.UtcNow;
if (request.CharacterIds is { } characterIds)
{
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
}
if (request.Tags is { } names)
{
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
@@ -157,7 +143,6 @@ public class BeatService(
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)
{
Guard.Default(id, nameof(id));
@@ -175,11 +160,6 @@ public class BeatService(
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(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{
@@ -198,8 +178,6 @@ public class BeatService(
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;
foreach (var id in request.BeatIds)
{
@@ -215,35 +193,26 @@ public class BeatService(
return await ListAsync(chapterId, ct);
}
private async Task ValidateReferencesAsync(
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, 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)
{
return [];
}
if (characterId is { } cid)
{
var belongs = await db.Characters
.AnyAsync(c => c.Id == cid && c.ProjectId == chapter.ProjectId, ct);
var found = await db.Characters
.Where(c => c.ProjectId == projectId && distinct.Contains(c.Id))
.ToListAsync(ct);
if (!belongs)
if (found.Count != distinct.Count)
{
logger.LogWarning("Rejected beat reference: character {CharacterId} does not belong to project {ProjectId}", cid, chapter.ProjectId);
logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId);
throw new InvalidOperationException(
"A beat's character must belong to the same project as its chapter.");
}
"A beat's characters must belong to the same project as its chapter.");
}
if (sceneId is { } sid)
{
var belongs = await db.Scenes.AnyAsync(s => s.Id == sid && s.ChapterId == chapter.Id, ct);
if (!belongs)
{
logger.LogWarning("Rejected beat reference: scene {SceneId} does not belong to chapter {ChapterId}", sid, chapter.Id);
throw new InvalidOperationException(
"A beat can only be grouped under a scene in the same chapter.");
}
}
return found;
}
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
@@ -259,8 +228,7 @@ public class BeatService(
private IQueryable<Beat> Query() =>
db.Beats
.Include(b => b.Character)
.Include(b => b.Scene)
.Include(b => b.Characters)
.Include(b => b.Tags);
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.Common;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
namespace Novelly.Api.Chapters;
/// <summary>A chapter: an ordered container of scenes plus its own planning fields.</summary>
public class Chapter
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project? Project { get; set; }
/// <summary>Position in the manuscript, 1-based.</summary>
public int Number { get; set; }
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; }
/// <summary>Whose head we are in for this chapter.</summary>
public Guid? PovCharacterId { get; set; }
public Character? PovCharacter { get; set; }
@@ -34,14 +27,14 @@ public class Chapter
public DraftStatus Status { get; set; } = DraftStatus.Planned;
public int? TargetWordCount { get; set; }
public string? Prose { get; set; }
public int WordCount { get; set; }
public DateTimeOffset CreatedAt { 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; } = [];
/// <summary>The prose layer. Beats may optionally be grouped under these.</summary>
public List<Scene> Scenes { get; set; } = [];
public List<Tag> Tags { get; set; } = [];
}
+18 -16
View File
@@ -1,7 +1,6 @@
using Novelly.Api.Beats;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
namespace Novelly.Api.Chapters;
@@ -18,15 +17,10 @@ public record ChapterSummaryResponse(
DraftStatus Status,
int? TargetWordCount,
int BeatCount,
int SceneCount,
int WordCount,
IReadOnlyList<TagResponse> Tags,
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(
Guid Id,
Guid ProjectId,
@@ -40,7 +34,8 @@ public record ChapterResponse(
DraftStatus Status,
int? TargetWordCount,
IReadOnlyList<BeatResponse> Beats,
IReadOnlyList<SceneResponse> Scenes,
string? Prose,
int WordCount,
IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt);
@@ -53,6 +48,7 @@ public record CreateChapterRequest(
string? Notes = null,
DraftStatus Status = DraftStatus.Planned,
int? TargetWordCount = null,
string? Prose = null,
IReadOnlyList<string>? Tags = null);
public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest>
@@ -66,16 +62,12 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
else if (model.Title.Length > 200)
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;
}
}
/// <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(
string? Title = null,
int? Number = null,
@@ -85,6 +77,7 @@ public record UpdateChapterRequest(
string? Notes = null,
DraftStatus? Status = null,
int? TargetWordCount = null,
string? Prose = null,
IReadOnlyList<string>? Tags = null);
public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest>
@@ -101,7 +94,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
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;
}
@@ -110,7 +103,8 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
file static class ChapterValidation
{
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)
result.AddError("Number", "'Number' must be greater than zero.");
@@ -127,6 +121,9 @@ file static class ChapterValidation
if (targetWordCount is < 0)
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))
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.Status, c.TargetWordCount,
[.. 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.UpdatedAt);
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
c.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount),
c.Beats.Count, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
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) =>
(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 (
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) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a chapter and its scenes.");
.WithSummary("Delete a chapter.");
return app;
}
+11 -9
View File
@@ -22,14 +22,12 @@ public class ChapterService(
return await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats)
.Include(c => c.Scenes)
.Include(c => c.Tags)
.Where(c => c.ProjectId == projectId)
.OrderBy(c => c.Number)
.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)
{
Guard.Default(id, nameof(id));
@@ -38,7 +36,6 @@ public class ChapterService(
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)
{
Guard.Default(projectId, nameof(projectId));
@@ -63,7 +60,9 @@ public class ChapterService(
Setting = request.Setting,
Notes = request.Notes,
Status = request.Status,
TargetWordCount = request.TargetWordCount
TargetWordCount = request.TargetWordCount,
Prose = request.Prose,
WordCount = ChapterMapping.CountWords(request.Prose)
};
if (request.Tags is { } names)
@@ -74,7 +73,6 @@ public class ChapterService(
db.Chapters.Add(chapter);
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))!;
}
@@ -100,6 +98,13 @@ public class ChapterService(
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status;
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;
if (request.Tags is { } names)
@@ -111,7 +116,6 @@ public class ChapterService(
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)
{
Guard.Default(id, nameof(id));
@@ -148,10 +152,8 @@ public class ChapterService(
var chapter = await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats).ThenInclude(b => b.Character)
.Include(c => c.Beats).ThenInclude(b => b.Scene)
.Include(c => c.Beats).ThenInclude(b => b.Characters)
.Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Scenes).ThenInclude(s => s.PovCharacter)
.Include(c => c.Tags)
.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.Tags;
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 Guid Id { get; set; } = Guid.NewGuid();
@@ -16,10 +13,6 @@ public class Character
public string Name { get; set; } = string.Empty;
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 string? Age { get; set; }
@@ -30,22 +23,15 @@ public class Character
public string? Personality { get; set; }
public string? Backstory { get; set; }
/// <summary>What the character consciously wants.</summary>
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? InternalConflict { 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; }
/// <summary>Speech patterns, verbal tics, register — anything that makes dialogue sound like them.</summary>
public string? Voice { get; set; }
public string? Notes { get; set; }
@@ -56,11 +42,11 @@ public class Character
public List<CharacterRelationship> Relationships { 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<Beat> Beats { get; set; } = [];
}
/// <summary>A directed relationship from one character to another.</summary>
public class CharacterRelationship
{
public Guid Id { get; set; } = Guid.NewGuid();
@@ -71,7 +57,6 @@ public class CharacterRelationship
public Guid RelatedCharacterId { get; set; }
public Character? RelatedCharacter { get; set; }
/// <summary>e.g. "sister", "rival", "former mentor".</summary>
public string RelationshipType { get; set; } = string.Empty;
public string? Description { get; set; }
-1
View File
@@ -1,6 +1,5 @@
namespace Novelly.Api.Common;
/// <summary>How far along a chapter or scene is in the drafting pipeline.</summary>
public enum DraftStatus
{
Planned,
@@ -9,16 +9,10 @@ using Novelly.Api.Data;
using Novelly.Api.Imports;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
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 IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
@@ -35,7 +29,6 @@ public static class NovellyServiceRegistration
services.AddScoped<BeatService>();
services.AddScoped<TagService>();
services.AddScoped<ChapterService>();
services.AddScoped<SceneService>();
services.AddScoped<OpenQuestionService>();
services.AddScoped<NovelAgentToolset>();
services.AddScoped<NovelAgentService>();
@@ -43,8 +36,6 @@ public static class NovellyServiceRegistration
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
// A single unbounded queue shared by the request path (writer, in ImportService)
// and the background runner (reader) — the only background-job infra in the app.
services.AddSingleton(Channel.CreateUnbounded<Guid>());
services.AddScoped<ImportService>();
services.AddScoped<ImportAgentToolset>();
-6
View File
@@ -6,15 +6,10 @@ using Novelly.Api.Characters;
using Novelly.Api.Imports;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
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
{
DbSet<Project> Projects { get; }
@@ -24,7 +19,6 @@ public interface INovelDbContext
DbSet<Beat> Beats { get; }
DbSet<Tag> Tags { get; }
DbSet<Chapter> Chapters { get; }
DbSet<Scene> Scenes { get; }
DbSet<OpenQuestion> OpenQuestions { get; }
DbSet<AgentConversation> Conversations { 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
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")
@@ -133,15 +148,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<Guid?>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("SceneId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
@@ -161,10 +170,6 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SceneId");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Beats");
@@ -191,6 +196,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<string>("Setting")
.HasColumnType("TEXT");
@@ -213,6 +221,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<int>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PovCharacterId");
@@ -497,67 +508,6 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("OpenQuestions");
});
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<string>("Conflict")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Goal")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Outcome")
.HasColumnType("TEXT");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<int>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PovCharacterId");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Scenes");
});
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{
b.Property<Guid>("Id")
@@ -587,6 +537,21 @@ namespace Novelly.Api.Data.Migrations
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)
@@ -662,21 +627,7 @@ namespace Novelly.Api.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", "Character")
.WithMany()
.HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Scenes.Scene", "Scene")
.WithMany()
.HasForeignKey("SceneId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("Character");
b.Navigation("Scene");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
@@ -770,24 +721,6 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
.WithMany("Scenes")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
.WithMany()
.HasForeignKey("PovCharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("PovCharacter");
});
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
@@ -807,8 +740,6 @@ namespace Novelly.Api.Data.Migrations
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.Navigation("Beats");
b.Navigation("Scenes");
});
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.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
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()
: ValueConverter<DateTimeOffset, long>(
value => value.UtcTicks,
@@ -33,7 +26,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
public DbSet<Beat> Beats => Set<Beat>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Chapter> Chapters => Set<Chapter>();
public DbSet<Scene> Scenes => Set<Scene>();
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
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.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()
.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);
// 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()
.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)
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
// A beat outlives the scene it was grouped under: deleting a scene is a
// decision about prose, not about the plan.
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);
entity.HasMany(b => b.Characters).WithMany(c => c.Beats)
.UsingEntity(join => join.ToTable("BeatCharacters"));
});
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.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.HasMany(t => t.Characters).WithMany(c => c.Tags)
@@ -139,19 +119,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
entity.HasOne(c => c.PovCharacter).WithMany()
.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 =>
@@ -159,15 +126,11 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
entity.Property(q => q.Question).IsRequired().HasMaxLength(500);
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.HasOne(q => q.Project).WithMany()
.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()
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
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.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);
});
}
+2 -23
View File
@@ -8,30 +8,14 @@ using Novelly.Api.Projects;
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);
/// <summary>A tool the import agent can call, bound to a handler that runs against this run's state.</summary>
internal record ImportAgentTool(
string Name,
string Description,
JsonElement InputSchema,
Func<JsonElement, CancellationToken, Task<object?>> Handler);
/// <summary>
/// The tools the outline-import agent can reach for: read-only, root-scoped filesystem
/// access to the source folder, a write capability limited to exactly the resume ledger,
/// and the same application services the chat agent and REST API use for everything else.
///
/// Deliberately a separate toolset from <see cref="NovelAgentToolset"/> rather than an
/// extension of it — filesystem access must never be reachable from a normal chat
/// conversation. One instance is built per import run (see <see cref="Initialize"/>), so
/// the current project id lives here rather than being threaded through every call.
/// </summary>
public class ImportAgentToolset(
ProjectService projects,
CharacterService characters,
@@ -54,17 +38,14 @@ public class ImportAgentToolset(
public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
/// <summary>Binds this instance to one run. Must be called before any tool executes.</summary>
public void Initialize(string sourceRoot, Guid? existingProjectId)
{
_sourceRoot = sourceRoot;
ProjectId = existingProjectId;
}
/// <summary>Reads the ledger directly — the run driver's ground truth for "is this done", not the model's say-so.</summary>
public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot);
/// <summary>Runs a tool and serialises its result. Failures come back as text so the model can read and self-correct.</summary>
public async Task<AgentToolResult> ExecuteAsync(string name, JsonElement input, CancellationToken ct = default)
{
if (!ByName.TryGetValue(name, out var tool))
@@ -196,8 +177,6 @@ public class ImportAgentToolset(
{
var json = JsonInput.RequiredString(input, "json");
// Fail loudly on malformed JSON now rather than writing garbage the next
// run's read_ledger can't parse.
using var _ = JsonDocument.Parse(json);
File.WriteAllText(ImportPaths.LedgerPath(_sourceRoot), json);
@@ -345,7 +324,7 @@ public class ImportAgentToolset(
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
.Str("title", "The Beat column — three to five words.", required: true)
.Str("character_id", "Id of the character named in the Character column, if it resolves.")
.StringArray("character_ids", "Ids of the characters named in the Character column, if they resolve.")
.Str("what_happened", "The What column.")
.Str("whats_next", "The Why column.")
.Build(),
@@ -356,7 +335,7 @@ public class ImportAgentToolset(
chapterId,
new CreateBeatRequest(
JsonInput.RequiredString(input, "title"),
CharacterId: JsonInput.Guid(input, "character_id"),
CharacterIds: JsonInput.Guids(input, "character_ids"),
WhatHappened: JsonInput.String(input, "what_happened"),
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.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
using Serilog;
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
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
@@ -28,8 +25,6 @@ builder.Services.AddNovelly(builder.Configuration);
builder.Services.AddOpenApi();
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 =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
@@ -43,16 +38,11 @@ builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
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())
{
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.UseExceptionHandler(handler => handler.Run(async context =>
@@ -95,7 +85,6 @@ app.MapProjectEndpoints()
.MapCharacterEndpoints()
.MapChapterEndpoints()
.MapBeatEndpoints()
.MapSceneEndpoints()
.MapTagEndpoints()
.MapOpenQuestionEndpoints()
.MapAgentEndpoints()
@@ -103,5 +92,4 @@ app.MapProjectEndpoints()
app.Run();
/// <summary>Exposed so the tests can spin the API up with WebApplicationFactory.</summary>
public partial class Program;
+1 -3
View File
@@ -27,12 +27,11 @@ public class ProjectService(
p.Phase,
p.Characters.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))
.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)
{
Guard.Default(id, nameof(id));
@@ -92,7 +91,6 @@ public class ProjectService(
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)
{
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(
TagResponse Tag,
IReadOnlyList<TaggedCharacterResponse> Characters,
@@ -105,12 +100,8 @@ public static class TagMapping
b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder,
b.Title,
b.Character?.Name,
b.Characters.Count > 0 ? string.Join(", ", b.Characters.OrderBy(c => c.Name).Select(c => c.Name)) : null,
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();
}
+1 -9
View File
@@ -26,7 +26,6 @@ public class TagService(
.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)
{
Guard.Default(tagId, nameof(tagId));
@@ -36,7 +35,7 @@ public class TagService(
var tag = await db.Tags
.Include(t => t.Characters)
.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)
.FirstOrDefaultAsync(t => t.Id == tagId, ct);
@@ -46,7 +45,6 @@ public class TagService(
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)
{
Guard.Default(projectId, nameof(projectId));
@@ -110,7 +108,6 @@ public class TagService(
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)
{
Guard.Default(tagId, nameof(tagId));
@@ -129,11 +126,6 @@ public class TagService(
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(
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
{
+10 -15
View File
@@ -9,8 +9,8 @@ public static class BeatTools
{
[McpServerTool(Name = "get_chapter_outline")]
[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 "
+ "summary paragraph sits on the chapter itself, via get_chapter.")]
+ "title, who it belongs to, what happened, and what it sets up. The chapter's "
+ "summary paragraph and drafted prose sit on the chapter itself, via get_chapter.")]
public static Task<CallToolResult> GetChapterOutline(
NovelApiClient api,
[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,
CancellationToken ct,
[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("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) =>
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")]
[Description("Revise a beat. Only the fields you supply change. Supplying a tag list replaces "
+ "the beat's tags outright, so include the ones you want to keep. A character or "
+ "scene id already set stays put unless you pass clearCharacter/clearScene — "
+ "leaving the id null means 'don't touch it', not 'remove it'.")]
[Description("Revise a beat. Only the fields you supply change. Supplying a characterIds or "
+ "tag list replaces the beat's characters or tags outright — pass an empty list "
+ "to clear one, and include everything you want to keep.")]
public static Task<CallToolResult> UpdateBeat(
NovelApiClient api,
[Description("The beat's id.")] Guid beatId,
CancellationToken ct,
[Description("Three to five words naming the beat.")] string? title = 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("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("Detach this beat's character, leaving it unassigned.")] bool clearCharacter = false,
[Description("Detach this beat's scene, leaving it ungrouped.")] bool clearScene = false) =>
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
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")]
[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
{
[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(
NovelApiClient api,
[Description("The project's id.")] Guid projectId,
@@ -16,7 +16,7 @@ public static class ManuscriptTools
api.GetAsync($"/api/projects/{projectId}/chapters", ct);
[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(
NovelApiClient api,
[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("Planned, Outlined, Drafted, Revised or Final.")] string? status = 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) =>
api.PostAsync($"/api/projects/{projectId}/chapters", new
{
@@ -46,11 +47,14 @@ public static class ManuscriptTools
setting,
status = status ?? "Planned",
targetWordCount,
prose,
tags
}, ct);
[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(
NovelApiClient api,
[Description("The chapter's id.")] Guid chapterId,
@@ -63,77 +67,8 @@ public static class ManuscriptTools
[Description("Anything else worth recording.")] string? notes = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = 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) =>
api.PatchAsync($"/api/chapters/{chapterId}",
new { title, number, summary, povCharacterId, setting, notes, status, targetWordCount, 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);
new { title, number, summary, povCharacterId, setting, notes, status, targetWordCount, prose, tags }, 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",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.2"
},
"devDependencies": {
+7 -70
View File
@@ -16,7 +16,6 @@ import type {
OpenQuestion,
Project,
ProjectSummary,
Scene,
TagReferences,
TagSummary,
} from './types'
@@ -36,8 +35,6 @@ export const keys = {
importJob: (id: string) => ['imports', id] as const,
}
// --- Projects ---------------------------------------------------------------
export const useProjects = () =>
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') })
@@ -72,8 +69,6 @@ export function useDeleteProject() {
})
}
// --- Characters -------------------------------------------------------------
export const useCharacters = (projectId: string) =>
useQuery({
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) =>
useQuery({
queryKey: keys.characterBeats(characterId ?? ''),
@@ -123,8 +114,6 @@ export const useCharacterBeats = (characterId: string | undefined) =>
enabled: Boolean(characterId),
})
// --- Character arcs ----------------------------------------------------------
export function useCreateArcStage(projectId: string) {
const qc = useQueryClient()
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 = (
projectId: string,
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) {
const qc = useQueryClient()
return useMutation({
@@ -234,8 +213,6 @@ export function useDeleteQuestion(projectId: string) {
})
}
// --- Tags --------------------------------------------------------------------
export const useTags = (projectId: string) =>
useQuery({
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() {
const qc = useQueryClient()
return useMutation({
@@ -270,13 +243,12 @@ export function useDeleteTag() {
})
}
// --- Beats (a chapter's outline) ---------------------------------------------
export function useCreateBeat(chapterId: string, projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Partial<Beat> & { title: string }) =>
api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
mutationFn: (
body: Partial<Omit<Beat, 'tags' | 'characters'>> & { title: string; tags?: string[]; characterIds?: string[] },
) => api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
@@ -287,7 +259,10 @@ export function useCreateBeat(chapterId: string, projectId: string) {
export function useUpdateBeat(chapterId: string, projectId: string) {
const qc = useQueryClient()
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),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
@@ -313,8 +288,6 @@ export function useReorderBeats(chapterId: string) {
})
}
// --- Chapters and scenes ----------------------------------------------------
export const useChapters = (projectId: string) =>
useQuery({
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) =>
useQuery({
queryKey: keys.conversations(projectId),
@@ -407,7 +352,6 @@ export function useSendAgentMessage(projectId: string) {
onSuccess: (turn) => {
qc.invalidateQueries({ queryKey: keys.conversations(projectId) })
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.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
@@ -417,8 +361,6 @@ export function useSendAgentMessage(projectId: string) {
})
}
// --- Outline import ----------------------------------------------------------
export function useInspectImport() {
return useMutation({
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
@@ -434,11 +376,6 @@ export function useStartImport() {
const terminalImportStatuses: ImportJobStatus[] = ['Completed', 'Failed', 'Paused']
/**
* Polls a running import job. This is the app's first polling hook — there's no
* SSE/websocket infrastructure to reuse so it stops on its own once the job reaches a
* terminal status rather than depending on the caller to unmount it in time.
*/
export function useImportJob(jobId: string | undefined) {
return useQuery({
queryKey: keys.importJob(jobId ?? ''),
+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 =
| 'Protagonist'
@@ -21,7 +20,6 @@ export const characterRoles: CharacterRole[] = [
'Foil',
]
/** How much of the book a character carries. Separate from the part they play. */
export type 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']
/** Where a novel is in its lifecycle, from first notes to a finished manuscript. */
export type 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 {
id: string
chapterId: string
sortOrder: number
title: string
characterId: string | null
characterName: string | null
characters: BeatCharacter[]
whatHappened: string | null
whatsNext: string | null
sceneId: string | null
sceneTitle: string | null
tags: Tag[]
updatedAt: string
}
@@ -116,7 +114,6 @@ export interface Relationship {
description: string | null
}
/** One step in a main character's arc. Flat and ordered, like a chapter's beats. */
export interface ArcStage {
id: string
characterId: string
@@ -129,7 +126,6 @@ export interface ArcStage {
updatedAt: string
}
/** A beat a character appears in, carrying its chapter so the page can link into the outline. */
export interface CharacterBeat {
id: string
chapterId: string
@@ -139,8 +135,6 @@ export interface CharacterBeat {
title: string
whatHappened: string | null
whatsNext: string | null
sceneId: string | null
sceneTitle: string | null
}
export interface Character {
@@ -168,24 +162,6 @@ export interface Character {
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 {
id: string
projectId: string
@@ -198,20 +174,19 @@ export interface ChapterSummary {
status: DraftStatus
targetWordCount: number | null
beatCount: number
sceneCount: number
wordCount: number
tags: Tag[]
updatedAt: string
}
export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'sceneCount' | 'wordCount'> {
export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'wordCount'> {
notes: string | null
beats: Beat[]
scenes: Scene[]
prose: string | null
wordCount: number
updatedAt: string
}
/** Something the writer has not decided yet, hung off a chapter outline and/or a character. */
export interface OpenQuestion {
id: string
projectId: string
@@ -260,8 +235,6 @@ export interface AgentTurn {
message: AgentMessage
}
// --- Outline import -----------------------------------------------------------
export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | 'Paused'
export interface ImportJob {
@@ -276,7 +249,6 @@ export interface ImportJob {
updatedAt: string
}
/** Whether a source folder is ready for a fresh import, has one to resume, or is already done. */
export type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete'
export interface ImportInspection {
@@ -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;
}
/*
* 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 {
--paper: #faf7f0;
--surface: #ffffff;
@@ -132,4 +128,57 @@ body {
font-family: var(--font-sans);
@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">
<h2 className="text-lg font-semibold">Your writing partner</h2>
<p className="mt-1 text-sm muted">
It can read and edit the brief, the outline, character dossiers, chapters and
scenes the same data you see in the other tabs.
It can read and edit the brief, the outline, character dossiers and chapter
prose the same data you see in the other tabs.
</p>
<div className="mt-4 grid gap-2">
{starters.map((starter) => (
+61 -118
View File
@@ -4,22 +4,23 @@ import {
useChapter,
useCharacters,
useCreateBeat,
useCreateScene,
useDeleteBeat,
useDeleteChapter,
useDeleteScene,
useReorderBeats,
useTags,
useUpdateBeat,
useUpdateChapter,
useUpdateScene,
} from '../api/hooks'
import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types'
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
import { draftStatuses, type Beat, type Chapter } from '../api/types'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { TagChip, TagEditor } from '../components/TagEditor'
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
import { MarkdownEditor } from '../components/MarkdownEditor'
import { OpenQuestions } from '../components/OpenQuestions'
import { useHotkey } from '../keyboard/HotkeysContext'
type ChapterTab = 'outline' | 'prose'
export default function ChapterPage() {
const { projectId = '', chapterId = '' } = useParams()
const navigate = useNavigate()
@@ -29,10 +30,9 @@ export default function ChapterPage() {
const update = useUpdateChapter(projectId)
const remove = useDeleteChapter(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('s', 'Add scene', () => createScene.mutate({ title: 'New scene' }), { group: 'Chapter' })
if (isPending) return <Spinner label="Loading chapter" />
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="text-sm muted">
{chapter.beats.length} beats · {chapter.scenes.length} scenes ·{' '}
{chapter.scenes.reduce((sum, s) => sum + s.wordCount, 0).toLocaleString()} words
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
</div>
<button
className="btn"
@@ -132,9 +131,29 @@ export default function ChapterPage() {
</div>
</section>
{/* The outline: a paragraph, then the beat table. */}
<div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
{(
[
['outline', 'Outline'],
['prose', 'Text'],
] as const
).map(([value, tabLabel]) => (
<button
key={value}
onClick={() => setTab(value)}
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)',
}}
>
{tabLabel}
</button>
))}
</div>
{tab === 'outline' ? (
<section className="mb-8">
<h2 className="mb-1 text-lg font-semibold">Outline</h2>
<p className="mb-3 text-sm muted">
A paragraph on what the chapter does, then the beats that carry it.
</p>
@@ -170,29 +189,16 @@ export default function ChapterPage() {
</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
className="btn"
onClick={() => createScene.mutate({ title: 'New scene' })}
disabled={createScene.isPending}
>
Add scene
</button>
</div>
<ul className="grid gap-3">
{chapter.scenes.map((scene) => (
<SceneCard key={scene.id} chapterId={chapter.id} scene={scene} />
))}
</ul>
) : (
<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">
<h3 className="mb-1 text-sm font-semibold">Notes</h3>
@@ -247,7 +253,7 @@ function BeatTable({
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 })
return (
@@ -256,15 +262,14 @@ function BeatTable({
<thead>
<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-[14%] 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">
Character
<th className="w-[16%] 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">
Characters
</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
</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-[12%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Scene</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-8" />
</tr>
</thead>
@@ -320,18 +325,11 @@ function BeatTable({
</td>
<td className="px-2 py-2 align-top">
<select
className="input"
value={beat.characterName ?? '—'}
onChange={(e) => {
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>
<CharacterMultiSelect
selected={beat.characters}
options={characters}
onChange={(characterIds) => patch(beat.id, { characterIds })}
/>
</td>
<td className="px-2 py-2 align-top">
@@ -356,21 +354,6 @@ function BeatTable({
/>
</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">
<div className="flex flex-col items-center gap-2">
<button
@@ -413,7 +396,17 @@ function BeatTable({
)}
</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">
{beat.whatHappened ?? <span className="muted"></span>}
@@ -423,8 +416,6 @@ function BeatTable({
{beat.whatsNext ?? <span className="muted"></span>}
</td>
<td className="px-2 py-2 align-top">{beat.sceneTitle ?? <span className="muted"></span>}</td>
<td className="px-2 py-2 align-top" />
</tr>
),
@@ -434,51 +425,3 @@ function BeatTable({
</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">
{chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>}
<span>{chapter.beatCount} beats</span>
<span>{chapter.sceneCount} scenes</span>
<span>{chapter.wordCount.toLocaleString()} words</span>
<StatusBadge status={chapter.status} />
</div>
@@ -10,9 +10,6 @@ public class AnthropicClientTests
{
[Test]
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(
() => new AnthropicAgentModelClient(Options.Create(new AgentOptions()), NullLogger<AnthropicAgentModelClient>.Instance),
Throws.Nothing);
+11 -52
View File
@@ -1,9 +1,7 @@
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
namespace Novelly.Api.Tests;
@@ -75,22 +73,20 @@ public class BeatServiceTests : ServiceTestFixture
}
[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 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(
"She burns the atlas",
CharacterId: ines.Id,
CharacterIds: [ines.Id, mara.Id],
WhatHappened: "The pages go up faster than she expected.",
WhatsNext: "Nothing to navigate by but memory.",
SceneId: scene.Id));
WhatsNext: "Nothing to navigate by but memory."));
Assert.Multiple(() =>
{
Assert.That(beat.Character!.Name, Is.EqualTo("Ines"));
Assert.That(beat.Scene!.Title, Is.EqualTo("The dock at dawn"));
Assert.That(beat.Characters.Select(c => c.Name), Is.EquivalentTo(new[] { "Ines", "Mara" }));
Assert.That(beat.WhatHappened, Does.Contain("faster than she expected"));
});
}
@@ -103,41 +99,10 @@ public class BeatServiceTests : ServiceTestFixture
Assert.That(
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"));
}
[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]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
@@ -164,23 +129,17 @@ public class BeatServiceTests : ServiceTestFixture
}
[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 scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas", CharacterId: ines.Id, SceneId: scene.Id));
"She burns the atlas", CharacterIds: [ines.Id]));
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(
beat.Id, new UpdateBeatRequest(ClearCharacter: true, ClearScene: true)))!;
var cleared = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(CharacterIds: [])))!;
Assert.Multiple(() =>
{
Assert.That(cleared.Character, Is.Null);
Assert.That(cleared.Scene, Is.Null);
});
Assert.That(cleared.Characters, Is.Empty);
}
}
@@ -2,14 +2,8 @@ using Microsoft.Extensions.Logging;
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);
/// <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 List<CapturedLogEntry> Entries { get; } = [];
+3 -6
View File
@@ -64,8 +64,6 @@ public class CharacterArcTests : ServiceTestFixture
[Test]
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(
"Mara", CharacterRole.Deuteragonist, CharacterImportance.Main));
await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
@@ -162,7 +160,6 @@ public class CharacterArcTests : ServiceTestFixture
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))!;
Assert.Multiple(() =>
@@ -195,9 +192,9 @@ public class CharacterArcTests : ServiceTestFixture
var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("First", Number: 1));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterId: _characterId));
await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterId: _characterId));
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterId: mara.Id));
await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterIds: [_characterId]));
await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterIds: [_characterId]));
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterIds: [mara.Id]));
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat"));
var beats = (await Beats.ListForCharacterAsync(_characterId))!;
@@ -5,11 +5,6 @@ using Novelly.Api.Projects;
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]
public class ExceptionHandlingTests : ServiceTestFixture
{
@@ -64,8 +64,6 @@ public class ImportAgentToolsetTests : ServiceTestFixture
[Test]
public async Task Write_ledger_can_only_ever_touch_the_ledger_file_no_matter_what_path_is_asked_for()
{
// The tool takes no path argument at all — this is the enforcement, not a check
// against a supplied path. Confirm the write always lands at exactly the ledger name.
await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["project"]}""" }));
Assert.Multiple(() =>
@@ -105,7 +105,6 @@ public class ImportServiceTests : ServiceTestFixture
Assert.That(second.Id, Is.EqualTo(first.Id));
// Only one job was ever queued.
Assert.That(_queue.Reader.TryRead(out _), Is.True);
Assert.That(_queue.Reader.TryRead(out _), Is.False);
}
+7 -19
View File
@@ -4,15 +4,9 @@ using Novelly.Api.Agent;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
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]
public class ListingTests : ServiceTestFixture
{
@@ -22,7 +16,6 @@ public class ListingTests : ServiceTestFixture
var older = await Projects.CreateAsync(new CreateProjectRequest("Older 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."));
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("Mara"));
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour"));
await Scenes.CreateAsync(first.Id, new CreateSceneRequest("Dawn", Prose: "One two three"));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("Dusk", Prose: "Four five"));
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall", Prose: "One two three"));
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour", Prose: "Four five"));
var summary = (await Projects.ListAsync()).Single();
@@ -71,22 +62,19 @@ public class ListingTests : ServiceTestFixture
}
[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 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));
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);
Assert.Multiple(() =>
{
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).Scenes.Sum(s => s.WordCount), Is.EqualTo(3));
Assert.That(listed.Single(c => c.Id == first.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(0));
Assert.That(listed.Single(c => c.Id == second.Id).WordCount, Is.EqualTo(3));
Assert.That(listed.Single(c => c.Id == first.Id).WordCount, Is.EqualTo(0));
});
}
@@ -97,7 +85,7 @@ public class ListingTests : ServiceTestFixture
var agent = new NovelAgentService(
Db.Context,
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()),
NullLogger<NovelAgentService>.Instance,
new SendAgentMessageRequestValidator());
+1 -5
View File
@@ -7,10 +7,6 @@ using Novelly.Api.Projects;
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]
public class LoggingTests : ServiceTestFixture
{
@@ -82,7 +78,7 @@ public class LoggingTests : ServiceTestFixture
BeatLogs.Entries.Clear();
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>());
Assert.Multiple(() =>
@@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
private NovelAgentToolset _toolset = null!;
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(
Db.Context,
@@ -143,7 +143,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
{
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(
Enumerable.Repeat<IReadOnlyList<AgentContentBlock>>(
[ToolUse("t", "list_characters", new { })], 20).ToList());
@@ -178,7 +177,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
{
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].Select(m => m.Role),
@@ -209,10 +207,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
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)
: 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(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[1].IsResolved, Is.True);
});
@@ -128,7 +127,6 @@ public class OpenQuestionTests : ServiceTestFixture
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.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."));
@@ -179,7 +177,6 @@ public class OpenQuestionTests : ServiceTestFixture
{
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));
});
}
+11 -16
View File
@@ -3,7 +3,6 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
namespace Novelly.Api.Tests;
@@ -69,19 +68,18 @@ public class ProjectDataTests : ServiceTestFixture
public async Task Word_count_is_recomputed_whenever_prose_changes()
{
var projectId = await NewProjectAsync();
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
"The dock at dawn", Prose: "Five words go right here"));
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest(
"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")))!;
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(() =>
{
@@ -91,14 +89,13 @@ public class ProjectDataTests : ServiceTestFixture
}
[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 chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
"The dock at dawn", Prose: "The tide came in slow."));
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest(
"Landfall", 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(() =>
{
@@ -109,12 +106,11 @@ public class ProjectDataTests : ServiceTestFixture
}
[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();
await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest("The dock at dawn"));
await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
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.Characters.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.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
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
{
protected TestDatabase Db { get; private set; } = null!;
@@ -24,7 +14,6 @@ public abstract class ServiceTestFixture
protected ProjectService Projects { get; private set; } = null!;
protected CharacterService Characters { 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 CharacterArcService Arcs { 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<CharacterService> CharacterLogs { 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<TagService> TagLogs { get; private set; } = null!;
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
@@ -47,7 +35,6 @@ public abstract class ServiceTestFixture
ProjectLogs = new CapturingLogger<ProjectService>();
CharacterLogs = new CapturingLogger<CharacterService>();
ChapterLogs = new CapturingLogger<ChapterService>();
SceneLogs = new CapturingLogger<SceneService>();
BeatLogs = new CapturingLogger<BeatService>();
ArcLogs = new CapturingLogger<CharacterArcService>();
QuestionLogs = new CapturingLogger<OpenQuestionService>();
@@ -58,7 +45,6 @@ public abstract class ServiceTestFixture
Db.Context, Tags, CharacterLogs,
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator());
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
Scenes = new SceneService(Db.Context, SceneLogs, new CreateSceneRequestValidator(), new UpdateSceneRequestValidator());
Beats = new BeatService(
Db.Context, Tags, BeatLogs,
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator());
@@ -72,7 +58,6 @@ public abstract class ServiceTestFixture
OnSetUp();
}
/// <summary>Runs after the services exist, for per-class seed data.</summary>
protected virtual void OnSetUp()
{
}
-11
View File
@@ -4,16 +4,6 @@ using Novelly.Api.Data;
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
{
private readonly SqliteConnection _connection;
@@ -29,7 +19,6 @@ public class TestDatabase : IDisposable
public NovelDbContext Context { get; }
/// <summary>A second context over the same database, for asserting on persisted state.</summary>
public NovelDbContext CreateContext() =>
new(new DbContextOptionsBuilder<NovelDbContext>().UseSqlite(_connection).Options);