Files
novelly/src/Novelly.Api/Agent/NovelAgentToolset.cs
T
James WamplerandClaude Opus 5 0358667679 Add main/supporting characters, character arcs and open questions
Three things the outline could not express before:

Main vs supporting. A new CharacterImportance sits alongside CharacterRole
rather than inside it — role is the part a character plays (protagonist,
mentor, foil), importance is how much of the book they carry, and a mentor can
be either. Characters start Supporting and get promoted. Listings put main
characters first.

Character arcs. A main character's arc is a flat ordered list of stages, the
same shape as a chapter's beats and for the same reason: an arc is a sequence
of changes, not a tree. A stage can be pinned to the chapter where it lands.
Nothing refuses an arc on a supporting character — demoting someone should not
delete their work.

Open questions. What the writer has not decided yet, hanging off a chapter
outline, a character, both, or neither. They can be resolved, reopened or
deleted, and resolving can append the decision to the notes of whatever the
question was attached to, so it lands where the writer will re-read it.
Resolved questions drop off the list unless asked for.

Also adds GET /api/characters/{id}/beats — every beat a character appears in,
in manuscript order, carrying each beat's chapter so the character page can
link straight into that chapter's outline.

Deletes are deliberately asymmetric: deleting a chapter unpins arc stages and
detaches questions rather than taking them, because a plan outlives a decision
about where the chapter break falls. Deleting a character or project does take
their arcs and questions.

All three capabilities are surfaced in the REST API, the agent toolset and the
MCP server, per the one-source-of-truth rule.

Two things worth flagging in the migration: EF's generated default for the new
Importance column was an empty string, which does not parse back to a
CharacterImportance and would have faulted every read of an existing dossier —
it now defaults to Supporting, verified by migrating a database seeded on the
old schema and reading the row back through the API. And the earlier migrations
were renamed to the namespace EF derives from the output folder, so future
`migrations add` runs stop drifting.

72 tests pass (28 new). The endpoints were also exercised over curl end to end:
arc stages resolving their chapter, a character's beats across chapters, and a
question attached to both a chapter and a character resolving into both sets of
notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
2026-08-06 12:11:20 -07:00

581 lines
29 KiB
C#

