From df10b1f99bc70c4099044beb07e765cfb9acfeee Mon Sep 17 00:00:00 2001 From: James Wampler Date: Sat, 15 Aug 2026 20:36:25 -0700 Subject: [PATCH] Add batch character assignment for chapter outline beats Lets a writer select several beats and add one character to all of them at once, without disturbing each beat's existing characters. Surfaced through the REST API, the embedded agent, and the MCP server per the project's rule that all three share the same service methods. --- src/Novelly.Api/Agent/NovelAgentToolset.cs | 34 ++++++-- src/Novelly.Api/Beats/BeatContracts.cs | 33 +++++--- src/Novelly.Api/Beats/BeatEndpoints.cs | 5 ++ src/Novelly.Api/Beats/BeatService.cs | 77 +++++++++++++++--- src/Novelly.Mcp/Tools/BeatTools.cs | 11 +++ src/Novelly.Web/src/api/hooks.ts | 9 +++ src/Novelly.Web/src/pages/ChapterPage.tsx | 79 ++++++++++++++++++- tests/Novelly.Api.Tests/BeatServiceTests.cs | 59 ++++++++++++++ tests/Novelly.Api.Tests/ServiceTestFixture.cs | 3 +- 9 files changed, 277 insertions(+), 33 deletions(-) diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index 0391637..9dedcb9 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -11,7 +11,10 @@ namespace Novelly.Api.Agent; public record AgentToolResult(string Content, bool IsError); -internal record ToolNotFound(string Message); +internal record ToolNotFound(string Entity, Guid Id) +{ + public string Message => $"{Entity} '{Id}' was not found."; +} public record AgentTool( string Name, @@ -58,7 +61,7 @@ public class NovelAgentToolset( if (result is ToolNotFound notFound) { - logger.LogInformation("Tool {Tool} for project {ProjectId} found nothing: {Message}", name, projectId, notFound.Message); + logger.LogWarning("Tool {Tool} for project {ProjectId} found no {Entity} {EntityId}", name, projectId, notFound.Entity, notFound.Id); return new AgentToolResult(notFound.Message, true); } @@ -78,14 +81,14 @@ public class NovelAgentToolset( } private static async Task OrNotFound(Task lookup, string entity, Guid id) where T : class => - await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found."); + await lookup as object ?? new ToolNotFound(entity, id); private static async Task OrNotFound( Task lookup, Func map, string entity, Guid id) where TEntity : class => - await lookup is { } value ? map(value)! : new ToolNotFound($"{entity} '{id}' was not found."); + await lookup is { } value ? map(value)! : new ToolNotFound(entity, id); private static async Task DeletedOrNotFound(Task delete, string entity, Guid id) => - await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found."); + await delete ? new { deleted = true } : new ToolNotFound(entity, id); private Dictionary ByName => _byName ??= Build().ToDictionary(t => t.Name); @@ -265,6 +268,27 @@ public class NovelAgentToolset( .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); }); + yield return new AgentTool( + "assign_character_to_beats", + "Add a character to several beats at once. Leaves each beat's existing characters and " + + "other fields alone — this only adds, it never removes.", + new JsonSchemaBuilder() + .Str("chapter_id", "Id of the chapter the beats belong to.", required: true) + .Str("character_id", "Id of the character to add.", required: true) + .StringArray("beat_ids", "Ids of the beats to add the character to.", required: true) + .Build(), + async (_, input, ct) => + { + var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); + return await OrNotFound(beats.AssignCharacterAsync( + chapterId, + new AssignCharacterToBeatsRequest( + JsonInput.RequiredGuid(input, "character_id"), + [.. (JsonInput.Strings(input, "beat_ids") ?? []) + .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) + .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); + }); + yield return new AgentTool( "list_tags", "List the project's tags with how many characters, chapters and beats carry each. " diff --git a/src/Novelly.Api/Beats/BeatContracts.cs b/src/Novelly.Api/Beats/BeatContracts.cs index aea2377..2825374 100644 --- a/src/Novelly.Api/Beats/BeatContracts.cs +++ b/src/Novelly.Api/Beats/BeatContracts.cs @@ -30,11 +30,7 @@ public class CreateBeatRequestValidator : IModelValidator { var result = new ValidationResult(); - if (string.IsNullOrWhiteSpace(model.Title)) - result.AddError("Title", "'Title' must not be empty."); - else if (model.Title.Length > 200) - result.AddError("Title", "'Title' must be 200 characters or fewer."); - + result.AddRequiredTextErrors("Title", "Title", model.Title, 200); BeatValidation.OptionalFields(model.SortOrder, model.WhatHappened, model.WhatsNext, model.Tags, result); return result; @@ -55,14 +51,7 @@ public class UpdateBeatRequestValidator : IModelValidator { var result = new ValidationResult(); - if (model.Title is not null) - { - if (model.Title.Length == 0) - result.AddError("Title", "'Title' can not be cleared — a beat always needs one."); - else if (model.Title.Length > 200) - result.AddError("Title", "'Title' must be 200 characters or fewer."); - } - + result.AddUnclearableTextErrors("Title", "Title", model.Title, "a beat", 200); BeatValidation.OptionalFields(model.SortOrder, model.WhatHappened, model.WhatsNext, model.Tags, result); return result; @@ -112,6 +101,24 @@ public class ReorderBeatsRequestValidator : IModelValidator } } +public record AssignCharacterToBeatsRequest(Guid CharacterId, IReadOnlyList BeatIds); + +public class AssignCharacterToBeatsRequestValidator : IModelValidator +{ + public ValidationResult Validate(AssignCharacterToBeatsRequest model) + { + var result = new ValidationResult(); + + if (model.CharacterId == Guid.Empty) + result.AddError("CharacterId", "'Character Id' must not be empty."); + + if (model.BeatIds is null || model.BeatIds.Count == 0) + result.AddError("BeatIds", "'Beat Ids' must not be empty."); + + return result; + } +} + public static class BeatMapping { public static BeatResponse ToResponse(this Beat b) => new( diff --git a/src/Novelly.Api/Beats/BeatEndpoints.cs b/src/Novelly.Api/Beats/BeatEndpoints.cs index 49f1399..5f8aac3 100644 --- a/src/Novelly.Api/Beats/BeatEndpoints.cs +++ b/src/Novelly.Api/Beats/BeatEndpoints.cs @@ -34,6 +34,11 @@ public static class BeatEndpoints (await service.ReorderAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult()) .WithSummary("Renumber a chapter's beats to match the order given."); + chapterScoped.MapPost("/assign-character", async ( + Guid chapterId, AssignCharacterToBeatsRequest request, BeatService service, CancellationToken ct) => + (await service.AssignCharacterAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult()) + .WithSummary("Add a character to several beats at once, leaving each beat's existing characters alone."); + app.MapGet("/api/characters/{characterId:guid}/beats", async ( Guid characterId, BeatService service, CancellationToken ct) => (await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse()).ToList().ToApiResult()) diff --git a/src/Novelly.Api/Beats/BeatService.cs b/src/Novelly.Api/Beats/BeatService.cs index 5059502..e9bc790 100644 --- a/src/Novelly.Api/Beats/BeatService.cs +++ b/src/Novelly.Api/Beats/BeatService.cs @@ -14,7 +14,8 @@ public class BeatService( ILogger logger, IModelValidator createValidator, IModelValidator updateValidator, - IModelValidator reorderValidator) + IModelValidator reorderValidator, + IModelValidator assignCharacterValidator) { public async Task> ListAsync(Guid chapterId, CancellationToken ct = default) { @@ -45,7 +46,7 @@ public class BeatService( if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct)) { - logger.LogInformation("Character {CharacterId} not found", characterId); + logger.LogWarning("Character {CharacterId} not found", characterId); return null; } @@ -66,14 +67,14 @@ public class BeatService( { Guard.Default(chapterId, nameof(chapterId)); Guard.Null(request, nameof(request)); - createValidator.Validate(request).ThrowIfInvalid(); + createValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation("Creating beat {Title} for chapter {ChapterId}", request.Title, chapterId); var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct); if (chapter is null) { - logger.LogInformation("Rejected beat creation: chapter {ChapterId} not found", chapterId); + logger.LogWarning("Rejected beat creation: chapter {ChapterId} not found", chapterId); return null; } @@ -106,7 +107,7 @@ public class BeatService( { Guard.Default(id, nameof(id)); Guard.Null(request, nameof(request)); - updateValidator.Validate(request).ThrowIfInvalid(); + updateValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation("Updating beat {BeatId}", id); @@ -165,7 +166,7 @@ public class BeatService( { Guard.Default(chapterId, nameof(chapterId)); Guard.Null(request, nameof(request)); - reorderValidator.Validate(request).ThrowIfInvalid(); + reorderValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId); @@ -174,7 +175,7 @@ public class BeatService( var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList(); if (missing.Count > 0) { - logger.LogInformation("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]); + logger.LogWarning("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]); return null; } @@ -193,8 +194,57 @@ public class BeatService( return await ListAsync(chapterId, ct); } + public async Task?> AssignCharacterAsync( + Guid chapterId, AssignCharacterToBeatsRequest request, CancellationToken ct = default) + { + Guard.Default(chapterId, nameof(chapterId)); + Guard.Null(request, nameof(request)); + assignCharacterValidator.Validate(request).ThrowIfInvalid(logger); + + logger.LogInformation( + "Assigning character {CharacterId} to {Count} beats in chapter {ChapterId}", + request.CharacterId, request.BeatIds.Count, chapterId); + + var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct); + if (chapter is null) + { + logger.LogWarning("Rejected character assignment: chapter {ChapterId} not found", chapterId); + return null; + } + + var character = await db.Characters + .FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.ProjectId == chapter.ProjectId, ct); + if (character is null) + { + logger.LogWarning( + "Rejected character assignment: character {CharacterId} not found in project {ProjectId}", + request.CharacterId, chapter.ProjectId); + return null; + } + + var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct); + var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList(); + if (missing.Count > 0) + { + logger.LogWarning( + "Rejected character assignment: chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]); + return null; + } + + foreach (var beat in beats.Where(b => b.Characters.All(c => c.Id != character.Id))) + { + beat.Characters.Add(character); + beat.UpdatedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(ct); + return await ListAsync(chapterId, ct); + } + private async Task> ResolveCharactersAsync(Guid projectId, IReadOnlyList characterIds, CancellationToken ct) { + logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId); + var distinct = characterIds.Distinct().ToList(); if (distinct.Count == 0) { @@ -212,6 +262,7 @@ public class BeatService( "A beat's characters must belong to the same project as its chapter."); } + logger.LogDebug("Resolved {Count} characters for project {ProjectId}", found.Count, projectId); return found; } @@ -223,7 +274,9 @@ public class BeatService( .Where(b => b.ChapterId == chapterId) .MaxAsync(b => (int?)b.SortOrder, ct); - return (max ?? 0) + 1; + var next = (max ?? 0) + 1; + logger.LogDebug("Next sort order for chapter {ChapterId} is {SortOrder}", chapterId, next); + return next; } private IQueryable Query() => @@ -238,13 +291,11 @@ public class BeatService( var beat = await Query().FirstOrDefaultAsync(b => b.Id == id, ct); if (beat is null) { - logger.LogInformation("Beat {BeatId} not found", id); - } - else - { - logger.LogDebug("Found beat {BeatId}", id); + logger.LogWarning("Beat {BeatId} not found", id); + return beat; } + logger.LogDebug("Found beat {BeatId}", id); return beat; } } diff --git a/src/Novelly.Mcp/Tools/BeatTools.cs b/src/Novelly.Mcp/Tools/BeatTools.cs index 06c7825..383a15a 100644 --- a/src/Novelly.Mcp/Tools/BeatTools.cs +++ b/src/Novelly.Mcp/Tools/BeatTools.cs @@ -58,6 +58,17 @@ public static class BeatTools CancellationToken ct) => api.DeleteAsync($"/api/beats/{beatId}", ct); + [McpServerTool(Name = "assign_character_to_beats")] + [Description("Add a character to several beats at once. Leaves each beat's existing characters " + + "and other fields alone — this only adds, it never removes.")] + public static Task AssignCharacterToBeats( + NovelApiClient api, + [Description("The chapter's id.")] Guid chapterId, + [Description("Id of the character to add.")] Guid characterId, + [Description("Ids of the beats to add the character to.")] Guid[] beatIds, + CancellationToken ct) => + api.PostAsync($"/api/chapters/{chapterId}/beats/assign-character", new { characterId, beatIds }, ct); + [McpServerTool(Name = "reorder_beats")] [Description("Renumber a chapter's beats to match the order given. List every beat id in the " + "order wanted; any left out keep their relative position at the end.")] diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index 3bbd927..8175aa3 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -293,6 +293,15 @@ export function useReorderBeats(chapterId: string) { }) } +export function useAssignCharacterToBeats(chapterId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ characterId, beatIds }: { characterId: string; beatIds: string[] }) => + api.post(`/api/chapters/${chapterId}/beats/assign-character`, { characterId, beatIds }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }), + }) +} + export const useChapters = (projectId: string) => useQuery({ queryKey: keys.chapters(projectId), diff --git a/src/Novelly.Web/src/pages/ChapterPage.tsx b/src/Novelly.Web/src/pages/ChapterPage.tsx index ab3e670..58e8b0a 100644 --- a/src/Novelly.Web/src/pages/ChapterPage.tsx +++ b/src/Novelly.Web/src/pages/ChapterPage.tsx @@ -1,6 +1,7 @@ import { useState, type MouseEvent } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { + useAssignCharacterToBeats, useChapter, useCharacters, useCreateBeat, @@ -250,8 +251,25 @@ function BeatTable({ const update = useUpdateBeat(chapter.id, projectId) const remove = useDeleteBeat(chapter.id) const reorder = useReorderBeats(chapter.id) + const assignCharacter = useAssignCharacterToBeats(chapter.id) const [editingId, setEditingId] = useState(null) const [deletingBeat, setDeletingBeat] = useState(null) + const [selectedIds, setSelectedIds] = useState([]) + const [assignCharacterId, setAssignCharacterId] = useState('') + + const toggleSelected = (id: string) => + setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id])) + + const toggleSelectAll = () => + setSelectedIds((ids) => (ids.length === chapter.beats.length ? [] : chapter.beats.map((b) => b.id))) + + const assignToSelected = () => { + if (!assignCharacterId || selectedIds.length === 0) return + assignCharacter.mutate( + { characterId: assignCharacterId, beatIds: selectedIds }, + { onSuccess: () => setSelectedIds([]) }, + ) + } if (chapter.beats.length === 0) { return ( @@ -274,10 +292,50 @@ function BeatTable({ update.mutate({ id, ...body }) return ( -
+
+ {selectedIds.length > 0 && ( +
+ + {selectedIds.length} beat{selectedIds.length === 1 ? '' : 's'} selected + + + + + {assignCharacter.error && } +
+ )} + +
+
+ + # Beat @@ -302,6 +360,15 @@ function BeatTable({ }} onKeyDown={(e) => e.key === 'Escape' && setEditingId(null)} > + + toggleSelected(beat.id)} + onClick={(e) => e.stopPropagation()} + /> +
{index + 1} @@ -419,6 +486,15 @@ function BeatTable({ } }} > +
+ toggleSelected(beat.id)} + onClick={(e) => e.stopPropagation()} + /> + {index + 1} @@ -467,6 +543,7 @@ function BeatTable({ onClose={() => setDeletingBeat(null)} /> )} + ) } diff --git a/tests/Novelly.Api.Tests/BeatServiceTests.cs b/tests/Novelly.Api.Tests/BeatServiceTests.cs index 87a40bf..48d08bf 100644 --- a/tests/Novelly.Api.Tests/BeatServiceTests.cs +++ b/tests/Novelly.Api.Tests/BeatServiceTests.cs @@ -103,6 +103,65 @@ public class BeatServiceTests : ServiceTestFixture Throws.TypeOf().With.Message.Contains("same project")); } + [Test] + public async Task Assigning_a_character_to_several_beats_leaves_their_other_characters_alone() + { + var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id])); + var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); + + var assigned = await Beats.AssignCharacterAsync( + _chapterId, new AssignCharacterToBeatsRequest(mara.Id, [first.Id, second.Id])); + + Assert.Multiple(() => + { + Assert.That( + assigned!.Single(b => b.Id == first.Id).Characters.Select(c => c.Name), + Is.EquivalentTo(new[] { "Ines", "Mara" })); + Assert.That( + assigned.Single(b => b.Id == second.Id).Characters.Select(c => c.Name), + Is.EqualTo(new[] { "Mara" })); + }); + } + + [Test] + public async Task Assigning_a_character_already_on_a_beat_does_not_duplicate_it() + { + var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id])); + + var assigned = await Beats.AssignCharacterAsync( + _chapterId, new AssignCharacterToBeatsRequest(ines.Id, [beat.Id])); + + Assert.That(assigned!.Single(b => b.Id == beat.Id).Characters.Select(c => c.Name), Is.EqualTo(new[] { "Ines" })); + } + + [Test] + public async Task Assigning_a_character_from_another_project_returns_null() + { + var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); + var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); + var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); + + Assert.That( + await Beats.AssignCharacterAsync(_chapterId, new AssignCharacterToBeatsRequest(stranger.Id, [beat.Id])), + Is.Null); + } + + [Test] + public async Task Assigning_to_an_unknown_beat_returns_null_rather_than_partially_applying() + { + var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); + + var result = await Beats.AssignCharacterAsync( + _chapterId, new AssignCharacterToBeatsRequest(ines.Id, [beat.Id, Guid.NewGuid()])); + + Assert.That(result, Is.Null); + Assert.That((await Beats.GetAsync(beat.Id))!.Characters, Is.Empty); + } + [Test] public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() { diff --git a/tests/Novelly.Api.Tests/ServiceTestFixture.cs b/tests/Novelly.Api.Tests/ServiceTestFixture.cs index 6768c46..03aa20d 100644 --- a/tests/Novelly.Api.Tests/ServiceTestFixture.cs +++ b/tests/Novelly.Api.Tests/ServiceTestFixture.cs @@ -51,7 +51,8 @@ public abstract class ServiceTestFixture Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator()); Beats = new BeatService( Db.Context, Tags, BeatLogs, - new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator()); + new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(), + new AssignCharacterToBeatsRequestValidator()); Arcs = new CharacterArcService( Db.Context, ArcLogs, new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator());