From ec1b74c85ae34a37334359641834be27ae970995 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 22 Jul 2026 20:19:32 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(kbc):=20coverage=20v2=20=E2=80=94=20me?= =?UTF-8?q?dia=20assets=20auto-attach=20via=20citing=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Media assets (images under assets/) are document attachments, not first-class sources. Coverage v2 auto-accounts an image against any document that embeds it in its body, once that document is itself cited or excluded — so compiled_from no longer needs a token per image and the exclusion ledger no longer needs a row per image. Motivation - compiled_from bloat: aggregated cf drowned in image tokens (UHI 昇腾.md: 139 entries = 35 docs + 104 images), unreadable to a human. - exclusion-ledger noise: images as first-class sources forced ~563 of UHI's 603 exclusion rows to be mechanical asset entries. - attribution gap: the schema had no way to express "which document embeds this image", so mediaverify and the collapse UI each guessed with heuristics. Math (v2) auto = { a in media_assets | exists d in (cited | excluded): edge(d->a) } unaccounted = sources - cited - excluded - auto - edges are derived from the raw tree (each document's body ![](), [](), and links), resolved relative to the document + posixpath.normpath + URL-decoded; a link to a nonexistent/non-media path yields no edge, no error. - an ORPHAN image (embedded by no accounted document — upload residue) is NOT auto-attached; it stays unaccounted and must be excluded with a reason so a human still sees it (anti fail-open). - a directly-cited asset still counts as cited (v1 compatibility). Monotonicity (old green libraries stay green) v2 only ADDS an accounting path, so unaccounted can only shrink. All pre-existing selfcheck tests pass unchanged — none asserted "unreferenced asset => unaccounted" over an assets/ path (the media-asset predicate requires an assets/ segment, which no existing test used). Sheet-placeholder carve-out assets/sheets/*.md table placeholders are content files, not media (is_media_asset requires an image extension). They stay first-class sources — auto-attaching them would launder "the table data was never compiled" into "covered". Observability coverage adds auto_attached (count) and auto_attached_sample (<=20 asset<-via edges), so auto-accounting is never a silent fail-open. Two-repo fixture kbc/platform/pod/fixtures/asset-provenance/ is the shared, byte-for-byte contract (raw tree + candidate + EXCLUSIONS + expected.json). sicore's adoption ledger will assert the SAME expected.json. Cases: relative link, ../ cross-dir, HTML , URL-encoded path, missing target (no edge), 0-byte download-failed placeholder, assets/sheets/*.md (not media), image shared by a cited and an unaccounted document, orphan image, image inheriting a document's exclusion, directly-cited asset. S1b: box constitution now tells the compile agent to cite documents only — images auto-attach; do not cite or exclude them one-by-one; orphan images still need a reasoned exclusion; assets/sheets/ placeholders handled as usual. --- .../pod/fixtures/asset-provenance/README.md | 17 +++ .../authoring/EXCLUSIONS.json | 10 ++ .../asset-provenance/candidate/detail.md | 7 + .../asset-provenance/candidate/index.md | 8 + .../asset-provenance/candidate/overview.md | 8 + .../asset-provenance/candidate/setup.md | 7 + .../asset-provenance/candidate/tables.md | 7 + .../fixtures/asset-provenance/expected.json | 70 +++++++++ .../fixtures/asset-provenance/raw/archive.md | 4 + .../raw/assets/directcited.png | 1 + .../asset-provenance/raw/assets/hero.png | 0 .../asset-provenance/raw/assets/inherited.png | 1 + .../raw/assets/orphan-excluded.png | 1 + .../asset-provenance/raw/assets/orphan.png | 1 + .../asset-provenance/raw/assets/shared.png | 1 + .../raw/assets/sheets/table1.md | 4 + .../asset-provenance/raw/excluded-doc.md | 3 + .../asset-provenance/raw/guide/assets/a b.png | 1 + .../raw/guide/assets/diagram.png | 1 + .../raw/guide/assets/screenshot.png | 1 + .../asset-provenance/raw/guide/deep/detail.md | 3 + .../asset-provenance/raw/guide/setup.md | 9 ++ .../fixtures/asset-provenance/raw/overview.md | 6 + .../raw/report.assets/fig.png | 1 + kbc/platform/pod/prompts/en/box_role.md | 2 +- kbc/platform/pod/prompts/zh/box_role.md | 2 +- kbc/platform/pod/selfcheck.py | 143 +++++++++++++++++- kbc/platform/pod/test_selfcheck.py | 128 ++++++++++++++++ 28 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 kbc/platform/pod/fixtures/asset-provenance/README.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/authoring/EXCLUSIONS.json create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/detail.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/index.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/overview.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/setup.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/tables.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/expected.json create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/archive.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/directcited.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/hero.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/inherited.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan-excluded.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/shared.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/sheets/table1.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/excluded-doc.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/a b.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/diagram.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/screenshot.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/guide/deep/detail.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/overview.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/report.assets/fig.png diff --git a/kbc/platform/pod/fixtures/asset-provenance/README.md b/kbc/platform/pod/fixtures/asset-provenance/README.md new file mode 100644 index 000000000..65247c4cd --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/README.md @@ -0,0 +1,17 @@ +# asset-provenance fixture (coverage v2) + +Shared, byte-for-byte contract between siclaw `selfcheck.py` and the sicore +adoption ledger (DESIGN-kb-asset-provenance-2026-07-22 §六). Both repos run +their edge extraction + coverage math over this same `raw/` + `candidate/` + +`authoring/EXCLUSIONS.json` and assert the result equals `expected.json`. + +Cases exercised: relative link, `../` cross-directory link, HTML ``, +URL-encoded path (`%20`), a body reference to a nonexistent asset (no edge, +no error), a 0-byte download-failed placeholder, `assets/sheets/*.md` (a +content file, not media), one image shared by a cited and an unaccounted +document (auto via the accounted one), an orphan image (unaccounted unless +excluded), an image inheriting its document's exclusion, and a directly-cited +asset (still counts as cited, v1 compatibility). + +Image files hold placeholder bytes — coverage never decodes them; only their +path and presence in the inventory matter. diff --git a/kbc/platform/pod/fixtures/asset-provenance/authoring/EXCLUSIONS.json b/kbc/platform/pod/fixtures/asset-provenance/authoring/EXCLUSIONS.json new file mode 100644 index 000000000..c0fca86b2 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/authoring/EXCLUSIONS.json @@ -0,0 +1,10 @@ +[ + { + "pattern": "excluded-doc.md", + "reason": "superseded draft, not compiled" + }, + { + "pattern": "assets/orphan-excluded.png", + "reason": "upload residue, embedded by no document" + } +] diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/detail.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/detail.md new file mode 100644 index 000000000..001a2b37a --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/detail.md @@ -0,0 +1,7 @@ +--- +type: Topic +title: Detail +compiled_from: + - guide/deep/detail.md +--- +Detail page. diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md new file mode 100644 index 000000000..161385a83 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md @@ -0,0 +1,8 @@ +--- +okf_version: "0.1" +--- +# Index +- [Overview](overview.md) +- [Setup](setup.md) +- [Detail](detail.md) +- [Tables](tables.md) diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/overview.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/overview.md new file mode 100644 index 000000000..0bdbecc03 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/overview.md @@ -0,0 +1,8 @@ +--- +type: Topic +title: Overview +compiled_from: + - overview.md + - assets/directcited.png +--- +Overview page. diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/setup.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/setup.md new file mode 100644 index 000000000..d807d8852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/setup.md @@ -0,0 +1,7 @@ +--- +type: Topic +title: Setup +compiled_from: + - guide/setup.md +--- +Setup page. diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/tables.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/tables.md new file mode 100644 index 000000000..dea8b97e9 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/tables.md @@ -0,0 +1,7 @@ +--- +type: Topic +title: Tables +compiled_from: + - assets/sheets/table1.md +--- +Tables page. diff --git a/kbc/platform/pod/fixtures/asset-provenance/expected.json b/kbc/platform/pod/fixtures/asset-provenance/expected.json new file mode 100644 index 000000000..eeb276126 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/expected.json @@ -0,0 +1,70 @@ +{ + "attribution_edges": { + "archive.md": [ + "assets/shared.png" + ], + "excluded-doc.md": [ + "assets/inherited.png" + ], + "guide/deep/detail.md": [ + "guide/assets/diagram.png" + ], + "guide/setup.md": [ + "guide/assets/a b.png", + "guide/assets/diagram.png", + "guide/assets/screenshot.png" + ], + "overview.md": [ + "assets/hero.png", + "assets/shared.png", + "report.assets/fig.png" + ] + }, + "coverage": { + "total_sources": 16, + "cited": 5, + "excluded": 2, + "auto_attached": 7, + "auto_attached_sample": [ + { + "asset": "assets/hero.png", + "via": "overview.md" + }, + { + "asset": "assets/inherited.png", + "via": "excluded-doc.md" + }, + { + "asset": "assets/shared.png", + "via": "overview.md" + }, + { + "asset": "guide/assets/a b.png", + "via": "guide/setup.md" + }, + { + "asset": "guide/assets/diagram.png", + "via": "guide/deep/detail.md" + }, + { + "asset": "guide/assets/diagram.png", + "via": "guide/setup.md" + }, + { + "asset": "guide/assets/screenshot.png", + "via": "guide/setup.md" + }, + { + "asset": "report.assets/fig.png", + "via": "overview.md" + } + ], + "unaccounted": [ + "archive.md", + "assets/orphan.png" + ], + "dangling_citations": [], + "noop_exclusions": [], + "closed": false + } +} diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/archive.md b/kbc/platform/pod/fixtures/asset-provenance/raw/archive.md new file mode 100644 index 000000000..a300e6ea8 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/archive.md @@ -0,0 +1,4 @@ +# Archive + +Neither compiled nor excluded, so this document itself is unaccounted. +It also embeds the shared figure: ![shared](assets/shared.png) diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/directcited.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/directcited.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/directcited.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/hero.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/hero.png new file mode 100644 index 000000000..e69de29bb diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/inherited.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/inherited.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/inherited.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan-excluded.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan-excluded.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan-excluded.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/orphan.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/shared.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/shared.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/shared.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/sheets/table1.md b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/sheets/table1.md new file mode 100644 index 000000000..03c8467a9 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/sheets/table1.md @@ -0,0 +1,4 @@ +| col | val | +| --- | --- | +| a | 1 | +| b | 2 | diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/excluded-doc.md b/kbc/platform/pod/fixtures/asset-provenance/raw/excluded-doc.md new file mode 100644 index 000000000..c2ac9d4b8 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/excluded-doc.md @@ -0,0 +1,3 @@ +# Excluded doc + +This document is excluded; the image it embeds inherits the exclusion (auto-attached via an excluded document): ![inherited](assets/inherited.png) diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/a b.png b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/a b.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/a b.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/diagram.png b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/diagram.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/diagram.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/screenshot.png b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/screenshot.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/assets/screenshot.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/deep/detail.md b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/deep/detail.md new file mode 100644 index 000000000..747daf693 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/deep/detail.md @@ -0,0 +1,3 @@ +# Detail + +Cross-directory plain link to the shared diagram: [see diagram](../assets/diagram.png) diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md new file mode 100644 index 000000000..27fcbcc4d --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/guide/setup.md @@ -0,0 +1,9 @@ +# Setup + +Relative image: ![diagram](assets/diagram.png) + +HTML table image: shot + +URL-encoded path: ![spaced](assets/a%20b.png) + +Missing target — must not error and yields no edge: ![gone](assets/missing.png) diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/overview.md b/kbc/platform/pod/fixtures/asset-provenance/raw/overview.md new file mode 100644 index 000000000..14064dedc --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/overview.md @@ -0,0 +1,6 @@ +# Overview + +![hero](assets/hero.png) + +The shared figure ![shared](assets/shared.png) and the legacy-form figure ![fig](report.assets/fig.png). +This page also directly cites assets/directcited.png in its candidate cf. diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/report.assets/fig.png b/kbc/platform/pod/fixtures/asset-provenance/raw/report.assets/fig.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/report.assets/fig.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/prompts/en/box_role.md b/kbc/platform/pod/prompts/en/box_role.md index 3c0e32f8c..18d63bbcb 100644 --- a/kbc/platform/pod/prompts/en/box_role.md +++ b/kbc/platform/pod/prompts/en/box_role.md @@ -28,7 +28,7 @@ Structured signal tools available to you: **Applying rulings**: the owner later answers tickets one by one in the contradiction view; you will receive an "apply the following rulings" instruction containing `{ticket_id, affected_pages, correct value}` entries. For each one: open the listed `affected_pages`, change that contradiction's spot to the correct value and remove the `⚠️ 存疑` mark there; if the answer is "accept as uncertain / keep both sources", keep them side by side and do not force a verdict. **Touch only the pages named; leave everything else alone.** **After finishing each ticket (including the accept-as-uncertain kind that changes no value), immediately call `resolve_ticket(ticket_id, applied_value, pages_edited, note, dispatch_nonce)`** (echo each ticket's `nonce:` verbatim if the dispatch carried one) — it is the only action that closes a ticket (there is no manual close button on the owner's side): `applied_value` = the value you actually wrote (for accept-as-uncertain write "kept both sources"); `pages_edited` = the candidate files you actually touched for this ticket and **must cover the ticket's `affected_pages`** (missed pages get auto-flagged for review on the owner's side); `note` = one line on what you changed. **One ticket per call, no batching, no missed pages; never hand-edit `status` in `CONTRADICTIONS.json`** — the tool writes it for you. When all rulings are applied, reply briefly with which pages changed overall. **A ruling dispatch can land while you are mid-compile or mid-task: once the patches are done, return to the interrupted task and finish it — do not stop and wait to be reminded** (no scheduler will resume you; if you stop, a half-finished compile stays half-finished). -**Coverage ledger (mechanically checked by the system, not left to conscience)**: every candidate page's frontmatter `compiled_from` must list the raw relative paths it was actually compiled from (recommended `- " · "`, hash optional). **Images / PDFs and other media are sources too**: whenever their content is digested into a page, register their paths in that page's `compiled_from` just the same; `derived: true` is reserved for pages truly compiled from no raw file at all (e.g. a glossary) — a page compiled from an image must still register `compiled_from`, never use `derived` to escape the ledger. Any raw file you decide **not** to compile (media included) must go into `authoring/EXCLUSIONS.json` (a JSON array of `{"pattern": "path or glob relative to raw", "reason": "one-line reason the owner can understand"}`; globs match SEGMENT-wise: a bare `logs` matches only a file literally named logs, `logs/*` only direct children - a whole subtree needs `logs/**` or the `logs/` prefix form; a pattern matching no source gets called out by the self-check for you to fix) — writing "not included" only in the index prose is invisible to the system and does not count. At the end of every round the system mechanically checks "all raw sources = union of compiled_from + EXCLUSIONS matches": anything unaccounted for comes back as a 【system self-check】 repair instruction, and you must either compile it or explicitly exclude it, one by one — do not leave it hanging. In addition, any file the body cites as `(source: X)` must appear in that page's compiled_from, and every page must be reachable by links from index.md (orphan pages are caught by lint). +**Coverage ledger (mechanically checked by the system, not left to conscience)**: every candidate page's frontmatter `compiled_from` must list the raw relative paths it was actually compiled from (recommended `- " · "`, hash optional). **Cite documents, not the images inside them**: a media asset (an image under an `assets/` directory, embedded in a document's body) attaches to its document automatically — the system reads each raw document's body image links and books every image it embeds against that document, so you do **not** register images in `compiled_from` one-by-one, and you do **not** exclude them one-by-one. Cite only the documents themselves; the image content-quality check is a separate image-audit step, not your ledger job. Two carve-outs: an **orphan image** (under `assets/` but embedded by no document — usually upload residue) still needs an explicit `EXCLUSIONS.json` entry with a reason, since nothing else accounts for it; and `assets/sheets/*.md` table placeholders are **content files, not media** — cite or exclude them like any other source. PDFs and pre-rendered office files (`.pptx`/`.xlsx`/`.docx` → sibling `.md`) are content sources too, cited or excluded as usual. `derived: true` is reserved for pages truly compiled from no raw file at all (e.g. a glossary), never a way to escape the ledger. Any raw file you decide **not** to compile (orphan media included) must go into `authoring/EXCLUSIONS.json` (a JSON array of `{"pattern": "path or glob relative to raw", "reason": "one-line reason the owner can understand"}`; globs match SEGMENT-wise: a bare `logs` matches only a file literally named logs, `logs/*` only direct children - a whole subtree needs `logs/**` or the `logs/` prefix form; a pattern matching no source gets called out by the self-check for you to fix) — writing "not included" only in the index prose is invisible to the system and does not count. At the end of every round the system mechanically checks "all raw sources = union of compiled_from + EXCLUSIONS matches": anything unaccounted for comes back as a 【system self-check】 repair instruction, and you must either compile it or explicitly exclude it, one by one — do not leave it hanging. In addition, any file the body cites as `(source: X)` must appear in that page's compiled_from, and every page must be reachable by links from index.md (orphan pages are caught by lint). **Chart/screenshot transcription discipline**: when extracting numbers from images, screenshots or charts: first identify the chart type, the axes and units, and the legend/series labels — only then transcribe values; **for tabular monitoring screenshots (nvitop / nvidia-smi / top …) every value must be aligned to its COLUMN header before transcribing — a percentage bar belongs to a specific column (a MEM bar is NOT GPU-Util; this exact confusion has really happened), and N/A rows (dead/offline devices) must be recorded as-is, never averaged into "each of the N devices…"**; write only numbers you can actually read, and when series are ambiguous or a value is unreadable, mark it `⚠️ 存疑` + file a ticket rather than guess; when the same data exists in both a chart and text/tables, the text/table wins; do not Read several images in one message (it trips the image-processing limit); **never attach an image's (source:) tag to information the image does not contain** — a conclusion inferred across sources must either cite the source that actually supports it, or be marked `⚠️ 存疑` + ticketed, never disguised as a sourced fact. After compiling, the system blind-transcribes the images and mechanically compares your claims against the transcripts; mismatches come back as a 【系统自检 · 图像复核】 repair directive (claim / transcript value / fix per item) — just follow it item by item. diff --git a/kbc/platform/pod/prompts/zh/box_role.md b/kbc/platform/pod/prompts/zh/box_role.md index 57dabd94f..eadcd6104 100644 --- a/kbc/platform/pod/prompts/zh/box_role.md +++ b/kbc/platform/pod/prompts/zh/box_role.md @@ -28,7 +28,7 @@ **应用裁决**:负责人事后会在「矛盾处理」里逐条给出正确答案,你会收到一条「应用以下裁决」指令,里面给你若干 `{ticket_id, affected_pages, 正确值}`。对每条:打开对应 `affected_pages`,把该矛盾处改成正确值、并去掉那处的 `⚠️ 存疑` 标注;若答案是"接受存疑/保留双源",就保持并列、不强行定论。**只动被点名的页,别的页不碰。** **每处理完一条(包括"接受存疑"那种不改值的),都必须立刻调一次 `resolve_ticket(ticket_id, applied_value, pages_edited, note, dispatch_nonce)`(指令里每条工单若带 `nonce:` 就原样回传)** —— 这是唯一能让工单"解单"的动作(负责人侧没有手动关单按钮、全靠它):`applied_value` = 你实际写进页里的值(接受存疑就写"保留双源");`pages_edited` = 你这条实际改动的 candidate 文件名,**必须覆盖该工单的 `affected_pages`**(漏页负责人侧会被自动标"待核");`note` = 一句话说你改了什么。**一条一个、别批量、别漏页**;**别再手工去改 `CONTRADICTIONS.json` 的 `status`**,该工具会替你写。全部回修完再简短回一句总体动了哪几页。**回修指令可能在你编译/干别的活干到一半时插进来:回修完成后,回到被打断的任务把它干完,不要停下来等人提醒**(没有任何调度器会替你续命,你一停,半截的编译就一直停在半截)。 -**覆盖账本(系统机械核查,不靠自觉)**:每个 candidate 页 frontmatter 的 `compiled_from` 必须列出它实际编自的 raw 相对路径(推荐 `- " · <路径>"`,hash 可省略)。**图片/PDF 等媒体也是源**:内容被你消化进页面的,照样在该页 `compiled_from` 登记其路径;`derived: true` 只留给真正不编自任何 raw 文件的纯综合页(如术语表)——编自图片的页也必须登记 compiled_from,不许用 derived 逃账。raw 里你决定**不编**的文件(含媒体),必须写进 `authoring/EXCLUSIONS.json`(JSON 数组,元素 `{"pattern": "相对 raw 的路径或 glob", "reason": "一句话理由,让负责人看得懂"}`;glob **按路径段匹配**:裸 `logs` 只匹配名字恰为 logs 的文件、`logs/*` 只匹配直接子级、整个子树要写 `logs/**` 或 `logs/` 前缀;没命中任何源的模式自检会点名要你修正)——只在 index 散文里写"未收录"系统看不见,不算数。每轮结束系统会机械核对「raw 全部源 = compiled_from 并集 + EXCLUSIONS 匹配」:有未入账的,你会收到一条【系统自检】回修指令,逐个补编或显式排除,二选一,不许晾着。此外正文里 `(source: X)` 引用的文件必须出现在本页 compiled_from,所有页必须从 index.md 有链可达(孤儿页会被 lint 抓)。 +**覆盖账本(系统机械核查,不靠自觉)**:每个 candidate 页 frontmatter 的 `compiled_from` 必须列出它实际编自的 raw 相对路径(推荐 `- " · <路径>"`,hash 可省略)。**只引用文档,不引用文档里的图片**:媒体资产(`assets/` 目录下、嵌在某文档正文里的图片)由系统自动附属到其文档——系统读每个 raw 文档正文里的图片链接,把它嵌的每张图记在该文档名下,所以图片你**不要**逐张登记进 `compiled_from`,也**不要**逐张排除。只引用文档本身即可;图片内容质量另有独立的图像复核环节负责,不归你这本账。两个例外:**孤儿图片**(在 `assets/` 下但没有任何文档引用它——多半是上传残留)仍需在 `EXCLUSIONS.json` 里带理由显式排除,因为没有别的东西替它入账;`assets/sheets/*.md` 表格占位是**内容件,不是媒体**——照常当普通源引用或带理由排除。PDF 与预渲染的 office 文件(`.pptx`/`.xlsx`/`.docx` → 同名 `.md`)也是内容源,照常引用或排除。`derived: true` 只留给真正不编自任何 raw 文件的纯综合页(如术语表),绝不是逃账的口子。raw 里你决定**不编**的文件(含孤儿媒体),必须写进 `authoring/EXCLUSIONS.json`(JSON 数组,元素 `{"pattern": "相对 raw 的路径或 glob", "reason": "一句话理由,让负责人看得懂"}`;glob **按路径段匹配**:裸 `logs` 只匹配名字恰为 logs 的文件、`logs/*` 只匹配直接子级、整个子树要写 `logs/**` 或 `logs/` 前缀;没命中任何源的模式自检会点名要你修正)——只在 index 散文里写"未收录"系统看不见,不算数。每轮结束系统会机械核对「raw 全部源 = compiled_from 并集 + EXCLUSIONS 匹配」:有未入账的,你会收到一条【系统自检】回修指令,逐个补编或显式排除,二选一,不许晾着。此外正文里 `(source: X)` 引用的文件必须出现在本页 compiled_from,所有页必须从 index.md 有链可达(孤儿页会被 lint 抓)。 **图表/截图转写纪律**:从图片、截图、图表提取数字时:先认清图表类型、坐标轴与单位、图例/系列标签,再逐值转写;**表格型监控截图(nvitop/nvidia-smi/top 等)每个数值必须对准列名——百分比条要先确认它属于哪一列(MEM 显存条 ≠ GPU-Util,这个混淆真实发生过);N/A 行(掉卡/离线)如实记录,不要平均化成「每块都…」**;只写你确实读得清的数值,系列分不清或读不准的,宁可标 ⚠️ 存疑+落工单也绝不猜;同一数据图文并存时以文字/表格为准;一条消息不要连读多张图(会撞图片处理上限);**图里没有的信息不要挂该图的 (source:) 标注**——跨来源推断出的结论要么挂真正支撑它的来源,要么标 ⚠️ 存疑+落工单,绝不伪装成带源事实。编完后系统会对图片做独立盲转写并与你的断言机械比对,不符项会以【系统自检 · 图像复核】回修指令下发(列明断言/转写值/修法),逐条照办即可。 diff --git a/kbc/platform/pod/selfcheck.py b/kbc/platform/pod/selfcheck.py index 4236ea3e6..d19441a7d 100644 --- a/kbc/platform/pod/selfcheck.py +++ b/kbc/platform/pod/selfcheck.py @@ -42,6 +42,16 @@ MEDIA_SOURCE_EXTS = IMAGE_SOURCE_EXTS | {".pdf", ".ppt", ".pptx", ".doc", ".docx", ".xls", ".xlsx"} KNOWN_SOURCE_EXTS = TEXT_SOURCE_EXTS | MEDIA_SOURCE_EXTS +# Media ASSETS (coverage v2, DESIGN-kb-asset-provenance-2026-07-22 §4.1): images +# that live under an `assets/` directory (or the legacy `*.assets/` export form). +# They are DOCUMENT ATTACHMENTS, not first-class sources — a media asset embedded +# in the body of an accounted document is auto-accounted against that document +# (see is_media_asset / asset_attribution_edges / coverage), so cf no longer needs +# a token per image and the exclusion ledger no longer needs a row per image. +# Superset of IMAGE_SOURCE_EXTS by .tiff; kept EXACT and byte-for-byte in sync +# with the sicore ledger mirror via the shared fixture. +MEDIA_ASSET_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".tiff"} + EXCLUSIONS_PATH = "authoring/EXCLUSIONS.json" SELFCHECK_PATH = "authoring/SELFCHECK.json" @@ -86,6 +96,23 @@ def source_inventory(workdir: str) -> list[str]: return sorted(out) +def is_media_asset(rel: str) -> bool: + """A raw source is a media ASSET — a document attachment, auto-accountable in + coverage v2 — when some path SEGMENT is `assets` (or the legacy `*.assets` + export form) AND its extension is a known image type (case-insensitive). + + Deliberately NARROW: sheet placeholders at `assets/sheets/*.md` and every + other `.md`/`.json` are content files (their extension is not an image type), + so they stay first-class sources that must be cited or excluded. Auto-attaching + a sheet placeholder would launder 'the table data was never compiled' into + 'covered' — the exact fail-open §4.1 forbids.""" + ext = posixpath.splitext(rel)[1].lower() + if ext not in MEDIA_ASSET_EXTS: + return False + return any(seg == "assets" or seg.endswith(".assets") + for seg in rel.split("/")) + + def _strip_source_prefix(entry: str) -> str: """Drop a leading raw/ or drop/ so a path compares against the raw-relative inventory. Applied to BOTH compiled_from entries and exclusion patterns, so @@ -252,6 +279,9 @@ def _matches(path: str, pattern: str) -> bool: _MD_LINK_RE = re.compile(r"\]\(\s*(<[^>\n]+>|[^)\n]+)\)") _WIKI_LINK_RE = re.compile(r"\[\[([^\]|#]+)") +# HTML (single OR double quotes) — 07-21 lesson: a feishu doc +# embedded 120 table images as HTML , invisible to a markdown-only scan. +_HTML_IMG_SRC_RE = re.compile(r"]*?\bsrc\s*=\s*(\"[^\"]*\"|'[^']*')", re.IGNORECASE) _BODY_SOURCE_START_RE = re.compile( r"(?P[((])\s*(?:source|src|源|来源)\s*[::]\s*", re.IGNORECASE, ) @@ -589,6 +619,69 @@ def _markdown_link_targets(text: str) -> list[str]: return targets +def document_link_targets(md_text: str) -> list[str]: + """Every link/image destination in one document's prose — the building block + of the doc→asset attribution edge (coverage v2, §4.2). Covers markdown + ``![](...)`` and ``[](...)`` plus HTML ```` (single- OR + double-quoted). Each target is URL-decoded (``%20`` → space) and has its + fragment and optional link title stripped. Code fences/spans are masked so a + path inside example code is not mistaken for a real embed.""" + targets: list[str] = [] + prose = _markdown_prose(md_text) + for captured in _MD_LINK_RE.findall(prose): + destination = captured.strip() + if destination.startswith("<") and destination.endswith(">"): + destination = destination[1:-1].strip() + else: + titled = re.fullmatch(r"(.+?)\s+(?:\"[^\"]*\"|'[^']*')", destination) + if titled: + destination = titled.group(1).strip() + destination = unquote(destination).split("#", 1)[0].strip() + if destination: + targets.append(destination) + for captured in _HTML_IMG_SRC_RE.findall(prose): + destination = unquote(captured[1:-1]).split("#", 1)[0].strip() + if destination: + targets.append(destination) + return targets + + +def asset_attribution_edges( + workdir: str, sources: list[str] | None = None, +) -> dict[str, list[str]]: + """Map each raw ``.md`` document to the media assets it embeds in its body. + + For every document ``d`` under ``raw/``, resolve each body link/img target + relative to ``d``'s directory (``posixpath.normpath``, URL-decoded); an edge + ``d→a`` is recorded only when ``a`` is a media asset present in the raw + inventory. A target that resolves to nothing — or to a non-media file — + yields no edge and never errors: the raw tree is the single source of truth + for what embeds what, so the box (local files) and the server (sync-time + refs) derive the SAME edges. Values are sorted for determinism.""" + sources = source_inventory(workdir) if sources is None else sources + media = {s for s in sources if is_media_asset(s)} + raw = Path(workdir) / "raw" + edges: dict[str, list[str]] = {} + for doc in sources: + if not doc.lower().endswith(".md"): + continue + try: + text = (raw / doc).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + base = posixpath.dirname(doc) + hits: set[str] = set() + for target in document_link_targets(text): + if target.startswith(("http://", "https://", "//", "/")): + continue + resolved = posixpath.normpath(posixpath.join(base, target)) + if resolved in media: + hits.add(resolved) + if hits: + edges[doc] = sorted(hits) + return edges + + def _body_source_payload_spans(text: str) -> list[tuple[int, int, str]]: """Extract source payloads and exact source-text offsets outside code.""" payloads: list[tuple[int, int, str]] = [] @@ -1003,7 +1096,21 @@ def dup_candidates(pages: dict[str, dict], cap: int = 20) -> list[dict]: def coverage(workdir: str, pages: dict[str, dict], exclusions: list[dict]) -> dict: - """The ledger: raw inventory − compiled_from union − exclusions = unaccounted.""" + """The ledger (coverage v2): raw inventory − compiled_from union − exclusions + − auto-attached media = unaccounted. + + v2 adds ONE accounting path (monotonic — it can only SHRINK unaccounted, so + every v1-green library stays green): a media asset (image under `assets/`, + see is_media_asset) embedded in the body of a document that is ITSELF + accounted (cited or excluded) is auto-attached to that document and counts as + accounted. Media assets are the document's attachments, not first-class + sources — so `compiled_from` no longer needs a token per image and the + exclusion ledger no longer needs a row per image. Two things deliberately do + NOT auto-attach: an ORPHAN asset embedded by no accounted document (upload + residue — it stays unaccounted and must be excluded with a reason, so a human + still sees it), and `assets/sheets/*.md` placeholders (content files, not + media — is_media_asset excludes them, so they remain first-class sources). A + directly-cited asset still counts as cited (v1 compatibility).""" sources = source_inventory(workdir) source_set = set(sources) cited: set[str] = set() @@ -1020,12 +1127,34 @@ def coverage(workdir: str, pages: dict[str, dict], exclusions: list[dict]) -> di # glob) — surfaced as a warning so the owner fixes it, but non-blocking (a # stale exclusion for an already-removed file shouldn't wedge the gate). noop_exclusions = sorted({e["pattern"] for e in exclusions} - hit) - unaccounted = sorted(source_set - cited - excluded) + # v2 auto-attach. Edges come from the raw tree (each document's body image + # links), so both repos compute the SAME accounting from the SAME frozen + # source set. `auto` is the NET-NEW set — media assets accounted ONLY via an + # embedding accounted document (not already cited/excluded); the subtraction + # below is identical either way, but reporting the net-new set keeps cited / + # excluded / auto_attached a disjoint, auditable decomposition of accounted. + media_assets = {s for s in source_set if is_media_asset(s)} + accounted_docs = cited | excluded + edges = asset_attribution_edges(workdir, sources) + auto_edges = sorted( + (asset, doc) + for doc, assets in edges.items() if doc in accounted_docs + for asset in assets + if asset in media_assets and asset not in cited and asset not in excluded + ) + auto = {asset for asset, _ in auto_edges} + unaccounted = sorted(source_set - cited - excluded - auto) dangling = sorted(cited - source_set) return { "total_sources": len(sources), "cited": len(cited & source_set), "excluded": len(excluded), + # Auto-attach must be OBSERVABLE, never a silent fail-open (platform + # architecture audit's largest class): report the count and a capped + # sample of the accounting edges (asset ← the document that embeds it). + "auto_attached": len(auto), + "auto_attached_sample": [{"asset": asset, "via": doc} + for asset, doc in auto_edges[:20]], "unaccounted": unaccounted, "dangling_citations": dangling, "noop_exclusions": noop_exclusions, @@ -1244,14 +1373,20 @@ def narration(report: dict, locale: str | None = None) -> str: cov, lint = report["coverage"], report["lint"] en = _is_en(locale) noop = cov.get("noop_exclusions") or [] + auto = cov.get("auto_attached") or 0 warn = "" if noop: warn = (f" ⚠ {len(noop)} exclusion(s) match no source — likely a typo" if en else f" ⚠ {len(noop)} 条排除未命中任何源——疑似写错") + auto_note = "" + if auto: + auto_note = (f"; {auto} media auto-attached" if en + else f";{auto} 张媒体自动附属") if en: if report["state"] == "passed": return (f"Self-check (ledger): closed ✓ — {cov['cited']} sources compiled" - f" / {cov['excluded']} explicitly excluded / {cov['total_sources']} total; lint passed") + warn + f" / {cov['excluded']} explicitly excluded / {cov['total_sources']} total" + f"{auto_note}; lint passed") + warn parts = [] if cov["unaccounted"]: parts.append(f"{len(cov['unaccounted'])} source file(s) unaccounted") @@ -1263,7 +1398,7 @@ def narration(report: dict, locale: str | None = None) -> str: return "Self-check (ledger): " + ", ".join(parts) + " — " + tail + warn if report["state"] == "passed": return (f"自检(账本):闭合 ✓ — {cov['cited']} 源已编 / {cov['excluded']} 显式排除" - f" / 共 {cov['total_sources']};lint 通过") + warn + f" / 共 {cov['total_sources']}{auto_note};lint 通过") + warn parts = [] if cov["unaccounted"]: parts.append(f"{len(cov['unaccounted'])} 个源文件未入账") diff --git a/kbc/platform/pod/test_selfcheck.py b/kbc/platform/pod/test_selfcheck.py index bab837345..21d4db2d9 100644 --- a/kbc/platform/pod/test_selfcheck.py +++ b/kbc/platform/pod/test_selfcheck.py @@ -1601,6 +1601,130 @@ def test_content_hash_shared_formula(): print("OK content_hash: pack == tree == canonical (single shared formula)") +def test_is_media_asset(): + """Media-asset predicate (coverage v2 §4.1): an assets/ (or legacy *.assets) + segment AND an image extension; sheet placeholders and non-images are not.""" + yes = ["assets/a.png", "guide/assets/b.JPG", "report.assets/c.png", + "x/assets/y/d.webp", "assets/photo.tiff"] + no = ["docs/x.png", # no assets segment + "assets/sheets/t.md", # sheet placeholder = content file + "assets/data.json", # json is not an image + "assets/notes.pdf", # pdf is not a media asset + "assetsx/y.png", # segment is not exactly `assets` + "my.assets.bak/y.png"] # segment ends with .bak, not .assets + for p in yes: + assert selfcheck.is_media_asset(p), p + for p in no: + assert not selfcheck.is_media_asset(p), p + print("OK is_media_asset (assets/ + *.assets, image ext, case-insensitive; sheet/.json/.pdf excluded)") + + +def test_document_link_targets(): + """Link/img extraction (coverage v2 §4.2): md image + md link + HTML + (both quote styles), URL-decoded, angle-bracket, title/fragment stripped, + code fences masked; external targets pass through (filtered at edge time).""" + targets = selfcheck.document_link_targets( + "# T\n" + "![a](assets/a.png)\n" + "[b](assets/b.png)\n" + "\n" + "x\n" + "![e](assets/a%20b.png)\n" + "![f]()\n" + "![t](assets/t.png \"caption\")\n" + "![h](assets/i.png#frag)\n" + "```\n![code](assets/nope.png)\n```\n" + "[ext](https://example.test/y.png)\n" + ) + for want in ["assets/a.png", "assets/b.png", "assets/c.png", "assets/d.png", + "assets/a b.png", "assets/g h.png", "assets/t.png", "assets/i.png", + "https://example.test/y.png"]: + assert want in targets, (want, targets) + assert "assets/nope.png" not in targets, targets # masked inside a code fence + print("OK document_link_targets (md/html/url-encoded/angle/title/fragment; code masked)") + + +def test_coverage_v2_auto_attach(): + """Coverage v2 auto-attach semantics, each in isolation.""" + okf_index = "---\nokf_version: \"0.1\"\n---\n# Index\n- [p](p.md)\n" + + # A. image embedded by a cited doc → auto; orphan image → unaccounted; a + # sheet placeholder is a content file, not media (cited, not auto). + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _mk(base, "raw/d.md", "# D\n![x](assets/a.png)\n") + _mk(base, "raw/assets/a.png"); _mk(base, "raw/assets/orphan.png") + _mk(base, "raw/assets/sheets/t.md", "| c |\n| - |\n| 1 |\n") + _mk(base, "candidate/index.md", okf_index) + _mk(base, "candidate/p.md", + "---\ntype: Topic\ncompiled_from:\n - d.md\n - assets/sheets/t.md\n---\nok") + cov = selfcheck.run_layer1(td)["coverage"] + assert cov["unaccounted"] == ["assets/orphan.png"], cov + assert cov["auto_attached"] == 1, cov + assert cov["auto_attached_sample"] == [{"asset": "assets/a.png", "via": "d.md"}], cov + assert not selfcheck.is_media_asset("assets/sheets/t.md") + assert not cov["closed"] + + # B. v1 compatibility + no double count: a directly-cited asset stays cited, + # an explicitly-excluded asset stays excluded, and neither shows up as auto. + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _mk(base, "raw/d.md", "# D\n![a](assets/a.png)\n![b](assets/b.png)\n") + _mk(base, "raw/assets/a.png"); _mk(base, "raw/assets/b.png") + _mk(base, "candidate/index.md", okf_index) + _mk(base, "candidate/p.md", + "---\ntype: Topic\ncompiled_from:\n - d.md\n - assets/a.png\n---\nok") + _mk(base, "authoring/EXCLUSIONS.json", + json.dumps([{"pattern": "assets/b.png", "reason": "not needed"}])) + cov = selfcheck.run_layer1(td)["coverage"] + assert cov["closed"], cov + assert cov["auto_attached"] == 0, cov + assert cov["cited"] == 2 and cov["excluded"] == 1, cov + + # C. an image inherits its document's exclusion (auto via an excluded doc). + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _mk(base, "raw/x.md", "# X\n![c](assets/c.png)\n") + _mk(base, "raw/assets/c.png") + _mk(base, "candidate/index.md", "---\nokf_version: \"0.1\"\n---\n# Index\n") + _mk(base, "authoring/EXCLUSIONS.json", + json.dumps([{"pattern": "x.md", "reason": "draft"}])) + cov = selfcheck.run_layer1(td)["coverage"] + assert cov["closed"], cov + assert cov["auto_attached"] == 1, cov + assert cov["auto_attached_sample"] == [{"asset": "assets/c.png", "via": "x.md"}], cov + + # D. an image shared by a cited AND an unaccounted doc: auto via the cited + # one; the unaccounted document itself stays unaccounted (no fail-open). + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _mk(base, "raw/cited.md", "# C\n![s](assets/s.png)\n") + _mk(base, "raw/loose.md", "# L\n![s](assets/s.png)\n") + _mk(base, "raw/assets/s.png") + _mk(base, "candidate/index.md", okf_index) + _mk(base, "candidate/p.md", "---\ntype: Topic\ncompiled_from:\n - cited.md\n---\nok") + cov = selfcheck.run_layer1(td)["coverage"] + assert cov["unaccounted"] == ["loose.md"], cov + assert cov["auto_attached"] == 1, cov + assert cov["auto_attached_sample"] == [{"asset": "assets/s.png", "via": "cited.md"}], cov + print("OK coverage v2 auto-attach (embed/orphan/sheet, v1 compat, exclusion inherit, shared-any-cited)") + + +def test_asset_provenance_fixture(): + """The shared two-repo fixture: edges + coverage v2 must equal expected.json + byte-for-byte (sicore's adoption ledger asserts the SAME expected.json).""" + fx = Path(__file__).resolve().parent / "fixtures" / "asset-provenance" + expected = json.loads((fx / "expected.json").read_text(encoding="utf-8")) + edges = selfcheck.asset_attribution_edges(str(fx)) + assert edges == expected["attribution_edges"], edges + pages = selfcheck.candidate_pages(str(fx)) + exclusions, errors = selfcheck.load_exclusions(str(fx)) + assert errors == [], errors + cov = selfcheck.coverage(str(fx), pages, exclusions) + assert cov == expected["coverage"], cov + print("OK asset-provenance fixture (edges + coverage v2 == expected.json)") + + def main(): os.environ["KBC_L1_REPAIR_ROUNDS"] = "1" os.environ.setdefault("KBC_PK_MODE", "off") # PK never fires in unrelated wiring tests @@ -1615,6 +1739,10 @@ def main(): test_coverage_and_lint() test_candidate_credential_lint() test_media_ledger_and_new_lint() + test_is_media_asset() + test_document_link_targets() + test_coverage_v2_auto_attach() + test_asset_provenance_fixture() test_body_source_annotations() test_deterministic_body_source_normalization() test_spaced_markdown_links() From 5790bf354a3cf72de169acafe7629544c19dc7f9 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 22 Jul 2026 20:25:31 +0800 Subject: [PATCH 2/3] feat(kbc): media numeric re-verification follows attribution edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to coverage v2. media_citing_pages() gains a third discovery path: a candidate page that cites a DOCUMENT d in its compiled_from now inherits the image numeric-fidelity check of every image d embeds in its body (via asset_attribution_edges), unioned with the existing two paths (directly-cited compiled_from images + body (source: img) citations), which legacy pages keep using. Why: S1b tells the compile agent to stop citing images one-by-one — they auto-attach. media_citing_pages() drove the blind fresh-eyes transcription check off cited images only, so without this the numeric check would silently stop covering embedded charts — a silent fail-open on the exact fidelity risk it exists to catch, in the window before the image-audit dimension lands. Edge assets are intersected with IMAGE_SOURCE_EXTS (the transcription surface), so a media asset like .tiff is accounted by coverage but not numerically re-read. Additive only: no existing media test used an assets/ raw path, so all prior assertions pass unchanged. --- kbc/platform/pod/selfcheck.py | 23 ++++++++++++++++++++--- kbc/platform/pod/test_selfcheck.py | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/kbc/platform/pod/selfcheck.py b/kbc/platform/pod/selfcheck.py index d19441a7d..8cebf843c 100644 --- a/kbc/platform/pod/selfcheck.py +++ b/kbc/platform/pod/selfcheck.py @@ -1592,15 +1592,27 @@ def file_residual_ticket(workdir: str, report: dict, locale: str | None = None) def media_citing_pages(workdir: str) -> dict[str, list[str]]: - """candidate page → sorted raw-relative image paths it digests, gathered - from compiled_from entries AND body (source: …) citations. Basename match - tolerated for body citations (bodies usually cite the bare filename).""" + """candidate page → sorted raw-relative image paths whose numeric fidelity + this page is responsible for. Three discovery paths, UNIONED: + 1. compiled_from image entries — an image cited directly (legacy pages); + 2. body ``(source: …)`` image citations — basename match tolerated; + 3. attribution-edge reverse lookup — a page that cites a DOCUMENT ``d`` in + its compiled_from inherits the numeric check of every image ``d`` + embeds in its body. + Path 3 is what keeps image re-verification alive under coverage v2: agents + now cite DOCUMENTS (images auto-attach, see coverage/asset_attribution_edges) + rather than listing images one-by-one, so without it the fresh-eyes numeric + check would silently stop covering embedded charts — a silent fail-open on the + exact fidelity risk it exists to catch. Only IMAGE_SOURCE_EXTS images go to + transcription, so edge assets are intersected with the raw image set (a media + asset like .tiff is accounted by coverage but not numerically re-read here).""" raw_images = [p for p in source_inventory(workdir) if posixpath.splitext(p)[1].lower() in IMAGE_SOURCE_EXTS] by_basename: dict[str, list[str]] = {} for p in raw_images: by_basename.setdefault(posixpath.basename(p), []).append(p) raw_set = set(raw_images) + edges = asset_attribution_edges(workdir) out: dict[str, list[str]] = {} for rel, page in candidate_pages(workdir).items(): if "error" in page: @@ -1615,6 +1627,11 @@ def media_citing_pages(workdir: str) -> dict[str, list[str]]: matches = by_basename.get(posixpath.basename(entry), []) if len(matches) == 1: hits.add(matches[0]) + # Path 3: images embedded by a document this page cites in compiled_from. + for entry in page["sources"]: + for asset in edges.get(entry, ()): + if asset in raw_set: + hits.add(asset) if hits: out[rel] = sorted(hits) return out diff --git a/kbc/platform/pod/test_selfcheck.py b/kbc/platform/pod/test_selfcheck.py index 21d4db2d9..820ff7d4a 100644 --- a/kbc/platform/pod/test_selfcheck.py +++ b/kbc/platform/pod/test_selfcheck.py @@ -1710,6 +1710,24 @@ def test_coverage_v2_auto_attach(): print("OK coverage v2 auto-attach (embed/orphan/sheet, v1 compat, exclusion inherit, shared-any-cited)") +def test_media_citing_pages_via_attribution_edge(): + """Coverage v2: a page citing only a DOCUMENT still enters the image + numeric-verification surface for every image that document embeds — agents no + longer cite images one-by-one, so the attribution edge is the carrier.""" + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _mk(base, "raw/doc.md", "# D\n![c](assets/chart.png)\n") + _mk(base, "raw/assets/chart.png") + _mk(base, "candidate/index.md", "---\nokf_version: \"0.1\"\n---\n# Index\n- [p](p.md)") + # cites the DOCUMENT only — no image in compiled_from, no (source: img) + _mk(base, "candidate/p.md", + "---\ntype: Topic\ncompiled_from:\n - doc.md\n---\nSummary of the chart.") + citing = selfcheck.media_citing_pages(td) + assert citing == {"p.md": ["assets/chart.png"]}, citing + assert list(selfcheck.pending_media_verification(td)) == ["p.md"] + print("OK media_citing_pages via attribution edge (doc-only citation still verifies embedded images)") + + def test_asset_provenance_fixture(): """The shared two-repo fixture: edges + coverage v2 must equal expected.json byte-for-byte (sicore's adoption ledger asserts the SAME expected.json).""" @@ -1742,6 +1760,7 @@ def main(): test_is_media_asset() test_document_link_targets() test_coverage_v2_auto_attach() + test_media_citing_pages_via_attribution_edge() test_asset_provenance_fixture() test_body_source_annotations() test_deterministic_body_source_normalization() From d581f3731bb03f9af2182e285b1b64ce97f99f6d Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 22 Jul 2026 20:38:09 +0800 Subject: [PATCH 3/3] feat(kbc): align coverage-v2 edge semantics with sicore ledger mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two implementation-level details locked byte-for-byte with the sicore adoption ledger (shared fixture, DESIGN §六), after cross-checking the two implementations: 1. is_media_asset segment match is now case-INSENSITIVE (`Assets/`, `report.ASSETS/` count too). The platform always writes lowercase `assets/`, so this only affects hand-authored trees, but both repos must agree. Locked by a unit test, NOT the shared fixture: an uppercase directory is not portable on a case-insensitive filesystem (it collides with `assets/`). 2. document_link_targets truncates a `#fragment` AND `?query` from the target, and does so on the STILL-ENCODED string BEFORE percent-decoding (new helper _strip_fragment_query). This matches the ledger's parse order (strip angle → truncate #/? → unescape), so an encoded %23/%3F inside a real filename survives while an actual delimiter is removed. Everything else already matched (ext set incl. tiff / no pdf, both quote styles for HTML , resolve-then-membership skip rule, single unquote, Clean(Join) keeping leading ..). The shared fixture gains a `?query`-suffixed target case (raw/extras.md → assets/q.png?v=2). Neither change alters any prior fixture output; expected.json is regenerated from the reference implementation with an in-generator sanity assert on the invariants. All selfcheck + mediaverify tests green. --- .../pod/fixtures/asset-provenance/README.md | 15 ++++++----- .../asset-provenance/candidate/extras.md | 7 ++++++ .../asset-provenance/candidate/index.md | 1 + .../fixtures/asset-provenance/expected.json | 13 +++++++--- .../asset-provenance/raw/assets/q.png | 1 + .../fixtures/asset-provenance/raw/extras.md | 3 +++ kbc/platform/pod/selfcheck.py | 25 ++++++++++++++----- kbc/platform/pod/test_selfcheck.py | 15 +++++++---- 8 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 kbc/platform/pod/fixtures/asset-provenance/candidate/extras.md create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/assets/q.png create mode 100644 kbc/platform/pod/fixtures/asset-provenance/raw/extras.md diff --git a/kbc/platform/pod/fixtures/asset-provenance/README.md b/kbc/platform/pod/fixtures/asset-provenance/README.md index 65247c4cd..cbf54624d 100644 --- a/kbc/platform/pod/fixtures/asset-provenance/README.md +++ b/kbc/platform/pod/fixtures/asset-provenance/README.md @@ -6,12 +6,15 @@ their edge extraction + coverage math over this same `raw/` + `candidate/` + `authoring/EXCLUSIONS.json` and assert the result equals `expected.json`. Cases exercised: relative link, `../` cross-directory link, HTML ``, -URL-encoded path (`%20`), a body reference to a nonexistent asset (no edge, -no error), a 0-byte download-failed placeholder, `assets/sheets/*.md` (a -content file, not media), one image shared by a cited and an unaccounted -document (auto via the accounted one), an orphan image (unaccounted unless -excluded), an image inheriting its document's exclusion, and a directly-cited -asset (still counts as cited, v1 compatibility). +URL-encoded path (`%20`), a `?query`-suffixed target (truncated before +decoding), a body reference to a nonexistent asset (no edge, no error), a +0-byte download-failed placeholder, `assets/sheets/*.md` (a content file, not +media), one image shared by a cited and an unaccounted document (auto via the +accounted one), an orphan image (unaccounted unless excluded), an image +inheriting its document's exclusion, and a directly-cited asset (still counts +as cited, v1 compatibility). Case-insensitivity of the `assets`/`*.assets` +segment is locked by a unit test (an uppercase dir is not portable on a +case-insensitive filesystem), not by a fixture file. Image files hold placeholder bytes — coverage never decodes them; only their path and presence in the inventory matter. diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/extras.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/extras.md new file mode 100644 index 000000000..fc3228962 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/extras.md @@ -0,0 +1,7 @@ +--- +type: Topic +title: Extras +compiled_from: + - extras.md +--- +Extras page. diff --git a/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md b/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md index 161385a83..c19ae577e 100644 --- a/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md +++ b/kbc/platform/pod/fixtures/asset-provenance/candidate/index.md @@ -6,3 +6,4 @@ okf_version: "0.1" - [Setup](setup.md) - [Detail](detail.md) - [Tables](tables.md) +- [Extras](extras.md) diff --git a/kbc/platform/pod/fixtures/asset-provenance/expected.json b/kbc/platform/pod/fixtures/asset-provenance/expected.json index eeb276126..5bf35881d 100644 --- a/kbc/platform/pod/fixtures/asset-provenance/expected.json +++ b/kbc/platform/pod/fixtures/asset-provenance/expected.json @@ -6,6 +6,9 @@ "excluded-doc.md": [ "assets/inherited.png" ], + "extras.md": [ + "assets/q.png" + ], "guide/deep/detail.md": [ "guide/assets/diagram.png" ], @@ -21,10 +24,10 @@ ] }, "coverage": { - "total_sources": 16, - "cited": 5, + "total_sources": 18, + "cited": 6, "excluded": 2, - "auto_attached": 7, + "auto_attached": 8, "auto_attached_sample": [ { "asset": "assets/hero.png", @@ -34,6 +37,10 @@ "asset": "assets/inherited.png", "via": "excluded-doc.md" }, + { + "asset": "assets/q.png", + "via": "extras.md" + }, { "asset": "assets/shared.png", "via": "overview.md" diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/assets/q.png b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/q.png new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/assets/q.png @@ -0,0 +1 @@ +placeholder diff --git a/kbc/platform/pod/fixtures/asset-provenance/raw/extras.md b/kbc/platform/pod/fixtures/asset-provenance/raw/extras.md new file mode 100644 index 000000000..124d58fed --- /dev/null +++ b/kbc/platform/pod/fixtures/asset-provenance/raw/extras.md @@ -0,0 +1,3 @@ +# Extras + +Query-string suffix (truncated): ![q](assets/q.png?v=2) diff --git a/kbc/platform/pod/selfcheck.py b/kbc/platform/pod/selfcheck.py index 8cebf843c..d7287697e 100644 --- a/kbc/platform/pod/selfcheck.py +++ b/kbc/platform/pod/selfcheck.py @@ -109,7 +109,10 @@ def is_media_asset(rel: str) -> bool: ext = posixpath.splitext(rel)[1].lower() if ext not in MEDIA_ASSET_EXTS: return False - return any(seg == "assets" or seg.endswith(".assets") + # Segment match is case-INSENSITIVE, matching the sicore ledger mirror (an + # `Assets/` dir counts too); the platform always writes lowercase `assets/`, + # so this only matters for hand-authored trees, but the two repos must agree. + return any(seg.lower() == "assets" or seg.lower().endswith(".assets") for seg in rel.split("/")) @@ -619,13 +622,23 @@ def _markdown_link_targets(text: str) -> list[str]: return targets +def _strip_fragment_query(target: str) -> str: + """Drop a URL ``#fragment`` / ``?query`` from the STILL-ENCODED target, before + percent-decoding — so an encoded ``%23``/``%3F`` that is part of a real + filename survives while an actual fragment/query delimiter is removed. Order + matches the sicore ledger's parser (strip angle → truncate #/? → unescape) so + the two repos derive byte-identical edges.""" + return target.split("#", 1)[0].split("?", 1)[0] + + def document_link_targets(md_text: str) -> list[str]: """Every link/image destination in one document's prose — the building block of the doc→asset attribution edge (coverage v2, §4.2). Covers markdown ``![](...)`` and ``[](...)`` plus HTML ```` (single- OR - double-quoted). Each target is URL-decoded (``%20`` → space) and has its - fragment and optional link title stripped. Code fences/spans are masked so a - path inside example code is not mistaken for a real embed.""" + double-quoted). Angle brackets are unwrapped and an optional link title + stripped; then the ``#fragment``/``?query`` is truncated and the result is + URL-decoded once (``%20`` → space). Code fences/spans are masked so a path + inside example code is not mistaken for a real embed.""" targets: list[str] = [] prose = _markdown_prose(md_text) for captured in _MD_LINK_RE.findall(prose): @@ -636,11 +649,11 @@ def document_link_targets(md_text: str) -> list[str]: titled = re.fullmatch(r"(.+?)\s+(?:\"[^\"]*\"|'[^']*')", destination) if titled: destination = titled.group(1).strip() - destination = unquote(destination).split("#", 1)[0].strip() + destination = unquote(_strip_fragment_query(destination)).strip() if destination: targets.append(destination) for captured in _HTML_IMG_SRC_RE.findall(prose): - destination = unquote(captured[1:-1]).split("#", 1)[0].strip() + destination = unquote(_strip_fragment_query(captured[1:-1])).strip() if destination: targets.append(destination) return targets diff --git a/kbc/platform/pod/test_selfcheck.py b/kbc/platform/pod/test_selfcheck.py index 820ff7d4a..17cec7b94 100644 --- a/kbc/platform/pod/test_selfcheck.py +++ b/kbc/platform/pod/test_selfcheck.py @@ -1605,7 +1605,10 @@ def test_is_media_asset(): """Media-asset predicate (coverage v2 §4.1): an assets/ (or legacy *.assets) segment AND an image extension; sheet placeholders and non-images are not.""" yes = ["assets/a.png", "guide/assets/b.JPG", "report.assets/c.png", - "x/assets/y/d.webp", "assets/photo.tiff"] + "x/assets/y/d.webp", "assets/photo.tiff", + # case-INSENSITIVE segment (locked here, not in the fixture: an + # uppercase dir is not portable on a case-insensitive filesystem). + "Assets/e.png", "report.ASSETS/f.png", "guide/AsSeTs/g.png"] no = ["docs/x.png", # no assets segment "assets/sheets/t.md", # sheet placeholder = content file "assets/data.json", # json is not an image @@ -1616,7 +1619,7 @@ def test_is_media_asset(): assert selfcheck.is_media_asset(p), p for p in no: assert not selfcheck.is_media_asset(p), p - print("OK is_media_asset (assets/ + *.assets, image ext, case-insensitive; sheet/.json/.pdf excluded)") + print("OK is_media_asset (assets/ + *.assets, case-insensitive seg + image ext; sheet/.json/.pdf excluded)") def test_document_link_targets(): @@ -1633,15 +1636,17 @@ def test_document_link_targets(): "![f]()\n" "![t](assets/t.png \"caption\")\n" "![h](assets/i.png#frag)\n" + "![q](assets/q.png?v=2)\n" "```\n![code](assets/nope.png)\n```\n" "[ext](https://example.test/y.png)\n" ) for want in ["assets/a.png", "assets/b.png", "assets/c.png", "assets/d.png", "assets/a b.png", "assets/g h.png", "assets/t.png", "assets/i.png", - "https://example.test/y.png"]: + "assets/q.png", "https://example.test/y.png"]: assert want in targets, (want, targets) - assert "assets/nope.png" not in targets, targets # masked inside a code fence - print("OK document_link_targets (md/html/url-encoded/angle/title/fragment; code masked)") + assert "assets/nope.png" not in targets, targets # masked inside a code fence + assert "assets/q.png?v=2" not in targets, targets # ?query truncated + print("OK document_link_targets (md/html/url-encoded/angle/title/fragment/query; code masked)") def test_coverage_v2_auto_attach():