using System.Text.Json; using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common; using Novelly.Api.Locations; using Novelly.Api.Novels; using Novelly.Api.Questions; using Novelly.Api.Tags; namespace Novelly.Api.Agent; public record AgentToolResult(string Content, bool IsError); internal record ToolNotFound(string Entity, Guid Id) { public string Message => $"{Entity} '{Id}' was not found."; } public record AgentTool( string Name, string Description, JsonElement InputSchema, Func> Handler); public class NovelAgentToolset( NovelService novels, CharacterService characters, CharacterArcService arcs, ChapterService chapters, BeatService beats, TagService tags, LocationService locations, OpenQuestionService questions, ILogger logger) { private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = false, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }; private Dictionary? _byName; private IReadOnlyList Tools => [.. ByName.Values]; public IReadOnlyList Definitions => [.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; public async Task ExecuteAsync(string name, Guid novelId, JsonElement input, CancellationToken ct = default) { if (!ByName.TryGetValue(name, out var tool)) { logger.LogWarning("Agent requested unknown tool {Tool}", name); return new AgentToolResult($"No such tool: '{name}'.", true); } logger.LogDebug("Running tool {Tool} for novel {NovelId}", name, novelId); try { var result = await tool.Handler(novelId, input, ct); if (result is ToolNotFound notFound) { logger.LogWarning("Tool {Tool} for novel {NovelId} found no {Entity} {EntityId}", name, novelId, notFound.Entity, notFound.Id); return new AgentToolResult(notFound.Message, true); } logger.LogDebug("Tool {Tool} for novel {NovelId} succeeded", name, novelId); return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false); } catch (ArgumentException ex) { logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid argument", name, novelId); return new AgentToolResult(ex.Message, true); } catch (InvalidOperationException ex) { logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid operation", name, novelId); return new AgentToolResult(ex.Message, true); } } private static async Task OrNotFound(Task lookup, string entity, Guid id) where T : class => await lookup as object ?? new ToolNotFound(entity, id); private static async Task OrNotFound( Task lookup, Func map, string entity, Guid id) where TEntity : class => await lookup is { } value ? map(value)! : new ToolNotFound(entity, id); private static async Task DeletedOrNotFound(Task delete, string entity, Guid id) => await delete ? new { deleted = true } : new ToolNotFound(entity, id); private Dictionary ByName => _byName ??= Build().ToDictionary(t => t.Name); private IEnumerable Build() { yield return new AgentTool( "get_novel_brief", "Read the novel'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 (novelId, _, ct) => await OrNotFound(novels.GetAsync(novelId, ct), p => p.ToResponse(null), "Novel", novelId)); yield return new AgentTool( "update_novel_brief", "Revise the novel'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 (novelId, input, ct) => await OrNotFound(novels.UpdateAsync(novelId, new UpdateNovelRequest( 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), p => p.ToResponse(null), "Novel", novelId)); yield return new AgentTool( "list_characters", "List every character in the novel with their full dossiers.", new JsonSchemaBuilder().Build(), async (novelId, _, ct) => (await characters.ListAsync(novelId, ct)).Select(c => c.ToResponse())); yield return new AgentTool( "create_character", "Add a character dossier. Name is the only requirement — leave fields blank when " + "the writer has not decided them yet rather than inventing detail.", CharacterSchema(includeName: true, nameRequired: true).Build(), async (novelId, input, ct) => await OrNotFound(characters.CreateAsync(novelId, new CreateCharacterRequest( JsonInput.RequiredString(input, "name"), JsonInput.Enum(input, "role") ?? CharacterRole.Supporting, JsonInput.Enum(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"), JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId)); 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) => { var characterId = JsonInput.RequiredGuid(input, "character_id"); return await OrNotFound(characters.UpdateAsync( characterId, new UpdateCharacterRequest( JsonInput.String(input, "name"), JsonInput.Enum(input, "role"), JsonInput.Enum(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"), JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Character", characterId); }); yield return new AgentTool( "link_character_identity", "Record that a character is really another character — e.g. one introduced under one name " + "who is later revealed to be a character already in the novel under another name. Both " + "keep their own dossier and beats; the canonical identity is whichever character you link to.", new JsonSchemaBuilder() .Str("character_id", "Id of the character being revealed as someone else.", required: true) .Str("same_character_as_id", "Id of the character this one really is.", required: true) .Str("revealed_in_chapter_id", "Id of the chapter where the reveal happens, if any.") .Str("note", "Context on the reveal, e.g. how and why the disguise held.") .Build(), async (_, input, ct) => { var characterId = JsonInput.RequiredGuid(input, "character_id"); return await OrNotFound(characters.LinkIdentityAsync( characterId, new LinkCharacterIdentityRequest( JsonInput.RequiredGuid(input, "same_character_as_id"), JsonInput.Guid(input, "revealed_in_chapter_id"), JsonInput.String(input, "note")), ct), c => c.ToResponse(), "Character", characterId); }); yield return new AgentTool( "unlink_character_identity", "Remove a character's identity link, restoring it to its own separate identity.", new JsonSchemaBuilder() .Str("character_id", "Id of the character to unlink.", required: true) .Build(), async (_, input, ct) => { var characterId = JsonInput.RequiredGuid(input, "character_id"); return await DeletedOrNotFound(characters.UnlinkIdentityAsync(characterId, ct), "Character", characterId); }); 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)).Select(b => b.ToResponse())); 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) => { var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); return await OrNotFound(beats.CreateAsync( chapterId, new CreateBeatRequest( JsonInput.RequiredString(input, "title"), JsonInput.Int(input, "sort_order"), JsonInput.Guids(input, "character_ids"), JsonInput.String(input, "what_happened"), JsonInput.String(input, "whats_next"), JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId); }); 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) => { var beatId = JsonInput.RequiredGuid(input, "beat_id"); return await OrNotFound(beats.UpdateAsync( beatId, new UpdateBeatRequest( JsonInput.String(input, "title"), JsonInput.Int(input, "sort_order"), JsonInput.Guids(input, "character_ids"), JsonInput.String(input, "what_happened"), JsonInput.String(input, "whats_next"), JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId); }); 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) => { var beatId = JsonInput.RequiredGuid(input, "beat_id"); return await DeletedOrNotFound(beats.DeleteAsync(beatId, ct), "Beat", beatId); }); 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) => { var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); return await OrNotFound(beats.ReorderAsync( chapterId, new ReorderBeatsRequest( [.. (JsonInput.Strings(input, "beat_ids") ?? []) .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); }); yield return new AgentTool( "assign_character_to_beats", "Add a character to several beats at once. Leaves each beat's existing characters and " + "other fields alone — this only adds, it never removes.", new JsonSchemaBuilder() .Str("chapter_id", "Id of the chapter the beats belong to.", required: true) .Str("character_id", "Id of the character to add.", required: true) .StringArray("beat_ids", "Ids of the beats to add the character to.", required: true) .Build(), async (_, input, ct) => { var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); return await OrNotFound(beats.AssignCharacterAsync( chapterId, new AssignCharacterToBeatsRequest( JsonInput.RequiredGuid(input, "character_id"), [.. (JsonInput.Strings(input, "beat_ids") ?? []) .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); }); yield return new AgentTool( "move_beats", "Move one or more beats from one chapter to another, appending them to the target " + "chapter's end in the order given.", new JsonSchemaBuilder() .Str("chapter_id", "Id of the beats' current chapter.", required: true) .Str("target_chapter_id", "Id of the chapter to move the beats into.", required: true) .StringArray("beat_ids", "Ids of the beats to move.", required: true) .Build(), async (_, input, ct) => { var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); return await OrNotFound(beats.MoveAsync( chapterId, new MoveBeatsRequest( JsonInput.RequiredGuid(input, "target_chapter_id"), [.. (JsonInput.Strings(input, "beat_ids") ?? []) .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); }); yield return new AgentTool( "list_tags", "List the novel'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 (novelId, _, ct) => await tags.ListAsync(novelId, 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) => { var tagId = JsonInput.RequiredGuid(input, "tag_id"); return await OrNotFound(tags.GetReferencesAsync(tagId, ct), t => t.ToReferencesResponse(), "Tag", tagId); }); yield return new AgentTool( "list_locations", "List the novel's locations with how many chapters are set there. " + "Read this before inventing a new location so you reuse the writer's vocabulary.", new JsonSchemaBuilder().Build(), async (novelId, _, ct) => await locations.ListAsync(novelId, ct)); yield return new AgentTool( "get_location_references", "Cross-reference a location: every chapter set there.", new JsonSchemaBuilder() .Str("location_id", "Id of the location to trace.", required: true) .Build(), async (_, input, ct) => { var locationId = JsonInput.RequiredGuid(input, "location_id"); return await OrNotFound(locations.GetReferencesAsync(locationId, ct), l => l.ToReferencesResponse(), "Location", locationId); }); yield return new AgentTool( "list_chapters", "List the novel's chapters in manuscript order with beat and word counts.", new JsonSchemaBuilder().Build(), async (novelId, _, ct) => (await chapters.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse())); yield return new AgentTool( "get_chapter", "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(), async (_, input, ct) => { var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); return await OrNotFound(chapters.GetAsync(chapterId, ct), c => c.ToResponse(), "Chapter", chapterId); }); 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.") .StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.") .Str("notes", "Anything else worth recording.") .Enum("status", "Drafting status.", System.Enum.GetNames()) .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 (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest( JsonInput.RequiredString(input, "title"), JsonInput.Int(input, "number"), JsonInput.String(input, "summary"), JsonInput.Strings(input, "locations"), JsonInput.String(input, "notes"), JsonInput.Enum(input, "status") ?? DraftStatus.Planned, JsonInput.Int(input, "target_word_count"), JsonInput.String(input, "prose"), JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId)); yield return new AgentTool( "update_chapter", "Revise a chapter's title, number, summary, locations, 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.") .Int("number", "Position in the manuscript.") .Str("summary", "What the chapter covers.") .StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.") .Str("notes", "Anything else worth recording.") .Enum("status", "Drafting status.", System.Enum.GetNames()) .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) => { var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); return await OrNotFound(chapters.UpdateAsync( chapterId, new UpdateChapterRequest( JsonInput.String(input, "title"), JsonInput.Int(input, "number"), JsonInput.String(input, "summary"), JsonInput.Strings(input, "locations"), JsonInput.String(input, "notes"), JsonInput.Enum(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( "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) => { var characterId = JsonInput.RequiredGuid(input, "character_id"); return await OrNotFound( beats.ListForCharacterAsync(characterId, ct), list => list.Select(b => b.ToCharacterBeatResponse(characterId)), "Character", characterId); }); 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)).Select(s => s.ToResponse())); 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) => { var characterId = JsonInput.RequiredGuid(input, "character_id"); return await OrNotFound(arcs.CreateAsync( characterId, new CreateArcStageRequest( JsonInput.RequiredString(input, "title"), JsonInput.Int(input, "sort_order"), JsonInput.String(input, "description"), JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "Character", characterId); }); 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) => { var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id"); return await OrNotFound(arcs.UpdateAsync( arcStageId, new UpdateArcStageRequest( JsonInput.String(input, "title"), JsonInput.Int(input, "sort_order"), JsonInput.String(input, "description"), JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId); }); 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) => { var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id"); return await DeletedOrNotFound(arcs.DeleteAsync(arcStageId, ct), "CharacterArcStage", arcStageId); }); 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) => { var characterId = JsonInput.RequiredGuid(input, "character_id"); return await OrNotFound(arcs.ReorderAsync( characterId, new ReorderArcStagesRequest( [.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct), list => list.Select(s => s.ToResponse()), "Character", characterId); }); 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 (novelId, input, ct) => (await questions.ListAsync( novelId, JsonInput.Guid(input, "chapter_id"), JsonInput.Guid(input, "character_id"), JsonInput.Bool(input, "include_resolved") ?? false, ct)).Select(q => q.ToResponse())); 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 (novelId, input, ct) => await OrNotFound(questions.CreateAsync( novelId, new CreateOpenQuestionRequest( JsonInput.RequiredString(input, "question"), JsonInput.String(input, "detail"), JsonInput.Guid(input, "chapter_id"), JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Novel", novelId)); 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) => { var questionId = JsonInput.RequiredGuid(input, "question_id"); return await OrNotFound(questions.ResolveAsync( questionId, new ResolveOpenQuestionRequest( JsonInput.RequiredString(input, "resolution"), JsonInput.Bool(input, "append_to_notes") ?? false), ct), q => q.ToResponse(), "OpenQuestion", questionId); }); 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) => { var questionId = JsonInput.RequiredGuid(input, "question_id"); return await OrNotFound(questions.ReopenAsync(questionId, ct), q => q.ToResponse(), "OpenQuestion", questionId); }); 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) => { var questionId = JsonInput.RequiredGuid(input, "question_id"); return await DeletedOrNotFound(questions.DeleteAsync(questionId, ct), "OpenQuestion", questionId); }); } 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()) .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()) .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.") .StringArray("aliases", "Other names this character is known by. Replaces the existing aliases."); } private static JsonSchemaBuilder BeatSchema() => new JsonSchemaBuilder() .Int("sort_order", "Position in the chapter. Appended to the end when omitted.") .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.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags."); }