Selaa lähdekoodia

detail 页依赖台账 + 台账维护回链 + 缺件如实化(用户令: /detail 只能基于输入重算)

用户令 2026-09-18: http://127.0.0.1:28084/detail 下各页面不许用旧版产出补, 只能参照旧版产物的呈现样式内容, 必须基于 data/raw 重算。本批把这条令落成可复算的清单与三处修正。

新增:
- scripts/detail_deps.py + docs/detail_页面依赖与重算台账_v0.1.md: 页签 -> 取数接口 -> 产物件 -> 来源/生成端 -> 缺口处置。两头对源码核证据(app.js 的 TABS/fetch 与 windscada_serve.py 的函数符号), 核不上 rc=5; --probe 实测走网关(页面同路径), 实测段独立标记, 不参与 --check 比对。
- 实测结论: 10 页签中 7 个有数; 振动评估(/api/vibcms)与问答、报告(/api/facts)三页因缺件报错, 其余缺件页面降级。

修正:
- windscada_serve.vibcms_results(): 空 glob 不再抛 IndexError(页面曾显示'windcms 报告解析失败: list index out of range'), 改回结构化 no_report 并写明由谁生成、为何算不出。
- dq_findings_view(): 0 条 finding 时如实写清'本页为空, 不是自查无发现', 并说明这四族不在重算链上。
- 台账维护上链(rebuild_all ⑤a): 用户'清除产物'后 _provenance.json 整本丢失(写它的旧第⑤步已按用户令下链, 新第⑤步只读不写) => 门户逐件来源与反向呼应审计全退化成未归类。--refresh 纯账实相符、不搬任何随包件, 放在审计之前。
- refresh_ledger 补'另一个方向': 盘上有而无人登记的件按族表归口(实测 21 件: windscada/*.parquet 17、ontology/* 3、windcms/cache 1), 归不上族的记 unregistered 并打出来; 族表修正可重跑该条。
- 族表: powercurve 之外新增 vib_fleet_scalar_z(raw-derived, 生成端 scripts/rudong_fusion_run.py)与 windcms_cache(raw-derived, src/windcms/data.py), 并把 stale 的'从交付包补齐'口径改成'运行期不从交付包补, 由研发补生成端'(与用户令一致)。

验证: products_restore_missing --refresh -> 1726 件全 raw-derived(shipped 0/unregistered 0); products_reverse_audit --check rc=0(未归类 0, 判据失败 0); detail_deps --check rc=0; ontology.audit rc=0; guanlan.py check 仅 Ollama 一项 FAIL(预期)。
zhouyang.xie 3 viikkoa sitten
vanhempi
commit
5f91b8abd6

+ 149 - 0
docs/detail_页面依赖与重算台账_v0.1.md

@@ -0,0 +1,149 @@
+# /detail 页面依赖与重算台账 v0.1
+
+用户令 (2026-09-18): `/detail` 各页面不许用旧版产出补, 必须由 `data/raw` 重算;
+旧版产物只可参照**呈现样式与内容**。逐页依赖见下表 (自动生成段)。
+
+<!-- DETAIL-DEPS:BEGIN -->
+# /detail 页面依赖与重算台账 (自动生成, 禁手改)
+
+> 生成端: `python scripts/detail_deps.py --write`|场站 `rudong`|产物仓 `outputs/rudong`|盘上 1,728 件
+>
+> 用户令 (2026-09-18): **/detail 各页面不许用旧版产出补**, 只能参照旧版产物的呈现样式与内容, 数值必须由 `data/raw` 重算。本表把每页的取数接口与产物件逐条列清, 缺的写明怎么补。
+
+## 1 页面 → 取数接口
+
+| 页签 | 页面 | 取数接口 |
+|---|---|---|
+| overview | 总览 | `/api/fleet` |
+| energy | 发电与损失 | `/api/fleet` |
+| component | 部件与系统 | `/api/fleet` |
+|  |  | `/api/problem` |
+|  |  | `/api/turbine` |
+| vibration | 振动评估(CMS) | `/api/fleet` |
+|  |  | `/api/vibcms` |
+| generation | 特性曲线 | `/api/curves` |
+| fault | 故障与可用率 | `/api/fleet` |
+| decision | 决策链 | `/api/fleet` |
+|  |  | `/api/ont_chain` |
+| assistant | 问答 | `/api/fleet` |
+|  |  | `/api/ask` |
+|  |  | `/api/ask_status` |
+|  |  | `/api/ask_models` |
+|  |  | `/api/facts` |
+|  |  | `/api/facts/claim` |
+| report | 报告 | `/api/fleet` |
+|  |  | `/api/facts` |
+|  |  | `/api/rpt_compose` |
+| system | 系统自查 | `/api/fleet` |
+|  |  | `/api/maint_survey` |
+
+注: `/api/maint_std` `/api/maint_framework` `/api/dq_findings` `/api/ontology` 属**经典页** (`/?…`, 组件根), `/v2` 工作台不取它们 —— 但同一批本体产物 (ontology/objects.json) 仍然决定着它们能不能出数, 故一并列在 §2。
+
+## 2 接口 → 产物 → 来源 / 生成端
+
+| 接口 | 产物 | 件 | 大小 | 台账来源 | 生成端(族) | 判定 |
+|---|---|---:|---:|---|---|---|
+| /api/fleet | `windscada/temp_monthly.parquet` ✅必需 | 1 | 0.1 MB | raw-derived | scripts/windscada_monthly_build.py (scada_monthly) | 在位 |
+|  | `windscada/alarms.parquet` ✅必需 | 1 | 0.9 MB | raw-derived | scripts/windscada_alarms_ingest.py (scada_alarms) | 在位 |
+|  | `windscada/loss_monthly.parquet` ✅必需 | 1 | 0.1 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
+|  | `windscada/powercurve_bins.parquet` ✅必需 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
+|  | `windscada/powercurve_dev.parquet` ✅必需 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
+| /api/fleet | `windscada/*.parquet` ○可选 | 17 | 2.0 MB | raw-derived | scripts/windscada_alarms_ingest.py (scada_alarms·scada_derived·scada_oil·scada_monthly·scada_workorders) | 在位 |
+|  | `ontology/turbine_params.parquet` ○可选 | 1 | 0.1 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
+|  | `m5_cms_tcm/handoff_vibration_v2.json` ○可选 | 0 | 0.0 MB | — | — (vib_handoff_and_scans) | 缺(页面降级) |
+|  | `m5_cms_tcm/fleet_scalar_z.parquet` ○可选 | 0 | 0.0 MB | — | — (vib_fleet_scalar_z) | 缺(页面降级) |
+|  | `pitch/pitch_daily.parquet` ○可选 | 0 | 0.0 MB | — | — (pitch_shipped) | 缺(页面降级) |
+|  | `pitch/pitch_zero_monthly.parquet` ○可选 | 0 | 0.0 MB | — | — (pitch_shipped) | 缺(页面降级) |
+| /api/curves | `windscada/powercurve_bins.parquet` ✅必需 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
+| /api/curves | `windscada/curve_lenses.parquet` ○可选 | 1 | 0.1 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
+|  | `windscada/curve_liveness.parquet` ○可选 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
+|  | `windscada/control_profile.parquet` ○可选 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
+|  | `windscada/hydraulic_accum.parquet` ○可选 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
+| /api/problem | `windscada/alarms.parquet` ✅必需 | 1 | 0.9 MB | raw-derived | scripts/windscada_alarms_ingest.py (scada_alarms) | 在位 |
+| /api/problem | `ontology/objects.json` ○可选 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
+|  | `windscada/workorders.parquet` ○可选 | 1 | 0.5 MB | raw-derived | scripts/windscada_workorder_ingest.py (scada_workorders) | 在位 |
+| /api/turbine | `windscada/alarms.parquet` ✅必需 | 1 | 0.9 MB | raw-derived | scripts/windscada_alarms_ingest.py (scada_alarms) | 在位 |
+| /api/turbine | `ontology/objects.json` ○可选 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
+|  | `windscada/workorders.parquet` ○可选 | 1 | 0.5 MB | raw-derived | scripts/windscada_workorder_ingest.py (scada_workorders) | 在位 |
+| /api/vibcms | `windcms/报告_CMS振动状态评估报告_*.md` ✅必需 | 0 | 0.0 MB | — | — (—) | **缺** |
+| /api/vibcms | `m5_cms_tcm/handoff_vibration_v2.json` ○可选 | 0 | 0.0 MB | — | — (vib_handoff_and_scans) | 缺(页面降级) |
+|  | `windcms/cache/*` ○可选 | 1 | 2.2 MB | raw-derived | src/windcms/data.py (windcms_cache) | 在位 |
+| /api/maint_survey | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
+| /api/maint_std | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
+| /api/maint_framework | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
+| /api/dq_findings | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
+| /api/ont_chain | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
+| /api/ontology | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
+| /api/facts | `guanlan/facts_contract_v0.json` ✅必需 | 0 | 0.0 MB | — | — (guanlan_contract) | **缺** |
+|  | `guanlan/derived/detail_cards.json` ✅必需 | 0 | 0.0 MB | — | — (guanlan_contract) | **缺** |
+| /api/facts | `guanlan/derived/qa_refs.json` ○可选 | 0 | 0.0 MB | — | — (guanlan_contract) | 缺(页面降级) |
+|  | `guanlan/derived/portal_claims.json` ○可选 | 0 | 0.0 MB | — | — (guanlan_contract) | 缺(页面降级) |
+
+## 3 缺口与处置 (缺的件谁生成、能不能从 data/raw 重算)
+
+| 接口 | 缺件 | 必需 | 生成端 | 能否由 data/raw 重算 |
+|---|---|---|---|---|
+| `/api/fleet` | `m5_cms_tcm/handoff_vibration_v2.json` | 否 | (无生成端) | **不能**: 振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ 运行期不从交付包补(用户令 2026-09-17 ⇒ 需研发补生成端 |
+| `/api/fleet` | `m5_cms_tcm/fleet_scalar_z.parquet` | 否 | `scripts/rudong_fusion_run.py` | 能: 放原始件到 data/raw 后 `rebuild_all.py` |
+| `/api/fleet` | `pitch/pitch_daily.parquet` | 否 | (无生成端) | **不能**: 无生成端 ⇒ 运行期不从交付包补(用户令 2026-09-17), 页面降级; 要出件须研发补生成端 ⇒ 需研发补生成端 |
+| `/api/fleet` | `pitch/pitch_zero_monthly.parquet` | 否 | (无生成端) | **不能**: 无生成端 ⇒ 运行期不从交付包补(用户令 2026-09-17), 页面降级; 要出件须研发补生成端 ⇒ 需研发补生成端 |
+| `/api/vibcms` | `windcms/报告_CMS振动状态评估报告_*.md` | 是 | (无生成端) | **不能**: 生成端在包内但输入不足(model_run/fusion 未随包) ⇒ 运行期不从交付包补(用户令 2026-09-17 ⇒ 需研发补生成端 |
+| `/api/vibcms` | `m5_cms_tcm/handoff_vibration_v2.json` | 否 | (无生成端) | **不能**: 振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ 运行期不从交付包补(用户令 2026-09-17 ⇒ 需研发补生成端 |
+| `/api/facts` | `guanlan/facts_contract_v0.json` | 是 | (无生成端) | **不能**: 派生链的中间层: 它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳; 运行期不从交付包补( ⇒ 需研发补生成端 |
+| `/api/facts` | `guanlan/derived/detail_cards.json` | 是 | (无生成端) | **不能**: 派生链的中间层: 它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳; 运行期不从交付包补( ⇒ 需研发补生成端 |
+| `/api/facts` | `guanlan/derived/qa_refs.json` | 否 | (无生成端) | **不能**: 派生链的中间层: 它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳; 运行期不从交付包补( ⇒ 需研发补生成端 |
+| `/api/facts` | `guanlan/derived/portal_claims.json` | 否 | (无生成端) | **不能**: 派生链的中间层: 它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳; 运行期不从交付包补( ⇒ 需研发补生成端 |
+
+## 4 与输入数据的对应 (重算口径)
+
+| 产物族 | 输入 (data/raw/<场站>/…) | 生成端 |
+|---|---|---|
+| scada_monthly | scada_10min | `scripts/windscada_monthly_build.py` |
+| scada_alarms | 故障报警 | `scripts/windscada_alarms_ingest.py` |
+| scada_derived | scada_10min | `src/windscada/perf/powercurve.py` |
+| ontology_core | 西门子4.0技术资料 | `src/ontology/kb_ingest.py` |
+| vib_handoff_and_scans | — | `—` |
+| vib_fleet_scalar_z | m5_cms_tcm/windows/*/index.parquet | `scripts/rudong_fusion_run.py` |
+| pitch_shipped | — | `—` |
+| scada_workorders | 风机故障记录 | `scripts/windscada_workorder_ingest.py` |
+| guanlan_contract | — | `—` |
+
+## 5 复现
+
+```
+python scripts/detail_deps.py --probe        # 实测在线接口 (页面同路径: 网关 → 组件 /v2)
+python scripts/products_restore_missing.py --refresh   # 重算后维护逐件来源台账
+python scripts/rebuild_all.py               # 全量重算 (链上含 ⑤a 台账维护 → ⑤ 反向呼应审计)
+```
+<!-- DETAIL-DEPS:END -->
+
+<!-- DETAIL-PROBE:BEGIN -->
+## P 实测 (本次运行, 走网关 28084 → 组件 /v2)
+
+| 页签 | 接口 | 判定 | 实测 |
+|---|---|---|---|
+| overview | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+| energy | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+| component | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+|  | `/api/problem` | 有数 | HTTP 200 · 16 B |
+|  | `/api/turbine` | 有数 | HTTP 200 · 13,107 B |
+| vibration | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+|  | `/api/vibcms` | 报错 | HTTP 200 · windcms 报告解析失败: list index out of range |
+| generation | `/api/curves` | 有数 | HTTP 200 · 23,913 B |
+| fault | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+| decision | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+|  | `/api/ont_chain` | 有数 | HTTP 200 · 288 B |
+| assistant | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+|  | `/api/ask` | 有数 | HTTP 404 · 3 B |
+|  | `/api/ask_status` | 有数 | HTTP 200 · 173 B |
+|  | `/api/ask_models` | 有数 | HTTP 200 · 137 B |
+|  | `/api/facts` | 报错 | HTTP 503 · facts derived 缺失: [Errno 2] No such file or directory: 'F:\\temp\\app\ |
+|  | `/api/facts/claim` | — | — |
+| report | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+|  | `/api/facts` | 报错 | HTTP 503 · facts derived 缺失: [Errno 2] No such file or directory: 'F:\\temp\\app\ |
+|  | `/api/rpt_compose` | 不适用(本机模型未启动) | HTTP 200 · 本地模型不可达: <urlopen error [WinError 10061] 由于目标计算机积极拒绝,无法连接。> |
+| system | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+|  | `/api/maint_survey` | 有数 | HTTP 200 · 7,440 B |
+
+判读口径: 只认响应**顶层**的 `err`/`error`/`no_products`/`no_report`; `/api/ask*` `/api/rpt_compose` 依赖本机模型, 未启动时记「不适用」不算缺件。
+<!-- DETAIL-PROBE:END -->

+ 385 - 0
scripts/detail_deps.py

@@ -0,0 +1,385 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""`/detail` 详细分析工作台 · 页面 → 取数接口 → 产物 → 来源 的**可复算**台账 (2026-09-18 用户令)。
+
+用户令: 「http://127.0.0.1:28084/detail 下的各页面, 不能用旧版的产出补, 只能参照旧版产物的呈现
+样式内容, 必须基于输入数据重算」。本器把这条令变成一份**每次都能重跑出来的清单**:
+
+    页面(页签)  ←  取数接口(在线 /api/*)  ←  产物件  →  来源(台账) + 生成端(族表) + 缺口处置
+
+证据不是印象, 两头都对着源码核:
+  · 页面 → 接口: 在 `src/windscada/ui/app.js` 里逐个搜到该接口 (页签表 TABS 与各 fetch 调用);
+  · 接口 → 产物: 声明时给出 `scripts/windscada_serve.py` 里的函数名, 本器逐一核对该符号存在;
+  · 产物 → 来源: 读 `outputs/<场>/_provenance.json`(逐件来源台账, 由 products_restore_missing
+    --refresh 维护) 与 `scripts/products_reverse_audit.py` 的族表(生成端)。
+任一条核不上 → 本器报 `[核不上]` 并 rc=5 —— 页面改了接口而清单没跟, 或产物换了生成端, 当场露出来。
+
+`/detail` 是**网关**(scripts/guanlan_gateway.py, 默认 28084)把请求转到**组件服务**
+(scripts/windscada_serve.py, 默认 18033)的 `/v2` 前端; 两边都是同一套 `/api/*`, 所以本清单对
+在线页与单文件快照 (src/windscada/ui/snapshot.py) 同时成立。
+
+用法:
+    python scripts/detail_deps.py                # 打印清单 (人看)
+    python scripts/detail_deps.py --probe        # 额外实测在线接口 (需服务在跑; 只打印, 不写盘)
+    python scripts/detail_deps.py --write        # 写 docs/detail_页面依赖与重算台账_v0.1.md
+    python scripts/detail_deps.py --check        # 文档是否与现场一致 (rc=5 = 该重写)
+"""
+from __future__ import annotations
+
+import argparse
+import fnmatch
+import json
+import pathlib
+import re
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / 'scripts'))
+from src import paths as P                                              # noqa: E402
+
+APPJS = ROOT / 'src' / 'windscada' / 'ui' / 'app.js'
+SERVE = ROOT / 'scripts' / 'windscada_serve.py'
+DOC = ROOT / 'docs' / 'detail_页面依赖与重算台账_v0.1.md'
+BEGIN, END = '<!-- DETAIL-DEPS:BEGIN -->', '<!-- DETAIL-DEPS:END -->'
+PBEGIN, PEND = '<!-- DETAIL-PROBE:BEGIN -->', '<!-- DETAIL-PROBE:END -->'
+
+# ── 页签 → 取数接口 (证据: app.js 里能搜到该接口) ────────────────────────────────
+#   ★实测校准 (2026-09-18): 所有页签的**底数**都是 `/api/fleet` (app.js:1029 `load()`), 其余按页签
+#     各自取: decision→ont_chain(403) · report→rpt_compose/facts(586) · system→maint_survey(728)
+#     · generation→curves(752) · vibration/cms→vibcms(931) · assistant→ask*/facts(425/413)。
+#     下钻浮层 (`/api/problem` `/api/turbine`, app.js:995) 从任意页签都能触发, 挂在部件页。
+PAGES: list[tuple[str, str, list[str]]] = [
+    ('overview', '总览', ['/api/fleet']),
+    ('energy', '发电与损失', ['/api/fleet']),
+    ('component', '部件与系统', ['/api/fleet', '/api/problem', '/api/turbine']),
+    ('vibration', '振动评估(CMS)', ['/api/fleet', '/api/vibcms']),
+    ('generation', '特性曲线', ['/api/curves']),
+    ('fault', '故障与可用率', ['/api/fleet']),
+    ('decision', '决策链', ['/api/fleet', '/api/ont_chain']),
+    ('assistant', '问答', ['/api/fleet', '/api/ask', '/api/ask_status', '/api/ask_models',
+                           '/api/facts', '/api/facts/claim']),
+    ('report', '报告', ['/api/fleet', '/api/facts', '/api/rpt_compose']),
+    ('system', '系统自查', ['/api/fleet', '/api/maint_survey']),
+]
+
+# ── 接口 → 产物 → 生成端 (证据: windscada_serve.py 里的符号名, 本器核对存在) ──────
+#   req=True  = 缺了这页就出不来 (接口回 no_products / err);
+#   req=False = 接口内有 .exists() 兜底, 缺了只是少一块 (页面仍出, 但内容不全)。
+APIS: list[dict] = [
+    dict(path='/api/fleet', fn='fleet_view', req=[
+        'windscada/temp_monthly.parquet', 'windscada/alarms.parquet', 'windscada/loss_monthly.parquet',
+        'windscada/powercurve_bins.parquet', 'windscada/powercurve_dev.parquet'],
+        opt=['windscada/*.parquet', 'ontology/turbine_params.parquet',
+             'm5_cms_tcm/handoff_vibration_v2.json', 'm5_cms_tcm/fleet_scalar_z.parquet',
+             'pitch/pitch_daily.parquet', 'pitch/pitch_zero_monthly.parquet'],
+        note='硬缺件在 `_load()` 里直接抛 ProductsMissing ⇒ 接口回 err=no_products'),
+    dict(path='/api/curves', fn='curve_view',
+        req=['windscada/powercurve_bins.parquet'],
+        opt=['windscada/curve_lenses.parquet', 'windscada/curve_liveness.parquet',
+             'windscada/control_profile.parquet', 'windscada/hydraulic_accum.parquet'],
+        note='曲线镜头读 10min 原始档 + 已重算的派生件'),
+    dict(path='/api/problem', fn='single_problem', req=['windscada/alarms.parquet'],
+        opt=['ontology/objects.json', 'windscada/workorders.parquet'], note='部件下钻浮层'),
+    dict(path='/api/turbine', fn='turbine_problems', req=['windscada/alarms.parquet'],
+        opt=['ontology/objects.json', 'windscada/workorders.parquet'], note='逐台下钻浮层'),
+    dict(path='/api/vibcms', fn='vibcms_results',
+        req=['windcms/报告_CMS振动状态评估报告_*.md'],
+        opt=['m5_cms_tcm/handoff_vibration_v2.json', 'windcms/cache/*'],
+        note='报告是振动六层链 report 步的出件; 缺它本接口如实回 no_report(不再抛 IndexError)'),
+    dict(path='/api/maint_survey', fn='maint_framework_view', req=['ontology/objects.json'], opt=[],
+        note='维护面盘点读本体对象库 (/v2 的「系统自查」页签取它)'),
+    dict(path='/api/maint_std', fn='maint_framework_view', ui='classic', req=['ontology/objects.json'],
+        opt=[], note='四判据+成熟度 (经典页 `/?…` 用, /v2 不取)'),
+    dict(path='/api/maint_framework', fn='maint_framework_view', ui='classic', req=['ontology/objects.json'],
+        opt=[], note='运维体系三维 (经典页用, /v2 不取)'),
+    dict(path='/api/dq_findings', fn='dq_findings_view', ui='classic', req=['ontology/objects.json'], opt=[],
+        note='只认 finding/data_quality|caliber|system_coverage|tier_trend 四族对象; 这四族**不在重算链上** '
+             '(唯一写方 scripts/ingest_ops_2025.py 读 data/raw/工作库, 该目录不在现场数据里)'),
+    dict(path='/api/ont_chain', fn='ontology_view', req=['ontology/objects.json'], opt=[], note='故障链/预防链/故障树'),
+    dict(path='/api/ontology', fn='ontology_view', ui='classic', req=['ontology/objects.json'], opt=[],
+        note='本体总表 (经典页用, /v2 用 fleet 里的本体投影)'),
+    dict(path='/api/facts', fn=None,
+        req=['guanlan/facts_contract_v0.json', 'guanlan/derived/detail_cards.json'],
+        opt=['guanlan/derived/qa_refs.json', 'guanlan/derived/portal_claims.json'],
+        note='契约链: 输入是 sop/findings.json + paradigm_r1 三份**人裁底稿** ⇒ 与 data/raw 隔着不止一跳'),
+    dict(path='/api/ask', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
+    dict(path='/api/ask_status', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
+    dict(path='/api/ask_models', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
+    dict(path='/api/rpt_compose', fn=None, req=[], opt=[], note='本机 Ollama; 无产物依赖'),
+]
+
+
+def _expand_braces(glob) -> list[str]:
+    """族 glob (str 或 list) → 展开 `{a,b}` 后的模式列表 (与 products_reverse_audit._files_of 同规则)。"""
+    out = []
+    for g in ([glob] if isinstance(glob, str) else list(glob)):
+        if '{' in g:
+            head, tail = g.split('{', 1)
+            opts, rest = tail.split('}', 1)
+            out += [head + o + rest for o in opts.split(',')]
+        else:
+            out.append(g)
+    return out
+
+
+def _pat_match(rel: str, glob) -> bool:
+    """rel 是否落在族 glob 里 (深度对齐: 模式无 `**` 时 `/` 个数必须相等 —— fnmatch 的 `*` 跨 `/`)。"""
+    for pt in _expand_braces(glob):
+        if '**' not in pt and rel.count('/') != pt.count('/'):
+            continue
+        if rel == pt or fnmatch.fnmatch(rel, pt):
+            return True
+    return False
+
+
+# ── 环境读取 ────────────────────────────────────────────────────────────────────
+def load_env(farm: str | None = None):
+    root = P.out_root(farm)
+    ledger = {}
+    lf = root / '_provenance.json'
+    if lf.is_file():
+        try:
+            ledger = (json.loads(lf.read_text(encoding='utf-8')) or {}).get('files') or {}
+        except Exception:
+            ledger = {}
+    disk = {p.relative_to(root).as_posix(): p.stat().st_size
+            for p in root.rglob('*') if p.is_file()}
+    try:
+        from products_reverse_audit import FAMILIES
+    except Exception:
+        FAMILIES = []
+
+    def family_of(rel: str):
+        for fam in FAMILIES:
+            if _pat_match(rel, fam['glob']):
+                return fam
+        return None
+
+    return dict(farm=farm or P.farm(), root=root, ledger=ledger, disk=disk, family_of=family_of,
+                fams=FAMILIES)
+
+
+def expand(pat: str, env) -> list[str]:
+    got = [r for r in env['disk'] if fnmatch.fnmatch(r, pat)]
+    return sorted(got)
+
+
+def row_of(pat: str, env) -> dict:
+    """一条产物声明 → 在位情况 + 来源 + 生成端。"""
+    hits = expand(pat, env)
+    if hits:
+        srcs = sorted({(env['ledger'].get(r) or {}).get('source', '(未登记)') for r in hits})
+        fams = []
+        for r in hits[:40]:
+            f = env['family_of'](r)
+            if f and f['id'] not in fams:
+                fams.append(f['id'])
+        gen = next((env['family_of'](r) for r in hits if env['family_of'](r)), None)
+        return dict(pat=pat, n=len(hits), bytes=sum(env['disk'][r] for r in hits),
+                    src='·'.join(srcs), builder=(gen or {}).get('gen') or '—',
+                    family='·'.join(fams) or '—', ok=True)
+    f = env['family_of'](pat) if '*' not in pat else None
+    return dict(pat=pat, n=0, bytes=0, src='—', builder='—',
+                family=(f or {}).get('id') or '—', ok=False)
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--probe', action='store_true', help='实测在线接口 (需服务在跑; 只打印)')
+    ap.add_argument('--write', action='store_true', help='写回文档')
+    ap.add_argument('--check', action='store_true', help='核对文档与现场一致 (rc=5 = 该重写)')
+    ap.add_argument('--farm', default=None)
+    a = ap.parse_args()
+    env = load_env(a.farm)
+    appjs = APPJS.read_text(encoding='utf-8')
+    serve = SERVE.read_text(encoding='utf-8')
+
+    # ① 证据核对: 页面端接口在 app.js 里找得到吗; 接口函数在 serve 里找得到吗
+    bad = []
+    for pid, name, eps in PAGES:
+        if f"'{pid}'" not in appjs:
+            bad.append(f'页签 {pid} 不在 app.js 的 TABS 里')
+        for ep in eps:
+            if f"'{ep}'" not in appjs and f"`{ep}" not in appjs and ep not in appjs:
+                bad.append(f'页签 {pid} 声明的 {ep} 在 app.js 里搜不到')
+    for it in APIS:
+        if it.get('ui') == 'classic':
+            # 经典页 (/ 老前端) 的接口: 出处是 serve 里的路由分支, 不是 app.js
+            if f"'{it['path']}'" not in serve:
+                bad.append(f"经典页接口 {it['path']} 在 windscada_serve.py 的路由里搜不到")
+        elif f"'{it['path']}'" not in appjs and it['path'] not in appjs:
+            bad.append(f"{it['path']} 声明给 /v2 用, 但 app.js 里搜不到它")
+        if it['fn'] and f"def {it['fn']}(" not in serve:
+            bad.append(f"{it['path']} 声明的实现函数 {it['fn']}() 在 windscada_serve.py 里不存在")
+
+    probe = {}
+    if a.probe:
+        probe = {it['path']: _probe(it['path']) for it in APIS}
+
+    L = []
+    L.append(f'# /detail 页面依赖与重算台账 (自动生成, 禁手改)')
+    L.append('')
+    L.append(f'> 生成端: `python scripts/detail_deps.py --write`|场站 `{env["farm"]}`|'
+             f'产物仓 `{P.rel(env["root"])}`|盘上 {len(env["disk"]):,} 件')
+    L.append('>')
+    L.append('> 用户令 (2026-09-18): **/detail 各页面不许用旧版产出补**, 只能参照旧版产物的呈现样式与内容, '
+             '数值必须由 `data/raw` 重算。本表把每页的取数接口与产物件逐条列清, 缺的写明怎么补。')
+    L.append('')
+    L.append('## 1 页面 → 取数接口')
+    L.append('')
+    L.append('| 页签 | 页面 | 取数接口 |')
+    L.append('|---|---|---|')
+    for pid, name, eps in PAGES:
+        for i, ep in enumerate(eps):
+            L.append(f'| {pid if i == 0 else ""} | {name if i == 0 else ""} | `{ep}` |')
+    L.append('')
+    L.append('注: `/api/maint_std` `/api/maint_framework` `/api/dq_findings` `/api/ontology` 属**经典页** '
+             '(`/?…`, 组件根), `/v2` 工作台不取它们 —— 但同一批本体产物 (ontology/objects.json) 仍然决定着'
+             '它们能不能出数, 故一并列在 §2。')
+    L.append('')
+    L.append('## 2 接口 → 产物 → 来源 / 生成端')
+    L.append('')
+    L.append('| 接口 | 产物 | 件 | 大小 | 台账来源 | 生成端(族) | 判定 |')
+    L.append('|---|---|---:|---:|---|---|---|')
+    gaps = []
+    for it in APIS:
+        for kind, label in (('req', '✅必需'), ('opt', '○可选')):
+            for pat in it[kind]:
+                r = row_of(pat, env)
+                verdict = ('在位' if r['ok'] else ('**缺**' if kind == 'req' else '缺(页面降级)'))
+                if not r['ok']:
+                    gaps.append((it['path'], pat, kind, r['family']))
+                L.append(f"| {it['path'] if pat == it[kind][0] else ''} | `{pat}` {label} | {r['n']} | "
+                         f"{r['bytes'] / 1e6:.1f} MB | {r['src']} | {r['builder']} ({r['family']}) | {verdict} |")
+    L.append('')
+    L.append('## 3 缺口与处置 (缺的件谁生成、能不能从 data/raw 重算)')
+    L.append('')
+    if not gaps:
+        L.append('无缺口: 表内每件产物都在位且来源已登记。')
+    else:
+        L.append('| 接口 | 缺件 | 必需 | 生成端 | 能否由 data/raw 重算 |')
+        L.append('|---|---|---|---|---|')
+        for ep, pat, kind, fam in gaps:
+            g = _gen_of(env, pat)
+            L.append(f"| `{ep}` | `{pat}` | {'是' if kind == 'req' else '否'} | {g['gen']} | {g['how']} |")
+    L.append('')
+    L.append('## 4 与输入数据的对应 (重算口径)')
+    L.append('')
+    L.append('| 产物族 | 输入 (data/raw/<场站>/…) | 生成端 |')
+    L.append('|---|---|---|')
+    seen = set()
+    for it in APIS:
+        for pat in it['req'] + it['opt']:
+            f = env['family_of'](pat) if '*' not in pat else None
+            if not f or f['id'] in seen:
+                continue
+            seen.add(f['id'])
+            L.append(f"| {f['id']} | {f.get('input') or '—'} | `{f.get('gen') or '—'}` |")
+    L.append('')
+    L.append('## 5 复现')
+    L.append('')
+    L.append('```')
+    L.append('python scripts/detail_deps.py --probe        # 实测在线接口 (页面同路径: 网关 → 组件 /v2)')
+    L.append('python scripts/products_restore_missing.py --refresh   # 重算后维护逐件来源台账')
+    L.append('python scripts/rebuild_all.py               # 全量重算 (链上含 ⑤a 台账维护 → ⑤ 反向呼应审计)')
+    L.append('```')
+    body = '\n'.join(L)
+    text = f'{BEGIN}\n{body}\n{END}\n'
+
+    # ── 实测段: 与"清单"分开标记 —— 它的数随服务在场与否变, 不该让 --check 抖动 ─────────
+    ptext = ''
+    if probe:
+        P_ = ['## P 实测 (本次运行, 走网关 28084 → 组件 /v2)', '',
+              '| 页签 | 接口 | 判定 | 实测 |', '|---|---|---|---|']
+        for pid, name, eps in PAGES:
+            for i, ep in enumerate(eps):
+                r = probe.get(ep) or {}
+                st = (f"HTTP {r['http']}" if r.get('http') else '—') + \
+                     (f" · {r['err'][:70]}" if r.get('err') else (f" · {r['n']:,} B" if r.get('n') else ''))
+                P_.append(f"| {pid if i == 0 else ''} | `{ep}` | {r.get('verdict', '—')} | {st} |")
+        P_ += ['', '判读口径: 只认响应**顶层**的 `err`/`error`/`no_products`/`no_report`; '
+                   '`/api/ask*` `/api/rpt_compose` 依赖本机模型, 未启动时记「不适用」不算缺件。']
+        ptext = f'{PBEGIN}\n' + '\n'.join(P_) + f'\n{PEND}\n'
+
+    if bad:
+        print('证据核对不通过 —— 清单与源码/现场脱节:')
+        for b in bad:
+            print('   [核不上] ' + b)
+    print(f'清单: {len(PAGES)} 页签 · {len(APIS)} 接口 · 缺口 {len(gaps)} 件 · '
+          f'产物仓 {len(env["disk"]):,} 件')
+    if a.write:
+        old = DOC.read_text(encoding='utf-8') if DOC.is_file() else ''
+        if PBEGIN in old and PEND in old:                 # 旧实测段先整段摘掉, 再按需重写
+            old = old[:old.index(PBEGIN)].rstrip() + '\n' + old[old.index(PEND) + len(PEND):].lstrip('\n')
+        if BEGIN in old and END in old:
+            new = old[:old.index(BEGIN)] + text + old[old.index(END) + len(END):].lstrip('\n')
+        else:
+            new = (old.rstrip() + '\n\n' if old.strip() else
+                   '# /detail 页面依赖与重算台账 v0.1\n\n'
+                   '用户令 (2026-09-18): `/detail` 各页面不许用旧版产出补, 必须由 `data/raw` 重算;\n'
+                   '旧版产物只可参照**呈现样式与内容**。逐页依赖见下表 (自动生成段)。\n\n') + text
+        new = new.rstrip() + '\n\n' + ptext if ptext else new
+        DOC.write_text(new, encoding='utf-8')
+        print(f'已写 {P.rel(DOC)}' + ('' if ptext else ' (未带实测段; 加 --probe 可一并写入)'))
+    if a.check:
+        cur = DOC.read_text(encoding='utf-8') if DOC.is_file() else ''
+        seg = cur[cur.index(BEGIN):cur.index(END) + len(END)] if (BEGIN in cur and END in cur) else ''
+        if seg.strip() != text.strip():
+            print('[X] 文档与现场不一致 ⇒ 跑 --write 重写')
+            return 5
+        print('[OK] 文档与现场一致 (清单段; 实测段随服务状态变, 不参与比对)')
+    return 5 if bad else 0
+
+
+def _gen_of(env, pat: str) -> dict:
+    """缺口件 → 生成端与"能否从 data/raw 重算"的如实判断 (依据: 族表的 kind/algo/why)。"""
+    f = next((fam for fam in env['fams'] if _pat_match(pat, fam['glob'])), None)
+    if not f:
+        return dict(gen='—', how='族表里没有这一族 ⇒ 需人工归口')
+    if f.get('gen'):
+        return dict(gen=f"`{f['gen']}`", how='能: 放原始件到 data/raw 后 `rebuild_all.py`')
+    return dict(gen='(无生成端)', how=f"**不能**: {str(f.get('why') or '')[:60]} ⇒ 需研发补生成端")
+
+
+def _probe(ep: str) -> dict:
+    """实测一个接口 (走网关, 与页面同路径)。判读只认**顶层**的 err/error/no_products/no_report ——
+    ★2026-09-18 首版按正则全文搜 `"err":` 判读, 结果把 fleet 响应里某个以 err 结尾的字段的**值**
+    (per_turbine) 当成了报错, 页面明明好着却显示"有缺件痕迹"。判读不许靠猜文本。"""
+    import urllib.error
+    import urllib.request
+    base = 'http://127.0.0.1:28084/detail'
+    q = {'/api/fleet': '?win=2026%E5%B9%B4',
+         '/api/turbine': '?t=WTG01&win=2026%E5%B9%B4',
+         '/api/problem': '?t=WTG01&sys=%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F&win=2026%E5%B9%B4',
+         '/api/ont_chain': '?kind=prev&system=%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F',
+         '/api/facts/claim': '?id=RD-000'}.get(ep, '')
+    try:
+        with urllib.request.urlopen(base + ep + q, timeout=60) as r:
+            raw, code = r.read(), r.status
+    except urllib.error.HTTPError as e:
+        raw, code = e.read(), e.code
+    except Exception as e:
+        return dict(http=None, n=0, err=f'{type(e).__name__}: {e}', verdict='连不上')
+    try:
+        obj = json.loads(raw.decode('utf-8', 'replace'))
+    except Exception:
+        return dict(http=code, n=len(raw), err='(非 JSON)', verdict='?')
+    if not isinstance(obj, dict):
+        return dict(http=code, n=len(raw), err='', verdict='有数')
+    msg = next((str(obj[k]) for k in ('err', 'error') if obj.get(k)), '')
+    if ep == '/api/ask':      # 只收 POST (页面上是表单提交), GET 探测必 404 —— 不是缺件
+        return dict(http=code, n=len(raw), err='', verdict='不适用(POST 接口)')
+    # 本机模型 (Ollama) 没起 / 未接: 这不是"重算缺件", 标成不适用 —— 与 `guanlan.py check` 的口径一致
+    if ep in ('/api/ask_status', '/api/ask_models', '/api/rpt_compose'):
+        return dict(http=code, n=len(raw), err=msg,
+                    verdict='不适用(本机模型未启动)' if msg or code >= 400 else '有数')
+    if obj.get('no_products') or obj.get('no_report') or (ep == '/api/facts/claim' and code >= 400):
+        return dict(http=code, n=len(raw), err=msg or 'no_products', verdict='无产物(如实)')
+    if msg:
+        return dict(http=code, n=len(raw), err=msg, verdict='报错')
+    return dict(http=code, n=len(raw), err='', verdict='有数')
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 80 - 1
scripts/products_restore_missing.py

@@ -41,6 +41,7 @@ r"""补齐"包内没有生成端"的产物 —— 只补缺件, 不动 raw 重
 from __future__ import annotations
 
 import argparse
+import fnmatch
 import json
 import pathlib
 import shutil
@@ -160,6 +161,58 @@ class StashMissing(SystemExit):
     exit_code = 6
 
 
+def _family_patterns(glob) -> list[str]:
+    """族 glob(str 或 list)→ 展开花括号后的模式列表 (与 products_reverse_audit._files_of 同规则)。"""
+    out = []
+    for g in ([glob] if isinstance(glob, str) else list(glob)):
+        if '{' in g:
+            head, tail = g.split('{', 1)
+            opts, rest = tail.split('}', 1)
+            out += [head + o + rest for o in opts.split(',')]
+        else:
+            out.append(g)
+    return out
+
+
+def _attribute(rel: str) -> dict:
+    """盘上有、无人登记的件 → 按**族表**归口 (2026-09-18)。
+
+    为什么必须做这一步: 生成端**自登记**(`_derived_manifest.json`) 并非全覆盖 —— 实测
+    `windscada/*.parquet`(17 件, 由 `rebuild_from_raw.py --scada` 的 10 个构建器写)、
+    `ontology/*`(3 件, 由本体的 kb_ingest/populate/retrieval 写) 都不自登记。此前本模式
+    **只**照抄自登记件 ⇒ 台账 1705 条、盘上 1728 件, 差的 23 件既不在账上也不被报出
+    (账实不符的另一个方向, 而且正好是"页面主取数"那一批)。现在按族表补归口, 归不上
+    的**如实记 `unregistered`** —— 不猜来源。
+
+    深度对齐规则同 `products_reverse_audit._files_of`: 模式无 `**` 时, `/` 个数必须相等
+    (fnmatch 的 `*` 跨 `/`, 不显式挡会让 `windscada/*.parquet` 吞掉子目录里的同名件)。
+    """
+    try:
+        from products_reverse_audit import FAMILIES
+    except Exception as e:                                      # 族表读不到: 全部按未归类记
+        return dict(source='unregistered', builder='?',
+                    why=f'盘上有、生成端未自登记, 且族表读不到({type(e).__name__}) ⇒ 需人工归口')
+    for fam in FAMILIES:
+        for pt in _family_patterns(fam.get('glob')):
+            if '**' not in pt and rel.count('/') != pt.count('/'):
+                continue
+            if rel == pt or fnmatch.fnmatch(rel, pt):
+                kind = fam.get('kind') or 'raw-derived'
+                src = {'raw-derived': 'raw-derived', 'shipped': 'shipped', 'human': 'human',
+                       'source-derived': 'source-derived', 'not-product': 'not-product'}.get(kind, kind)
+                return dict(source=src, builder=fam.get('gen') or '(族表登记: 无生成端)',
+                            why=f'盘上在位但生成端未自登记 → 按族表 {fam.get("id")} 归口'
+                                f'({fam.get("func") or ""})')
+    return dict(source='unregistered', builder='?',
+                why='盘上有、生成端未自登记、族表也未登记 ⇒ 需人工归口 '
+                    '(本条是软件兜底记账, 不是真来源; 见到了要么补自登记, 要么补族表)')
+
+
+def _reattr_needed(v: dict) -> bool:
+    """这条账是不是"族表归口的产物"(可随族表改进而重写),而不是真来源(自登记/人工补入)。"""
+    return v.get('source') == 'unregistered' or '按族表' in str(v.get('why') or '')
+
+
 def refresh_ledger(farm: str | None = None) -> int:
     r"""**只维护台账**(不需要任何随包件/交付包):把盘上已经不存在的条目从 `_provenance.json` 里去掉。
 
@@ -193,18 +246,44 @@ def refresh_ledger(farm: str | None = None) -> int:
                 added += 1
     except Exception:
         pass
+    # ── 另一个方向: 盘上有、账上没有的件 → 按族表归口 (绝不静默漏掉) ──────────────
+    #    台账两本 (_provenance.json / _derived_manifest.json) 都是**这本台账自己**:
+    #    它们不是产物, 不登记 (否则每次写盘都会让"账实相符"永远差两件)。
+    swept: list[str] = []
+    for p in sorted(root.rglob('*')):
+        if not p.is_file():
+            continue
+        rel = p.relative_to(root).as_posix()
+        if rel in ('_provenance.json', '_derived_manifest.json'):
+            continue
+        # 只补"没人登记"的; 但**族表归口过的条目要重跑** —— 否则族表改了(例如给 windcms/cache
+        # 单列一族)这条账会一直停在旧归口上, 而它并不是谁自登记/谁补进来的真来源。
+        if rel in kept and not _reattr_needed(kept[rel]):
+            continue
+        kept[rel] = _attribute(rel)
+        swept.append(rel)
     by = {}
     for v in kept.values():
         by[v.get('source', '?')] = by.get(v.get('source', '?'), 0) + 1
-    print(f'台账: {len(old)} 条 → {len(kept)} 条(盘上已删 {len(dropped)} 条, 自登记补入 {added} 条)')
+    print(f'台账: {len(old)} 条 → {len(kept)} 条(盘上已删 {len(dropped)} 条, 自登记补入 {added} 条, '
+          f'按族表归口 {len(swept)} 条)')
     for r in dropped[:12]:
         print(f'   - 移出/删除: {r}')
     if len(dropped) > 12:
         print(f'   … 另有 {len(dropped) - 12} 条')
+    for r in swept[:8]:
+        print(f'   + 归口: {r}  ← {kept[r].get("builder")}')
+    if len(swept) > 8:
+        print(f'   … 另有 {len(swept) - 8} 条归口')
+    unk = [r for r in swept if kept[r].get('source') == 'unregistered']
+    if unk:
+        print(f'   ★ {len(unk)} 件**归不上族**: ' + ', '.join(unk[:6]) + (' …' if len(unk) > 6 else ''))
     print('   来源分布: ' + ' · '.join(f'{k}={v}' for k, v in sorted(by.items())))
     f.write_text(json.dumps(dict(
         at=time.strftime('%Y-%m-%d %H:%M:%S'),
         note='逐件来源台账: raw-derived = 由 data/raw 重算(含验证依据); shipped = 包内无生成端(历史随包件); '
+             'source-derived = 由源码重建(如发布清单); human = 人工正本/交证件; '
+             'unregistered = 盘上有但无人登记(需人工归口); '
              'log-relocated = 包内日志按用户令 2 落到 logs/build/',
         counts=dict(by), files=kept), ensure_ascii=False, indent=1), encoding='utf-8')
     print(f'已写 {P.rel(f)}')

+ 27 - 8
scripts/products_reverse_audit.py

@@ -106,21 +106,38 @@ FAMILIES: list[dict] = [
          gen=None, input=None, pred=(), why='领域正本随包发来, 不由 data/raw 推导'),
 
     # ── 以下族: 全库 0 处写入方 ⇒ 反向呼应**在原理上不成立**(如实记账, 不假装成立)────
+    #    ★2026-09-18 用户令补充: 这些件**运行期一律不从交付包补**(用户令 2026-09-17 #1 的延伸),
+    #      页面如实为空/降级; 要出件只有两条路 —— ① 研发补生成端; ② 现场正本随原始件进 data/raw。
+    #      故本表的 why 里不再写"从交付包补齐"那种话(那是**离线人工补救**, 见 products_restore_missing.py)。
+    dict(id='vib_fleet_scalar_z', glob='m5_cms_tcm/fleet_scalar_z.parquet', kind='raw-derived',
+         func='振动线 · 全场标量 z 值 (fusion 步出件)',
+         algo='scripts/rudong_fusion_run.py(逆向工程实现: 窗索引 scalar_value → 同 (传感器,测量,工况) 族中位 '
+              '→ 稳健 z=(val−med)/(1.4826·MAD); 与随包样件 8,887 行逐值对拍 val 列 100% 一致)',
+         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm/windows/*/index.parquet', pred=('rows',)),
     dict(id='vib_handoff_and_scans', glob='m5_cms_tcm/*.{json,parquet,csv,md,txt}',
          kind='shipped',
          func='振动线出件与专项扫描(handoff / 历史 / 基线 / 各类 freq scan / 判级台账)', algo='(无生成端)',
-         gen=None, input=None, pred=(), why='振动六层链四步脚本未随包(oem_frequency_scan 等),'
-                                            '现场正本才可重出;现只能从交付包补齐'),
+         gen=None, input=None, pred=(), why='振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ '
+                                            '运行期不从交付包补(用户令 2026-09-17); 要出件须研发补生成端, '
+                                            '或由现场正本随原始件进 data/raw 后重算'),
     dict(id='vib_figs', glob='m5_cms_tcm/figs/*', kind='shipped',
          func='振动线出图(谱图/趋势图)', algo='(无生成端)', gen=None, input=None, pred=(),
          why='随包快照(振动线出件)'),
     dict(id='windscada_pages', glob='windscada/{index.html,turbines/*,review/*}', kind='shipped',
          func='总览「全场状态」静态页 + 逐台页 + 评审记录', algo='(无生成端)',
          gen=None, input=None, pred=(), why='页面件由振动线/历史分析出件,本包无写入方'),
+    # ★2026-09-18 补: `windcms/cache/*` 是**读侧缓存**(标量按内容哈希落盘), 它不是随包快照 ——
+    #   原先 `windcms_pages`(shipped) 先认领了它, 于是重算后新写的缓存被记成"包内无生成端"。
+    #   写方在 src/windcms/data.py::load_scalars (窗索引 → 标量), 故单列一族并放在 windcms_pages 之前。
+    dict(id='windcms_cache', glob='windcms/cache/*', kind='raw-derived',
+         func='CMS 报告侧 · 标量缓存', algo='src/windcms/data.py::load_scalars(窗索引 → 标量数组, '
+              '按内容哈希命名落 cache/)', gen='src/windcms/data.py', input='windcms', pred=('rows',)),
     dict(id='windcms_pages', glob='windcms/**', kind='shipped',
-         func='CMS 振动评估报告页/逐台页/缓存', algo='scripts/windcms.py report(本包缺 model_run/fusion '
-              '产物, 重生成会掉内容 −97%, 故**按设计跳过**)', gen=None, input=None, pred=(),
-         why='生成端在包内但输入不足 ⇒ 现状按随包件冻结'),
+         func='CMS 振动评估报告页/逐台页', algo='src/windcms/report_std.py(自算报告)+ scripts/windcms.py report;'
+              '两者都要六层链的 model_run/fusion 出件, 本包没有 ⇒ 跑不出来',
+         gen=None, input=None, pred=(),
+         why='生成端在包内但输入不足(model_run/fusion 未随包) ⇒ 运行期不从交付包补(用户令 2026-09-17), '
+             '`/api/vibcms` 如实回 no_report; 要出件须研发先补 model_run/fusion 两步'),
     dict(id='sop_workspace', glob='sop/**', kind='shipped',
          func='SOP 中间件/评审/事实契约底稿', algo='(无生成端)', gen=None, input=None, pred=(),
          why='评审过程件, 属人工作业留痕'),
@@ -132,14 +149,16 @@ FAMILIES: list[dict] = [
          why='实验底稿, 事实契约的输入'),
     dict(id='pitch_shipped', glob='pitch/**', kind='shipped',
          func='变桨侧派生件(零位/日粒度)', algo='(无生成端;rebuild_from_raw --scada 只覆盖其中一部分)',
-         gen=None, input=None, pred=(), why='随包快照'),
+         gen=None, input=None, pred=(), why='无生成端 ⇒ 运行期不从交付包补(用户令 2026-09-17), '
+                                            '页面降级; 要出件须研发补生成端'),
     dict(id='ontology_releases', glob='ontology/release_*/*', kind='shipped',
          func='本体发布层 r1/r2(只读暴露给网关 /release/)', algo='(无生成端)', gen=None, input=None,
          pred=(), why='发布层清单, 由研发出件'),
     dict(id='guanlan_contract', glob='guanlan/**', kind='shipped',
          func='事实契约与对外派生(门户结论段/取数)', algo='scripts/guanlan_facts_contract.py(读者在包内, '
-              '输入是 ontology+m5+sop 三处**产物**)', gen=None, input=None, pred=(),
-         why='派生链的中间层:它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳'),
+              '输入是 sop/findings.json + paradigm_r1 三份**人裁底稿**)', gen=None, input=None, pred=(),
+         why='派生链的中间层: 它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳; '
+             '运行期不从交付包补(用户令 2026-09-17) ⇒ `/api/facts` 如实回 503 缺件'),
     dict(id='windscada_release_manifest', glob='windscada/release-manifest.json', kind='source-derived',
          func='发布清单快照(`/healthz` 与版本信息读它)',
          algo='scripts/guanlan_baseline_manifest.py(从 src/windscada/ui 源重建 zh 页 → 摘要 '

+ 11 - 0
scripts/rebuild_all.py

@@ -13,6 +13,7 @@ r"""一条命令重算全部 (2026-09-12) —— 把散在各脚本里的重算
     ② 三门台账    rebuild_from_raw.py                 报警/工单/油样
     ③ SCADA 侧    rebuild_from_raw.py --scada         10 个构建器, 逐台读 ~14 GB, 约 15 分钟
     ④ 月度派生件  windscada_monthly_build.py          能从 raw 重算的月表(与随包件逐值对齐才落盘)
+    ⑤a 台账维护   products_restore_missing.py --refresh  账实相符(谁在位、谁有生成端); 只记账不搬件
     ⑤ 反向呼应审计 products_reverse_audit.py --check   逐件回溯"输出→生成端→输入"(用户令 2026-09-17 #1: 运行期不从交付包补齐)
        ↑ 必须在 ⑥ 之前: 本体层的 populate/chain_ingest 要吃这些 L1 产物
     ⑥ 重启服务    guanlan.py stop/serve               chain_ingest 要从 /api/fleet 取链盘
@@ -74,6 +75,16 @@ def build_plan(a) -> list:
         plan.append(step_cmd('④b 振动侧摄入 (原始导出 → 窗索引/谱)',
                              [PY, 'scripts/vib_raw_build.py'] + (['--with-report'] if a.vib_report else []),
                              note='没有振动原始件时空跑属正常; 若解析出错会以非零退出 (不静默)'))
+    # ⑤a 台账维护 (用户令 2026-09-18: 页面只认"有来路的件" ⇒ 台账必须跟着重算一起更新)。
+    #    ★实逮: 2026-09-18 用户「清除产物」后从 raw 全量重算, `outputs/<场>/_provenance.json`
+    #      **整本丢了** —— 因为写它的唯一入口 (旧的第⑤步 products_restore_missing.py) 已按用户令
+    #      从链上拿下, 而新的第⑤步只**读**台账不写。后果: 门户/页面的「逐件来源」、反向呼应审计的
+    #      族匹配全部退化成"未归类", 而盘上 1,728 件其实件件有生成端。故把 `--refresh`(纯账实相符,
+    #      不搬任何随包件) 作为**独立一步**放回链上, 位置在审计之前。
+    plan.append(step_cmd('⑤a 逐件来源台账 (账实相符: 谁在位、谁有生成端)',
+                         [PY, 'scripts/products_restore_missing.py', '--refresh'],
+                         note='只维护台账, **不搬任何随包件**(用户令 2026-09-17: 运行期不从交付包补齐); '
+                              '盘上有而无人登记的件按族表归口, 归不上族的记 unregistered 并打出来'))
     # ⑤ 反向呼应审计 (用户令 2026-09-17 #1): **运行链不再从含产物的交付包补齐**。
     #    原先这一步跑 products_restore_missing.py —— 那等于"重算链依赖一个包", 与"目标机放数据自己重算"
     #    这条主线冲突: 交付包默认不含产物, 于是这一步在真机上要么报"找不到随包件来源"(rc=6), 要么

+ 28 - 3
scripts/windscada_serve.py

@@ -427,7 +427,17 @@ def dq_findings_view():
             age_basis=p2.get('age_basis_zh'), age_basis_en=p2.get('age_basis_en'),
         ))
     rows.sort(key=lambda x: (x['kind'] != 'data_quality', x['id']))
-    return dict(rows=rows, n=len(rows))
+    r = dict(rows=rows, n=len(rows))
+    if not rows:
+        # ★2026-09-18 用户令("页面只能基于输入数据重算")之后实逮: 清过产物再重算的机器上
+        #   `objects.json` 里 **0 条 finding/* 对象** ⇒ 本页只是"空", 不说原因就等于让人以为
+        #   "系统自查没问题"。这四族的生成端确实不在重算链上(见族表 guanlan/ontology 说明),
+        #   所以如实写清, 并明确"不用旧版产出补"。
+        r['note'] = ('本体对象库里没有 finding/* 对象 (四族: data_quality / caliber / system_coverage / '
+                     'tier_trend) ⇒ 本页**如实为空**, 不是"自查无发现"。这些 finding 的生成端不在重算链上'
+                     '(唯一写方 scripts/ingest_ops_2025.py 的输入 data/raw/工作库 不在现场数据里), '
+                     '运行期不从交付包补齐(用户令 2026-09-17); 要出件须研发补生成端。')
+    return r
 
 
 def _en_page(page):
@@ -527,9 +537,24 @@ _CJK = re.compile(r'[\u4e00-\u9fff]')
 
 def vibcms_results():
     """windcms 评估报告 → 结果层转录 (2026-08-28 用户令: 生接改融合·只显示结果·模型与计算隐藏).
-    取最新一期报告md; '融合级(模型)/CMS红黄'两列=模型输出, 不出结果层; 分析功能留独立cms."""
+    取最新一期报告md; '融合级(模型)/CMS红黄'两列=模型输出, 不出结果层; 分析功能留独立cms.
+
+    ★2026-09-18 用户令"页面只能基于输入数据重算、不许用旧版产出补"之后实逮: 清过产物再重算的机器上
+    `windcms/` 里没有 `报告_CMS振动状态评估报告_*.md`(它是振动六层链 report 步的出件, 而 model_run/
+    fusion 两步脚本仍未随包), 于是 `sorted(glob)[-1]` 抛 **IndexError: list index out of range**,
+    页面看到的是"windcms 报告解析失败: list index out of range" —— 与 `ProductsMissing` 的教训同一个病:
+    **缺产物被报成了程序坏了**。现在按缺产物如实回结构化的 `no_report`, 并写清它由谁生成、怎么补。
+    """
+    reps = sorted(ST.parent.glob('windcms/报告_CMS振动状态评估报告_*.md'))
+    if not reps:
+        return dict(date=None, overall='', action=[], grades=[], diff=dict(only_cms=[], only_handoff=[]),
+                    no_report=True,
+                    note_zh='本机没有 CMS 振动状态评估报告 (由振动六层链的 report 步生成; 本包六层链的 '
+                            'model_run/fusion 两步脚本未随包 ⇒ 该报告无法从 data/raw 重算)。'
+                            '如实为空, 不用旧版产出补。',
+                    err='无产物: windcms/报告_CMS振动状态评估报告_*.md 不在位')
     try:
-        rp = sorted(ST.parent.glob('windcms/报告_CMS振动状态评估报告_*.md'))[-1]
+        rp = reps[-1]
         md = rp.read_text(encoding='utf-8')       # 不写 encoding 会按系统 locale(cp936) 读 → 中文报告乱码
         date = rp.stem.rsplit('_', 1)[-1]
         overall = ''