diff --git a/src/Novelly.Api/Agent/JsonSchema.cs b/src/Novelly.Api/Agent/JsonSchema.cs
index a2e5b86..bc3323c 100644
--- a/src/Novelly.Api/Agent/JsonSchema.cs
+++ b/src/Novelly.Api/Agent/JsonSchema.cs
@@ -114,6 +114,26 @@ public static class JsonInput
};
}
+ ///
+ /// Reads a boolean flag. Models sometimes send "true" as a string even when the
+ /// schema says boolean, so both spellings are accepted.
+ ///
+ public static bool? Bool(JsonElement input, string name)
+ {
+ if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty(name, out var value))
+ {
+ return null;
+ }
+
+ return value.ValueKind switch
+ {
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ JsonValueKind.String when bool.TryParse(value.GetString(), out var flag) => flag,
+ _ => null
+ };
+ }
+
///
/// 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,
diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs
index a5a3afa..1398a97 100644
--- a/src/Novelly.Api/Agent/NovelAgentToolset.cs
+++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs
@@ -4,6 +4,7 @@ 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;
@@ -27,10 +28,12 @@ public record AgentTool(
public class NovelAgentToolset(
ProjectService projects,
CharacterService characters,
+ CharacterArcService arcs,
ChapterService chapters,
BeatService beats,
SceneService scenes,
- TagService tags)
+ TagService tags,
+ OpenQuestionService questions)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
@@ -123,6 +126,7 @@ public class NovelAgentToolset(
async (projectId, input, ct) => await characters.CreateAsync(projectId, 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"),
@@ -149,6 +153,7 @@ public class NovelAgentToolset(
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"),
@@ -364,8 +369,162 @@ public class NovelAgentToolset(
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum(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();
@@ -377,6 +536,11 @@ public class NovelAgentToolset(
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.")
diff --git a/src/Novelly.Api/Beats/BeatDtos.cs b/src/Novelly.Api/Beats/BeatDtos.cs
index 598e918..169d4b3 100644
--- a/src/Novelly.Api/Beats/BeatDtos.cs
+++ b/src/Novelly.Api/Beats/BeatDtos.cs
@@ -38,6 +38,22 @@ public record UpdateBeatRequest(
Guid? SceneId = null,
IReadOnlyList? Tags = null);
+///
+/// A beat this character appears in, carrying enough of its chapter to link straight to
+/// the row in that chapter's outline.
+///
+public record CharacterBeatDto(
+ Guid Id,
+ Guid ChapterId,
+ int ChapterNumber,
+ string ChapterTitle,
+ int SortOrder,
+ string Title,
+ string? WhatHappened,
+ string? WhatsNext,
+ Guid? SceneId,
+ string? SceneTitle);
+
/// Reorders a chapter's beats in one call, by listing their ids in the order wanted.
public record ReorderBeatsRequest(IReadOnlyList BeatIds);
diff --git a/src/Novelly.Api/Beats/BeatEndpoints.cs b/src/Novelly.Api/Beats/BeatEndpoints.cs
index bc2dd66..29fd672 100644
--- a/src/Novelly.Api/Beats/BeatEndpoints.cs
+++ b/src/Novelly.Api/Beats/BeatEndpoints.cs
@@ -23,6 +23,12 @@ public static class BeatEndpoints
Results.Ok(await service.ReorderAsync(chapterId, request, ct)))
.WithSummary("Renumber a chapter's beats to match the order given.");
+ app.MapGet("/api/characters/{characterId:guid}/beats", async (
+ Guid characterId, BeatService service, CancellationToken ct) =>
+ Results.Ok(await service.ListForCharacterAsync(characterId, ct)))
+ .WithTags("Beats")
+ .WithSummary("Every beat this character appears in, in manuscript order.");
+
var beats = app.MapGroup("/api/beats").WithTags("Beats");
beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
diff --git a/src/Novelly.Api/Beats/BeatService.cs b/src/Novelly.Api/Beats/BeatService.cs
index 288047c..eacf0a3 100644
--- a/src/Novelly.Api/Beats/BeatService.cs
+++ b/src/Novelly.Api/Beats/BeatService.cs
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
+using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Tags;
@@ -25,6 +26,44 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
+ ///
+ /// 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.
+ ///
+ public async Task> ListForCharacterAsync(
+ Guid characterId, CancellationToken ct = default)
+ {
+ if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct))
+ {
+ throw new NotFoundException(nameof(Character), characterId);
+ }
+
+ var beats = await db.Beats
+ .Include(b => b.Chapter)
+ .Include(b => b.Scene)
+ .Where(b => b.CharacterId == characterId)
+ .ToListAsync(ct);
+
+ return
+ [
+ .. beats
+ .OrderBy(b => b.Chapter?.Number ?? 0)
+ .ThenBy(b => b.SortOrder)
+ .Select(b => new CharacterBeatDto(
+ b.Id,
+ b.ChapterId,
+ b.Chapter?.Number ?? 0,
+ b.Chapter?.Title ?? "(unknown chapter)",
+ b.SortOrder,
+ b.Title,
+ b.WhatHappened,
+ b.WhatsNext,
+ b.SceneId,
+ b.Scene?.Title))
+ ];
+ }
+
public async Task CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
{
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct)
diff --git a/src/Novelly.Api/Characters/Character.cs b/src/Novelly.Api/Characters/Character.cs
index 943b49a..35fb0d4 100644
--- a/src/Novelly.Api/Characters/Character.cs
+++ b/src/Novelly.Api/Characters/Character.cs
@@ -16,6 +16,12 @@ public class Character
public string Name { get; set; } = string.Empty;
public CharacterRole Role { get; set; } = CharacterRole.Supporting;
+ ///
+ /// 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.
+ ///
+ public CharacterImportance Importance { get; set; } = CharacterImportance.Supporting;
+
public string? Age { get; set; }
public string? Pronouns { get; set; }
public string? Occupation { get; set; }
@@ -33,7 +39,10 @@ public class Character
public string? InternalConflict { get; set; }
public string? ExternalConflict { get; set; }
- /// How the character changes over the course of the book.
+ ///
+ /// How the character changes over the course of the book, in a sentence or two.
+ /// breaks the same change into ordered steps.
+ ///
public string? ArcSummary { get; set; }
/// Speech patterns, verbal tics, register — anything that makes dialogue sound like them.
@@ -46,6 +55,9 @@ public class Character
public List Relationships { get; set; } = [];
public List Tags { get; set; } = [];
+
+ /// The character's arc, in order. Kept mainly for main characters.
+ public List ArcStages { get; set; } = [];
}
/// A directed relationship from one character to another.
diff --git a/src/Novelly.Api/Characters/CharacterArcService.cs b/src/Novelly.Api/Characters/CharacterArcService.cs
new file mode 100644
index 0000000..9adff1e
--- /dev/null
+++ b/src/Novelly.Api/Characters/CharacterArcService.cs
@@ -0,0 +1,143 @@
+using Microsoft.EntityFrameworkCore;
+using Novelly.Api.Common;
+using Novelly.Api.Data;
+
+namespace Novelly.Api.Characters;
+
+///
+/// A character's arc: a flat, ordered list of the changes they go through. Same shape as
+/// a chapter's beats, and for the same reason — an arc is a sequence, not a tree.
+///
+///
+/// Arcs are only really worth keeping for main characters, but nothing here refuses one
+/// on a supporting character. Demoting someone should not delete work, and a character
+/// who turns out to matter gets promoted after the arc is already sketched.
+///
+public class CharacterArcService(INovelDbContext db)
+{
+ public async Task> ListAsync(Guid characterId, CancellationToken ct = default)
+ {
+ var stages = await Query()
+ .Where(s => s.CharacterId == characterId)
+ .OrderBy(s => s.SortOrder)
+ .ToListAsync(ct);
+
+ return [.. stages.Select(s => s.ToDto())];
+ }
+
+ public async Task GetAsync(Guid id, CancellationToken ct = default) =>
+ (await FindAsync(id, ct)).ToDto();
+
+ public async Task CreateAsync(
+ Guid characterId, CreateArcStageRequest request, CancellationToken ct = default)
+ {
+ var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct)
+ ?? throw new NotFoundException(nameof(Character), characterId);
+
+ await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
+
+ var stage = new CharacterArcStage
+ {
+ CharacterId = characterId,
+ Title = request.Title,
+ SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct),
+ Description = request.Description,
+ ChapterId = request.ChapterId
+ };
+
+ db.CharacterArcStages.Add(stage);
+ await db.SaveChangesAsync(ct);
+ return (await FindAsync(stage.Id, ct)).ToDto();
+ }
+
+ public async Task UpdateAsync(
+ Guid id, UpdateArcStageRequest request, CancellationToken ct = default)
+ {
+ var stage = await FindAsync(id, ct);
+
+ var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct)
+ ?? throw new NotFoundException(nameof(Character), stage.CharacterId);
+
+ await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
+
+ stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title;
+ stage.SortOrder = request.SortOrder ?? stage.SortOrder;
+ stage.Description = Patch.Apply(stage.Description, request.Description);
+ stage.ChapterId = request.ChapterId ?? stage.ChapterId;
+ stage.UpdatedAt = DateTimeOffset.UtcNow;
+
+ await db.SaveChangesAsync(ct);
+ return (await FindAsync(id, ct)).ToDto();
+ }
+
+ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
+ {
+ var stage = await FindAsync(id, ct);
+ db.CharacterArcStages.Remove(stage);
+ await db.SaveChangesAsync(ct);
+ }
+
+ ///
+ /// Renumbers a character's arc to match the order given. Stages left out keep their
+ /// relative position after the ones listed, exactly as beat reordering works.
+ ///
+ public async Task> ReorderAsync(
+ Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default)
+ {
+ var stages = await db.CharacterArcStages
+ .Where(s => s.CharacterId == characterId)
+ .ToListAsync(ct);
+
+ var missing = request.StageIds.Where(id => stages.All(s => s.Id != id)).ToList();
+ if (missing.Count > 0)
+ {
+ throw new NotFoundException(nameof(CharacterArcStage), missing[0]);
+ }
+
+ var order = 1;
+ foreach (var id in request.StageIds)
+ {
+ stages.Single(s => s.Id == id).SortOrder = order++;
+ }
+
+ foreach (var stage in stages.Where(s => !request.StageIds.Contains(s.Id)).OrderBy(s => s.SortOrder))
+ {
+ stage.SortOrder = order++;
+ }
+
+ await db.SaveChangesAsync(ct);
+ return await ListAsync(characterId, ct);
+ }
+
+ private async Task EnsureChapterIsInSameProjectAsync(
+ Character character, Guid? chapterId, CancellationToken ct)
+ {
+ if (chapterId is not { } id)
+ {
+ return;
+ }
+
+ var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct);
+
+ if (!belongs)
+ {
+ throw new InvalidOperationException(
+ "An arc stage can only point at a chapter in the same project as its character.");
+ }
+ }
+
+ private async Task NextSortOrderAsync(Guid characterId, CancellationToken ct)
+ {
+ var max = await db.CharacterArcStages
+ .Where(s => s.CharacterId == characterId)
+ .MaxAsync(s => (int?)s.SortOrder, ct);
+
+ return (max ?? 0) + 1;
+ }
+
+ private IQueryable Query() => db.CharacterArcStages.Include(s => s.Chapter);
+
+ private async Task FindAsync(Guid id, CancellationToken ct) =>
+ await Query().FirstOrDefaultAsync(s => s.Id == id, ct)
+ ?? throw new NotFoundException(nameof(CharacterArcStage), id);
+}
diff --git a/src/Novelly.Api/Characters/CharacterArcStage.cs b/src/Novelly.Api/Characters/CharacterArcStage.cs
new file mode 100644
index 0000000..e101f6c
--- /dev/null
+++ b/src/Novelly.Api/Characters/CharacterArcStage.cs
@@ -0,0 +1,31 @@
+using Novelly.Api.Chapters;
+
+namespace Novelly.Api.Characters;
+
+///
+/// One step in a main character's arc. Flat and ordered by , the
+/// same shape as a chapter's beats — an arc is a sequence of changes, not a tree.
+///
+public class CharacterArcStage
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ public Guid CharacterId { get; set; }
+ public Character? Character { get; set; }
+
+ /// Position in the arc, 1-based.
+ public int SortOrder { get; set; }
+
+ /// A short handle for the change — "stops covering for her brother".
+ public string Title { get; set; } = string.Empty;
+
+ /// What shifts in the character here, and what it costs them.
+ public string? Description { get; set; }
+
+ /// Optionally, where in the manuscript this stage lands.
+ public Guid? ChapterId { get; set; }
+ public Chapter? Chapter { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/src/Novelly.Api/Characters/CharacterDtos.cs b/src/Novelly.Api/Characters/CharacterDtos.cs
index f6e2d07..8c78264 100644
--- a/src/Novelly.Api/Characters/CharacterDtos.cs
+++ b/src/Novelly.Api/Characters/CharacterDtos.cs
@@ -7,6 +7,7 @@ public record CharacterDto(
Guid ProjectId,
string Name,
CharacterRole Role,
+ CharacterImportance Importance,
string? Age,
string? Pronouns,
string? Occupation,
@@ -22,6 +23,7 @@ public record CharacterDto(
string? Notes,
IReadOnlyList Relationships,
IReadOnlyList Tags,
+ IReadOnlyList ArcStages,
DateTimeOffset UpdatedAt);
public record RelationshipDto(
@@ -34,6 +36,7 @@ public record RelationshipDto(
public record CreateCharacterRequest(
string Name,
CharacterRole Role = CharacterRole.Supporting,
+ CharacterImportance Importance = CharacterImportance.Supporting,
string? Age = null,
string? Pronouns = null,
string? Occupation = null,
@@ -56,6 +59,7 @@ public record CreateCharacterRequest(
public record UpdateCharacterRequest(
string? Name = null,
CharacterRole? Role = null,
+ CharacterImportance? Importance = null,
string? Age = null,
string? Pronouns = null,
string? Occupation = null,
@@ -76,10 +80,38 @@ public record CreateRelationshipRequest(
string RelationshipType,
string? Description = null);
+public record ArcStageDto(
+ Guid Id,
+ Guid CharacterId,
+ int SortOrder,
+ string Title,
+ string? Description,
+ Guid? ChapterId,
+ int? ChapterNumber,
+ string? ChapterTitle,
+ DateTimeOffset UpdatedAt);
+
+public record CreateArcStageRequest(
+ string Title,
+ int? SortOrder = null,
+ string? Description = null,
+ Guid? ChapterId = null);
+
+/// Patch-style update. A null field is left alone; an empty string clears it.
+public record UpdateArcStageRequest(
+ string? Title = null,
+ int? SortOrder = null,
+ string? Description = null,
+ Guid? ChapterId = null);
+
+/// Reorders a character's arc in one call, by listing the stage ids in the order wanted.
+public record ReorderArcStagesRequest(IReadOnlyList StageIds);
+
+
public static class CharacterMapping
{
public static CharacterDto ToDto(this Character c) => new(
- c.Id, c.ProjectId, c.Name, c.Role, c.Age, c.Pronouns, c.Occupation,
+ c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
[.. c.Relationships.Select(r => new RelationshipDto(
@@ -89,5 +121,17 @@ public static class CharacterMapping
r.RelationshipType,
r.Description))],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
+ [.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToDto())],
c.UpdatedAt);
+
+ public static ArcStageDto ToDto(this CharacterArcStage s) => new(
+ s.Id,
+ s.CharacterId,
+ s.SortOrder,
+ s.Title,
+ s.Description,
+ s.ChapterId,
+ s.Chapter?.Number,
+ s.Chapter?.Title,
+ s.UpdatedAt);
}
diff --git a/src/Novelly.Api/Characters/CharacterEndpoints.cs b/src/Novelly.Api/Characters/CharacterEndpoints.cs
index 9dda3df..1638e02 100644
--- a/src/Novelly.Api/Characters/CharacterEndpoints.cs
+++ b/src/Novelly.Api/Characters/CharacterEndpoints.cs
@@ -49,6 +49,42 @@ public static class CharacterEndpoints
})
.WithSummary("Remove a relationship.");
+ characters.MapGet("/{id:guid}/arc", async (
+ Guid id, CharacterArcService service, CancellationToken ct) =>
+ Results.Ok(await service.ListAsync(id, ct)))
+ .WithSummary("Read a character's arc: its stages, in order.");
+
+ characters.MapPost("/{id:guid}/arc", async (
+ Guid id, CreateArcStageRequest request, CharacterArcService service, CancellationToken ct) =>
+ {
+ var created = await service.CreateAsync(id, request, ct);
+ return Results.Created($"/api/arc-stages/{created.Id}", created);
+ })
+ .WithSummary("Add a stage to a character's arc.");
+
+ characters.MapPost("/{id:guid}/arc/reorder", async (
+ Guid id, ReorderArcStagesRequest request, CharacterArcService service, CancellationToken ct) =>
+ Results.Ok(await service.ReorderAsync(id, request, ct)))
+ .WithSummary("Renumber a character's arc to match the order given.");
+
+ var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters");
+
+ arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
+ Results.Ok(await service.GetAsync(id, ct)))
+ .WithSummary("Read one arc stage.");
+
+ arcStages.MapPatch("/{id:guid}", async (
+ Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) =>
+ Results.Ok(await service.UpdateAsync(id, request, ct)))
+ .WithSummary("Update an arc stage.");
+
+ arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
+ {
+ await service.DeleteAsync(id, ct);
+ return Results.NoContent();
+ })
+ .WithSummary("Delete an arc stage.");
+
return app;
}
}
diff --git a/src/Novelly.Api/Characters/CharacterImportance.cs b/src/Novelly.Api/Characters/CharacterImportance.cs
new file mode 100644
index 0000000..537bc40
--- /dev/null
+++ b/src/Novelly.Api/Characters/CharacterImportance.cs
@@ -0,0 +1,13 @@
+namespace Novelly.Api.Characters;
+
+///
+/// How much of the book a character carries. This is separate from
+/// : role is the part they play in the story (protagonist,
+/// mentor, foil), importance is how much weight they take. A mentor can be either.
+/// Main characters are the ones worth tracking an arc for.
+///
+public enum CharacterImportance
+{
+ Main,
+ Supporting
+}
diff --git a/src/Novelly.Api/Characters/CharacterService.cs b/src/Novelly.Api/Characters/CharacterService.cs
index fe2d599..03b1161 100644
--- a/src/Novelly.Api/Characters/CharacterService.cs
+++ b/src/Novelly.Api/Characters/CharacterService.cs
@@ -12,7 +12,8 @@ public class CharacterService(INovelDbContext db, TagService tags)
{
var characters = await Query()
.Where(c => c.ProjectId == projectId)
- .OrderBy(c => c.Role)
+ .OrderBy(c => c.Importance)
+ .ThenBy(c => c.Role)
.ThenBy(c => c.Name)
.ToListAsync(ct);
@@ -31,6 +32,7 @@ public class CharacterService(INovelDbContext db, TagService tags)
ProjectId = projectId,
Name = request.Name,
Role = request.Role,
+ Importance = request.Importance,
Age = request.Age,
Pronouns = request.Pronouns,
Occupation = request.Occupation,
@@ -62,6 +64,7 @@ public class CharacterService(INovelDbContext db, TagService tags)
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
character.Role = request.Role ?? character.Role;
+ character.Importance = request.Importance ?? character.Importance;
character.Age = Patch.Apply(character.Age, request.Age);
character.Pronouns = Patch.Apply(character.Pronouns, request.Pronouns);
character.Occupation = Patch.Apply(character.Occupation, request.Occupation);
@@ -133,7 +136,9 @@ public class CharacterService(INovelDbContext db, TagService tags)
db.Characters
.Include(c => c.Relationships)
.ThenInclude(r => r.RelatedCharacter)
- .Include(c => c.Tags);
+ .Include(c => c.Tags)
+ .Include(c => c.ArcStages)
+ .ThenInclude(s => s.Chapter);
private async Task FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(c => c.Id == id, ct)
diff --git a/src/Novelly.Api/Common/NovellyServiceRegistration.cs b/src/Novelly.Api/Common/NovellyServiceRegistration.cs
index 2f753a5..42109db 100644
--- a/src/Novelly.Api/Common/NovellyServiceRegistration.cs
+++ b/src/Novelly.Api/Common/NovellyServiceRegistration.cs
@@ -5,6 +5,7 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Data;
using Novelly.Api.Projects;
+using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
@@ -27,10 +28,12 @@ public static class NovellyServiceRegistration
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Novelly.Api/Data/INovelDbContext.cs b/src/Novelly.Api/Data/INovelDbContext.cs
index db25917..9d6ba42 100644
--- a/src/Novelly.Api/Data/INovelDbContext.cs
+++ b/src/Novelly.Api/Data/INovelDbContext.cs
@@ -4,6 +4,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
+using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
@@ -18,10 +19,12 @@ public interface INovelDbContext
DbSet Projects { get; }
DbSet Characters { get; }
DbSet CharacterRelationships { get; }
+ DbSet CharacterArcStages { get; }
DbSet Beats { get; }
DbSet Tags { get; }
DbSet Chapters { get; }
DbSet Scenes { get; }
+ DbSet OpenQuestions { get; }
DbSet Conversations { get; }
DbSet AgentMessages { get; }
diff --git a/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.Designer.cs b/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.Designer.cs
index 5866962..34c50db 100644
--- a/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.Designer.cs
+++ b/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.Designer.cs
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
-namespace Novelly.Api.Migrations
+namespace Novelly.Api.Data.Migrations
{
[DbContext(typeof(NovelDbContext))]
[Migration("20260806023249_InitialSchema")]
diff --git a/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.cs b/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.cs
index 8aa7c43..06155df 100644
--- a/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.cs
+++ b/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.cs
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
-namespace Novelly.Api.Migrations
+namespace Novelly.Api.Data.Migrations
{
///
public partial class InitialSchema : Migration
diff --git a/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs b/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs
index 0d8a658..b0a8025 100644
--- a/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs
+++ b/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
-namespace Novelly.Api.Migrations
+namespace Novelly.Api.Data.Migrations
{
[DbContext(typeof(NovelDbContext))]
[Migration("20260806031243_ReplaceOutlineWithBeatsAndTags")]
diff --git a/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs b/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs
index 2cc6367..11b3d5a 100644
--- a/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs
+++ b/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
-namespace Novelly.Api.Migrations
+namespace Novelly.Api.Data.Migrations
{
///
public partial class ReplaceOutlineWithBeatsAndTags : Migration
diff --git a/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.Designer.cs b/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.Designer.cs
new file mode 100644
index 0000000..008ea46
--- /dev/null
+++ b/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.Designer.cs
@@ -0,0 +1,791 @@
+//
+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("20260806055755_AddCharacterImportanceArcsAndOpenQuestions")]
+ partial class AddCharacterImportanceArcsAndOpenQuestions
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("BeatTag", b =>
+ {
+ b.Property("BeatsId")
+ .HasColumnType("TEXT");
+
+ b.Property("TagsId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("BeatsId", "TagsId");
+
+ b.HasIndex("TagsId");
+
+ b.ToTable("BeatTags", (string)null);
+ });
+
+ modelBuilder.Entity("ChapterTag", b =>
+ {
+ b.Property("ChaptersId")
+ .HasColumnType("TEXT");
+
+ b.Property("TagsId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ChaptersId", "TagsId");
+
+ b.HasIndex("TagsId");
+
+ b.ToTable("ChapterTags", (string)null);
+ });
+
+ modelBuilder.Entity("CharacterTag", b =>
+ {
+ b.Property("CharactersId")
+ .HasColumnType("TEXT");
+
+ b.Property("TagsId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("CharactersId", "TagsId");
+
+ b.HasIndex("TagsId");
+
+ b.ToTable("CharacterTags", (string)null);
+ });
+
+ modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProjectId")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.ToTable("Conversations");
+ });
+
+ modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ConversationId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("Sequence")
+ .HasColumnType("INTEGER");
+
+ b.Property("ToolCallsJson")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ConversationId", "Sequence")
+ .IsUnique();
+
+ b.ToTable("AgentMessages");
+ });
+
+ modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("TEXT");
+
+ b.Property("CharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SceneId")
+ .HasColumnType("TEXT");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("WhatHappened")
+ .HasColumnType("TEXT");
+
+ b.Property("WhatsNext")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CharacterId");
+
+ b.HasIndex("SceneId");
+
+ b.HasIndex("ChapterId", "SortOrder");
+
+ b.ToTable("Beats");
+ });
+
+ modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("Number")
+ .HasColumnType("INTEGER");
+
+ b.Property("PovCharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProjectId")
+ .HasColumnType("TEXT");
+
+ b.Property("Setting")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("TargetWordCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PovCharacterId");
+
+ b.HasIndex("ProjectId", "Number");
+
+ b.ToTable("Chapters");
+ });
+
+ modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Age")
+ .HasColumnType("TEXT");
+
+ b.Property("Appearance")
+ .HasColumnType("TEXT");
+
+ b.Property("ArcSummary")
+ .HasColumnType("TEXT");
+
+ b.Property("Backstory")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("ExternalConflict")
+ .HasColumnType("TEXT");
+
+ b.Property("Importance")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("InternalConflict")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("Need")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("Occupation")
+ .HasColumnType("TEXT");
+
+ b.Property("Personality")
+ .HasColumnType("TEXT");
+
+ b.Property("ProjectId")
+ .HasColumnType("TEXT");
+
+ b.Property("Pronouns")
+ .HasColumnType("TEXT");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Voice")
+ .HasColumnType("TEXT");
+
+ b.Property("Want")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.ToTable("Characters");
+ });
+
+ modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("TEXT");
+
+ b.Property("CharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Description")
+ .HasColumnType("TEXT");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasColumnType("TEXT");
+
+ b.Property("RelatedCharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("RelationshipType")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CharacterId");
+
+ b.HasIndex("RelatedCharacterId");
+
+ b.ToTable("CharacterRelationships");
+ });
+
+ modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Author")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Genre")
+ .HasColumnType("TEXT");
+
+ b.Property("Logline")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("Synopsis")
+ .HasColumnType("TEXT");
+
+ b.Property("TargetWordCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Projects");
+ });
+
+ modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("TEXT");
+
+ b.Property("CharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Detail")
+ .HasColumnType("TEXT");
+
+ b.Property("ProjectId")
+ .HasColumnType("TEXT");
+
+ b.Property("Question")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Resolution")
+ .HasColumnType("TEXT");
+
+ b.Property("ResolvedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("CharacterId");
+
+ b.HasIndex("ProjectId");
+
+ b.ToTable("OpenQuestions");
+ });
+
+ modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("TEXT");
+
+ b.Property("Conflict")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Goal")
+ .HasColumnType("TEXT");
+
+ b.Property("Location")
+ .HasColumnType("TEXT");
+
+ b.Property("Outcome")
+ .HasColumnType("TEXT");
+
+ b.Property("PovCharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("Prose")
+ .HasColumnType("TEXT");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Color")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("ProjectId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId", "Name")
+ .IsUnique();
+
+ b.ToTable("Tags");
+ });
+
+ 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.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 =>
+ {
+ 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.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")
+ .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");
+
+ b.Navigation("Scenes");
+ });
+
+ 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
+ }
+ }
+}
diff --git a/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.cs b/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.cs
new file mode 100644
index 0000000..65dc3ed
--- /dev/null
+++ b/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.cs
@@ -0,0 +1,133 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Novelly.Api.Data.Migrations
+{
+ ///
+ public partial class AddCharacterImportanceArcsAndOpenQuestions : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ // Existing characters predate the main/supporting distinction. EF's generated
+ // default is an empty string, which does not parse back to a CharacterImportance
+ // and would fault every read of an existing dossier — everyone starts Supporting.
+ migrationBuilder.AddColumn(
+ name: "Importance",
+ table: "Characters",
+ type: "TEXT",
+ maxLength: 32,
+ nullable: false,
+ defaultValue: "Supporting");
+
+ migrationBuilder.CreateTable(
+ name: "CharacterArcStages",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ CharacterId = table.Column(type: "TEXT", nullable: false),
+ SortOrder = table.Column(type: "INTEGER", nullable: false),
+ Title = table.Column(type: "TEXT", maxLength: 200, nullable: false),
+ Description = table.Column(type: "TEXT", nullable: true),
+ ChapterId = table.Column(type: "TEXT", nullable: true),
+ CreatedAt = table.Column(type: "INTEGER", nullable: false),
+ UpdatedAt = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_CharacterArcStages", x => x.Id);
+ table.ForeignKey(
+ name: "FK_CharacterArcStages_Chapters_ChapterId",
+ column: x => x.ChapterId,
+ principalTable: "Chapters",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.SetNull);
+ table.ForeignKey(
+ name: "FK_CharacterArcStages_Characters_CharacterId",
+ column: x => x.CharacterId,
+ principalTable: "Characters",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "OpenQuestions",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ ProjectId = table.Column(type: "TEXT", nullable: false),
+ Question = table.Column(type: "TEXT", maxLength: 500, nullable: false),
+ Detail = table.Column(type: "TEXT", nullable: true),
+ ChapterId = table.Column(type: "TEXT", nullable: true),
+ CharacterId = table.Column(type: "TEXT", nullable: true),
+ Resolution = table.Column(type: "TEXT", nullable: true),
+ ResolvedAt = table.Column(type: "INTEGER", nullable: true),
+ CreatedAt = table.Column(type: "INTEGER", nullable: false),
+ UpdatedAt = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_OpenQuestions", x => x.Id);
+ table.ForeignKey(
+ name: "FK_OpenQuestions_Chapters_ChapterId",
+ column: x => x.ChapterId,
+ principalTable: "Chapters",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.SetNull);
+ table.ForeignKey(
+ name: "FK_OpenQuestions_Characters_CharacterId",
+ column: x => x.CharacterId,
+ principalTable: "Characters",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.SetNull);
+ table.ForeignKey(
+ name: "FK_OpenQuestions_Projects_ProjectId",
+ column: x => x.ProjectId,
+ principalTable: "Projects",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_CharacterArcStages_ChapterId",
+ table: "CharacterArcStages",
+ column: "ChapterId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_CharacterArcStages_CharacterId_SortOrder",
+ table: "CharacterArcStages",
+ columns: new[] { "CharacterId", "SortOrder" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_OpenQuestions_ChapterId",
+ table: "OpenQuestions",
+ column: "ChapterId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_OpenQuestions_CharacterId",
+ table: "OpenQuestions",
+ column: "CharacterId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_OpenQuestions_ProjectId",
+ table: "OpenQuestions",
+ column: "ProjectId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "CharacterArcStages");
+
+ migrationBuilder.DropTable(
+ name: "OpenQuestions");
+
+ migrationBuilder.DropColumn(
+ name: "Importance",
+ table: "Characters");
+ }
+ }
+}
diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs
index 626b396..90b4458 100644
--- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs
+++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs
@@ -2,12 +2,12 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
-using Novelly.Api.Data;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Novelly.Api.Data;
#nullable disable
-namespace Novelly.Api.Migrations
+namespace Novelly.Api.Data.Migrations
{
[DbContext(typeof(NovelDbContext))]
partial class NovelDbContextModelSnapshot : ModelSnapshot
@@ -246,6 +246,11 @@ namespace Novelly.Api.Migrations
b.Property("ExternalConflict")
.HasColumnType("TEXT");
+ b.Property("Importance")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
b.Property("InternalConflict")
.HasColumnType("TEXT");
@@ -293,6 +298,44 @@ namespace Novelly.Api.Migrations
b.ToTable("Characters");
});
+ modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("TEXT");
+
+ b.Property("CharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Description")
+ .HasColumnType("TEXT");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
@@ -362,6 +405,52 @@ namespace Novelly.Api.Migrations
b.ToTable("Projects");
});
+ modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("TEXT");
+
+ b.Property("CharacterId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Detail")
+ .HasColumnType("TEXT");
+
+ b.Property("ProjectId")
+ .HasColumnType("TEXT");
+
+ b.Property("Question")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Resolution")
+ .HasColumnType("TEXT");
+
+ b.Property("ResolvedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("CharacterId");
+
+ b.HasIndex("ProjectId");
+
+ b.ToTable("OpenQuestions");
+ });
+
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
{
b.Property("Id")
@@ -573,6 +662,24 @@ namespace Novelly.Api.Migrations
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")
@@ -592,6 +699,31 @@ namespace Novelly.Api.Migrations
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.Scenes.Scene", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
@@ -635,6 +767,8 @@ namespace Novelly.Api.Migrations
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{
+ b.Navigation("ArcStages");
+
b.Navigation("Relationships");
});
diff --git a/src/Novelly.Api/Data/NovelDbContext.cs b/src/Novelly.Api/Data/NovelDbContext.cs
index 07cf092..d8b99f2 100644
--- a/src/Novelly.Api/Data/NovelDbContext.cs
+++ b/src/Novelly.Api/Data/NovelDbContext.cs
@@ -5,6 +5,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
+using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
@@ -27,10 +28,12 @@ public class NovelDbContext(DbContextOptions options)
public DbSet Projects => Set();
public DbSet Characters => Set();
public DbSet CharacterRelationships => Set();
+ public DbSet CharacterArcStages => Set();
public DbSet Beats => Set();
public DbSet Tags => Set();
public DbSet Chapters => Set();
public DbSet Scenes => Set();
+ public DbSet OpenQuestions => Set();
public DbSet Conversations => Set();
public DbSet AgentMessages => Set();
@@ -59,10 +62,25 @@ public class NovelDbContext(DbContextOptions options)
{
entity.Property(c => c.Name).IsRequired().HasMaxLength(200);
entity.Property(c => c.Role).HasConversion().HasMaxLength(32);
+ entity.Property(c => c.Importance).HasConversion().HasMaxLength(32);
entity.HasIndex(c => c.ProjectId);
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasMany(c => c.ArcStages).WithOne(s => s.Character!)
+ .HasForeignKey(s => s.CharacterId).OnDelete(DeleteBehavior.Cascade);
+ });
+
+ builder.Entity(entity =>
+ {
+ 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);
});
builder.Entity(entity =>
@@ -133,6 +151,26 @@ public class NovelDbContext(DbContextOptions options)
.HasForeignKey(s => s.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
});
+ builder.Entity(entity =>
+ {
+ 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()
+ .HasForeignKey(q => q.CharacterId).OnDelete(DeleteBehavior.SetNull);
+ });
+
builder.Entity(entity =>
{
entity.Property(c => c.Title).IsRequired().HasMaxLength(200);
diff --git a/src/Novelly.Api/Program.cs b/src/Novelly.Api/Program.cs
index 9670100..2b32782 100644
--- a/src/Novelly.Api/Program.cs
+++ b/src/Novelly.Api/Program.cs
@@ -8,6 +8,7 @@ using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Projects;
+using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
@@ -79,6 +80,7 @@ app.MapProjectEndpoints()
.MapBeatEndpoints()
.MapSceneEndpoints()
.MapTagEndpoints()
+ .MapOpenQuestionEndpoints()
.MapAgentEndpoints();
app.Run();
diff --git a/src/Novelly.Api/Questions/OpenQuestion.cs b/src/Novelly.Api/Questions/OpenQuestion.cs
new file mode 100644
index 0000000..3c6c9a0
--- /dev/null
+++ b/src/Novelly.Api/Questions/OpenQuestion.cs
@@ -0,0 +1,43 @@
+using Novelly.Api.Chapters;
+using Novelly.Api.Characters;
+using Novelly.Api.Projects;
+
+namespace Novelly.Api.Questions;
+
+///
+/// Something the writer has not decided yet — "does she know about the letter before the
+/// harbour?". Questions hang off the chapter outline or the character they belong to, or
+/// both, or neither when they are about the book as a whole.
+///
+public class OpenQuestion
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ public Guid ProjectId { get; set; }
+ public Project? Project { get; set; }
+
+ /// The question itself, in one line.
+ public string Question { get; set; } = string.Empty;
+
+ /// Room for the thinking around it — options considered, what each costs.
+ public string? Detail { get; set; }
+
+ /// The chapter outline this question is about, if it is about one.
+ public Guid? ChapterId { get; set; }
+ public Chapter? Chapter { get; set; }
+
+ /// The character this question is about, if it is about one.
+ public Guid? CharacterId { get; set; }
+ public Character? Character { get; set; }
+
+ /// What was decided. Set when the question is resolved, cleared when reopened.
+ public string? Resolution { get; set; }
+
+ /// When it was decided. Null while the question is still open.
+ public DateTimeOffset? ResolvedAt { get; set; }
+
+ public bool IsResolved => ResolvedAt is not null;
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/src/Novelly.Api/Questions/OpenQuestionDtos.cs b/src/Novelly.Api/Questions/OpenQuestionDtos.cs
new file mode 100644
index 0000000..98b1661
--- /dev/null
+++ b/src/Novelly.Api/Questions/OpenQuestionDtos.cs
@@ -0,0 +1,62 @@
+namespace Novelly.Api.Questions;
+
+public record OpenQuestionDto(
+ Guid Id,
+ Guid ProjectId,
+ string Question,
+ string? Detail,
+ Guid? ChapterId,
+ int? ChapterNumber,
+ string? ChapterTitle,
+ Guid? CharacterId,
+ string? CharacterName,
+ string? Resolution,
+ bool IsResolved,
+ DateTimeOffset? ResolvedAt,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt);
+
+public record CreateOpenQuestionRequest(
+ string Question,
+ string? Detail = null,
+ Guid? ChapterId = null,
+ Guid? CharacterId = null);
+
+///
+/// Patch-style update. A null field is left alone; an empty string clears it. Use
+/// / to detach a question, since a
+/// null id already means "leave the association alone".
+///
+public record UpdateOpenQuestionRequest(
+ string? Question = null,
+ string? Detail = null,
+ Guid? ChapterId = null,
+ Guid? CharacterId = null,
+ bool ClearChapter = false,
+ bool ClearCharacter = false);
+
+///
+/// Settles a question. The resolution is kept on the question itself; setting
+/// also appends it to the notes of whatever the question is
+/// attached to, so the decision lands where the writer will actually re-read it.
+///
+public record ResolveOpenQuestionRequest(string Resolution, bool AppendToNotes = false);
+
+public static class OpenQuestionMapping
+{
+ public static OpenQuestionDto ToDto(this OpenQuestion q) => new(
+ q.Id,
+ q.ProjectId,
+ q.Question,
+ q.Detail,
+ q.ChapterId,
+ q.Chapter?.Number,
+ q.Chapter?.Title,
+ q.CharacterId,
+ q.Character?.Name,
+ q.Resolution,
+ q.IsResolved,
+ q.ResolvedAt,
+ q.CreatedAt,
+ q.UpdatedAt);
+}
diff --git a/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs b/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs
new file mode 100644
index 0000000..5ae02f2
--- /dev/null
+++ b/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs
@@ -0,0 +1,56 @@
+namespace Novelly.Api.Questions;
+
+public static class OpenQuestionEndpoints
+{
+ public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app)
+ {
+ var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions");
+
+ projectScoped.MapGet("/", async (
+ Guid projectId,
+ OpenQuestionService service,
+ CancellationToken ct,
+ Guid? chapterId = null,
+ Guid? characterId = null,
+ bool includeResolved = false) =>
+ Results.Ok(await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct)))
+ .WithSummary("List a project's open questions, optionally narrowed to one chapter or character.");
+
+ projectScoped.MapPost("/", async (
+ Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
+ {
+ var created = await service.CreateAsync(projectId, request, ct);
+ return Results.Created($"/api/questions/{created.Id}", created);
+ })
+ .WithSummary("Raise an open question, optionally against a chapter outline and/or a character.");
+
+ var questions = app.MapGroup("/api/questions").WithTags("Questions");
+
+ questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
+ Results.Ok(await service.GetAsync(id, ct)))
+ .WithSummary("Read one question.");
+
+ questions.MapPatch("/{id:guid}", async (
+ Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
+ Results.Ok(await service.UpdateAsync(id, request, ct)))
+ .WithSummary("Update a question or change what it is attached to.");
+
+ questions.MapPost("/{id:guid}/resolve", async (
+ Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
+ Results.Ok(await service.ResolveAsync(id, request, ct)))
+ .WithSummary("Settle a question, optionally appending the resolution to the notes it hangs off.");
+
+ questions.MapPost("/{id:guid}/reopen", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
+ Results.Ok(await service.ReopenAsync(id, ct)))
+ .WithSummary("Put a resolved question back on the list.");
+
+ questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
+ {
+ await service.DeleteAsync(id, ct);
+ return Results.NoContent();
+ })
+ .WithSummary("Delete a question.");
+
+ return app;
+ }
+}
diff --git a/src/Novelly.Api/Questions/OpenQuestionService.cs b/src/Novelly.Api/Questions/OpenQuestionService.cs
new file mode 100644
index 0000000..9b775e8
--- /dev/null
+++ b/src/Novelly.Api/Questions/OpenQuestionService.cs
@@ -0,0 +1,201 @@
+using Microsoft.EntityFrameworkCore;
+using Novelly.Api.Chapters;
+using Novelly.Api.Characters;
+using Novelly.Api.Common;
+using Novelly.Api.Data;
+using Novelly.Api.Projects;
+
+namespace Novelly.Api.Questions;
+
+///
+/// The project's open questions — the decisions still outstanding. A question can be
+/// attached to a chapter outline, a character, both, or neither.
+///
+public class OpenQuestionService(INovelDbContext db)
+{
+ ///
+ /// Lists a project's questions, open ones first and newest first within each group.
+ /// Filters narrow to what one page cares about; resolved questions are left out
+ /// unless asked for, since the point of the list is what is still undecided.
+ ///
+ public async Task> ListAsync(
+ Guid projectId,
+ Guid? chapterId = null,
+ Guid? characterId = null,
+ bool includeResolved = false,
+ CancellationToken ct = default)
+ {
+ var query = Query().Where(q => q.ProjectId == projectId);
+
+ if (chapterId is { } cid)
+ {
+ query = query.Where(q => q.ChapterId == cid);
+ }
+
+ if (characterId is { } chid)
+ {
+ query = query.Where(q => q.CharacterId == chid);
+ }
+
+ if (!includeResolved)
+ {
+ query = query.Where(q => q.ResolvedAt == null);
+ }
+
+ var questions = await query.ToListAsync(ct);
+
+ return
+ [
+ .. questions
+ .OrderBy(q => q.ResolvedAt is not null)
+ .ThenByDescending(q => q.CreatedAt)
+ .Select(q => q.ToDto())
+ ];
+ }
+
+ public async Task GetAsync(Guid id, CancellationToken ct = default) =>
+ (await FindAsync(id, ct)).ToDto();
+
+ public async Task CreateAsync(
+ Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default)
+ {
+ if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
+ {
+ throw new NotFoundException(nameof(Project), projectId);
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Question))
+ {
+ throw new ArgumentException("A question needs to say something.");
+ }
+
+ await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct);
+
+ var question = new OpenQuestion
+ {
+ ProjectId = projectId,
+ Question = request.Question.Trim(),
+ Detail = request.Detail,
+ ChapterId = request.ChapterId,
+ CharacterId = request.CharacterId
+ };
+
+ db.OpenQuestions.Add(question);
+ await db.SaveChangesAsync(ct);
+ return (await FindAsync(question.Id, ct)).ToDto();
+ }
+
+ public async Task UpdateAsync(
+ Guid id, UpdateOpenQuestionRequest request, CancellationToken ct = default)
+ {
+ var question = await FindAsync(id, ct);
+
+ await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct);
+
+ question.Question = Patch.Apply(question.Question, request.Question) ?? question.Question;
+ question.Detail = Patch.Apply(question.Detail, request.Detail);
+ question.ChapterId = request.ClearChapter ? null : request.ChapterId ?? question.ChapterId;
+ question.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? question.CharacterId;
+ question.UpdatedAt = DateTimeOffset.UtcNow;
+
+ await db.SaveChangesAsync(ct);
+ return (await FindAsync(id, ct)).ToDto();
+ }
+
+ ///
+ /// Settles a question. With AppendToNotes the resolution is also appended to the
+ /// notes of the chapter and character it hangs off, so the decision ends up where the
+ /// writer reads rather than only in a list they have stopped looking at.
+ ///
+ public async Task ResolveAsync(
+ Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default)
+ {
+ var question = await FindAsync(id, ct);
+
+ if (string.IsNullOrWhiteSpace(request.Resolution))
+ {
+ throw new ArgumentException("A resolution needs to say what was decided.");
+ }
+
+ question.Resolution = request.Resolution.Trim();
+ question.ResolvedAt = DateTimeOffset.UtcNow;
+ question.UpdatedAt = question.ResolvedAt.Value;
+
+ if (request.AppendToNotes)
+ {
+ var note = $"{question.Question} — {question.Resolution}";
+
+ if (question.ChapterId is { } chapterId)
+ {
+ var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
+ if (chapter is not null)
+ {
+ chapter.Notes = AppendNote(chapter.Notes, note);
+ chapter.UpdatedAt = DateTimeOffset.UtcNow;
+ }
+ }
+
+ if (question.CharacterId is { } characterId)
+ {
+ var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
+ if (character is not null)
+ {
+ character.Notes = AppendNote(character.Notes, note);
+ character.UpdatedAt = DateTimeOffset.UtcNow;
+ }
+ }
+ }
+
+ await db.SaveChangesAsync(ct);
+ return (await FindAsync(id, ct)).ToDto();
+ }
+
+ /// Puts a question back on the list. The resolution goes; anything already appended to notes stays.
+ public async Task ReopenAsync(Guid id, CancellationToken ct = default)
+ {
+ var question = await FindAsync(id, ct);
+
+ question.Resolution = null;
+ question.ResolvedAt = null;
+ question.UpdatedAt = DateTimeOffset.UtcNow;
+
+ await db.SaveChangesAsync(ct);
+ return (await FindAsync(id, ct)).ToDto();
+ }
+
+ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
+ {
+ var question = await FindAsync(id, ct);
+ db.OpenQuestions.Remove(question);
+ await db.SaveChangesAsync(ct);
+ }
+
+ /// Blank line between entries, so appended resolutions stay readable as notes accumulate.
+ private static string AppendNote(string? existing, string note) =>
+ string.IsNullOrWhiteSpace(existing) ? note : $"{existing.TrimEnd()}\n\n{note}";
+
+ private async Task ValidateAssociationsAsync(
+ Guid projectId, Guid? chapterId, Guid? characterId, CancellationToken ct)
+ {
+ if (chapterId is { } cid
+ && !await db.Chapters.AnyAsync(c => c.Id == cid && c.ProjectId == projectId, ct))
+ {
+ throw new InvalidOperationException(
+ "A question can only be attached to a chapter in the same project.");
+ }
+
+ if (characterId is { } chid
+ && !await db.Characters.AnyAsync(c => c.Id == chid && c.ProjectId == projectId, ct))
+ {
+ throw new InvalidOperationException(
+ "A question can only be attached to a character in the same project.");
+ }
+ }
+
+ private IQueryable Query() =>
+ db.OpenQuestions.Include(q => q.Chapter).Include(q => q.Character);
+
+ private async Task FindAsync(Guid id, CancellationToken ct) =>
+ await Query().FirstOrDefaultAsync(q => q.Id == id, ct)
+ ?? throw new NotFoundException(nameof(OpenQuestion), id);
+}
diff --git a/src/Novelly.Mcp/Tools/CharacterTools.cs b/src/Novelly.Mcp/Tools/CharacterTools.cs
index 194097b..7b9aad6 100644
--- a/src/Novelly.Mcp/Tools/CharacterTools.cs
+++ b/src/Novelly.Mcp/Tools/CharacterTools.cs
@@ -33,6 +33,8 @@ public static class CharacterTools
CancellationToken ct,
[Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")]
string? role = null,
+ [Description("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")]
+ string? importance = null,
[Description("Age, exact or approximate.")] string? age = null,
[Description("The pronouns this character uses.")] string? pronouns = null,
[Description("What they do.")] string? occupation = null,
@@ -51,6 +53,7 @@ public static class CharacterTools
{
name,
role = role ?? "Supporting",
+ importance = importance ?? "Supporting",
age,
pronouns,
occupation,
@@ -76,6 +79,8 @@ public static class CharacterTools
[Description("New name.")] string? name = null,
[Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")]
string? role = null,
+ [Description("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")]
+ string? importance = null,
[Description("Age, exact or approximate.")] string? age = null,
[Description("The pronouns this character uses.")] string? pronouns = null,
[Description("What they do.")] string? occupation = null,
@@ -94,6 +99,7 @@ public static class CharacterTools
{
name,
role,
+ importance,
age,
pronouns,
occupation,
@@ -110,6 +116,70 @@ public static class CharacterTools
tags
}, ct);
+ [McpServerTool(Name = "get_character_beats")]
+ [Description("Every beat this character appears in, across the whole book, in manuscript order. "
+ + "This is what the character actually does on the page, as opposed to what the "
+ + "dossier claims about them — read it before revising a character.")]
+ public static Task GetCharacterBeats(
+ NovelApiClient api,
+ [Description("The character's id.")] Guid characterId,
+ CancellationToken ct) =>
+ api.GetAsync($"/api/characters/{characterId}/beats", ct);
+
+ [McpServerTool(Name = "get_character_arc")]
+ [Description("Read a main character's arc: the ordered stages of how they change, each "
+ + "optionally pinned to the chapter where it lands.")]
+ public static Task GetCharacterArc(
+ NovelApiClient api,
+ [Description("The character's id.")] Guid characterId,
+ CancellationToken ct) =>
+ api.GetAsync($"/api/characters/{characterId}/arc", ct);
+
+ [McpServerTool(Name = "add_arc_stage")]
+ [Description("Add a stage to a character's arc. Arcs are kept for main characters — promote "
+ + "the character with update_character first if they are still Supporting.")]
+ public static Task AddArcStage(
+ NovelApiClient api,
+ [Description("Id of the character whose arc to add to.")] Guid characterId,
+ [Description("A short handle for the change, three to five words.")] string title,
+ CancellationToken ct,
+ [Description("What shifts in the character here, and what it costs them.")] string? description = null,
+ [Description("Id of the chapter where this stage lands, if it is pinned to one.")] Guid? chapterId = null,
+ [Description("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) =>
+ api.PostAsync($"/api/characters/{characterId}/arc",
+ new { title, sortOrder, description, chapterId }, ct);
+
+ [McpServerTool(Name = "update_arc_stage")]
+ [Description("Revise a stage of a character's arc. Only the fields you supply change.")]
+ public static Task UpdateArcStage(
+ NovelApiClient api,
+ [Description("The arc stage's id.")] Guid arcStageId,
+ CancellationToken ct,
+ [Description("New title for the stage.")] string? title = null,
+ [Description("What shifts in the character here.")] string? description = null,
+ [Description("Id of the chapter where this stage lands.")] Guid? chapterId = null,
+ [Description("Position in the arc.")] int? sortOrder = null) =>
+ api.PatchAsync($"/api/arc-stages/{arcStageId}",
+ new { title, sortOrder, description, chapterId }, ct);
+
+ [McpServerTool(Name = "delete_arc_stage")]
+ [Description("Remove a stage from a character's arc.")]
+ public static Task DeleteArcStage(
+ NovelApiClient api,
+ [Description("The arc stage's id.")] Guid arcStageId,
+ CancellationToken ct) =>
+ api.DeleteAsync($"/api/arc-stages/{arcStageId}", ct);
+
+ [McpServerTool(Name = "reorder_arc_stages")]
+ [Description("Renumber a character's arc to match the order given. Stages left out keep their "
+ + "relative position after the ones listed.")]
+ public static Task ReorderArcStages(
+ NovelApiClient api,
+ [Description("Id of the character whose arc to reorder.")] Guid characterId,
+ [Description("Arc stage ids in the order wanted.")] string[] stageIds,
+ CancellationToken ct) =>
+ api.PostAsync($"/api/characters/{characterId}/arc/reorder", new { stageIds }, ct);
+
[McpServerTool(Name = "relate_characters")]
[Description("Record a relationship from one character to another in the same project.")]
public static Task RelateCharacters(
diff --git a/src/Novelly.Mcp/Tools/QuestionTools.cs b/src/Novelly.Mcp/Tools/QuestionTools.cs
new file mode 100644
index 0000000..d77a704
--- /dev/null
+++ b/src/Novelly.Mcp/Tools/QuestionTools.cs
@@ -0,0 +1,92 @@
+using System.ComponentModel;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Novelly.Mcp.Tools;
+
+[McpServerToolType]
+public static class QuestionTools
+{
+ [McpServerTool(Name = "list_open_questions")]
+ [Description("The decisions the writer has not made yet, newest first. Read this before "
+ + "proposing changes — an open question marks somewhere the writer is still "
+ + "thinking, not a gap to fill in for them.")]
+ public static Task ListOpenQuestions(
+ NovelApiClient api,
+ [Description("The project's id.")] Guid projectId,
+ CancellationToken ct,
+ [Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null,
+ [Description("Narrow to questions about one character.")] Guid? characterId = null,
+ [Description("Include questions already settled. Defaults to false.")] bool includeResolved = false)
+ {
+ var query = new List { $"includeResolved={includeResolved.ToString().ToLowerInvariant()}" };
+
+ if (chapterId is { } chapter)
+ {
+ query.Add($"chapterId={chapter}");
+ }
+
+ if (characterId is { } character)
+ {
+ query.Add($"characterId={character}");
+ }
+
+ return api.GetAsync($"/api/projects/{projectId}/questions?{string.Join('&', query)}", ct);
+ }
+
+ [McpServerTool(Name = "raise_open_question")]
+ [Description("Record a question the writer has not settled, attached to the chapter outline "
+ + "and/or the character it is about. Prefer raising a question over guessing.")]
+ public static Task RaiseOpenQuestion(
+ NovelApiClient api,
+ [Description("The project's id.")] Guid projectId,
+ [Description("The question, in one line.")] string question,
+ CancellationToken ct,
+ [Description("The thinking around it — options considered, and what each costs.")] string? detail = null,
+ [Description("Id of the chapter outline this is about, if any.")] Guid? chapterId = null,
+ [Description("Id of the character this is about, if any.")] Guid? characterId = null) =>
+ api.PostAsync($"/api/projects/{projectId}/questions",
+ new { question, detail, chapterId, characterId }, ct);
+
+ [McpServerTool(Name = "update_open_question")]
+ [Description("Revise a question or change what it is attached to. Only the fields you supply change.")]
+ public static Task