m6_acceptance_report.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. from __future__ import annotations
  2. from collections import Counter
  3. from typing import Any, Iterable
  4. from content_agent.dashboard_service import _timeline_summary
  5. from content_agent.interfaces import RuntimeStore
  6. SCHEMA_VERSION = "v4_m6_acceptance_report.v1"
  7. REPORT_INPUT_FILES = [
  8. "run_events.jsonl",
  9. "pattern_recall_evidence.jsonl",
  10. "rule_decisions.jsonl",
  11. "walk_actions.jsonl",
  12. "final_output.json",
  13. "strategy_review.json",
  14. ]
  15. def build_report(
  16. run_id: str,
  17. runtime: RuntimeStore,
  18. *,
  19. platform: str | None = None,
  20. platform_mode: str | None = None,
  21. ) -> dict[str, Any]:
  22. run_events = _read_jsonl(runtime, run_id, "run_events.jsonl")
  23. evidence_rows = _read_jsonl(runtime, run_id, "pattern_recall_evidence.jsonl")
  24. decisions = _read_jsonl(runtime, run_id, "rule_decisions.jsonl")
  25. walk_actions = _read_jsonl(runtime, run_id, "walk_actions.jsonl")
  26. source_paths = _read_jsonl(runtime, run_id, "source_path_records.jsonl")
  27. final_output = _read_json(runtime, run_id, "final_output.json")
  28. strategy_review = _read_json(runtime, run_id, "strategy_review.json")
  29. return {
  30. "schema_version": SCHEMA_VERSION,
  31. "run_id": run_id,
  32. "policy_run_id": _policy_run_id(final_output, decisions, run_events),
  33. "platform": platform or _first_value(decisions, "platform"),
  34. "platform_mode": platform_mode,
  35. "generated_from": {
  36. "runtime_files": {
  37. filename: bool(_file_exists(runtime, run_id, filename))
  38. for filename in REPORT_INPUT_FILES
  39. }
  40. },
  41. "gemini_summary": _gemini_summary(run_events, evidence_rows),
  42. "performance": _performance_summary(run_events, walk_actions, source_paths),
  43. "score_calibration": _score_calibration(decisions),
  44. "observability": _observability_summary(decisions, walk_actions, final_output),
  45. "strategy_review_summary": _strategy_review_summary(strategy_review),
  46. "known_gaps": [
  47. {
  48. "gap_id": "tag_relevance_not_production",
  49. "description": (
  50. "V4 plan mentions tag Gemini relevance, but current M4/M6 scope does "
  51. "not implement it as production semantics."
  52. ),
  53. "severity": "planning_gap",
  54. "blocking_m6_acceptance": False,
  55. }
  56. ],
  57. }
  58. def _read_json(runtime: RuntimeStore, run_id: str, filename: str) -> dict[str, Any]:
  59. try:
  60. return runtime.read_json(run_id, filename)
  61. except FileNotFoundError:
  62. return {}
  63. def _read_jsonl(runtime: RuntimeStore, run_id: str, filename: str) -> list[dict[str, Any]]:
  64. try:
  65. return runtime.read_jsonl(run_id, filename)
  66. except FileNotFoundError:
  67. return []
  68. def _file_exists(runtime: RuntimeStore, run_id: str, filename: str) -> bool:
  69. try:
  70. return bool(runtime.file_status(run_id).get(filename))
  71. except Exception:
  72. try:
  73. if filename.endswith(".jsonl"):
  74. return bool(runtime.read_jsonl(run_id, filename))
  75. return bool(runtime.read_json(run_id, filename))
  76. except Exception:
  77. return False
  78. def _policy_run_id(
  79. final_output: dict[str, Any],
  80. decisions: list[dict[str, Any]],
  81. run_events: list[dict[str, Any]],
  82. ) -> str | None:
  83. return (
  84. final_output.get("policy_run_id")
  85. or _first_value(decisions, "policy_run_id")
  86. or _first_value(run_events, "policy_run_id")
  87. )
  88. def _first_value(rows: list[dict[str, Any]], key: str) -> Any:
  89. for row in rows:
  90. value = row.get(key)
  91. if value not in (None, ""):
  92. return value
  93. return None
  94. def _gemini_summary(
  95. run_events: list[dict[str, Any]],
  96. evidence_rows: list[dict[str, Any]],
  97. ) -> dict[str, Any]:
  98. summaries = [
  99. row.get("evidence_summary") or row.get("raw_payload") or {}
  100. for row in evidence_rows
  101. ]
  102. failure_type_counts: Counter[str] = Counter()
  103. http_status_counts: Counter[str] = Counter()
  104. retry_rescued_count = 0
  105. final_failed_count = 0
  106. successful_count = 0
  107. for summary in summaries:
  108. final_status = str(summary.get("final_status") or "")
  109. failure_type = summary.get("failure_type")
  110. if failure_type:
  111. failure_type_counts[str(failure_type)] += 1
  112. http_status = summary.get("http_status_code")
  113. if http_status not in (None, ""):
  114. http_status_counts[str(http_status)] += 1
  115. retry_count = _int_value(summary.get("retry_count"))
  116. if final_status in {"ok", "success"}:
  117. successful_count += 1
  118. if retry_count > 0:
  119. retry_rescued_count += 1
  120. elif final_status:
  121. final_failed_count += 1
  122. return {
  123. "used": len(summaries),
  124. "cap": None,
  125. "quota_exhausted": False,
  126. "successful_count": successful_count,
  127. "failure_type_counts": dict(failure_type_counts),
  128. "http_status_counts": dict(http_status_counts),
  129. "retry_rescued_count": retry_rescued_count,
  130. "final_failed_count": final_failed_count,
  131. }
  132. def _performance_summary(
  133. run_events: list[dict[str, Any]],
  134. walk_actions: list[dict[str, Any]],
  135. source_paths: list[dict[str, Any]],
  136. ) -> dict[str, Any]:
  137. timeline = _timeline_summary(run_events, walk_actions, source_paths)
  138. return {
  139. "total_duration_ms": timeline.get("total_duration_ms"),
  140. "stage_duration_ms": timeline.get("stage_duration_ms", {}),
  141. "platform_query_failure_count": _platform_query_failure_count(run_events),
  142. "platform_rate_limited_count": timeline.get("platform_rate_limited_count", 0),
  143. "error_counts": timeline.get("error_counts", {}),
  144. "walk_status_counts": timeline.get("walk_status_counts", {}),
  145. }
  146. def _platform_query_failure_count(run_events: list[dict[str, Any]]) -> int:
  147. return sum(1 for row in run_events if row.get("event_type") == "platform_query_failed")
  148. def _score_calibration(decisions: list[dict[str, Any]]) -> dict[str, Any]:
  149. return {
  150. "decision_count": len(decisions),
  151. "score_ranges": {
  152. "query_relevance_score": _range_summary(
  153. (row.get("scorecard") or {}).get("query_relevance_score")
  154. for row in decisions
  155. ),
  156. "platform_performance_score": _range_summary(
  157. (row.get("scorecard") or {}).get("platform_performance_score")
  158. for row in decisions
  159. ),
  160. "score": _range_summary(row.get("score") for row in decisions),
  161. },
  162. "threshold_edge_samples": _threshold_edge_samples(decisions),
  163. "decision_action_counts": dict(Counter(row.get("decision_action") for row in decisions)),
  164. }
  165. def _range_summary(values: Iterable[Any]) -> dict[str, float | None]:
  166. numeric = [
  167. float(value)
  168. for value in values
  169. if isinstance(value, (int, float)) and not isinstance(value, bool)
  170. ]
  171. if not numeric:
  172. return {"min": None, "max": None, "avg": None}
  173. return {
  174. "min": min(numeric),
  175. "max": max(numeric),
  176. "avg": round(sum(numeric) / len(numeric), 4),
  177. }
  178. def _threshold_edge_samples(decisions: list[dict[str, Any]]) -> list[dict[str, Any]]:
  179. samples = []
  180. for row in decisions:
  181. score = row.get("score")
  182. if not isinstance(score, (int, float)) or isinstance(score, bool):
  183. continue
  184. if 53 <= score <= 57 or 68 <= score <= 72:
  185. samples.append(
  186. {
  187. "decision_id": row.get("decision_id"),
  188. "decision_target_id": row.get("decision_target_id"),
  189. "score": score,
  190. "decision_action": row.get("decision_action"),
  191. "decision_reason_code": row.get("decision_reason_code"),
  192. }
  193. )
  194. return samples[:20]
  195. def _observability_summary(
  196. decisions: list[dict[str, Any]],
  197. walk_actions: list[dict[str, Any]],
  198. final_output: dict[str, Any],
  199. ) -> dict[str, Any]:
  200. return {
  201. "missing_observable_fields": _missing_observable_fields(decisions),
  202. "allow_walk_distribution": _allow_walk_distribution(decisions),
  203. "walk_deny_reasons": _walk_deny_reasons(walk_actions),
  204. "author_asset_count": len(final_output.get("author_assets") or []),
  205. }
  206. def _missing_observable_fields(decisions: list[dict[str, Any]]) -> list[dict[str, Any]]:
  207. counts: Counter[tuple[str, str]] = Counter()
  208. for row in decisions:
  209. for item in (row.get("scorecard") or {}).get("missing_observable_fields") or []:
  210. if isinstance(item, dict):
  211. field = str(item.get("field") or "unknown")
  212. reason = str(item.get("reason") or item.get("missing_type") or "unknown")
  213. else:
  214. field = str(item)
  215. reason = "unknown"
  216. counts[(field, reason)] += 1
  217. return [
  218. {"field": field, "reason": reason, "count": count}
  219. for (field, reason), count in sorted(counts.items())
  220. ]
  221. def _allow_walk_distribution(decisions: list[dict[str, Any]]) -> dict[str, int]:
  222. counts = Counter()
  223. for row in decisions:
  224. replay_data = row.get("decision_replay_data") or {}
  225. if replay_data.get("allow_walk") is True:
  226. counts["allowed"] += 1
  227. elif replay_data.get("allow_walk") is False:
  228. counts["denied"] += 1
  229. else:
  230. counts["missing"] += 1
  231. return {
  232. "allowed": counts["allowed"],
  233. "denied": counts["denied"],
  234. "missing": counts["missing"],
  235. }
  236. def _walk_deny_reasons(walk_actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
  237. counts: Counter[str] = Counter()
  238. for row in walk_actions:
  239. if row.get("walk_status") not in {"skipped", "failed", "blocked"}:
  240. continue
  241. reason = (
  242. row.get("reason_code")
  243. or (row.get("raw_payload") or {}).get("walk_gate_reason_code")
  244. or "unknown"
  245. )
  246. counts[str(reason)] += 1
  247. return [
  248. {"reason_code": reason, "count": count}
  249. for reason, count in counts.most_common()
  250. ]
  251. def _strategy_review_summary(strategy_review: dict[str, Any]) -> dict[str, Any]:
  252. v4_summary = strategy_review.get("v4_summary") or {}
  253. if not v4_summary and isinstance(strategy_review.get("raw_payload"), dict):
  254. v4_summary = strategy_review["raw_payload"].get("v4_summary") or {}
  255. return {
  256. "review_status": strategy_review.get("review_status", "not_generated"),
  257. "v4_summary_schema_version": v4_summary.get("schema_version"),
  258. "score_buckets": v4_summary.get("score_buckets", {}),
  259. "allow_walk_distribution": v4_summary.get("allow_walk_distribution", {}),
  260. "walk_gate_review": v4_summary.get("walk_gate_review", {}),
  261. }
  262. def _int_value(value: Any) -> int:
  263. if isinstance(value, bool):
  264. return 0
  265. if isinstance(value, int):
  266. return value
  267. if isinstance(value, float):
  268. return int(value)
  269. try:
  270. return int(str(value))
  271. except (TypeError, ValueError):
  272. return 0