test_v4_validator_contract.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. from __future__ import annotations
  2. import copy
  3. from typing import Any, Callable
  4. from content_agent.business_modules.run_record.validation import validate_run
  5. from content_agent.integrations.runtime_files import LocalRuntimeFileStore, RUNTIME_FILENAMES
  6. RUN_ID = "run_v4_contract"
  7. POLICY_RUN_ID = "policy_v4_contract"
  8. def test_v4_contract_runtime_passes_validate_run(tmp_path):
  9. runtime = _write_runtime(tmp_path)
  10. result = validate_run(RUN_ID, runtime)
  11. assert result["status"] == "pass"
  12. def test_v4_contract_does_not_apply_to_v3_scorecard_records(tmp_path):
  13. def mutate(data: dict[str, Any]) -> None:
  14. decision = data["rule_decisions.jsonl"][0]
  15. decision["score"] = None
  16. decision["scorecard"] = {
  17. "total_score": None,
  18. "fit_senior_50plus": True,
  19. "relevance_score": 0.91,
  20. }
  21. decision["decision_replay_data"].pop("allow_walk")
  22. runtime = _write_runtime(tmp_path, mutate)
  23. result = validate_run(RUN_ID, runtime)
  24. assert result["status"] == "pass"
  25. def test_v4_score_contract_rejects_bad_total(tmp_path):
  26. def mutate(data: dict[str, Any]) -> None:
  27. data["rule_decisions.jsonl"][0]["score"] = 92
  28. _refresh_final_output_explanations(data)
  29. runtime = _write_runtime(tmp_path, mutate)
  30. result = validate_run(RUN_ID, runtime)
  31. assert _check_ids(result) == ["v4_score_total_mismatch"]
  32. def test_v4_walk_gate_rejects_allow_walk_below_threshold(tmp_path):
  33. def mutate(data: dict[str, Any]) -> None:
  34. decision = data["rule_decisions.jsonl"][0]
  35. decision["score"] = 65
  36. decision["scorecard"]["query_relevance_score"] = 60
  37. decision["scorecard"]["platform_performance_score"] = 70
  38. _refresh_final_output_explanations(data)
  39. runtime = _write_runtime(tmp_path, mutate)
  40. result = validate_run(RUN_ID, runtime)
  41. assert "v4_allow_walk_threshold_mismatch" in _check_ids(result)
  42. def test_v4_walk_gate_rejects_allow_walk_false_at_passing_threshold(tmp_path):
  43. def mutate(data: dict[str, Any]) -> None:
  44. data["rule_decisions.jsonl"][0]["decision_replay_data"]["allow_walk"] = False
  45. _refresh_final_output_explanations(data)
  46. runtime = _write_runtime(tmp_path, mutate)
  47. result = validate_run(RUN_ID, runtime)
  48. assert "v4_allow_walk_threshold_mismatch" in _check_ids(result)
  49. def test_v4_walk_action_rejects_success_when_allow_walk_false(tmp_path):
  50. def mutate(data: dict[str, Any]) -> None:
  51. decision = data["rule_decisions.jsonl"][0]
  52. decision["score"] = 70
  53. decision["scorecard"]["platform_performance_score"] = 60
  54. decision["decision_replay_data"]["allow_walk"] = False
  55. decision["decision_replay_data"]["allow_walk_reason"] = "v4_query_and_platform_pass"
  56. decision["decision_replay_data"]["walk_gate_snapshot"] = {
  57. "query_relevance_score": 80,
  58. "platform_performance_score": 60,
  59. "score": 70,
  60. }
  61. _refresh_final_output_explanations(data)
  62. data["walk_actions.jsonl"].append(
  63. _walk_action(
  64. "walk_page_001",
  65. "hashtag_to_query",
  66. "success",
  67. {
  68. "decision_id": "decision_001",
  69. "allow_walk": False,
  70. "allow_walk_reason": "v4_query_and_platform_pass",
  71. "walk_gate_snapshot": decision["decision_replay_data"]["walk_gate_snapshot"],
  72. },
  73. )
  74. )
  75. runtime = _write_runtime(tmp_path, mutate)
  76. result = validate_run(RUN_ID, runtime)
  77. assert "v4_walk_action_allow_walk_denied" in _check_ids(result)
  78. def test_v4_walk_action_rejects_missing_gate_snapshot(tmp_path):
  79. def mutate(data: dict[str, Any]) -> None:
  80. data["walk_actions.jsonl"].append(
  81. _walk_action(
  82. "walk_tag_001",
  83. "hashtag_to_query",
  84. "success",
  85. {
  86. "decision_id": "decision_001",
  87. "allow_walk": True,
  88. "allow_walk_reason": "query>=70/platform>=65/score>=70",
  89. },
  90. )
  91. )
  92. runtime = _write_runtime(tmp_path, mutate)
  93. result = validate_run(RUN_ID, runtime)
  94. assert "v4_walk_action_gate_context_missing" in _check_ids(result)
  95. def test_v4_action_thresholds_reject_conflicting_action(tmp_path):
  96. def mutate(data: dict[str, Any]) -> None:
  97. decision = data["rule_decisions.jsonl"][0]
  98. decision["decision_action"] = "KEEP_CONTENT_FOR_REVIEW"
  99. runtime = _write_runtime(tmp_path, mutate)
  100. result = validate_run(RUN_ID, runtime)
  101. assert "v4_action_threshold_mismatch" in _check_ids(result)
  102. def test_v4_gemini_failure_requires_structured_failure_fields(tmp_path):
  103. def mutate(data: dict[str, Any]) -> None:
  104. summary = data["pattern_recall_evidence.jsonl"][0]["evidence_summary"]
  105. summary.clear()
  106. summary.update(
  107. {
  108. "schema_version": "v4_gemini_query_relevance.v1",
  109. "final_status": "failed",
  110. "retry_count": 0,
  111. }
  112. )
  113. runtime = _write_runtime(tmp_path, mutate)
  114. result = validate_run(RUN_ID, runtime)
  115. assert {
  116. "v4_gemini_failure_incomplete",
  117. "v4_gemini_failure_retry_invalid",
  118. } <= set(_check_ids(result))
  119. def test_v4_legacy_field_blocklist_rejects_v4_records_only(tmp_path):
  120. def mutate(data: dict[str, Any]) -> None:
  121. data["rule_decisions.jsonl"][0]["scorecard"]["platform_heat"] = 88
  122. data["pattern_recall_evidence.jsonl"][0]["evidence_summary"]["relevance_score"] = 0.9
  123. runtime = _write_runtime(tmp_path, mutate)
  124. result = validate_run(RUN_ID, runtime)
  125. assert _check_ids(result).count("v4_legacy_field_present") == 2
  126. def test_v4_final_output_requires_explanation(tmp_path):
  127. def mutate(data: dict[str, Any]) -> None:
  128. data["final_output.json"]["decision_records"][0].pop("v4_explanation")
  129. runtime = _write_runtime(tmp_path, mutate)
  130. result = validate_run(RUN_ID, runtime)
  131. assert "v4_final_output_explanation_missing" in _check_ids(result)
  132. def test_v4_final_output_explanation_must_match_decision(tmp_path):
  133. def mutate(data: dict[str, Any]) -> None:
  134. data["final_output.json"]["decision_records"][0]["v4_explanation"]["allow_walk"] = False
  135. runtime = _write_runtime(tmp_path, mutate)
  136. result = validate_run(RUN_ID, runtime)
  137. assert "v4_final_output_explanation_mismatch" in _check_ids(result)
  138. def test_v4_strategy_review_requires_summary_when_generated(tmp_path):
  139. def mutate(data: dict[str, Any]) -> None:
  140. data["strategy_review.json"].pop("v4_summary")
  141. runtime = _write_runtime(tmp_path, mutate)
  142. result = validate_run(RUN_ID, runtime)
  143. assert "v4_strategy_review_explanation_missing" in _check_ids(result)
  144. def _write_runtime(
  145. tmp_path,
  146. mutate: Callable[[dict[str, Any]], None] | None = None,
  147. ) -> LocalRuntimeFileStore:
  148. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  149. runtime.prepare_run(RUN_ID)
  150. data = _runtime_payload()
  151. if mutate:
  152. mutate(data)
  153. for filename in RUNTIME_FILENAMES:
  154. value = data[filename]
  155. if isinstance(value, list):
  156. runtime.append_jsonl(RUN_ID, filename, value)
  157. else:
  158. runtime.write_json(RUN_ID, filename, value)
  159. return runtime
  160. def _runtime_payload() -> dict[str, Any]:
  161. evidence_pack = {
  162. "pattern_source_system": "pg_pattern_v2",
  163. "case_id_type": "post_id",
  164. "source_kind": "pattern_itemset",
  165. "source_post_id": "post_001",
  166. "pattern_execution_id": 581,
  167. "mining_config_id": 2082,
  168. "itemset_ids": [1608352],
  169. "itemset_items": ["毛主席", "感人"],
  170. "support": 5,
  171. "absolute_support": 5,
  172. "matched_post_ids": ["post_001", "post_002"],
  173. "video_ids": ["video_001"],
  174. "case_ids": ["case_001"],
  175. "seed_terms": ["父爱感悟"],
  176. "discovery_start_source": "pattern_seed",
  177. "previous_discovery_step": "search_query_generated",
  178. "origin_path_id": "path_origin_001",
  179. "run_id": RUN_ID,
  180. "policy_run_id": POLICY_RUN_ID,
  181. "source_certainty": "high",
  182. "validation_status": "validated",
  183. }
  184. source_evidence = _source_evidence(evidence_pack, "content_001")
  185. path_ids = ["path_pattern_query", "path_query_content", "path_decision_asset"]
  186. decision = {
  187. "record_schema_version": "runtime_record.v1",
  188. "run_id": RUN_ID,
  189. "policy_run_id": POLICY_RUN_ID,
  190. "decision_id": "decision_001",
  191. "policy_bundle_id": "policy_bundle_v4",
  192. "rule_pack_id": "douyin_content_discovery_rule_pack_v4",
  193. "rule_pack_version": "4.0.0",
  194. "strategy_version": "V4",
  195. "decision_target_type": "content",
  196. "decision_target_id": "content_001",
  197. "decision_action": "ADD_TO_CONTENT_POOL",
  198. "decision_reason_code": "v4_query_and_platform_pass",
  199. "search_query_effect_status": "success",
  200. "score": 75,
  201. "scorecard": {
  202. "schema_version": "v4_scorecard.v1",
  203. "query_relevance_score": 80,
  204. "platform_performance_score": 70,
  205. "missing_observable_fields": [],
  206. },
  207. "source_evidence": copy.deepcopy(source_evidence),
  208. "decision_replay_data": {
  209. "policy_bundle_hash": "hash_v4",
  210. "rule_pack_id": "douyin_content_discovery_rule_pack_v4",
  211. "rule_pack_version": "4.0.0",
  212. "dispatch_id": "dispatch_v4",
  213. "strategy_version": "V4",
  214. "allow_walk": True,
  215. "allow_walk_reason": "query>=70/platform>=65/score>=70",
  216. "walk_gate_snapshot": {
  217. "query_relevance_score": 80,
  218. "platform_performance_score": 70,
  219. "score": 75,
  220. },
  221. },
  222. "raw_payload": {"decision_id": "decision_001", "v4_contract": True},
  223. }
  224. v4_explanation = _v4_explanation(decision)
  225. return {
  226. "source_context.json": {
  227. "schema_version": "runtime_record.v1",
  228. "run_id": RUN_ID,
  229. "demand_content_id": "123",
  230. "ext_data": {"evidence_pack": evidence_pack},
  231. },
  232. "pattern_seed_pack.json": {
  233. "schema_version": "runtime_record.v1",
  234. "run_id": RUN_ID,
  235. "policy_run_id": POLICY_RUN_ID,
  236. "itemsets": [{"itemset_id": 1608352}],
  237. "seed_terms": ["父爱感悟"],
  238. },
  239. "search_queries.jsonl": [
  240. {
  241. "record_schema_version": "runtime_record.v1",
  242. "run_id": RUN_ID,
  243. "policy_run_id": POLICY_RUN_ID,
  244. "search_query_id": "query_001",
  245. "search_query": "父爱感悟",
  246. "search_query_generation_method": "v4_seed",
  247. "discovery_start_source": "pattern_seed",
  248. "previous_discovery_step": "search_query_generated",
  249. "search_query_effect_status": "success",
  250. "pattern_seed_ref": {"query_source_type": "seed"},
  251. "raw_payload": {"query_source_refs": [{"query_source_type": "seed"}]},
  252. }
  253. ],
  254. "discovered_content_items.jsonl": [
  255. {
  256. "record_schema_version": "runtime_record.v1",
  257. "run_id": RUN_ID,
  258. "policy_run_id": POLICY_RUN_ID,
  259. "content_discovery_id": "discovery_001",
  260. "search_query_id": "query_001",
  261. "platform": "douyin",
  262. "platform_content_id": "content_001",
  263. "content_url": "https://example.com/content_001",
  264. "statistics": {"share_count": 10},
  265. "tags": ["父爱"],
  266. "source_evidence": copy.deepcopy(source_evidence),
  267. "pattern_match_result": {
  268. "judge_status": "ok",
  269. "pattern_recall_evidence_id": "recall_001",
  270. },
  271. "platform_raw_payload": {},
  272. "raw_payload": {"platform_content_id": "content_001"},
  273. }
  274. ],
  275. "content_media_records.jsonl": [
  276. {
  277. "record_schema_version": "runtime_record.v1",
  278. "run_id": RUN_ID,
  279. "policy_run_id": POLICY_RUN_ID,
  280. "media_record_id": "media_001",
  281. "platform": "douyin",
  282. "platform_content_id": "content_001",
  283. "media_status": "available",
  284. "raw_payload": {"platform_content_id": "content_001"},
  285. }
  286. ],
  287. "pattern_recall_evidence.jsonl": [
  288. {
  289. "record_schema_version": "runtime_record.v1",
  290. "run_id": RUN_ID,
  291. "policy_run_id": POLICY_RUN_ID,
  292. "recall_evidence_id": "recall_001",
  293. "content_discovery_id": "discovery_001",
  294. "platform_content_id": "content_001",
  295. "recall_status": "judged",
  296. "evidence_summary": {
  297. "schema_version": "v4_gemini_query_relevance.v1",
  298. "final_status": "success",
  299. "query_relevance_score": 80,
  300. "reason": "契合 query",
  301. },
  302. "raw_payload": {"recall_evidence_id": "recall_001"},
  303. }
  304. ],
  305. "rule_decisions.jsonl": [decision],
  306. "walk_actions.jsonl": [],
  307. "run_events.jsonl": [],
  308. "source_path_records.jsonl": [
  309. _path("path_pattern_query", "pattern_to_search_query", "Pattern", 581, "SearchQuery", "query_001"),
  310. _path("path_query_content", "search_query_to_content", "SearchQuery", "query_001", "Content", "content_001"),
  311. _path(
  312. "path_decision_asset",
  313. "decision_to_asset",
  314. "RuleDecision",
  315. "decision_001",
  316. "ContentAsset",
  317. "content_001",
  318. decision_id="decision_001",
  319. ),
  320. ],
  321. "search_clues.jsonl": [
  322. {
  323. "record_schema_version": "runtime_record.v1",
  324. "run_id": RUN_ID,
  325. "policy_run_id": POLICY_RUN_ID,
  326. "clue_id": "clue_001",
  327. "search_query_id": "query_001",
  328. "search_query": "父爱感悟",
  329. "result_count": 1,
  330. "pooled_content_count": 1,
  331. "review_content_count": 0,
  332. "pending_content_count": 0,
  333. "rejected_content_count": 0,
  334. "search_query_effect_status": "success",
  335. "query_aggregation_id": "agg_query_success",
  336. "raw_payload": {"clue_id": "clue_001"},
  337. }
  338. ],
  339. "final_output.json": {
  340. "schema_version": "runtime_record.v1",
  341. "run_id": RUN_ID,
  342. "policy_run_id": POLICY_RUN_ID,
  343. "validation_status": "pass",
  344. "content_assets": [
  345. {
  346. "platform_content_id": "content_001",
  347. "decision_id": "decision_001",
  348. "source_path_record_ids": path_ids,
  349. "source_evidence": copy.deepcopy(source_evidence),
  350. "v4_explanation": copy.deepcopy(v4_explanation),
  351. }
  352. ],
  353. "author_assets": [],
  354. "review_records": [],
  355. "decision_records": [
  356. {
  357. "decision_id": "decision_001",
  358. "score": 75,
  359. "scorecard": copy.deepcopy(decision["scorecard"]),
  360. "decision_replay_data": copy.deepcopy(decision["decision_replay_data"]),
  361. "source_evidence": copy.deepcopy(source_evidence),
  362. "v4_explanation": copy.deepcopy(v4_explanation),
  363. }
  364. ],
  365. "search_clues": [],
  366. "reject_records": [],
  367. "summary": {
  368. "pooled_content_count": 1,
  369. "review_content_count": 0,
  370. "pending_content_count": 0,
  371. "rejected_content_count": 0,
  372. "run_path_complete": True,
  373. "trace_complete": True,
  374. },
  375. },
  376. "strategy_review.json": {
  377. "schema_version": "runtime_record.v1",
  378. "run_id": RUN_ID,
  379. "policy_run_id": POLICY_RUN_ID,
  380. "summary": {},
  381. "v4_summary": {
  382. "schema_version": "v4_strategy_review_summary.v1",
  383. "score_buckets": {
  384. "pool": 1,
  385. "review": 0,
  386. "reject": 0,
  387. "technical_retry": 0,
  388. "unknown": 0,
  389. },
  390. "allow_walk_distribution": {
  391. "allowed": 1,
  392. "denied": 0,
  393. "missing": 0,
  394. },
  395. "walk_gate_review": {
  396. "v4_gate_distribution": {
  397. "allowed": 0,
  398. "denied": 0,
  399. "missing": 0,
  400. }
  401. },
  402. },
  403. "raw_payload": {"strategy_review_id": "review_001"},
  404. },
  405. }
  406. def _v4_explanation(decision: dict[str, Any]) -> dict[str, Any]:
  407. scorecard = decision["scorecard"]
  408. replay = decision["decision_replay_data"]
  409. return {
  410. "schema_version": "v4_decision_explanation.v1",
  411. "scorecard_schema_version": scorecard["schema_version"],
  412. "query_relevance_score": scorecard["query_relevance_score"],
  413. "platform_performance_score": scorecard["platform_performance_score"],
  414. "score": decision["score"],
  415. "platform_performance_components": scorecard.get("platform_performance_components", []),
  416. "missing_observable_fields": scorecard.get("missing_observable_fields", []),
  417. "decision_action": decision["decision_action"],
  418. "decision_reason_code": decision["decision_reason_code"],
  419. "search_query_effect_status": decision["search_query_effect_status"],
  420. "allow_walk": replay["allow_walk"],
  421. "allow_walk_reason": replay["allow_walk_reason"],
  422. "walk_gate_snapshot": replay["walk_gate_snapshot"],
  423. }
  424. def _refresh_final_output_explanations(data: dict[str, Any]) -> None:
  425. decisions = {decision["decision_id"]: decision for decision in data["rule_decisions.jsonl"]}
  426. for section in ["content_assets", "review_records", "reject_records", "decision_records"]:
  427. for record in data["final_output.json"].get(section, []):
  428. decision = decisions.get(record.get("decision_id"))
  429. if not decision:
  430. continue
  431. record["v4_explanation"] = _v4_explanation(decision)
  432. if section == "decision_records":
  433. record["score"] = decision.get("score")
  434. record["scorecard"] = copy.deepcopy(decision.get("scorecard"))
  435. record["decision_replay_data"] = copy.deepcopy(
  436. decision.get("decision_replay_data")
  437. )
  438. def _source_evidence(evidence_pack: dict[str, Any], platform_content_id: str) -> dict[str, Any]:
  439. evidence = copy.deepcopy(evidence_pack)
  440. evidence["discovered_platform_content_id"] = platform_content_id
  441. return evidence
  442. def _path(
  443. path_id: str,
  444. path_type: str,
  445. from_type: str,
  446. from_id: Any,
  447. to_type: str,
  448. to_id: Any,
  449. decision_id: str | None = None,
  450. ) -> dict[str, Any]:
  451. return {
  452. "record_schema_version": "runtime_record.v1",
  453. "run_id": RUN_ID,
  454. "policy_run_id": POLICY_RUN_ID,
  455. "source_path_record_id": path_id,
  456. "source_path_type": path_type,
  457. "from_node_type": from_type,
  458. "from_node_id": from_id,
  459. "to_node_type": to_type,
  460. "to_node_id": to_id,
  461. "decision_id": decision_id,
  462. "raw_payload": {"source_path_record_id": path_id},
  463. }
  464. def _walk_action(
  465. action_id: str,
  466. edge_id: str,
  467. status: str,
  468. raw_payload: dict[str, Any],
  469. ) -> dict[str, Any]:
  470. return {
  471. "record_schema_version": "runtime_record.v1",
  472. "run_id": RUN_ID,
  473. "policy_run_id": POLICY_RUN_ID,
  474. "walk_action_id": action_id,
  475. "edge_id": edge_id,
  476. "edge_type": "query",
  477. "from_node_type": "SearchQuery",
  478. "from_node_id": "query_001",
  479. "to_node_type": "SearchQuery",
  480. "to_node_id": "query_002",
  481. "walk_action": "fetch_next_page",
  482. "walk_status": status,
  483. "budget_tier": "normal",
  484. "depth": 1,
  485. "reason_code": None,
  486. "raw_payload": raw_payload,
  487. }
  488. def _check_ids(result: dict[str, Any]) -> list[str]:
  489. return [finding["check_id"] for finding in result["findings"]]