run_v4_m6_real_acceptance.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import sys
  6. from pathlib import Path
  7. from typing import Any
  8. ROOT = Path(__file__).resolve().parents[1]
  9. if str(ROOT) not in sys.path:
  10. sys.path.insert(0, str(ROOT))
  11. from content_agent.business_modules.m6_acceptance_report import build_report
  12. from content_agent.dashboard_service import DashboardService
  13. from content_agent.run_service import RunService
  14. from content_agent.schemas import RunStartRequest
  15. PLATFORMS = ("douyin", "kuaishou", "shipinhao")
  16. ALLOWED_DATA_ORIGINS = {"production_db", "mixed_with_runtime_export"}
  17. SMOKE_DEFAULTS = {
  18. "CONTENT_AGENT_GEMINI_MAX_WORKERS": "2",
  19. "CONTENT_AGENT_WALK_MAX_TOTAL_ACTIONS_PER_RUN": "4",
  20. }
  21. def main() -> int:
  22. if not _db_runtime_enabled():
  23. raise SystemExit("CONTENT_AGENT_DB_RUNTIME_ENABLED=1 is required for M6 real acceptance")
  24. _apply_smoke_defaults()
  25. service = RunService.from_env()
  26. dashboard_service = DashboardService.from_runtime(service.runtime)
  27. results = []
  28. for platform in PLATFORMS:
  29. state = service.start_run(
  30. RunStartRequest(platform=platform, platform_mode="real", strategy_version="V4")
  31. )
  32. run_id = state["run_id"]
  33. result = _collect_run_result(service, dashboard_service, state, platform)
  34. results.append(result)
  35. result["acceptance_status"] = _acceptance_status(result)
  36. payload = {
  37. "schema_version": "v4_m6_real_acceptance.v1",
  38. "source_kind": "db_default_safe_source",
  39. "smoke_limits": _smoke_limits(),
  40. "platforms": list(PLATFORMS),
  41. "results": results,
  42. "status": "pass" if all(row["acceptance_status"] == "pass" for row in results) else "fail",
  43. }
  44. print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
  45. return 0 if payload["status"] == "pass" else 1
  46. def _apply_smoke_defaults() -> None:
  47. for key, value in SMOKE_DEFAULTS.items():
  48. os.environ.setdefault(key, value)
  49. def _smoke_limits() -> dict[str, str | None]:
  50. return {key: os.environ.get(key) for key in SMOKE_DEFAULTS}
  51. def _collect_run_result(
  52. service: RunService,
  53. dashboard_service: DashboardService,
  54. state: dict[str, Any],
  55. platform: str,
  56. ) -> dict[str, Any]:
  57. run_id = state["run_id"]
  58. summary = _safe_call(lambda: service.get_summary(run_id), {})
  59. validation = _safe_call(lambda: service.validate_run(run_id), {"status": "fail", "findings": []})
  60. dashboard = _safe_call(lambda: dashboard_service.dashboard(run_id), {})
  61. timeline = _safe_call(lambda: dashboard_service.timeline(run_id), {})
  62. strategy_review = _safe_call(lambda: service.strategy_review(run_id), {})
  63. runtime_files = _safe_call(lambda: dashboard_service.runtime_files(run_id), {})
  64. report = _safe_call(
  65. lambda: build_report(run_id, service.runtime, platform=platform, platform_mode="real"),
  66. {},
  67. )
  68. data_origin = dashboard.get("data_origin") or runtime_files.get("data_origin")
  69. return {
  70. "platform": platform,
  71. "platform_mode": "real",
  72. "run_id": run_id,
  73. "policy_run_id": state.get("policy_run_id") or summary.get("policy_run_id"),
  74. "status": state.get("status"),
  75. "validation_status": validation.get("status"),
  76. "output_dir": summary.get("output_dir"),
  77. "data_origin": data_origin,
  78. "db_run_record_present": _db_run_record_present(dashboard, data_origin),
  79. "runtime_files": _runtime_file_status(runtime_files),
  80. "real_interface_progress": _real_interface_progress(runtime_files, timeline),
  81. "dashboard_summary": dashboard.get("summary", {}),
  82. "timeline_summary": timeline.get("summary", {}),
  83. "strategy_review_status": strategy_review.get("review_status"),
  84. "failure_classification": _failure_classification(state, dashboard),
  85. "m6_report": report,
  86. }
  87. def _safe_call(fn: Any, fallback: Any) -> Any:
  88. try:
  89. return fn()
  90. except Exception as exc:
  91. return {"error": type(exc).__name__, "message": str(exc)} if isinstance(fallback, dict) else fallback
  92. def _runtime_file_status(runtime_files: dict[str, Any]) -> dict[str, bool]:
  93. return {
  94. item.get("filename"): bool(item.get("exists"))
  95. for item in runtime_files.get("files", [])
  96. if item.get("filename")
  97. }
  98. def _real_interface_progress(
  99. runtime_files: dict[str, Any],
  100. timeline: dict[str, Any],
  101. ) -> dict[str, bool]:
  102. file_status = _runtime_file_status(runtime_files)
  103. stages = (timeline.get("summary") or {}).get("stage_duration_ms") or {}
  104. return {
  105. "source_loaded": bool(file_status.get("source_context.json")),
  106. "query_generated": bool(file_status.get("search_queries.jsonl")),
  107. "platform_attempted": "search_platform" in stages
  108. or bool(file_status.get("discovered_content_items.jsonl")),
  109. "scoring_attempted": "evaluate_rules" in stages
  110. or bool(file_status.get("rule_decisions.jsonl")),
  111. "final_output_generated": bool(file_status.get("final_output.json")),
  112. }
  113. def _acceptance_status(result: dict[str, Any]) -> str:
  114. if not result["db_run_record_present"]:
  115. return "fail"
  116. if not result["real_interface_progress"]["query_generated"]:
  117. return "blocked_before_platform"
  118. if result["status"] == "failed" and not result["failure_classification"]:
  119. return "fail"
  120. return "pass"
  121. def _db_run_record_present(dashboard: dict[str, Any], data_origin: str | None) -> bool:
  122. if data_origin not in ALLOWED_DATA_ORIGINS:
  123. return False
  124. summary = dashboard.get("summary") or {}
  125. return bool(summary.get("run_id"))
  126. def _failure_classification(
  127. state: dict[str, Any],
  128. dashboard: dict[str, Any],
  129. ) -> dict[str, Any] | None:
  130. if state.get("status") not in {"failed", "partial_success"}:
  131. return None
  132. primary = dashboard.get("primary_failure_reason") or {}
  133. error_code = state.get("error_code") or primary.get("reason_code")
  134. error_detail = state.get("error_detail") or {}
  135. return {
  136. "category": _failure_category(error_code, error_detail),
  137. "error_code": error_code,
  138. "message": state.get("error_message") or primary.get("message"),
  139. "error_detail": error_detail,
  140. "primary_failure_reason": primary,
  141. }
  142. def _failure_category(error_code: str | None, detail: dict[str, Any]) -> str:
  143. code = str(error_code or "")
  144. text = json.dumps(detail, ensure_ascii=False, default=str).lower()
  145. if code in {"PLATFORM_CONFIG_MISSING", "DB_CONFIG_MISSING", "INVALID_SOURCE", "INVALID_REQUEST"}:
  146. return "configuration"
  147. if code in {"PLATFORM_RATE_LIMITED"} or "429" in text or "rate" in text:
  148. return "rate_limit"
  149. if code.startswith("PLATFORM_"):
  150. return "upstream_platform"
  151. if "gemini" in text or "openrouter" in text:
  152. return "gemini_technical_failure"
  153. if "timeout" in text or "network" in text or "connection" in text:
  154. return "network_or_vpn"
  155. return "runtime_or_validator"
  156. def _db_runtime_enabled() -> bool:
  157. value = os.environ.get("CONTENT_AGENT_DB_RUNTIME_ENABLED")
  158. if value is None:
  159. value = _env_file_value("CONTENT_AGENT_DB_RUNTIME_ENABLED")
  160. return str(value or "").lower() in {"1", "true", "yes", "on"}
  161. def _env_file_value(key: str) -> str | None:
  162. try:
  163. lines = open(".env", encoding="utf-8").read().splitlines()
  164. except FileNotFoundError:
  165. return None
  166. for line in lines:
  167. stripped = line.strip()
  168. if not stripped or stripped.startswith("#") or "=" not in stripped:
  169. continue
  170. name, value = stripped.split("=", 1)
  171. if name.strip() == key:
  172. return value.strip().strip('"').strip("'")
  173. return None
  174. if __name__ == "__main__":
  175. raise SystemExit(main())