feat(encyclopedia): fold thin articles into a neighbour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user