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:
@@ -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(
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user