Files
Wiki/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs
T
claude-botandClaude Opus 5.5 179bc3bcbc feat(encyclopedia): consolidate nearby articles, plan hierarchy, room articles
Article vectors (titleDe | titleEn | scope, embedded locally, cosine in
memory) feed the assign candidates alongside the note-neighbour articles
and form the groups of a new consolidate step after assign: one bounded
call per group of up to 8 nearby articles, at most 60 per run, returning
merges (fold mechanics, redirects incl. public ones) and parent links
(no cycles anywhere in the plan). Groups are remembered by fingerprint
in _state.json.

PlannedArticle gains a parent; split children get the overview. The write
prompt marks main and sub-articles among its link targets (children
capped at 20); the article page shows the main article and the visible
sub-articles from the plan. Parent changes do not make articles stale.

The assign prompt also sends room notes to the room article; the write
prompt writes room articles from the room's angle.

The plan file gets version 3: the v2 plan reads as no plan, its state is
dropped, and the next run bootstraps. Prompts.Version 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:36:59 +01:00

222 lines
8.9 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Wiki.Application.Encyclopedia;
using Wiki.Core.Model;
namespace Wiki.Application.Tests;
public class AssignNotesUseCaseTests
{
private static readonly EncyclopediaSettings Settings = new(16000, 10, 300_000, [], null);
private static AssignNotesUseCase UseCase(FakeTextModel model) =>
new(model, Settings, NullLogger<AssignNotesUseCase>.Instance);
private static PlannedArticle Article(string slug, params string[] sources) =>
new(slug, "Titel " + slug, "Title " + slug, ArticleCategory.System, "Scope " + slug, sources);
private static ArticlePlan PlanWith(params PlannedArticle[] articles)
{
var plan = ArticlePlan.Empty();
foreach (var a in articles) plan.AddArticle(a);
return plan;
}
private static Document[] Batch(params string[] slugs) =>
slugs.Select(s => TestNotes.Note(s)).ToArray();
[Fact]
public void Prompt_RoomNotesAlsoFeedTheRoomArticle()
{
Assert.Contains("additionally feeds that room's article (category room)", Prompts.Assign);
Assert.Contains("\"EG Schlafzimmer\"", Prompts.Assign);
}
[Fact]
public async Task Assign_ExistingAndNew()
{
var heating = Article("heating", "old");
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"Garten","titleEn":"Garden","category":"room","scope":"Der Garten."}],
"notes":[{"note":"a","articles":["heating","garden"]},{"note":"b","articles":["garden"]},{"note":"c","skip":true}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b", "c"), [heating], PlanWith(heating), CancellationToken.None);
Assert.False(outcome.Unusable);
var garden = Assert.Single(outcome.New);
Assert.Equal("garden", garden.Slug);
Assert.Equal(ArticleCategory.Room, garden.Category);
Assert.Equal(["a", "b"], garden.Sources);
Assert.Equal(["heating", "garden"], outcome.Assignments["a"]);
Assert.Empty(outcome.Assignments["c"]);
}
[Fact]
public async Task Assign_NoteOutsideBatch_Dropped()
{
var heating = Article("heating");
var model = new FakeTextModel().Reply("""{"new":[],"notes":[{"note":"a","articles":["heating"]},{"note":"zzz","articles":["heating"]}]}""");
var outcome = await UseCase(model).AssignAsync(Batch("a"), [heating], PlanWith(heating), CancellationToken.None);
Assert.Equal(["a"], outcome.Assignments.Keys);
}
[Fact]
public async Task Assign_ArticleOutsideCandidates_Dropped()
{
var heating = Article("heating");
var other = Article("other");
var model = new FakeTextModel().Reply("""{"new":[],"notes":[{"note":"a","articles":["heating","other"]},{"note":"b","articles":["other"]}]}""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], PlanWith(heating, other), CancellationToken.None);
Assert.Equal(["heating"], outcome.Assignments["a"]);
Assert.False(outcome.Assignments.ContainsKey("b"));
}
[Fact]
public async Task Assign_NewWithExistingNonCandidateSlug_MergedIntoExistingArticle()
{
var heating = Article("heating");
var plan = PlanWith(heating, Article("garden"));
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["garden"]},{"note":"b","articles":["heating"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
Assert.False(outcome.Unusable);
Assert.Empty(outcome.New);
Assert.Equal(["garden"], outcome.Assignments["a"]);
Assert.Equal(["heating"], outcome.Assignments["b"]);
}
[Fact]
public async Task Assign_NewWithRedirectedSlug_DroppedAndItsNotesUnassigned()
{
var heating = Article("heating");
var plan = PlanWith(heating, Article("garden"), Article("yard"));
plan.Fold("yard", "garden", isPublic: false);
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"yard","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["yard"]},{"note":"b","articles":["heating"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
Assert.Empty(outcome.New);
Assert.False(outcome.Assignments.ContainsKey("a"));
Assert.Equal(["heating"], outcome.Assignments["b"]);
}
[Fact]
public async Task Assign_NewWithRetiredSlug_DroppedAndItsNotesUnassigned()
{
var heating = Article("heating");
var plan = PlanWith(heating, Article("garden", "gone"));
plan.RemoveNote("gone");
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["garden"]},{"note":"b","articles":["heating"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a", "b"), [heating], plan, CancellationToken.None);
Assert.Empty(outcome.New);
Assert.False(outcome.Assignments.ContainsKey("a"));
Assert.Equal(["heating"], outcome.Assignments["b"]);
}
[Fact]
public async Task Assign_UnreferencedNew_Dropped()
{
var heating = Article("heating");
var model = new FakeTextModel().Reply("""
{"new":[{"slug":"garden","titleDe":"G","titleEn":"G","category":"room","scope":"s"}],
"notes":[{"note":"a","articles":["heating"]}]}
""");
var outcome = await UseCase(model).AssignAsync(Batch("a"), [heating], PlanWith(heating), CancellationToken.None);
Assert.False(outcome.Unusable);
Assert.Empty(outcome.New);
}
[Fact]
public async Task Assign_Truncated_IsUnusable()
{
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);
Assert.True(outcome.Unusable);
Assert.Empty(outcome.Assignments);
}
[Fact]
public async Task Assign_PromptIsBounded()
{
var articles = Enumerable.Range(0, 500).Select(i => Article($"article-{i}", "n")).ToArray();
var plan = PlanWith(articles);
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(100).ToArray(), plan, CancellationToken.None);
var user = model.Calls[0].User;
Assert.Equal(20, user.Split('\n').Count(l => l.StartsWith("note-")));
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 = TestNotes.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 = TestNotes.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()
{
var model = new FakeTextModel().Throw(new ModelUnavailableException("down"));
await Assert.ThrowsAsync<ModelUnavailableException>(() =>
UseCase(model).AssignAsync(Batch("a"), [], ArticlePlan.Empty(), CancellationToken.None));
}
}