dashboard_service.py 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159
  1. from __future__ import annotations
  2. import os
  3. import socket
  4. from datetime import date, datetime
  5. from pathlib import Path
  6. from typing import Any
  7. from content_agent.business_modules.run_record import validate_run
  8. from content_agent.integrations.composite_runtime import CompositeRuntimeStore
  9. from content_agent.integrations.database_runtime import DatabaseRuntimeStore
  10. from content_agent.integrations.database_runtime import RUNTIME_FILE_TABLES
  11. from content_agent.integrations.runtime_files import RUNTIME_FILENAMES
  12. from content_agent.interfaces import RuntimeStore
  13. DATA_ORIGIN_PRODUCTION_DB = "production_db"
  14. DATA_ORIGIN_RUNTIME_EXPORT = "runtime_export"
  15. DATA_ORIGIN_MIXED = "mixed_with_runtime_export"
  16. class DashboardService:
  17. def __init__(
  18. self,
  19. runtime: RuntimeStore,
  20. export_runtime: RuntimeStore | None = None,
  21. ) -> None:
  22. self.runtime = runtime
  23. self.export_runtime = export_runtime
  24. @classmethod
  25. def from_runtime(cls, runtime: RuntimeStore) -> "DashboardService":
  26. if isinstance(runtime, CompositeRuntimeStore):
  27. return cls(runtime.primary, runtime.export)
  28. return cls(runtime)
  29. @property
  30. def data_origin(self) -> str:
  31. if isinstance(self.runtime, DatabaseRuntimeStore):
  32. return DATA_ORIGIN_PRODUCTION_DB
  33. return DATA_ORIGIN_RUNTIME_EXPORT
  34. def run_exists(self, run_id: str) -> bool:
  35. if isinstance(self.runtime, DatabaseRuntimeStore):
  36. row = self.runtime._fetch_one(
  37. "SELECT COUNT(*) AS cnt FROM `content_agent_runs` WHERE `run_id` = %s",
  38. (run_id,),
  39. )
  40. return bool(row and row.get("cnt", 0))
  41. try:
  42. return self.runtime.run_dir(run_id).exists()
  43. except Exception:
  44. return any(self.runtime.file_status(run_id).values())
  45. def list_runs(
  46. self,
  47. *,
  48. status: str | None = None,
  49. platform: str | None = None,
  50. platform_mode: str | None = None,
  51. strategy_version: str | None = None,
  52. validation_status: str | None = None,
  53. error_code: str | None = None,
  54. page: int = 1,
  55. page_size: int = 20,
  56. ) -> dict[str, Any]:
  57. page = max(page, 1)
  58. page_size = min(max(page_size, 1), 100)
  59. if isinstance(self.runtime, DatabaseRuntimeStore):
  60. return self._list_db_runs(
  61. status=status,
  62. platform=platform,
  63. platform_mode=platform_mode,
  64. strategy_version=strategy_version,
  65. validation_status=validation_status,
  66. error_code=error_code,
  67. page=page,
  68. page_size=page_size,
  69. )
  70. items = [
  71. self._apply_liveness(self._local_run_list_item(run_id))
  72. for run_id in getattr(self.runtime, "list_runs", lambda: [])()
  73. ]
  74. items = [
  75. item
  76. for item in items
  77. if _matches(item, "status", status)
  78. and _matches(item, "platform", platform)
  79. and _matches(item, "platform_mode", platform_mode)
  80. and _matches(item, "strategy_version", strategy_version)
  81. and _matches(item, "validation_status", validation_status)
  82. and _matches(item, "error_code", error_code)
  83. ]
  84. total = len(items)
  85. offset = (page - 1) * page_size
  86. return {
  87. "items": items[offset:offset + page_size],
  88. "page": page,
  89. "page_size": page_size,
  90. "total": total,
  91. "data_origin": self.data_origin,
  92. }
  93. def dashboard(self, run_id: str) -> dict[str, Any]:
  94. runtime_files_response = self.runtime_files(run_id)
  95. runtime_files = runtime_files_response["files"]
  96. file_status = {item["filename"]: item["exists"] for item in runtime_files}
  97. record_counts = {item["filename"]: item["record_count"] for item in runtime_files}
  98. final_output = self._read_json_optional(run_id, "final_output.json")
  99. strategy_review = self._read_json_optional(run_id, "strategy_review.json")
  100. source_context = self._read_json_optional(run_id, "source_context.json") or {}
  101. validation = self._validate_optional(run_id)
  102. run_item = self._db_run_item(run_id) if isinstance(self.runtime, DatabaseRuntimeStore) else self._local_run_list_item(run_id)
  103. queries = self._read_jsonl_optional(run_id, "search_queries.jsonl")
  104. run_events = self._read_jsonl_optional(run_id, "run_events.jsonl")
  105. content_items = self._read_jsonl_optional(run_id, "discovered_content_items.jsonl")
  106. decisions = self._read_jsonl_optional(run_id, "rule_decisions.jsonl")
  107. walk_actions = self._read_jsonl_optional(run_id, "walk_actions.jsonl")
  108. source_paths = self._read_jsonl_optional(run_id, "source_path_records.jsonl")
  109. counts = {
  110. "queries": record_counts.get("search_queries.jsonl", 0),
  111. "discovered_content_items": record_counts.get("discovered_content_items.jsonl", 0),
  112. "rule_decisions": record_counts.get("rule_decisions.jsonl", 0),
  113. "walk_actions": record_counts.get("walk_actions.jsonl", 0),
  114. "source_path_records": record_counts.get("source_path_records.jsonl", 0),
  115. "run_events": record_counts.get("run_events.jsonl", 0),
  116. }
  117. primary_failure_reason = _primary_failure_reason(run_item, run_events)
  118. return _json_safe({
  119. "run_id": run_id,
  120. "summary": run_item,
  121. "counts": counts,
  122. "files": file_status,
  123. "runtime_files": runtime_files,
  124. "validation": validation,
  125. "final_output_summary": (final_output or {}).get("summary", {}),
  126. "strategy_review_status": (strategy_review or {}).get(
  127. "review_status",
  128. "not_generated",
  129. ),
  130. "business_summary": _business_summary(
  131. run_item,
  132. counts,
  133. final_output or {},
  134. primary_failure_reason,
  135. ),
  136. "stage_conclusions": _stage_conclusions(
  137. file_status,
  138. counts,
  139. run_item,
  140. queries,
  141. run_events,
  142. decisions,
  143. walk_actions,
  144. final_output or {},
  145. strategy_review or {},
  146. source_context,
  147. ),
  148. "rule_application_summary": _rule_application_summary(decisions, content_items),
  149. "walk_graph": _walk_graph(queries, content_items, walk_actions, source_paths),
  150. "primary_failure_reason": primary_failure_reason,
  151. "technical_refs": {
  152. "runtime_files_url": f"/runs/{run_id}/runtime-files",
  153. "validation_url": f"/runs/{run_id}/validation",
  154. "data_origin": runtime_files_response["data_origin"],
  155. "runtime_file_count": len(runtime_files),
  156. },
  157. "data_origin": runtime_files_response["data_origin"],
  158. "links": {
  159. "queries": f"/runs/{run_id}/queries",
  160. "timeline": f"/runs/{run_id}/timeline",
  161. "content_items": f"/runs/{run_id}/content-items",
  162. "runtime_files": f"/runs/{run_id}/runtime-files",
  163. },
  164. })
  165. def runtime_files(self, run_id: str) -> dict[str, Any]:
  166. if isinstance(self.runtime, DatabaseRuntimeStore):
  167. counts = self._db_runtime_file_counts(run_id)
  168. files = [
  169. {
  170. "filename": filename,
  171. "exists": counts.get(filename, 0) > 0,
  172. "record_count": counts.get(filename, 0),
  173. "file_type": "jsonl" if filename.endswith(".jsonl") else "json",
  174. "contract_status": "current",
  175. "data_origin": DATA_ORIGIN_PRODUCTION_DB,
  176. }
  177. for filename in RUNTIME_FILENAMES
  178. ]
  179. return {
  180. "run_id": run_id,
  181. "files": files,
  182. "data_origin": DATA_ORIGIN_PRODUCTION_DB,
  183. }
  184. status = self.runtime.file_status(run_id)
  185. files = []
  186. for filename in RUNTIME_FILENAMES:
  187. exists = bool(status.get(filename))
  188. files.append({
  189. "filename": filename,
  190. "exists": exists,
  191. "record_count": self._runtime_record_count(run_id, filename) if exists else 0,
  192. "file_type": "jsonl" if filename.endswith(".jsonl") else "json",
  193. "contract_status": "current",
  194. "data_origin": self._file_origin(run_id, filename),
  195. })
  196. return {
  197. "run_id": run_id,
  198. "files": files,
  199. "data_origin": self._combined_origin(run_id),
  200. }
  201. def runtime_file(
  202. self,
  203. run_id: str,
  204. filename: str,
  205. *,
  206. limit: int = 100,
  207. offset: int = 0,
  208. ) -> dict[str, Any]:
  209. self._validate_runtime_filename(filename)
  210. limit = min(max(limit, 1), 500)
  211. offset = max(offset, 0)
  212. if filename.endswith(".jsonl"):
  213. records = self._read_jsonl_optional(run_id, filename)
  214. return {
  215. "run_id": run_id,
  216. "filename": filename,
  217. "records": _json_safe(records[offset:offset + limit]),
  218. "offset": offset,
  219. "limit": limit,
  220. "total": len(records),
  221. "data_origin": self._file_origin(run_id, filename),
  222. }
  223. data = self._read_json_optional(run_id, filename) or {}
  224. return {
  225. "run_id": run_id,
  226. "filename": filename,
  227. "data": _json_safe(data),
  228. "data_origin": self._file_origin(run_id, filename),
  229. }
  230. def queries(self, run_id: str) -> dict[str, Any]:
  231. queries = self._read_jsonl_optional(run_id, "search_queries.jsonl")
  232. clues_by_query = {
  233. row.get("search_query_id"): row
  234. for row in self._read_jsonl_optional(run_id, "search_clues.jsonl")
  235. }
  236. decisions = self._read_jsonl_optional(run_id, "rule_decisions.jsonl")
  237. decision_counts: dict[str, dict[str, int]] = {}
  238. for decision in decisions:
  239. query_id = decision.get("search_query_id")
  240. if not query_id:
  241. continue
  242. counts = decision_counts.setdefault(query_id, {})
  243. action = decision.get("decision_action") or "unknown"
  244. counts[action] = counts.get(action, 0) + 1
  245. items = []
  246. for query in queries:
  247. query_id = query.get("search_query_id")
  248. clue = clues_by_query.get(query_id) or {}
  249. items.append(_json_safe({
  250. **query,
  251. "search_clue": clue,
  252. "decision_action_counts": decision_counts.get(query_id, {}),
  253. "search_query_effect_status": clue.get(
  254. "search_query_effect_status",
  255. query.get("search_query_effect_status"),
  256. ),
  257. "walk_next_step": clue.get("walk_next_step"),
  258. "failure_reason": clue.get("failure_reason")
  259. or (clue.get("raw_payload") or {}).get("failure_reason"),
  260. }))
  261. return {
  262. "run_id": run_id,
  263. "items": items,
  264. "total": len(items),
  265. "data_origin": self._combined_origin(run_id),
  266. }
  267. def timeline(self, run_id: str) -> dict[str, Any]:
  268. run_event_rows = self._read_jsonl_optional(run_id, "run_events.jsonl")
  269. walk_action_rows = self._read_jsonl_optional(run_id, "walk_actions.jsonl")
  270. source_path_rows = self._read_jsonl_optional(run_id, "source_path_records.jsonl")
  271. events = [
  272. {
  273. "source": "run_events.jsonl",
  274. "stage": row.get("stage") or row.get("event_type"),
  275. "event_type": row.get("event_type"),
  276. "status": row.get("status"),
  277. "timestamp": row.get("created_at") or row.get("timestamp"),
  278. "error_code": row.get("error_code"),
  279. "walk_action_id": (row.get("raw_payload") or {}).get("walk_action_id"),
  280. "record": row,
  281. }
  282. for row in run_event_rows
  283. ]
  284. events.extend(
  285. {
  286. "source": "walk_actions.jsonl",
  287. "stage": "walk",
  288. "event_type": row.get("edge_type"),
  289. "status": row.get("walk_status"),
  290. "timestamp": row.get("created_at"),
  291. "error_code": None,
  292. "walk_action_id": row.get("walk_action_id"),
  293. "record": row,
  294. }
  295. for row in walk_action_rows
  296. )
  297. events.extend(
  298. {
  299. "source": "source_path_records.jsonl",
  300. "stage": "source_path",
  301. "event_type": row.get("source_path_type"),
  302. "status": row.get("status"),
  303. "timestamp": row.get("created_at"),
  304. "error_code": None,
  305. "walk_action_id": row.get("walk_action_id")
  306. or (row.get("raw_payload") or {}).get("walk_action_id"),
  307. "record": row,
  308. }
  309. for row in source_path_rows
  310. )
  311. events.sort(key=lambda item: str(item.get("timestamp") or ""))
  312. return {
  313. "run_id": run_id,
  314. "items": _json_safe(events),
  315. "total": len(events),
  316. "data_origin": self._combined_origin(run_id),
  317. "summary": _timeline_summary(
  318. run_event_rows, walk_action_rows, source_path_rows
  319. ),
  320. }
  321. def content_items(self, run_id: str) -> dict[str, Any]:
  322. media_by_platform_id = {
  323. row.get("platform_content_id"): row
  324. for row in self._read_jsonl_optional(run_id, "content_media_records.jsonl")
  325. }
  326. recall_by_platform_id = {
  327. row.get("platform_content_id"): row
  328. for row in self._read_jsonl_optional(run_id, "pattern_recall_evidence.jsonl")
  329. }
  330. decisions_by_target = {
  331. row.get("decision_target_id"): row
  332. for row in self._read_jsonl_optional(run_id, "rule_decisions.jsonl")
  333. }
  334. items = []
  335. for content in self._read_jsonl_optional(run_id, "discovered_content_items.jsonl"):
  336. platform_content_id = content.get("platform_content_id")
  337. discovery_id = content.get("content_discovery_id")
  338. items.append(_json_safe({
  339. **content,
  340. "media_record": media_by_platform_id.get(platform_content_id),
  341. "pattern_recall_evidence": recall_by_platform_id.get(platform_content_id),
  342. "rule_decision": decisions_by_target.get(discovery_id)
  343. or decisions_by_target.get(platform_content_id),
  344. }))
  345. return {
  346. "run_id": run_id,
  347. "items": items,
  348. "total": len(items),
  349. "data_origin": self._combined_origin(run_id),
  350. }
  351. def _list_db_runs(self, **filters: Any) -> dict[str, Any]:
  352. page = filters.pop("page")
  353. page_size = filters.pop("page_size")
  354. where_sql, params = _db_run_filters(filters)
  355. count_row = self.runtime._fetch_one(
  356. f"SELECT COUNT(*) AS cnt FROM `content_agent_runs` r {where_sql}",
  357. tuple(params),
  358. )
  359. rows = self.runtime._fetch_all(
  360. "SELECT r.run_id, "
  361. "(SELECT p.policy_run_id FROM `content_agent_policy_runs` p "
  362. "WHERE p.run_id = r.run_id ORDER BY p.id DESC LIMIT 1) AS policy_run_id, "
  363. "r.status, r.current_step, r.platform, r.platform_mode, r.strategy_version, "
  364. "r.validation_status, r.error_code, r.started_at, r.completed_at, "
  365. "r.demand_content_id, dc.name AS demand_name, dc.reason AS demand_desc "
  366. f"FROM `content_agent_runs` r "
  367. "LEFT JOIN `demand_content` dc ON dc.id = r.demand_content_id "
  368. f"{where_sql} "
  369. "ORDER BY r.started_at DESC, r.id DESC LIMIT %s OFFSET %s",
  370. tuple([*params, page_size, (page - 1) * page_size]),
  371. )
  372. return {
  373. "items": [self._apply_liveness(_json_safe(_db_run_row_to_item(row))) for row in rows],
  374. "page": page,
  375. "page_size": page_size,
  376. "total": int((count_row or {}).get("cnt") or 0),
  377. "data_origin": self.data_origin,
  378. }
  379. def _db_run_item(self, run_id: str) -> dict[str, Any]:
  380. row = self.runtime._fetch_one(
  381. "SELECT r.run_id, "
  382. "(SELECT p.policy_run_id FROM `content_agent_policy_runs` p "
  383. "WHERE p.run_id = r.run_id ORDER BY p.id DESC LIMIT 1) AS policy_run_id, "
  384. "r.status, r.current_step, r.platform, r.platform_mode, r.strategy_version, "
  385. "r.validation_status, r.error_code, r.started_at, r.completed_at "
  386. "FROM `content_agent_runs` r WHERE r.run_id = %s LIMIT 1",
  387. (run_id,),
  388. )
  389. return self._apply_liveness(_json_safe(_db_run_row_to_item(row or {"run_id": run_id, "status": "unknown"})))
  390. def _apply_liveness(self, item: dict[str, Any]) -> dict[str, Any]:
  391. # status=running 但执行进程已死(硬杀/OOM/重启,来不及写终态)→ 显示「已中断」。无时间阈值。
  392. if item.get("status") != "running":
  393. return item
  394. events = self._read_jsonl_optional(item.get("run_id") or "", "run_events.jsonl") or []
  395. executor = next(
  396. (
  397. row["raw_payload"]["executor"]
  398. for row in events
  399. if isinstance(row.get("raw_payload"), dict)
  400. and isinstance(row["raw_payload"].get("executor"), dict)
  401. ),
  402. None,
  403. )
  404. if not executor:
  405. return item # 旧 run 没记 executor,无法判定,保持原状
  406. if executor.get("host") and executor.get("host") != socket.gethostname():
  407. return item # 跨机,本地查不了进程
  408. if not _process_alive(executor.get("pid"), executor.get("proc_start")):
  409. return {**item, "status": "interrupted", "current_step": item.get("current_step") or "unknown"}
  410. return item
  411. def _local_run_list_item(self, run_id: str) -> dict[str, Any]:
  412. final_output = self._read_json_optional(run_id, "final_output.json") or {}
  413. run_events = self._read_jsonl_optional(run_id, "run_events.jsonl")
  414. lifecycle = [
  415. row for row in run_events if str(row.get("event_id", "")).startswith("lifecycle_")
  416. ]
  417. latest = lifecycle[-1] if lifecycle else {}
  418. # 派单信息(平台 / 内容形态 / 策略)落在 dispatch 里,顶层取不到——优先顶层、回退 dispatch。
  419. dispatch = final_output.get("dispatch") or {}
  420. # 需求名/描述:来自 source_context.json(1 run = 1 需求)。
  421. # ext_data.type 有时是干净标签("中医养生知识需求")、有时是占位垃圾("pattern");
  422. # name 是逗号拼接的种子词且常重复("中医养生,中医养生")。取值:有意义的 type 优先,否则用去重后的 name。
  423. source_ctx = self._read_json_optional(run_id, "source_context.json") or {}
  424. ext_data = source_ctx.get("ext_data") or {}
  425. raw_type = str(ext_data.get("type") or "").strip()
  426. good_type = raw_type if raw_type and raw_type.lower() != "pattern" else ""
  427. name_parts = [s.strip() for s in str(source_ctx.get("name") or "").split(",") if s.strip()]
  428. deduped_name = "、".join(dict.fromkeys(name_parts))
  429. demand_name = good_type or deduped_name or None
  430. demand_desc = ext_data.get("desc") or ext_data.get("reason")
  431. # 这些 run 没有 lifecycle_ 事件,started_at 才一直为空;改成从所有事件的 created_at 取首尾,
  432. # 作为运行起止时间(ISO8601 同时区可按字典序求 min/max)。
  433. event_times = [str(row.get("created_at")) for row in run_events if row.get("created_at")]
  434. return _json_safe({
  435. "run_id": run_id,
  436. "policy_run_id": final_output.get("policy_run_id"),
  437. "status": latest.get("status") or ("success" if final_output else "failed"),
  438. "current_step": "review_strategy" if final_output else "unknown",
  439. "platform": final_output.get("platform") or dispatch.get("platform"),
  440. "platform_mode": final_output.get("platform_mode") or dispatch.get("runtime_stage"),
  441. "content_format": final_output.get("content_format") or dispatch.get("content_format"),
  442. "strategy_version": final_output.get("strategy_version") or dispatch.get("strategy_version"),
  443. "demand_content_id": _int_or_none(source_ctx.get("demand_content_id")),
  444. "demand_name": demand_name,
  445. "demand_desc": demand_desc,
  446. "validation_status": final_output.get("validation_status"),
  447. "error_code": latest.get("error_code"),
  448. "started_at": (latest.get("created_at") or (min(event_times) if event_times else None)),
  449. "completed_at": (max(event_times) if event_times else None),
  450. })
  451. def _read_json_optional(self, run_id: str, filename: str) -> dict[str, Any] | None:
  452. try:
  453. return self.runtime.read_json(run_id, filename)
  454. except Exception:
  455. if self.export_runtime:
  456. try:
  457. return self.export_runtime.read_json(run_id, filename)
  458. except Exception:
  459. return None
  460. return None
  461. def _read_jsonl_optional(self, run_id: str, filename: str) -> list[dict[str, Any]]:
  462. try:
  463. return self.runtime.read_jsonl(run_id, filename)
  464. except Exception:
  465. if self.export_runtime:
  466. try:
  467. return self.export_runtime.read_jsonl(run_id, filename)
  468. except Exception:
  469. return []
  470. return []
  471. def _validate_optional(self, run_id: str) -> dict[str, Any]:
  472. if isinstance(self.runtime, DatabaseRuntimeStore):
  473. run_item = self._db_run_item(run_id)
  474. status = run_item.get("validation_status")
  475. return {
  476. "run_id": run_id,
  477. "status": status or "unknown",
  478. "findings": [] if status == "pass" else [],
  479. }
  480. try:
  481. return validate_run(run_id, self.runtime)
  482. except Exception as exc:
  483. return {
  484. "run_id": run_id,
  485. "status": "fail",
  486. "findings": [{"severity": "error", "message": str(exc)}],
  487. }
  488. def _runtime_record_count(self, run_id: str, filename: str) -> int:
  489. if isinstance(self.runtime, DatabaseRuntimeStore):
  490. return self._db_runtime_file_counts(run_id).get(filename, 0)
  491. if filename.endswith(".jsonl"):
  492. return len(self._read_jsonl_optional(run_id, filename))
  493. return 1 if self._read_json_optional(run_id, filename) is not None else 0
  494. def _combined_origin(self, run_id: str) -> str:
  495. if not isinstance(self.runtime, DatabaseRuntimeStore):
  496. return DATA_ORIGIN_RUNTIME_EXPORT
  497. return DATA_ORIGIN_PRODUCTION_DB
  498. def _file_origin(self, run_id: str, filename: str) -> str:
  499. if not isinstance(self.runtime, DatabaseRuntimeStore):
  500. return DATA_ORIGIN_RUNTIME_EXPORT
  501. primary_exists = self.runtime.file_status(run_id).get(filename, False)
  502. export_exists = bool(self.export_runtime and self.export_runtime.file_status(run_id).get(filename))
  503. if primary_exists:
  504. return DATA_ORIGIN_PRODUCTION_DB
  505. if export_exists:
  506. return DATA_ORIGIN_RUNTIME_EXPORT
  507. return self.data_origin
  508. def _validate_runtime_filename(self, filename: str) -> None:
  509. if (
  510. filename not in RUNTIME_FILENAMES
  511. or ".." in filename
  512. or "/" in filename
  513. or "\\" in filename
  514. or Path(filename).is_absolute()
  515. ):
  516. raise ValueError("runtime filename is not allowed")
  517. def _db_runtime_file_counts(self, run_id: str) -> dict[str, int]:
  518. counts: dict[str, int] = {}
  519. with self.runtime._connection_factory() as conn:
  520. with conn.cursor() as cur:
  521. for filename in RUNTIME_FILENAMES:
  522. table = RUNTIME_FILE_TABLES.get(filename)
  523. if not table:
  524. counts[filename] = 0
  525. continue
  526. cur.execute(
  527. f"SELECT COUNT(*) AS cnt FROM `{table}` WHERE `run_id` = %s",
  528. (run_id,),
  529. )
  530. row = cur.fetchone() or {}
  531. counts[filename] = int(row.get("cnt") or 0)
  532. return counts
  533. def _db_run_filters(filters: dict[str, Any]) -> tuple[str, list[Any]]:
  534. allowed = {
  535. "status": "status",
  536. "platform": "platform",
  537. "platform_mode": "platform_mode",
  538. "strategy_version": "strategy_version",
  539. "validation_status": "validation_status",
  540. "error_code": "error_code",
  541. }
  542. clauses = []
  543. params = []
  544. for key, column in allowed.items():
  545. value = filters.get(key)
  546. if value:
  547. clauses.append(f"r.`{column}` = %s")
  548. params.append(value)
  549. if not clauses:
  550. return "", params
  551. return "WHERE " + " AND ".join(clauses), params
  552. def _proc_start_time(pid: int) -> str | None:
  553. try:
  554. with open(f"/proc/{pid}/stat", encoding="utf-8") as handle:
  555. content = handle.read()
  556. return content[content.rindex(")") + 2:].split()[19]
  557. except Exception:
  558. return None
  559. def _process_alive(pid: Any, expected_start: Any) -> bool:
  560. # True = 活着 / 无法判定(绝不误杀真实运行中的 run);False = 进程确实没了(或 PID 被复用)。
  561. try:
  562. pid_int = int(pid)
  563. except (TypeError, ValueError):
  564. return True
  565. try:
  566. os.kill(pid_int, 0)
  567. except ProcessLookupError:
  568. return False
  569. except PermissionError:
  570. return True # 进程在,只是不归当前用户
  571. except Exception:
  572. return True
  573. if expected_start:
  574. actual = _proc_start_time(pid_int)
  575. if actual is not None and actual != str(expected_start):
  576. return False # PID 被复用成了别的进程
  577. return True
  578. def _db_run_row_to_item(row: dict[str, Any]) -> dict[str, Any]:
  579. return {
  580. "run_id": row.get("run_id"),
  581. "policy_run_id": row.get("policy_run_id"),
  582. "status": row.get("status"),
  583. "current_step": row.get("current_step"),
  584. "platform": row.get("platform"),
  585. "platform_mode": row.get("platform_mode"),
  586. "strategy_version": row.get("strategy_version"),
  587. "validation_status": row.get("validation_status"),
  588. "error_code": row.get("error_code"),
  589. "started_at": row.get("started_at"),
  590. "completed_at": row.get("completed_at"),
  591. "demand_content_id": row.get("demand_content_id"),
  592. "demand_name": row.get("demand_name"),
  593. "demand_desc": row.get("demand_desc"),
  594. }
  595. def _matches(item: dict[str, Any], key: str, expected: str | None) -> bool:
  596. return not expected or item.get(key) == expected
  597. def _int_or_none(value: Any) -> int | None:
  598. try:
  599. return int(str(value).strip()) if value not in (None, "") else None
  600. except (TypeError, ValueError):
  601. return None
  602. def _json_safe(value: Any) -> Any:
  603. if isinstance(value, dict):
  604. return {key: _json_safe(item) for key, item in value.items()}
  605. if isinstance(value, list):
  606. return [_json_safe(item) for item in value]
  607. if isinstance(value, (datetime, date)):
  608. return value.isoformat()
  609. return value
  610. def _business_summary(
  611. run_item: dict[str, Any],
  612. counts: dict[str, int],
  613. final_output: dict[str, Any],
  614. primary_failure_reason: dict[str, Any] | None,
  615. ) -> dict[str, Any]:
  616. summary = final_output.get("summary") or {}
  617. status = run_item.get("status") or "unknown"
  618. if primary_failure_reason:
  619. headline = f"本次运行停在{primary_failure_reason.get('stage_label')}:{primary_failure_reason.get('reason_label')}"
  620. elif status == "success":
  621. headline = "本次运行已完成,可查看内容判断和资产沉淀结果"
  622. elif status == "partial_success":
  623. headline = "本次运行部分成功,有 query 或平台请求失败"
  624. elif status == "running":
  625. headline = "本次运行仍在进行中"
  626. else:
  627. headline = "本次运行未形成完整成功链路"
  628. return {
  629. "headline": headline,
  630. "status": status,
  631. "source_label": _source_label(run_item),
  632. "query_count": counts.get("queries", 0),
  633. "content_count": counts.get("discovered_content_items", 0),
  634. "kept_count": int(summary.get("pooled_content_count") or 0),
  635. "review_count": int(summary.get("review_content_count") or 0),
  636. "technical_retry_count": int(summary.get("technical_retry_content_count") or 0),
  637. "rejected_count": int(summary.get("rejected_content_count") or 0),
  638. "asset_count": int(summary.get("author_asset_count") or 0),
  639. "primary_failure_reason": primary_failure_reason,
  640. }
  641. def _stage_conclusions(
  642. files: dict[str, bool],
  643. counts: dict[str, int],
  644. run_item: dict[str, Any],
  645. queries: list[dict[str, Any]],
  646. run_events: list[dict[str, Any]],
  647. decisions: list[dict[str, Any]],
  648. walk_actions: list[dict[str, Any]],
  649. final_output: dict[str, Any],
  650. strategy_review: dict[str, Any],
  651. source_context: dict[str, Any],
  652. ) -> list[dict[str, Any]]:
  653. query_failures = [
  654. event for event in run_events
  655. if event.get("event_type") == "platform_query_failed"
  656. or event.get("status") == "failed" and event.get("search_query_id")
  657. ]
  658. decision_counts = _effect_status_counts(decisions)
  659. walk_counts = _walk_status_counts(walk_actions)
  660. final_summary = final_output.get("summary") or {}
  661. return [
  662. {
  663. "stage_id": "source",
  664. "label": "数据源",
  665. "status": "success" if files.get("source_context.json") else "failed",
  666. "headline": "真实需求已读取" if files.get("source_context.json") else "需求来源缺失",
  667. "detail": _source_stage_detail(source_context)
  668. if files.get("source_context.json")
  669. else "未找到 source_context,无法解释输入来源",
  670. "metric": "已就绪" if files.get("pattern_seed_pack.json") else "种子缺失",
  671. },
  672. {
  673. "stage_id": "query",
  674. "label": "Query",
  675. "status": "success" if counts.get("queries") else "failed",
  676. "headline": f"生成 {counts.get('queries', 0)} 条搜索意图",
  677. "detail": f"{len(query_failures)} 条 query 有失败记录,其余进入平台搜索或后续链路",
  678. "metric": _query_generation_label(queries, run_events),
  679. },
  680. {
  681. "stage_id": "platform",
  682. "label": "平台 / 内容",
  683. "status": "success" if counts.get("discovered_content_items") else "failed",
  684. "headline": f"发现 {counts.get('discovered_content_items', 0)} 条内容",
  685. "detail": "平台结果已进入内容发现"
  686. if counts.get("discovered_content_items")
  687. else "未形成发现内容,通常需要先查看平台请求或 query 失败原因",
  688. "metric": f"{len(query_failures)} 条平台失败",
  689. },
  690. {
  691. "stage_id": "judge",
  692. "label": "判断",
  693. "status": "success" if counts.get("rule_decisions") else "pending",
  694. "headline": f"{counts.get('rule_decisions', 0)} 条内容完成判断",
  695. "detail": _decision_status_label(decision_counts),
  696. "metric": f"入池 {decision_counts.get('success', 0)} / 阻断 {decision_counts.get('rule_blocked', 0)}",
  697. },
  698. {
  699. "stage_id": "walk",
  700. "label": "游走",
  701. "status": "success" if counts.get("walk_actions") else "pending",
  702. "headline": f"触发 {counts.get('walk_actions', 0)} 个游走动作",
  703. "detail": _walk_status_label(walk_counts),
  704. "metric": f"成功 {walk_counts.get('success', 0)} / 失败 {walk_counts.get('failed', 0)}",
  705. },
  706. {
  707. "stage_id": "asset",
  708. "label": "资产",
  709. "status": "success" if files.get("final_output.json") else "pending",
  710. "headline": f"沉淀 {final_summary.get('pooled_content_count', 0) or 0} 条内容资产",
  711. "detail": (
  712. f"待复看 {final_summary.get('review_content_count', 0) or 0},"
  713. f"技术重试 {final_summary.get('technical_retry_content_count', 0) or 0},"
  714. f"淘汰 {final_summary.get('rejected_content_count', 0) or 0}"
  715. ),
  716. "metric": f"作者资产 {final_summary.get('author_asset_count', 0) or 0}",
  717. },
  718. {
  719. "stage_id": "learning",
  720. "label": "学习",
  721. "status": "success" if strategy_review.get("review_status") == "generated" else "pending",
  722. "headline": "策略复盘已生成"
  723. if strategy_review.get("review_status") == "generated"
  724. else "策略复盘未生成",
  725. "detail": "可查看 query / rule / walk 的优化建议"
  726. if strategy_review.get("review_status") == "generated"
  727. else "当前 run 还没有可展示的策略学习建议",
  728. "metric": strategy_review.get("review_status", "not_generated"),
  729. },
  730. ]
  731. def _rule_application_summary(
  732. decisions: list[dict[str, Any]],
  733. content_items: list[dict[str, Any]],
  734. ) -> list[dict[str, Any]]:
  735. content_by_id = {
  736. item.get("content_discovery_id") or item.get("platform_content_id"): item
  737. for item in content_items
  738. }
  739. summaries = []
  740. for decision in decisions:
  741. replay = decision.get("decision_replay_data") or {}
  742. target_id = decision.get("decision_target_id")
  743. content = content_by_id.get(target_id) or {}
  744. triggered_rules = decision.get("triggered_blocking_rules") or []
  745. scorecard = decision.get("scorecard") or {}
  746. summaries.append({
  747. "decision_id": decision.get("decision_id"),
  748. "content_title": content.get("title") or decision.get("decision_target_id"),
  749. "platform_content_id": content.get("platform_content_id") or decision.get("decision_target_id"),
  750. "rule_pack": decision.get("rule_pack_id")
  751. or replay.get("rule_pack_id")
  752. or "Content Rule Pack V1",
  753. "hard_gate_status": "没通过" if triggered_rules else "通过",
  754. "score": decision.get("score") or scorecard.get("total_score"),
  755. "decision_action": decision.get("decision_action"),
  756. "decision_reason_code": decision.get("decision_reason_code"),
  757. # 与 _effect_status_counts 同口径: decision 正式字段是 search_query_effect_status,
  758. # content_effect_status 仅旧数据回退(decision 记录从无该字段时原代码恒读 None)。
  759. "content_effect_status": decision.get("search_query_effect_status")
  760. or decision.get("content_effect_status"),
  761. "scorecard_schema_version": scorecard.get("schema_version"),
  762. "v4_explanation": _dashboard_v4_explanation(decision),
  763. "primary_reason": _reason_label(decision.get("decision_reason_code")),
  764. "technical_ref": {
  765. "decision_id": decision.get("decision_id"),
  766. "target_id": decision.get("decision_target_id"),
  767. "has_replay_data": bool(replay),
  768. },
  769. })
  770. return summaries
  771. def _walk_graph(
  772. queries: list[dict[str, Any]],
  773. content_items: list[dict[str, Any]],
  774. walk_actions: list[dict[str, Any]],
  775. source_paths: list[dict[str, Any]],
  776. ) -> dict[str, Any]:
  777. nodes: dict[str, dict[str, Any]] = {
  778. "source": {
  779. "id": "source",
  780. "type": "source",
  781. "label": "需求",
  782. "status": "success",
  783. }
  784. }
  785. edges: list[dict[str, Any]] = []
  786. for query in queries[:12]:
  787. node_id = f"query:{query.get('search_query_id')}"
  788. nodes[node_id] = {
  789. "id": node_id,
  790. "type": "query",
  791. "label": query.get("search_query") or query.get("search_query_id"),
  792. "status": query.get("search_query_effect_status") or "success",
  793. }
  794. edges.append({
  795. "id": f"source->{node_id}",
  796. "source": "source",
  797. "target": node_id,
  798. "label": query.get("search_query_generation_method") or "query",
  799. "status": "success",
  800. "rule_pack": None,
  801. })
  802. for content in content_items[:20]:
  803. node_id = f"content:{content.get('platform_content_id') or content.get('content_discovery_id')}"
  804. query_id = content.get("search_query_id")
  805. source_id = f"query:{query_id}" if query_id else "source"
  806. nodes[node_id] = {
  807. "id": node_id,
  808. "type": "content",
  809. "label": content.get("title") or content.get("platform_content_id") or "内容",
  810. "status": (content.get("pattern_match_result") or {}).get("recall_status") or "pending",
  811. }
  812. edges.append({
  813. "id": f"{source_id}->{node_id}",
  814. "source": source_id if source_id in nodes else "source",
  815. "target": node_id,
  816. "label": "搜索命中",
  817. "status": "success",
  818. "rule_pack": "Content Rule Pack V1",
  819. })
  820. for action in walk_actions:
  821. source_id = _walk_node_id(action.get("from_node_type"), action.get("from_node_id"))
  822. target_id = _walk_node_id(action.get("to_node_type"), action.get("to_node_id"))
  823. nodes.setdefault(source_id, {
  824. "id": source_id,
  825. "type": action.get("from_node_type") or "node",
  826. "label": action.get("from_node_id") or "起点",
  827. "status": "success",
  828. })
  829. nodes.setdefault(target_id, {
  830. "id": target_id,
  831. "type": action.get("to_node_type") or "node",
  832. "label": action.get("to_node_id") or action.get("edge_type") or "下一跳",
  833. "status": action.get("walk_status") or "pending",
  834. })
  835. execution = (action.get("raw_payload") or {}).get("rule_pack_execution") or {}
  836. edges.append({
  837. "id": action.get("walk_action_id") or f"{source_id}->{target_id}",
  838. "source": source_id,
  839. "target": target_id,
  840. "label": action.get("edge_id") or action.get("edge_type") or action.get("walk_action"),
  841. "status": action.get("walk_status") or "pending",
  842. "rule_pack": action.get("rule_pack_id"),
  843. "rule_pack_executed": execution.get("executed"),
  844. "executed_rule_pack_id": execution.get("executed_rule_pack_id"),
  845. "budget_tier": action.get("budget_tier"),
  846. "reason_code": action.get("reason_code"),
  847. "v4_gate": _walk_edge_v4_gate_context(action),
  848. })
  849. return {
  850. "nodes": list(nodes.values()),
  851. "edges": edges,
  852. "source_path_count": len(source_paths),
  853. }
  854. def _dashboard_v4_explanation(decision: dict[str, Any]) -> dict[str, Any]:
  855. existing = decision.get("v4_explanation")
  856. if isinstance(existing, dict) and existing:
  857. return existing
  858. scorecard = decision.get("scorecard") or {}
  859. if scorecard.get("schema_version") != "v4_scorecard.v1":
  860. return {}
  861. replay_data = decision.get("decision_replay_data") or {}
  862. return {
  863. "schema_version": "v4_decision_explanation.v1",
  864. "scorecard_schema_version": scorecard.get("schema_version"),
  865. "query_relevance_score": scorecard.get("query_relevance_score"),
  866. "platform_performance_score": scorecard.get("platform_performance_score"),
  867. "score": decision.get("score"),
  868. "platform_performance_components": scorecard.get("platform_performance_components", []),
  869. "missing_observable_fields": scorecard.get("missing_observable_fields", []),
  870. "decision_action": decision.get("decision_action"),
  871. "decision_reason_code": decision.get("decision_reason_code"),
  872. "search_query_effect_status": decision.get("search_query_effect_status")
  873. or decision.get("content_effect_status"),
  874. "allow_walk": replay_data.get("allow_walk"),
  875. "allow_walk_reason": replay_data.get("allow_walk_reason"),
  876. "walk_gate_snapshot": replay_data.get("walk_gate_snapshot"),
  877. }
  878. def _walk_edge_v4_gate_context(action: dict[str, Any]) -> dict[str, Any]:
  879. raw_payload = action.get("raw_payload") or {}
  880. fields = [
  881. "decision_id",
  882. "allow_walk",
  883. "allow_walk_reason",
  884. "walk_gate_snapshot",
  885. "walk_gate_status",
  886. "walk_gate_reason_code",
  887. ]
  888. context = {field: raw_payload.get(field) for field in fields if field in raw_payload}
  889. return context
  890. def _primary_failure_reason(
  891. run_item: dict[str, Any],
  892. run_events: list[dict[str, Any]],
  893. ) -> dict[str, Any] | None:
  894. error_code = run_item.get("error_code")
  895. if not error_code and run_item.get("status") not in {"failed", "partial_success"}:
  896. return None
  897. failed_events = [
  898. event for event in run_events
  899. if event.get("status") == "failed" or event.get("error_code")
  900. ]
  901. event = failed_events[-1] if failed_events else {}
  902. code = error_code or event.get("error_code") or "UNKNOWN"
  903. return {
  904. "reason_code": code,
  905. "reason_label": _reason_label(code),
  906. "stage": event.get("stage") or _stage_from_error_code(code),
  907. "stage_label": _stage_label(event.get("stage") or _stage_from_error_code(code)),
  908. "message": event.get("message") or run_item.get("error_message") or code,
  909. }
  910. def _source_label(run_item: dict[str, Any]) -> str:
  911. demand_id = run_item.get("demand_content_id")
  912. if demand_id:
  913. return f"真实需求 #{demand_id}"
  914. return "真实需求池 / 默认样例"
  915. def _source_stage_detail(source_context: dict[str, Any]) -> str:
  916. demand_id = source_context.get("demand_content_id") or source_context.get("id")
  917. if demand_id:
  918. return f"需求池 ID:{demand_id}"
  919. raw_demand = source_context.get("raw_demand_content")
  920. if isinstance(raw_demand, dict) and raw_demand.get("id"):
  921. return f"需求池 ID:{raw_demand.get('id')}"
  922. return "需求池 ID:缺失"
  923. def _query_generation_label(queries: list[dict[str, Any]], run_events: list[dict[str, Any]]) -> str:
  924. generated_count = len(queries)
  925. generation_failed = any(
  926. event.get("error_code") == "QUERY_GENERATION_FAILED"
  927. or (event.get("event_type") == "query_generation_failed")
  928. for event in run_events
  929. )
  930. if generated_count and generation_failed:
  931. return f"{generated_count} 条生成成功 / 生成失败"
  932. if generated_count:
  933. return f"{generated_count} 条生成成功"
  934. if generation_failed:
  935. return "生成失败"
  936. return "0 条生成成功"
  937. DECODE_EVENT_TYPES = (
  938. "decode_submitted",
  939. "decode_polling",
  940. "decode_succeeded",
  941. "decode_failed",
  942. "decode_timeout",
  943. )
  944. def _timeline_summary(
  945. events: list[dict[str, Any]],
  946. walk_actions: list[dict[str, Any]],
  947. source_paths: list[dict[str, Any]],
  948. ) -> dict[str, Any]:
  949. stage_duration_ms: dict[str, int] = {}
  950. error_counts: dict[str, int] = {}
  951. decode_event_counts: dict[str, int] = {}
  952. platform_rate_limited_count = 0
  953. run_started_at: str | None = None
  954. run_ended_at: str | None = None
  955. for event in events:
  956. event_type = str(event.get("event_type") or "")
  957. event_id = str(event.get("event_id") or "")
  958. payload = event.get("raw_payload") or {}
  959. if event_type in {"stage_completed", "stage_failed"}:
  960. stage = payload.get("stage")
  961. duration = payload.get("duration_ms")
  962. if stage and isinstance(duration, (int, float)):
  963. stage_duration_ms[stage] = stage_duration_ms.get(stage, 0) + int(duration)
  964. error_code = event.get("error_code")
  965. if error_code:
  966. error_counts[str(error_code)] = error_counts.get(str(error_code), 0) + 1
  967. if error_code == "PLATFORM_RATE_LIMITED":
  968. platform_rate_limited_count += 1
  969. if event_type in DECODE_EVENT_TYPES:
  970. key = event_type.removeprefix("decode_")
  971. decode_event_counts[key] = decode_event_counts.get(key, 0) + 1
  972. if event_type == "run_started" or event_id.startswith("lifecycle_start"):
  973. run_started_at = run_started_at or event.get("created_at")
  974. if event_type in {"run_succeeded", "run_failed"} or event_id.startswith(
  975. ("lifecycle_success", "lifecycle_failed")
  976. ):
  977. run_ended_at = event.get("created_at")
  978. # 固定回退顺序: run started/completed 差值 -> stage 耗时求和 -> null,不估算。
  979. total_duration_ms: int | None = None
  980. if run_started_at and run_ended_at:
  981. try:
  982. total_duration_ms = int(
  983. (
  984. datetime.fromisoformat(str(run_ended_at))
  985. - datetime.fromisoformat(str(run_started_at))
  986. ).total_seconds()
  987. * 1000
  988. )
  989. except ValueError:
  990. total_duration_ms = None
  991. if total_duration_ms is None and stage_duration_ms:
  992. total_duration_ms = sum(stage_duration_ms.values())
  993. # 唯一来源: walk_actions 中 query 链动作(hashtag_to_query 标签游走)且 walk_status=="failed";
  994. # 不读 run_events 失败事件,不求并集。首轮 keyword 搜索失败属 stage 失败,计入 error_counts。
  995. query_failure_count = sum(
  996. 1
  997. for action in walk_actions
  998. if action.get("edge_id") in {"hashtag_to_query"}
  999. and action.get("walk_status") == "failed"
  1000. )
  1001. # V3 判定为 Gemini 直读,正常 run 无 decode 事件,此计数恒 {};仅当历史数据带 decode 事件时呈现。
  1002. decode_status_counts = decode_event_counts
  1003. return {
  1004. "total_duration_ms": total_duration_ms,
  1005. "stage_duration_ms": stage_duration_ms,
  1006. "query_failure_count": query_failure_count,
  1007. "platform_rate_limited_count": platform_rate_limited_count,
  1008. "decode_status_counts": decode_status_counts,
  1009. "error_counts": error_counts,
  1010. "walk_status_counts": _walk_status_counts(walk_actions),
  1011. }
  1012. def _effect_status_counts(decisions: list[dict[str, Any]]) -> dict[str, int]:
  1013. counts: dict[str, int] = {}
  1014. for decision in decisions:
  1015. status = str(
  1016. decision.get("search_query_effect_status")
  1017. or decision.get("content_effect_status")
  1018. or "unknown"
  1019. )
  1020. counts[status] = counts.get(status, 0) + 1
  1021. return counts
  1022. def _walk_status_counts(walk_actions: list[dict[str, Any]]) -> dict[str, int]:
  1023. counts: dict[str, int] = {}
  1024. for action in walk_actions:
  1025. status = str(action.get("walk_status") or "unknown")
  1026. counts[status] = counts.get(status, 0) + 1
  1027. return counts
  1028. def _decision_status_label(counts: dict[str, int]) -> str:
  1029. if not counts:
  1030. return "当前没有内容进入规则判断"
  1031. return ",".join(f"{_reason_label(key)} {value}" for key, value in counts.items())
  1032. def _walk_status_label(counts: dict[str, int]) -> str:
  1033. if not counts:
  1034. return "当前没有触发游走动作"
  1035. return ",".join(f"{_reason_label(key)} {value}" for key, value in counts.items())
  1036. def _reason_label(code: Any) -> str:
  1037. labels = {
  1038. "success": "成功",
  1039. "pending": "待复看",
  1040. "failed": "失败",
  1041. "rule_blocked": "规则阻断",
  1042. "PLATFORM_REQUEST_FAILED": "平台请求失败",
  1043. "QUERY_GENERATION_FAILED": "Query 生成失败",
  1044. "INVALID_SOURCE": "需求来源无效",
  1045. "missing_score": "分数缺失",
  1046. "missing_source_evidence": "来源证据缺失",
  1047. "high_risk_content": "高风险内容",
  1048. "budget_exhausted": "预算耗尽",
  1049. }
  1050. value = str(code or "unknown")
  1051. return labels.get(value, value)
  1052. def _stage_from_error_code(code: Any) -> str:
  1053. value = str(code or "")
  1054. if "QUERY" in value:
  1055. return "query"
  1056. if "PLATFORM" in value:
  1057. return "platform"
  1058. if "SOURCE" in value:
  1059. return "source"
  1060. return "run"
  1061. def _stage_label(stage: Any) -> str:
  1062. labels = {
  1063. "source": "数据源阶段",
  1064. "query": "Query 阶段",
  1065. "platform": "平台搜索阶段",
  1066. "judge": "规则判断阶段",
  1067. "walk": "游走阶段",
  1068. "run": "运行阶段",
  1069. }
  1070. return labels.get(str(stage or "run"), str(stage or "运行阶段"))
  1071. def _walk_node_id(node_type: Any, node_id: Any) -> str:
  1072. return f"{node_type or 'node'}:{node_id or 'unknown'}"