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]
|