validation.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826
  1. from __future__ import annotations
  2. import json
  3. from collections import Counter
  4. from typing import Any
  5. from content_agent.constants import RUNTIME_RECORD_SCHEMA_VERSION, RUNTIME_SCHEMA_VERSION
  6. from content_agent.findings import fail as _fail
  7. from content_agent.integrations.runtime_files import RUNTIME_FILENAMES
  8. from content_agent.interfaces import RuntimeFileStore
  9. JSON_FILES = {
  10. "source_context.json",
  11. "pattern_seed_pack.json",
  12. "final_output.json",
  13. "strategy_review.json",
  14. }
  15. JSONL_FILES = set(RUNTIME_FILENAMES) - JSON_FILES
  16. POLICY_RUN_FILES = {
  17. "pattern_seed_pack.json",
  18. "search_queries.jsonl",
  19. "discovered_content_items.jsonl",
  20. "content_media_records.jsonl",
  21. "pattern_recall_evidence.jsonl",
  22. "rule_decisions.jsonl",
  23. "walk_actions.jsonl",
  24. "run_events.jsonl",
  25. "source_path_records.jsonl",
  26. "search_clues.jsonl",
  27. "final_output.json",
  28. "strategy_review.json",
  29. }
  30. RAW_PAYLOAD_FILES = {
  31. "search_queries.jsonl",
  32. "discovered_content_items.jsonl",
  33. "content_media_records.jsonl",
  34. "pattern_recall_evidence.jsonl",
  35. "rule_decisions.jsonl",
  36. "walk_actions.jsonl",
  37. "run_events.jsonl",
  38. "source_path_records.jsonl",
  39. "search_clues.jsonl",
  40. "strategy_review.json",
  41. }
  42. FORBIDDEN_PAYLOAD_KEYS = {
  43. "password",
  44. "token",
  45. "access_token",
  46. "refresh_token",
  47. "api_key",
  48. "apikey",
  49. "secret",
  50. "dsn",
  51. "authorization",
  52. "cookie",
  53. "session",
  54. "credential",
  55. }
  56. DECISION_ACTIONS = {
  57. "ADD_TO_CONTENT_POOL",
  58. "KEEP_CONTENT_FOR_REVIEW",
  59. "REJECT_CONTENT",
  60. }
  61. EFFECT_STATUSES = {"success", "pending", "failed", "rule_blocked"}
  62. WALK_STATUSES = {"success", "pending", "failed", "skipped", "rule_blocked"}
  63. DECISION_REPLAY_REQUIRED_FIELDS = {
  64. "policy_bundle_hash",
  65. "rule_pack_id",
  66. "rule_pack_version",
  67. "dispatch_id",
  68. "strategy_version",
  69. }
  70. SOURCE_EVIDENCE_FIELDS = {
  71. "source_kind",
  72. "pattern_source_system",
  73. "case_id_type",
  74. "source_post_id",
  75. "pattern_execution_id",
  76. "mining_config_id",
  77. "itemset_ids",
  78. "itemset_items",
  79. "support",
  80. "absolute_support",
  81. "matched_post_ids",
  82. "video_ids",
  83. "case_ids",
  84. "seed_terms",
  85. "discovery_start_source",
  86. "previous_discovery_step",
  87. "origin_path_id",
  88. "run_id",
  89. "policy_run_id",
  90. "discovered_platform_content_id",
  91. "source_certainty",
  92. "validation_status",
  93. }
  94. def validate_run(run_id: str, runtime: RuntimeFileStore) -> dict[str, Any]:
  95. findings: list[dict[str, Any]] = []
  96. data = _load_files(run_id, runtime, findings)
  97. if any(finding["level"] == "fail" for finding in findings):
  98. return _result(run_id, findings)
  99. _check_run_ids(run_id, data, findings)
  100. _check_schema_versions(data, findings)
  101. _check_policy_run_ids(data, findings)
  102. _check_raw_payloads(data, findings)
  103. _check_unique_ids(data, findings)
  104. _check_references(data, findings)
  105. _check_pattern_recall_evidence(data, findings)
  106. _check_source_evidence(data, findings)
  107. _check_source_paths(data, findings)
  108. _check_summary(data, findings)
  109. _check_completeness(data, findings)
  110. return _result(run_id, findings)
  111. def compute_final_output_completeness(
  112. final_output: dict[str, Any],
  113. decisions: list[dict[str, Any]],
  114. source_path_records: list[dict[str, Any]],
  115. ) -> dict[str, Any]:
  116. findings: list[str] = []
  117. decision_ids = {decision.get("decision_id") for decision in decisions}
  118. path_ids = {path.get("source_path_record_id") for path in source_path_records}
  119. decision_asset_paths = {
  120. (path.get("decision_id"), path.get("to_node_id")): path
  121. for path in source_path_records
  122. if path.get("source_path_type") == "decision_to_asset"
  123. }
  124. for asset in final_output.get("content_assets", []):
  125. decision_id = asset.get("decision_id")
  126. content_id = asset.get("platform_content_id")
  127. asset_path_ids = set(asset.get("source_path_record_ids", []))
  128. if decision_id not in decision_ids:
  129. findings.append(f"content_asset missing decision: {content_id}")
  130. missing_paths = sorted(asset_path_ids - path_ids)
  131. if missing_paths:
  132. findings.append(f"content_asset missing paths: {content_id}")
  133. decision_asset = decision_asset_paths.get((decision_id, content_id))
  134. if not decision_asset:
  135. findings.append(f"content_asset missing decision_to_asset: {content_id}")
  136. elif decision_asset.get("source_path_record_id") not in asset_path_ids:
  137. findings.append(f"content_asset omits decision_to_asset: {content_id}")
  138. for record in final_output.get("review_records", []):
  139. if record.get("decision_id") not in decision_ids:
  140. findings.append(f"review_record missing decision: {record.get('platform_content_id')}")
  141. if set(record.get("source_path_record_ids", [])) - path_ids:
  142. findings.append(f"review_record missing paths: {record.get('platform_content_id')}")
  143. for record in final_output.get("reject_records", []):
  144. if record.get("decision_id") not in decision_ids:
  145. findings.append(f"reject_record missing decision: {record.get('decision_target_id')}")
  146. final_decision_ids = {
  147. record.get("decision_id") for record in final_output.get("decision_records", [])
  148. }
  149. if decision_ids - final_decision_ids:
  150. findings.append("decision_records incomplete")
  151. for author_asset in final_output.get("author_assets", []):
  152. if set(author_asset.get("decision_ids", [])) - decision_ids:
  153. findings.append(f"author_asset missing decisions: {author_asset.get('author_asset_id')}")
  154. if set(author_asset.get("source_path_record_ids", [])) - path_ids:
  155. findings.append(f"author_asset missing paths: {author_asset.get('author_asset_id')}")
  156. evidence_refs = author_asset.get("evidence_refs") or {}
  157. if evidence_refs.get("decision_ids") != author_asset.get("decision_ids"):
  158. findings.append(f"author_asset evidence incomplete: {author_asset.get('author_asset_id')}")
  159. required_sections = [
  160. "content_assets",
  161. "author_assets",
  162. "review_records",
  163. "decision_records",
  164. "search_clues",
  165. "reject_records",
  166. "summary",
  167. ]
  168. missing_sections = [section for section in required_sections if section not in final_output]
  169. findings.extend(f"final_output missing section: {section}" for section in missing_sections)
  170. complete = not findings
  171. return {
  172. "validation_status": "pass" if complete else "fail",
  173. "run_path_complete": complete,
  174. "trace_complete": complete,
  175. "findings_summary": findings,
  176. }
  177. def _load_files(
  178. run_id: str,
  179. runtime: RuntimeFileStore,
  180. findings: list[dict[str, Any]],
  181. ) -> dict[str, Any]:
  182. run_dir = runtime.run_dir(run_id)
  183. data: dict[str, Any] = {}
  184. for filename in RUNTIME_FILENAMES:
  185. path = run_dir / filename
  186. if not path.exists():
  187. _fail(findings, "file_missing", f"missing runtime file: {filename}")
  188. continue
  189. try:
  190. if filename in JSON_FILES:
  191. data[filename] = json.loads(path.read_text(encoding="utf-8"))
  192. else:
  193. data[filename] = [
  194. json.loads(line)
  195. for line in path.read_text(encoding="utf-8").splitlines()
  196. if line.strip()
  197. ]
  198. except json.JSONDecodeError as exc:
  199. _fail(findings, "json_parse_failed", f"{filename} cannot parse: {exc}")
  200. return data
  201. def _check_run_ids(run_id: str, data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  202. for filename, value in data.items():
  203. rows = value if isinstance(value, list) else [value]
  204. for row in rows:
  205. if isinstance(row, dict) and row.get("run_id") != run_id:
  206. _fail(findings, "run_id_mismatch", f"{filename} has mismatched run_id")
  207. def _check_schema_versions(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  208. for filename, value in data.items():
  209. if filename in JSON_FILES:
  210. if isinstance(value, dict) and value.get("schema_version") != RUNTIME_SCHEMA_VERSION:
  211. _fail(findings, "schema_version_missing", f"{filename} has bad schema_version")
  212. continue
  213. rows = value if isinstance(value, list) else []
  214. for row in rows:
  215. if row.get("record_schema_version") != RUNTIME_RECORD_SCHEMA_VERSION:
  216. _fail(
  217. findings,
  218. "record_schema_version_missing",
  219. f"{filename} has bad record_schema_version",
  220. )
  221. def _check_policy_run_ids(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  222. if "policy_run_id" in data.get("source_context.json", {}):
  223. _fail(findings, "policy_run_id_unexpected", "source_context.json must stay run-level")
  224. policy_run_id = data.get("pattern_seed_pack.json", {}).get("policy_run_id")
  225. if not policy_run_id:
  226. _fail(findings, "policy_run_id_missing", "pattern_seed_pack.json missing policy_run_id")
  227. return
  228. for filename in POLICY_RUN_FILES:
  229. value = data.get(filename)
  230. rows = value if isinstance(value, list) else [value]
  231. for row in rows:
  232. if isinstance(row, dict) and row.get("policy_run_id") != policy_run_id:
  233. _fail(
  234. findings,
  235. "policy_run_id_mismatch",
  236. f"{filename} has mismatched policy_run_id",
  237. )
  238. def _check_raw_payloads(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  239. for filename in RAW_PAYLOAD_FILES:
  240. value = data.get(filename)
  241. rows = value if isinstance(value, list) else [value]
  242. for row in rows:
  243. if not isinstance(row, dict):
  244. continue
  245. raw_payload = row.get("raw_payload")
  246. if not isinstance(raw_payload, dict) or not raw_payload:
  247. _fail(findings, "raw_payload_missing", f"{filename} missing raw_payload")
  248. continue
  249. forbidden_paths = _find_forbidden_payload_keys(raw_payload)
  250. if forbidden_paths:
  251. _fail(
  252. findings,
  253. "raw_payload_forbidden_key",
  254. f"{filename} raw_payload contains forbidden keys: {forbidden_paths}",
  255. )
  256. def _find_forbidden_payload_keys(value: Any, prefix: str = "raw_payload") -> list[str]:
  257. if isinstance(value, dict):
  258. paths: list[str] = []
  259. for key, child in value.items():
  260. child_path = f"{prefix}.{key}"
  261. if str(key).lower() in FORBIDDEN_PAYLOAD_KEYS:
  262. paths.append(child_path)
  263. paths.extend(_find_forbidden_payload_keys(child, child_path))
  264. return paths
  265. if isinstance(value, list):
  266. paths = []
  267. for index, child in enumerate(value):
  268. paths.extend(_find_forbidden_payload_keys(child, f"{prefix}[{index}]"))
  269. return paths
  270. return []
  271. def _check_unique_ids(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  272. checks = [
  273. ("search_queries.jsonl", "search_query_id"),
  274. ("discovered_content_items.jsonl", "content_discovery_id"),
  275. ("discovered_content_items.jsonl", "platform_content_id"),
  276. ("pattern_recall_evidence.jsonl", "recall_evidence_id"),
  277. ("rule_decisions.jsonl", "decision_id"),
  278. ("rule_decisions.jsonl", "decision_target_id"),
  279. ("walk_actions.jsonl", "walk_action_id"),
  280. ("source_path_records.jsonl", "source_path_record_id"),
  281. ]
  282. for filename, field in checks:
  283. values = [row.get(field) for row in data.get(filename, [])]
  284. duplicates = [value for value, count in Counter(values).items() if value and count > 1]
  285. if duplicates:
  286. _fail(findings, "duplicate_id", f"{filename}.{field} duplicates: {duplicates}")
  287. def _check_references(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  288. search_query_ids = {row["search_query_id"] for row in data.get("search_queries.jsonl", [])}
  289. items = data.get("discovered_content_items.jsonl", [])
  290. content_ids = {row["platform_content_id"] for row in items}
  291. decisions = data.get("rule_decisions.jsonl", [])
  292. decision_ids = {row["decision_id"] for row in decisions}
  293. walk_action_ids = {row["walk_action_id"] for row in data.get("walk_actions.jsonl", [])}
  294. path_ids = {row["source_path_record_id"] for row in data.get("source_path_records.jsonl", [])}
  295. for item in items:
  296. if item.get("search_query_id") not in search_query_ids:
  297. _fail(
  298. findings,
  299. "missing_search_query_ref",
  300. f"content item has unknown search_query_id: {item.get('search_query_id')}",
  301. )
  302. for media in data.get("content_media_records.jsonl", []):
  303. if not media.get("platform"):
  304. _fail(findings, "platform_missing", "content_media_records.jsonl missing platform")
  305. if media.get("platform_content_id") not in content_ids:
  306. _fail(
  307. findings,
  308. "missing_content_ref",
  309. f"media has unknown platform_content_id: {media.get('platform_content_id')}",
  310. )
  311. for evidence in data.get("pattern_recall_evidence.jsonl", []):
  312. if evidence.get("platform_content_id") not in content_ids:
  313. _fail(
  314. findings,
  315. "missing_content_ref",
  316. f"recall evidence has unknown platform_content_id: {evidence.get('platform_content_id')}",
  317. )
  318. for decision in decisions:
  319. if decision.get("decision_target_id") not in content_ids:
  320. _fail(
  321. findings,
  322. "missing_content_ref",
  323. f"decision has unknown decision_target_id: {decision.get('decision_target_id')}",
  324. )
  325. if decision.get("decision_action") not in DECISION_ACTIONS:
  326. _fail(
  327. findings,
  328. "bad_decision_action",
  329. f"unsupported decision_action: {decision.get('decision_action')}",
  330. )
  331. if decision.get("search_query_effect_status") not in EFFECT_STATUSES:
  332. _fail(
  333. findings,
  334. "bad_effect_status",
  335. f"unsupported decision effect status: {decision.get('search_query_effect_status')}",
  336. )
  337. replay_data = decision.get("decision_replay_data") or {}
  338. missing_replay_fields = [
  339. field for field in DECISION_REPLAY_REQUIRED_FIELDS if not replay_data.get(field)
  340. ]
  341. if missing_replay_fields:
  342. _fail(
  343. findings,
  344. "decision_replay_incomplete",
  345. f"decision {decision.get('decision_id')} missing replay fields: {missing_replay_fields}",
  346. )
  347. for clue in data.get("search_clues.jsonl", []):
  348. if clue.get("search_query_effect_status") not in EFFECT_STATUSES:
  349. _fail(
  350. findings,
  351. "bad_effect_status",
  352. f"unsupported query effect status: {clue.get('search_query_effect_status')}",
  353. )
  354. if not clue.get("query_aggregation_id"):
  355. _fail(
  356. findings,
  357. "missing_query_aggregation_id",
  358. f"search clue missing query_aggregation_id: {clue.get('clue_id')}",
  359. )
  360. for action in data.get("walk_actions.jsonl", []):
  361. missing_action_fields = [
  362. field
  363. for field in ["walk_action_id", "edge_id", "walk_action", "walk_status"]
  364. if not action.get(field)
  365. ]
  366. if missing_action_fields:
  367. _fail(
  368. findings,
  369. "walk_action_incomplete",
  370. f"walk action missing fields: {missing_action_fields}",
  371. )
  372. if action.get("walk_status") not in WALK_STATUSES:
  373. _fail(
  374. findings,
  375. "bad_walk_status",
  376. f"unsupported walk_status: {action.get('walk_status')}",
  377. )
  378. decision_id = action.get("decision_id")
  379. if decision_id and decision_id not in decision_ids:
  380. _fail(findings, "missing_decision_ref", f"walk action has unknown decision_id: {decision_id}")
  381. for path in data.get("source_path_records.jsonl", []):
  382. decision_id = path.get("decision_id")
  383. if decision_id and decision_id not in decision_ids:
  384. _fail(findings, "missing_decision_ref", f"path has unknown decision_id: {decision_id}")
  385. walk_action_id = (path.get("raw_payload") or {}).get("walk_action_id") or path.get("walk_action_id")
  386. if walk_action_id and walk_action_id not in walk_action_ids:
  387. _fail(
  388. findings,
  389. "missing_walk_action_ref",
  390. f"path has unknown walk_action_id: {walk_action_id}",
  391. )
  392. final_output = data.get("final_output.json", {})
  393. for asset in final_output.get("content_assets", []):
  394. if asset.get("decision_id") not in decision_ids:
  395. _fail(
  396. findings,
  397. "missing_decision_ref",
  398. f"asset has unknown decision_id: {asset.get('decision_id')}",
  399. )
  400. for path_id in asset.get("source_path_record_ids", []):
  401. if path_id not in path_ids:
  402. _fail(findings, "missing_path_ref", f"asset has unknown path_id: {path_id}")
  403. for record in final_output.get("review_records", []):
  404. if record.get("decision_id") not in decision_ids:
  405. _fail(
  406. findings,
  407. "missing_decision_ref",
  408. f"review_records has unknown decision_id: {record.get('decision_id')}",
  409. )
  410. for path_id in record.get("source_path_record_ids", []):
  411. if path_id not in path_ids:
  412. _fail(
  413. findings,
  414. "missing_path_ref",
  415. f"review_records has unknown path_id: {path_id}",
  416. )
  417. for section in ["reject_records", "decision_records"]:
  418. for row in final_output.get(section, []):
  419. if row.get("decision_id") not in decision_ids:
  420. _fail(
  421. findings,
  422. "missing_decision_ref",
  423. f"{section} has unknown decision_id: {row.get('decision_id')}",
  424. )
  425. for author_asset in final_output.get("author_assets", []):
  426. for decision_id in author_asset.get("decision_ids", []):
  427. if decision_id not in decision_ids:
  428. _fail(
  429. findings,
  430. "missing_decision_ref",
  431. f"author_assets has unknown decision_id: {decision_id}",
  432. )
  433. for path_id in author_asset.get("source_path_record_ids", []):
  434. if path_id not in path_ids:
  435. _fail(
  436. findings,
  437. "missing_path_ref",
  438. f"author_assets has unknown path_id: {path_id}",
  439. )
  440. evidence_refs = author_asset.get("evidence_refs") or {}
  441. if evidence_refs.get("decision_ids") != author_asset.get("decision_ids"):
  442. _fail(
  443. findings,
  444. "author_asset_evidence_incomplete",
  445. f"author asset evidence refs do not match decisions: {author_asset.get('author_asset_id')}",
  446. )
  447. def _check_pattern_recall_evidence(
  448. data: dict[str, Any],
  449. findings: list[dict[str, Any]],
  450. ) -> None:
  451. evidence_rows = data.get("pattern_recall_evidence.jsonl", [])
  452. evidence_by_id = {row.get("recall_evidence_id"): row for row in evidence_rows}
  453. for item in data.get("discovered_content_items.jsonl", []):
  454. pattern_match = item.get("pattern_match_result") or {}
  455. # V3(M3):桥接键 pattern_recall 已退役;改以"是否被判定过"(judge_status 存在)为准——
  456. # 每条经 Gemini 判定的内容必须能解析到真实 evidence 行,否则视为血缘损坏。
  457. if not pattern_match.get("judge_status"):
  458. continue
  459. evidence_id = pattern_match.get("pattern_recall_evidence_id")
  460. evidence = evidence_by_id.get(evidence_id)
  461. if not evidence:
  462. _fail(
  463. findings,
  464. "pattern_recall_evidence_missing",
  465. f"matched item cannot find recall evidence: {evidence_id}",
  466. )
  467. # V3(M2):判定改为 Gemini 直读,不再有 decode 强证据词/分类树路径;
  468. # matched_terms/matched_category_paths 不再适用,故不再强制。
  469. def _check_source_evidence(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  470. source_context = data.get("source_context.json", {})
  471. evidence_pack = source_context.get("ext_data", {}).get("evidence_pack", {})
  472. for decision in data.get("rule_decisions.jsonl", []):
  473. _check_one_source_evidence(
  474. findings,
  475. decision.get("source_evidence") or {},
  476. evidence_pack,
  477. f"decision {decision.get('decision_id')}",
  478. )
  479. final_output = data.get("final_output.json", {})
  480. for asset in final_output.get("content_assets", []):
  481. _check_one_source_evidence(
  482. findings,
  483. asset.get("source_evidence") or {},
  484. evidence_pack,
  485. f"asset {asset.get('platform_content_id')}",
  486. )
  487. for record in final_output.get("reject_records", []):
  488. _check_one_source_evidence(
  489. findings,
  490. record.get("source_evidence") or {},
  491. evidence_pack,
  492. f"reject {record.get('decision_target_id')}",
  493. )
  494. for record in final_output.get("review_records", []):
  495. _check_one_source_evidence(
  496. findings,
  497. record.get("source_evidence") or {},
  498. evidence_pack,
  499. f"review {record.get('platform_content_id')}",
  500. )
  501. for record in final_output.get("decision_records", []):
  502. _check_one_source_evidence(
  503. findings,
  504. record.get("source_evidence") or {},
  505. evidence_pack,
  506. f"decision record {record.get('decision_id')}",
  507. )
  508. _check_final_decision_coverage(data, findings)
  509. def _check_one_source_evidence(
  510. findings: list[dict[str, Any]],
  511. source_evidence: dict[str, Any],
  512. evidence_pack: dict[str, Any],
  513. label: str,
  514. ) -> None:
  515. missing_fields = sorted(field for field in SOURCE_EVIDENCE_FIELDS if field not in source_evidence)
  516. if missing_fields:
  517. _fail(findings, "source_evidence_missing_fields", f"{label} missing {missing_fields}")
  518. # V3(M4):分类树 category/element binding 已随 decode 链路退役,不再作为血缘门槛。
  519. scalar_fields = [
  520. "pattern_execution_id",
  521. "source_post_id",
  522. "pattern_source_system",
  523. "case_id_type",
  524. "mining_config_id",
  525. "support",
  526. "absolute_support",
  527. "source_certainty",
  528. "validation_status",
  529. ]
  530. list_fields = [
  531. "itemset_ids",
  532. "itemset_items",
  533. "matched_post_ids",
  534. "video_ids",
  535. "case_ids",
  536. "seed_terms",
  537. ]
  538. for field in scalar_fields + list_fields:
  539. if source_evidence.get(field) != evidence_pack.get(field):
  540. _fail(findings, "source_evidence_mismatch", f"{label} mismatched {field}")
  541. if (
  542. "decode_case_ids" in source_evidence
  543. and source_evidence.get("decode_case_ids") != evidence_pack.get("decode_case_ids")
  544. ):
  545. _fail(findings, "source_evidence_mismatch", f"{label} mismatched decode_case_ids")
  546. platform_content_id = source_evidence.get("discovered_platform_content_id")
  547. if platform_content_id:
  548. if platform_content_id == source_evidence.get("source_post_id"):
  549. _fail(findings, "source_evidence_content_pollution", f"{label} rewrites source_post_id")
  550. if platform_content_id in (source_evidence.get("matched_post_ids") or []):
  551. _fail(
  552. findings,
  553. "source_evidence_content_pollution",
  554. f"{label} rewrites matched_post_ids",
  555. )
  556. def _check_final_decision_coverage(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  557. decision_ids = {decision["decision_id"] for decision in data.get("rule_decisions.jsonl", [])}
  558. final_decision_ids = {
  559. record.get("decision_id")
  560. for record in data.get("final_output.json", {}).get("decision_records", [])
  561. }
  562. missing = sorted(decision_ids - final_decision_ids)
  563. if missing:
  564. _fail(findings, "final_decision_missing", f"final_output decision_records missing {missing}")
  565. def _check_source_paths(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  566. evidence_pack = data.get("source_context.json", {}).get("ext_data", {}).get("evidence_pack", {})
  567. pattern_execution_id = evidence_pack.get("pattern_execution_id")
  568. paths = data.get("source_path_records.jsonl", [])
  569. path_by_id = {path["source_path_record_id"]: path for path in paths}
  570. pattern_query_paths = {
  571. path["to_node_id"]: path
  572. for path in paths
  573. if path.get("source_path_type") == "pattern_to_search_query"
  574. }
  575. query_content_paths = {
  576. path["to_node_id"]: path
  577. for path in paths
  578. if path.get("source_path_type") == "search_query_to_content"
  579. }
  580. decision_asset_paths = {
  581. (path.get("decision_id"), path.get("to_node_id")): path
  582. for path in paths
  583. if path.get("source_path_type") == "decision_to_asset"
  584. }
  585. for decision in data.get("rule_decisions.jsonl", []):
  586. _check_content_source_path(
  587. findings,
  588. label=f"decision {decision.get('decision_id')}",
  589. platform_content_id=decision.get("decision_target_id"),
  590. pattern_execution_id=pattern_execution_id,
  591. pattern_query_paths=pattern_query_paths,
  592. query_content_paths=query_content_paths,
  593. )
  594. for asset in data.get("final_output.json", {}).get("content_assets", []):
  595. platform_content_id = asset.get("platform_content_id")
  596. path_ids = set(asset.get("source_path_record_ids", []))
  597. query_content = query_content_paths.get(platform_content_id)
  598. if not query_content or query_content.get("source_path_record_id") not in path_ids:
  599. _fail(
  600. findings,
  601. "source_path_broken",
  602. f"asset lacks search_query_to_content path: {platform_content_id}",
  603. )
  604. continue
  605. pattern_query = pattern_query_paths.get(query_content.get("from_node_id"))
  606. if not pattern_query or pattern_query.get("source_path_record_id") not in path_ids:
  607. _fail(
  608. findings,
  609. "source_path_broken",
  610. f"asset lacks pattern_to_search_query path: {platform_content_id}",
  611. )
  612. continue
  613. if pattern_query.get("from_node_id") != pattern_execution_id:
  614. _fail(
  615. findings,
  616. "source_path_broken",
  617. f"asset path starts from wrong pattern: {platform_content_id}",
  618. )
  619. for path_id in path_ids:
  620. if path_id not in path_by_id:
  621. _fail(findings, "source_path_broken", f"asset path missing: {path_id}")
  622. decision_asset = decision_asset_paths.get(
  623. (asset.get("decision_id"), platform_content_id)
  624. )
  625. if not decision_asset:
  626. _fail(
  627. findings,
  628. "decision_to_asset_missing",
  629. f"asset lacks decision_to_asset path: {platform_content_id}",
  630. )
  631. continue
  632. if decision_asset.get("source_path_record_id") not in path_ids:
  633. _fail(
  634. findings,
  635. "decision_to_asset_missing",
  636. f"asset source paths omit decision_to_asset: {platform_content_id}",
  637. )
  638. if decision_asset.get("from_node_type") != "RuleDecision":
  639. _fail(
  640. findings,
  641. "decision_to_asset_broken",
  642. f"asset decision_to_asset starts from wrong node: {platform_content_id}",
  643. )
  644. if decision_asset.get("from_node_id") != asset.get("decision_id"):
  645. _fail(
  646. findings,
  647. "decision_to_asset_broken",
  648. f"asset decision_to_asset has wrong decision id: {platform_content_id}",
  649. )
  650. if decision_asset.get("to_node_type") != "ContentAsset":
  651. _fail(
  652. findings,
  653. "decision_to_asset_broken",
  654. f"asset decision_to_asset ends at wrong node: {platform_content_id}",
  655. )
  656. for record in data.get("final_output.json", {}).get("review_records", []):
  657. platform_content_id = record.get("platform_content_id")
  658. path_ids = set(record.get("source_path_record_ids", []))
  659. query_content = query_content_paths.get(platform_content_id)
  660. if not query_content or query_content.get("source_path_record_id") not in path_ids:
  661. _fail(
  662. findings,
  663. "source_path_broken",
  664. f"review record lacks search_query_to_content path: {platform_content_id}",
  665. )
  666. continue
  667. pattern_query = pattern_query_paths.get(query_content.get("from_node_id"))
  668. if not pattern_query or pattern_query.get("source_path_record_id") not in path_ids:
  669. _fail(
  670. findings,
  671. "source_path_broken",
  672. f"review record lacks pattern_to_search_query path: {platform_content_id}",
  673. )
  674. def _check_content_source_path(
  675. findings: list[dict[str, Any]],
  676. label: str,
  677. platform_content_id: Any,
  678. pattern_execution_id: Any,
  679. pattern_query_paths: dict[str, dict[str, Any]],
  680. query_content_paths: dict[str, dict[str, Any]],
  681. ) -> None:
  682. query_content = query_content_paths.get(platform_content_id)
  683. if not query_content:
  684. _fail(
  685. findings,
  686. "source_path_broken",
  687. f"{label} lacks search_query_to_content path: {platform_content_id}",
  688. )
  689. return
  690. pattern_query = pattern_query_paths.get(query_content.get("from_node_id"))
  691. if not pattern_query:
  692. _fail(
  693. findings,
  694. "source_path_broken",
  695. f"{label} lacks pattern_to_search_query path: {platform_content_id}",
  696. )
  697. return
  698. if pattern_query.get("from_node_id") != pattern_execution_id:
  699. _fail(
  700. findings,
  701. "source_path_broken",
  702. f"{label} path starts from wrong pattern: {platform_content_id}",
  703. )
  704. def _check_summary(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  705. decisions = data.get("rule_decisions.jsonl", [])
  706. action_counts = Counter(decision.get("decision_action") for decision in decisions)
  707. summary = data.get("final_output.json", {}).get("summary", {})
  708. expected = {
  709. "pooled_content_count": action_counts["ADD_TO_CONTENT_POOL"],
  710. "review_content_count": action_counts["KEEP_CONTENT_FOR_REVIEW"],
  711. "pending_content_count": 0,
  712. "rejected_content_count": action_counts["REJECT_CONTENT"],
  713. }
  714. for field, value in expected.items():
  715. if summary.get(field) != value:
  716. _fail(
  717. findings,
  718. "summary_mismatch",
  719. f"summary.{field} expected {value}, got {summary.get(field)}",
  720. )
  721. clue_counts = Counter()
  722. for clue in data.get("search_clues.jsonl", []):
  723. clue_counts["ADD_TO_CONTENT_POOL"] += clue.get("pooled_content_count", 0)
  724. clue_counts["KEEP_CONTENT_FOR_REVIEW"] += clue.get("review_content_count", 0)
  725. clue_counts["REJECT_CONTENT"] += clue.get("rejected_content_count", 0)
  726. for action, count in action_counts.items():
  727. if clue_counts[action] < count:
  728. _fail(
  729. findings,
  730. "search_clue_mismatch",
  731. f"search_clues {action} expected at least {count}, got {clue_counts[action]}",
  732. )
  733. def _check_completeness(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  734. final_output = data.get("final_output.json", {})
  735. expected = compute_final_output_completeness(
  736. final_output,
  737. data.get("rule_decisions.jsonl", []),
  738. data.get("source_path_records.jsonl", []),
  739. )
  740. summary = final_output.get("summary", {})
  741. for field in ["run_path_complete", "trace_complete"]:
  742. if summary.get(field) != expected[field]:
  743. _fail(
  744. findings,
  745. "completeness_mismatch",
  746. f"summary.{field} expected {expected[field]}, got {summary.get(field)}",
  747. )
  748. if final_output.get("validation_status") != expected["validation_status"]:
  749. _fail(
  750. findings,
  751. "completeness_mismatch",
  752. f"validation_status expected {expected['validation_status']}, got {final_output.get('validation_status')}",
  753. )
  754. def _result(run_id: str, findings: list[dict[str, Any]]) -> dict[str, Any]:
  755. return {
  756. "run_id": run_id,
  757. "status": "fail" if any(finding["level"] == "fail" for finding in findings) else "pass",
  758. "findings": findings,
  759. }