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.
This commit is contained in:
@@ -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<object> OrNotFound<T>(Task<T?> 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<object> OrNotFound<TEntity, TResponse>(
|
||||
Task<TEntity?> lookup, Func<TEntity, TResponse> 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<object> DeletedOrNotFound(Task<bool> 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<string, AgentTool> 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. "
|
||||
|
||||
@@ -30,11 +30,7 @@ public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest>
|
||||
{
|
||||
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<UpdateBeatRequest>
|
||||
{
|
||||
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<ReorderBeatsRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public record AssignCharacterToBeatsRequest(Guid CharacterId, IReadOnlyList<Guid> BeatIds);
|
||||
|
||||
public class AssignCharacterToBeatsRequestValidator : IModelValidator<AssignCharacterToBeatsRequest>
|
||||
{
|
||||
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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -14,7 +14,8 @@ public class BeatService(
|
||||
ILogger<BeatService> logger,
|
||||
IModelValidator<CreateBeatRequest> createValidator,
|
||||
IModelValidator<UpdateBeatRequest> updateValidator,
|
||||
IModelValidator<ReorderBeatsRequest> reorderValidator)
|
||||
IModelValidator<ReorderBeatsRequest> reorderValidator,
|
||||
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<Beat>> 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<IReadOnlyList<Beat>?> 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<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> 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<Beat> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user