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)
.Int("number", "Position in the manuscript, 1-based.")
.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("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
@@ -346,7 +345,6 @@ public class NovelAgentToolset(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
@@ -356,7 +354,7 @@ public class NovelAgentToolset(
yield return new AgentTool(
"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 "
+ "word count is recomputed automatically.",
new JsonSchemaBuilder()
@@ -364,7 +362,6 @@ public class NovelAgentToolset(
.Str("title", "New title.")
.Int("number", "Position in the manuscript.")
.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("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
@@ -381,7 +378,6 @@ public class NovelAgentToolset(
JsonInput.String(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"),
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.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Tags;
@@ -18,9 +19,6 @@ public class Chapter
public string? Summary { get; set; }
public Guid? PovCharacterId { get; set; }
public Character? PovCharacter { get; set; }
public string? Setting { get; set; }
public string? Notes { get; set; }
@@ -38,3 +36,13 @@ public class Chapter
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,
string Title,
string? Summary,
Guid? PovCharacterId,
string? PovCharacterName,
string? Setting,
DraftStatus Status,
int? TargetWordCount,
@@ -27,8 +25,6 @@ public record ChapterResponse(
int Number,
string Title,
string? Summary,
Guid? PovCharacterId,
string? PovCharacterName,
string? Setting,
string? Notes,
DraftStatus Status,
@@ -43,7 +39,6 @@ public record CreateChapterRequest(
string Title,
int? Number = null,
string? Summary = null,
Guid? PovCharacterId = null,
string? Setting = null,
string? Notes = null,
DraftStatus Status = DraftStatus.Planned,
@@ -57,11 +52,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
{
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);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result;
@@ -72,7 +63,6 @@ public record UpdateChapterRequest(
string? Title = null,
int? Number = null,
string? Summary = null,
Guid? PovCharacterId = null,
string? Setting = null,
string? Notes = null,
DraftStatus? Status = null,
@@ -86,14 +76,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
{
var result = new ValidationResult();
if (model.Title is not null)
{
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.");
}
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a chapter", 200);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result;
@@ -133,7 +116,7 @@ public static class ChapterMapping
{
public static ChapterResponse ToResponse(this Chapter c) => new(
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.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
c.Prose, c.WordCount,
@@ -142,7 +125,7 @@ public static class ChapterMapping
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
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.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt);
+6 -12
View File
@@ -20,7 +20,6 @@ public class ChapterService(
logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
return await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats)
.Include(c => c.Tags)
.Where(c => c.ProjectId == projectId)
@@ -40,13 +39,13 @@ public class ChapterService(
{
Guard.Default(projectId, nameof(projectId));
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);
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;
}
@@ -56,7 +55,6 @@ public class ChapterService(
Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct),
Summary = request.Summary,
PovCharacterId = request.PovCharacterId,
Setting = request.Setting,
Notes = request.Notes,
Status = request.Status,
@@ -80,7 +78,7 @@ public class ChapterService(
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating chapter {ChapterId}", id);
@@ -93,7 +91,6 @@ public class ChapterService(
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
chapter.PovCharacterId = request.PovCharacterId ?? chapter.PovCharacterId;
chapter.Setting = Patch.Apply(chapter.Setting, request.Setting);
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status;
@@ -151,7 +148,6 @@ public class ChapterService(
logger.LogDebug("Finding chapter {ChapterId}", id);
var chapter = await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats).ThenInclude(b => b.Characters)
.Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Tags)
@@ -159,13 +155,11 @@ public class ChapterService(
if (chapter is null)
{
logger.LogInformation("Chapter {ChapterId} not found", id);
}
else
{
logger.LogDebug("Found chapter {ChapterId}", id);
logger.LogWarning("Chapter {ChapterId} not found", id);
return chapter;
}
logger.LogDebug("Found chapter {ChapterId}", id);
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")
.HasColumnType("INTEGER");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
@@ -226,8 +223,6 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id");
b.HasIndex("PovCharacterId");
b.HasIndex("ProjectId", "Number");
b.ToTable("Chapters");
@@ -743,19 +738,12 @@ namespace Novelly.Api.Data.Migrations
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");
});
+17 -25
View File
@@ -3,16 +3,8 @@ using Novelly.Api.Agent;
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);
/// <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(
IAgentModelClient model,
ImportAgentToolset toolset,
@@ -24,6 +16,10 @@ public class ImportAgentService(
public async Task<ImportRunResult> RunAsync(
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);
var startingLedger = toolset.ReadLedgerOrNull();
@@ -70,14 +66,10 @@ public class ImportAgentService(
+ "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)
{
logger.LogDebug("Starting import turn with {IterationCeiling}-iteration ceiling", _options.ImportMaxIterationsPerTurn);
for (var iteration = 0; iteration < _options.ImportMaxIterationsPerTurn; iteration++)
{
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
@@ -85,6 +77,7 @@ public class ImportAgentService(
var requestedTools = response.Content.OfType<AgentToolUseBlock>().ToList();
if (requestedTools.Count == 0)
{
logger.LogDebug("Import turn finished after {Iterations} iterations with no further tool calls", iteration);
return;
}
@@ -95,7 +88,8 @@ public class ImportAgentService(
{
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");
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
@@ -109,8 +103,6 @@ public class ImportAgentService(
_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 const string SystemPromptTemplate = """
@@ -139,10 +131,9 @@ public class ImportAgentService(
tagline, `## Appearance`, `## Background`, `## Motivation`, an optional `## Events`
section (bulleted, each optionally marked `*(Ch. N)*`), and an optional `## Notes`.
`**Thread:**` may name one character, several, or a character plus a qualifier
only treat it as a POV character, and only auto-create an undossiered name from it,
when it names exactly one clear proper name. A list or vague reference stays
unresolved; never guess which one was meant.
`**Thread:**` may name one character, several, or a character plus a qualifier only
auto-create an undossiered name from it when it names exactly one clear proper name. A
list or vague reference stays unresolved; never guess which one was meant.
## The ledger
@@ -182,12 +173,13 @@ public class ImportAgentService(
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
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
character; **auto-create** a character stub (name only, via create_character) for
For each: **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
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
create_beat for each table row, in order. If `## Notes` is present, call
summary, tags [Part value, "thread:<raw Thread text>"]. Then
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
`completedChapters`. Mark "chapters" done only once every chapter file is
processed, across however many turns that takes.
+13 -14
View File
@@ -8,7 +8,10 @@ using Novelly.Api.Projects;
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(
string Name,
@@ -62,7 +65,7 @@ public class ImportAgentToolset(
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);
}
@@ -221,7 +224,7 @@ public class ImportAgentToolset(
Notes: JsonInput.String(input, "notes")), ct);
return updated is null
? new ImportToolNotFound($"Project '{projectId}' was not found.")
? new ImportToolNotFound("Project", projectId)
: updated.ToResponse();
});
@@ -242,7 +245,7 @@ public class ImportAgentToolset(
Notes: JsonInput.String(input, "notes")), ct);
return created is null
? new ImportToolNotFound($"Project '{projectId}' was not found.")
? new ImportToolNotFound("Project", projectId)
: created.ToResponse();
});
@@ -265,7 +268,7 @@ public class ImportAgentToolset(
Notes: JsonInput.String(input, "notes")), ct);
return updated is null
? new ImportToolNotFound($"Character '{characterId}' was not found.")
? new ImportToolNotFound("Character", characterId)
: updated.ToResponse();
});
@@ -276,7 +279,6 @@ public class ImportAgentToolset(
.Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based, matching the outline's chapter number.")
.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.")
.StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].")
.Build(),
@@ -287,22 +289,20 @@ public class ImportAgentToolset(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
Notes: JsonInput.String(input, "notes"),
Tags: JsonInput.Strings(input, "tags")), ct);
return created is null
? new ImportToolNotFound($"Project '{projectId}' was not found.")
? new ImportToolNotFound("Project", projectId)
: created.ToResponse();
});
yield return new ImportAgentTool(
"update_chapter",
"Revise a chapter's summary, POV or notes.",
"Revise a chapter's summary or notes.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to update.", required: true)
.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.")
.Build(),
async (input, ct) =>
@@ -310,11 +310,10 @@ public class ImportAgentToolset(
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
var updated = await chapters.UpdateAsync(chapterId, new UpdateChapterRequest(
Summary: JsonInput.String(input, "summary"),
PovCharacterId: JsonInput.Guid(input, "pov_character_id"),
Notes: JsonInput.String(input, "notes")), ct);
return updated is null
? new ImportToolNotFound($"Chapter '{chapterId}' was not found.")
? new ImportToolNotFound("Chapter", chapterId)
: updated.ToResponse();
});
@@ -340,7 +339,7 @@ public class ImportAgentToolset(
WhatsNext: JsonInput.String(input, "whats_next")), ct);
return created is null
? new ImportToolNotFound($"Chapter '{chapterId}' was not found.")
? new ImportToolNotFound("Chapter", chapterId)
: created.ToResponse();
});
@@ -364,7 +363,7 @@ public class ImportAgentToolset(
ChapterId: JsonInput.Guid(input, "chapter_id")), ct);
return created is null
? new ImportToolNotFound($"Character '{characterId}' was not found.")
? new ImportToolNotFound("Character", characterId)
: created.ToResponse();
});
}
+2 -5
View File
@@ -32,7 +32,6 @@ public static class ManuscriptTools
CancellationToken ct,
[Description("Position in the manuscript, 1-based.")] int? number = 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("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
[Description("Target length in words.")] int? targetWordCount = null,
@@ -43,7 +42,6 @@ public static class ManuscriptTools
title,
number,
summary,
povCharacterId,
setting,
status = status ?? "Planned",
targetWordCount,
@@ -52,7 +50,7 @@ public static class ManuscriptTools
}, ct);
[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 "
+ "markdown; the word count is recomputed automatically.")]
public static Task<CallToolResult> UpdateChapter(
@@ -62,7 +60,6 @@ public static class ManuscriptTools
[Description("New title.")] string? title = null,
[Description("Position in the manuscript.")] int? number = 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("Anything else worth recording.")] string? notes = 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("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
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
title: string
summary: string | null
povCharacterId: string | null
povCharacterName: string | null
setting: string | null
status: DraftStatus
targetWordCount: number | null
+1 -16
View File
@@ -84,22 +84,7 @@ export default function ChapterPage() {
/>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<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>
<div className="mt-4">
<AutoField
label="Setting"
value={chapter.setting}
@@ -57,7 +57,6 @@ export default function ChaptersPage() {
)}
</div>
<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.wordCount.toLocaleString()} words</span>
<StatusBadge status={chapter.status} />