feat(encyclopedia): fold thin articles into a neighbour

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 09:21:32 +01:00
co-authored by Claude Opus 5.5
parent ad45fe4ac2
commit 2d94f9a8d8
3 changed files with 222 additions and 0 deletions
@@ -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;
/// <summary>Asks whether a thin article reads as a section of a nearby one, in one bounded model call.</summary>
public sealed class FoldArticleUseCase(ITextModel model, EncyclopediaSettings settings, ILogger<FoldArticleUseCase> log)
{
/// <summary>Fewer than <c>FoldMinSources</c> sources and less than <c>FoldMinChars</c> body in total; sources missing from <paramref name="notes"/> count as zero chars.</summary>
public static bool IsThin(PlannedArticle a, IReadOnlyDictionary<string, Document> 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;
/// <summary>The slug of the nearby article to fold into; null for keep or an unusable reply. Model-unavailable and other errors propagate.</summary>
public async Task<string?> FoldAsync(
PlannedArticle thin, IReadOnlyDictionary<string, Document> notes, IReadOnlyList<PlannedArticle> 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<string>(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<string, Document> notes, List<PlannedArticle> 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();
}
/// <summary>Keeps a field on its line and inside its column.</summary>
private static string Flatten(string s) => s.Replace('\r', ' ').Replace('\n', ' ').Replace('|', ' ');
}
@@ -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.
""";
/// <summary>Placeholder in <see cref="Plan"/> replaced by <c>MaxSourceCharsPerArticle</c>.</summary>
public const string MaxSourceCharsToken = "{maxSourceChars}";
@@ -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<FoldArticleUseCase>.Instance);
private static PlannedArticle Article(string slug, params string[] sources) =>
new(slug, "Titel " + slug, "Title " + slug, ArticleCategory.System, "Scope " + slug, sources);
private static Dictionary<string, Document> 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<ModelUnavailableException>(() =>
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<string, Document>
{
["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));
}
}