191 lines
7.7 KiB
Python
191 lines
7.7 KiB
Python
# ============================================================
|
||
# 兜底验证器 v1(moduleId: core-fallback-verify, 可重生 ✅)
|
||
# 《Pi-Agent兜底能力详细方案》§4.1/§4.5 + GOAL-P2 交付 4 + P2-DESIGN §4:
|
||
# 兜底执行后的 world diff、验证规则(行数/数量对账)、报告生成。
|
||
# 铁律:报告里的每个数字都只许来自冻结快照(cp_before/cp_after 的
|
||
# world 深拷贝),绝不引用 Pi 报告文本或 Pi 自述。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
# 对账覆盖的业务主数据表(分表 diff 的固定口径)
|
||
_DIFF_TABLES = (
|
||
"salesOrders", "flexOrders", "materials", "flexMaterials",
|
||
"flexEquipment", "flexMolds", "flexOperations", "flexRoutings",
|
||
"flexBom", "productionOrders", "workOrders",
|
||
)
|
||
|
||
# 数量对账类规则的数据源字段(存在即计入 quantityDelta)
|
||
_QUANTITY_FIELDS = ("quantity", "stock")
|
||
|
||
|
||
def _canonical(item: Any) -> str:
|
||
"""条目的规范 JSON(排序键 + 紧凑分隔符),modified 判定与指纹共用口径。"""
|
||
return json.dumps(item, ensure_ascii=True, sort_keys=True,
|
||
separators=(",", ":"), default=str)
|
||
|
||
|
||
def _entry_key(item: dict) -> tuple:
|
||
"""条目身份键:数值 id 优先,其次 orderNo/code,最后整条 canonical(防无键表)。"""
|
||
if isinstance(item, dict):
|
||
if isinstance(item.get("id"), int):
|
||
return ("id", item["id"])
|
||
if item.get("orderNo"):
|
||
return ("orderNo", str(item["orderNo"]))
|
||
if item.get("code"):
|
||
return ("code", str(item["code"]))
|
||
return ("json", _canonical(item))
|
||
|
||
|
||
def _quantity_of(item: dict) -> float:
|
||
total = 0.0
|
||
for field in _QUANTITY_FIELDS:
|
||
value = item.get(field) if isinstance(item, dict) else None
|
||
if isinstance(value, (int, float)):
|
||
total += float(value)
|
||
return total
|
||
|
||
|
||
def world_diff(before: dict, after: dict) -> dict:
|
||
"""分表 diff:{table: {"added","removed","modified","quantityDelta"}}。
|
||
|
||
modified 判定 = 同身份键条目内容变化(canonical JSON 不等);
|
||
quantityDelta 对含 quantity/stock 字段的条目求和(added 减 removed)。
|
||
"""
|
||
diff: dict[str, dict] = {}
|
||
for table in _DIFF_TABLES:
|
||
before_rows = before.get(table) or []
|
||
after_rows = after.get(table) or []
|
||
before_map = {_entry_key(r): r for r in before_rows if isinstance(r, dict)}
|
||
after_map = {_entry_key(r): r for r in after_rows if isinstance(r, dict)}
|
||
added_keys = [k for k in after_map if k not in before_map]
|
||
removed_keys = [k for k in before_map if k not in after_map]
|
||
modified = sum(
|
||
1 for k in before_map.keys() & after_map.keys()
|
||
if _canonical(before_map[k]) != _canonical(after_map[k])
|
||
)
|
||
qty_delta = (
|
||
sum(_quantity_of(after_map[k]) for k in added_keys)
|
||
- sum(_quantity_of(before_map[k]) for k in removed_keys)
|
||
)
|
||
if added_keys or removed_keys or modified:
|
||
diff[table] = {
|
||
"added": len(added_keys),
|
||
"removed": len(removed_keys),
|
||
"modified": modified,
|
||
"quantityDelta": qty_delta,
|
||
}
|
||
return diff
|
||
|
||
|
||
def check_expectations(plan: dict, diff: dict) -> list[dict]:
|
||
"""逐条比对计划 expected(结构化字段)与实际 diff。
|
||
|
||
返回 [{"step","expect","actual","ok"} ...];容差 = 0——任一声明字段不等即 ok=False,
|
||
调用方据此把报告 verdict 判为 MISMATCH(显式,不圆场)。
|
||
"""
|
||
checks: list[dict] = []
|
||
for step in plan.get("steps") or []:
|
||
seq = step.get("seq")
|
||
for expect in step.get("expected") or []:
|
||
if not isinstance(expect, dict) or not expect.get("table"):
|
||
continue
|
||
table = str(expect["table"])
|
||
actual = (diff.get(table) or {}).copy()
|
||
actual.setdefault("added", 0)
|
||
actual.setdefault("removed", 0)
|
||
actual.setdefault("modified", 0)
|
||
ok = True
|
||
for field in ("added", "removed", "modified"):
|
||
if field in expect and expect[field] is not None \
|
||
and int(expect[field]) != int(actual.get(field) or 0):
|
||
ok = False
|
||
checks.append({
|
||
"step": seq,
|
||
"expect": {k: expect[k] for k in ("table", "added", "removed", "modified")
|
||
if k in expect},
|
||
"actual": {"table": table,
|
||
**{k: actual.get(k, 0) for k in ("added", "removed", "modified")}},
|
||
"ok": ok,
|
||
})
|
||
return checks
|
||
|
||
|
||
def diff_summary_lines(diff: dict) -> list[str]:
|
||
"""分表 diff 的人类可读摘要行(回复文案与报告共用)。"""
|
||
lines = []
|
||
for table, d in diff.items():
|
||
parts = []
|
||
if d["added"]:
|
||
parts.append(f"+{d['added']}")
|
||
if d["removed"]:
|
||
parts.append(f"-{d['removed']}")
|
||
if d["modified"]:
|
||
parts.append(f"~{d['modified']}")
|
||
line = f"{table} {'/'.join(parts)}"
|
||
if d.get("quantityDelta"):
|
||
line += f"(数量净变化 {d['quantityDelta']:g})"
|
||
lines.append(line)
|
||
return lines or ["(无业务主数据变化)"]
|
||
|
||
|
||
def build_report(run_dir: Path, plan: dict, *,
|
||
cp_before_id: str, cp_after_id: str,
|
||
before_world: dict, after_world: dict,
|
||
checks: list[dict]) -> Path:
|
||
"""生成 outbox/verify-report.md:计划摘要 / 分表 diff 表 / 对账结论 / 证据引用。
|
||
|
||
数字来源 = 两个冻结快照的 world(调用方保证传入的是 checkpoint 仓内深拷贝),
|
||
返回报告路径。verdict:全部对账通过 = PASS;任一不符 = MISMATCH(显式标注)。
|
||
"""
|
||
diff = world_diff(before_world, after_world)
|
||
verdict = "PASS" if all(c["ok"] for c in checks) else "MISMATCH"
|
||
|
||
lines = [
|
||
"# 兜底执行验证报告",
|
||
"",
|
||
f"- 运行:{plan.get('runId') or ''}",
|
||
f"- 场景:{plan.get('scenario') or ''} · 步骤数 {len(plan.get('steps') or [])}",
|
||
f"- 检查点:执行前 `{cp_before_id}` → 执行后 `{cp_after_id}`",
|
||
"",
|
||
"## 分表 diff(before → after)",
|
||
"",
|
||
"| 表 | 新增 | 移除 | 修改 | 数量净变化 |",
|
||
"|----|------|------|------|-----------|",
|
||
]
|
||
for table in _DIFF_TABLES:
|
||
d = diff.get(table)
|
||
if not d:
|
||
continue
|
||
lines.append(f"| {table} | {d['added']} | {d['removed']} | {d['modified']} "
|
||
f"| {d['quantityDelta']:g} |")
|
||
if not diff:
|
||
lines.append("| (无变化) | 0 | 0 | 0 | 0 |")
|
||
lines += ["", "## 对账结论", ""]
|
||
if checks:
|
||
for c in checks:
|
||
mark = "✅" if c["ok"] else "❌"
|
||
lines.append(f"- {mark} 步骤{c['step']} 期望 {json.dumps(c['expect'], ensure_ascii=False)}"
|
||
f" · 实际 {json.dumps(c['actual'], ensure_ascii=False)}")
|
||
else:
|
||
lines.append("- (计划未声明结构化预期,仅呈现实际 diff)")
|
||
lines += [
|
||
"",
|
||
f"**verdict: {verdict}**",
|
||
"",
|
||
f"本报告全部数字来自检查点 {cp_before_id} 与 {cp_after_id} 的冻结快照。",
|
||
]
|
||
out = Path(run_dir) / "outbox" / "verify-report.md"
|
||
out.parent.mkdir(parents=True, exist_ok=True)
|
||
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
return out
|
||
|
||
|
||
def report_fingerprint(text: str) -> str:
|
||
"""报告文本 sha256(审计 rationale 引用用,不落全文)。"""
|
||
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|