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 the novel's chapters in manuscript order with beat and word counts.",
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(
"get_chapter",
@@ -396,15 +401,25 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
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(
"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()
.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.")
.StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.")
.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.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.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.Int(input, "number"),
JsonInput.Enum<ChapterKind>(input, "kind") ?? ChapterKind.Body,
JsonInput.String(input, "summary"),
JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"),
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(
"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 "
+ "word count is recomputed automatically.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to update.", required: true)
.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.")
.StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
.Str("notes", "Anything else worth recording.")
@@ -444,18 +472,27 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.UpdateAsync(
var chapter = await chapters.UpdateAsync(
chapterId,
new UpdateChapterRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.Enum<ChapterKind>(input, "kind"),
JsonInput.String(input, "summary"),
JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
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(
+3
View File
@@ -16,6 +16,8 @@ public class Chapter
public int Number { get; set; }
public ChapterKind Kind { get; set; } = ChapterKind.Body;
public string Title { get; set; } = string.Empty;
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.Status).HasConversion<string>().HasMaxLength(32);
entity.Property(c => c.Kind).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => new { c.NovelId, c.Number });
}
}
+10 -4
View File
@@ -10,6 +10,8 @@ public record ChapterSummaryResponse(
Guid Id,
Guid NovelId,
int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title,
string? Summary,
IReadOnlyList<LocationResponse> Locations,
@@ -24,6 +26,8 @@ public record ChapterResponse(
Guid Id,
Guid NovelId,
int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title,
string? Summary,
IReadOnlyList<LocationResponse> Locations,
@@ -39,6 +43,7 @@ public record ChapterResponse(
public record CreateChapterRequest(
string Title,
int? Number = null,
ChapterKind Kind = ChapterKind.Body,
string? Summary = null,
IReadOnlyList<string>? Locations = null,
string? Notes = null,
@@ -63,6 +68,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
public record UpdateChapterRequest(
string? Title = null,
int? Number = null,
ChapterKind? Kind = null,
string? Summary = null,
IReadOnlyList<string>? Locations = null,
string? Notes = null,
@@ -115,8 +121,8 @@ file static class ChapterValidation
public static class ChapterMapping
{
public static ChapterResponse ToResponse(this Chapter c) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Notes,
c.Status, c.TargetWordCount,
@@ -125,8 +131,8 @@ public static class ChapterMapping
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt);
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Status, c.TargetWordCount,
c.Beats.Count, c.WordCount,
+28 -4
View File
@@ -12,7 +12,12 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
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.");
novelScoped.MapPost("/", async (
@@ -24,7 +29,8 @@ public static class ChapterEndpoints
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);
})
.WithSummary("Add a chapter.");
@@ -34,12 +40,30 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
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.");
chapters.MapPatch("/{id:guid}", async (
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.");
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,
Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
Kind = request.Kind,
Summary = request.Summary,
Notes = request.Notes,
Status = request.Status,
@@ -116,6 +117,7 @@ public class ChapterService(
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number;
chapter.Kind = request.Kind ?? chapter.Kind;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status;
@@ -166,6 +168,15 @@ public class ChapterService(
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)
{
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")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
@@ -274,10 +274,12 @@ public class ImportAgentToolset(
yield return new ImportAgentTool(
"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()
.Str("title", "Chapter title.", required: true)
.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("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'].")
@@ -288,6 +290,7 @@ public class ImportAgentToolset(
var created = await chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.Enum<ChapterKind>(input, "kind") ?? ChapterKind.Body,
JsonInput.String(input, "summary"),
Notes: JsonInput.String(input, "notes"),
Tags: JsonInput.Strings(input, "tags")), ct);
@@ -78,4 +78,62 @@ public class ChapterServiceTests : ServiceTestFixture
[Test]
public async Task Deleting_a_missing_chapter_returns_false_rather_than_throwing() =>
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));
});
}
}