validate_source_inspector.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. #!/usr/bin/env python3
  2. """Read-only validator for every visible data-source Inspector in real runs."""
  3. from __future__ import annotations
  4. import argparse
  5. import hashlib
  6. import json
  7. import sys
  8. import urllib.error
  9. import urllib.parse
  10. import urllib.request
  11. from collections import Counter
  12. from concurrent.futures import ThreadPoolExecutor, as_completed
  13. from typing import Any
  14. HIDDEN_BUSINESS_KEYS = {"technical", "changes"}
  15. def request_json(base_url: str, path: str) -> tuple[int, Any]:
  16. url = f"{base_url.rstrip('/')}{path}"
  17. try:
  18. with urllib.request.urlopen(url, timeout=60) as response:
  19. return response.status, json.load(response)
  20. except urllib.error.HTTPError as exc:
  21. try:
  22. payload = json.load(exc)
  23. except Exception:
  24. payload = {"detail": str(exc)}
  25. return exc.code, payload
  26. def quote_ref(value: str) -> str:
  27. return urllib.parse.quote(value, safe="")
  28. def collect_detail_refs(value: Any) -> list[str]:
  29. found: list[str] = []
  30. def visit(node: Any) -> None:
  31. if isinstance(node, dict):
  32. for key, child in node.items():
  33. if key == "detailRef" and isinstance(child, str) and child:
  34. found.append(child)
  35. visit(child)
  36. elif isinstance(node, list):
  37. for child in node:
  38. visit(child)
  39. visit(value)
  40. return list(dict.fromkeys(found))
  41. def pointer_lookup(value: Any, pointer: str) -> tuple[bool, Any]:
  42. if not pointer:
  43. return True, value
  44. current = value
  45. for raw in pointer.lstrip("/").split("/"):
  46. part = raw.replace("~1", "/").replace("~0", "~")
  47. if isinstance(current, list):
  48. try:
  49. current = current[int(part)]
  50. except (ValueError, IndexError):
  51. return False, None
  52. elif isinstance(current, dict) and part in current:
  53. current = current[part]
  54. else:
  55. return False, None
  56. return True, current
  57. def digest(value: Any) -> str:
  58. serialized = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
  59. return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
  60. def validate_binding(binding: dict[str, Any], sources: dict[str, Any]) -> list[str]:
  61. errors: list[str] = []
  62. source_id = str(binding.get("sourceId") or "")
  63. selector = binding.get("selector") or {}
  64. resolution = binding.get("resolution")
  65. source = sources.get(source_id)
  66. if source is None:
  67. return [f"binding {binding.get('id')} 的 sourceId {source_id} 未返回"]
  68. if resolution == "resolved" and source.get("resultState") in {"missing", "unavailable"}:
  69. errors.append(
  70. f"binding {binding.get('id')} 标记 resolved,但来源状态为 {source.get('resultState')}"
  71. )
  72. if selector.get("kind") == "unresolved":
  73. code = str(selector.get("code") or "")
  74. reason = str(selector.get("reason") or "")
  75. if not reason:
  76. errors.append(f"binding {binding.get('id')} 的 unresolved 原因为空")
  77. if not code:
  78. errors.append(f"binding {binding.get('id')} 的 unresolved 缺少原因码")
  79. if code in {"binding-map-missing", "field-path-mismatch"}:
  80. errors.append(f"binding {binding.get('id')} 存在可视化映射错误 {code}")
  81. elif code not in {
  82. "source-record-not-saved",
  83. "event-body-not-saved",
  84. "unsafe-association",
  85. "reliable-log-anchor-unavailable",
  86. }:
  87. errors.append(f"binding {binding.get('id')} 使用了未允许的缺失原因码 {code}")
  88. if code in {"source-record-not-saved", "event-body-not-saved"} and source.get("resultState") not in {"missing", "unavailable"}:
  89. errors.append(
  90. f"binding {binding.get('id')} 声称原始记录未保存,"
  91. f"但来源状态为 {source.get('resultState')}"
  92. )
  93. return errors
  94. if selector.get("kind") == "json-pointer":
  95. found, _selected = pointer_lookup(source.get("rawRecord"), selector.get("path") or "")
  96. if not found:
  97. errors.append(f"binding {binding.get('id')} 无法反向定位 {selector.get('path')}")
  98. elif selector.get("kind") == "text-span":
  99. found, text = pointer_lookup(source.get("rawRecord"), selector.get("path") or "")
  100. if not found or not isinstance(text, str):
  101. errors.append(f"binding {binding.get('id')} 的原文字段不存在")
  102. else:
  103. characters = list(text)
  104. start = int(selector.get("startCodePoint") or 0)
  105. end = int(selector.get("endCodePoint") or 0)
  106. if "".join(characters[start:end]) != selector.get("exactText"):
  107. errors.append(f"binding {binding.get('id')} 的原文区间不匹配")
  108. if digest(text) != selector.get("sourceDigest"):
  109. errors.append(f"binding {binding.get('id')} 的 sourceDigest 不匹配")
  110. elif selector.get("kind") == "members":
  111. members = selector.get("members") or []
  112. if not members:
  113. errors.append(f"binding {binding.get('id')} 的聚合成员为空")
  114. for member in members:
  115. errors.extend(validate_binding({
  116. "id": f"{binding.get('id')}:member",
  117. "sourceId": member.get("sourceId"),
  118. "selector": member.get("selector"),
  119. "resolution": "resolved",
  120. }, sources))
  121. return errors
  122. def selected_value(binding: dict[str, Any], source: dict[str, Any]) -> tuple[bool, Any]:
  123. selector = binding.get("selector") or {}
  124. kind = selector.get("kind")
  125. raw = source.get("rawRecord")
  126. if kind == "json-pointer":
  127. return pointer_lookup(raw, str(selector.get("path") or ""))
  128. if kind == "whole-record":
  129. return True, raw
  130. if kind == "text-span":
  131. found, text = pointer_lookup(raw, str(selector.get("path") or ""))
  132. if not found or not isinstance(text, str):
  133. return False, None
  134. start = int(selector.get("startCodePoint") or 0)
  135. end = int(selector.get("endCodePoint") or 0)
  136. return True, "".join(list(text)[start:end])
  137. return False, None
  138. def equivalent(left: Any, right: Any) -> bool:
  139. return json.dumps(left, ensure_ascii=False, sort_keys=True, default=str) == json.dumps(
  140. right, ensure_ascii=False, sort_keys=True, default=str
  141. )
  142. def validate_business_value(
  143. binding: dict[str, Any], source: dict[str, Any], business_value: Any
  144. ) -> list[str]:
  145. """Direct bindings must select the exact value shown in column one."""
  146. if (binding.get("transform") or {}).get("kind") != "direct":
  147. return []
  148. found, selected = selected_value(binding, source)
  149. if found and not equivalent(selected, business_value):
  150. return [
  151. f"binding {binding.get('id')} 声明直接展示,但 selector 值与业务项不一致"
  152. ]
  153. return []
  154. def validate_empty_sources(sources: dict[str, Any]) -> list[str]:
  155. errors: list[str] = []
  156. for source_id, source in sources.items():
  157. if source.get("resultState") != "empty":
  158. continue
  159. kind = source.get("kind")
  160. locator = source.get("locator") or {}
  161. raw = source.get("rawRecord")
  162. if kind == "database" and not (
  163. raw == [] and int(locator.get("resultCount") or 0) == 0
  164. ):
  165. errors.append(f"空结果来源 {source_id} 无法证明数据库查询返回 0 条")
  166. if kind == "runtime-event" and not isinstance(raw, dict):
  167. errors.append(f"空结果来源 {source_id} 没有保存运行 Event")
  168. return errors
  169. def validate_view(payload: dict[str, Any]) -> tuple[Counter, list[str]]:
  170. counts: Counter = Counter()
  171. errors: list[str] = []
  172. if payload.get("schemaVersion") != "inspector-source-v2":
  173. return counts, ["schemaVersion 不是 inspector-source-v2"]
  174. business = payload.get("businessProjection") or {}
  175. sources = payload.get("sources") or {}
  176. for source in sources.values():
  177. counts[f"source:{source.get('resultState') or 'present'}"] += 1
  178. actual_selectors: list[str] = []
  179. for module in payload.get("modules") or []:
  180. bindings = {item.get("id"): item for item in module.get("bindings") or []}
  181. for row in module.get("rows") or []:
  182. counts["rows"] += 1
  183. business_selector = str(row.get("businessSelector") or "")
  184. actual_selectors.append(business_selector)
  185. found, business_value = pointer_lookup(business, business_selector)
  186. if not found:
  187. errors.append(f"业务行 {row.get('id')} 无法定位 {row.get('businessSelector')}")
  188. ids = row.get("bindingIds") or []
  189. if not ids:
  190. errors.append(f"业务行 {row.get('id')} 没有 binding")
  191. for binding_id in ids:
  192. binding = bindings.get(binding_id)
  193. if binding is None:
  194. errors.append(f"业务行 {row.get('id')} 引用了不存在的 binding {binding_id}")
  195. continue
  196. counts[str(binding.get("resolution") or "unknown")] += 1
  197. errors.extend(validate_binding(binding, sources))
  198. source = sources.get(str(binding.get("sourceId") or "")) or {}
  199. errors.extend(validate_business_value(binding, source, business_value))
  200. errors.extend(validate_log_sources(sources))
  201. errors.extend(validate_empty_sources(sources))
  202. expected_selectors = visible_business_selectors(business)
  203. missing_selectors = [value for value in expected_selectors if value not in actual_selectors]
  204. if missing_selectors:
  205. errors.append(f"旧业务 Inspector 的可见项没有生成对照行: {missing_selectors}")
  206. return counts, errors
  207. def validate_log_sources(sources: dict[str, Any]) -> list[str]:
  208. errors: list[str] = []
  209. for source_id, source in sources.items():
  210. if source.get("kind") != "log-anchor":
  211. continue
  212. locator = source.get("locator") or {}
  213. event_id = locator.get("eventId")
  214. if event_id is None:
  215. errors.append(f"日志来源 {source_id} 没有 eventId")
  216. continue
  217. event = sources.get(f"event:{event_id}") or {}
  218. event_raw = event.get("rawRecord") or {}
  219. expected_msg_id = str(event_raw.get("msg_id") or locator.get("msgId") or "")
  220. if not expected_msg_id:
  221. errors.append(f"Event {event_id} 没有 msg_id,不应附带日志来源")
  222. continue
  223. raw_log = source.get("rawRecord")
  224. rendered = raw_log if isinstance(raw_log, str) else json.dumps(raw_log, ensure_ascii=False)
  225. if f"msg_id={expected_msg_id}" not in rendered and f"msg_id:{expected_msg_id}" not in rendered:
  226. errors.append(f"日志来源 {source_id} 与 Event {event_id} 的 msg_id 不一致")
  227. return errors
  228. def visible_business_selectors(projection: dict[str, Any]) -> list[str]:
  229. """Mirror the readable Inspector's visible leaves, including fixed empty notices."""
  230. selectors: list[str] = []
  231. if projection.get("question") not in (None, ""):
  232. selectors.append("/question")
  233. for section_index, section in enumerate(projection.get("businessSections") or []):
  234. if not isinstance(section, dict):
  235. continue
  236. root = f"/businessSections/{section_index}"
  237. if isinstance(section.get("items"), list):
  238. selectors.extend(f"{root}/items/{index}" for index, _ in enumerate(section["items"]))
  239. elif section.get("content") is not None:
  240. selectors.append(f"{root}/content")
  241. for block_index, block in enumerate(projection.get("blocks") or []):
  242. if not isinstance(block, dict):
  243. continue
  244. root = f"/blocks/{block_index}"
  245. kind = str(block.get("type") or "summary")
  246. if kind in {"summary", "notice"}:
  247. selectors.append(f"{root}/value")
  248. elif kind == "items":
  249. selectors.extend(f"{root}/items/{index}" for index, _ in enumerate(block.get("items") or []))
  250. elif kind == "implementation-plan":
  251. if block.get("mode") is not None:
  252. selectors.append(f"{root}/mode")
  253. for route_index, route in enumerate(block.get("routes") or []):
  254. if not isinstance(route, dict):
  255. continue
  256. for key in ("target", "method", "action", "emphasis", "pathType"):
  257. if route.get(key) is not None:
  258. selectors.append(f"{root}/routes/{route_index}/{key}")
  259. if block.get("reasoning") is not None:
  260. selectors.append(f"{root}/reasoning")
  261. elif kind == "creative-process":
  262. for step_index, step in enumerate(block.get("steps") or []):
  263. if not isinstance(step, dict):
  264. continue
  265. for key in ("action", "summary", "plan", "reasoning"):
  266. if step.get(key) is not None:
  267. selectors.append(f"{root}/steps/{step_index}/{key}")
  268. elif kind == "evaluation-branches":
  269. for branch_index, branch in enumerate(block.get("branches") or []):
  270. if not isinstance(branch, dict):
  271. continue
  272. for key in ("conclusion", "achievements", "problems", "evidence"):
  273. if branch.get(key) not in (None, [], ""):
  274. selectors.append(f"{root}/branches/{branch_index}/{key}")
  275. elif kind in {"evaluation-matrix", "comparison"}:
  276. selectors.extend(f"{root}/rows/{index}" for index, _ in enumerate(block.get("rows") or []))
  277. elif kind == "artifact":
  278. selectors.extend(f"{root}/refs/{index}" for index, _ in enumerate(block.get("refs") or []))
  279. else:
  280. selectors.append(root)
  281. if projection.get("detailKind") == "retrieval-event":
  282. conditions = projection.get("queryConditions") or []
  283. selectors.extend(
  284. [f"/queryConditions/{index}/value" for index, _ in enumerate(conditions)]
  285. or ["/queryConditions"]
  286. )
  287. outcome = projection.get("outcome") or {}
  288. selectors.extend(
  289. f"/outcome/{key}"
  290. for key in ("message", "count", "errorKind")
  291. if outcome.get(key) is not None
  292. )
  293. selectors.extend(f"/items/{index}" for index, _ in enumerate(projection.get("items") or []))
  294. completeness = projection.get("completeness") or {}
  295. if completeness.get("bodyAvailable") is False:
  296. selectors.append("/completeness/bodyAvailable")
  297. if completeness.get("truncated"):
  298. selectors.append(
  299. "/completeness/omittedCharacters"
  300. if completeness.get("omittedCharacters") is not None
  301. else "/completeness/truncated"
  302. )
  303. if isinstance(projection.get("activities"), list):
  304. activities = projection.get("activities") or []
  305. selectors.extend(
  306. [f"/activities/{index}" for index, _ in enumerate(activities)]
  307. or ["/missingActivityNotice" if projection.get("missingActivityNotice") else "/activities"]
  308. )
  309. return selectors
  310. def validate_detail(base_url: str, run_id: int, detail_ref: str) -> tuple[str, Counter, list[str], bool]:
  311. encoded = quote_ref(detail_ref)
  312. status, payload = request_json(
  313. base_url, f"/api/script-builds/{run_id}/inspector-view/{encoded}"
  314. )
  315. if status == 404:
  316. return detail_ref, Counter(), [], False
  317. if status != 200:
  318. return detail_ref, Counter(), [f"inspector-view HTTP {status}: {payload}"], True
  319. counts, errors = validate_view(payload)
  320. old_status, old = request_json(
  321. base_url, f"/api/script-builds/{run_id}/activities/{encoded}"
  322. )
  323. if old_status == 200:
  324. expected = {key: value for key, value in old.items() if key not in HIDDEN_BUSINESS_KEYS}
  325. if payload.get("businessProjection") != expected:
  326. errors.append("第一列与旧业务 Inspector 投影不一致")
  327. return detail_ref, counts, errors, True
  328. def validate_run(base_url: str, run_id: int, *, workers: int) -> dict[str, Any]:
  329. status, execution = request_json(base_url, f"/api/script-builds/{run_id}/execution-view")
  330. if status != 200:
  331. return {"runId": run_id, "fatal": f"execution-view HTTP {status}: {execution}"}
  332. refs = collect_detail_refs(execution)
  333. totals: Counter = Counter()
  334. failures: list[dict[str, Any]] = []
  335. checked = 0
  336. with ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
  337. futures = {
  338. executor.submit(validate_detail, base_url, run_id, detail_ref): detail_ref
  339. for detail_ref in refs
  340. }
  341. for future in as_completed(futures):
  342. detail_ref, counts, errors, did_check = future.result()
  343. checked += int(did_check)
  344. totals.update(counts)
  345. if errors:
  346. failures.append({"detailRef": detail_ref, "errors": errors})
  347. failures.sort(key=lambda item: refs.index(item["detailRef"]))
  348. return {
  349. "runId": run_id,
  350. "visibleRefs": len(refs),
  351. "checkedInspectors": checked,
  352. "counts": dict(totals),
  353. "failures": failures,
  354. }
  355. def main() -> int:
  356. parser = argparse.ArgumentParser()
  357. parser.add_argument("--base-url", default="http://127.0.0.1:8788")
  358. parser.add_argument("--runs", type=int, nargs="+", default=[422, 443, 444, 446])
  359. parser.add_argument("--output")
  360. parser.add_argument("--workers", type=int, default=8)
  361. args = parser.parse_args()
  362. results = [validate_run(args.base_url, run_id, workers=args.workers) for run_id in args.runs]
  363. report = {"baseUrl": args.base_url, "runs": results}
  364. rendered = json.dumps(report, ensure_ascii=False, indent=2)
  365. print(rendered)
  366. if args.output:
  367. with open(args.output, "w", encoding="utf-8") as handle:
  368. handle.write(rendered + "\n")
  369. return 1 if any(result.get("fatal") or result.get("failures") for result in results) else 0
  370. if __name__ == "__main__":
  371. sys.exit(main())