# ============================================================ # 统一 EvidenceItem 与可追溯链 v1(moduleId: core-evidence, 可重生 ✅) # plan.md §3.4 / 矩阵「结论、动作、报告可追溯和可复算」: # 把 run-id、算法版本、种子、知识版本、用户确认串成一条可复算链 # ============================================================ from __future__ import annotations # 前向类型引用 import hashlib # 链哈希 import json # 规范化序列化 from dataclasses import dataclass, field, asdict # 结构化记录 from typing import Any # 类型标注 # 证据引用语法::(与既有 schedule-version: 协议兼容) _EVIDENCE_KINDS = frozenset({ "schedule-version", "run", "algorithm", "seed", "knowledge", "confirm", "report", }) def evidence_ref(kind: str, ref_id: Any) -> str: """生成统一证据引用::(kind 白名单,防注入)。""" if kind not in _EVIDENCE_KINDS: raise ValueError(f"unknown evidence kind: {kind!r}") return f"{kind}:{ref_id}" def parse_evidence_ref(ref: str) -> tuple[str, str]: """解析证据引用为 (kind, id);非法引用抛 ValueError。""" if not isinstance(ref, str) or ":" not in ref: raise ValueError(f"malformed evidence ref: {ref!r}") kind, _, ref_id = ref.partition(":") if kind not in _EVIDENCE_KINDS: raise ValueError(f"unknown evidence kind: {kind!r}") return kind, ref_id @dataclass class EvidenceItem: """一条结构化证据:可追溯链的原子元素。 kind: 证据种类(schedule-version/run/algorithm/seed/knowledge/confirm/report) ref: 人类可读引用(如 run-) version: 版本号(算法/知识/种子版本) runId: 求解运行 ID engine: 引擎类型(RULE/CP/GA/HYBRID/EXTERNAL) seed: 随机种子(复算用) inputsHash: 输入包哈希(复算用) knowledgeVersion: 知识库版本(RAG 出处) confirmId: 用户确认令牌(P2/P3 门禁) note: 审批意见/备注 meta: 额外元数据 """ kind: str ref: str version: str | None = None runId: str | None = None engine: str | None = None seed: Any = None inputsHash: str | None = None knowledgeVersion: str | None = None confirmId: str | None = None note: str | None = None meta: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: if self.kind not in _EVIDENCE_KINDS: raise ValueError(f"unknown evidence kind: {self.kind!r}") def _canonical_json(value: Any) -> str: return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str) def trace_chain(items: list[EvidenceItem | dict[str, Any]]) -> dict[str, Any]: """把多条证据串成可复算链。 chainHash = SHA256(规范化 JSON 串联),任何元素/顺序变化都会断链。 返回 {items, chainHash, count}。 """ normalized: list[dict[str, Any]] = [] for item in items: if isinstance(item, EvidenceItem): normalized.append(asdict(item)) elif isinstance(item, dict): normalized.append(dict(item)) else: raise TypeError(f"evidence item must be EvidenceItem or dict, got {type(item)}") chain_hash = hashlib.sha256(_canonical_json(normalized).encode("utf-8")).hexdigest() return {"items": normalized, "chainHash": chain_hash, "count": len(normalized)} def verify_chain(items: list[EvidenceItem | dict[str, Any]], expected_hash: str) -> bool: """校验链哈希(可复算/可追溯)。""" return trace_chain(items)["chainHash"] == expected_hash def verify_audit_trace(events: list[dict[str, Any]]) -> dict[str, Any]: """审计链完整性校验(矩阵 114:trace 断链检测,重算链哈希逐条比对)。 遍历 ALGO_RUN 审计的 traceChainHash,重算链哈希逐条比对: - 结构校验:哈希格式(64 位十六进制)、traceCount 与 traceSummary 条数一致、 summary 条目 kind 白名单 + ref 非空; - 链重算:若审计 rationale 携带 canonical traceItems(ALGO_RUN 写入时落盘), 用 trace_chain 重算并与期望 traceChainHash 比对(verify_chain 语义); 旧记录(无 traceItems)仅结构校验,避免误报。 返回 {"ok", "checked", "broken"};broken 明细:{eventId, expected, actual, reason}。 """ import re as _re _hex64 = _re.compile(r"^[0-9a-f]{64}$") broken: list[dict[str, Any]] = [] checked = 0 for ev in events: rationale = ev.get("rationale") or {} expected = rationale.get("traceChainHash") if not expected: continue # 非 trace 审计(无链)跳过 checked += 1 event_id = ev.get("id") summary = rationale.get("traceSummary") count = rationale.get("traceCount") if not (isinstance(expected, str) and _hex64.match(expected)): broken.append({"eventId": event_id, "expected": str(expected), "actual": "invalid-hash", "reason": "bad-hash-format"}) continue if not isinstance(summary, list): broken.append({"eventId": event_id, "expected": str(count), "actual": "missing-summary", "reason": "missing-summary"}) continue if not isinstance(count, int) or count != len(summary): broken.append({"eventId": event_id, "expected": str(count), "actual": str(len(summary)), "reason": "count-mismatch"}) continue bad = next((it for it in summary if not isinstance(it, dict) or it.get("kind") not in _EVIDENCE_KINDS or not isinstance(it.get("ref"), str) or not it["ref"]), None) if bad is not None: broken.append({"eventId": event_id, "expected": "valid-kind/ref", "actual": str(bad), "reason": "bad-summary-entry"}) continue items = rationale.get("traceItems") # canonical 全量条目(写入时落盘) if isinstance(items, list) and items: actual = trace_chain(items)["chainHash"] # 重算链哈希(可复算) if actual != expected: broken.append({"eventId": event_id, "expected": expected, "actual": actual, "reason": "chain-hash-mismatch"}) return {"ok": len(broken) == 0, "checked": checked, "broken": broken} def schedule_version_item(version_id: Any, **kwargs: Any) -> EvidenceItem: """构造 schedule-version 证据项(与既有协议兼容)。""" return EvidenceItem(kind="schedule-version", ref=str(version_id), version=str(version_id), **kwargs)