Remove chapter-level POV character

Character assignment now lives on beats only, per batch-assign. Drops
Chapter.PovCharacterId (entity, contracts, service, agent/import/MCP
tools, web UI) and the backing column via migration.
This commit is contained in:
James Wampler
2026-08-15 20:44:10 -07:00
parent df10b1f99b
commit 7d8dd0c4fd
13 changed files with 973 additions and 117 deletions
+1 -5
View File
@@ -334,7 +334,6 @@ public class NovelAgentToolset(
.Str("title", "Chapter title.", required: true) .Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based.") .Int("number", "Position in the manuscript, 1-based.")
.Str("summary", "What the chapter covers.") .Str("summary", "What the chapter covers.")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("setting", "Where and when the chapter takes place.") .Str("setting", "Where and when the chapter takes place.")
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>()) .Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
@@ -346,7 +345,6 @@ public class NovelAgentToolset(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"), JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned, JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
@@ -356,7 +354,7 @@ public class NovelAgentToolset(
yield return new AgentTool( yield return new AgentTool(
"update_chapter", "update_chapter",
"Revise a chapter's title, number, summary, POV, setting, notes, status or drafted " "Revise a chapter's title, number, summary, setting, notes, status or drafted "
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the " + "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
+ "word count is recomputed automatically.", + "word count is recomputed automatically.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
@@ -364,7 +362,6 @@ public class NovelAgentToolset(
.Str("title", "New title.") .Str("title", "New title.")
.Int("number", "Position in the manuscript.") .Int("number", "Position in the manuscript.")
.Str("summary", "What the chapter covers.") .Str("summary", "What the chapter covers.")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("setting", "Where and when the chapter takes place.") .Str("setting", "Where and when the chapter takes place.")
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>()) .Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
@@ -381,7 +378,6 @@ public class NovelAgentToolset(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"), JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"), JsonInput.Enum<DraftStatus>(input, "status"),
+12 -4
View File
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -18,9 +19,6 @@ public class Chapter
public string? Summary { get; set; } public string? Summary { get; set; }
public Guid? PovCharacterId { get; set; }
public Character? PovCharacter { get; set; }
public string? Setting { get; set; } public string? Setting { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
@@ -38,3 +36,13 @@ public class Chapter
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
} }
public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
{
public void Configure(EntityTypeBuilder<Chapter> entity)
{
entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => new { c.ProjectId, c.Number });
}
}
+4 -21
View File
@@ -11,8 +11,6 @@ public record ChapterSummaryResponse(
int Number, int Number,
string Title, string Title,
string? Summary, string? Summary,
Guid? PovCharacterId,
string? PovCharacterName,
string? Setting, string? Setting,
DraftStatus Status, DraftStatus Status,
int? TargetWordCount, int? TargetWordCount,
@@ -27,8 +25,6 @@ public record ChapterResponse(
int Number, int Number,
string Title, string Title,
string? Summary, string? Summary,
Guid? PovCharacterId,
string? PovCharacterName,
string? Setting, string? Setting,
string? Notes, string? Notes,
DraftStatus Status, DraftStatus Status,
@@ -43,7 +39,6 @@ public record CreateChapterRequest(
string Title, string Title,
int? Number = null, int? Number = null,
string? Summary = null, string? Summary = null,
Guid? PovCharacterId = null,
string? Setting = null, string? Setting = null,
string? Notes = null, string? Notes = null,
DraftStatus Status = DraftStatus.Planned, DraftStatus Status = DraftStatus.Planned,
@@ -57,11 +52,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
{ {
var result = new ValidationResult(); var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Title)) result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
result.AddError("Title", "'Title' must not be empty.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result); ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result; return result;
@@ -72,7 +63,6 @@ public record UpdateChapterRequest(
string? Title = null, string? Title = null,
int? Number = null, int? Number = null,
string? Summary = null, string? Summary = null,
Guid? PovCharacterId = null,
string? Setting = null, string? Setting = null,
string? Notes = null, string? Notes = null,
DraftStatus? Status = null, DraftStatus? Status = null,
@@ -86,14 +76,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
{ {
var result = new ValidationResult(); var result = new ValidationResult();
if (model.Title is not null) result.AddUnclearableTextErrors("Title", "Title", model.Title, "a chapter", 200);
{
if (model.Title.Length == 0)
result.AddError("Title", "'Title' can not be cleared — a chapter always needs one.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result); ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result; return result;
@@ -133,7 +116,7 @@ public static class ChapterMapping
{ {
public static ChapterResponse ToResponse(this Chapter c) => new( public static ChapterResponse ToResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes, c.Setting, c.Notes,
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())], [.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
c.Prose, c.WordCount, c.Prose, c.WordCount,
@@ -142,7 +125,7 @@ public static class ChapterMapping
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new( public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount, c.Setting, c.Status, c.TargetWordCount,
c.Beats.Count, c.WordCount, c.Beats.Count, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt); c.UpdatedAt);
+6 -12
View File
@@ -20,7 +20,6 @@ public class ChapterService(
logger.LogInformation("Listing chapters for project {ProjectId}", projectId); logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
return await db.Chapters return await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats) .Include(c => c.Beats)
.Include(c => c.Tags) .Include(c => c.Tags)
.Where(c => c.ProjectId == projectId) .Where(c => c.ProjectId == projectId)
@@ -40,13 +39,13 @@ public class ChapterService(
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(); createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId); logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogInformation("Rejected chapter creation: project {ProjectId} not found", projectId); logger.LogWarning("Rejected chapter creation: project {ProjectId} not found", projectId);
return null; return null;
} }
@@ -56,7 +55,6 @@ public class ChapterService(
Title = request.Title, Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct), Number = request.Number ?? await NextChapterNumberAsync(projectId, ct),
Summary = request.Summary, Summary = request.Summary,
PovCharacterId = request.PovCharacterId,
Setting = request.Setting, Setting = request.Setting,
Notes = request.Notes, Notes = request.Notes,
Status = request.Status, Status = request.Status,
@@ -80,7 +78,7 @@ public class ChapterService(
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(); updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating chapter {ChapterId}", id); logger.LogInformation("Updating chapter {ChapterId}", id);
@@ -93,7 +91,6 @@ public class ChapterService(
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number; chapter.Number = request.Number ?? chapter.Number;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary); chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
chapter.PovCharacterId = request.PovCharacterId ?? chapter.PovCharacterId;
chapter.Setting = Patch.Apply(chapter.Setting, request.Setting); chapter.Setting = Patch.Apply(chapter.Setting, request.Setting);
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes); chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status; chapter.Status = request.Status ?? chapter.Status;
@@ -151,7 +148,6 @@ public class ChapterService(
logger.LogDebug("Finding chapter {ChapterId}", id); logger.LogDebug("Finding chapter {ChapterId}", id);
var chapter = await db.Chapters var chapter = await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats).ThenInclude(b => b.Characters) .Include(c => c.Beats).ThenInclude(b => b.Characters)
.Include(c => c.Beats).ThenInclude(b => b.Tags) .Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Tags) .Include(c => c.Tags)
@@ -159,13 +155,11 @@ public class ChapterService(
if (chapter is null) if (chapter is null)
{ {
logger.LogInformation("Chapter {ChapterId} not found", id); logger.LogWarning("Chapter {ChapterId} not found", id);
} return chapter;
else
{
logger.LogDebug("Found chapter {ChapterId}", id);
} }
logger.LogDebug("Found chapter {ChapterId}", id);
return chapter; return chapter;
} }
} }
@@ -0,0 +1,867 @@
// <auto-generated />
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("20260816034124_RemoveChapterPovCharacter")]
partial class RemoveChapterPovCharacter
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("BeatCharacter", b =>
{
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.Property<Guid>("CharactersId")
.HasColumnType("TEXT");
b.HasKey("BeatsId", "CharactersId");
b.HasIndex("CharactersId");
b.ToTable("BeatCharacters", (string)null);
});
modelBuilder.Entity("BeatTag", b =>
{
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("BeatsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("BeatTags", (string)null);
});
modelBuilder.Entity("ChapterTag", b =>
{
b.Property<Guid>("ChaptersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("ChaptersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("ChapterTags", (string)null);
});
modelBuilder.Entity("CharacterTag", b =>
{
b.Property<Guid>("CharactersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("CharactersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("CharacterTags", (string)null);
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Conversations");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("ConversationId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<int>("Sequence")
.HasColumnType("INTEGER");
b.Property<string>("ToolCallsJson")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ConversationId", "Sequence")
.IsUnique();
b.ToTable("AgentMessages");
});
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("WhatHappened")
.HasColumnType("TEXT");
b.Property<string>("WhatsNext")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Beats");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<string>("Setting")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<int>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ProjectId", "Number");
b.ToTable("Chapters");
});
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Age")
.HasColumnType("TEXT");
b.Property<string>("Appearance")
.HasColumnType("TEXT");
b.Property<string>("ArcSummary")
.HasColumnType("TEXT");
b.Property<string>("Backstory")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("ExternalConflict")
.HasColumnType("TEXT");
b.Property<string>("Importance")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("InternalConflict")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Need")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Occupation")
.HasColumnType("TEXT");
b.Property<string>("Personality")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Pronouns")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Voice")
.HasColumnType("TEXT");
b.Property<string>("Want")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Characters");
});
modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ChapterId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<Guid>("RelatedCharacterId")
.HasColumnType("TEXT");
b.Property<string>("RelationshipType")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("RelatedCharacterId");
b.ToTable("CharacterRelationships");
});
modelBuilder.Entity("Novelly.Api.Genres.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
b.HasData(
new
{
Id = new Guid("b89aadb3-ee96-5a33-897d-94946b037f96"),
Name = "Adventure"
},
new
{
Id = new Guid("1295b746-5de1-5724-aab8-186d4220c84f"),
Name = "Contemporary Fiction"
},
new
{
Id = new Guid("786d6d01-be6c-5dff-ab53-17081d2979ed"),
Name = "Crime"
},
new
{
Id = new Guid("800eea0a-52cb-5e03-8b6f-5e1ceaec8554"),
Name = "Dystopian"
},
new
{
Id = new Guid("8dbe0291-1ab6-5045-b327-00f2025a7b0a"),
Name = "Fantasy"
},
new
{
Id = new Guid("93face5a-9a61-5d63-9a8d-7fd5d49eab7d"),
Name = "Historical Fiction"
},
new
{
Id = new Guid("4eba456f-b706-5f1f-bfc9-5d32cab0da62"),
Name = "Horror"
},
new
{
Id = new Guid("d49c5adf-3ed9-5bc9-8652-1f7a9a098ecb"),
Name = "Literary Fiction"
},
new
{
Id = new Guid("f72c6437-c8e7-519f-8d35-5aefeebbff9e"),
Name = "Magical Realism"
},
new
{
Id = new Guid("1b670010-b4cc-5b22-a879-d36eb1bf3429"),
Name = "Memoir"
},
new
{
Id = new Guid("03063bbf-de5d-5dd0-af06-0ee939de58bc"),
Name = "Middle Grade"
},
new
{
Id = new Guid("c22ed045-52e5-54b0-8cdd-cd1d6a699c19"),
Name = "Mystery"
},
new
{
Id = new Guid("abe2e8bc-a35e-5a30-a07f-7ae30a00d838"),
Name = "Non-Fiction"
},
new
{
Id = new Guid("f8543db0-c519-56a0-996a-c6028176e57e"),
Name = "Poetry"
},
new
{
Id = new Guid("b6251b9e-63a1-563f-94c0-834162fb580b"),
Name = "Romance"
},
new
{
Id = new Guid("4f188842-488e-567a-b31d-831e0c551fa5"),
Name = "Science Fiction"
},
new
{
Id = new Guid("ae67fc84-1ed9-55ae-8c9f-8a37adb52b57"),
Name = "Thriller"
},
new
{
Id = new Guid("37956a94-e9c4-5d29-abbc-f121d687f997"),
Name = "Young Adult"
});
});
modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("ChaptersCompleted")
.HasColumnType("INTEGER");
b.Property<int>("ChaptersTotal")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("SourceRoot")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("StatusMessage")
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SourceRoot");
b.ToTable("ImportJobs");
});
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Author")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasColumnType("TEXT");
b.Property<string>("Logline")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Phase")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Synopsis")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ChapterId")
.HasColumnType("TEXT");
b.Property<Guid?>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Detail")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Resolution")
.HasColumnType("TEXT");
b.Property<long?>("ResolvedAt")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.HasIndex("CharacterId");
b.HasIndex("ProjectId");
b.ToTable("OpenQuestions");
});
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Color")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId", "Name")
.IsUnique();
b.ToTable("Tags");
});
modelBuilder.Entity("BeatCharacter", b =>
{
b.HasOne("Novelly.Api.Beats.Beat", null)
.WithMany()
.HasForeignKey("BeatsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", null)
.WithMany()
.HasForeignKey("CharactersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
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.Navigation("Chapter");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Chapters")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
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.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");
});
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
}
}
}
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class RemoveChapterPovCharacter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapters_Characters_PovCharacterId",
table: "Chapters");
migrationBuilder.DropIndex(
name: "IX_Chapters_PovCharacterId",
table: "Chapters");
migrationBuilder.DropColumn(
name: "PovCharacterId",
table: "Chapters");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "PovCharacterId",
table: "Chapters",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Chapters_PovCharacterId",
table: "Chapters",
column: "PovCharacterId");
migrationBuilder.AddForeignKey(
name: "FK_Chapters_Characters_PovCharacterId",
table: "Chapters",
column: "PovCharacterId",
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
}
}
@@ -190,9 +190,6 @@ namespace Novelly.Api.Data.Migrations
b.Property<int>("Number") b.Property<int>("Number")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId") b.Property<Guid>("ProjectId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -226,8 +223,6 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("PovCharacterId");
b.HasIndex("ProjectId", "Number"); b.HasIndex("ProjectId", "Number");
b.ToTable("Chapters"); b.ToTable("Chapters");
@@ -743,19 +738,12 @@ namespace Novelly.Api.Data.Migrations
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => 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") b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Chapters") .WithMany("Chapters")
.HasForeignKey("ProjectId") .HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("PovCharacter");
b.Navigation("Project"); b.Navigation("Project");
}); });
+17 -25
View File
@@ -3,16 +3,8 @@ using Novelly.Api.Agent;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
/// <summary>What one import run produced, for <see cref="ImportJobRunner"/> to persist onto the job.</summary>
public record ImportRunResult(bool Completed, Guid? ProjectId, int ChaptersCompleted, string? Message); public record ImportRunResult(bool Completed, Guid? ProjectId, int ChaptersCompleted, string? Message);
/// <summary>
/// Drives the outline-import agent to completion (or to its per-run safety limit) against
/// one source folder. Structurally like <see cref="NovelAgentService"/>'s tool-use loop, but
/// with two differences that matter: it runs many turns per call rather than one, and after
/// each turn it re-reads the ledger itself to decide whether to continue — the model saying
/// it's done is not trusted, the file it wrote is.
/// </summary>
public class ImportAgentService( public class ImportAgentService(
IAgentModelClient model, IAgentModelClient model,
ImportAgentToolset toolset, ImportAgentToolset toolset,
@@ -24,6 +16,10 @@ public class ImportAgentService(
public async Task<ImportRunResult> RunAsync( public async Task<ImportRunResult> RunAsync(
string sourceRoot, Guid? existingProjectId, int chaptersTotal, CancellationToken ct = default) string sourceRoot, Guid? existingProjectId, int chaptersTotal, CancellationToken ct = default)
{ {
logger.LogInformation(
"Running import for {SourceRoot}, existing project {ExistingProjectId}, {ChaptersTotal} chapters total",
sourceRoot, existingProjectId, chaptersTotal);
toolset.Initialize(sourceRoot, existingProjectId); toolset.Initialize(sourceRoot, existingProjectId);
var startingLedger = toolset.ReadLedgerOrNull(); var startingLedger = toolset.ReadLedgerOrNull();
@@ -70,14 +66,10 @@ public class ImportAgentService(
+ "again for the same folder will resume from the ledger."); + "again for the same folder will resume from the ledger.");
} }
/// <summary>
/// One bounded round of model calls and tool execution — the same shape as
/// <see cref="NovelAgentService.SendMessageAsync"/>'s inner loop, just against the import
/// toolset and with a higher iteration ceiling, since a batch of chapters needs far more
/// tool calls than a chat reply.
/// </summary>
private async Task RunOneTurnAsync(string systemPrompt, List<AgentChatMessage> transcript, CancellationToken ct) private async Task RunOneTurnAsync(string systemPrompt, List<AgentChatMessage> transcript, CancellationToken ct)
{ {
logger.LogDebug("Starting import turn with {IterationCeiling}-iteration ceiling", _options.ImportMaxIterationsPerTurn);
for (var iteration = 0; iteration < _options.ImportMaxIterationsPerTurn; iteration++) for (var iteration = 0; iteration < _options.ImportMaxIterationsPerTurn; iteration++)
{ {
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct); var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
@@ -85,6 +77,7 @@ public class ImportAgentService(
var requestedTools = response.Content.OfType<AgentToolUseBlock>().ToList(); var requestedTools = response.Content.OfType<AgentToolUseBlock>().ToList();
if (requestedTools.Count == 0) if (requestedTools.Count == 0)
{ {
logger.LogDebug("Import turn finished after {Iterations} iterations with no further tool calls", iteration);
return; return;
} }
@@ -95,7 +88,8 @@ public class ImportAgentService(
{ {
var outcome = await toolset.ExecuteAsync(call.Name, call.Input, ct); var outcome = await toolset.ExecuteAsync(call.Name, call.Input, ct);
logger.LogInformation( logger.Log(
outcome.IsError ? LogLevel.Warning : LogLevel.Information,
"Import tool {Tool} {Outcome}", call.Name, outcome.IsError ? "failed" : "succeeded"); "Import tool {Tool} {Outcome}", call.Name, outcome.IsError ? "failed" : "succeeded");
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError)); results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
@@ -109,8 +103,6 @@ public class ImportAgentService(
_options.ImportMaxIterationsPerTurn); _options.ImportMaxIterationsPerTurn);
} }
// Not a raw interpolated string: the ledger example below is full of JSON braces, and
// escaping every one of them for $"""...""" is more error-prone than a single Replace.
private static string BuildSystemPrompt(string sourceRoot) => SystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot); private static string BuildSystemPrompt(string sourceRoot) => SystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot);
private const string SystemPromptTemplate = """ private const string SystemPromptTemplate = """
@@ -139,10 +131,9 @@ public class ImportAgentService(
tagline, `## Appearance`, `## Background`, `## Motivation`, an optional `## Events` tagline, `## Appearance`, `## Background`, `## Motivation`, an optional `## Events`
section (bulleted, each optionally marked `*(Ch. N)*`), and an optional `## Notes`. section (bulleted, each optionally marked `*(Ch. N)*`), and an optional `## Notes`.
`**Thread:**` may name one character, several, or a character plus a qualifier `**Thread:**` may name one character, several, or a character plus a qualifier only
only treat it as a POV character, and only auto-create an undossiered name from it, auto-create an undossiered name from it when it names exactly one clear proper name. A
when it names exactly one clear proper name. A list or vague reference stays list or vague reference stays unresolved; never guess which one was meant.
unresolved; never guess which one was meant.
## The ledger ## The ledger
@@ -182,12 +173,13 @@ public class ImportAgentService(
any chapter number already in `completedChapters`. Do roughly 10 chapters, then any chapter number already in `completedChapters`. Do roughly 10 chapters, then
stop this pass for now the run driver will call you again to continue if more stop this pass for now the run driver will call you again to continue if more
remain, so there is no need to force the rest into one turn. remain, so there is no need to force the rest into one turn.
For each: resolve `pov_character_id` only when Thread names exactly one known For each: **auto-create** a character stub (name only, via create_character) for
character; **auto-create** a character stub (name only, via create_character) for
any single, unqualified name in the Thread or in a beat's Character column any single, unqualified name in the Thread or in a beat's Character column
that isn't in the ledger yet, then use its id. create_chapter with title, number, that isn't in the ledger yet, then use its id. create_chapter with title, number,
summary, pov_character_id, tags [Part value, "thread:<raw Thread text>"]. Then summary, tags [Part value, "thread:<raw Thread text>"]. Then
create_beat for each table row, in order. If `## Notes` is present, call create_beat for each table row, in order, passing character_ids for every name that
resolves the Thread character included, on every beat in the chapter, since a
chapter no longer carries a POV of its own. If `## Notes` is present, call
update_chapter with notes. Record `chapters[number]`, append to update_chapter with notes. Record `chapters[number]`, append to
`completedChapters`. Mark "chapters" done only once every chapter file is `completedChapters`. Mark "chapters" done only once every chapter file is
processed, across however many turns that takes. processed, across however many turns that takes.
+13 -14
View File
@@ -8,7 +8,10 @@ using Novelly.Api.Projects;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
internal record ImportToolNotFound(string Message); internal record ImportToolNotFound(string Entity, Guid Id)
{
public string Message => $"{Entity} '{Id}' was not found.";
}
internal record ImportAgentTool( internal record ImportAgentTool(
string Name, string Name,
@@ -62,7 +65,7 @@ public class ImportAgentToolset(
if (result is ImportToolNotFound notFound) if (result is ImportToolNotFound notFound)
{ {
logger.LogInformation("Import tool {Tool} found nothing: {Message}", name, notFound.Message); logger.LogWarning("Import tool {Tool} found no {Entity} {EntityId}", name, notFound.Entity, notFound.Id);
return new AgentToolResult(notFound.Message, true); return new AgentToolResult(notFound.Message, true);
} }
@@ -221,7 +224,7 @@ public class ImportAgentToolset(
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return updated is null return updated is null
? new ImportToolNotFound($"Project '{projectId}' was not found.") ? new ImportToolNotFound("Project", projectId)
: updated.ToResponse(); : updated.ToResponse();
}); });
@@ -242,7 +245,7 @@ public class ImportAgentToolset(
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return created is null return created is null
? new ImportToolNotFound($"Project '{projectId}' was not found.") ? new ImportToolNotFound("Project", projectId)
: created.ToResponse(); : created.ToResponse();
}); });
@@ -265,7 +268,7 @@ public class ImportAgentToolset(
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return updated is null return updated is null
? new ImportToolNotFound($"Character '{characterId}' was not found.") ? new ImportToolNotFound("Character", characterId)
: updated.ToResponse(); : updated.ToResponse();
}); });
@@ -276,7 +279,6 @@ public class ImportAgentToolset(
.Str("title", "Chapter title.", required: true) .Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based, matching the outline's chapter number.") .Int("number", "Position in the manuscript, 1-based, matching the outline's chapter number.")
.Str("summary", "The chapter's prose summary paragraph(s).") .Str("summary", "The chapter's prose summary paragraph(s).")
.Str("pov_character_id", "Id of the point-of-view character, only when the Thread names exactly one.")
.Str("notes", "The chapter file's ## Notes section, if present.") .Str("notes", "The chapter file's ## Notes section, if present.")
.StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].") .StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].")
.Build(), .Build(),
@@ -287,22 +289,20 @@ public class ImportAgentToolset(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
Notes: JsonInput.String(input, "notes"), Notes: JsonInput.String(input, "notes"),
Tags: JsonInput.Strings(input, "tags")), ct); Tags: JsonInput.Strings(input, "tags")), ct);
return created is null return created is null
? new ImportToolNotFound($"Project '{projectId}' was not found.") ? new ImportToolNotFound("Project", projectId)
: created.ToResponse(); : created.ToResponse();
}); });
yield return new ImportAgentTool( yield return new ImportAgentTool(
"update_chapter", "update_chapter",
"Revise a chapter's summary, POV or notes.", "Revise a chapter's summary or notes.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to update.", required: true) .Str("chapter_id", "Id of the chapter to update.", required: true)
.Str("summary", "The chapter's prose summary paragraph(s).") .Str("summary", "The chapter's prose summary paragraph(s).")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("notes", "The chapter file's ## Notes section.") .Str("notes", "The chapter file's ## Notes section.")
.Build(), .Build(),
async (input, ct) => async (input, ct) =>
@@ -310,11 +310,10 @@ public class ImportAgentToolset(
var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
var updated = await chapters.UpdateAsync(chapterId, new UpdateChapterRequest( var updated = await chapters.UpdateAsync(chapterId, new UpdateChapterRequest(
Summary: JsonInput.String(input, "summary"), Summary: JsonInput.String(input, "summary"),
PovCharacterId: JsonInput.Guid(input, "pov_character_id"),
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return updated is null return updated is null
? new ImportToolNotFound($"Chapter '{chapterId}' was not found.") ? new ImportToolNotFound("Chapter", chapterId)
: updated.ToResponse(); : updated.ToResponse();
}); });
@@ -340,7 +339,7 @@ public class ImportAgentToolset(
WhatsNext: JsonInput.String(input, "whats_next")), ct); WhatsNext: JsonInput.String(input, "whats_next")), ct);
return created is null return created is null
? new ImportToolNotFound($"Chapter '{chapterId}' was not found.") ? new ImportToolNotFound("Chapter", chapterId)
: created.ToResponse(); : created.ToResponse();
}); });
@@ -364,7 +363,7 @@ public class ImportAgentToolset(
ChapterId: JsonInput.Guid(input, "chapter_id")), ct); ChapterId: JsonInput.Guid(input, "chapter_id")), ct);
return created is null return created is null
? new ImportToolNotFound($"Character '{characterId}' was not found.") ? new ImportToolNotFound("Character", characterId)
: created.ToResponse(); : created.ToResponse();
}); });
} }
+2 -5
View File
@@ -32,7 +32,6 @@ public static class ManuscriptTools
CancellationToken ct, CancellationToken ct,
[Description("Position in the manuscript, 1-based.")] int? number = null, [Description("Position in the manuscript, 1-based.")] int? number = null,
[Description("The chapter's outline summary paragraph.")] string? summary = null, [Description("The chapter's outline summary paragraph.")] string? summary = null,
[Description("Id of the point-of-view character.")] Guid? povCharacterId = null,
[Description("Where and when the chapter takes place.")] string? setting = null, [Description("Where and when the chapter takes place.")] string? setting = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
[Description("Target length in words.")] int? targetWordCount = null, [Description("Target length in words.")] int? targetWordCount = null,
@@ -43,7 +42,6 @@ public static class ManuscriptTools
title, title,
number, number,
summary, summary,
povCharacterId,
setting, setting,
status = status ?? "Planned", status = status ?? "Planned",
targetWordCount, targetWordCount,
@@ -52,7 +50,7 @@ public static class ManuscriptTools
}, ct); }, ct);
[McpServerTool(Name = "update_chapter")] [McpServerTool(Name = "update_chapter")]
[Description("Revise a chapter's title, number, summary, POV character, setting, notes, status " [Description("Revise a chapter's title, number, summary, setting, notes, status "
+ "or drafted prose. Use 'prose' to write or replace the chapter's draft text in " + "or drafted prose. Use 'prose' to write or replace the chapter's draft text in "
+ "markdown; the word count is recomputed automatically.")] + "markdown; the word count is recomputed automatically.")]
public static Task<CallToolResult> UpdateChapter( public static Task<CallToolResult> UpdateChapter(
@@ -62,7 +60,6 @@ public static class ManuscriptTools
[Description("New title.")] string? title = null, [Description("New title.")] string? title = null,
[Description("Position in the manuscript.")] int? number = null, [Description("Position in the manuscript.")] int? number = null,
[Description("The chapter's outline summary paragraph.")] string? summary = null, [Description("The chapter's outline summary paragraph.")] string? summary = null,
[Description("Id of the point-of-view character.")] Guid? povCharacterId = null,
[Description("Where and when the chapter takes place.")] string? setting = null, [Description("Where and when the chapter takes place.")] string? setting = null,
[Description("Anything else worth recording.")] string? notes = null, [Description("Anything else worth recording.")] string? notes = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
@@ -70,5 +67,5 @@ public static class ManuscriptTools
[Description("The chapter's drafted text, in markdown.")] string? prose = null, [Description("The chapter's drafted text, in markdown.")] string? prose = null,
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) => [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
api.PatchAsync($"/api/chapters/{chapterId}", api.PatchAsync($"/api/chapters/{chapterId}",
new { title, number, summary, povCharacterId, setting, notes, status, targetWordCount, prose, tags }, ct); new { title, number, summary, setting, notes, status, targetWordCount, prose, tags }, ct);
} }
-2
View File
@@ -173,8 +173,6 @@ export interface ChapterSummary {
number: number number: number
title: string title: string
summary: string | null summary: string | null
povCharacterId: string | null
povCharacterName: string | null
setting: string | null setting: string | null
status: DraftStatus status: DraftStatus
targetWordCount: number | null targetWordCount: number | null
+1 -16
View File
@@ -84,22 +84,7 @@ export default function ChapterPage() {
/> />
</div> </div>
<div className="mt-4 grid gap-4 sm:grid-cols-2"> <div className="mt-4">
<label className="block">
<span className="label">POV character</span>
<select
className="input"
value={chapter.povCharacterId ?? ''}
onChange={(e) => patch({ povCharacterId: e.target.value || null })}
>
<option value=""></option>
{characters?.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<AutoField <AutoField
label="Setting" label="Setting"
value={chapter.setting} value={chapter.setting}
@@ -57,7 +57,6 @@ export default function ChaptersPage() {
)} )}
</div> </div>
<div className="flex shrink-0 items-center gap-3 text-xs muted"> <div className="flex shrink-0 items-center gap-3 text-xs muted">
{chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>}
<span>{chapter.beatCount} beats</span> <span>{chapter.beatCount} beats</span>
<span>{chapter.wordCount.toLocaleString()} words</span> <span>{chapter.wordCount.toLocaleString()} words</span>
<StatusBadge status={chapter.status} /> <StatusBadge status={chapter.status} />