evaluator.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812
  1. from __future__ import annotations
  2. from typing import Any
  3. from content_agent.constants import RUNTIME_RECORD_SCHEMA_VERSION
  4. from content_agent.record_payload import with_raw_payload
  5. HARD_GATE_REASON_CATEGORY = "hard_gate"
  6. SCORE_REASON_CATEGORY = "score_or_data_failed"
  7. REVIEW_REASON_CATEGORY = "review_needed"
  8. SUCCESS_REASON_CATEGORY = "score_pass"
  9. TECHNICAL_REASON_CATEGORY = "technical_error"
  10. V4_SCORECARD_SCHEMA_VERSION = "v4_scorecard.v1"
  11. V4_POOL_REASON = "v4_query_and_platform_pass"
  12. V4_REVIEW_REASON = "v4_score_review_needed"
  13. V4_REJECT_REASON = "v4_query_or_score_below_threshold"
  14. V4_TECHNICAL_RETRY_REASON = "v4_technical_retry_needed"
  15. V4_TECHNICAL_RETRY_ACTION = "TECHNICAL_RETRY_REQUIRED"
  16. RULE_DECISION_RAW_PAYLOAD_EXCLUDE_KEYS = {
  17. "run_id",
  18. "policy_run_id",
  19. "decision_id",
  20. "policy_bundle_id",
  21. "rule_pack_id",
  22. "rule_pack_version",
  23. "strategy_version",
  24. "decision_target_type",
  25. "decision_target_id",
  26. "triggered_blocking_rules",
  27. "scorecard",
  28. "score",
  29. "decision_action",
  30. "decision_reason_code",
  31. "search_query_effect_status",
  32. "source_evidence",
  33. "decision_replay_data",
  34. }
  35. # V5-M2:V4 权重从规则包 score_weight_profiles 读取;缺配置时 fallback 保持历史行为。
  36. V4_PORTRAIT_UNAVAILABLE_REASON = "portrait_unavailable"
  37. V4_PORTRAIT_INCOMPLETE_REASON = "portrait_incomplete"
  38. def decide(
  39. run_id: str,
  40. policy_run_id: str,
  41. index: int,
  42. bundle: dict[str, Any],
  43. policy_bundle: dict[str, Any],
  44. ) -> dict[str, Any]:
  45. rule_pack = policy_bundle["rule_pack"]
  46. matched_gates = _evaluate_hard_gates(bundle, rule_pack.get("hard_gates", []))
  47. blocking_gates = [
  48. gate for gate in matched_gates if gate.get("severity") == "fatal" or gate.get("stop_scoring")
  49. ]
  50. triggered_blocking_rules = [gate["gate_id"] for gate in blocking_gates]
  51. if blocking_gates:
  52. primary_gate = _primary_hard_gate(blocking_gates)
  53. replay_marker = {
  54. "matched_gates": [_gate_replay(gate) for gate in matched_gates],
  55. "primary_gate_id": primary_gate["gate_id"],
  56. "primary_reason_code": primary_gate["decision_reason_code"],
  57. "primary_gate_priority": primary_gate.get("priority"),
  58. "reason_category": HARD_GATE_REASON_CATEGORY,
  59. }
  60. scorecard = _scorecard(None, rule_pack.get("scorecard", {}), missing=True)
  61. if _is_v4_policy(policy_bundle):
  62. replay_marker.update(_v4_walk_gate(False, primary_gate["decision_reason_code"]))
  63. if primary_gate["decision_reason_code"] == V4_TECHNICAL_RETRY_REASON:
  64. scorecard = _v4_scorecard_total(bundle, policy_bundle)
  65. effect = _effect_status_for_decision(
  66. policy_bundle,
  67. primary_gate["decision_action"],
  68. HARD_GATE_REASON_CATEGORY,
  69. is_hard_gate=True,
  70. )
  71. return _build_decision(
  72. run_id=run_id,
  73. policy_run_id=policy_run_id,
  74. index=index,
  75. bundle=bundle,
  76. policy_bundle=policy_bundle,
  77. decision_action=primary_gate["decision_action"],
  78. decision_reason_code=primary_gate["decision_reason_code"],
  79. search_query_effect_status=effect["content_effect_status"],
  80. triggered_blocking_rules=triggered_blocking_rules,
  81. scorecard=scorecard,
  82. score=None,
  83. replay_marker={**replay_marker, "effect_mapping_id": effect.get("mapping_id")},
  84. )
  85. if _is_v4_policy(policy_bundle):
  86. return _decide_v4(
  87. run_id,
  88. policy_run_id,
  89. index,
  90. bundle,
  91. policy_bundle,
  92. matched_gates,
  93. triggered_blocking_rules,
  94. )
  95. scorecard = _scorecard_total(bundle, rule_pack.get("scorecard", {}))
  96. score = scorecard.get("total_score")
  97. if score is None or _scorecard_all_dimensions_missing(scorecard):
  98. return _missing_score_decision(
  99. run_id,
  100. policy_run_id,
  101. index,
  102. bundle,
  103. policy_bundle,
  104. matched_gates,
  105. scorecard,
  106. )
  107. threshold = _match_threshold(score, rule_pack.get("thresholds", []))
  108. if threshold is None:
  109. raise ValueError(f"no threshold matched score: {score}")
  110. decision_action = threshold["decision_action"]
  111. decision_reason_code = threshold["decision_reason_code"]
  112. reason_category = _reason_category_for_threshold(decision_action, decision_reason_code)
  113. effect = _effect_status_for_decision(
  114. policy_bundle, decision_action, reason_category, is_hard_gate=False
  115. )
  116. return _build_decision(
  117. run_id=run_id,
  118. policy_run_id=policy_run_id,
  119. index=index,
  120. bundle=bundle,
  121. policy_bundle=policy_bundle,
  122. decision_action=decision_action,
  123. decision_reason_code=decision_reason_code,
  124. search_query_effect_status=effect["content_effect_status"],
  125. triggered_blocking_rules=triggered_blocking_rules,
  126. scorecard=scorecard,
  127. score=score,
  128. replay_marker={
  129. "matched_gates": [_gate_replay(gate) for gate in matched_gates],
  130. "matched_threshold": _threshold_label(threshold),
  131. "matched_threshold_priority": threshold.get("priority"),
  132. "effect_mapping_id": effect.get("mapping_id"),
  133. "reason_category": reason_category,
  134. "matched_scoring_rules": scorecard.get("matched_scoring_rules", []),
  135. },
  136. )
  137. def _is_v4_policy(policy_bundle: dict[str, Any]) -> bool:
  138. return (
  139. policy_bundle.get("strategy_version") == "V4"
  140. or policy_bundle.get("rule_pack_version") == "4.0.0"
  141. or policy_bundle.get("rule_pack", {}).get("scorecard", {}).get("schema_version")
  142. == V4_SCORECARD_SCHEMA_VERSION
  143. )
  144. def _decide_v4(
  145. run_id: str,
  146. policy_run_id: str,
  147. index: int,
  148. bundle: dict[str, Any],
  149. policy_bundle: dict[str, Any],
  150. matched_gates: list[dict[str, Any]],
  151. triggered_blocking_rules: list[str],
  152. ) -> dict[str, Any]:
  153. scorecard = _v4_scorecard_total(bundle, policy_bundle)
  154. score = scorecard.get("total_score")
  155. thresholds = _v4_thresholds(policy_bundle, _get_path(bundle, "content.platform"))
  156. scorecard["score_thresholds"] = thresholds # 随决策落库:供前端展示实际生效门槛(单一真源)
  157. if score is None:
  158. decision_action = V4_TECHNICAL_RETRY_ACTION
  159. decision_reason_code = _v4_technical_retry_reason(scorecard)
  160. else:
  161. decision_action, decision_reason_code = _v4_decision(scorecard, thresholds)
  162. reason_category = _reason_category_for_threshold(decision_action, decision_reason_code)
  163. effect = _effect_status_for_decision(
  164. policy_bundle, decision_action, reason_category, is_hard_gate=False
  165. )
  166. allow_walk = _v4_allow_walk(scorecard, score, thresholds)
  167. return _build_decision(
  168. run_id=run_id,
  169. policy_run_id=policy_run_id,
  170. index=index,
  171. bundle=bundle,
  172. policy_bundle=policy_bundle,
  173. decision_action=decision_action,
  174. decision_reason_code=decision_reason_code,
  175. search_query_effect_status=effect["content_effect_status"],
  176. triggered_blocking_rules=triggered_blocking_rules,
  177. scorecard=scorecard,
  178. score=score,
  179. replay_marker={
  180. "matched_gates": [_gate_replay(gate) for gate in matched_gates],
  181. "effect_mapping_id": effect.get("mapping_id"),
  182. "reason_category": reason_category,
  183. **_v4_walk_gate(
  184. allow_walk,
  185. decision_reason_code,
  186. scorecard=scorecard,
  187. score=score,
  188. thresholds=thresholds,
  189. ),
  190. },
  191. )
  192. def _v4_scorecard_total(bundle: dict[str, Any], policy_bundle: dict[str, Any]) -> dict[str, Any]:
  193. query_score = _query_relevance_from_bundle(bundle)
  194. platform = _platform_performance_from_bundle(bundle)
  195. platform_score = platform.get("platform_performance_score") if isinstance(platform, dict) else None
  196. missing = platform.get("missing_observable_fields", []) if isinstance(platform, dict) else []
  197. components = platform.get("platform_performance_components", []) if isinstance(platform, dict) else []
  198. scorecard = {
  199. "schema_version": V4_SCORECARD_SCHEMA_VERSION,
  200. "query_relevance_score": query_score,
  201. "platform_performance_score": platform_score,
  202. "platform_performance_components": components,
  203. "missing_observable_fields": missing if isinstance(missing, list) else [],
  204. }
  205. fifty = _fifty_plus_from_bundle(bundle)
  206. selected_profile, weights = _v4_score_weight_profile(policy_bundle, bundle)
  207. scorecard["score_weights"] = weights
  208. missing_failure = _v4_missing_critical_dimension_failure(selected_profile, bundle)
  209. if missing_failure:
  210. scorecard.update(missing_failure)
  211. scorecard["total_score"] = None
  212. scorecard["score_missing"] = True
  213. scorecard.update(_technical_failure_fields(bundle))
  214. return scorecard
  215. if fifty is None:
  216. total = _v4_weighted_total(
  217. {"query_relevance": query_score, "platform_performance": platform_score},
  218. weights,
  219. )
  220. else:
  221. status = fifty.get("status")
  222. scorecard["fifty_plus_status"] = status
  223. fifty_score = fifty.get("score")
  224. if status == "ok" and _is_number(query_score) and _is_number(platform_score) and _is_number(fifty_score):
  225. scorecard["fifty_plus_score"] = fifty_score
  226. scorecard["fifty_plus_components"] = fifty.get("components", {})
  227. total = _v4_weighted_total(
  228. {
  229. "query_relevance": query_score,
  230. "platform_performance": platform_score,
  231. "fifty_plus": fifty_score,
  232. },
  233. weights,
  234. )
  235. elif status == "not_attempted" and _is_number(query_score) and _is_number(platform_score):
  236. total = _v4_weighted_total(
  237. {"query_relevance": query_score, "platform_performance": platform_score},
  238. weights,
  239. )
  240. else:
  241. # unavailable / incomplete / 缺分 → 技术失败(停游走)。把失败类型+原因写进 scorecard,供技术详情展示。
  242. total = None
  243. scorecard["failure_type"] = (
  244. V4_PORTRAIT_UNAVAILABLE_REASON
  245. if status == "unavailable"
  246. else V4_PORTRAIT_INCOMPLETE_REASON
  247. if status == "incomplete"
  248. else "fifty_plus_missing"
  249. )
  250. fifty_reason = fifty.get("failure_reason")
  251. if fifty_reason:
  252. scorecard["fifty_plus_failure_reason"] = fifty_reason
  253. scorecard["total_score"] = total
  254. scorecard["score_missing"] = total is None
  255. scorecard.update(_technical_failure_fields(bundle))
  256. return scorecard
  257. _V4_SCORE_WEIGHT_FALLBACK_PROFILES = {
  258. "default_two_dimension": {"query_relevance": 50, "platform_performance": 50},
  259. "douyin_fifty_plus_ok": {"query_relevance": 35, "platform_performance": 35, "fifty_plus": 30},
  260. "douyin_fifty_plus_not_attempted": {"query_relevance": 35, "platform_performance": 35},
  261. }
  262. def _v4_score_weights(policy_bundle: dict[str, Any], bundle: dict[str, Any]) -> dict[str, float]:
  263. _, weights = _v4_score_weight_profile(policy_bundle, bundle)
  264. return weights
  265. def _v4_score_weight_profile(policy_bundle: dict[str, Any], bundle: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]:
  266. profiles = (
  267. policy_bundle.get("rule_pack", {})
  268. .get("scorecard", {})
  269. .get("score_weight_profiles")
  270. )
  271. generic_profile = _select_generic_score_weight_profile(profiles, bundle)
  272. if generic_profile:
  273. return generic_profile, _decimal_weights(generic_profile.get("weights"))
  274. if not isinstance(profiles, dict) or "profiles" in profiles:
  275. profiles = _V4_SCORE_WEIGHT_FALLBACK_PROFILES
  276. fifty = _fifty_plus_from_bundle(bundle)
  277. if fifty is None:
  278. profile_name = "default_two_dimension"
  279. elif fifty.get("status") == "not_attempted":
  280. profile_name = "douyin_fifty_plus_not_attempted"
  281. else:
  282. profile_name = "douyin_fifty_plus_ok"
  283. raw_weights = profiles.get(profile_name)
  284. if not isinstance(raw_weights, dict) or not raw_weights:
  285. raw_weights = _V4_SCORE_WEIGHT_FALLBACK_PROFILES[profile_name]
  286. return {"profile_id": profile_name, "weights": raw_weights}, _decimal_weights(raw_weights)
  287. def _decimal_weights(raw_weights: Any) -> dict[str, float]:
  288. if not isinstance(raw_weights, dict):
  289. return {}
  290. return {
  291. str(key): float(value) / 100
  292. for key, value in raw_weights.items()
  293. if _is_number(value)
  294. }
  295. def _select_generic_score_weight_profile(profiles: Any, bundle: dict[str, Any]) -> dict[str, Any] | None:
  296. if not isinstance(profiles, dict) or not isinstance(profiles.get("profiles"), list):
  297. return None
  298. platform = str(_get_path(bundle, "content.platform") or "")
  299. content_format = str(_get_path(bundle, "content.platform_content_format") or "video")
  300. matches = [
  301. profile
  302. for profile in profiles["profiles"]
  303. if isinstance(profile, dict)
  304. and profile.get("approval_status") == "approved"
  305. and profile.get("runtime_enabled") is True
  306. and _scope_matches(profile.get("platform_scope"), platform)
  307. and _content_format_matches(profile.get("content_format"), content_format)
  308. and _applies_when_matches(str(profile.get("applies_when") or "always"), bundle)
  309. ]
  310. if not matches:
  311. return None
  312. matches.sort(key=lambda row: int(row.get("priority") or 0), reverse=True)
  313. if len(matches) > 1 and int(matches[0].get("priority") or 0) == int(matches[1].get("priority") or 0):
  314. raise ValueError(f"score_weight_profile_priority_conflict: {matches[0].get('profile_id')}, {matches[1].get('profile_id')}")
  315. return matches[0]
  316. def _scope_matches(scope: Any, platform: str) -> bool:
  317. values = scope if isinstance(scope, list) else [scope]
  318. return "*" in values or platform in values
  319. def _content_format_matches(scope: Any, content_format: str) -> bool:
  320. if scope in {None, "", "*"}:
  321. return True
  322. values = scope if isinstance(scope, list) else [scope]
  323. return "*" in values or content_format in values
  324. def _applies_when_matches(expr: str, bundle: dict[str, Any]) -> bool:
  325. if expr in {"", "always"}:
  326. return True
  327. if expr == "no_extra_dimension_available":
  328. return _fifty_plus_from_bundle(bundle) is None
  329. if expr.startswith("field_exists:"):
  330. return _get_path(bundle, expr.removeprefix("field_exists:")) is not None
  331. if expr.startswith("field_missing:"):
  332. return _get_path(bundle, expr.removeprefix("field_missing:")) is None
  333. if expr.startswith("field_equals:"):
  334. _, path, expected = expr.split(":", 2)
  335. return str(_get_path(bundle, path)) == expected
  336. raise ValueError(f"unsupported score_weight_profile applies_when: {expr}")
  337. def _v4_missing_critical_dimension_failure(profile: dict[str, Any], bundle: dict[str, Any]) -> dict[str, Any] | None:
  338. if profile.get("missing_dimension_policy") != "technical_retry":
  339. return None
  340. paths = profile.get("score_source_paths") if isinstance(profile.get("score_source_paths"), dict) else {}
  341. for dimension in profile.get("critical_dimensions") or []:
  342. source_path = paths.get(dimension)
  343. if source_path and _get_path(bundle, source_path) is None:
  344. return {
  345. "failure_type": "critical_dimension_missing",
  346. "final_status": "failed",
  347. "missing_dimension_key": dimension,
  348. "missing_dimension_score_source_path": source_path,
  349. "missing_dimension_failure_reason": f"missing critical dimension {dimension} at {source_path}",
  350. }
  351. return None
  352. def _v4_weighted_total(scores: dict[str, Any], weights: dict[str, float]) -> float | None:
  353. if not weights:
  354. return None
  355. total = 0.0
  356. for key, weight in weights.items():
  357. score = scores.get(key)
  358. if not _is_number(score):
  359. return None
  360. total += float(score) * weight
  361. return round(total, 2)
  362. def _fifty_plus_from_bundle(bundle: dict[str, Any]) -> dict[str, Any] | None:
  363. block = bundle.get("content_audience_50plus")
  364. return block if isinstance(block, dict) else None
  365. def _v4_technical_retry_reason(scorecard: dict[str, Any]) -> str:
  366. status = scorecard.get("fifty_plus_status")
  367. if status == "unavailable":
  368. return V4_PORTRAIT_UNAVAILABLE_REASON
  369. if status == "incomplete":
  370. return V4_PORTRAIT_INCOMPLETE_REASON
  371. return V4_TECHNICAL_RETRY_REASON
  372. def _query_relevance_from_bundle(bundle: dict[str, Any]) -> Any:
  373. return _get_path(bundle, "pattern_match_result.query_relevance_score")
  374. def _platform_performance_from_bundle(bundle: dict[str, Any]) -> dict[str, Any]:
  375. value = _get_path(bundle, "content_engagement_metrics.platform_performance")
  376. return value if isinstance(value, dict) else {}
  377. _V4_THRESHOLDS_FALLBACK = {
  378. "pool_total": 70, "pool_query": 70,
  379. "review_total": 55, "review_query": 55,
  380. "walk_query": 70, "walk_platform": 65, "walk_total": 70,
  381. }
  382. def _v4_thresholds(policy_bundle: dict[str, Any], platform: Any) -> dict[str, Any]:
  383. """按平台解析 V4 判定/游走门槛:score_thresholds.json 的 default 合并 per-platform 覆盖。
  384. 缺配置时回退到内置字面量(向后兼容)。改门槛只动 tech_documents/数据接口与来源/score_thresholds.json。"""
  385. cfg = policy_bundle.get("score_thresholds")
  386. if not isinstance(cfg, dict):
  387. return dict(_V4_THRESHOLDS_FALLBACK)
  388. default = cfg.get("default") if isinstance(cfg.get("default"), dict) else {}
  389. override = cfg.get(str(platform)) if isinstance(cfg.get(str(platform)), dict) else {}
  390. return {**_V4_THRESHOLDS_FALLBACK, **default, **override}
  391. def _v4_decision(scorecard: dict[str, Any], thresholds: dict[str, Any]) -> tuple[str, str]:
  392. query = scorecard.get("query_relevance_score")
  393. score = scorecard.get("total_score")
  394. if _is_number(query) and _is_number(score) and query >= thresholds["pool_query"] and score >= thresholds["pool_total"]:
  395. return "ADD_TO_CONTENT_POOL", V4_POOL_REASON
  396. if _is_number(query) and _is_number(score) and query >= thresholds["review_query"] and score >= thresholds["review_total"]:
  397. return "KEEP_CONTENT_FOR_REVIEW", V4_REVIEW_REASON
  398. return "REJECT_CONTENT", V4_REJECT_REASON
  399. def _technical_failure_fields(bundle: dict[str, Any]) -> dict[str, Any]:
  400. pattern = bundle.get("pattern_match_result")
  401. if not isinstance(pattern, dict):
  402. return {}
  403. fields = {}
  404. for field in ["failure_type", "exception_type", "http_status_code", "retry_count", "final_status"]:
  405. if field in pattern:
  406. fields[field] = pattern.get(field)
  407. return fields
  408. def _v4_allow_walk(scorecard: dict[str, Any], score: Any, thresholds: dict[str, Any]) -> bool:
  409. query = scorecard.get("query_relevance_score")
  410. platform = scorecard.get("platform_performance_score")
  411. return (
  412. _is_number(query)
  413. and _is_number(platform)
  414. and _is_number(score)
  415. and query >= thresholds["walk_query"]
  416. and platform >= thresholds["walk_platform"]
  417. and score >= thresholds["walk_total"]
  418. )
  419. def _v4_walk_gate(
  420. allow_walk: bool,
  421. reason: str,
  422. *,
  423. scorecard: dict[str, Any] | None = None,
  424. score: Any = None,
  425. thresholds: dict[str, Any] | None = None,
  426. ) -> dict[str, Any]:
  427. scorecard = scorecard or {}
  428. thresholds = thresholds or _V4_THRESHOLDS_FALLBACK
  429. snapshot = {
  430. "query_relevance_score": scorecard.get("query_relevance_score"),
  431. "platform_performance_score": scorecard.get("platform_performance_score"),
  432. "score": score,
  433. }
  434. return {
  435. "allow_walk": allow_walk,
  436. "allow_walk_reason": (
  437. f"query>={thresholds['walk_query']}/platform>={thresholds['walk_platform']}/score>={thresholds['walk_total']}"
  438. if allow_walk
  439. else reason
  440. ),
  441. "walk_gate_snapshot": snapshot,
  442. }
  443. def _build_decision(
  444. run_id: str,
  445. policy_run_id: str,
  446. index: int,
  447. bundle: dict[str, Any],
  448. policy_bundle: dict[str, Any],
  449. decision_action: str,
  450. decision_reason_code: str,
  451. search_query_effect_status: str,
  452. triggered_blocking_rules: list[str],
  453. scorecard: dict[str, Any],
  454. score: Any,
  455. replay_marker: dict[str, Any],
  456. ) -> dict[str, Any]:
  457. content = bundle["content"]
  458. decision = {
  459. "record_schema_version": RUNTIME_RECORD_SCHEMA_VERSION,
  460. "run_id": run_id,
  461. "policy_run_id": policy_run_id,
  462. "decision_id": f"d_{index:03d}",
  463. "policy_bundle_id": policy_bundle["policy_bundle_id"],
  464. "rule_pack_id": policy_bundle["rule_pack_id"],
  465. "rule_pack_version": policy_bundle["rule_pack_version"],
  466. "strategy_id": policy_bundle["strategy_id"],
  467. "strategy_version": policy_bundle["strategy_version"],
  468. "policy_bundle_hash": policy_bundle.get("policy_bundle_hash"),
  469. "dispatch_id": policy_bundle.get("dispatch_id"),
  470. "decision_target_type": content["decision_target_type"],
  471. "decision_target_id": content["decision_target_id"],
  472. "triggered_blocking_rules": triggered_blocking_rules,
  473. "scorecard": scorecard,
  474. "score": score,
  475. "decision_action": decision_action,
  476. "decision_reason_code": decision_reason_code,
  477. "search_query_effect_status": search_query_effect_status,
  478. "source_evidence": bundle["source_evidence"],
  479. "decision_input_snapshot_id": bundle["run_context"]["decision_input_snapshot_id"],
  480. "decision_evidence_refs": _evidence_refs(policy_bundle["rule_pack"]),
  481. "decision_replay_data": {
  482. "policy_run_id": policy_run_id,
  483. "policy_bundle_id": policy_bundle["policy_bundle_id"],
  484. "policy_bundle_hash": policy_bundle.get("policy_bundle_hash"),
  485. "rule_package_id": policy_bundle.get("rule_package_id"),
  486. "rule_pack_id": policy_bundle["rule_pack_id"],
  487. "rule_pack_version": policy_bundle["rule_pack_version"],
  488. "dispatch_id": policy_bundle.get("dispatch_id"),
  489. "runtime_stage": policy_bundle.get("runtime_stage"),
  490. "strategy_version": policy_bundle["strategy_version"],
  491. "runtime_record_schema_version": RUNTIME_RECORD_SCHEMA_VERSION,
  492. "decision_evidence_refs": _evidence_refs(policy_bundle["rule_pack"]),
  493. "missing_dimensions": [
  494. row["key"] for row in scorecard.get("dimensions", []) if row.get("score_missing")
  495. ],
  496. **replay_marker,
  497. },
  498. }
  499. return with_raw_payload(decision, exclude_keys=RULE_DECISION_RAW_PAYLOAD_EXCLUDE_KEYS)
  500. def _evaluate_hard_gates(bundle: dict[str, Any], gates: list[dict[str, Any]]) -> list[dict[str, Any]]:
  501. return [gate for gate in gates if _condition_matches(bundle, gate.get("when", {}))]
  502. def _primary_hard_gate(gates: list[dict[str, Any]]) -> dict[str, Any]:
  503. return sorted(gates, key=lambda gate: (gate.get("priority", 999), gate.get("gate_id", "")))[0]
  504. def _condition_matches(bundle: dict[str, Any], condition: dict[str, Any]) -> bool:
  505. value = _get_path(bundle, condition.get("field", ""))
  506. op = condition.get("op")
  507. expected = condition.get("value")
  508. if op == "is_empty":
  509. return _is_empty(value)
  510. if op == "in":
  511. return value in (expected or [])
  512. if op == "not_in":
  513. return value not in (expected or [])
  514. if op == "eq":
  515. return value == expected
  516. if op == "gte":
  517. return value is not None and value >= expected
  518. if op == "lt":
  519. return value is not None and value < expected
  520. if op == "lte":
  521. return value is not None and value <= expected
  522. raise ValueError(f"unsupported rule operator: {op}")
  523. def _match_threshold(score: int | float | None, thresholds: list[dict[str, Any]]) -> dict[str, Any] | None:
  524. if score is None:
  525. return None
  526. for threshold in thresholds:
  527. min_score = threshold.get("min_score")
  528. max_score = threshold.get("max_score")
  529. if min_score is not None and score < min_score:
  530. continue
  531. if max_score is not None and score > max_score:
  532. continue
  533. return threshold
  534. return None
  535. def _get_path(data: dict[str, Any], path: str) -> Any:
  536. current: Any = data
  537. for part in path.split("."):
  538. if not part:
  539. return None
  540. if not isinstance(current, dict):
  541. return None
  542. current = current.get(part)
  543. if current is None:
  544. return None
  545. return current
  546. def _is_empty(value: Any) -> bool:
  547. return value is None or value == "" or value == [] or value == {}
  548. def _is_number(value: Any) -> bool:
  549. return isinstance(value, (int, float)) and not isinstance(value, bool)
  550. def _threshold_label(threshold: dict[str, Any] | None) -> str:
  551. if not threshold:
  552. return "unknown"
  553. min_score = threshold.get("min_score")
  554. max_score = threshold.get("max_score")
  555. if min_score is not None and max_score is not None:
  556. return f"{min_score}<=score<={max_score}"
  557. if min_score is not None:
  558. return f"score>={min_score}"
  559. if max_score is not None:
  560. return f"score<={max_score}"
  561. return "unknown"
  562. def _scorecard(score: Any, config: dict[str, Any], missing: bool = False) -> dict[str, Any]:
  563. return {
  564. "total_score": score,
  565. "dimensions": [
  566. {
  567. "key": dimension.get("key"),
  568. "max_score": dimension.get("max_score"),
  569. "score": None,
  570. "evidence_paths": dimension.get("evidence_paths", []),
  571. }
  572. for dimension in config.get("dimensions", [])
  573. if dimension.get("runtime_status") == "active"
  574. ],
  575. "matched_scoring_rules": [],
  576. "score_missing": missing,
  577. }
  578. def _scorecard_total(bundle: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
  579. dimensions = [
  580. dimension for dimension in config.get("dimensions", []) if dimension.get("runtime_status") == "active"
  581. ]
  582. rules = [rule for rule in config.get("scoring_rules", []) if rule.get("enabled", True)]
  583. if dimensions and not rules:
  584. raise ValueError("active scorecard dimensions require scorecard.scoring_rules")
  585. dimension_rows = []
  586. matched_rules = []
  587. total_score = 0
  588. for dimension in dimensions:
  589. score, rule = _score_dimension(bundle, dimension, rules)
  590. total_score += score
  591. if rule:
  592. matched_rules.append(rule["scoring_rule_id"])
  593. dimension_rows.append(
  594. {
  595. "key": dimension.get("key"),
  596. "max_score": dimension.get("max_score"),
  597. "score": score,
  598. "matched_rule_id": rule.get("scoring_rule_id") if rule else None,
  599. "score_missing": rule is None,
  600. "evidence_paths": dimension.get("evidence_paths", []),
  601. }
  602. )
  603. return {
  604. "total_score": total_score if dimensions else None,
  605. "dimensions": dimension_rows,
  606. "matched_scoring_rules": matched_rules,
  607. "score_missing": False,
  608. }
  609. def _scorecard_has_any_evidence(scorecard: dict[str, Any]) -> bool:
  610. return any(not row.get("score_missing") for row in scorecard.get("dimensions", []))
  611. def _scorecard_all_dimensions_missing(scorecard: dict[str, Any]) -> bool:
  612. return not _scorecard_has_any_evidence(scorecard)
  613. def _score_dimension(
  614. bundle: dict[str, Any], dimension: dict[str, Any], rules: list[dict[str, Any]]
  615. ) -> tuple[int | float, dict[str, Any] | None]:
  616. dimension_rules = sorted(
  617. [rule for rule in rules if rule.get("dimension_key") == dimension.get("key")],
  618. key=lambda rule: (rule.get("priority", 999), rule.get("scoring_rule_id", "")),
  619. )
  620. if not dimension_rules:
  621. raise ValueError(f"active scorecard dimension lacks scoring rules: {dimension.get('key')}")
  622. for rule in dimension_rules:
  623. condition = {
  624. "field": rule.get("field_path", ""),
  625. "op": rule.get("operator"),
  626. "value": rule.get("expected_value"),
  627. }
  628. if _condition_matches(bundle, condition):
  629. return rule.get("score_value", 0), rule
  630. return 0, None
  631. def _missing_score_decision(
  632. run_id: str,
  633. policy_run_id: str,
  634. index: int,
  635. bundle: dict[str, Any],
  636. policy_bundle: dict[str, Any],
  637. matched_gates: list[dict[str, Any]],
  638. scorecard: dict[str, Any],
  639. ) -> dict[str, Any]:
  640. missing_policy = policy_bundle["rule_pack"].get("scorecard", {}).get("score_missing_policy", {})
  641. decision_action = missing_policy.get("decision_action", "REJECT_CONTENT")
  642. decision_reason_code = missing_policy.get("decision_reason_code", "missing_score")
  643. effect = _effect_status_for_decision(
  644. policy_bundle, decision_action, SCORE_REASON_CATEGORY, is_hard_gate=False
  645. )
  646. return _build_decision(
  647. run_id=run_id,
  648. policy_run_id=policy_run_id,
  649. index=index,
  650. bundle=bundle,
  651. policy_bundle=policy_bundle,
  652. decision_action=decision_action,
  653. decision_reason_code=decision_reason_code,
  654. search_query_effect_status=effect["content_effect_status"],
  655. triggered_blocking_rules=[],
  656. scorecard={**scorecard, "score_missing": True},
  657. score=None,
  658. replay_marker={
  659. "matched_gates": [_gate_replay(gate) for gate in matched_gates],
  660. "score_missing_policy": missing_policy,
  661. "effect_mapping_id": effect.get("mapping_id"),
  662. "reason_category": SCORE_REASON_CATEGORY,
  663. },
  664. )
  665. def _effect_status_for_decision(
  666. policy_bundle: dict[str, Any],
  667. decision_action: str,
  668. reason_category: str,
  669. is_hard_gate: bool,
  670. ) -> dict[str, Any]:
  671. candidates = [
  672. mapping
  673. for mapping in policy_bundle.get("effect_status_mapping", [])
  674. if mapping.get("enabled", True)
  675. and mapping.get("target_level") == "content"
  676. and mapping.get("decision_action") == decision_action
  677. and mapping.get("is_hard_gate") is is_hard_gate
  678. ]
  679. exact = [mapping for mapping in candidates if mapping.get("reason_category") == reason_category]
  680. matches = exact or candidates
  681. if (
  682. not matches
  683. and decision_action == V4_TECHNICAL_RETRY_ACTION
  684. and reason_category == TECHNICAL_REASON_CATEGORY
  685. and not is_hard_gate
  686. ):
  687. return {
  688. "mapping_id": "fallback_technical_retry_failed",
  689. "content_effect_status": "failed",
  690. "query_effect_status": "failed",
  691. }
  692. if not matches:
  693. raise ValueError(f"effect mapping not found for {decision_action}/{reason_category}")
  694. return sorted(matches, key=lambda mapping: (mapping.get("priority", 999), mapping.get("mapping_id", "")))[0]
  695. def _reason_category_for_threshold(decision_action: str, decision_reason_code: str) -> str:
  696. if decision_reason_code == V4_TECHNICAL_RETRY_REASON:
  697. return TECHNICAL_REASON_CATEGORY
  698. if decision_action == "ADD_TO_CONTENT_POOL":
  699. return SUCCESS_REASON_CATEGORY
  700. if decision_action == "KEEP_CONTENT_FOR_REVIEW":
  701. return REVIEW_REASON_CATEGORY
  702. if decision_reason_code == "missing_score":
  703. return SCORE_REASON_CATEGORY
  704. return SCORE_REASON_CATEGORY
  705. def _gate_replay(gate: dict[str, Any]) -> dict[str, Any]:
  706. return {
  707. "gate_id": gate.get("gate_id"),
  708. "decision_reason_code": gate.get("decision_reason_code"),
  709. "priority": gate.get("priority"),
  710. "severity": gate.get("severity"),
  711. "stop_scoring": gate.get("stop_scoring"),
  712. }
  713. def _evidence_refs(rule_pack: dict[str, Any]) -> list[str]:
  714. refs = ["source_evidence"]
  715. for field in rule_pack.get("input_contract", {}).get("required_fields", []):
  716. if field not in refs:
  717. refs.append(field)
  718. return refs