From a5322e71d26a6b3f2dac291e71acdca76afdb73e Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Mon, 26 Oct 2026 09:16:24 +0100 Subject: [PATCH] fix(encyclopedia): tighten assign tests and first-paragraph handling Co-Authored-By: Claude Sonnet 5.5 --- .../Encyclopedia/AssignNotesUseCase.cs | 11 +++-- src/Wiki.Application/Encyclopedia/Prompts.cs | 2 +- .../AssignNotesUseCaseTests.cs | 45 +++++++++++++++++-- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/Wiki.Application/Encyclopedia/AssignNotesUseCase.cs b/src/Wiki.Application/Encyclopedia/AssignNotesUseCase.cs index 66794d8..108a6de 100644 --- a/src/Wiki.Application/Encyclopedia/AssignNotesUseCase.cs +++ b/src/Wiki.Application/Encyclopedia/AssignNotesUseCase.cs @@ -150,7 +150,12 @@ public sealed partial class AssignNotesUseCase(ITextModel model, EncyclopediaSet log.LogWarning("Dropping new article {Slug}: empty scope", slug); return null; } - return new PlannedArticle(slug, Text(o["titleDe"]) ?? slug, Text(o["titleEn"]) ?? slug, category, scope, []); + if (Text(o["titleDe"]) is not { } titleDe || Text(o["titleEn"]) is not { } titleEn) + { + log.LogWarning("Dropping new article {Slug}: missing title", slug); + return null; + } + return new PlannedArticle(slug, titleDe, titleEn, category, scope, []); } private static string? Text(JsonNode? n) => @@ -177,8 +182,8 @@ public sealed partial class AssignNotesUseCase(ITextModel model, EncyclopediaSet private static string FirstParagraph(string body) { var lines = body.Replace("\r", "").Split('\n') - .SkipWhile(l => l.Length == 0 || l.TrimStart().StartsWith('#')) - .TakeWhile(l => l.Trim().Length > 0); + .SkipWhile(l => string.IsNullOrWhiteSpace(l) || l.TrimStart().StartsWith('#')) + .TakeWhile(l => !string.IsNullOrWhiteSpace(l)); var text = Flatten(string.Join(' ', lines)).Trim(); return text.Length > FirstParagraphChars ? text[..FirstParagraphChars] : text; } diff --git a/src/Wiki.Application/Encyclopedia/Prompts.cs b/src/Wiki.Application/Encyclopedia/Prompts.cs index 1bbaeb8..8048a33 100644 --- a/src/Wiki.Application/Encyclopedia/Prompts.cs +++ b/src/Wiki.Application/Encyclopedia/Prompts.cs @@ -2,7 +2,7 @@ namespace Wiki.Application.Encyclopedia; /// /// Prompt texts of the encyclopedia. is bumped by hand whenever a prompt changes, -/// so that a prompt fix retries earlier rejections and failed plans. +/// so that a prompt fix retries earlier rejections and failed batches. /// public static class Prompts { diff --git a/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs b/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs index 9193922..5c1c7a2 100644 --- a/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs +++ b/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs @@ -103,7 +103,7 @@ public class AssignNotesUseCaseTests [Fact] public async Task Assign_Truncated_IsUnusable() { - var model = new FakeTextModel().Reply("""{"new":[],"notes":[""", truncated: true); + var model = new FakeTextModel().Reply("""{"new":[],"notes":[{"note":"a","skip":true}]}""", truncated: true); var outcome = await UseCase(model).AssignAsync(Batch("a"), [], ArticlePlan.Empty(), CancellationToken.None); @@ -119,14 +119,53 @@ public class AssignNotesUseCaseTests var batch = Batch(Enumerable.Range(0, 20).Select(i => $"note-{i:00}").ToArray()); var model = new FakeTextModel().Reply("""{"new":[],"notes":[]}"""); - await UseCase(model).AssignAsync(batch, articles.Take(20).ToArray(), plan, CancellationToken.None); + await UseCase(model).AssignAsync(batch, articles.Take(100).ToArray(), plan, CancellationToken.None); var user = model.Calls[0].User; Assert.Equal(20, user.Split('\n').Count(l => l.StartsWith("note-"))); - Assert.True(user.Split('\n').Count(l => l.StartsWith("article-")) <= 20); + Assert.Equal(20, user.Split('\n').Count(l => l.StartsWith("article-"))); Assert.DoesNotContain("article-20 ", user); } + [Fact] + public async Task Assign_FirstParagraph_SkipsHeadingsAndBlankLines() + { + var note = PlanArticlesUseCaseTests.Note("a") with { Body = "# T\n\n \nfirst line\ncontinued\n\nsecond para" }; + var model = new FakeTextModel().Reply("""{"new":[],"notes":[]}"""); + + await UseCase(model).AssignAsync([note], [], ArticlePlan.Empty(), CancellationToken.None); + + var line = model.Calls[0].User.Split('\n').Single(l => l.StartsWith("a | ")); + Assert.EndsWith(" | first line continued", line); + } + + [Fact] + public async Task Assign_FirstParagraph_CutTo300() + { + var note = PlanArticlesUseCaseTests.Note("a") with { Body = new string('y', 500) }; + var model = new FakeTextModel().Reply("""{"new":[],"notes":[]}"""); + + await UseCase(model).AssignAsync([note], [], ArticlePlan.Empty(), CancellationToken.None); + + var line = model.Calls[0].User.Split('\n').Single(l => l.StartsWith("a | ")); + Assert.EndsWith(" | " + new string('y', 300), line); + } + + [Fact] + public async Task Assign_NewWithoutTitle_Dropped() + { + var heating = Article("heating"); + var model = new FakeTextModel().Reply(""" + {"new":[{"slug":"garden","titleDe":"G","category":"room","scope":"s"}], + "notes":[{"note":"a","articles":["garden","heating"]}]} + """); + + var outcome = await UseCase(model).AssignAsync(Batch("a"), [heating], PlanWith(heating), CancellationToken.None); + + Assert.Empty(outcome.New); + Assert.Equal(["heating"], outcome.Assignments["a"]); + } + [Fact] public async Task Assign_ModelUnavailable_Propagates() {