#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""门户单文件 · 契约生成的结论段 (用户批 2026-09-06: 门户正文切到契约生成). 门户浅色壳由 Codex 侧生成 (本库生成器是深色版, 不可重生成),
因此采用**就地幂等注入**: 在
正文末尾插入/替换
(由 derived/portal_claims.json 渲染).
用法: python scripts/guanlan_portal_inject_claims.py [--out ] ; 幂等: 再跑只替换同 id 段. 出: 新文件 sha256."""
import hashlib, html, json, re, sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]; PC = ROOT / "outputs/rudong/guanlan/derived/portal_claims.json"
MARK_RE = re.compile(r'', re.S)
DC = ROOT / "outputs/rudong/guanlan/derived/detail_cards.json"; CONTRACT = ROOT / "outputs/rudong/guanlan/facts_contract_v0.json"
def section(pc: dict) -> str:
"""契约正文段 (用户裁 2026-09-07 "门户正文全部切到契约生成"): ① 可交付级表 ② 候选/参考/INSUFFICIENT 按系统分组 (折叠) ③ 撤回清单 (带日期). 全部由契约派生物渲染, 禁手改."""
cs = pc["contract_sha256"]; e = html.escape
rows = "".join(f'{e(p["claim_id"])} | {e(p["verdict"])} | {e(p["text"])} | {e(", ".join(p.get("systems") or []) or "—")} | {e(str(p.get("window_days") or "—"))} |
' for p in pc["paragraphs"])
cards = json.loads(DC.read_text(encoding="utf-8")) if DC.is_file() else {"cards": []}
assert cards.get("contract_sha256", cs) == cs, "detail_cards 与 portal_claims 契约 sha 不一致"
rest = [c for c in cards["cards"] if c["verdict"] not in ("定论", "准定论·预警")]
by_sys = {}
for c in rest: by_sys.setdefault(", ".join(c.get("systems") or []) or "跨系统/口径", []).append(c)
groups = "".join(f'{e(k)} · {len(v)} 条 ({", ".join(f"{vv} {sum(1 for c in v if c["verdict"] == vv)}" for vv in ("候选", "参考", "INSUFFICIENT") if any(c["verdict"] == vv for c in v))})
| claim | 裁决 | 结论 | 缺项 |
'
+ "".join(f'{e(c["claim_id"])} | {e(c["verdict"])} | {e(c["title"])} | {e(c.get("missing") or "—")} |
' for c in v) + '
' for k, v in sorted(by_sys.items(), key=lambda kv: -len(kv[1])))
con = json.loads(CONTRACT.read_text(encoding="utf-8")) if CONTRACT.is_file() else {"claims": []}
assert con.get("contract_sha256", cs) == cs, "契约文件与派生物 sha 不一致"
wd = [c for c in con["claims"] if c["verdict"] == "撤回"]
_t = lambda c: (c.get("display") or {}).get("title_zh_public") or c.get("title_public") or "" # 内部契约 / 云端脱敏面孔 (claims_public.json) 两种形态
withdrawn = "".join(f'{e(c["claim_id"])} {e(str(c.get("verdict_raw") or ""))[:40]} — {e(_t(c)[:110])}' for c in wd)
return (f'本场结论 · 契约生成
'
f'
定论与准定论·预警 ({len(pc["paragraphs"])} 条)
由结构化事实契约 {cs[:16]} 渲染 (共 {con.get("n_claims", "?")} 条); 数据、时间窗、结论只经契约更新, 本段禁手改 (release-manifest.content_contract)。
'
f'
'
f'
候选 · 参考 · 证据不足 ({len(rest)} 条, 按系统)
{groups}'
f'
已撤回 ({len(wd)} 条)
')
def inject(src: Path, out: Path):
t = src.read_text(encoding="utf-8"); sec = section(json.loads(PC.read_text(encoding="utf-8")))
if MARK_RE.search(t): t2 = MARK_RE.sub(lambda m: sec, t, count=1)
else:
m = re.search(r'(]*>)(.*?)(
\s*