execution_builder.py 69 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975
  1. from __future__ import annotations
  2. import re
  3. from collections import Counter, defaultdict
  4. from datetime import datetime, timezone
  5. from typing import Any
  6. from zoneinfo import ZoneInfo
  7. from .business_detail_text import branch_task_sections, branch_task_text, business_text
  8. from .decision_projection import AgentDecisionProjector
  9. from .main_decision_text import (
  10. parse_goal_text,
  11. parse_objective_text,
  12. plan_mode_label,
  13. plan_path_summary,
  14. preview as decision_preview,
  15. )
  16. from .runtime_event_projection import RuntimeEventProjector
  17. _DECISIONS = AgentDecisionProjector()
  18. TERMINAL_STATUSES = {
  19. "success",
  20. "partial",
  21. "failed",
  22. "stopped",
  23. "completed",
  24. "cancelled",
  25. "cancel",
  26. "cancle",
  27. }
  28. class ExecutionViewBuilder:
  29. """Deterministic V8 business narrative projector.
  30. The projector consumes plain dictionaries and has no database, HTTP or
  31. framework dependencies. Business rows win over runtime events; events may
  32. only fill clearly labelled evaluation/activity detail.
  33. """
  34. def __init__(self, event_projector: RuntimeEventProjector | None = None):
  35. self._event_projector = event_projector or RuntimeEventProjector()
  36. def from_bundle(self, bundle: dict[str, Any]) -> dict[str, Any]:
  37. record = bundle.get("record") or {}
  38. raw_rounds = sorted(
  39. bundle.get("rounds") or [],
  40. key=lambda item: (_integer(item.get("round_index")) or 0, _integer(item.get("id")) or 0),
  41. )
  42. raw_branches = sorted(
  43. bundle.get("branches") or [],
  44. key=lambda item: (
  45. _integer(item.get("round_index")) or 0,
  46. _integer(item.get("branch_id")) or 0,
  47. ),
  48. )
  49. data_decisions = [
  50. item
  51. for item in bundle.get("dataDecisions") or []
  52. if (_integer(item.get("branch_id")) or 0) > 0
  53. ]
  54. multipath_decisions = bundle.get("multipathDecisions") or []
  55. domain_info = bundle.get("domainInfo") or []
  56. events = bundle.get("events") or []
  57. valid_rounds = {
  58. value
  59. for value in (_integer(item.get("round_index")) for item in raw_rounds)
  60. if value is not None
  61. }
  62. valid_branches = {
  63. value
  64. for value in (_integer(item.get("branch_id")) for item in raw_branches)
  65. if value is not None
  66. }
  67. captured_at = datetime.now(timezone.utc)
  68. runtime = self._event_projector.project(
  69. events,
  70. valid_rounds=valid_rounds,
  71. valid_branches=valid_branches,
  72. captured_at=captured_at,
  73. run_status=str(record.get("status") or "unknown"),
  74. script_direction=record.get("script_direction"),
  75. rounds=raw_rounds,
  76. multipath_decisions=multipath_decisions,
  77. )
  78. implementer_tasks = _implementer_tasks_by_branch(events)
  79. decisions_by_branch: dict[int, list[dict[str, Any]]] = defaultdict(list)
  80. for item in data_decisions:
  81. branch_id = _integer(item.get("branch_id"))
  82. if branch_id is not None:
  83. decisions_by_branch[branch_id].append(item)
  84. domain_by_branch: dict[int, list[dict[str, Any]]] = defaultdict(list)
  85. domain_by_round: dict[int, list[dict[str, Any]]] = defaultdict(list)
  86. for item in domain_info:
  87. branch_id = _integer(item.get("branch_id"))
  88. round_index = _integer(item.get("round_index"))
  89. if branch_id is not None:
  90. domain_by_branch[branch_id].append(item)
  91. if round_index is not None:
  92. domain_by_round[round_index].append(item)
  93. multipath_by_round: dict[int, list[dict[str, Any]]] = defaultdict(list)
  94. unassigned_business: list[dict[str, Any]] = []
  95. for item in multipath_decisions:
  96. round_index = _integer(item.get("round_index"))
  97. if round_index in valid_rounds:
  98. multipath_by_round[round_index].append(item)
  99. else:
  100. unassigned_business.append(
  101. {
  102. "type": "multipath-decision",
  103. "id": item.get("id"),
  104. "reason": "没有可确认的所属轮次",
  105. }
  106. )
  107. projected_rounds: list[dict[str, Any]] = []
  108. for index, raw_round in enumerate(raw_rounds):
  109. round_index = _integer(raw_round.get("round_index")) or index + 1
  110. branch_rows = [
  111. item
  112. for item in raw_branches
  113. if _integer(item.get("round_index")) == round_index
  114. ]
  115. next_round = raw_rounds[index + 1] if index + 1 < len(raw_rounds) else None
  116. projected_rounds.append(
  117. self._round_story(
  118. record,
  119. raw_round,
  120. branch_rows,
  121. decisions_by_branch,
  122. domain_by_branch,
  123. domain_by_round.get(round_index, []),
  124. multipath_by_round.get(round_index, []),
  125. runtime,
  126. implementer_tasks,
  127. next_round,
  128. )
  129. )
  130. data_shape = _data_shape(
  131. raw_branches, multipath_decisions, domain_info, events
  132. )
  133. warnings = _warnings(
  134. data_shape=data_shape,
  135. ignored_legacy_count=_integer(
  136. bundle.get("ignoredLegacyDataDecisionCount")
  137. )
  138. or 0,
  139. rounds=projected_rounds,
  140. runtime_unassigned=runtime.get("unassigned") or [],
  141. business_unassigned=unassigned_business,
  142. )
  143. artifact = bundle.get("currentArtifact") or {}
  144. current_round = projected_rounds[-1]["roundIndex"] if projected_rounds else None
  145. view = {
  146. "schemaVersion": "8",
  147. "capturedAt": captured_at.isoformat(),
  148. "dataShape": data_shape,
  149. "header": _header(record, current_round),
  150. "planning": _planning_analysis(
  151. (runtime.get("mainAgentDecisions") or {}).get("planningAnalysis"),
  152. ),
  153. "objective": _objective(
  154. record,
  155. (runtime.get("mainAgentDecisions") or {}).get("objective"),
  156. ),
  157. "rounds": projected_rounds,
  158. "finalResult": _final_result(record, projected_rounds, artifact),
  159. "unassigned": {
  160. "runtimeEvents": runtime.get("unassigned") or [],
  161. "businessRecords": unassigned_business,
  162. },
  163. "warnings": warnings,
  164. }
  165. if view["planning"] is None:
  166. view.pop("planning")
  167. return view
  168. def _round_story(
  169. self,
  170. record: dict[str, Any],
  171. raw_round: dict[str, Any],
  172. branch_rows: list[dict[str, Any]],
  173. decisions_by_branch: dict[int, list[dict[str, Any]]],
  174. domain_by_branch: dict[int, list[dict[str, Any]]],
  175. round_domain_info: list[dict[str, Any]],
  176. multipath_rows: list[dict[str, Any]],
  177. runtime: dict[str, Any],
  178. implementer_tasks: dict[int, str],
  179. next_round: dict[str, Any] | None,
  180. ) -> dict[str, Any]:
  181. round_index = _integer(raw_round.get("round_index")) or 0
  182. main_decisions = runtime.get("mainAgentDecisions") or {}
  183. goal_context = (main_decisions.get("roundGoalsByRound") or {}).get(round_index)
  184. plan_context = (main_decisions.get("implementationPlansByRound") or {}).get(
  185. round_index
  186. )
  187. plan = _plan_projection(
  188. raw_round,
  189. plan_context,
  190. )
  191. branches = [
  192. _branch_story(
  193. round_index,
  194. row,
  195. decisions_by_branch.get(_integer(row.get("branch_id")) or 0, []),
  196. domain_by_branch.get(_integer(row.get("branch_id")) or 0, []),
  197. runtime.get("retrievalStagesByBranch", {}).get(
  198. _integer(row.get("branch_id")) or 0
  199. ),
  200. runtime.get("creativeByBranch", {}).get(
  201. _integer(row.get("branch_id")) or 0
  202. ),
  203. implementer_tasks.get(_integer(row.get("branch_id")) or 0),
  204. str(record.get("status") or "unknown"),
  205. )
  206. for row in branch_rows
  207. ]
  208. branch_ids = {branch["branchId"] for branch in branches}
  209. decision_batches = [
  210. _decision_batch(round_index, row, branch_ids, branches)
  211. for row in sorted(multipath_rows, key=_record_order)
  212. ]
  213. convergence_batches, unassigned_reviews = _convergence_batches(
  214. round_index,
  215. runtime.get("multipathReviewsByRound", {}).get(round_index, []),
  216. decision_batches,
  217. str(record.get("status") or "unknown"),
  218. )
  219. if not convergence_batches and branches:
  220. convergence_batches = [
  221. _convergence_batch(
  222. round_index,
  223. None,
  224. None,
  225. "records-missing",
  226. str(record.get("status") or "unknown"),
  227. branch_ids=sorted(branch_ids),
  228. )
  229. ]
  230. _annotate_unscoped_round_reviews(
  231. convergence_batches,
  232. runtime.get("unassigned") or [],
  233. round_index,
  234. )
  235. if unassigned_reviews:
  236. runtime.setdefault("unassigned", []).extend(unassigned_reviews)
  237. overall_evaluation = _round_evaluation(
  238. round_index,
  239. runtime.get("overallEvaluationsByRound", {}).get(round_index, []),
  240. str(record.get("status") or "unknown"),
  241. )
  242. state = _round_state(record, raw_round, overall_evaluation)
  243. result = _round_result(
  244. record,
  245. round_index,
  246. branches,
  247. round_domain_info,
  248. state,
  249. next_round,
  250. )
  251. goal_summary = _goal_summary(raw_round.get("goal"))
  252. round_summary = _round_summary(
  253. round_index=round_index,
  254. goal=goal_summary,
  255. plan=plan,
  256. branches=branches,
  257. batches=decision_batches,
  258. domain_info=round_domain_info,
  259. run_status=str(record.get("status") or "unknown"),
  260. )
  261. goal_parsed = parse_goal_text(raw_round.get("goal"))
  262. goal_lines: list[dict[str, Any]] = []
  263. focus_items = goal_parsed.get("focusItems") or []
  264. if focus_items:
  265. focus_summary = ";".join(str(item) for item in focus_items[:2])
  266. if len(focus_items) > 2:
  267. focus_summary += f";另有 {len(focus_items) - 2} 项,详情查看"
  268. goal_lines.append(_line("focus", "本轮聚焦", focus_summary))
  269. visible_inputs = _context_input_labels(goal_context)
  270. if visible_inputs:
  271. goal_lines.append(_line("visible-before", "形成前可见", " · ".join(visible_inputs)))
  272. goal_decision = _direction_decision(
  273. id=f"round:{round_index}:goal",
  274. subtype="round-goal",
  275. context=goal_context,
  276. decision_items=(goal_context or {}).get("decisionItems")
  277. or [goal_parsed["headline"]],
  278. primary_label="要解决",
  279. primary_value=goal_parsed["headline"],
  280. secondary=goal_lines,
  281. prompt_ref="prompt:main",
  282. )
  283. return {
  284. "id": f"round:{round_index}",
  285. "roundIndex": round_index,
  286. "state": state,
  287. "summary": round_summary,
  288. "goal": _story(
  289. id=f"round:{round_index}:goal",
  290. role="goal",
  291. kind="decision",
  292. title="本轮目标",
  293. label="要解决",
  294. value=goal_parsed["headline"],
  295. secondary=goal_lines,
  296. detail_ref=f"round:{round_index}:goal",
  297. source_notice=None if raw_round.get("goal") else "record-missing",
  298. decision=goal_decision,
  299. ),
  300. "plan": plan,
  301. "branches": branches,
  302. "convergenceBatches": convergence_batches,
  303. "overallEvaluation": overall_evaluation,
  304. "result": result,
  305. "detailRef": f"round:{round_index}",
  306. }
  307. def _header(record: dict[str, Any], current_round: int | None) -> dict[str, Any]:
  308. agent_config = record.get("agent_config")
  309. model = None
  310. if isinstance(agent_config, dict):
  311. model = agent_config.get("model_name") or agent_config.get("model")
  312. return {
  313. "id": str(record.get("id") or ""),
  314. "title": f"脚本构建 #{record.get('id')}",
  315. "status": str(record.get("status") or "unknown"),
  316. "summary": _plain(record.get("summary")),
  317. "errorMessage": _plain(record.get("error_message")),
  318. "model": model,
  319. "totalCost": record.get("cost_usd"),
  320. "currentRound": current_round,
  321. "createdAt": record.get("start_time"),
  322. "updatedAt": record.get("end_time"),
  323. "direction": {
  324. "currentValue": _plain(record.get("script_direction")),
  325. "note": "这是数据库当前保存的创作方向。",
  326. },
  327. }
  328. def _objective(
  329. record: dict[str, Any], decision_context: dict[str, Any] | None
  330. ) -> dict[str, Any]:
  331. raw_direction = record.get("script_direction")
  332. direction = _plain(raw_direction)
  333. # Section parsing needs the persisted Markdown line breaks. ``direction``
  334. # is only the compact truthy/display fallback used by the surrounding view.
  335. parsed = parse_objective_text(raw_direction)
  336. secondary: list[dict[str, Any]] = []
  337. input_labels = _context_input_labels(decision_context, relation="direct-read")
  338. if input_labels:
  339. secondary.append(
  340. _line("inputs", "决策前读取", " · ".join(input_labels))
  341. )
  342. structure: list[str] = []
  343. target_count = int(parsed.get("targetCount") or 0)
  344. dimension_count = int(parsed.get("domainDimensionCount") or 0)
  345. if target_count:
  346. structure.append(f"{target_count} 个创作目标")
  347. if dimension_count:
  348. structure.append(f"{dimension_count} 个领域评估维度")
  349. if structure:
  350. secondary.append(_line("structure", "目标结构", " · ".join(structure)))
  351. decision = _direction_decision(
  352. id="run:objective",
  353. subtype="objective",
  354. context=decision_context,
  355. decision_items=(decision_context or {}).get("decisionItems")
  356. or ([parsed.get("headline")] if parsed.get("headline") else []),
  357. primary_label="核心目标",
  358. primary_value=parsed.get("headline"),
  359. secondary=secondary,
  360. prompt_ref="prompt:main",
  361. )
  362. return _story(
  363. id="run:objective",
  364. role="objective",
  365. kind="decision" if direction else "missing",
  366. title="创作目标",
  367. label="核心目标",
  368. value=parsed.get("headline") or "创作方向未记录",
  369. secondary=secondary,
  370. detail_ref="run:objective",
  371. source_notice=None if direction else "record-missing",
  372. decision=decision,
  373. )
  374. def _planning_analysis(context: dict[str, Any] | None) -> dict[str, Any] | None:
  375. if not context or context.get("completeness") != "complete" or not context.get("eventRef"):
  376. return None
  377. secondary = [
  378. _line("plan", "执行计划", context.get("plan")),
  379. _line("next", "下一步", context.get("action")),
  380. ]
  381. return _story(
  382. id="run:planning-analysis",
  383. role="planning-analysis",
  384. kind="execution",
  385. title="创作规划解析",
  386. label="规划概要",
  387. value=context.get("summary") or "主 Agent 已完成首次创作规划",
  388. secondary=secondary,
  389. detail_ref=context.get("eventRef"),
  390. status="completed",
  391. )
  392. def _plan_projection(
  393. raw_round: dict[str, Any], decision_context: dict[str, Any] | None
  394. ) -> dict[str, Any]:
  395. round_index = _integer(raw_round.get("round_index")) or 0
  396. paths = raw_round.get("multipath_plan")
  397. if not isinstance(paths, list):
  398. paths = []
  399. raw_mode = _plain(raw_round.get("race_or_divide"))
  400. mode = plan_mode_label(raw_mode, len(paths))
  401. summary = (
  402. f"{mode or '方式未记录'} · {len(paths)} 个方案"
  403. if paths
  404. else "多路规划未记录"
  405. )
  406. decision = _direction_decision(
  407. id=f"round:{round_index}:plan",
  408. subtype="implementation-plan",
  409. context=decision_context,
  410. decision_items=(decision_context or {}).get("decisionItems")
  411. or [summary],
  412. primary_label="实现方式",
  413. primary_value=summary,
  414. secondary=_plan_card_lines(paths, raw_round.get("plan_note")),
  415. prompt_ref="prompt:main",
  416. )
  417. node = _story(
  418. id=f"round:{round_index}:plan",
  419. role="plan",
  420. kind="decision" if paths else "missing",
  421. title="实现规划",
  422. label="实现方式",
  423. value=summary,
  424. secondary=_plan_card_lines(paths, raw_round.get("plan_note")),
  425. detail_ref=f"round:{round_index}:plan",
  426. source_notice=None if paths else "record-missing",
  427. decision=decision,
  428. )
  429. return {
  430. "mode": mode,
  431. "rawMode": raw_mode,
  432. "paths": paths,
  433. "note": _plain(raw_round.get("plan_note")),
  434. "currentOnly": True,
  435. "runtimeRevisionCount": len((decision_context or {}).get("revisionRefs") or []),
  436. "runtimeRevisionRefs": list((decision_context or {}).get("revisionRefs") or []),
  437. "node": node,
  438. }
  439. def _branch_story(
  440. round_index: int,
  441. row: dict[str, Any],
  442. decisions: list[dict[str, Any]],
  443. domain_info: list[dict[str, Any]],
  444. retrieval_stage: dict[str, Any] | None,
  445. creative_decision: dict[str, Any] | None,
  446. implementer_task: str | None,
  447. run_status: str,
  448. ) -> dict[str, Any]:
  449. branch_id = _integer(row.get("branch_id")) or 0
  450. path_type = str(row.get("path_type") or "unknown")
  451. if path_type not in {"内容", "领域信息"}:
  452. path_type = "unknown"
  453. task = (
  454. branch_task_text(implementer_task)
  455. or branch_task_text(row.get("impl_task"))
  456. or business_text(row.get("target"))
  457. )
  458. retrieval_stage = retrieval_stage or _missing_retrieval_stage(
  459. round_index, branch_id, run_status
  460. )
  461. projected_data_decisions = [
  462. _DECISIONS.data_tradeoff(
  463. {
  464. **item,
  465. "decision": business_text(item.get("decision")),
  466. "reasoning": business_text(item.get("reasoning")),
  467. "id": f"data-decision:{item.get('id')}",
  468. "detailRef": f"data-decision:{item.get('id')}",
  469. "promptRef": _implementer_prompt_ref(retrieval_stage),
  470. }
  471. )
  472. for item in decisions
  473. ]
  474. for item, projection in zip(decisions, projected_data_decisions):
  475. full_conclusion = business_text(item.get("decision"))
  476. if full_conclusion:
  477. projection.setdefault("card", {}).setdefault("primary", {})[
  478. "value"
  479. ] = full_conclusion
  480. decision_nodes = [
  481. _story(
  482. id=f"data-decision:{item.get('id')}",
  483. role="data-decision",
  484. kind="decision",
  485. title="数据取舍",
  486. label="取舍结论",
  487. value=projection["card"]["primary"]["value"],
  488. secondary=projection["card"]["secondary"],
  489. detail_ref=f"data-decision:{item.get('id')}",
  490. decision=projection,
  491. )
  492. for item, projection in zip(decisions, projected_data_decisions)
  493. ]
  494. output = _branch_output(row, path_type, domain_info)
  495. if creative_decision and output.get("type") == "script-artifact":
  496. creative_decision = dict(creative_decision)
  497. explicit_basis = [
  498. {
  499. "label": business_text(item.get("decision")) or "已记录一项数据取舍",
  500. "value": business_text(item.get("reasoning")) or None,
  501. "detailRef": f"data-decision:{item.get('id')}",
  502. }
  503. for item in decisions
  504. if item.get("decision")
  505. ]
  506. creative_decision["artifactRef"] = output.get("snapshotRef")
  507. creative_decision["body"] = {
  508. **(creative_decision.get("body") or {}),
  509. "output": output.get("summary"),
  510. "explicitBasis": explicit_basis,
  511. "candidateSnapshot": {
  512. "snapshotRef": output.get("snapshotRef"),
  513. "paragraphCount": output.get("paragraphCount"),
  514. "elementCount": output.get("elementCount"),
  515. "linkCount": output.get("linkCount"),
  516. "exactness": _candidate_output_exactness(output),
  517. },
  518. }
  519. detail = creative_decision.get("detail")
  520. if isinstance(detail, dict):
  521. blocks = [
  522. block
  523. for block in detail.get("blocks") or []
  524. if not (
  525. isinstance(block, dict)
  526. and block.get("title") in {"实际产出", "产出的候选表"}
  527. )
  528. ]
  529. if explicit_basis:
  530. blocks.insert(
  531. 0,
  532. {
  533. "type": "items",
  534. "title": "明确采用的数据取舍",
  535. "items": explicit_basis,
  536. "visualRole": "evidence",
  537. "presentation": "list",
  538. "collapsible": True,
  539. },
  540. )
  541. blocks.append(
  542. {
  543. "type": "summary",
  544. "title": "产出的候选表",
  545. "value": output.get("summary"),
  546. "visualRole": "key-result",
  547. "presentation": "prose",
  548. }
  549. )
  550. creative_decision["detail"] = {**detail, "blocks": blocks}
  551. secondary = list((creative_decision.get("card") or {}).get("secondary") or [])
  552. if explicit_basis:
  553. secondary.append(_line("basis", "采用数据", f"{len(explicit_basis)} 项明确取舍"))
  554. secondary.append(_line("output", "产出的候选表", output.get("summary")))
  555. creative_decision["card"] = {
  556. **(creative_decision.get("card") or {}),
  557. "secondary": secondary[:3],
  558. }
  559. output["node"] = _story(
  560. id=creative_decision["id"],
  561. role="candidate-output",
  562. kind="decision",
  563. title="产生候选创作表",
  564. label="创作处理",
  565. value=creative_decision["card"]["primary"]["value"],
  566. secondary=creative_decision["card"]["secondary"],
  567. detail_ref=creative_decision["detailRef"],
  568. artifact_ref=output.get("snapshotRef"),
  569. has_changes=bool(output.get("snapshotRef")),
  570. decision=creative_decision,
  571. )
  572. status = str(row.get("status") or "open")
  573. disposition_context = _branch_disposition_context(status, run_status)
  574. title = _plain(row.get("target")) or f"方案 {branch_id}"
  575. return {
  576. "id": f"round:{round_index}:branch:{branch_id}",
  577. "branchId": branch_id,
  578. "pathType": path_type,
  579. "status": status,
  580. "title": title,
  581. "summary": {
  582. "task": task or "实现任务未记录",
  583. "output": output["summary"],
  584. },
  585. "task": _branch_task_story(
  586. id=f"round:{round_index}:branch:{branch_id}:task",
  587. task=task,
  588. detail_ref=f"round:{round_index}:branch:{branch_id}:task",
  589. ),
  590. "retrievalStage": retrieval_stage,
  591. "dataDecisions": [
  592. {
  593. "id": f"data-decision:{item.get('id')}",
  594. "recordId": item.get("id"),
  595. "decision": _plain(item.get("decision")),
  596. "reasoning": _plain(item.get("reasoning")),
  597. "sources": item.get("sources") or [],
  598. "createdAt": item.get("created_at"),
  599. "node": node,
  600. "decisionProjection": projection,
  601. }
  602. for item, node, projection in zip(
  603. decisions, decision_nodes, projected_data_decisions
  604. )
  605. ],
  606. "output": output,
  607. "outcome": {
  608. "status": status,
  609. "label": disposition_context["label"],
  610. "reasoning": _plain(row.get("decision_reasoning")),
  611. "sourceNotice": disposition_context["sourceNotice"],
  612. },
  613. "detailRef": f"round:{round_index}:branch:{branch_id}",
  614. }
  615. def _branch_output(
  616. row: dict[str, Any], path_type: str, domain_info: list[dict[str, Any]]
  617. ) -> dict[str, Any]:
  618. branch_id = _integer(row.get("branch_id")) or 0
  619. round_index = _integer(row.get("round_index")) or 0
  620. if path_type == "领域信息":
  621. facts = [
  622. item
  623. for item in domain_info
  624. if _integer(item.get("branch_id")) == branch_id
  625. and _integer(item.get("round_index")) in {None, round_index}
  626. ]
  627. summary = (
  628. f"新增 {len(facts)} 条已核实领域事实"
  629. if facts
  630. else "已查询完成,本方案没有新增领域事实"
  631. )
  632. node = _story(
  633. id=f"round:{round_index}:branch:{branch_id}:output",
  634. role="domain-output",
  635. kind="result",
  636. title="领域事实",
  637. label="产出",
  638. value=summary,
  639. detail_ref=f"round:{round_index}:branch:{branch_id}:output",
  640. has_changes=bool(facts),
  641. source_notice=None,
  642. )
  643. return {
  644. "type": "domain-info",
  645. "summary": summary,
  646. "factCount": len(facts),
  647. "factRefs": [f"domain-info:{item.get('id')}" for item in facts],
  648. "node": node,
  649. }
  650. candidate = row.get("candidate_snapshot") or {}
  651. snapshot = candidate.get("snapshot") if isinstance(candidate, dict) else None
  652. counts = _snapshot_counts(snapshot)
  653. if any(counts.values()):
  654. summary = (
  655. f"候选脚本包含 {counts['paragraphs']} 个段落、"
  656. f"{counts['elements']} 个元素"
  657. )
  658. elif row.get("self_assessment"):
  659. assessment = business_text(row.get("self_assessment"))
  660. first_item = next(
  661. (line.strip(" -*") for line in assessment.splitlines() if line.strip()),
  662. assessment,
  663. )
  664. summary = f"实现 Agent 自评:{first_item}"
  665. else:
  666. summary = "候选脚本内容未记录"
  667. has_output = bool(snapshot) or bool(row.get("self_assessment"))
  668. node = _story(
  669. id=f"round:{round_index}:branch:{branch_id}:output",
  670. role="candidate-output",
  671. kind="result" if has_output else "missing",
  672. title="候选产出",
  673. label="产出",
  674. value=summary,
  675. detail_ref=f"round:{round_index}:branch:{branch_id}:output",
  676. has_changes=bool(snapshot),
  677. source_notice=None if has_output else "record-missing",
  678. artifact_ref=f"artifact:branch:{branch_id}" if snapshot else None,
  679. )
  680. return {
  681. "type": "script-artifact" if has_output else "missing",
  682. "summary": summary,
  683. "snapshotRef": f"artifact:branch:{branch_id}" if snapshot else None,
  684. "paragraphCount": counts["paragraphs"],
  685. "elementCount": counts["elements"],
  686. "linkCount": counts["links"],
  687. "accuracy": {
  688. "historical": candidate.get("historicalAccuracy")
  689. if isinstance(candidate, dict)
  690. else "unknown",
  691. "currentProjection": candidate.get("currentProjectionAccuracy")
  692. if isinstance(candidate, dict)
  693. else "unknown",
  694. },
  695. "node": node,
  696. }
  697. def _branch_task_story(
  698. *, id: str, task: str | None, detail_ref: str
  699. ) -> dict[str, Any]:
  700. if not task:
  701. return _story(
  702. id=id,
  703. role="branch-task",
  704. kind="missing",
  705. title="实现任务",
  706. label="实现任务",
  707. value="实现任务未记录",
  708. detail_ref=detail_ref,
  709. source_notice="record-missing",
  710. )
  711. sections = branch_task_sections(task)
  712. preferred = next(
  713. (
  714. item
  715. for kind in ("scope", "method", "goal", "notes")
  716. for item in sections
  717. if item.get("kind") == kind and item.get("content")
  718. ),
  719. None,
  720. )
  721. primary = (preferred or {}).get("content") or task
  722. secondary = [
  723. _line(str(item.get("kind") or index), item.get("title") or "任务说明", item.get("content"))
  724. for index, item in enumerate(sections, 1)
  725. if item is not preferred and item.get("content")
  726. ][:3]
  727. node = _story(
  728. id=id,
  729. role="branch-task",
  730. kind="execution",
  731. title="实现任务",
  732. label=(preferred or {}).get("title") or "实现任务",
  733. value=primary,
  734. secondary=secondary,
  735. detail_ref=detail_ref,
  736. )
  737. node["taskStructure"] = {
  738. "sectionCount": len(sections),
  739. "sectionKinds": [item.get("kind") for item in sections],
  740. }
  741. return node
  742. def _candidate_output_exactness(output: dict[str, Any]) -> str:
  743. historical = (output.get("accuracy") or {}).get("historical")
  744. if historical == "exact":
  745. return "historical-snapshot"
  746. return "current-only" if output.get("snapshotRef") else "unknown"
  747. def _missing_retrieval_stage(
  748. round_index: int, branch_id: int, run_status: str
  749. ) -> dict[str, Any]:
  750. running = str(run_status or "").lower() not in TERMINAL_STATUSES
  751. return {
  752. "id": f"round:{round_index}:branch:{branch_id}:retrieval",
  753. "roundIndex": round_index,
  754. "branchId": branch_id,
  755. "implementerEventId": None,
  756. "observedMode": "unknown",
  757. "waves": [],
  758. "directToolGroups": [],
  759. "agentRuns": [],
  760. "status": "running" if running else "missing",
  761. "startedAt": None,
  762. "endedAt": None,
  763. "detailRef": f"round:{round_index}:branch:{branch_id}:retrieval",
  764. "sourceNotice": None if running else "record-missing",
  765. }
  766. def _decision_batch(
  767. round_index: int,
  768. row: dict[str, Any],
  769. valid_branch_ids: set[int],
  770. branches: list[dict[str, Any]],
  771. ) -> dict[str, Any]:
  772. branch_ids = _ids(row.get("branch_ids"))
  773. missing = [branch_id for branch_id in branch_ids if branch_id not in valid_branch_ids]
  774. record_id = row.get("id")
  775. decision = _plain(row.get("decision")) or "决策内容未记录"
  776. display_decision = _business_terms(decision)
  777. secondary = [
  778. _line(
  779. "branches",
  780. "比较方案",
  781. "、".join(f"方案 {branch_id}" for branch_id in branch_ids)
  782. or "涉及方案未记录",
  783. )
  784. ]
  785. if row.get("reasoning"):
  786. secondary.append(
  787. _line("reason", "理由", _preview(row.get("reasoning"), 120))
  788. )
  789. branch_by_id = {branch["branchId"]: branch for branch in branches}
  790. outcomes = [
  791. {
  792. "branchId": branch_id,
  793. "status": branch_by_id.get(branch_id, {}).get("outcome", {}).get("status", "unknown"),
  794. "label": branch_by_id.get(branch_id, {}).get("outcome", {}).get("label", "状态未记录"),
  795. "reasoning": branch_by_id.get(branch_id, {}).get("outcome", {}).get("reasoning"),
  796. }
  797. for branch_id in branch_ids
  798. ]
  799. if outcomes:
  800. secondary.insert(
  801. 1,
  802. _line(
  803. "outcomes",
  804. "各方案结果",
  805. ";".join(
  806. f"方案 {item['branchId']} · {item['label']}" for item in outcomes
  807. ),
  808. ),
  809. )
  810. projection = _DECISIONS.multipath_tradeoff(
  811. {
  812. "id": f"multipath-decision:{record_id}",
  813. "detailRef": f"multipath-decision:{record_id}",
  814. "promptRef": "prompt:main",
  815. "branchIds": branch_ids,
  816. "scopeItems": [f"方案 {branch_id}" for branch_id in branch_ids],
  817. "decision": display_decision,
  818. "reasoning": business_text(row.get("reasoning")),
  819. "selected": [
  820. f"方案 {item['branchId']}"
  821. for item in outcomes
  822. if item["status"] == "merged"
  823. ],
  824. "parked": [
  825. f"方案 {item['branchId']}"
  826. for item in outcomes
  827. if item["status"] == "parked"
  828. ],
  829. "rejected": [
  830. f"方案 {item['branchId']}"
  831. for item in outcomes
  832. if item["status"] == "discarded"
  833. ],
  834. "completeness": "partial" if missing or not branch_ids else "complete",
  835. }
  836. )
  837. projection.setdefault("card", {}).setdefault("primary", {})[
  838. "value"
  839. ] = display_decision
  840. return {
  841. "id": f"multipath-decision:{record_id}",
  842. "recordId": record_id,
  843. "roundIndex": round_index,
  844. "branchIds": branch_ids,
  845. "decision": decision,
  846. "reasoning": _plain(row.get("reasoning")),
  847. "createdAt": row.get("created_at"),
  848. "completeness": "partial" if missing or not branch_ids else "complete",
  849. "missingBranchIds": missing,
  850. "branchOutcomes": outcomes,
  851. "decisionProjection": projection,
  852. "node": _story(
  853. id=f"multipath-decision:{record_id}",
  854. role="multipath-decision",
  855. kind="decision",
  856. title="主 Agent 多路决策",
  857. label="决定",
  858. value=projection["card"]["primary"]["value"],
  859. secondary=projection["card"]["secondary"],
  860. detail_ref=f"multipath-decision:{record_id}",
  861. source_notice="record-missing" if missing else None,
  862. decision=projection,
  863. ),
  864. }
  865. def _convergence_batches(
  866. round_index: int,
  867. reviews: list[dict[str, Any]],
  868. decisions: list[dict[str, Any]],
  869. run_status: str,
  870. ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
  871. ordered_reviews = sorted(reviews, key=_runtime_order)
  872. ordered_decisions = sorted(decisions, key=_decision_order)
  873. unmatched_reviews = set(range(len(ordered_reviews)))
  874. batches: list[dict[str, Any]] = []
  875. for decision in ordered_decisions:
  876. decision_set = set(decision.get("branchIds") or [])
  877. decision_time = _date_value(decision.get("createdAt"))
  878. candidates = [
  879. index
  880. for index in unmatched_reviews
  881. if set(ordered_reviews[index].get("branchIds") or []) == decision_set
  882. and _not_after(ordered_reviews[index].get("endedAt"), decision_time)
  883. ]
  884. review_index = max(
  885. candidates,
  886. key=lambda index: _runtime_order(ordered_reviews[index]),
  887. default=None,
  888. )
  889. review = ordered_reviews[review_index] if review_index is not None else None
  890. if review_index is not None:
  891. unmatched_reviews.remove(review_index)
  892. batches.append(
  893. _convergence_batch(
  894. round_index,
  895. review,
  896. decision,
  897. "exact-branch-set-and-time" if review else "decision-only",
  898. run_status,
  899. )
  900. )
  901. for index in sorted(unmatched_reviews, key=lambda value: _runtime_order(ordered_reviews[value])):
  902. review = ordered_reviews[index]
  903. batches.append(
  904. _convergence_batch(
  905. round_index, review, None, "review-only", run_status
  906. )
  907. )
  908. batches.sort(key=_convergence_order)
  909. return batches, []
  910. def _annotate_unscoped_round_reviews(
  911. batches: list[dict[str, Any]],
  912. unassigned: list[dict[str, Any]],
  913. round_index: int,
  914. ) -> None:
  915. """Keep unscoped reviews visible without fabricating a candidate link."""
  916. reviews = [
  917. item
  918. for item in unassigned
  919. if str(item.get("name") or "") == "script_multipath_evaluator"
  920. and _integer(item.get("roundIndex")) == round_index
  921. and str(item.get("association") or "") == "missing-branch-scope"
  922. ]
  923. if not reviews:
  924. return
  925. refs = [item.get("detailRef") for item in reviews if item.get("detailRef")]
  926. for batch in batches:
  927. missing = batch.get("missingReview")
  928. if not isinstance(missing, dict):
  929. continue
  930. missing["card"] = {
  931. "primary": {
  932. "label": "评审关联",
  933. "value": "发现同轮评审,但无法安全关联到具体候选",
  934. },
  935. "secondary": [
  936. _line("records", "未关联记录", f"{len(refs)} 条评审记录")
  937. ],
  938. }
  939. missing["sourceNotice"] = "association-incomplete"
  940. missing["unscopedReviewRefs"] = refs
  941. def _convergence_batch(
  942. round_index: int,
  943. review: dict[str, Any] | None,
  944. decision: dict[str, Any] | None,
  945. association: str,
  946. run_status: str,
  947. branch_ids: list[int] | None = None,
  948. ) -> dict[str, Any]:
  949. branch_ids = branch_ids or list(
  950. dict.fromkeys(
  951. (review or {}).get("branchIds")
  952. or (decision or {}).get("branchIds")
  953. or []
  954. )
  955. )
  956. suffix = (
  957. f"review-{review.get('eventId')}"
  958. if review
  959. else f"decision-{decision.get('recordId')}"
  960. if decision
  961. else "records-missing"
  962. )
  963. missing_context = _missing_multipath_context(run_status)
  964. if review is not None and not review.get("decisionProjection"):
  965. review = dict(review)
  966. review["decisionProjection"] = _evaluation_decision(
  967. review,
  968. subtype="multipath-evaluation",
  969. detail_ref=review.get("detailRef"),
  970. prompt_ref=review.get("promptRef"),
  971. )
  972. missing_review = None
  973. if review is None:
  974. missing_review_id = f"round:{round_index}:{suffix}:review-missing"
  975. missing_review = _story(
  976. id=missing_review_id,
  977. role="multipath-review",
  978. kind=missing_context["kind"],
  979. title="多方案评审",
  980. label="评审记录",
  981. value="未找到可与本次决策安全关联的多方案评审记录",
  982. detail_ref=missing_review_id,
  983. source_notice=missing_context["sourceNotice"],
  984. status=missing_context.get("status"),
  985. )
  986. missing_decision = None
  987. if decision is None:
  988. missing_decision_id = f"round:{round_index}:{suffix}:decision-missing"
  989. missing_decision = _story(
  990. id=missing_decision_id,
  991. role="multipath-decision",
  992. kind=missing_context["kind"],
  993. title="主 Agent 多路决策",
  994. label=missing_context["label"],
  995. value=missing_context["value"],
  996. detail_ref=missing_decision_id,
  997. source_notice=missing_context["sourceNotice"],
  998. status=missing_context.get("status"),
  999. )
  1000. if review is not None and decision is not None:
  1001. _attach_review_comparison(review, decision, association)
  1002. return {
  1003. "id": f"round:{round_index}:convergence:{suffix}",
  1004. "roundIndex": round_index,
  1005. "branchIds": branch_ids,
  1006. "review": review,
  1007. "decision": decision,
  1008. "missingReview": missing_review,
  1009. "missingDecision": missing_decision,
  1010. "association": association,
  1011. }
  1012. def _round_evaluation(
  1013. round_index: int, evaluations: list[dict[str, Any]], run_status: str
  1014. ) -> dict[str, Any]:
  1015. if not evaluations:
  1016. context = _missing_evaluation_context(run_status)
  1017. status = context.get("status")
  1018. if not status:
  1019. status = (
  1020. "not-evaluated"
  1021. if context["sourceNotice"] == "record-missing"
  1022. else "unknown"
  1023. )
  1024. return _story(
  1025. id=f"round:{round_index}:evaluation",
  1026. role="round-evaluation",
  1027. kind=context["kind"],
  1028. title="主脚本整体评审",
  1029. label=context["label"],
  1030. value=context["value"],
  1031. detail_ref=f"round:{round_index}:evaluation",
  1032. source_notice=context["sourceNotice"],
  1033. status=status,
  1034. )
  1035. latest = evaluations[-1]
  1036. conclusion = _plain(latest.get("conclusion"))
  1037. projection = _evaluation_decision(
  1038. latest,
  1039. subtype="overall-evaluation",
  1040. detail_ref=latest.get("detailRef"),
  1041. prompt_ref=latest.get("promptRef"),
  1042. )
  1043. return _story(
  1044. id=f"round:{round_index}:evaluation",
  1045. role="round-evaluation",
  1046. kind="decision" if conclusion else "missing",
  1047. title="主脚本整体评审",
  1048. label="评审结论",
  1049. value=projection["card"]["primary"]["value"],
  1050. secondary=projection["card"]["secondary"],
  1051. detail_ref=latest.get("detailRef"),
  1052. source_notice="runtime-associated",
  1053. status=_evaluation_state(conclusion),
  1054. decision=projection,
  1055. )
  1056. def _evaluation_decision(
  1057. evaluation: dict[str, Any],
  1058. *,
  1059. subtype: str,
  1060. detail_ref: Any,
  1061. prompt_ref: Any,
  1062. ) -> dict[str, Any]:
  1063. projection = _DECISIONS.evaluation(
  1064. {
  1065. "id": evaluation.get("id") or detail_ref,
  1066. "detailRef": detail_ref,
  1067. "promptRef": prompt_ref,
  1068. "subtype": subtype,
  1069. "subjects": evaluation.get("subjects") or [
  1070. f"方案 {value}" for value in evaluation.get("branchIds") or []
  1071. ],
  1072. "criteria": evaluation.get("criteria") or [],
  1073. "itemConclusions": evaluation.get("itemConclusions")
  1074. or evaluation.get("branchConclusions")
  1075. or [],
  1076. "branchEvaluations": evaluation.get("branchEvaluations") or [],
  1077. "comparisonRows": evaluation.get("comparisonRows") or [],
  1078. "achievements": evaluation.get("achievements") or [],
  1079. "problems": evaluation.get("problems") or [],
  1080. "conclusion": evaluation.get("conclusion"),
  1081. "recommendation": evaluation.get("recommendation")
  1082. or evaluation.get("comparison"),
  1083. "nextGoal": evaluation.get("nextGoal"),
  1084. "completeness": "partial"
  1085. if evaluation.get("notices")
  1086. else "complete",
  1087. }
  1088. )
  1089. full_conclusion = business_text(
  1090. evaluation.get("conclusion")
  1091. or evaluation.get("recommendation")
  1092. or evaluation.get("comparison")
  1093. )
  1094. if full_conclusion:
  1095. projection.setdefault("card", {}).setdefault("primary", {})[
  1096. "value"
  1097. ] = full_conclusion
  1098. return projection
  1099. def _attach_review_comparison(
  1100. review: dict[str, Any], decision: dict[str, Any], association: str
  1101. ) -> None:
  1102. projection = decision.get("decisionProjection")
  1103. if not isinstance(projection, dict):
  1104. return
  1105. conclusions = {
  1106. int(item.get("branchId") or 0): item
  1107. for item in review.get("branchConclusions") or []
  1108. if isinstance(item, dict)
  1109. }
  1110. outcomes = {
  1111. int(item.get("branchId") or 0): item
  1112. for item in decision.get("branchOutcomes") or []
  1113. if isinstance(item, dict)
  1114. }
  1115. reasoning = business_text(decision.get("reasoning"))
  1116. rows: list[dict[str, Any]] = []
  1117. for branch_id in decision.get("branchIds") or []:
  1118. row = {
  1119. "candidate": f"方案 {branch_id}",
  1120. "recommendation": (conclusions.get(branch_id) or {}).get("conclusion")
  1121. or review.get("recommendation")
  1122. or "评审未给出单独结论",
  1123. "finalDecision": (outcomes.get(branch_id) or {}).get("label")
  1124. or "最终状态未记录",
  1125. }
  1126. handling = _explicit_recommendation_handling(reasoning, branch_id)
  1127. if handling:
  1128. row["handling"] = handling
  1129. rows.append(row)
  1130. projection.setdefault("body", {})["reviewComparison"] = rows
  1131. projection["body"]["reviewAssociation"] = association
  1132. review_input = {
  1133. "label": "同批候选方案的评审建议",
  1134. "summary": business_text(
  1135. review.get("recommendation") or review.get("comparison")
  1136. ),
  1137. "observedRelation": "returned-to-actor",
  1138. "decisionUse": "not-recorded",
  1139. "detailRef": review.get("detailRef"),
  1140. }
  1141. projection.setdefault("inputs", []).append(review_input)
  1142. projection.setdefault("notices", []).append(
  1143. {
  1144. "code": "same-batch-association",
  1145. "message": "评审与最终决定按同一批候选方案和发生顺序关联;系统没有保存直接的一对一关系。",
  1146. }
  1147. )
  1148. if rows:
  1149. blocks = projection.setdefault("detail", {}).setdefault("blocks", [])
  1150. blocks.insert(
  1151. 0,
  1152. {
  1153. "type": "items",
  1154. "title": "参与判断",
  1155. "items": [
  1156. {
  1157. "label": review_input["label"],
  1158. "value": review_input["summary"],
  1159. "note": "可确认评审在最终决定前返回;是否采用应以下方的逐方案对照为准。",
  1160. "detailRef": review_input["detailRef"],
  1161. }
  1162. ],
  1163. "visualRole": "evidence",
  1164. "presentation": "list",
  1165. "collapsible": True,
  1166. },
  1167. )
  1168. blocks.insert(
  1169. 3,
  1170. {
  1171. "type": "comparison",
  1172. "title": "评审建议与最终决定",
  1173. "rows": rows,
  1174. "visualRole": "section",
  1175. "presentation": "comparison",
  1176. },
  1177. )
  1178. def _explicit_recommendation_handling(reasoning: str, branch_id: int) -> str | None:
  1179. if not reasoning:
  1180. return None
  1181. for sentence in re.split(r"[。;;\n]+", reasoning):
  1182. if not re.search(rf"方案\s*{branch_id}\b", sentence):
  1183. continue
  1184. if any(
  1185. token in sentence
  1186. for token in (
  1187. "采纳",
  1188. "吸收",
  1189. "保留",
  1190. "延后",
  1191. "转入",
  1192. "下一轮",
  1193. "转译",
  1194. "不直接合并",
  1195. "未采用",
  1196. "拒绝",
  1197. "不采纳",
  1198. )
  1199. ):
  1200. return sentence.strip()
  1201. return None
  1202. _DATABASE_TIMEZONE = ZoneInfo("Asia/Shanghai")
  1203. def _date_value(value: Any) -> datetime | None:
  1204. if isinstance(value, datetime):
  1205. parsed = value
  1206. elif isinstance(value, str) and value:
  1207. try:
  1208. parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
  1209. except ValueError:
  1210. return None
  1211. else:
  1212. return None
  1213. if parsed.tzinfo is None:
  1214. parsed = parsed.replace(tzinfo=_DATABASE_TIMEZONE)
  1215. return parsed.astimezone(timezone.utc)
  1216. def _not_after(value: Any, boundary: datetime | None) -> bool:
  1217. current = _date_value(value)
  1218. return boundary is None or current is None or current <= boundary
  1219. def _record_order(row: dict[str, Any]) -> tuple[str, int]:
  1220. return (str(row.get("created_at") or ""), _integer(row.get("id")) or 0)
  1221. def _runtime_order(row: dict[str, Any]) -> tuple[str, int]:
  1222. return (
  1223. str(row.get("startedAt") or row.get("endedAt") or ""),
  1224. _integer(row.get("eventId")) or 0,
  1225. )
  1226. def _decision_order(row: dict[str, Any]) -> tuple[str, int]:
  1227. return (str(row.get("createdAt") or ""), _integer(row.get("recordId")) or 0)
  1228. def _convergence_order(row: dict[str, Any]) -> tuple[str, int]:
  1229. review = row.get("review") or {}
  1230. decision = row.get("decision") or {}
  1231. return (
  1232. str(review.get("startedAt") or decision.get("createdAt") or ""),
  1233. _integer(review.get("eventId") or decision.get("recordId")) or 0,
  1234. )
  1235. def _round_result(
  1236. record: dict[str, Any],
  1237. round_index: int,
  1238. branches: list[dict[str, Any]],
  1239. domain_info: list[dict[str, Any]],
  1240. state: str,
  1241. next_round: dict[str, Any] | None,
  1242. ) -> dict[str, Any]:
  1243. merged_content = [
  1244. branch
  1245. for branch in branches
  1246. if branch["pathType"] != "领域信息" and branch["status"] == "merged"
  1247. ]
  1248. script_summary = (
  1249. f"确认采用 {len(merged_content)} 个内容方案"
  1250. if merged_content
  1251. else "没有可确认的内容方案合入记录"
  1252. )
  1253. domain_summary = (
  1254. f"新增 {len(domain_info)} 条领域事实"
  1255. if domain_info
  1256. else "本轮没有新增领域事实记录"
  1257. )
  1258. if next_round:
  1259. next_step = f"数据库已记录第 {next_round.get('round_index')} 轮"
  1260. elif str(record.get("status") or "").lower() in TERMINAL_STATUSES:
  1261. next_step = "构建已经结束"
  1262. else:
  1263. next_step = "等待本轮收口"
  1264. primary = f"{script_summary};{domain_summary}"
  1265. return _story(
  1266. id=f"round:{round_index}:result",
  1267. role="round-result",
  1268. kind="result",
  1269. title="本轮产出",
  1270. label="产出",
  1271. value=primary,
  1272. secondary=[_line("next", "下一步", next_step)],
  1273. detail_ref=f"round:{round_index}:result",
  1274. artifact_ref=f"artifact:round:{round_index}",
  1275. has_changes=bool(merged_content or domain_info),
  1276. status=state,
  1277. )
  1278. def _round_state(
  1279. record: dict[str, Any],
  1280. raw_round: dict[str, Any],
  1281. evaluation: dict[str, Any] | None,
  1282. ) -> str:
  1283. if evaluation and evaluation.get("status") not in {None, "unknown"}:
  1284. return str(evaluation["status"])
  1285. run_status = str(record.get("status") or "").lower()
  1286. if str(raw_round.get("status") or "").lower() == "open" and run_status not in TERMINAL_STATUSES:
  1287. return "running"
  1288. return "unknown"
  1289. def _evaluation_state(conclusion: str | None) -> str:
  1290. text = str(conclusion or "")
  1291. if any(token in text for token in ("未通过", "不通过", "回退", "重试")):
  1292. return "needs-retry"
  1293. if "部分通过" in text:
  1294. return "partially-passed"
  1295. if "通过" in text:
  1296. return "passed"
  1297. return "unknown"
  1298. def _final_result(
  1299. record: dict[str, Any], rounds: list[dict[str, Any]], artifact: dict[str, Any]
  1300. ) -> dict[str, Any]:
  1301. branches = [branch for round_ in rounds for branch in round_["branches"]]
  1302. statuses = Counter(branch["status"] for branch in branches)
  1303. counts = _snapshot_counts(artifact)
  1304. status = str(record.get("status") or "unknown")
  1305. summary = _plain(record.get("summary"))
  1306. error = _plain(record.get("error_message"))
  1307. primary = summary or error or _final_status_text(status)
  1308. return {
  1309. "id": "run:final-result",
  1310. "role": "final-result",
  1311. "kind": "result",
  1312. "title": "最终结果",
  1313. "status": status,
  1314. "terminal": status.lower() in TERMINAL_STATUSES,
  1315. "roundCount": len(rounds),
  1316. "branchCounts": {
  1317. "total": len(branches),
  1318. "merged": statuses.get("merged", 0),
  1319. "parked": statuses.get("parked", 0),
  1320. "discarded": statuses.get("discarded", 0),
  1321. "open": statuses.get("open", 0),
  1322. },
  1323. "summary": _plain(record.get("summary")),
  1324. "errorMessage": _plain(record.get("error_message")),
  1325. "artifact": {
  1326. "snapshotRef": "artifact:base:current",
  1327. "paragraphCount": counts["paragraphs"],
  1328. "elementCount": counts["elements"],
  1329. "linkCount": counts["links"],
  1330. "historicalVersion": "current-only",
  1331. },
  1332. "card": {
  1333. "primary": {"label": "构建结论", "value": primary},
  1334. "secondary": [
  1335. _line("rounds", "轮次", f"{len(rounds)} 轮"),
  1336. _line(
  1337. "branches",
  1338. "方案处置",
  1339. f"采用 {statuses.get('merged', 0)} · 暂存 {statuses.get('parked', 0)} · 未采用 {statuses.get('discarded', 0)}",
  1340. ),
  1341. _line(
  1342. "artifact",
  1343. "主脚本规模",
  1344. f"{counts['paragraphs']} 段落 · {counts['elements']} 元素 · {counts['links']} 关联",
  1345. ),
  1346. ],
  1347. },
  1348. "artifactRef": "artifact:base:current",
  1349. "detailRef": "run:final-result",
  1350. }
  1351. def _final_status_text(status: str) -> str:
  1352. return {
  1353. "success": "构建已完成",
  1354. "completed": "构建已完成",
  1355. "partial": "构建部分完成",
  1356. "failed": "构建失败",
  1357. "stopped": "构建已停止",
  1358. "cancelled": "构建已取消",
  1359. "running": "构建进行中",
  1360. }.get(str(status or "").lower(), "构建状态已记录")
  1361. def _story(
  1362. *,
  1363. id: str,
  1364. role: str,
  1365. kind: str,
  1366. title: str,
  1367. label: str,
  1368. value: Any,
  1369. secondary: list[dict[str, Any]] | None = None,
  1370. detail_ref: str | None = None,
  1371. has_changes: bool = False,
  1372. source_notice: str | None = None,
  1373. status: str | None = None,
  1374. artifact_ref: str | None = None,
  1375. decision: dict[str, Any] | None = None,
  1376. ) -> dict[str, Any]:
  1377. node = {
  1378. "id": id,
  1379. "role": role,
  1380. "kind": kind,
  1381. "title": title,
  1382. "card": (decision or {}).get("card")
  1383. or {
  1384. # Callers pass a deliberately selected card field. Keep that field
  1385. # intact; line clamping is a view concern, not a data projection.
  1386. "primary": {"label": label, "value": _plain(value)},
  1387. "secondary": [item for item in (secondary or []) if item.get("value")],
  1388. },
  1389. "hasChanges": bool(has_changes),
  1390. }
  1391. if detail_ref:
  1392. node["detailRef"] = detail_ref
  1393. if source_notice:
  1394. node["sourceNotice"] = source_notice
  1395. if status:
  1396. node["status"] = status
  1397. if artifact_ref:
  1398. node["artifactRef"] = artifact_ref
  1399. if decision:
  1400. node["decision"] = decision
  1401. return node
  1402. def _line(key: str, label: str, value: Any, tone: str = "normal") -> dict[str, Any]:
  1403. return {"key": key, "label": label, "value": _plain(value), "tone": tone}
  1404. def _plan_card_lines(paths: list[Any], note: Any) -> list[dict[str, Any]]:
  1405. lines: list[dict[str, Any]] = []
  1406. if paths:
  1407. route_values = [plan_path_summary(path, index) for index, path in enumerate(paths[:2], 1)]
  1408. route_summary = ";".join(route_values)
  1409. if len(paths) > 2:
  1410. route_summary += f";另有 {len(paths) - 2} 路,详情查看"
  1411. lines.append(_line("routes", "各路任务", route_summary))
  1412. reason = _plain(note)
  1413. if reason:
  1414. lines.append(_line("reason", "规划理由", reason))
  1415. return lines[:2]
  1416. def _context_input_labels(
  1417. context: dict[str, Any] | None, *, relation: str | None = None
  1418. ) -> list[str]:
  1419. result: list[str] = []
  1420. for item in (context or {}).get("inputs") or []:
  1421. if not isinstance(item, dict):
  1422. continue
  1423. if relation and item.get("relation") != relation:
  1424. continue
  1425. label = _plain(item.get("label"))
  1426. if label and label not in result:
  1427. result.append(label)
  1428. return result[:3]
  1429. def _direction_decision(
  1430. *,
  1431. id: str,
  1432. subtype: str,
  1433. context: dict[str, Any] | None,
  1434. decision_items: list[Any],
  1435. primary_label: str,
  1436. primary_value: Any,
  1437. secondary: list[dict[str, Any]],
  1438. prompt_ref: str,
  1439. ) -> dict[str, Any]:
  1440. context = context if isinstance(context, dict) else {}
  1441. decision = _DECISIONS.direction(
  1442. {
  1443. "id": id,
  1444. "detailRef": id,
  1445. "promptRef": prompt_ref,
  1446. "subtype": subtype,
  1447. "decisionItems": [str(item) for item in decision_items if item],
  1448. "explicitReasoning": context.get("explicitReasoning"),
  1449. "constraints": context.get("constraints") or [],
  1450. "inputs": context.get("inputs") or [],
  1451. "sourceDocument": context.get("sourceDocument"),
  1452. "implementationPlan": {
  1453. "mode": context.get("displayMode"),
  1454. "rawMode": context.get("rawMode"),
  1455. "routes": context.get("routeItems") or [],
  1456. "reasoning": context.get("explicitReasoning"),
  1457. }
  1458. if subtype == "implementation-plan"
  1459. else None,
  1460. "completeness": context.get("completeness"),
  1461. }
  1462. )
  1463. decision["card"] = {
  1464. "primary": {
  1465. "label": primary_label,
  1466. "value": _plain(primary_value) or "决策结论未记录",
  1467. },
  1468. "secondary": [item for item in secondary if item.get("value")][:3],
  1469. }
  1470. revisions = []
  1471. for revision in context.get("revisions") or []:
  1472. if not isinstance(revision, dict):
  1473. continue
  1474. paths = revision.get("paths") if isinstance(revision.get("paths"), list) else []
  1475. parts = [str(revision.get("mode"))] if revision.get("mode") else []
  1476. if paths:
  1477. parts.append(f"{len(paths)} 路方案")
  1478. revisions.append(
  1479. {
  1480. "current": bool(revision.get("current")),
  1481. "detailRef": revision.get("detailRef"),
  1482. "summary": " · ".join(parts) or "已保存一次版本",
  1483. }
  1484. )
  1485. if revisions:
  1486. decision["body"]["revisions"] = revisions
  1487. decision["technicalRefs"] = list(context.get("technicalRefs") or [])
  1488. return decision
  1489. def _implementer_prompt_ref(retrieval_stage: dict[str, Any] | None) -> str | None:
  1490. event_id = _integer((retrieval_stage or {}).get("implementerEventId"))
  1491. return f"prompt:event:{event_id}" if event_id is not None else None
  1492. def _snapshot_counts(value: Any) -> dict[str, int]:
  1493. if isinstance(value, dict) and isinstance(value.get("snapshot"), dict):
  1494. value = value["snapshot"]
  1495. if not isinstance(value, dict):
  1496. return {"paragraphs": 0, "elements": 0, "links": 0}
  1497. stats = value.get("stats") if isinstance(value.get("stats"), dict) else {}
  1498. if stats:
  1499. return {
  1500. "paragraphs": int(stats.get("paragraph_count") or 0),
  1501. "elements": int(stats.get("element_count") or 0),
  1502. "links": int(stats.get("link_count") or 0),
  1503. }
  1504. return {
  1505. "paragraphs": len(value.get("paragraphs") or []),
  1506. "elements": len(value.get("elements") or []),
  1507. "links": len(value.get("paragraphElements") or value.get("paragraph_elements") or []),
  1508. }
  1509. def _ids(value: Any) -> list[int]:
  1510. if not isinstance(value, list):
  1511. return []
  1512. result: list[int] = []
  1513. for item in value:
  1514. number = _integer(item)
  1515. if number is not None and number not in result:
  1516. result.append(number)
  1517. return result
  1518. def _implementer_tasks_by_branch(
  1519. events: list[dict[str, Any]],
  1520. ) -> dict[int, str]:
  1521. """Return complete dispatch tasks when the lightweight event carries them.
  1522. Historical bundles may only retain previews; callers deliberately keep the
  1523. branch business record as a labelled fallback in that case.
  1524. """
  1525. result: dict[int, str] = {}
  1526. for event in events:
  1527. if (
  1528. str(event.get("event_type") or "") != "agent_invoke"
  1529. or str(event.get("event_name") or "") != "script_implementer"
  1530. ):
  1531. continue
  1532. branch_id = _integer(event.get("branch_id"))
  1533. if branch_id is None:
  1534. continue
  1535. data = event.get("inputData")
  1536. task = data.get("task") if isinstance(data, dict) else None
  1537. if not task:
  1538. wrapped = event.get("input")
  1539. content = wrapped.get("content") if isinstance(wrapped, dict) else None
  1540. task = content.get("task") if isinstance(content, dict) else None
  1541. text = branch_task_text(task)
  1542. if text:
  1543. result[branch_id] = text
  1544. return result
  1545. def _round_summary(
  1546. *,
  1547. round_index: int,
  1548. goal: dict[str, Any],
  1549. plan: dict[str, Any],
  1550. branches: list[dict[str, Any]],
  1551. batches: list[dict[str, Any]],
  1552. domain_info: list[dict[str, Any]],
  1553. run_status: str,
  1554. ) -> dict[str, Any]:
  1555. paths = plan.get("paths") or []
  1556. branch_ids = sorted(
  1557. {
  1558. branch_id
  1559. for batch in batches
  1560. for branch_id in batch.get("branchIds") or []
  1561. }
  1562. )
  1563. decision_context = _missing_multipath_context(run_status)
  1564. decision_summary = (
  1565. ";".join(_preview(_business_terms(batch.get("decision")), 64) for batch in batches[:2])
  1566. if batches
  1567. else decision_context["value"]
  1568. )
  1569. if len(batches) > 2:
  1570. decision_summary += f";另有 {len(batches) - 2} 个决策批次"
  1571. merged_content_count = sum(
  1572. 1
  1573. for branch in branches
  1574. if branch.get("pathType") != "领域信息" and branch.get("status") == "merged"
  1575. )
  1576. script_summary = (
  1577. f"{merged_content_count} 个内容方案进入主脚本"
  1578. if merged_content_count
  1579. else "没有可确认的内容方案合入记录"
  1580. )
  1581. domain_summary = (
  1582. f"新增 {len(domain_info)} 条领域事实"
  1583. if domain_info
  1584. else "没有新增领域事实记录"
  1585. )
  1586. return {
  1587. "title": f"第 {round_index} 轮",
  1588. "goal": goal,
  1589. "approach": {
  1590. "mode": plan.get("mode"),
  1591. "candidateCount": len(branches) or len(paths),
  1592. "plannedPathCount": len(paths),
  1593. "contentBranchCount": sum(
  1594. 1 for branch in branches if branch.get("pathType") == "内容"
  1595. ),
  1596. "domainBranchCount": sum(
  1597. 1 for branch in branches if branch.get("pathType") == "领域信息"
  1598. ),
  1599. "summary": _plan_summary(plan),
  1600. },
  1601. "decision": {
  1602. "batchCount": len(batches),
  1603. "branchIds": branch_ids,
  1604. "summary": decision_summary,
  1605. },
  1606. "output": {
  1607. "mergedContentCount": merged_content_count,
  1608. "domainFactCount": len(domain_info),
  1609. "scriptSummary": script_summary,
  1610. "domainSummary": domain_summary,
  1611. "summary": f"{script_summary};{domain_summary}",
  1612. },
  1613. }
  1614. def _goal_summary(value: Any) -> dict[str, Any]:
  1615. parsed = parse_goal_text(value)
  1616. if not parsed.get("raw"):
  1617. return {
  1618. "headline": "本轮目标未记录",
  1619. "itemCount": 0,
  1620. "previewItems": [],
  1621. "truncated": False,
  1622. }
  1623. items = list(parsed.get("focusItems") or [])
  1624. headline_source = str(parsed.get("headline") or parsed.get("raw"))
  1625. headline = _preview(headline_source, 96)
  1626. preview_items = [_preview(item, 72) for item in items[:3]]
  1627. return {
  1628. "headline": headline,
  1629. "itemCount": len(items),
  1630. "previewItems": preview_items,
  1631. "truncated": bool(
  1632. items
  1633. or len(headline_source) > len(headline)
  1634. or len(items) > len(preview_items)
  1635. ),
  1636. }
  1637. def _business_terms(value: Any) -> str:
  1638. text = _plain(value) or "未记录"
  1639. text = re.sub(r"(?i)\s*\bbranch\s*#?(\d+)\b\s*", r"方案 \1 ", text)
  1640. text = re.sub(r"(?i)\s*\bbase\b", "主脚本", text)
  1641. text = re.sub(r"\s+([,。;!?、:)】,.])", r"\1", text)
  1642. return text
  1643. def _missing_multipath_context(run_status: str) -> dict[str, Any]:
  1644. status = str(run_status or "unknown").lower()
  1645. if status == "running":
  1646. return {
  1647. "kind": "execution",
  1648. "label": "当前进度",
  1649. "value": "等待主 Agent 比较候选方案",
  1650. "sourceNotice": None,
  1651. "status": "running",
  1652. }
  1653. interrupted = {
  1654. "stopped": "构建停止前尚未形成多路决策",
  1655. "cancelled": "构建取消前尚未形成多路决策",
  1656. "cancel": "构建取消前尚未形成多路决策",
  1657. "cancle": "构建取消前尚未形成多路决策",
  1658. "failed": "构建失败前尚未形成多路决策",
  1659. }
  1660. if status in interrupted:
  1661. return {
  1662. "kind": "missing",
  1663. "label": "流程进度",
  1664. "value": interrupted[status],
  1665. "sourceNotice": "process-incomplete",
  1666. }
  1667. return {
  1668. "kind": "missing",
  1669. "label": "记录状态",
  1670. "value": "构建已经结束,但未找到主 Agent 多路决策记录",
  1671. "sourceNotice": "record-missing",
  1672. }
  1673. def _missing_evaluation_context(run_status: str) -> dict[str, Any]:
  1674. status = str(run_status or "unknown").lower()
  1675. if status == "running":
  1676. return {
  1677. "kind": "execution",
  1678. "label": "当前进度",
  1679. "value": "等待整体评审",
  1680. "sourceNotice": None,
  1681. "status": "running",
  1682. }
  1683. interrupted = {
  1684. "stopped": "构建停止前尚未进行整体评审",
  1685. "cancelled": "构建取消前尚未进行整体评审",
  1686. "cancel": "构建取消前尚未进行整体评审",
  1687. "cancle": "构建取消前尚未进行整体评审",
  1688. "failed": "构建失败前尚未形成整体评审结论",
  1689. }
  1690. if status in interrupted:
  1691. return {
  1692. "kind": "missing",
  1693. "label": "流程进度",
  1694. "value": interrupted[status],
  1695. "sourceNotice": "process-incomplete",
  1696. }
  1697. return {
  1698. "kind": "missing",
  1699. "label": "记录状态",
  1700. "value": "构建已经结束,但未找到明确的整体评审结论",
  1701. "sourceNotice": "record-missing",
  1702. }
  1703. def _branch_disposition_context(branch_status: str, run_status: str) -> dict[str, Any]:
  1704. if branch_status != "open":
  1705. return {
  1706. "kind": "decision",
  1707. "label": _status_label(branch_status),
  1708. "sourceNotice": None,
  1709. }
  1710. status = str(run_status or "unknown").lower()
  1711. if status == "running":
  1712. return {
  1713. "kind": "execution",
  1714. "label": "等待主 Agent 处置",
  1715. "sourceNotice": None,
  1716. }
  1717. interrupted = {
  1718. "stopped": "构建停止时尚未处置",
  1719. "cancelled": "构建取消时尚未处置",
  1720. "cancel": "构建取消时尚未处置",
  1721. "cancle": "构建取消时尚未处置",
  1722. "failed": "构建失败时尚未处置",
  1723. }
  1724. if status in interrupted:
  1725. return {
  1726. "kind": "missing",
  1727. "label": interrupted[status],
  1728. "sourceNotice": "process-incomplete",
  1729. }
  1730. return {
  1731. "kind": "missing",
  1732. "label": "构建结束时仍未处置",
  1733. "sourceNotice": "record-missing",
  1734. }
  1735. def _plan_summary(plan: dict[str, Any]) -> str:
  1736. paths = plan.get("paths") or []
  1737. if not paths:
  1738. return "多路规划未记录"
  1739. return f"{plan.get('mode') or '方式未记录'}:{len(paths)} 个候选方案"
  1740. def _status_label(status: str) -> str:
  1741. return {
  1742. "merged": "已采用",
  1743. "parked": "暂存",
  1744. "discarded": "未采用",
  1745. "open": "尚未处置",
  1746. }.get(status, status or "状态未记录")
  1747. def _data_shape(
  1748. branches: list[dict[str, Any]],
  1749. multipath_decisions: list[dict[str, Any]],
  1750. domain_info: list[dict[str, Any]],
  1751. events: list[dict[str, Any]],
  1752. ) -> str:
  1753. if (
  1754. multipath_decisions
  1755. or domain_info
  1756. or events
  1757. or any(branch.get("path_type") for branch in branches)
  1758. ):
  1759. return "current"
  1760. return "legacy-incomplete"
  1761. def _warnings(
  1762. *,
  1763. data_shape: str,
  1764. ignored_legacy_count: int,
  1765. rounds: list[dict[str, Any]],
  1766. runtime_unassigned: list[dict[str, Any]],
  1767. business_unassigned: list[dict[str, Any]],
  1768. ) -> list[dict[str, str]]:
  1769. warnings: list[dict[str, str]] = []
  1770. if data_shape == "legacy-incomplete":
  1771. warnings.append(
  1772. {
  1773. "id": "legacy-shape",
  1774. "level": "warning",
  1775. "message": "这是旧结构记录,只展示新数据契约能够直接确认的内容。",
  1776. }
  1777. )
  1778. if ignored_legacy_count:
  1779. warnings.append(
  1780. {
  1781. "id": "ignored-legacy-data-decisions",
  1782. "level": "info",
  1783. "message": f"已忽略 {ignored_legacy_count} 条旧语义主决策记录。",
  1784. }
  1785. )
  1786. missing_rounds = [
  1787. round_["roundIndex"]
  1788. for round_ in rounds
  1789. if round_["branches"]
  1790. and not any(
  1791. batch.get("decision")
  1792. for batch in round_.get("convergenceBatches") or []
  1793. )
  1794. and any(
  1795. (batch.get("missingDecision") or {}).get("sourceNotice")
  1796. == "record-missing"
  1797. for batch in round_.get("convergenceBatches") or []
  1798. )
  1799. ]
  1800. if missing_rounds:
  1801. warnings.append(
  1802. {
  1803. "id": "missing-multipath-decisions",
  1804. "level": "warning",
  1805. "message": "第 " + "、".join(map(str, missing_rounds)) + " 轮未记录主 Agent 多路决策。",
  1806. }
  1807. )
  1808. if runtime_unassigned:
  1809. warnings.append(
  1810. {
  1811. "id": "unassigned-runtime-events",
  1812. "level": "info",
  1813. "message": f"有 {len(runtime_unassigned)} 条运行记录无法安全归属,已留在技术详情中。",
  1814. }
  1815. )
  1816. if business_unassigned:
  1817. warnings.append(
  1818. {
  1819. "id": "unassigned-business-records",
  1820. "level": "warning",
  1821. "message": f"有 {len(business_unassigned)} 条多路决策缺少可确认的轮次归属。",
  1822. }
  1823. )
  1824. return warnings
  1825. def _plain(value: Any) -> str | None:
  1826. if value is None:
  1827. return None
  1828. text = str(value).replace("\r\n", "\n").strip()
  1829. if not text:
  1830. return None
  1831. text = re.sub(r"```[a-zA-Z0-9_-]*", "", text).replace("```", "")
  1832. text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M)
  1833. text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
  1834. text = re.sub(r"(?m)^\s*[-*]\s+", "• ", text)
  1835. return text.strip()
  1836. def _preview(value: Any, limit: int = 140) -> str:
  1837. text = _plain(value) or "未记录"
  1838. text = re.sub(r"\s+", " ", text)
  1839. if len(text) <= limit:
  1840. return text
  1841. window = text[:limit]
  1842. cut = max(window.rfind(mark) for mark in "。!?;")
  1843. if cut >= max(30, limit // 2):
  1844. return window[: cut + 1]
  1845. return window.rstrip(",、;:,. ") + "…"
  1846. def _integer(value: Any) -> int | None:
  1847. try:
  1848. return int(value) if value is not None else None
  1849. except (TypeError, ValueError):
  1850. return None