diff --git a/src/Wiki.Application/Encyclopedia/FoldArticleUseCase.cs b/src/Wiki.Application/Encyclopedia/FoldArticleUseCase.cs new file mode 100644 index 0000000..3d15791 --- /dev/null +++ b/src/Wiki.Application/Encyclopedia/FoldArticleUseCase.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Application.Encyclopedia; + +/// Asks whether a thin article reads as a section of a nearby one, in one bounded model call. +public sealed class FoldArticleUseCase(ITextModel model, EncyclopediaSettings settings, ILogger log) +{ + /// Fewer than FoldMinSources sources and less than FoldMinChars body in total; sources missing from count as zero chars. + public static bool IsThin(PlannedArticle a, IReadOnlyDictionary notes, EncyclopediaSettings s) => + a.Sources.Count < s.FoldMinSources + && a.Sources.Sum(n => notes.TryGetValue(n, out var d) ? (long)d.Body.Length : 0) < s.FoldMinChars; + + /// The slug of the nearby article to fold into; null for keep or an unusable reply. Model-unavailable and other errors propagate. + public async Task FoldAsync( + PlannedArticle thin, IReadOnlyDictionary notes, IReadOnlyList nearby, CancellationToken ct) + { + var shown = nearby.Where(n => n.Slug != thin.Slug).Take(settings.CandidateArticles).ToList(); + if (shown.Count == 0) return null; + + TextReply reply; + try + { + reply = await model.CompleteAsync(Prompts.Fold, UserPrompt(thin, notes, shown), settings.FoldMaxTokens, ct); + } + catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.BadRequest) + { + log.LogWarning(e, "Fold request for {Slug} was rejected", thin.Slug); + return null; + } + + if (reply.Truncated) + { + log.LogWarning("Fold reply for {Slug} hit the token cap ({Tokens})", thin.Slug, settings.FoldMaxTokens); + return null; + } + + JsonObject? root; + try + { + root = JsonNode.Parse(ArticlePlanJson.StripFence(reply.Text)) as JsonObject; + } + catch (JsonException) + { + root = null; + } + if (root is null) + { + log.LogWarning("Fold reply for {Slug} is not a JSON object", thin.Slug); + return null; + } + + if (root["into"] is JsonValue v && v.TryGetValue(out var into) && !string.IsNullOrWhiteSpace(into)) + { + into = into.Trim(); + if (shown.Any(n => n.Slug == into)) return into; + log.LogWarning("Fold reply for {Slug} names {Target}, which is not a nearby article", thin.Slug, into); + } + return null; + } + + private static string UserPrompt(PlannedArticle thin, IReadOnlyDictionary notes, List nearby) + { + var sb = new StringBuilder(); + sb.Append("Thin article: ").Append(thin.Slug).Append(" | ").Append(Flatten(thin.TitleDe)).Append(" | ") + .Append(Flatten(thin.TitleEn)).Append(" | ").Append(Flatten(thin.Scope)).Append('\n'); + sb.Append("Its sources (title):\n"); + foreach (var s in thin.Sources) + sb.Append(notes.TryGetValue(s, out var d) ? Flatten(d.Title) : s).Append('\n'); + sb.Append("\nNearby articles (slug | title | scope):\n"); + foreach (var n in nearby) + sb.Append(n.Slug).Append(" | ").Append(Flatten(n.TitleDe)).Append(" | ").Append(Flatten(n.Scope)).Append('\n'); + return sb.ToString(); + } + + /// Keeps a field on its line and inside its column. + private static string Flatten(string s) => s.Replace('\r', ' ').Replace('\n', ' ').Replace('|', ' '); +} diff --git a/src/Wiki.Application/Encyclopedia/Prompts.cs b/src/Wiki.Application/Encyclopedia/Prompts.cs index ed56930..1c3e871 100644 --- a/src/Wiki.Application/Encyclopedia/Prompts.cs +++ b/src/Wiki.Application/Encyclopedia/Prompts.cs @@ -42,6 +42,18 @@ public static class Prompts {"overview":{"scope":"...","sources":["note-slug"]},"children":[{"slug":"...","titleDe":"...","titleEn":"...","category":"room|system|device|topic","scope":"...","sources":["note-slug"]}]} """; + public const string Fold = """ + [fold] + You decide whether a thin article of an encyclopedia about one household (the house and its homelab IT) should become a section of a nearby article. + You receive the thin article as `slug | titleDe | titleEn | scope`, the titles of its source notes, and the nearby articles, one per line as `slug | title | scope`. + + - Fold only when the thin subject reads naturally as a section of one nearby article: a part of a parent system that was set once and forgotten. + - Otherwise keep it. When no nearby article is that parent, keep it. + - Choose the target only from the nearby articles. + + Reply with JSON only, no prose and no code fence: {"into":"article-slug"} to fold, or {"keep":true} to keep. + """; + /// Placeholder in replaced by MaxSourceCharsPerArticle. public const string MaxSourceCharsToken = "{maxSourceChars}"; diff --git a/test/Wiki.Application.Tests/FoldArticleUseCaseTests.cs b/test/Wiki.Application.Tests/FoldArticleUseCaseTests.cs new file mode 100644 index 0000000..ab6cdcc --- /dev/null +++ b/test/Wiki.Application.Tests/FoldArticleUseCaseTests.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Wiki.Application.Encyclopedia; +using Wiki.Core.Model; + +namespace Wiki.Application.Tests; + +public class FoldArticleUseCaseTests +{ + private static readonly EncyclopediaSettings Settings = new(8000, 16000, 10, 300_000, [], null) { CandidateArticles = 2 }; + + private static FoldArticleUseCase UseCase(FakeTextModel model) => + new(model, Settings, NullLogger.Instance); + + private static PlannedArticle Article(string slug, params string[] sources) => + new(slug, "Titel " + slug, "Title " + slug, ArticleCategory.System, "Scope " + slug, sources); + + private static Dictionary Notes(params string[] slugs) => + slugs.ToDictionary(s => s, s => PlanArticlesUseCaseTests.Note(s)); + + [Fact] + public async Task Fold_Into() + { + var thin = Article("valve", "a"); + var model = new FakeTextModel().Reply("""{"into":"heating"}"""); + + var target = await UseCase(model).FoldAsync(thin, Notes("a"), [Article("heating", "h"), Article("garden", "g")], CancellationToken.None); + + Assert.Equal("heating", target); + Assert.Equal(Settings.FoldMaxTokens, model.Calls[0].MaxTokens); + Assert.StartsWith("[fold]", model.Calls[0].System); + } + + [Fact] + public async Task Fold_Keep_IsNull() + { + var model = new FakeTextModel().Reply("""{"keep":true}"""); + + Assert.Null(await UseCase(model).FoldAsync(Article("valve", "a"), Notes("a"), [Article("heating", "h")], CancellationToken.None)); + } + + [Fact] + public async Task Fold_TargetNotNearby_IsNull() + { + var model = new FakeTextModel().Reply("""{"into":"elsewhere"}"""); + + Assert.Null(await UseCase(model).FoldAsync(Article("valve", "a"), Notes("a"), [Article("heating", "h")], CancellationToken.None)); + } + + [Fact] + public async Task Fold_TargetBeyondCandidateCap_IsNull() + { + var nearby = new[] { Article("n1", "1"), Article("n2", "2"), Article("n3", "3") }; + var model = new FakeTextModel().Reply("""{"into":"n3"}"""); + + Assert.Null(await UseCase(model).FoldAsync(Article("valve", "a"), Notes("a"), nearby, CancellationToken.None)); + Assert.DoesNotContain("n3 | ", model.Calls[0].User); + } + + [Fact] + public async Task Fold_IntoItself_IsNull() + { + var model = new FakeTextModel().Reply("""{"into":"valve"}"""); + + Assert.Null(await UseCase(model).FoldAsync(Article("valve", "a"), Notes("a"), [Article("valve", "a"), Article("heating", "h")], CancellationToken.None)); + } + + [Fact] + public async Task Fold_NoNearby_NoCall() + { + var model = new FakeTextModel(); + + Assert.Null(await UseCase(model).FoldAsync(Article("valve", "a"), Notes("a"), [], CancellationToken.None)); + Assert.Empty(model.Calls); + } + + [Fact] + public async Task Fold_Truncated_IsNull() + { + var model = new FakeTextModel().Reply("""{"into":"heating"}""", truncated: true); + + Assert.Null(await UseCase(model).FoldAsync(Article("valve", "a"), Notes("a"), [Article("heating", "h")], CancellationToken.None)); + } + + [Fact] + public async Task Fold_NotJson_IsNull() + { + var model = new FakeTextModel().Reply("hm"); + + Assert.Null(await UseCase(model).FoldAsync(Article("valve", "a"), Notes("a"), [Article("heating", "h")], CancellationToken.None)); + } + + [Fact] + public async Task Fold_BadRequest_IsNull() + { + var model = new FakeTextModel().Throw(new HttpRequestException("bad", null, System.Net.HttpStatusCode.BadRequest)); + + Assert.Null(await UseCase(model).FoldAsync(Article("valve", "a"), Notes("a"), [Article("heating", "h")], CancellationToken.None)); + } + + [Fact] + public async Task Fold_ModelUnavailable_Propagates() + { + var model = new FakeTextModel().Throw(new ModelUnavailableException("down")); + + await Assert.ThrowsAsync(() => + UseCase(model).FoldAsync(Article("valve", "a"), Notes("a"), [Article("heating", "h")], CancellationToken.None)); + } + + [Fact] + public void IsThin_Thresholds() + { + var s = new EncyclopediaSettings(8000, 16000, 10, 300_000, [], null); + var notes = new Dictionary + { + ["a"] = PlanArticlesUseCaseTests.Note("a", 1000), + ["b"] = PlanArticlesUseCaseTests.Note("b", 1000), + ["c"] = PlanArticlesUseCaseTests.Note("c", 1000), + ["big"] = PlanArticlesUseCaseTests.Note("big", 4000), + }; + + Assert.True(FoldArticleUseCase.IsThin(Article("x", "a", "b"), notes, s)); + Assert.False(FoldArticleUseCase.IsThin(Article("x", "a", "b", "c"), notes, s)); + Assert.False(FoldArticleUseCase.IsThin(Article("x", "big"), notes, s)); + Assert.False(FoldArticleUseCase.IsThin(Article("x", "a", "b", "ghost"), notes, s)); + Assert.True(FoldArticleUseCase.IsThin(Article("x", "a"), notes, s)); + } +}