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 UpdateOpenQuestion( + NovelApiClient api, + [Description("The question's id.")] Guid questionId, + CancellationToken ct, + [Description("New wording for the question.")] string? question = null, + [Description("New detail. Pass an empty string to clear it.")] string? detail = null, + [Description("Attach to this chapter outline.")] Guid? chapterId = null, + [Description("Attach to this character.")] Guid? characterId = null, + [Description("Detach from its chapter.")] bool clearChapter = false, + [Description("Detach from its character.")] bool clearCharacter = false) => + api.PatchAsync($"/api/questions/{questionId}", + new { question, detail, chapterId, characterId, clearChapter, clearCharacter }, ct); + + [McpServerTool(Name = "resolve_open_question")] + [Description("Settle a question with what the writer decided. Set appendToNotes to also write " + + "the resolution into the notes of the chapter and character it hangs off.")] + public static Task ResolveOpenQuestion( + NovelApiClient api, + [Description("The question's id.")] Guid questionId, + [Description("What was decided.")] string resolution, + CancellationToken ct, + [Description("Also append the resolution to the associated notes.")] bool appendToNotes = false) => + api.PostAsync($"/api/questions/{questionId}/resolve", new { resolution, appendToNotes }, ct); + + [McpServerTool(Name = "reopen_question")] + [Description("Put a resolved question back on the list. Anything already appended to notes stays.")] + public static Task ReopenQuestion( + NovelApiClient api, + [Description("The question's id.")] Guid questionId, + CancellationToken ct) => + api.PostAsync($"/api/questions/{questionId}/reopen", new { }, ct); + + [McpServerTool(Name = "delete_open_question")] + [Description("Delete a question outright. Resolving is usually better — it keeps the decision.")] + public static Task DeleteOpenQuestion( + NovelApiClient api, + [Description("The question's id.")] Guid questionId, + CancellationToken ct) => + api.DeleteAsync($"/api/questions/{questionId}", ct); +} diff --git a/tests/Novelly.Api.Tests/CharacterArcTests.cs b/tests/Novelly.Api.Tests/CharacterArcTests.cs new file mode 100644 index 0000000..ccde294 --- /dev/null +++ b/tests/Novelly.Api.Tests/CharacterArcTests.cs @@ -0,0 +1,202 @@ +using Novelly.Api.Beats; +using Novelly.Api.Chapters; +using Novelly.Api.Characters; +using Novelly.Api.Common; +using Novelly.Api.Projects; + +namespace Novelly.Api.Tests; + +[TestFixture] +public class CharacterArcTests : ServiceTestFixture +{ + private Guid _projectId; + private Guid _characterId; + + protected override void OnSetUp() + { + _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; + _characterId = Characters.CreateAsync( + _projectId, + new CreateCharacterRequest("Ines", Importance: CharacterImportance.Main)).Result.Id; + } + + [Test] + public async Task A_character_is_supporting_until_promoted() + { + var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + + Assert.That(mara.Importance, Is.EqualTo(CharacterImportance.Supporting)); + + var promoted = await Characters.UpdateAsync( + mara.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)); + + Assert.That(promoted.Importance, Is.EqualTo(CharacterImportance.Main)); + } + + [Test] + public async Task Importance_is_separate_from_the_part_a_character_plays() + { + var mentor = await Characters.CreateAsync(_projectId, new CreateCharacterRequest( + "Anders", CharacterRole.Mentor, CharacterImportance.Main)); + + Assert.Multiple(() => + { + Assert.That(mentor.Role, Is.EqualTo(CharacterRole.Mentor)); + Assert.That(mentor.Importance, Is.EqualTo(CharacterImportance.Main)); + }); + } + + [Test] + public async Task Main_characters_are_listed_before_supporting_ones() + { + await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Zeno")); + await Characters.CreateAsync( + _projectId, new CreateCharacterRequest("Mara", Importance: CharacterImportance.Main)); + + var listed = await Characters.ListAsync(_projectId); + + Assert.That( + listed.Select(c => c.Name), + Is.EqualTo(new[] { "Ines", "Mara", "Zeno" })); + } + + [Test] + public async Task Arc_stages_are_appended_in_order_and_read_back_that_way() + { + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("She trusts the map")); + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("The map is wrong")); + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("She navigates by memory")); + + var arc = await Arcs.ListAsync(_characterId); + + Assert.Multiple(() => + { + Assert.That( + arc.Select(s => s.Title), + Is.EqualTo(new[] { "She trusts the map", "The map is wrong", "She navigates by memory" })); + Assert.That(arc.Select(s => s.SortOrder), Is.EqualTo(new[] { 1, 2, 3 })); + }); + } + + [Test] + public async Task A_character_dossier_carries_its_arc() + { + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest( + "She trusts the map", Description: "Because her mother drew it.")); + + var character = await Characters.GetAsync(_characterId); + + Assert.Multiple(() => + { + Assert.That(character.ArcStages, Has.Count.EqualTo(1)); + Assert.That(character.ArcStages[0].Description, Does.Contain("her mother drew it")); + }); + } + + [Test] + public async Task Stages_left_out_of_a_reorder_keep_their_relative_position_at_the_end() + { + var first = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("First")); + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Second")); + var third = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Third")); + + var reordered = await Arcs.ReorderAsync( + _characterId, new ReorderArcStagesRequest([third.Id, first.Id])); + + Assert.That( + reordered.Select(s => s.Title), + Is.EqualTo(new[] { "Third", "First", "Second" })); + } + + [Test] + public async Task A_stage_pinned_to_a_chapter_resolves_that_chapter() + { + var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + + var stage = await Arcs.CreateAsync( + _characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id)); + + Assert.Multiple(() => + { + Assert.That(stage.ChapterTitle, Is.EqualTo("Landfall")); + Assert.That(stage.ChapterNumber, Is.EqualTo(1)); + }); + } + + [Test] + public async Task A_stage_cannot_be_pinned_to_a_chapter_from_another_project() + { + var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); + var elsewhere = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere")); + + Assert.That( + async () => await Arcs.CreateAsync( + _characterId, new CreateArcStageRequest("A stage", ChapterId: elsewhere.Id)), + Throws.TypeOf().With.Message.Contains("same project")); + } + + [Test] + public async Task Deleting_a_chapter_unpins_an_arc_stage_rather_than_deleting_it() + { + var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var stage = await Arcs.CreateAsync( + _characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id)); + + await Chapters.DeleteAsync(chapter.Id); + + // How a character changes outlives a decision about where the chapter break falls. + var survivor = await Arcs.GetAsync(stage.Id); + + Assert.Multiple(() => + { + Assert.That(survivor.ChapterId, Is.Null); + Assert.That(survivor.Title, Is.EqualTo("The map is wrong")); + }); + } + + [Test] + public async Task Deleting_a_character_takes_their_arc() + { + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("She trusts the map")); + + await Characters.DeleteAsync(_characterId); + + Assert.That(await Arcs.ListAsync(_characterId), Is.Empty); + } + + [Test] + public void Reordering_with_an_unknown_stage_is_refused() => + Assert.That( + async () => await Arcs.ReorderAsync( + _characterId, new ReorderArcStagesRequest([Guid.NewGuid()])), + Throws.TypeOf()); + + [Test] + public async Task The_character_page_sees_every_beat_they_appear_in_across_the_book() + { + var second = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second", Number: 2)); + var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("First", Number: 1)); + var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + + await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterId: _characterId)); + await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterId: _characterId)); + await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterId: mara.Id)); + await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat")); + + var beats = await Beats.ListForCharacterAsync(_characterId); + + Assert.Multiple(() => + { + Assert.That(beats.Select(b => b.Title), Is.EqualTo(new[] { "She finds the map", "She boards anyway" })); + Assert.That(beats[0].ChapterNumber, Is.EqualTo(1)); + Assert.That(beats[0].ChapterTitle, Is.EqualTo("First")); + Assert.That(beats[1].ChapterId, Is.EqualTo(second.Id)); + }); + } + + [Test] + public void Asking_for_the_beats_of_a_character_who_does_not_exist_reports_not_found() => + Assert.That( + async () => await Beats.ListForCharacterAsync(Guid.NewGuid()), + Throws.TypeOf()); +} diff --git a/tests/Novelly.Api.Tests/ListingTests.cs b/tests/Novelly.Api.Tests/ListingTests.cs index fbe1f9a..b103d0e 100644 --- a/tests/Novelly.Api.Tests/ListingTests.cs +++ b/tests/Novelly.Api.Tests/ListingTests.cs @@ -97,7 +97,7 @@ public class ListingTests : ServiceTestFixture var agent = new NovelAgentService( Db.Context, new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), - new NovelAgentToolset(Projects, Characters, Chapters, Beats, Scenes, Tags), + new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions), Options.Create(new AgentOptions()), NullLogger.Instance); diff --git a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs index 8a25673..1fd1be0 100644 --- a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs +++ b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs @@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture private NovelAgentToolset _toolset = null!; protected override void OnSetUp() => - _toolset = new NovelAgentToolset(Projects, Characters, Chapters, Beats, Scenes, Tags); + _toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions); private NovelAgentService BuildAgent(ScriptedModelClient model) => new( Db.Context, diff --git a/tests/Novelly.Api.Tests/OpenQuestionTests.cs b/tests/Novelly.Api.Tests/OpenQuestionTests.cs new file mode 100644 index 0000000..8f09873 --- /dev/null +++ b/tests/Novelly.Api.Tests/OpenQuestionTests.cs @@ -0,0 +1,261 @@ +using Novelly.Api.Chapters; +using Novelly.Api.Characters; +using Novelly.Api.Common; +using Novelly.Api.Projects; +using Novelly.Api.Questions; + +namespace Novelly.Api.Tests; + +[TestFixture] +public class OpenQuestionTests : ServiceTestFixture +{ + private Guid _projectId; + private Guid _chapterId; + private Guid _characterId; + + protected override void OnSetUp() + { + _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; + _chapterId = Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id; + _characterId = Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")).Result.Id; + } + + [Test] + public async Task A_question_can_hang_off_a_chapter_and_a_character_at_once() + { + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Does she know about the letter before the harbour?", + ChapterId: _chapterId, + CharacterId: _characterId)); + + Assert.Multiple(() => + { + Assert.That(question.ChapterTitle, Is.EqualTo("Landfall")); + Assert.That(question.ChapterNumber, Is.EqualTo(1)); + Assert.That(question.CharacterName, Is.EqualTo("Ines")); + Assert.That(question.IsResolved, Is.False); + }); + } + + [Test] + public async Task A_question_about_the_book_as_a_whole_needs_no_association() + { + var question = await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + + Assert.Multiple(() => + { + Assert.That(question.ChapterId, Is.Null); + Assert.That(question.CharacterId, Is.Null); + }); + } + + [Test] + public async Task The_outline_and_the_character_page_each_see_only_their_own_questions() + { + await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Where does the chapter break?", ChapterId: _chapterId)); + await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "What does she actually want?", CharacterId: _characterId)); + await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + + var forChapter = await Questions.ListAsync(_projectId, chapterId: _chapterId); + var forCharacter = await Questions.ListAsync(_projectId, characterId: _characterId); + var forProject = await Questions.ListAsync(_projectId); + + Assert.Multiple(() => + { + Assert.That(forChapter.Select(q => q.Question), Is.EqualTo(new[] { "Where does the chapter break?" })); + Assert.That(forCharacter.Select(q => q.Question), Is.EqualTo(new[] { "What does she actually want?" })); + Assert.That(forProject, Has.Count.EqualTo(3)); + }); + } + + [Test] + public async Task Resolved_questions_drop_off_the_list_unless_asked_for() + { + var settled = await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); + await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + + await Questions.ResolveAsync(settled.Id, new ResolveOpenQuestionRequest("After the harbour.")); + + var open = await Questions.ListAsync(_projectId); + var everything = await Questions.ListAsync(_projectId, includeResolved: true); + + Assert.Multiple(() => + { + Assert.That(open.Select(q => q.Question), Is.EqualTo(new[] { "Is this one book or two?" })); + Assert.That(everything, Has.Count.EqualTo(2)); + + // Still-open questions come first, so the list stays about what is undecided. + Assert.That(everything[0].IsResolved, Is.False); + Assert.That(everything[1].IsResolved, Is.True); + }); + } + + [Test] + public async Task Resolving_records_what_was_decided() + { + var question = await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); + + var resolved = await Questions.ResolveAsync( + question.Id, new ResolveOpenQuestionRequest("After the harbour burns.")); + + Assert.Multiple(() => + { + Assert.That(resolved.IsResolved, Is.True); + Assert.That(resolved.Resolution, Is.EqualTo("After the harbour burns.")); + Assert.That(resolved.ResolvedAt, Is.Not.Null); + }); + } + + [Test] + public async Task A_resolution_can_be_added_as_a_note_on_what_it_was_about() + { + await Chapters.UpdateAsync(_chapterId, new UpdateChapterRequest(Notes: "Runs long.")); + + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId)); + + await Questions.ResolveAsync( + question.Id, + new ResolveOpenQuestionRequest("After the harbour burns.", AppendToNotes: true)); + + var chapter = await Chapters.GetAsync(_chapterId); + var character = await Characters.GetAsync(_characterId); + + Assert.Multiple(() => + { + // The existing note is kept and the decision lands underneath it. + Assert.That(chapter.Notes, Does.StartWith("Runs long.")); + Assert.That(chapter.Notes, Does.Contain("Where does the chapter break? — After the harbour burns.")); + Assert.That(character.Notes, Is.EqualTo("Where does the chapter break? — After the harbour burns.")); + }); + } + + [Test] + public async Task A_resolution_stays_off_the_notes_unless_asked_for() + { + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Where does the chapter break?", ChapterId: _chapterId)); + + await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest("After the harbour.")); + + Assert.That((await Chapters.GetAsync(_chapterId)).Notes, Is.Null); + } + + [Test] + public async Task Reopening_clears_the_resolution_but_leaves_the_note_behind() + { + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Where does the chapter break?", ChapterId: _chapterId)); + + await Questions.ResolveAsync( + question.Id, new ResolveOpenQuestionRequest("After the harbour.", AppendToNotes: true)); + + var reopened = await Questions.ReopenAsync(question.Id); + var chapter = await Chapters.GetAsync(_chapterId); + + Assert.Multiple(() => + { + Assert.That(reopened.IsResolved, Is.False); + Assert.That(reopened.Resolution, Is.Null); + Assert.That(chapter.Notes, Does.Contain("After the harbour.")); + }); + } + + [Test] + public async Task A_question_can_be_detached_from_what_it_was_about() + { + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId)); + + var detached = await Questions.UpdateAsync( + question.Id, new UpdateOpenQuestionRequest(ClearChapter: true)); + + Assert.Multiple(() => + { + Assert.That(detached.ChapterId, Is.Null); + + // Only the chapter was cleared — a null id means "leave alone", not "detach". + Assert.That(detached.CharacterId, Is.EqualTo(_characterId)); + }); + } + + [Test] + public async Task Deleting_a_chapter_leaves_its_questions_open_rather_than_taking_them() + { + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Does she know about the letter?", ChapterId: _chapterId)); + + await Chapters.DeleteAsync(_chapterId); + + var survivor = await Questions.GetAsync(question.Id); + + Assert.Multiple(() => + { + Assert.That(survivor.ChapterId, Is.Null); + Assert.That(survivor.Question, Is.EqualTo("Does she know about the letter?")); + }); + } + + [Test] + public async Task A_question_can_be_deleted_outright() + { + var question = await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); + + await Questions.DeleteAsync(question.Id); + + Assert.Multiple(async () => + { + Assert.That(await Questions.ListAsync(_projectId, includeResolved: true), Is.Empty); + Assert.That( + async () => await Questions.GetAsync(question.Id), + Throws.TypeOf()); + }); + } + + [Test] + public async Task Deleting_a_project_takes_its_questions() + { + await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + + await Projects.DeleteAsync(_projectId); + + using var verification = Db.CreateContext(); + Assert.That(verification.OpenQuestions.Count(), Is.EqualTo(0)); + } + + [Test] + public void A_question_cannot_be_attached_to_another_project_s_chapter() + { + var elsewhere = Chapters.CreateAsync( + Projects.CreateAsync(new CreateProjectRequest("Other Book")).Result.Id, + new CreateChapterRequest("Elsewhere")).Result; + + Assert.That( + async () => await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("A question", ChapterId: elsewhere.Id)), + Throws.TypeOf().With.Message.Contains("same project")); + } + + [Test] + public void A_blank_question_is_refused() => + Assert.That( + async () => await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(" ")), + Throws.TypeOf()); + + [Test] + public async Task Resolving_with_nothing_decided_is_refused() + { + var question = await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); + + Assert.That( + async () => await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest(" ")), + Throws.TypeOf()); + } +} diff --git a/tests/Novelly.Api.Tests/ServiceTestFixture.cs b/tests/Novelly.Api.Tests/ServiceTestFixture.cs index 4e1c4cc..a56cae4 100644 --- a/tests/Novelly.Api.Tests/ServiceTestFixture.cs +++ b/tests/Novelly.Api.Tests/ServiceTestFixture.cs @@ -2,6 +2,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; @@ -25,6 +26,8 @@ public abstract class ServiceTestFixture protected ChapterService Chapters { get; private set; } = null!; protected SceneService Scenes { get; private set; } = null!; protected BeatService Beats { get; private set; } = null!; + protected CharacterArcService Arcs { get; private set; } = null!; + protected OpenQuestionService Questions { get; private set; } = null!; [SetUp] public void SetUpFixture() @@ -36,6 +39,8 @@ public abstract class ServiceTestFixture Chapters = new ChapterService(Db.Context, Tags); Scenes = new SceneService(Db.Context); Beats = new BeatService(Db.Context, Tags); + Arcs = new CharacterArcService(Db.Context); + Questions = new OpenQuestionService(Db.Context); OnSetUp(); }