docs: bounded encyclopedia planning implementation plan
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
# Encyclopedia Bounded Planning Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the one-call article planner with a stored plan that every run updates through bounded model calls (assign, split, fold), and bound the writer's link list.
|
||||
|
||||
**Architecture:** `_plan.json` becomes state (articles with scope, note→article assignments, redirects). A run diffs notes against assignments, assigns new notes in batches of similar notes against nearby articles found through the existing per-note vector index, splits oversized and folds thin articles, then writes stale articles as today. The host follows redirects with 301s after the visibility check.
|
||||
|
||||
**Tech Stack:** .NET 10, xunit, hand-written fakes (`test/Wiki.Application.Tests/Fakes.cs`), Markdig, Blazor static SSR.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-26-encyclopedia-bounded-planning-design.md` (planning), `docs/superpowers/specs/2026-10-24-house-encyclopedia-design.md` (everything else).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No model call's input or output may grow with the number of notes or articles. Every list passed to a prompt is capped by a setting.
|
||||
- No new NuGet packages, no mocking libraries; tests use the fakes in `Fakes.cs` (`FakeTextModel`, `FakeIndex`, `FakeEmbeddings`, `FakeStore`, `FakeCatalog`).
|
||||
- `Prompts.Version` goes from 5 to 6.
|
||||
- Defaults (in `EncyclopediaOptions` and `EncyclopediaSettings`): `AssignBatchSize` 20, `CandidateArticles` 10, `NeighbourNotes` 30, `NeighbourSources` 10, `MaxAssignBatchesPerRun` 120, `MaxSplitsPerRun` 5, `MaxFoldsPerRun` 5, `MaxSourcesPerArticle` 120, `FoldMinSources` 3, `FoldMinChars` 4000, `AssignMaxTokens` 8000, `SplitMaxTokens` 8000, `FoldMaxTokens` 1000. Unchanged: `ArticleMaxTokens` 16000, `MaxArticlesPerRun` 10, `MaxSourceCharsPerArticle` 300000, `PilotSlugs`, `MaxArticles`. `PlanMaxTokens` is removed.
|
||||
- Slugs match `^[a-z0-9]+(-[a-z0-9]+)*$`. A slug in `redirects` is never reused.
|
||||
- Comments state facts, terse; match surrounding code style (primary constructors, records, `sealed`).
|
||||
- One commit per run, as today. Model-unavailable stops the run with what is staged committed.
|
||||
- Build and test: `env --chdir=<worktree> dotnet test Wiki.slnx` — all green before each commit.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Bootstrap from an empty plan:** no candidates exist, the first batches only create articles; later batches must see those as candidates *within the same run*. Pinned in Task 8 (`Run_Bootstrap_LaterBatchesSeeEarlierArticles`).
|
||||
2. **A reply naming notes outside its batch or articles outside its candidates:** those entries are dropped, the batch's other entries kept. Pinned in Task 4.
|
||||
3. **Removing the last source of an article that is a fold target or split parent:** article deleted, redirects pointing at it are removed so no chain dangles. Pinned in Task 1 (`RemoveNote_LastSource_DeletesArticleAndRedirectsToIt`).
|
||||
4. **Redirect cycles and long chains:** resolution stops after 5 hops and treats the slug as not found. Pinned in Task 1 and Task 9.
|
||||
5. **Embedding service down mid-run:** neighbour lookups throw `HttpRequestException`; the run stops assigning, keeps what is staged, still writes stale articles, answers 200. Pinned in Task 8.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Plan model v2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Wiki.Application/Encyclopedia/ArticlePlan.cs`
|
||||
- Test: `test/Wiki.Application.Tests/ArticlePlanTests.cs` (create)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `record PlannedArticle(string Slug, string TitleDe, string TitleEn, ArticleCategory Category, string Scope, IReadOnlyList<string> Sources)` (`Related` removed).
|
||||
- `sealed class ArticlePlan` — mutable, the only writer of plan state:
|
||||
- `IReadOnlyList<PlannedArticle> Articles`, `IReadOnlyDictionary<string, IReadOnlyList<string>> Assignments` (note → article slugs; empty list = skipped), `IReadOnlyDictionary<string, string> Redirects`.
|
||||
- `static ArticlePlan Empty()`.
|
||||
- `bool IsAssigned(string note)`; `PlannedArticle? Find(string slug)`; `bool SlugTaken(string slug)` (article or redirect).
|
||||
- `void AddArticle(PlannedArticle a)` (throws `InvalidOperationException` when `SlugTaken`).
|
||||
- `void Assign(string note, IReadOnlyList<string> articles)` — appends the note to each article's `Sources` (no duplicates), records the assignment; `[]` records a skip.
|
||||
- `IReadOnlyList<string> RemoveNote(string note)` — drops it from every article and from assignments; returns the slugs of articles deleted because they lost their last source. Redirects pointing at a deleted article are deleted too.
|
||||
- `void ReplaceArticle(PlannedArticle a)` (same slug, new scope/sources; keeps assignments consistent with the new `Sources`).
|
||||
- `void Fold(string thin, string into)` — moves `thin`'s sources to the end of `into`'s, rewrites assignments, deletes `thin`, sets `Redirects[thin] = into`, and repoints existing redirects whose target was `thin`.
|
||||
- `string? Resolve(string slug)` — follows `Redirects` up to 5 hops; returns the final article slug, `null` for unknown, cycles or > 5 hops.
|
||||
- `static class ArticlePlanJson`: `string Write(ArticlePlan)` (indented, camelCase, shape from the spec), `ArticlePlan? Read(string json)` — returns `null` for malformed JSON and for the old format (no `assignments` key); drops articles with bad slug/category; drops assignments to unknown articles.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — `ArticlePlanTests`: `AddArticle_TakenSlug_Throws` (incl. a slug that is a redirect), `Assign_AppendsSourcesInOrderWithoutDuplicates`, `Assign_Empty_RecordsSkip` (`IsAssigned` true, no article gains it), `RemoveNote_LastSource_DeletesArticleAndRedirectsToIt` (redirect `a→b`, remove b's only note ⇒ returns `["b"]`, `Redirects` no longer has `a`), `Fold_MovesSourcesAndRedirects` (also repoints `x→thin` to `x→into`), `Resolve_FollowsChain_StopsAtCycleAndFiveHops`, `Json_RoundTrips`, `Json_OldFormat_IsNull` (input `{"articles":[...],"coveredNotes":[...]}`), `Json_AssignmentToUnknownArticle_Dropped`.
|
||||
- [ ] **Step 2: Run, verify they fail** — `dotnet test Wiki.slnx --filter ArticlePlanTests` → compile errors / failures.
|
||||
- [ ] **Step 3: Implement** in `ArticlePlan.cs`. Existing callers (`PlanArticlesUseCase`, reconciler, writer, their tests) use `Related`/`CoveredNotes`/the old constructor: adapt them minimally so the solution builds and their tests pass (e.g. the old planner builds an `ArticlePlan` through `AddArticle`/`Assign`); Tasks 7–8 replace them.
|
||||
- [ ] **Step 4: Run tests** — the new tests pass; the whole solution builds.
|
||||
- [ ] **Step 5: Commit** `feat(encyclopedia): plan v2 model with assignments and redirects`.
|
||||
|
||||
### Task 2: State v2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Wiki.Application/Encyclopedia/EncyclopediaState.cs`
|
||||
- Test: `test/Wiki.Application.Tests/EncyclopediaStateTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `record EncyclopediaState(IReadOnlyDictionary<string, SubjectAttempt> Subjects, IReadOnlyDictionary<string, DateTimeOffset> FailedBatches, IReadOnlyDictionary<string, string> KeptArticles)`.
|
||||
- `FailedBatches`: fingerprint of a batch whose reply was unusable → when.
|
||||
- `KeptArticles`: article slug → `SubjectFingerprint` at which the fold call answered keep.
|
||||
- `static string BatchFingerprint(IReadOnlyList<Document> notes)` — SHA256 over `Prompts.Version` and sorted `slug@contentHash`.
|
||||
- `PlanAttempt` and `NoteSetFingerprint` are removed; JSON with an old `failedPlan` field still reads (field ignored).
|
||||
- `SubjectFingerprint(PlannedArticle, notes)` keeps its formula minus `Related` (which no longer exists), plus `Scope`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** — `BatchFingerprint_ChangesWhenANoteChanges`, `Read_OldFailedPlanField_Ignored`, `RoundTrip_FailedBatchesAndKept`.
|
||||
- [ ] **Step 2: Run, verify fail.**
|
||||
- [ ] **Step 3: Implement.**
|
||||
- [ ] **Step 4: Run tests pass; solution builds.**
|
||||
- [ ] **Step 5: Commit** `feat(encyclopedia): state v2 with per-batch and keep memory`.
|
||||
|
||||
### Task 3: Neighbours
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Wiki.Application/Encyclopedia/ArticleNeighbours.cs`
|
||||
- Test: `test/Wiki.Application.Tests/ArticleNeighboursTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `IEmbeddingModel` (active), `IVectorIndex.QueryAsync(modelId, vector, k, ct)`, `EmbeddingText.For(doc, 1500)`, `ArticlePlan` (Task 1).
|
||||
- Produces: `sealed class ArticleNeighbours(IEmbeddingModel model, IVectorIndex index, EncyclopediaSettings settings)`:
|
||||
- `Task<float[]> VectorAsync(Document note, ct)` — embeds `EmbeddingText.For(note, 1500)`; caches per (slug, ContentHash) for the lifetime of the instance.
|
||||
- `Task<IReadOnlyList<string>> NearbyArticlesAsync(Document note, ArticlePlan plan, ct)` — `NeighbourNotes` hits, mapped through `plan.Assignments`, summed scores, top `CandidateArticles`, ties by slug.
|
||||
- `Task<IReadOnlyList<string>> NearbyArticlesAsync(PlannedArticle article, IReadOnlyDictionary<string, Document> notes, ArticlePlan plan, ct)` — over its first `NeighbourSources` sources, excluding itself.
|
||||
- `Task<IReadOnlyList<Document>> NearestAsync(Document seed, IReadOnlySet<string> among, int n, IReadOnlyDictionary<string, Document> notes, ct)` — the seed plus its nearest hits that are in `among`, up to `n`; queries `max(n * 3, NeighbourNotes)` hits.
|
||||
- Exceptions from the embedding model or index propagate.
|
||||
|
||||
- [ ] **Step 1: Failing tests** with `FakeIndex` seeded by hand-made vectors: `NearbyArticles_SumsScoresPerArticle`, `NearbyArticles_CapsAtCandidateArticles`, `NearbyArticles_OfArticle_ExcludesItself`, `Nearest_OnlyAmongAndCapped`, `Vector_CachedPerContentHash` (`FakeEmbeddings.Batches` count).
|
||||
- [ ] **Step 2–4: Run fail, implement, run pass.**
|
||||
- [ ] **Step 5: Commit** `feat(encyclopedia): nearby articles through the note index`.
|
||||
|
||||
### Task 4: Assign
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Wiki.Application/Encyclopedia/AssignNotesUseCase.cs`
|
||||
- Modify: `src/Wiki.Application/Encyclopedia/Prompts.cs` (add `Assign`; remove `Plan`; `Version = 6`)
|
||||
- Test: `test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ITextModel.CompleteAsync(system, user, maxTokens, ct)`, `ArticlePlan`, `EncyclopediaSettings`.
|
||||
- Produces:
|
||||
- `record AssignOutcome(IReadOnlyList<PlannedArticle> New, IReadOnlyDictionary<string, IReadOnlyList<string>> Assignments, bool Unusable)` — `Assignments` keys ⊆ the batch; `[]` = skip; `Unusable` when the reply was truncated, not JSON, or had no valid entry.
|
||||
- `sealed class AssignNotesUseCase(ITextModel model, EncyclopediaSettings settings, ILogger<AssignNotesUseCase> log)` with `Task<AssignOutcome> AssignAsync(IReadOnlyList<Document> batch, IReadOnlyList<PlannedArticle> candidates, ArticlePlan plan, ct)`.
|
||||
- User prompt lines: notes as `slug | title | tags | body length | first paragraph` (first paragraph ≤ 300 chars, flattened like today's `Flatten`); candidates as `slug | titleDe | category | scope | source count`.
|
||||
- Reply shape: `{"new":[{"slug","titleDe","titleEn","category","scope"}],"notes":[{"note","articles":[...]}|{"note","skip":true}]}`.
|
||||
- Validation: drop `new` entries with bad slug, taken slug (`plan.SlugTaken`), duplicate, unknown category, empty scope; drop note entries for notes not in the batch, duplicates, or referencing slugs that are neither candidates nor valid `new`; drop `new` articles no note references. `HttpRequestException` 400 → `Unusable`; `ModelUnavailableException` propagates.
|
||||
- Prompt rules (binding content, wording free): readers and scope as in today's `Plan`; granularity rule (own article only with enough material, else parent system's article); skip only meta/tooling notes; prefer existing candidates; titles/slugs name nothing concrete (no hostnames, IDs, addresses); JSON only, compact.
|
||||
|
||||
- [ ] **Step 1: Failing tests** with `FakeTextModel`: `Assign_ExistingAndNew`, `Assign_NoteOutsideBatch_Dropped`, `Assign_ArticleOutsideCandidates_Dropped`, `Assign_NewWithTakenSlug_DroppedAndItsNotesUnassigned`, `Assign_UnreferencedNew_Dropped`, `Assign_Truncated_IsUnusable`, `Assign_PromptIsBounded` (batch 20, candidates 20 ⇒ user prompt has exactly 20 note lines and ≤ 20 candidate lines regardless of `plan` size — seed the plan with 500 articles), `Assign_ModelUnavailable_Propagates`.
|
||||
- [ ] **Step 2–4.**
|
||||
- [ ] **Step 5: Commit** `feat(encyclopedia): assign notes in bounded batches`.
|
||||
|
||||
### Task 5: Split
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Wiki.Application/Encyclopedia/SplitArticleUseCase.cs`
|
||||
- Modify: `Prompts.cs` (add `Split`)
|
||||
- Test: `test/Wiki.Application.Tests/SplitArticleUseCaseTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `record SplitOutcome(PlannedArticle Overview, IReadOnlyList<PlannedArticle> Children)`; `null` = unusable.
|
||||
- `sealed class SplitArticleUseCase(ITextModel model, EncyclopediaSettings settings, ILogger<SplitArticleUseCase> log)`: `Task<SplitOutcome?> SplitAsync(PlannedArticle article, IReadOnlyDictionary<string, Document> notes, ArticlePlan plan, ct)`.
|
||||
- `static bool NeedsSplit(PlannedArticle a, IReadOnlyDictionary<string, Document> notes, EncyclopediaSettings s)` — body chars of existing sources > `MaxSourceCharsPerArticle` or source count > `MaxSourcesPerArticle`.
|
||||
- Input: the article and its first `MaxSourcesPerArticle` sources as `slug | title | tags | body length`.
|
||||
- Reply: `{"overview":{"scope","sources":[...]},"children":[{"slug","titleDe","titleEn","category","scope","sources":[...]}]}`. Valid: 2–6 children, child slugs valid and not taken, every input source in overview or ≥ 1 child, sources ⊆ input. Overview keeps slug, titles, category; its sources = model's overview sources + the sources beyond the input window (in original order).
|
||||
|
||||
- [ ] **Step 1: Failing tests:** `Split_Valid`, `Split_LostSource_IsNull`, `Split_OneChild_IsNull`, `Split_InputCappedAtMaxSources_RestStaysWithOverview`, `NeedsSplit_ByCharsAndByCount`.
|
||||
- [ ] **Step 2–4.**
|
||||
- [ ] **Step 5: Commit** `feat(encyclopedia): split oversized articles`.
|
||||
|
||||
### Task 6: Fold
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Wiki.Application/Encyclopedia/FoldArticleUseCase.cs`
|
||||
- Modify: `Prompts.cs` (add `Fold`)
|
||||
- Test: `test/Wiki.Application.Tests/FoldArticleUseCaseTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `sealed class FoldArticleUseCase(ITextModel model, EncyclopediaSettings settings, ILogger<FoldArticleUseCase> log)`: `Task<string?> FoldAsync(PlannedArticle thin, IReadOnlyDictionary<string, Document> notes, IReadOnlyList<PlannedArticle> nearby, ct)` — target slug, or `null` for keep / unusable.
|
||||
- `static bool IsThin(PlannedArticle a, IReadOnlyDictionary<string, Document> notes, EncyclopediaSettings s)` — sources < `FoldMinSources` and total body < `FoldMinChars`.
|
||||
- Reply: `{"into":"slug"}` (must be one of `nearby`) or `{"keep":true}`.
|
||||
|
||||
- [ ] **Step 1: Failing tests:** `Fold_Into`, `Fold_Keep_IsNull`, `Fold_TargetNotNearby_IsNull`, `IsThin_Thresholds`.
|
||||
- [ ] **Step 2–4.**
|
||||
- [ ] **Step 5: Commit** `feat(encyclopedia): fold thin articles into a neighbour`.
|
||||
|
||||
### Task 7: Writer link targets
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Wiki.Application/Encyclopedia/WriteArticleUseCase.cs` (`IArticleWriter`, user prompt ~l.249-274)
|
||||
- Test: `test/Wiki.Application.Tests/WriteArticleUseCaseTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `IArticleWriter.WriteAsync(PlannedArticle planned, IReadOnlyList<Document> sources, IReadOnlyList<PlannedArticle> linkTargets, ct)` — the parameter is renamed and now carries only the bounded link targets; the "Related articles" line is removed; the "Articles you may link to" section lists `linkTargets` minus the article itself. The subject line adds `Scope`.
|
||||
|
||||
- [ ] **Step 1: Failing test:** `Write_LinkListIsExactlyTheTargets` (pass 3 targets, assert the prompt lists those 3 and no other slug), update existing tests for the signature.
|
||||
- [ ] **Step 2–4.**
|
||||
- [ ] **Step 5: Commit** `feat(encyclopedia): writer links only to bounded targets`.
|
||||
|
||||
### Task 8: Reconciler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Wiki.Application/Encyclopedia/EncyclopediaReconciler.cs`, `src/Wiki.Application/Encyclopedia/PlanArticlesUseCase.cs` (delete; move `EncyclopediaSettings` to `EncyclopediaSettings.cs`), `src/Wiki.Host/Composition/WikiOptions.cs`, `src/Wiki.Host/Program.cs`, `src/Wiki.Host/Api/ApiV1.cs`
|
||||
- Test: `test/Wiki.Application.Tests/EncyclopediaReconcilerTests.cs` (rewrite planning tests; delete `PlanArticlesUseCaseTests.cs`), `test/Wiki.Host.Tests/EncyclopediaEndpointTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1–7.
|
||||
- Produces:
|
||||
- `EncyclopediaSettings` with the fields of Global Constraints (positional record; update every `new EncyclopediaSettings(...)`).
|
||||
- `ReconcileSummary(int Planned, int Assigned, int Split, int Folded, int Written, int Deleted, int Rejected, int Remembered, bool StoppedByModel, string? Skipped, string? Commit)`; `Planned` = article count after the run.
|
||||
- Reconciler takes `ArticleNeighbours`, `AssignNotesUseCase`, `SplitArticleUseCase`, `FoldArticleUseCase` in place of `PlanArticlesUseCase`. DI: `ArticleNeighbours` gets the active `IEmbeddingModel`.
|
||||
- Run order (spec "A run"): freshness gate → load plan (`ArticlePlanJson.Read`, `null` ⇒ `ArticlePlan.Empty()`) and state → diff (removed notes: `plan.RemoveNote`, collect deleted articles) → assign loop: while new notes remain and batches < `MaxAssignBatchesPerRun`: seed = first new note (ordinal slug order) not yet assigned or tried this run; batch = `NearestAsync(seed, untried, AssignBatchSize)`; if `BatchFingerprint(batch)` is in `FailedBatches`, mark the batch's notes tried and continue without a call (does not count against the cap); candidates = union of `NearbyArticlesAsync(note)` over the batch (dedupe, cap `CandidateArticles * 2`) resolved against the *current, already-updated* plan; apply outcome (`AddArticle`, `Assign`) or remember `FailedBatches[fp]` and mark the batch's notes as tried this run → split loop over `NeedsSplit` articles, biggest first, ≤ `MaxSplitsPerRun` → fold loop over `IsThin` articles not in `KeptArticles` at their current fingerprint, ≤ `MaxFoldsPerRun` → stale/due/write as today, with `linkTargets` = `NearbyArticlesAsync(article)` resolved to articles, plus split children for an overview → delete files of articles no longer in the plan (removed or folded) → stage `_plan.json` if changed → `FinishAsync`.
|
||||
- `HttpRequestException` from neighbours/embeddings: log, stop the assign/split/fold steps, continue with writing. `ModelUnavailableException` anywhere: stop, commit staged, `StoppedByModel = true`.
|
||||
- Endpoint (`ApiV1.cs`): 502 also when `summary.Planned == 0`.
|
||||
- `WikiOptions.EncyclopediaOptions`: add the new fields with defaults, remove `PlanMaxTokens`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (`EncyclopediaReconcilerTests`, `FakeTextModel` scripted per call; `FakeIndex`/`FakeEmbeddings` seeded through the real `SyncUseCase` as today): `Run_Bootstrap_LaterBatchesSeeEarlierArticles` (40 notes, batch size 20: second assign prompt lists the article the first created), `Run_OnlyNewNotesAreAssigned` (second run with one new note ⇒ one assign call), `Run_RemovedNote_DropsSourceAndDeletesEmptyArticle`, `Run_UnusableBatch_RememberedAndNotRetried` (second run, unchanged notes ⇒ no assign call), `Run_SplitThenWriteOverviewWithChildrenAsLinks`, `Run_FoldDeletesFilesAndAddsRedirect`, `Run_KeptArticle_NotAskedAgain`, `Run_EmbeddingsDown_StillWritesStale` (Review Focus 5), `Run_StepCapsRespected` (`MaxAssignBatchesPerRun` 1 ⇒ one call), `Run_ModelUnavailable_CommitsStaged`. Endpoint: `Run_EmptyPlan_Is502`.
|
||||
- [ ] **Step 2–4.**
|
||||
- [ ] **Step 5: Commit** `feat(encyclopedia): reconcile through bounded assign, split and fold`.
|
||||
|
||||
### Task 9: Redirects in the host
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Wiki.Application/Reading/ArticleQueries.cs`, `src/Wiki.Host/Components/Pages/HouseArticle.razor`, `src/Wiki.Host/Rendering/ArticleMarkdown.cs`, `src/Wiki.Host/Program.cs` (if DI changes)
|
||||
- Test: `test/Wiki.Host.Tests/HouseTests.cs`, `test/Wiki.Host.Tests/ArticleMarkdownTests.cs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `ArticleQueries(IDocumentCatalog catalog, INoteStore store)` gains `Task<IReadOnlyDictionary<string, string>> RedirectsAsync(ct)` — reads `articles/_plan.json` at the catalog's commit via `store.ReadTextAsync`, parses with `ArticlePlanJson.Read`, caches per commit; empty when missing or unreadable. And `Task<string?> ResolveAsync(string slug, ct)` = the plan's `Resolve` semantics over those redirects (≤ 5 hops, cycles ⇒ `null`); a slug that is itself an article resolves to itself.
|
||||
- `HouseArticle.razor`: when `GetAsync(Lang, Slug)` is null, resolve; if the final target exists in `Lang` and is visible to the requester (same checks as a direct request), respond 301 to `/house/{Lang}/{target}`; otherwise the usual 404.
|
||||
- `ArticleMarkdown.Render(..., IReadOnlyDictionary<string, string> visibleTitles, IReadOnlyDictionary<string, string> redirects, ...)`: a `[[slug]]` not in `visibleTitles` is resolved through `redirects` (≤ 5 hops) before being dropped; the link `href` is the target.
|
||||
|
||||
- [ ] **Step 1: Failing tests:** Host `Redirect_ToVisibleTarget_Is301`, `Redirect_ToPrivateTarget_Anonymous_Is404` (same body/status as a missing slug), `Redirect_Chain_Followed`, `Redirect_Cycle_Is404`; Markdown `WikiLink_ToFoldedSlug_PointsAtTarget`, `WikiLink_RedirectToInvisible_Dropped`.
|
||||
- [ ] **Step 2–4.**
|
||||
- [ ] **Step 5: Commit** `feat(house): follow article redirects`.
|
||||
Reference in New Issue
Block a user