Label chapter chips by kind, not raw number

Beats, tags, locations, open questions, and character/arc-stage
responses that reference a chapter now carry a ChapterLabel/DisplayNumber
alongside the raw Number, computed via the new
ChapterDisplayNumberLookup. Front/back matter chips show their title;
body chapters show "Chapter N: Title".
This commit is contained in:
James Wampler
2026-08-19 18:02:53 -07:00
parent ef5260a111
commit 7f56c79b20
18 changed files with 407 additions and 88 deletions
+75 -24
View File
@@ -28,6 +28,7 @@ public class NovelAgentToolset(
CharacterService characters,
CharacterArcService arcs,
ChapterService chapters,
ChapterDisplayNumberLookup chapterLabels,
BeatService beats,
TagService tags,
LocationService locations,
@@ -359,7 +360,14 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var tagId = JsonInput.RequiredGuid(input, "tag_id");
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), t => t.ToReferencesResponse(), "Tag", tagId);
var tag = await tags.GetReferencesAsync(tagId, ct);
if (tag is null)
{
return new ToolNotFound("Tag", tagId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(tag.NovelId, ct);
return tag.ToReferencesResponse(displayNumbers);
});
yield return new AgentTool(
@@ -378,7 +386,14 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var locationId = JsonInput.RequiredGuid(input, "location_id");
return await OrNotFound(locations.GetReferencesAsync(locationId, ct), l => l.ToReferencesResponse(), "Location", locationId);
var location = await locations.GetReferencesAsync(locationId, ct);
if (location is null)
{
return new ToolNotFound("Location", locationId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(location.NovelId, ct);
return location.ToReferencesResponse(displayNumbers);
});
yield return new AgentTool(
@@ -503,14 +518,18 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("character_id", "Id of the character.", required: true)
.Build(),
async (_, input, ct) =>
async (novelId, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(
beats.ListForCharacterAsync(characterId, ct),
list => list.Select(b => b.ToCharacterBeatResponse(characterId)),
"Character",
characterId);
var characterBeats = await beats.ListForCharacterAsync(characterId, ct);
if (characterBeats is null)
{
return new ToolNotFound("Character", characterId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
return characterBeats.Select(b =>
b.ToCharacterBeatResponse(characterId, b.Chapter is null ? null : chapterLabels.LabelFor(b.Chapter, displayNumbers)));
});
yield return new AgentTool(
@@ -600,12 +619,18 @@ public class NovelAgentToolset(
.Str("character_id", "Narrow to questions about one character.")
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
.Build(),
async (novelId, input, ct) => (await questions.ListAsync(
novelId,
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id"),
JsonInput.Bool(input, "include_resolved") ?? false,
ct)).Select(q => q.ToResponse()));
async (novelId, input, ct) =>
{
var list = await questions.ListAsync(
novelId,
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id"),
JsonInput.Bool(input, "include_resolved") ?? false,
ct);
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
return list.Select(q => q.ToResponse(displayNumbers));
});
yield return new AgentTool(
"raise_open_question",
@@ -618,13 +643,24 @@ public class NovelAgentToolset(
.Str("chapter_id", "The chapter outline this is about, if any.")
.Str("character_id", "The character this is about, if any.")
.Build(),
async (novelId, input, ct) => await OrNotFound(questions.CreateAsync(
novelId,
new CreateOpenQuestionRequest(
JsonInput.RequiredString(input, "question"),
JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Novel", novelId));
async (novelId, input, ct) =>
{
var question = await questions.CreateAsync(
novelId,
new CreateOpenQuestionRequest(
JsonInput.RequiredString(input, "question"),
JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct);
if (question is null)
{
return new ToolNotFound("Novel", novelId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
return question.ToResponse(displayNumbers);
});
yield return new AgentTool(
"resolve_open_question",
@@ -638,11 +674,19 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "question_id");
return await OrNotFound(questions.ResolveAsync(
var question = await questions.ResolveAsync(
questionId,
new ResolveOpenQuestionRequest(
JsonInput.RequiredString(input, "resolution"),
JsonInput.Bool(input, "append_to_notes") ?? false), ct), q => q.ToResponse(), "OpenQuestion", questionId);
JsonInput.Bool(input, "append_to_notes") ?? false), ct);
if (question is null)
{
return new ToolNotFound("OpenQuestion", questionId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(question.NovelId, ct);
return question.ToResponse(displayNumbers);
});
yield return new AgentTool(
@@ -654,7 +698,14 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "question_id");
return await OrNotFound(questions.ReopenAsync(questionId, ct), q => q.ToResponse(), "OpenQuestion", questionId);
var question = await questions.ReopenAsync(questionId, ct);
if (question is null)
{
return new ToolNotFound("OpenQuestion", questionId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(question.NovelId, ct);
return question.ToResponse(displayNumbers);
});
yield return new AgentTool(