using System.Text.Json;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
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 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)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
private Dictionary<string, AgentTool>? _byName;
public IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
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))
{
return new AgentToolResult($"No such tool: '{name}'.", true);
}
try
{
var result = await tool.Handler(projectId, input, ct);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
}
catch (NotFoundException ex)
{
return new AgentToolResult(ex.Message, true);
}
catch (ArgumentException ex)
{
return new AgentToolResult(ex.Message, true);
}
catch (InvalidOperationException ex)
{
return new AgentToolResult(ex.Message, true);
}
}
private Dictionary<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
private IEnumerable<AgentTool> Build()
{
yield return new AgentTool(
"get_project_brief",
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
+ "Call this first in a conversation to ground yourself in what the book is.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await projects.GetAsync(projectId, ct));
yield return new AgentTool(
"update_project_brief",
"Revise the project's top-level fields. Only the fields you supply change; "
+ "pass an empty string to clear a field.",
new JsonSchemaBuilder()
.Str("title", "New title.")
.Str("author", "Author name.")
.Str("genre", "Genre or category.")
.Str("logline", "One-sentence pitch.")
.Str("synopsis", "Paragraph-length summary of the whole book.")
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
.Int("target_word_count", "Target manuscript length in words.")
.Build(),
async (projectId, input, ct) => await projects.UpdateAsync(projectId, new UpdateProjectRequest(
JsonInput.String(input, "title"),
JsonInput.String(input, "author"),
JsonInput.String(input, "genre"),
JsonInput.String(input, "logline"),
JsonInput.String(input, "synopsis"),
JsonInput.String(input, "notes"),
JsonInput.Int(input, "target_word_count")), ct));
yield return new AgentTool(
"list_characters",
"List every character in the project with their full dossiers.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await characters.ListAsync(projectId, ct));
yield return new AgentTool(
"create_character",
"Add a character dossier. Name is the only requirement — leave fields blank when "
+ "the writer has not decided them yet rather than inventing detail.",
CharacterSchema(includeName: true, nameRequired: true).Build(),
async (projectId, input, ct) => await characters.CreateAsync(projectId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
JsonInput.String(input, "age"),
JsonInput.String(input, "pronouns"),
JsonInput.String(input, "occupation"),
JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"),
JsonInput.String(input, "need"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"update_character",
"Revise an existing character dossier. Only the fields you supply change.",
CharacterSchema(includeName: true, nameRequired: false)
.Str("character_id", "Id of the character to update.", required: true)
.Build(),
async (_, input, ct) => await characters.UpdateAsync(
JsonInput.RequiredGuid(input, "character_id"),
new UpdateCharacterRequest(
JsonInput.String(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role"),
JsonInput.Enum<CharacterImportance>(input, "importance"),
JsonInput.String(input, "age"),
JsonInput.String(input, "pronouns"),
JsonInput.String(input, "occupation"),
JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"),
JsonInput.String(input, "need"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"get_chapter_outline",
"Read a chapter's outline: its summary paragraph and its beat table, in order. "
+ "A beat is one row — a short title, whose beat it is, what happened, and what it sets up.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter whose outline to read.", required: true)
.Build(),
async (_, input, ct) => await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
yield return new AgentTool(
"create_beat",
"Add a beat to a chapter's outline. Keep the title to three to five words — it is a "
+ "handle, not a sentence; the detail belongs in what_happened and whats_next.",
BeatSchema()
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
.Str("title", "Three to five words naming the beat.", required: true)
.Build(),
async (_, input, ct) => await beats.CreateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new CreateBeatRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"),
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"update_beat",
"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.",
BeatSchema()
.Str("beat_id", "Id of the beat to update.", required: true)
.Str("title", "Three to five words naming the beat.")
.Build(),
async (_, input, ct) => await beats.UpdateAsync(
JsonInput.RequiredGuid(input, "beat_id"),
new UpdateBeatRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"),
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"delete_beat",
"Remove a beat from a chapter's outline. Confirm with the writer before calling it.",
new JsonSchemaBuilder()
.Str("beat_id", "Id of the beat to delete.", required: true)
.Build(),
async (_, input, ct) =>
{
await beats.DeleteAsync(JsonInput.RequiredGuid(input, "beat_id"), ct);
return new { deleted = true };
});
yield return new AgentTool(
"reorder_beats",
"Renumber a chapter's beats to match the order given. List every beat id in the "
+ "order you want; any you leave out keep their relative position at the end.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true)
.StringArray("beat_ids", "Beat ids in their new order.", required: true)
.Build(),
async (_, input, ct) => await beats.ReorderAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new ReorderBeatsRequest(
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
.Where(g => g != Guid.Empty)]), ct));
yield return new AgentTool(
"list_tags",
"List the project's tags with how many characters, chapters and beats carry each. "
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await tags.ListAsync(projectId, ct));
yield return new AgentTool(
"get_tag_references",
"Cross-reference a tag: every character, chapter and beat carrying it. Use this to "
+ "trace a motif or a thread through the book.",
new JsonSchemaBuilder()
.Str("tag_id", "Id of the tag to trace.", required: true)
.Build(),
async (_, input, ct) => await tags.GetReferencesAsync(JsonInput.RequiredGuid(input, "tag_id"), ct));
yield return new AgentTool(
"list_chapters",
"List the project's chapters in manuscript order with scene and word counts.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await chapters.ListAsync(projectId, ct));
yield return new AgentTool(
"get_chapter",
"Read one chapter in full, including all of its scenes and any drafted prose.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to read.", required: true)
.Build(),
async (_, input, ct) => await chapters.GetAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
yield return new AgentTool(
"create_chapter",
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.",
new JsonSchemaBuilder()
.Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based.")
.Str("summary", "What the chapter covers.")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("setting", "Where and when the chapter takes place.")
.Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(),
async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"update_chapter",
"Revise a chapter's title, number, summary, POV, setting, notes or status.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to update.", required: true)
.Str("title", "New title.")
.Int("number", "Position in the manuscript.")
.Str("summary", "What the chapter covers.")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("setting", "Where and when the chapter takes place.")
.Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(),
async (_, input, ct) => await chapters.UpdateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new UpdateChapterRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct));
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) => await scenes.CreateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
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));
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) => await scenes.UpdateAsync(
JsonInput.RequiredGuid(input, "scene_id"),
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));
yield return new AgentTool(
"get_character_beats",
"Every beat this character appears in, across the whole book, in manuscript order. "
+ "Read this before revising a character — it is what they actually do on the page, "
+ "as opposed to what the dossier claims about them.",
new JsonSchemaBuilder()
.Str("character_id", "Id of the character.", required: true)
.Build(),
async (_, input, ct) => await beats.ListForCharacterAsync(
JsonInput.RequiredGuid(input, "character_id"), ct));
yield return new AgentTool(
"get_character_arc",
"Read a main character's arc: the ordered stages of how they change. Each stage may "
+ "be pinned to the chapter where it lands.",
new JsonSchemaBuilder()
.Str("character_id", "Id of the character.", required: true)
.Build(),
async (_, input, ct) => await arcs.ListAsync(
JsonInput.RequiredGuid(input, "character_id"), ct));
yield return new AgentTool(
"add_arc_stage",
"Add a stage to a character's arc. Arcs are for main characters — promote the "
+ "character first with update_character if they are still Supporting.",
ArcStageSchema()
.Str("character_id", "Id of the character whose arc to add to.", required: true)
.Str("title", "A short handle for the change, three to five words.", required: true)
.Build(),
async (_, input, ct) => await arcs.CreateAsync(
JsonInput.RequiredGuid(input, "character_id"),
new CreateArcStageRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct));
yield return new AgentTool(
"update_arc_stage",
"Revise a stage of a character's arc. Only the fields you supply change.",
ArcStageSchema()
.Str("arc_stage_id", "Id of the arc stage to update.", required: true)
.Str("title", "New title for the stage.")
.Build(),
async (_, input, ct) => await arcs.UpdateAsync(
JsonInput.RequiredGuid(input, "arc_stage_id"),
new UpdateArcStageRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct));
yield return new AgentTool(
"delete_arc_stage",
"Remove a stage from a character's arc.",
new JsonSchemaBuilder()
.Str("arc_stage_id", "Id of the arc stage to delete.", required: true)
.Build(),
async (_, input, ct) =>
{
await arcs.DeleteAsync(JsonInput.RequiredGuid(input, "arc_stage_id"), ct);
return new { deleted = true };
});
yield return new AgentTool(
"reorder_arc_stages",
"Renumber a character's arc to match the order given. Stages left out keep their "
+ "relative position after the ones listed.",
new JsonSchemaBuilder()
.Str("character_id", "Id of the character whose arc to reorder.", required: true)
.StringArray("stage_ids", "Arc stage ids in the order wanted.", required: true)
.Build(),
async (_, input, ct) => await arcs.ReorderAsync(
JsonInput.RequiredGuid(input, "character_id"),
new ReorderArcStagesRequest(
[.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct));
yield return new AgentTool(
"list_open_questions",
"The decisions the writer has not made yet. Read this before proposing changes — an "
+ "open question is a place the writer is still thinking, not a gap to fill in for them.",
new JsonSchemaBuilder()
.Str("chapter_id", "Narrow to questions about one chapter outline.")
.Str("character_id", "Narrow to questions about one character.")
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
.Build(),
async (projectId, input, ct) => await questions.ListAsync(
projectId,
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id"),
JsonInput.Bool(input, "include_resolved") ?? false,
ct));
yield return new AgentTool(
"raise_open_question",
"Record a question the writer has not settled. Attach it to the chapter outline "
+ "and/or the character it is about. Prefer raising a question over guessing when "
+ "the writer has not decided something.",
new JsonSchemaBuilder()
.Str("question", "The question, in one line.", required: true)
.Str("detail", "The thinking around it — options, and what each costs.")
.Str("chapter_id", "The chapter outline this is about, if any.")
.Str("character_id", "The character this is about, if any.")
.Build(),
async (projectId, input, ct) => await questions.CreateAsync(
projectId,
new CreateOpenQuestionRequest(
JsonInput.RequiredString(input, "question"),
JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct));
yield return new AgentTool(
"resolve_open_question",
"Settle a question with what the writer decided. Set append_to_notes to also write "
+ "the resolution into the notes of the chapter and character it hangs off.",
new JsonSchemaBuilder()
.Str("question_id", "Id of the question to resolve.", required: true)
.Str("resolution", "What was decided.", required: true)
.Bool("append_to_notes", "Also append the resolution to the associated notes.")
.Build(),
async (_, input, ct) => await questions.ResolveAsync(
JsonInput.RequiredGuid(input, "question_id"),
new ResolveOpenQuestionRequest(
JsonInput.RequiredString(input, "resolution"),
JsonInput.Bool(input, "append_to_notes") ?? false), ct));
yield return new AgentTool(
"reopen_question",
"Put a resolved question back on the list. Anything already appended to notes stays.",
new JsonSchemaBuilder()
.Str("question_id", "Id of the question to reopen.", required: true)
.Build(),
async (_, input, ct) => await questions.ReopenAsync(
JsonInput.RequiredGuid(input, "question_id"), ct));
yield return new AgentTool(
"delete_open_question",
"Delete a question outright. Resolving is usually better — it keeps the decision.",
new JsonSchemaBuilder()
.Str("question_id", "Id of the question to delete.", required: true)
.Build(),
async (_, input, ct) =>
{
await questions.DeleteAsync(JsonInput.RequiredGuid(input, "question_id"), ct);
return new { deleted = true };
});
}
private static JsonSchemaBuilder ArcStageSchema() =>
new JsonSchemaBuilder()
.Int("sort_order", "Position in the arc. Appended to the end when omitted.")
.Str("description", "What shifts in the character here, and what it costs them.")
.Str("chapter_id", "The chapter where this stage lands, if it is pinned to one.");
private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired)
{
var schema = new JsonSchemaBuilder();
if (includeName)
{
schema.Str("name", "The character's name.", nameRequired);
}
return schema
.Enum("role", "The part they play in the story.", System.Enum.GetNames<CharacterRole>())
.Enum(
"importance",
"How much of the book they carry. Main characters are the few the story is "
+ "about and are worth an arc; everyone else is Supporting.",
System.Enum.GetNames<CharacterImportance>())
.Str("age", "Age, exact or approximate.")
.Str("pronouns", "The pronouns this character uses.")
.Str("occupation", "What they do.")
.Str("appearance", "How they look.")
.Str("personality", "Temperament, habits, how they treat people.")
.Str("backstory", "History that shapes who they are now.")
.Str("want", "What they consciously pursue.")
.Str("need", "What they actually need, usually at odds with what they want.")
.Str("internal_conflict", "The war inside them.")
.Str("external_conflict", "What in the world opposes them.")
.Str("arc_summary", "How they change over the course of the book.")
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
.Str("notes", "Anything else worth recording.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
}
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.")
.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>());
}