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));
+ }
+}