From e560fd13a75d0d9e8e74e028c86612f8c85daeaa Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sat, 24 Oct 2026 08:26:45 +0000 Subject: [PATCH 1/6] docs: house encyclopedia design Wikipedia-style, bilingual (de/en) articles generated from the wiki, with public/private split via Authentik group, LAN/NetBird only. Co-Authored-By: Claude Opus 5.5 --- .../2026-10-24-house-encyclopedia-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-24-house-encyclopedia-design.md diff --git a/docs/superpowers/specs/2026-10-24-house-encyclopedia-design.md b/docs/superpowers/specs/2026-10-24-house-encyclopedia-design.md new file mode 100644 index 0000000..8e8b12a --- /dev/null +++ b/docs/superpowers/specs/2026-10-24-house-encyclopedia-design.md @@ -0,0 +1,129 @@ +# House encyclopedia — design + +Date: 2026-10-24. Target codebase: the .NET Wiki service (`konairius/Wiki`), deployed in the homelab cluster (`kubernetes/apps/wiki`). + +## Intent + +The wiki service already holds most of what is known about the house (≈2 000 distilled notes: switches per room, the Schalter-Konvention, the Vitocal heat pump, Zigbee, network…). Those notes are fact- and incident-shaped and written for an agent: correct, but not readable by a person who just wants to understand the house. + +The goal is a **Wikipedia-style encyclopedia of the house**, generated from the existing wiki, that anyone who needs to know something about the house can read: + +- the owner, +- the household, +- a friend fixing a non-working light switch while the owner is on a business trip, +- a guest who wants to know how the lighting automation works. + +Readers range from non-technical to expert, so every article opens plainly and gets more technical further down. + +## Goals + +1. **Articles in Wikipedia form.** Each article has: + - a lead paragraph a non-technical reader understands; + - a **properties panel** (infobox) on the right with the key facts; + - a **linked** table of contents; + - a body ordered **from general principles to concrete examples** (e.g. "How it works" → "The rules" → "Examples by room"); + - **diagrams** that explain mechanisms, and **photos** where the wiki has them; + - optionally a **History** section, which stays short and comes last. +2. **Articles for rooms and for systems, linked to each other**, like Wikipedia: "EG Küche" links to "Lighting automation", "Heating" links to the rooms it serves. A front page is just an entry point into this web. +3. **Granularity follows the material, like notability on Wikipedia.** A subject gets its own article when the wiki holds enough on it: e.g. a heating valve controller with 17 automations and a complicated history. A subject that was set once and forgotten is a section of its parent system's article. +4. **Bilingual: German and English.** Every article exists in both languages, with a language switch on the page. +5. **Public and private.** + - Articles are public by default and readable without login. + - **Private sections** inside a public article are visible only to members of the wiki group after an Authentik login. In the approved mockup this is a locked block within the article; logged-out readers see that something is there but not the content. + - Whole articles can be private. + - **The AI writing the article decides what is private. When unsure, it marks the content private.** +6. **Generated, never hand-written.** Articles are produced from the wiki by the AI and kept current by the existing reconciliation cycle. New facts become wiki notes through the nightly distill job, and the articles built on those notes are then refreshed. Nightly freshness is acceptable to the owner. + +## Non-goals + +- Human editing of articles. The wiki is the source; corrections go in as wiki facts. +- Replacing the existing wiki UI or the notes. The encyclopedia is an extra layer on top. +- A separate application. It lives inside the current Wiki service and reuses its Anthropic key and config. + +## Anti-goals: a failure even if it technically works + +- **Private content reachable by anyone outside the wiki group**: hostnames, IP addresses, device IDs, credentials, secret paths, network and security layout, server and restart procedures, and anything else that helps an attacker. This includes the raw notes, the search, and the existing APIs (e.g. the anonymous `/rank` endpoint) being reachable through the public entry point. +- Articles that read like the source notes: incident logs, PR numbers, entity IDs in the main text, history dominating. +- Invented facts. Every claim must come from wiki notes. + +## Constraints + +- Stack: inside `konairius/Wiki`: .NET (`net10.0`), Blazor Server with MudBlazor, ports-and-adapters layout. Notes are stored in the Gitea repo `k.renner/notes` via the existing `INoteStore`; markdown is rendered with Markdig. The LLM is the existing Anthropic adapter. Its key comes from the ExternalSecret `wiki-anthropic`. +- Auth: Authentik. `wiki.renner.uno` is already gated by the `authentik-gate` IngressClass (currently any authenticated user, passwordless login flow). +- Homelab rules: + - every change goes through git and a PR; + - the CiliumNetworkPolicy, gateway and egress house patterns apply; + - nothing exists only on a PVC (generated articles must be in git). +- Reconciliation: the service re-syncs from Gitea every 30 min and on the push webhook (`SyncBackgroundService` / `SyncUseCase`). Distillation from `raw/` into `wiki/` runs nightly at 04:30 on the workstation. +- Images: the wiki accepts photo attachments on facts (`raw/-N.`) since 2026-09-25. Today there are almost none, and the UI does not yet serve them. +- The current Anthropic adapter is configured for short answers (`MaxTokens` 1200). Articles are much longer. + +## Decisions made by the owner + +- It is an encyclopedia layer generated from the current wiki, not written by hand. +- Its audience is everyone who needs to know things about the house, from the owner to guests. +- The article shape is the approved mockup: lead, infobox, linked TOC, principle → examples, short history last, and private sections as locked blocks. +- The property panel must never overlap diagrams or figures. This was a layout bug in the mockup. +- Articles cover rooms and systems, linked; there is no separate navigation scheme. +- The split is public and private, with login via Authentik. The AI decides, and private is the default when it is unsure. +- Articles are in German and English. +- It lives inside the current Wiki software and uses the existing AI key. +- Updates are part of the general reconciliation. Nightly is fine. +- A subject gets its own article when the material warrants it; otherwise it is a section of its parent article. +- **Scope covers the whole wiki**: the house and the homelab IT behind it (Kubernetes, servers, network). Infrastructure articles exist too. Their sensitive parts are private under the same rule. +- **Private content is visible only to Authentik accounts in the matching wiki group.** A plain login is not enough. No such group exists yet: the current `wiki` gate has no `auth.renner.uno/group`. It is created following the house pattern: the gate group is declared in the blueprint without `users:`, membership is managed in Authentik, and the gate annotation is set on the `wiki` Ingress. Friends and helpers get access by being added to that group. +- **The site is not on the internet.** Both the public and the logged-in hostname are reachable only from the LAN or via NetBird. There is no external DNS record and no route through the public gateway. "Public" means "no login needed on the home network or VPN", not internet-public. +- **Spending limits are enforced on the Anthropic API side.** The service needs no budget logic of its own beyond incremental regeneration. It must handle a limit being reached: articles stay as last generated and the job retries later. + +## Calls made by the designer (owner to check) + +- **Articles are stored in the notes repo** under their own prefix, e.g. `articles//.md`. Frontmatter records: + - the source note slugs and their content hashes; + - the article's visibility; + - the language. + + Private sections are marked in the body. This keeps generated content in git, like everything else, and lets regeneration skip articles whose sources are unchanged. +- **Regeneration is incremental.** After each sync, an article is regenerated only when one of its source notes changed, or when the set of articles itself changes. Planning the article set is a separate, rarer step: which subjects deserve an article, and which notes feed each one. That step runs when notes are added or removed. +- **Public and logged-in views use two hostnames served by the same service.** + - The existing gated `wiki.renner.uno` shows the full article, private sections included. + - A new ungated public hostname shows the public view. + + The reason: on a path the gate skips, Authentik passes no user identity, so a single hostname can't tell a logged-in reader from an anonymous one. On the public hostname, the service must: + - serve only the encyclopedia routes; + - strip any `X-Authentik-*` headers; + - omit the content of private sections and private articles entirely from the HTML, not merely hide it. + - be reachable only from the LAN and NetBird, never from the internet. The builder applies the homelab's internal-hostname and NetBird exposure pattern, not the public gateway. + + The public page shows a "log in to see more" link to the same article on the gated hostname. The hostname name itself is still open: for example `haus.renner.uno`, picked when the hostname is registered. +- **Articles go live without human approval**, as the owner decided. The service commits generated articles directly to the notes repo, the same way it already commits raw deposits. Leak protection is therefore entirely automatic and has three layers: + - the writer marks content private; + - a deterministic filter on the public text catches IP addresses (v4 and v6), hostnames and FQDNs, HA entity IDs, MACs, file paths, and OpenBao or secret paths. Any match moves the paragraph into a private section; + - the independent LLM check described in the next item. +- **Credentials and secret values never appear in any article**, private sections included. The wiki should not hold them in the first place; if it does, the writer drops them. +- **When notes disagree or are out of date**, the article states the newest confirmed fact and leaves out anything that stays in dispute. It never asserts a disputed claim confidently to a non-technical reader. +- **Group membership covers the whole wiki.** Putting the group on the existing `wiki.renner.uno` gate restricts the whole existing wiki UI, including raw notes and search, to that group. That matches "accounts in the matching wiki group". It also means a helper added to the group can browse the raw notes, not just the private sections. +- **Public pages send `noindex`**, as a precaution even on the LAN. +- **Two passes guard against leaks.** After writing an article, a second, independent check reads only its public parts and flags anything that looks private. Flagged content moves into a private section. When something is in doubt, the private side wins. +- **German is written first and English is translated from it**, so both versions say the same thing. The order of the two languages is minor and left to the builder. +- **Diagrams are produced as text, e.g. Mermaid or a constrained SVG subset, and rendered in the page.** They are not image files generated outside the service. The builder picks the format. +- **A photo follows the same visibility rule as text.** Interior photos showing devices or equipment are private unless clearly harmless. + +## Left to the builder + +- Model choice and token budget per article, and how the article-set planning step is prompted. +- Exact URL scheme, front page layout, and styling. The style should be close to Wikipedia's look; the approved mockup is a reference. +- How links between articles are resolved, including links to articles that don't exist yet. +- Serving of attachment images. +- Rollout order. A sensible pilot is a handful of articles, e.g. "Wall switches", "Heating", "EG Schlafzimmer", reviewed before generating the whole set. + +## Reference mockup + +The approved mockup is an article on "Wall switches", built from the note `schalter-konvention-blueprint-system`. It has: + +- an infobox listing type, brands, controller, rule set, "works without network?", rooms, and related articles; +- the lead sentence: "Wall switches in the house are push buttons that don't switch power directly…"; +- a flow diagram: switch → Home Assistant → lamp/blinds; +- a gesture table; +- room examples for EG Schlafzimmer and EG Bad; +- a troubleshooting section with a private block (device names, restart procedure, which server); +- a two-line History section. -- 2.54.0 From e240bea5b1285f5a57cd8ba223d99cd2c624d489 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sat, 24 Oct 2026 08:30:12 +0000 Subject: [PATCH 2/6] docs: encyclopedia on one host, gated by the app's own OIDC wiki.renner.uno has been plain Traefik with in-app OIDC since homelab 2026-10-19; the gate-based two-hostname split no longer applies. Co-Authored-By: Claude Opus 5.5 --- .../2026-10-24-house-encyclopedia-design.md | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/specs/2026-10-24-house-encyclopedia-design.md b/docs/superpowers/specs/2026-10-24-house-encyclopedia-design.md index 8e8b12a..5f759da 100644 --- a/docs/superpowers/specs/2026-10-24-house-encyclopedia-design.md +++ b/docs/superpowers/specs/2026-10-24-house-encyclopedia-design.md @@ -42,14 +42,14 @@ Readers range from non-technical to expert, so every article opens plainly and g ## Anti-goals: a failure even if it technically works -- **Private content reachable by anyone outside the wiki group**: hostnames, IP addresses, device IDs, credentials, secret paths, network and security layout, server and restart procedures, and anything else that helps an attacker. This includes the raw notes, the search, and the existing APIs (e.g. the anonymous `/rank` endpoint) being reachable through the public entry point. +- **Private content reachable by anyone outside the wiki group**: hostnames, IP addresses, device IDs, credentials, secret paths, network and security layout, server and restart procedures, and anything else that helps an attacker. This includes the raw notes, the search, and the existing APIs being reachable without login because encyclopedia routes were opened. - Articles that read like the source notes: incident logs, PR numbers, entity IDs in the main text, history dominating. - Invented facts. Every claim must come from wiki notes. ## Constraints -- Stack: inside `konairius/Wiki`: .NET (`net10.0`), Blazor Server with MudBlazor, ports-and-adapters layout. Notes are stored in the Gitea repo `k.renner/notes` via the existing `INoteStore`; markdown is rendered with Markdig. The LLM is the existing Anthropic adapter. Its key comes from the ExternalSecret `wiki-anthropic`. -- Auth: Authentik. `wiki.renner.uno` is already gated by the `authentik-gate` IngressClass (currently any authenticated user, passwordless login flow). +- Stack: inside `konairius/Wiki`: .NET (`net10.0`), Blazor Server with MudBlazor, ports-and-adapters layout. Notes are stored in the Gitea repo `konairius/notes` via the existing `INoteStore`; markdown is rendered with Markdig. The LLM is the existing Anthropic adapter. Its key comes from the ExternalSecret `wiki-anthropic`. +- Auth: `wiki.renner.uno` is plain Traefik, LAN-only. The service signs people in itself with OIDC against the Authentik application `wiki`. Every page requires an authenticated user via the fallback policy. Today any Authentik user may sign in, because there is no policy binding. Machine clients use API keys or BasicAuth. - Homelab rules: - every change goes through git and a PR; - the CiliumNetworkPolicy, gateway and egress house patterns apply; @@ -71,7 +71,7 @@ Readers range from non-technical to expert, so every article opens plainly and g - Updates are part of the general reconciliation. Nightly is fine. - A subject gets its own article when the material warrants it; otherwise it is a section of its parent article. - **Scope covers the whole wiki**: the house and the homelab IT behind it (Kubernetes, servers, network). Infrastructure articles exist too. Their sensitive parts are private under the same rule. -- **Private content is visible only to Authentik accounts in the matching wiki group.** A plain login is not enough. No such group exists yet: the current `wiki` gate has no `auth.renner.uno/group`. It is created following the house pattern: the gate group is declared in the blueprint without `users:`, membership is managed in Authentik, and the gate annotation is set on the `wiki` Ingress. Friends and helpers get access by being added to that group. +- **Private content is visible only to Authentik accounts in the matching wiki group.** A plain login is not enough. No such group exists yet. It is created following the house pattern: the group is declared in the Authentik blueprint without `users:`, and membership is managed in Authentik. The group is bound to the `wiki` OIDC application, so only members can sign in to the wiki at all. Friends and helpers get access by being added to that group. - **The site is not on the internet.** Both the public and the logged-in hostname are reachable only from the LAN or via NetBird. There is no external DNS record and no route through the public gateway. "Public" means "no login needed on the home network or VPN", not internet-public. - **Spending limits are enforced on the Anthropic API side.** The service needs no budget logic of its own beyond incremental regeneration. It must handle a limit being reached: articles stay as last generated and the job retries later. @@ -84,24 +84,17 @@ Readers range from non-technical to expert, so every article opens plainly and g Private sections are marked in the body. This keeps generated content in git, like everything else, and lets regeneration skip articles whose sources are unchanged. - **Regeneration is incremental.** After each sync, an article is regenerated only when one of its source notes changed, or when the set of articles itself changes. Planning the article set is a separate, rarer step: which subjects deserve an article, and which notes feed each one. That step runs when notes are added or removed. -- **Public and logged-in views use two hostnames served by the same service.** - - The existing gated `wiki.renner.uno` shows the full article, private sections included. - - A new ungated public hostname shows the public view. - - The reason: on a path the gate skips, Authentik passes no user identity, so a single hostname can't tell a logged-in reader from an anonymous one. On the public hostname, the service must: - - serve only the encyclopedia routes; - - strip any `X-Authentik-*` headers; - - omit the content of private sections and private articles entirely from the HTML, not merely hide it. - - be reachable only from the LAN and NetBird, never from the internet. The builder applies the homelab's internal-hostname and NetBird exposure pattern, not the public gateway. - - The public page shows a "log in to see more" link to the same article on the gated hostname. The hostname name itself is still open: for example `haus.renner.uno`, picked when the hostname is registered. +- **There is one hostname: `wiki.renner.uno`.** The service already does OIDC itself, so it knows whether a reader is signed in. Only wiki-group members can sign in, because of the policy binding. + - The encyclopedia routes allow anonymous readers. Every other route keeps requiring a login. + - For an anonymous reader, private sections and private articles are left out of the HTML entirely, not merely hidden. A "log in to see more" link starts the OIDC login. + - The host stays LAN- and NetBird-only, as it is today: there is no `dns.renner.uno/public` label. - **Articles go live without human approval**, as the owner decided. The service commits generated articles directly to the notes repo, the same way it already commits raw deposits. Leak protection is therefore entirely automatic and has three layers: - the writer marks content private; - a deterministic filter on the public text catches IP addresses (v4 and v6), hostnames and FQDNs, HA entity IDs, MACs, file paths, and OpenBao or secret paths. Any match moves the paragraph into a private section; - the independent LLM check described in the next item. - **Credentials and secret values never appear in any article**, private sections included. The wiki should not hold them in the first place; if it does, the writer drops them. - **When notes disagree or are out of date**, the article states the newest confirmed fact and leaves out anything that stays in dispute. It never asserts a disputed claim confidently to a non-technical reader. -- **Group membership covers the whole wiki.** Putting the group on the existing `wiki.renner.uno` gate restricts the whole existing wiki UI, including raw notes and search, to that group. That matches "accounts in the matching wiki group". It also means a helper added to the group can browse the raw notes, not just the private sections. +- **Group membership covers the whole wiki.** Binding the group to the `wiki` application restricts the whole existing wiki UI, including raw notes and search, to that group. That matches "accounts in the matching wiki group". It also means a helper added to the group can browse the raw notes, not just the private sections. - **Public pages send `noindex`**, as a precaution even on the LAN. - **Two passes guard against leaks.** After writing an article, a second, independent check reads only its public parts and flags anything that looks private. Flagged content moves into a private section. When something is in doubt, the private side wins. - **German is written first and English is translated from it**, so both versions say the same thing. The order of the two languages is minor and left to the builder. -- 2.54.0 From 1d5df07ad01e1a044be1ee115f7db8e63fbcf859 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sat, 24 Oct 2026 08:35:53 +0000 Subject: [PATCH 3/6] docs: house encyclopedia implementation plan Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-24-house-encyclopedia.md | 632 ++++++++++++++++++ 1 file changed, 632 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-24-house-encyclopedia.md diff --git a/docs/superpowers/plans/2026-10-24-house-encyclopedia.md b/docs/superpowers/plans/2026-10-24-house-encyclopedia.md new file mode 100644 index 0000000..966dbc3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-house-encyclopedia.md @@ -0,0 +1,632 @@ +# House Encyclopedia 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:** The Wiki service generates bilingual (de/en) Wikipedia-style articles from its notes. It commits them to the notes repo and serves them at `wiki.renner.uno/house`: public parts are open to anonymous readers, and private parts are visible only to signed-in wiki-group members. + +**Architecture:** +- A new `Article` document kind lives under `articles//.md` in the notes repo and is indexed by the existing sync. +- After every successful sync, an encyclopedia reconciler regenerates only stale articles and commits all changes in one multi-file Gitea commit. An article is stale when a source hash changed, it is missing, or its subject was removed. + - The reconciler first plans the article set, stored in `articles/_plan.json`. + - For each stale article it then runs: write German, apply the deterministic leak filter, run the LLM leak check, translate to English, and filter again. +- Article pages are static-SSR Razor components with their own Wikipedia-like layout and `[AllowAnonymous]`. Private blocks are removed server-side for anonymous readers. +- Group gating happens in Authentik: a policy binding on the `wiki` OIDC application. + +**Tech Stack:** .NET 10, ASP.NET Core minimal APIs, Blazor (static SSR for article pages), Markdig 1.4.0, MudBlazor 9.10.0 (existing pages only), raw `HttpClient` against the Anthropic Messages API, the Gitea contents API, xunit 2.9.3 with hand-written fakes, Mermaid (vendored JS). + +**Spec:** `docs/superpowers/specs/2026-10-24-house-encyclopedia-design.md`. Read it before starting any task. + +## Global Constraints + +- Repo `konairius/Wiki`: `TreatWarningsAsErrors=true`, `Nullable` enabled, primary-constructor sealed classes, `CancellationToken ct` last, no mocking library, hand-written fakes only. +- Verification for every task: `dotnet build Wiki.slnx -c Release` then `dotnet test Wiki.slnx -c Release --logger "console;verbosity=minimal"`. Both must be green. +- Notes repo is `konairius/notes`, branch `main`. Articles live at `articles//.md`, where `` is `de` or `en`. The plan file is `articles/_plan.json`, which `Classify` ignores because of the `_` prefix. +- Article slugs are English kebab-case and are the same in both languages. Titles are localized. +- Private content is never present in HTML served to an anonymous reader. It is removed server-side, never hidden by CSS. +- When in doubt, content is private. This covers an unterminated private block, a failed leak check, unparseable model output, and a failed filter. +- Credentials and secret values never appear in any article, private sections included. +- Public article pages send `` and the response header `X-Robots-Tag: noindex`. +- No CDN at runtime: Mermaid is vendored under `wwwroot/lib/mermaid/`. +- Article generation reuses the Anthropic key at `Wiki:AnswerModels:Models:claude-sonnet:ApiKey`. No new secret is added. +- Spend limits are enforced on the API side. On a quota or overload error, stop the run, keep everything generated so far, and retry on the next cycle. +- Commits go to branch `feat/house-encyclopedia` with author `Claude Bot `, via a PR. Never push to `main` directly. + +## Review Focus + +1. **Unterminated `::: private` block.** An article body opens a private block and never closes it. Everything after the opener must be treated as private (Task 2: `RedactPrivate_UnterminatedBlock_DropsRest`). +2. **Model returns non-JSON or truncated output** from planning or the leak check. Leak check: the article is not published, and the previous version stays (Task 7: `Write_LeakCheckUnparseable_Throws`). Planning: the old plan stays (Task 6: `Plan_ModelGarbage_KeepsExistingPlan`). +3. **Anonymous request for a private article.** The response must not contain the article's title or any of its text, only a generic "log in to read" page (Task 11: `PrivateArticle_Anonymous_ShowsNoTitle`). +4. **Webhook loop.** The reconciler's own article commit triggers a sync. The next reconcile must find nothing stale and make no commit (Task 8: `Reconcile_AfterOwnCommit_IsNoop`). +5. **Every source note of an article is deleted.** The article's files are deleted in the next commit rather than left serving stale facts (Task 8: `Reconcile_SubjectDropped_DeletesBothLanguages`). + +--- + +### Task 1: `Article` document kind + +**Files:** +- Modify: `src/Wiki.Core/Model/Document.cs` (enum `DocumentKind`) +- Modify: `src/Wiki.Core/Parsing/DocumentParser.cs` (`Classify`, `PathFor`) +- Modify: `src/Wiki.Application/Sync/SyncUseCase.cs` (`AllKinds`) +- Modify: `src/Wiki.Adapters.Gitea/GiteaRepository.cs` (`ListAsync` kind filter, if it filters by path) +- Test: `test/Wiki.Core.Tests/DocumentParserTests.cs` (extend; create it if absent) + +**Interfaces:** +- Produces: `DocumentKind.Article`. + - `DocumentParser.Classify("articles/de/wall-switches.md") == (DocumentKind.Article, "de/wall-switches")`. + - `DocumentParser.PathFor(DocumentKind.Article, "en/heating") == "articles/en/heating.md"`. + - Article slugs in the catalog are `/`. + +- [ ] **Step 1: Write failing tests** + +```csharp +[Theory] +[InlineData("articles/de/wall-switches.md", "de/wall-switches")] +[InlineData("articles/en/heating.md", "en/heating")] +public void Classify_Article(string path, string slug) + => Assert.Equal((DocumentKind.Article, slug), DocumentParser.Classify(path)); + +[Theory] +[InlineData("articles/_plan.json")] +[InlineData("articles/fr/x.md")] // only de|en +[InlineData("articles/de/_draft.md")] +[InlineData("articles/de/sub/x.md")] // no nesting +[InlineData("articles/x.md")] +public void Classify_ArticleRejects(string path) => Assert.Null(DocumentParser.Classify(path)); + +[Fact] +public void PathFor_Article() => Assert.Equal("articles/en/heating.md", DocumentParser.PathFor(DocumentKind.Article, "en/heating")); +``` + +Add a `SyncUseCaseTests` case named `Sync_IndexesArticles_ButDoesNotEmbedThem`. The `FakeStore` holds one `wiki/` note and one `articles/de/x.md`. After `RunAsync`, the catalog contains both, and `FakeEmbeddings` was called only for the wiki note. + +- [ ] **Step 2: Run the tests.** `dotnet test Wiki.slnx --filter "FullyQualifiedName~DocumentParserTests|FullyQualifiedName~SyncUseCaseTests"`. Expected: FAIL (`Article` is not defined). +- [ ] **Step 3: Implement.** Add the enum member, the `Classify` branch (exactly two path segments after `articles/`, language `de|en`, the existing `_`-prefix rule), the `PathFor` case, and `Article` in `AllKinds`. Keep embedding restricted to `DocumentKind.Wiki`. If `GiteaRepository.ListAsync` maps paths through `Classify`, no change is needed there; verify it. +- [ ] **Step 4: Run the full suite.** Expected: PASS. +- [ ] **Step 5: Commit.** Message: `feat(core): Article document kind under articles//`. + +--- + +### Task 2: Article format: frontmatter, private blocks, redaction + +**Files:** +- Create: `src/Wiki.Core/Model/Article.cs` +- Create: `src/Wiki.Core/Parsing/ArticleBody.cs` +- Create: `src/Wiki.Core/Parsing/ArticleFile.cs` +- Test: `test/Wiki.Core.Tests/ArticleBodyTests.cs`, `test/Wiki.Core.Tests/ArticleFileTests.cs` + +**Interfaces:** +- Produces: + ```csharp + public enum ArticleVisibility { Public, Private } + public enum ArticleCategory { Room, System, Device, Topic } + public sealed record ArticleSource(string Slug, string ContentHash); + public sealed record Article(string Lang, string Slug, string Title, ArticleCategory Category, + ArticleVisibility Visibility, IReadOnlyList Sources, DateOnly Generated, + string Model, string Body); + public sealed record BodyBlock(int Index, string Text); + + public static class ArticleBody + { + public const string PrivateOpen = "::: private"; + public const string Close = ":::"; + public static string RedactPrivate(string body); // anonymous view + public static IReadOnlyList PublicBlocks(string body); // outside private blocks & code fences + public static string Privatize(string body, IReadOnlySet blockIndexes); // wrap those public blocks in ::: private + } + public static class ArticleFile + { + public static string Build(Article article); // frontmatter + body + public static Article Parse(Document document); // from catalog Document of kind Article + } + ``` +- Body conventions, which the Task 7 prompts must teach the writer: + - `::: infobox` … `:::` holds a two-column pipe table. + - `::: private` … `:::` marks a private block. + - Mermaid diagrams go in ```` ```mermaid ```` fences. + - Links to other articles use `[[slug]]`. + - Nesting is not allowed. +- Frontmatter keys: + - `title`, `lang`, `subject` (the slug without language), `category` (lowercase), `visibility` (lowercase) + - `sources`: a YAML list of `slug@contenthash` + - `generated` (`yyyy-MM-dd`), `model` + +- [ ] **Step 1: Write failing tests.** + - `RedactPrivate_RemovesBlock_KeepsLockedMarker`: given `"A\n\n::: private\nsecret\n:::\n\nB"`, the result contains `A`, `B` and `::: private-locked`, and does not contain `secret`. + - `RedactPrivate_UnterminatedBlock_DropsRest`: given `"A\n\n::: private\nsecret\n\nmore"`, the result does not contain `secret` or `more`. + - `RedactPrivate_IgnoresFenceInsideCode`: a `::: private` line inside a ```` ``` ```` code fence is not treated as an opener. + - `PublicBlocks_SkipsPrivateAndNumbersSequentially`: on `"A\n\n::: private\nx\n:::\n\nB"` the indexes are `[0:"A", 1:"B"]`. + - `PublicBlocks_InfoboxIsOneBlock`: an infobox container counts as one block. + - `Privatize_WrapsChosenBlocks`: `Privatize(body, {1})` wraps only `B`. Applying `PublicBlocks` again returns only `A`. + - `ArticleFile_RoundTrip`: `Build`, then `DocumentParser.Parse`, then `ArticleFile.Parse`, gives an equal `Article`, including two sources `schalter-konvention-blueprint-system@abc123` and `heizung@def456`. +- [ ] **Step 2: Run them.** Expected: FAIL (the types are not defined). +- [ ] **Step 3: Implement.** `RedactPrivate` replaces each private block with the two lines `::: private-locked` and `:::`. A line-based scanner tracks code-fence state. Build the frontmatter by hand with a `StringBuilder`, following the `DepositFactUseCase.BuildNote` style, and reuse its YAML scalar quoting rules. +- [ ] **Step 4: Run the suite.** Expected: PASS. +- [ ] **Step 5: Commit.** Message: `feat(core): article body format and private-block redaction`. + +--- + +### Task 3: Deterministic leak filter + +**Files:** +- Create: `src/Wiki.Core/Parsing/LeakScanner.cs` +- Test: `test/Wiki.Core.Tests/LeakScannerTests.cs` + +**Interfaces:** +- Consumes: `ArticleBody.PublicBlocks` and `ArticleBody.Privatize` (Task 2). +- Produces: + ```csharp + public sealed record LeakMatch(int BlockIndex, string Rule, string Value); + public static class LeakScanner + { + public static IReadOnlyList Scan(string body); // public blocks only + public static string PrivatizeLeaks(string body); // Privatize(body, blocks with any match) + } + ``` +- Rules (rule names are used in the tests): + - `ipv4` + - `ipv6`, matching at least two `:` groups of hex digits, e.g. `fd00::1` + - `mac` + - `fqdn`: any `label.label.tld` with at least 2 dots, or any name ending in `.renner.uno`, `.local`, `.lan`, `.internal`, or `.svc.cluster.local` + - `entity-id`: `(light|switch|sensor|binary_sensor|cover|climate|script|automation|scene|input_\w+|button|number|select|fan|media_player|person|device_tracker|lock|alarm_control_panel|camera|vacuum|water_heater|update)\.[a-z0-9_]+` + - `path`: tokens starting with `/`, `~/` or `./`, containing a further `/` + - `secret-path`: `apps//`, `secret/…`, `kv/…`, `vault_[a-z_]+` + - `credential`: the words `password`, `passwort`, `token`, `api key`, `apikey` or `secret`, followed by `:` or `=` and a value + +- [ ] **Step 1: Write failing tests.** Use a `[Theory]` with one `InlineData` per rule: + - `ipv4`: `"Der Server 10.2.40.1 antwortet"` + - `ipv6`: `"fd00::1"` + - `mac`: `"aa:bb:cc:dd:ee:ff"` + - `fqdn`: `"wiki.renner.uno"`, `"ak-outpost.authentik.svc.cluster.local"` + - `entity-id`: `"light.eg_kuche_ambiente"` + - `path`: `"/var/run/secrets/x"` + - `secret-path`: `"apps/ai/wiki-anthropic"` + - `credential`: `"Passwort: hunter2"` + + Each yields that rule name. Then: + - `Scan_IgnoresPrivateBlocks`: the same string inside `::: private` yields no match. + - `Scan_NoFalsePositive_OnPlainProse`: `"Die Wärmepumpe heizt den Estrich. Version 2.5 ist aktuell."` yields no match, so a version number is not an IPv4 address. + - `PrivatizeLeaks_MovesOnlyLeakingBlock`. +- [ ] **Step 2: Run them.** Expected: FAIL. +- [ ] **Step 3: Implement** the rules as `[GeneratedRegex]` partial methods. Inline code spans are scanned too, because a hostname in backticks is still a leak. +- [ ] **Step 4: Run them.** Expected: PASS. +- [ ] **Step 5: Commit.** Message: `feat(core): deterministic leak scanner for public article text`. + +--- + +### Task 4: Note store: multi-file commit and binary read + +**Files:** +- Modify: `src/Wiki.Core/Ports/Ports.cs` (`INoteStore`) +- Modify: `src/Wiki.Core/Model/Retrieval.cs` (new records) +- Modify: `src/Wiki.Adapters.Gitea/GiteaRepository.cs` +- Modify: `test/Wiki.Application.Tests/Fakes.cs`, `test/Wiki.Host.Tests/Fakes.cs` (`FakeStore`) +- Modify: `test/Wiki.Contracts/Gitea/FakeGiteaHandler.cs`, `test/Wiki.Contracts/NoteStoreContract.cs` + +**Interfaces:** +- Produces: + ```csharp + public enum FileChangeOp { Create, Update, Delete } + public sealed record FileChange(FileChangeOp Op, string Path, string? Text, string? BlobSha); + public sealed class StoreConflictException(string message) : Exception(message); + // INoteStore additions: + Task CommitAsync(IReadOnlyList changes, string message, CancellationToken ct); + Task ReadBytesAsync(string path, string commit, CancellationToken ct); + ``` + - `BlobSha` is required for `Update` and `Delete`. It is the `ContentHash` from `ListAsync`, which is the Gitea blob SHA. + - `CommitAsync` must produce exactly one commit. + - For non-.md files (`articles/_plan.json`) a caller gets the SHA from a new `Task BlobShaAsync(string path, string commit, CancellationToken ct)` on the port. Add it alongside the other two members. +- Gitea mapping: + - `POST api/v1/repos/{repo}/contents` with body `{ branch, message, files: [{ operation: "create"|"update"|"delete", path, content (base64), sha }] }`. + - HTTP 409 or 422 throws `StoreConflictException`. + - `ReadBytesAsync` uses `raw/{path}?ref=commit`. + - `BlobShaAsync` uses `GET contents/{path}?ref=` and reads `.sha`. A 404 returns null. + +- [ ] **Step 1: Write failing contract tests** in `NoteStoreContract`: + - `Commit_CreatesUpdatesDeletesInOneCommit`: after the call, `GetHeadAsync` advanced once, and `ListAsync` shows the create, the update with new content, and the delete gone. + - `Commit_StaleSha_ThrowsConflict`. + - `ReadBytes_ReturnsExactBytes`, using a 3-byte PNG header. + - `BlobSha_MissingFile_IsNull`. +- [ ] **Step 2: Run them.** Expected: FAIL (compile errors in the fakes). +- [ ] **Step 3: Implement** the port members, the Gitea adapter, `FakeGiteaHandler` support for the batch contents endpoint, and both `FakeStore`s. The `FakeStore`s record a `Commits` list of `(message, changes)`. +- [ ] **Step 4: Run the suite.** Expected: PASS. +- [ ] **Step 5: Commit.** Message: `feat(store): multi-file commit, binary read, blob sha`. + +--- + +### Task 5: Text model port and Anthropic adapter + +**Files:** +- Modify: `src/Wiki.Core/Ports/Ports.cs` +- Create: `src/Wiki.Core/Model/TextModel.cs` +- Create: `src/Wiki.Adapters.Anthropic/AnthropicTextModel.cs` +- Modify: `src/Wiki.Adapters.Anthropic/MessagesTransport.cs`, plus the registration extensions file in the same project +- Test: `test/Wiki.Contracts/Anthropic/AnthropicTextModelTests.cs`, reusing the existing fake handler + +**Interfaces:** +- Produces: + ```csharp + public sealed record TextReply(string Text, string ModelId, long InputTokens, long OutputTokens, bool Truncated); + public sealed class ModelUnavailableException(string message, Exception? inner = null) : Exception(message, inner); + public interface ITextModel : IAdapterHealth + { + string ModelId { get; } + Task CompleteAsync(string system, string user, int maxTokens, CancellationToken ct); + } + // registration + public static IServiceCollection AddAnthropicTextModel(this IServiceCollection services, Action configure); + ``` + - `Truncated` is true when the stream reports `stop_reason == "max_tokens"`. + - The transport throws `ModelUnavailableException` in three cases: + - 429 or 529 after `MaxRetries`; + - a stream `error` event of type `rate_limit_error` or `overloaded_error`; + - HTTP 400 whose error message mentions credit, billing or a usage limit. This covers API-side spend limits. + + All other failures keep throwing `HttpRequestException`. + - Existing answer and fact adapters inherit the new exception type. `ModelUnavailableException` must map to 502 in the host, the same as `HttpRequestException`. + +- [ ] **Step 1: Write failing tests.** + - `Complete_AccumulatesStreamedText`. + - `Complete_MaxTokensStop_SetsTruncated`. + - `Complete_429AfterRetries_ThrowsModelUnavailable`, using an injected no-op `delay`. + - `Complete_CreditError400_ThrowsModelUnavailable`. + - `Complete_PassesMaxTokensAndSystem`: assert the request JSON has `max_tokens` 16000 and the `system` string. +- [ ] **Step 2: Run them.** Expected: FAIL. +- [ ] **Step 3: Implement.** Add an overload `MessagesTransport.StreamAsync(..., int maxTokens, ...)` and keep the existing callers unchanged. `AnthropicTextModel.Name` is `anthropic-text:{ModelId}`. +- [ ] **Step 4: Run the suite.** Expected: PASS. +- [ ] **Step 5: Commit.** Message: `feat(anthropic): long-form text model port with typed unavailability`. + +--- + +### Task 6: Article-set planning + +**Files:** +- Create: `src/Wiki.Application/Encyclopedia/ArticlePlan.cs` +- Create: `src/Wiki.Application/Encyclopedia/PlanArticlesUseCase.cs` +- Create: `src/Wiki.Application/Encyclopedia/Prompts.cs` (all prompt texts, as `const string`) +- Test: `test/Wiki.Application.Tests/PlanArticlesUseCaseTests.cs`, with a `FakeTextModel` added to `Fakes.cs` + +**Interfaces:** +- Consumes: `ITextModel` (Task 5), `IDocumentCatalog.ListDocumentsAsync(DocumentKind.Wiki, ct)`. +- Produces: + ```csharp + public sealed record PlannedArticle(string Slug, string TitleDe, string TitleEn, ArticleCategory Category, IReadOnlyList Sources, IReadOnlyList Related); + public sealed record ArticlePlan(IReadOnlyList Articles, IReadOnlyList CoveredNotes); // CoveredNotes = all wiki slugs seen when planned + public static class ArticlePlanJson { public static string Write(ArticlePlan p); public static ArticlePlan? Read(string json); } + public sealed class PlanArticlesUseCase(ITextModel model, EncyclopediaSettings settings, ILogger log) + { + public static bool NeedsReplan(ArticlePlan? current, IReadOnlyList notes); // null, or slug set differs from CoveredNotes + public async Task PlanAsync(ArticlePlan? current, IReadOnlyList notes, CancellationToken ct); // null = keep current + } + public sealed record EncyclopediaSettings(int PlanMaxTokens, int ArticleMaxTokens, int MaxArticlesPerRun, int? MaxArticles); + ``` + `EncyclopediaSettings` lives in Application so the use cases don't depend on Host options. Task 9 maps it. +- Prompt contract (`Prompts.Plan`): + - Input: the current plan's JSON, if any. Then one line per note: `slug | title | tags | body length in chars`. + - Instruction: the Wikipedia notability rule from the spec. A subject gets its own article when there is enough material, e.g. many automations or a complicated history. Otherwise its notes go into the parent system's article as a section. + - Instruction: every note is used by at least one article or deliberately skipped (meta and wiki-tooling notes). + - Instruction: keep the existing slugs for existing subjects. + - Reply: JSON only, in the shape `{"articles":[{"slug","titleDe","titleEn","category":"room|system|device|topic","sources":[...],"related":[...]}]}`. +- Validation after parsing: + - drop sources that are not known note slugs; + - drop articles with zero sources; + - de-duplicate slugs; + - slugs must match `^[a-z0-9]+(-[a-z0-9]+)*$`; + - if `MaxArticles` is set, keep only the first N articles (the pilot cap). + +- [ ] **Step 1: Write failing tests.** + - `NeedsReplan_NullPlan_True`. + - `NeedsReplan_SameSlugs_False`. + - `NeedsReplan_NoteAdded_True`. + - `Plan_ParsesAndValidates`: the fake returns JSON containing one unknown source and one bad slug. Both are dropped. + - `Plan_ModelGarbage_KeepsExistingPlan`: the fake returns `"Sure! Here is"`, and `PlanAsync` returns null. + - `Plan_Truncated_KeepsExistingPlan`: the reply has `Truncated=true`, and `PlanAsync` returns null. + - `Plan_PilotCap_KeepsFirstN`. + - `Plan_PromptContainsExistingSlugs`: assert the user prompt sent to the fake contains the current plan's slugs. +- [ ] **Step 2: Run them.** Expected: FAIL. +- [ ] **Step 3: Implement.** Use `System.Text.Json`. Strip a ```` ```json ```` fence from the reply if one is present. +- [ ] **Step 4: Run them.** Expected: PASS. +- [ ] **Step 5: Commit.** Message: `feat(encyclopedia): article-set planning`. + +--- + +### Task 7: Article writing pipeline + +**Files:** +- Create: `src/Wiki.Application/Encyclopedia/WriteArticleUseCase.cs` +- Modify: `src/Wiki.Application/Encyclopedia/Prompts.cs` (`Write`, `LeakCheck`, `Translate`) +- Test: `test/Wiki.Application.Tests/WriteArticleUseCaseTests.cs` + +**Interfaces:** +- Consumes: + - `ITextModel`; + - `LeakScanner.PrivatizeLeaks`, `ArticleBody.PublicBlocks`, `ArticleBody.Privatize`; + - `ArticleFile.Build`; + - `PlannedArticle`; + - source `Document`s; + - `TimeProvider`. +- Produces: + ```csharp + public sealed record WrittenArticle(Article De, Article En); + public sealed class WriteArticleUseCase(ITextModel model, EncyclopediaSettings settings, TimeProvider clock) + { + public async Task WriteAsync(PlannedArticle planned, IReadOnlyList sources, + IReadOnlyList allArticles, CancellationToken ct); + } + public sealed class ArticleRejectedException(string slug, string reason) : Exception($"{slug}: {reason}"); + ``` +- Pipeline: + 1. **Write.** `Prompts.Write` produces German markdown. The first line of the reply is `VISIBILITY: public|private`, followed by the body. + 2. **Filter.** Apply `LeakScanner.PrivatizeLeaks`. + 3. **Leak check.** `Prompts.LeakCheck` receives only the numbered `PublicBlocks` and replies `{"privatize":[ints]}`. Apply `Privatize`. + 4. **Translate.** `Prompts.Translate` turns the whole German body into English. It must keep the `:::` containers, mermaid fences and `[[slug]]` links unchanged. + 5. **Filter again.** Apply `LeakScanner.PrivatizeLeaks` to the English body. + 6. **Check structure.** If the count of `::: private` blocks in German and English differs, throw `ArticleRejectedException`. +- `Prompts.Write` must state: + - the audience (non-technical first, then deeper); + - the order: lead → infobox → principle → examples → a short History at the end; + - the body conventions from Task 2; + - the link targets: only slugs in `allArticles`; + - that every claim comes from the sources; + - that the newest confirmed fact is used and disputed claims are omitted; + - the private rule: hostnames, IPs, device IDs, network and security layout, restart and server procedures, and attacker-useful detail are private, and private is the choice when unsure; + - that credentials and secret values are never written at all; + - that no PR numbers, incident logs or entity IDs appear in public text; + - that interior photos of equipment are private unless clearly harmless; + - which images are available: the `attachments` frontmatter of the sources. +- Fail-closed rules: + - **Any model reply `Truncated`:** throw `ArticleRejectedException`. + - **Unparseable leak-check JSON:** throw. + - **Missing `VISIBILITY` line:** treat as private. + - **Leak-check indexes out of range:** ignore those indexes. + +- [ ] **Step 1: Write failing tests**, using a `FakeTextModel` that returns scripted replies in order. + - `Write_RunsWriteLeakCheckTranslateInOrder`: assert three calls, with each system prompt starting with its role marker. + - `Write_LeakCheckPrivatizesChosenBlocks`. + - `Write_DeterministicFilterRunsBeforeLeakCheck`: the German body contains `10.2.40.1` in block 1. The leak-check prompt does not contain `10.2.40.1`. + - `Write_EnglishLeakFilteredToo`: the translation introduces `wiki.renner.uno`, and it ends up in a private block. + - `Write_LeakCheckUnparseable_Throws`. + - `Write_Truncated_Throws`. + - `Write_MissingVisibility_IsPrivate`. + - `Write_SourcesCarryHashes`: `De.Sources` equals the source docs' `(Slug, ContentHash)`. + - `Write_PrivateBlockCountMismatch_Throws`. +- [ ] **Step 2: Run them.** Expected: FAIL. +- [ ] **Step 3: Implement.** Each prompt's system string begins with a role marker (`[write]`, `[leak-check]`, `[translate]`) so the tests can tell the calls apart. Source notes are passed in full, each wrapped as `…`. +- [ ] **Step 4: Run them.** Expected: PASS. +- [ ] **Step 5: Commit.** Message: `feat(encyclopedia): write, leak-check and translate pipeline`. + +--- + +### Task 8: Encyclopedia reconciler + +**Files:** +- Create: `src/Wiki.Application/Encyclopedia/EncyclopediaReconciler.cs` +- Test: `test/Wiki.Application.Tests/EncyclopediaReconcilerTests.cs` + +**Interfaces:** +- Consumes: + - `INoteStore.GetHeadAsync`, `CommitAsync`, `BlobShaAsync`, `ReadAsync` (Task 4); + - `IDocumentCatalog.ListDocumentsAsync(DocumentKind.Wiki, ct)` and `ListDocumentsAsync(DocumentKind.Article, ct)`, called separately; + - `PlanArticlesUseCase` (Task 6) and `WriteArticleUseCase` (Task 7); + - `ArticleFile.Parse`, `ArticleFile.Build`; + - `ArticlePlanJson`. +- Produces: + ```csharp + public sealed record ReconcileSummary(int Planned, int Written, int Deleted, int Rejected, bool StoppedByModel, string? Commit); + public sealed class EncyclopediaReconciler(INoteStore store, IDocumentCatalog catalog, PlanArticlesUseCase planner, + WriteArticleUseCase writer, EncyclopediaSettings settings, ILogger log) + { + public async Task RunAsync(CancellationToken ct); + public static IReadOnlyList Stale(ArticlePlan plan, IReadOnlyList notes, IReadOnlyList
existing); + } + ``` +- Algorithm: + 1. Read `articles/_plan.json` at HEAD. A 404 means no plan yet. + 2. If `NeedsReplan`, call `PlanAsync`. A non-null result is staged as an update of `_plan.json`. + 3. Compute `Stale`. An article is stale when any of these is true: + - it is missing in either language; + - the set of its sources differs from the plan; + - any source hash differs from the catalog; + - the plan's title changed. + 4. Take at most `MaxArticlesPerRun` stale articles, in plan order, and write each one: + - `ArticleRejectedException` or `HttpRequestException`: log, count as rejected, continue. The old version stays. + - `ModelUnavailableException`: stop the loop and set `StoppedByModel=true`. + 5. For articles that exist in the catalog but are not in the plan, stage a `Delete` in both languages. + 6. If anything is staged, call `CommitAsync` once with the message `articles: N written, M deleted`. + - On `StoreConflictException`, log and return. The next cycle retries. +- The webhook-loop guard falls out of idempotence: after its own commit, nothing is stale. Add no extra guard. + +- [ ] **Step 1: Write failing tests**, using `FakeStore`, `FakeCatalog` and `FakeTextModel`. + - `Reconcile_FirstRun_PlansWritesCommitsOnce`: exactly one commit, containing `_plan.json` plus `de/` and `en/` files. + - `Reconcile_AfterOwnCommit_IsNoop`: run, sync the fake catalog from the store, run again. The second run makes zero commits and zero model calls. + - `Reconcile_SourceChanged_RewritesOnlyThat`. + - `Reconcile_RespectsMaxArticlesPerRun`. + - `Reconcile_ModelUnavailable_CommitsWhatIsDone`: the second article throws. The commit contains the first article, and `StoppedByModel` is true. + - `Reconcile_Rejected_KeepsOldVersion`: no change for that article's paths. + - `Reconcile_SubjectDropped_DeletesBothLanguages`. + - `Reconcile_StoreConflict_ReturnsWithoutThrow`. +- [ ] **Step 2: Run them.** Expected: FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run them.** Expected: PASS. +- [ ] **Step 5: Commit.** Message: `feat(encyclopedia): incremental reconciler with single-commit writes`. + +--- + +### Task 9: Composition and post-sync trigger + +**Files:** +- Modify: `src/Wiki.Host/Composition/WikiOptions.cs` (new `EncyclopediaOptions Encyclopedia`) +- Modify: `src/Wiki.Application/Sync/SyncCoordinator.cs` (completion event) +- Create: `src/Wiki.Host/Composition/EncyclopediaBackgroundService.cs` +- Modify: `src/Wiki.Host/Program.cs`, `src/Wiki.Host/appsettings.json` +- Test: `test/Wiki.Host.Tests/EncyclopediaBackgroundServiceTests.cs`, and extend `test/Wiki.Application.Tests/SyncUseCaseTests.cs` or the coordinator tests + +**Interfaces:** +- Produces: + ```csharp + public sealed class EncyclopediaOptions + { + public bool Enabled { get; set; } = false; + public string AnswerModel { get; set; } = "claude-sonnet"; // key read from Wiki:AnswerModels:Models:{AnswerModel}:ApiKey + public string Model { get; set; } = "claude-sonnet-5"; + public int ArticleMaxTokens { get; set; } = 16000; + public int PlanMaxTokens { get; set; } = 32000; + public int MaxArticlesPerRun { get; set; } = 10; + public int? MaxArticles { get; set; } = null; // pilot cap + } + // SyncCoordinator + public event Action? Completed; // raised after a successful, non-skipped run + // EncyclopediaBackgroundService : BackgroundService + public void Trigger(); + ``` + - `appsettings.json` gets `"Encyclopedia": { "Enabled": false }`. + - When `Enabled` is false, register nothing encyclopedia-related besides the options. + - The background service waits on a trigger. `Completed` from `SyncCoordinator` calls `Trigger()`. Runs never overlap, and a trigger that arrives during a run causes one follow-up run. + - Register the `ITextModel` via `AddAnthropicTextModel`, using `Model` and the API key described above. + - Add `ReconcileSummary? LastEncyclopediaRun` and `LastEncyclopediaError` to `StatusQuery`'s output, and show them on `/status`. + +- [ ] **Step 1: Write failing tests.** + - `Coordinator_RaisesCompleted_OnlyWhenNotSkipped`. + - `Service_TriggerDuringRun_RunsOnceMore`: use a fake reconciler that blocks on a `TaskCompletionSource`. + - `Host_EncyclopediaDisabled_NoTextModelRegistered`: build the `WikiHostFactory` and assert `ITextModel` is not resolvable. +- [ ] **Step 2: Run them.** Expected: FAIL. +- [ ] **Step 3: Implement.** For testability, put the reconciler behind a small `IEncyclopediaRun { Task RunAsync(CancellationToken ct); }` in Application. `EncyclopediaReconciler` implements it. +- [ ] **Step 4: Run the suite.** Expected: PASS. +- [ ] **Step 5: Commit.** Message: `feat(host): run the encyclopedia after each sync`. + +--- + +### Task 10: Article rendering + +**Files:** +- Create: `src/Wiki.Host/Rendering/ArticleMarkdown.cs` +- Create: `src/Wiki.Host/wwwroot/lib/mermaid/mermaid.min.js` (vendored `mermaid@11.4.1` `dist/mermaid.min.js`, with its LICENSE beside it) +- Create: `src/Wiki.Host/wwwroot/house.css` +- Test: `test/Wiki.Host.Tests/ArticleMarkdownTests.cs` + +**Interfaces:** +- Consumes: `ArticleBody.RedactPrivate` (Task 2). +- Produces: + ```csharp + public sealed record TocEntry(int Level, string Id, string Text); + public sealed record RenderedArticle(string Html, string? InfoboxHtml, IReadOnlyList Toc); + public static class ArticleMarkdown + { + // signedIn=false ⇒ body passed through RedactPrivate first; links resolve to /house/{lang}/{slug} only for slugs in `visibleSlugs`, else plain text + public static RenderedArticle Render(string body, string lang, string slug, bool signedIn, IReadOnlySet visibleSlugs); + } + ``` +- Rendering rules: + - **Pipeline:** `UseAdvancedExtensions().DisableHtml()`, which gives custom containers, auto-identifiers and diagrams. Raw HTML stays disabled, so the model can never inject markup. + - **Infobox:** the `::: infobox` container is extracted into `InfoboxHtml` and removed from `Html`. + - **Private blocks:** `::: private` renders as `
` with a lock label. `::: private-locked` renders as `
`, containing the localized "Privater Abschnitt — anmelden zum Lesen" or "Private section — log in to read" and a link to `/house/login?returnUrl=…`. + - **Mermaid:** a ```` ```mermaid ```` fence becomes `
` or Markdig's `
`. Pin whichever Markdig 1.4.0 emits in the test. + - **Images:** image URLs starting with `raw/` are rewritten to `/house/media/{lang}/{slug}/{n}`. `n` is the 0-based index among the image references that remain after redaction. Task 11 serves them. + - **TOC:** built from the h2 and h3 headings, with the ids Markdig assigns. Each TOC entry links to `#id`. + - **Overlap fix:** in `house.css`, the infobox floats right. `.mermaid`, `figure`, `table` and `h2` get `clear: right`, so a diagram never sits under the infobox. This is the mockup bug the owner flagged. + +- [ ] **Step 1: Write failing tests.** + - `Render_Anonymous_ContainsNoPrivateText`: the private block contains `GEHEIM`, and `Html` does not. + - `Render_SignedIn_ShowsPrivateBlock`. + - `Render_InfoboxExtracted`. + - `Render_TocMatchesHeadingIds`: each `TocEntry.Id` appears as `id="…"` in `Html`. + - `Render_LinkToInvisibleSlug_IsPlainText`. + - `Render_RawHtmlEscaped`: the input `