Add ChapterKind for front/back matter chapters

Chapters can now be marked FrontMatter/Body/BackMatter. Number stays
the manuscript sort key for every chapter; the author-facing display
number is now computed per-request as the chapter's ordinal among
Body chapters only, so a foreword or afterword no longer shifts the
numbering of the rest of the book. Surfaced through the API, agent
toolset, and import toolset.
This commit is contained in:
James Wampler
2026-08-19 17:54:20 -07:00
parent 71953220aa
commit ef5260a111
12 changed files with 1526 additions and 27 deletions
+47 -10
View File
@@ -385,7 +385,12 @@ public class NovelAgentToolset(
"list_chapters", "list_chapters",
"List the novel's chapters in manuscript order with beat and word counts.", "List the novel's chapters in manuscript order with beat and word counts.",
new JsonSchemaBuilder().Build(), new JsonSchemaBuilder().Build(),
async (novelId, _, ct) => (await chapters.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse())); async (novelId, _, ct) =>
{
var list = await chapters.ListAsync(novelId, ct);
var displayNumbers = ChapterNumbering.DisplayNumbers(list);
return list.Select(c => c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null));
});
yield return new AgentTool( yield return new AgentTool(
"get_chapter", "get_chapter",
@@ -396,15 +401,25 @@ public class NovelAgentToolset(
async (_, input, ct) => async (_, input, ct) =>
{ {
var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.GetAsync(chapterId, ct), c => c.ToResponse(), "Chapter", chapterId); var chapter = await chapters.GetAsync(chapterId, ct);
if (chapter is null)
{
return new ToolNotFound("Chapter", chapterId);
}
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
return chapter.ToResponse(displayNumber);
}); });
yield return new AgentTool( yield return new AgentTool(
"create_chapter", "create_chapter",
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.", "Add a chapter. Its number is appended to the end of the manuscript unless you supply one. "
+ "Front matter (foreword, introduction, prologue) and back matter (afterword, about the "
+ "author) are labeled by title alone and do not count against the numbered chapters.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("title", "Chapter title.", required: true) .Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based.") .Int("number", "Manuscript position, 1-based, counting front and back matter.")
.Enum("kind", "Front matter, a numbered body chapter, or back matter. Defaults to a body chapter.", System.Enum.GetNames<ChapterKind>())
.Str("summary", "What the chapter covers.") .Str("summary", "What the chapter covers.")
.StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.") .StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.")
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
@@ -413,26 +428,39 @@ public class NovelAgentToolset(
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.") .Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(), .Build(),
async (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest( async (novelId, input, ct) =>
{
var chapter = await chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.Enum<ChapterKind>(input, "kind") ?? ChapterKind.Body,
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
JsonInput.Strings(input, "locations"), JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned, JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"), JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"), JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId)); JsonInput.Strings(input, "tags")), ct);
if (chapter is null)
{
return new ToolNotFound("Novel", novelId);
}
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
return chapter.ToResponse(displayNumber);
});
yield return new AgentTool( yield return new AgentTool(
"update_chapter", "update_chapter",
"Revise a chapter's title, number, summary, locations, notes, status or drafted " "Revise a chapter's title, number, kind, summary, locations, 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()
.Str("chapter_id", "Id of the chapter to update.", required: true) .Str("chapter_id", "Id of the chapter to update.", required: true)
.Str("title", "New title.") .Str("title", "New title.")
.Int("number", "Position in the manuscript.") .Int("number", "Manuscript position, 1-based, counting front and back matter.")
.Enum("kind", "Front matter, a numbered body chapter, or back matter.", System.Enum.GetNames<ChapterKind>())
.Str("summary", "What the chapter covers.") .Str("summary", "What the chapter covers.")
.StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.") .StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
@@ -444,18 +472,27 @@ public class NovelAgentToolset(
async (_, input, ct) => async (_, input, ct) =>
{ {
var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.UpdateAsync( var chapter = await chapters.UpdateAsync(
chapterId, chapterId,
new UpdateChapterRequest( new UpdateChapterRequest(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.Enum<ChapterKind>(input, "kind"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
JsonInput.Strings(input, "locations"), JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"), JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"), JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"), JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId); JsonInput.Strings(input, "tags")), ct);
if (chapter is null)
{
return new ToolNotFound("Chapter", chapterId);
}
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
return chapter.ToResponse(displayNumber);
}); });
yield return new AgentTool( yield return new AgentTool(
+3
View File
@@ -16,6 +16,8 @@ public class Chapter
public int Number { get; set; } public int Number { get; set; }
public ChapterKind Kind { get; set; } = ChapterKind.Body;
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string? Summary { get; set; } public string? Summary { get; set; }
@@ -44,6 +46,7 @@ public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
{ {
entity.Property(c => c.Title).IsRequired().HasMaxLength(300); entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
entity.Property(c => c.Kind).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => new { c.NovelId, c.Number }); entity.HasIndex(c => new { c.NovelId, c.Number });
} }
} }
+10 -4
View File
@@ -10,6 +10,8 @@ public record ChapterSummaryResponse(
Guid Id, Guid Id,
Guid NovelId, Guid NovelId,
int Number, int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title, string Title,
string? Summary, string? Summary,
IReadOnlyList<LocationResponse> Locations, IReadOnlyList<LocationResponse> Locations,
@@ -24,6 +26,8 @@ public record ChapterResponse(
Guid Id, Guid Id,
Guid NovelId, Guid NovelId,
int Number, int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title, string Title,
string? Summary, string? Summary,
IReadOnlyList<LocationResponse> Locations, IReadOnlyList<LocationResponse> Locations,
@@ -39,6 +43,7 @@ public record ChapterResponse(
public record CreateChapterRequest( public record CreateChapterRequest(
string Title, string Title,
int? Number = null, int? Number = null,
ChapterKind Kind = ChapterKind.Body,
string? Summary = null, string? Summary = null,
IReadOnlyList<string>? Locations = null, IReadOnlyList<string>? Locations = null,
string? Notes = null, string? Notes = null,
@@ -63,6 +68,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
public record UpdateChapterRequest( public record UpdateChapterRequest(
string? Title = null, string? Title = null,
int? Number = null, int? Number = null,
ChapterKind? Kind = null,
string? Summary = null, string? Summary = null,
IReadOnlyList<string>? Locations = null, IReadOnlyList<string>? Locations = null,
string? Notes = null, string? Notes = null,
@@ -115,8 +121,8 @@ file static class ChapterValidation
public static class ChapterMapping public static class ChapterMapping
{ {
public static ChapterResponse ToResponse(this Chapter c) => new( public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())], [.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Notes, c.Notes,
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
@@ -125,8 +131,8 @@ public static class ChapterMapping
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt); c.UpdatedAt);
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new( public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())], [.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
c.Beats.Count, c.WordCount, c.Beats.Count, c.WordCount,
+28 -4
View File
@@ -12,7 +12,12 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) => novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) =>
Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse()))) {
var chapters = await service.ListAsync(novelId, ct);
var displayNumbers = ChapterNumbering.DisplayNumbers(chapters);
return Results.Ok(chapters.Select(c =>
c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null)));
})
.WithSummary("List a novel's chapters in manuscript order."); .WithSummary("List a novel's chapters in manuscript order.");
novelScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
@@ -24,7 +29,8 @@ public static class ChapterEndpoints
return Results.NotFound(); return Results.NotFound();
} }
var created = chapter.ToResponse(); var displayNumber = await service.DisplayNumberAsync(chapter, ct);
var created = chapter.ToResponse(displayNumber);
return Results.Created($"/api/chapters/{created.Id}", created); return Results.Created($"/api/chapters/{created.Id}", created);
}) })
.WithSummary("Add a chapter."); .WithSummary("Add a chapter.");
@@ -34,12 +40,30 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) {
var chapter = await service.GetAsync(id, ct);
if (chapter is null)
{
return Results.NotFound();
}
var displayNumber = await service.DisplayNumberAsync(chapter, ct);
return chapter.ToResponse(displayNumber).ToApiResult();
})
.WithSummary("Read a chapter with its beats and prose."); .WithSummary("Read a chapter with its beats and prose.");
chapters.MapPatch("/{id:guid}", async ( chapters.MapPatch("/{id:guid}", async (
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) => Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) {
var chapter = await service.UpdateAsync(id, request, ct);
if (chapter is null)
{
return Results.NotFound();
}
var displayNumber = await service.DisplayNumberAsync(chapter, ct);
return chapter.ToResponse(displayNumber).ToApiResult();
})
.WithSummary("Update a chapter."); .WithSummary("Update a chapter.");
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
+8
View File
@@ -0,0 +1,8 @@
namespace Novelly.Api.Chapters;
public enum ChapterKind
{
FrontMatter,
Body,
BackMatter
}
@@ -0,0 +1,25 @@
namespace Novelly.Api.Chapters;
public static class ChapterNumbering
{
public static IReadOnlyDictionary<Guid, int> DisplayNumbers(IEnumerable<Chapter> novelChapters)
{
var displayNumbers = new Dictionary<Guid, int>();
var next = 1;
foreach (var chapter in novelChapters.OrderBy(c => c.Number))
{
if (chapter.Kind != ChapterKind.Body)
continue;
displayNumbers[chapter.Id] = next++;
}
return displayNumbers;
}
public static string Label(ChapterKind kind, int? displayNumber, string title) =>
kind == ChapterKind.Body && displayNumber is { } number
? $"Chapter {number}: {title}"
: title;
}
@@ -73,6 +73,7 @@ public class ChapterService(
NovelId = novelId, NovelId = novelId,
Title = request.Title, Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct), Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
Kind = request.Kind,
Summary = request.Summary, Summary = request.Summary,
Notes = request.Notes, Notes = request.Notes,
Status = request.Status, Status = request.Status,
@@ -116,6 +117,7 @@ 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.Kind = request.Kind ?? chapter.Kind;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary); chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
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;
@@ -166,6 +168,15 @@ public class ChapterService(
return true; return true;
} }
public async Task<int?> DisplayNumberAsync(Chapter chapter, CancellationToken ct = default)
{
if (chapter.Kind != ChapterKind.Body)
return null;
return await db.Chapters.CountAsync(
c => c.NovelId == chapter.NovelId && c.Kind == ChapterKind.Body && c.Number <= chapter.Number, ct);
}
private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct) private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
{ {
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId); logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddChapterKind : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Kind",
table: "Chapters",
type: "TEXT",
maxLength: 32,
nullable: false,
defaultValue: "Body");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Kind",
table: "Chapters");
}
}
}
@@ -319,6 +319,11 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -274,10 +274,12 @@ public class ImportAgentToolset(
yield return new ImportAgentTool( yield return new ImportAgentTool(
"create_chapter", "create_chapter",
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.", "Add a chapter. Its number is appended to the end of the manuscript unless you supply one. "
+ "Use 'kind' for a foreword, prologue, afterword, or other unnumbered front/back matter.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.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.")
.Enum("kind", "Front matter, a numbered body chapter, or back matter. Defaults to a body chapter.", System.Enum.GetNames<ChapterKind>())
.Str("summary", "The chapter's prose summary paragraph(s).") .Str("summary", "The chapter's prose summary paragraph(s).")
.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'].")
@@ -288,6 +290,7 @@ public class ImportAgentToolset(
var created = await chapters.CreateAsync(novelId, new CreateChapterRequest( var created = await chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.Enum<ChapterKind>(input, "kind") ?? ChapterKind.Body,
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
Notes: JsonInput.String(input, "notes"), Notes: JsonInput.String(input, "notes"),
Tags: JsonInput.Strings(input, "tags")), ct); Tags: JsonInput.Strings(input, "tags")), ct);
@@ -78,4 +78,62 @@ public class ChapterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Deleting_a_missing_chapter_returns_false_rather_than_throwing() => public async Task Deleting_a_missing_chapter_returns_false_rather_than_throwing() =>
Assert.That(await Chapters.DeleteAsync(Guid.NewGuid()), Is.False); Assert.That(await Chapters.DeleteAsync(Guid.NewGuid()), Is.False);
[Test]
public async Task A_chapter_defaults_to_a_body_chapter()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
Assert.That(chapter!.Kind, Is.EqualTo(ChapterKind.Body));
}
[Test]
public async Task Front_matter_does_not_consume_a_chapter_number()
{
var foreword = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Foreword", Number: 1, Kind: ChapterKind.FrontMatter));
var first = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Number: 2));
var second = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("The Harbour", Number: 3));
var displayNumbers = ChapterNumbering.DisplayNumbers(await Chapters.ListAsync(_novelId));
Assert.Multiple(() =>
{
Assert.That(displayNumbers.ContainsKey(foreword!.Id), Is.False);
Assert.That(displayNumbers[first!.Id], Is.EqualTo(1));
Assert.That(displayNumbers[second!.Id], Is.EqualTo(2));
});
}
[Test]
public async Task Back_matter_is_listed_last_but_carries_no_chapter_number()
{
var body = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Number: 1));
var afterword = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Afterword", Number: 2, Kind: ChapterKind.BackMatter));
var afterwordDisplayNumber = await Chapters.DisplayNumberAsync(afterword!);
var bodyDisplayNumber = await Chapters.DisplayNumberAsync(body!);
Assert.Multiple(() =>
{
Assert.That(afterwordDisplayNumber, Is.Null);
Assert.That(bodyDisplayNumber, Is.EqualTo(1));
});
}
[Test]
public async Task Changing_a_chapter_to_front_matter_drops_it_from_the_chapter_count()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Prologue", Number: 1));
var other = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Number: 2));
var updated = await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Kind: ChapterKind.FrontMatter));
var updatedDisplayNumber = await Chapters.DisplayNumberAsync(updated!);
var otherDisplayNumber = await Chapters.DisplayNumberAsync(other!);
Assert.Multiple(() =>
{
Assert.That(updatedDisplayNumber, Is.Null);
Assert.That(otherDisplayNumber, Is.EqualTo(1));
});
}
} }