journey.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. from __future__ import annotations
  2. import json
  3. from datetime import datetime, timezone
  4. from typing import Any, Literal
  5. from .models import (
  6. DataRef,
  7. InputSummary,
  8. JourneyEdge,
  9. JourneyStep,
  10. JourneyView,
  11. ProcessView,
  12. StepEvidence,
  13. )
  14. from .narrator import narrate_contract, task_label
  15. def build_journey(script_build_id: int, source: dict[str, Any]) -> JourneyView:
  16. snapshot = source["snapshot"]
  17. tasks = {str(item["task_id"]): item for item in snapshot.get("tasks") or []}
  18. attempts = {str(item["attempt_id"]): item for item in snapshot.get("attempts") or []}
  19. validations = {str(item["validation_id"]): item for item in snapshot.get("validations") or []}
  20. decisions = {str(item["decision_id"]): item for item in snapshot.get("decisions") or []}
  21. contracts = source.get("contracts") or {}
  22. messages = source.get("messages") or {}
  23. root_trace_id = str(snapshot.get("root_trace_id") or "")
  24. planner_calls = _planner_calls(messages.get(root_trace_id) or [])
  25. operations = snapshot.get("operations") or []
  26. parallel = _parallel_attempts(operations)
  27. event_sequences = _event_sequences(source.get("events") or [])
  28. steps: list[JourneyStep] = []
  29. plan_by_task: dict[str, str] = {}
  30. execute_by_attempt: dict[str, str] = {}
  31. validation_by_id: dict[str, str] = {}
  32. for task_id, task in tasks.items():
  33. if task.get("parent_task_id") is None or task_id not in contracts:
  34. continue
  35. contract = contracts[task_id]
  36. narration = narrate_contract(contract)
  37. call = planner_calls.get(task_id)
  38. observable = _plain((call or {}).get("assistant_text"))
  39. step_id = f"plan:{task_id}"
  40. plan_by_task[task_id] = step_id
  41. steps.append(
  42. JourneyStep(
  43. step_id=step_id,
  44. step_type="plan",
  45. title=f"Planner 规划·{narration.output}",
  46. status=str(task.get("status") or "planned"),
  47. task_id=task_id,
  48. reasoning=observable or narration.reasoning,
  49. reasoning_source="observable" if observable else "contract",
  50. process=ProcessView(
  51. label=narration.action,
  52. actor="Planner",
  53. tool_name="plan_script_tasks",
  54. trace_id=root_trace_id,
  55. details={"task_kind": contract.get("task_kind")},
  56. ),
  57. input_data=_contract_inputs(contract),
  58. output_data=[
  59. DataRef(label="TaskContract", kind="contract", ref=contract.get("contract_ref"))
  60. ],
  61. contract=narration,
  62. created_at=str(task.get("created_at") or ""),
  63. evidence=StepEvidence(
  64. contract=contract,
  65. tool_calls=[call["tool_call"]] if call and call.get("tool_call") else [],
  66. ),
  67. )
  68. )
  69. for attempt_id, attempt in attempts.items():
  70. task_id = str(attempt.get("task_id") or "")
  71. task = tasks.get(task_id) or {}
  72. contract = contracts.get(task_id)
  73. worker_trace_id = str(attempt.get("worker_trace_id") or "")
  74. worker_messages = messages.get(worker_trace_id) or []
  75. observable = _assistant_text(worker_messages)
  76. narration = narrate_contract(contract) if contract else None
  77. tools = _tool_calls(worker_messages)
  78. position, total, group_id = parallel.get(attempt_id, (None, None, None))
  79. stats = attempt.get("execution_stats") or {}
  80. step_id = f"execute:{attempt_id}"
  81. execute_by_attempt[attempt_id] = step_id
  82. steps.append(
  83. JourneyStep(
  84. step_id=step_id,
  85. step_type="execute",
  86. title=f"Worker 执行·{_task_name(task, contract)}",
  87. status=str(attempt.get("status") or "unknown"),
  88. task_id=task_id,
  89. attempt_id=attempt_id,
  90. reasoning=observable
  91. or (
  92. f"执行任务合同:{contract.get('objective')}"
  93. if contract
  94. else "执行已冻结的任务合同"
  95. ),
  96. reasoning_source="observable" if observable else "contract",
  97. process=ProcessView(
  98. label=_worker_process_label(tools),
  99. actor="Worker",
  100. trace_id=worker_trace_id or None,
  101. details={"execution_mode": attempt.get("execution_mode")},
  102. ),
  103. input_data=_contract_inputs(contract or {}),
  104. output_data=_attempt_outputs(attempt),
  105. contract=narration,
  106. parallel_group_id=group_id,
  107. parallel_position=position,
  108. parallel_total=total,
  109. model=stats.get("primary_model"),
  110. tokens=stats.get("total_tokens"),
  111. cost=stats.get("total_cost"),
  112. duration_ms=attempt.get("duration_ms"),
  113. created_at=str(attempt.get("started_at") or attempt.get("created_at") or ""),
  114. evidence=StepEvidence(
  115. contract=contract,
  116. tool_calls=tools,
  117. artifact_refs=_artifact_refs(attempt),
  118. ),
  119. )
  120. )
  121. for validation_id, validation in validations.items():
  122. attempt_id = str(validation.get("attempt_id") or "")
  123. attempt = attempts.get(attempt_id) or {}
  124. task_id = str(validation.get("task_id") or attempt.get("task_id") or "")
  125. plan = validation.get("validation_plan") or {}
  126. stats = validation.get("execution_stats") or {}
  127. reasoning = _validation_reason(validation)
  128. task_name = _task_name(tasks.get(task_id) or {}, contracts.get(task_id))
  129. step_id = f"validate:{validation_id}"
  130. validation_by_id[validation_id] = step_id
  131. steps.append(
  132. JourneyStep(
  133. step_id=step_id,
  134. step_type="validate",
  135. title=f"Validator 验收·{task_name}",
  136. status=str(validation.get("verdict") or validation.get("status") or "unknown"),
  137. task_id=task_id,
  138. attempt_id=attempt_id or None,
  139. validation_id=validation_id,
  140. reasoning=reasoning,
  141. reasoning_source="validation",
  142. process=ProcessView(
  143. label=(
  144. "确定性规则预检"
  145. if plan.get("mode") == "deterministic"
  146. else "独立 Validator 语义质检"
  147. ),
  148. actor="Validator",
  149. trace_id=validation.get("validator_trace_id"),
  150. details={
  151. "mode": plan.get("mode"),
  152. "preflight_rule_ids": plan.get("preflight_rule_ids") or [],
  153. },
  154. ),
  155. input_data=_attempt_outputs(attempt),
  156. output_data=[
  157. DataRef(
  158. label=f"Validation·{validation.get('verdict') or validation.get('status')}",
  159. kind="validation",
  160. ref=validation_id,
  161. detail=_plain(validation.get("summary")),
  162. )
  163. ],
  164. model=stats.get("primary_model"),
  165. tokens=stats.get("total_tokens"),
  166. cost=stats.get("total_cost"),
  167. duration_ms=validation.get("duration_ms"),
  168. created_at=str(validation.get("started_at") or validation.get("created_at") or ""),
  169. evidence=StepEvidence(validation=validation, artifact_refs=_artifact_refs(attempt)),
  170. )
  171. )
  172. for decision_id, decision in decisions.items():
  173. task_id = str(decision.get("task_id") or "")
  174. action = str(decision.get("action") or "unknown").upper()
  175. validation_id = str(decision.get("validation_id") or "")
  176. attempt_id = str(decision.get("attempt_id") or "")
  177. steps.append(
  178. JourneyStep(
  179. step_id=f"decide:{decision_id}",
  180. step_type="decide",
  181. title=f"Planner 决定·{_action_label(action)}",
  182. status=action.lower(),
  183. task_id=task_id,
  184. attempt_id=attempt_id or None,
  185. validation_id=validation_id or None,
  186. decision_id=decision_id,
  187. reasoning=_plain(decision.get("reason")) or "Planner 根据验收结果选择下一步",
  188. reasoning_source="decision",
  189. process=ProcessView(
  190. label=_action_process(action),
  191. actor="Planner",
  192. tool_name="decide_script_task",
  193. trace_id=root_trace_id,
  194. details={
  195. "from_status": decision.get("from_status"),
  196. "to_status": decision.get("to_status"),
  197. },
  198. ),
  199. input_data=[
  200. DataRef(label="Validation", kind="validation", ref=validation_id or None)
  201. ],
  202. output_data=[
  203. DataRef(label=_action_label(action), kind="decision", ref=decision_id)
  204. ],
  205. created_at=str(decision.get("created_at") or ""),
  206. evidence=StepEvidence(decision=decision),
  207. )
  208. )
  209. steps.sort(key=lambda step: _step_sort_key(step, event_sequences))
  210. steps = [step.model_copy(update={"sequence": index}) for index, step in enumerate(steps, 1)]
  211. edges = _edges(steps, tasks, plan_by_task, execute_by_attempt, validation_by_id)
  212. root_task = next((item for item in tasks.values() if item.get("parent_task_id") is None), {})
  213. raw_input = source["input_summary"]
  214. return JourneyView(
  215. schema_version="script-build-journey/v1",
  216. generated_at=datetime.now(timezone.utc).isoformat(),
  217. script_build_id=script_build_id,
  218. root_trace_id=root_trace_id,
  219. status=str(root_task.get("status") or "unknown"),
  220. objective=_plain(snapshot.get("root_objective")) or "完成脚本创作与交付",
  221. input_summary=InputSummary(
  222. topic=_plain(raw_input.get("topic")) or "未记录选题",
  223. account_name=(raw_input.get("persona") or {}).get("resolved_account_name")
  224. or (raw_input.get("persona") or {}).get("account_name"),
  225. persona_point_count=int((raw_input.get("persona") or {}).get("point_count") or 0),
  226. strategy_names=[
  227. str(item["name"])
  228. for item in raw_input.get("strategies") or []
  229. if isinstance(item, dict) and item.get("name")
  230. ],
  231. snapshot_id=str(raw_input.get("input_snapshot_id") or ""),
  232. digest=str(raw_input.get("input_digest") or ""),
  233. ),
  234. steps=steps,
  235. edges=edges,
  236. warnings=_warnings(steps),
  237. )
  238. def _planner_calls(messages: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
  239. results = {
  240. str(message.get("tool_call_id")): _parse_result(message.get("content"))
  241. for message in messages
  242. if message.get("role") == "tool" and message.get("tool_call_id")
  243. }
  244. output: dict[str, dict[str, Any]] = {}
  245. last_text = ""
  246. for message in sorted(messages, key=lambda value: int(value.get("sequence") or 0)):
  247. if message.get("role") != "assistant":
  248. continue
  249. content = message.get("content")
  250. if not isinstance(content, dict):
  251. continue
  252. if _plain(content.get("text")):
  253. last_text = _plain(content.get("text"))
  254. for call in content.get("tool_calls") or []:
  255. function = call.get("function") or {}
  256. if function.get("name") != "plan_script_tasks":
  257. continue
  258. result = results.get(str(call.get("id"))) or {}
  259. for task_id in result.get("task_ids") or []:
  260. output[str(task_id)] = {
  261. "assistant_text": last_text,
  262. "tool_call": {
  263. "name": "plan_script_tasks",
  264. "arguments": _parse_json(function.get("arguments")),
  265. },
  266. }
  267. return output
  268. def _tool_calls(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
  269. output: list[dict[str, Any]] = []
  270. for message in messages:
  271. content = message.get("content")
  272. if message.get("role") != "assistant" or not isinstance(content, dict):
  273. continue
  274. for call in content.get("tool_calls") or []:
  275. function = call.get("function") or {}
  276. output.append(
  277. {
  278. "name": str(function.get("name") or "unknown"),
  279. "arguments": _parse_json(function.get("arguments")),
  280. }
  281. )
  282. return output
  283. def _assistant_text(messages: list[dict[str, Any]]) -> str:
  284. values = []
  285. for message in messages:
  286. content = message.get("content")
  287. if message.get("role") == "assistant" and isinstance(content, dict):
  288. text = _plain(content.get("text"))
  289. if text:
  290. values.append(text)
  291. return " ".join(values[:3])[:1_200]
  292. def _contract_inputs(contract: dict[str, Any]) -> list[DataRef]:
  293. values = []
  294. for item in contract.get("input_decision_refs") or []:
  295. if not isinstance(item, dict):
  296. continue
  297. artifact = item.get("artifact_ref") or {}
  298. label = f"已接受的{task_label(str(item.get('expected_task_kind') or '上游成果'))}"
  299. values.append(
  300. DataRef(
  301. label=label,
  302. kind=str(artifact.get("kind") or "artifact"),
  303. ref=artifact.get("uri"),
  304. detail=str(item.get("decision_id") or ""),
  305. )
  306. )
  307. if not values:
  308. values.append(DataRef(label="冻结创作输入", kind="input-snapshot"))
  309. return values
  310. def _attempt_outputs(attempt: dict[str, Any]) -> list[DataRef]:
  311. submission = attempt.get("submission") or {}
  312. values = []
  313. for item in submission.get("artifact_refs") or []:
  314. if isinstance(item, dict):
  315. values.append(
  316. DataRef(
  317. label=str(item.get("summary") or item.get("kind") or "Artifact"),
  318. kind=str(item.get("kind") or "artifact"),
  319. ref=item.get("uri"),
  320. )
  321. )
  322. return values or [DataRef(label="待提交的任务成果", kind="artifact")]
  323. def _artifact_refs(attempt: dict[str, Any]) -> list[dict[str, Any]]:
  324. submission = attempt.get("submission") or {}
  325. return [item for item in submission.get("artifact_refs") or [] if isinstance(item, dict)]
  326. def _validation_reason(validation: dict[str, Any]) -> str:
  327. summary = _plain(validation.get("summary"))
  328. failures = [
  329. f"{item.get('criterion_id')}:{_plain(item.get('reason'))}"
  330. for item in validation.get("criterion_results") or []
  331. if isinstance(item, dict) and str(item.get("verdict")) not in {"passed", "pass"}
  332. ]
  333. return ";".join(failures) or summary or "Validator 已逐项检查验收标准"
  334. def _parallel_attempts(operations: list[dict[str, Any]]) -> dict[str, tuple[int, int, str]]:
  335. output: dict[str, tuple[int, int, str]] = {}
  336. for operation in operations:
  337. attempt_ids = [str(value) for value in operation.get("attempt_ids") or []]
  338. if len(attempt_ids) < 2:
  339. continue
  340. group_id = str(operation.get("operation_id") or "")
  341. for index, attempt_id in enumerate(attempt_ids, start=1):
  342. output[attempt_id] = (index, len(attempt_ids), group_id)
  343. return output
  344. def _loop_target(
  345. action: str,
  346. task_id: str,
  347. attempt_id: str,
  348. tasks: dict[str, dict[str, Any]],
  349. plan_by_task: dict[str, str],
  350. execute_by_attempt: dict[str, str],
  351. ) -> str | None:
  352. if action == "RETRY":
  353. return execute_by_attempt.get(attempt_id)
  354. if action == "REVISE":
  355. return plan_by_task.get(task_id)
  356. if action == "SPLIT":
  357. children = tasks.get(task_id, {}).get("child_task_ids") or []
  358. return next(
  359. (plan_by_task.get(str(value)) for value in children if plan_by_task.get(str(value))),
  360. None,
  361. )
  362. return None
  363. def _edges(
  364. steps: list[JourneyStep],
  365. tasks: dict[str, dict[str, Any]],
  366. plan_by_task: dict[str, str],
  367. execute_by_attempt: dict[str, str],
  368. validation_by_id: dict[str, str],
  369. ) -> list[JourneyEdge]:
  370. edges: list[JourneyEdge] = []
  371. for left, right in zip(steps, steps[1:]):
  372. edges.append(
  373. JourneyEdge(
  374. edge_id=f"sequence:{left.step_id}:{right.step_id}",
  375. source=left.step_id,
  376. target=right.step_id,
  377. relation="sequence",
  378. label="然后",
  379. )
  380. )
  381. for step in steps:
  382. if step.step_type == "execute" and step.attempt_id:
  383. source = plan_by_task.get(step.task_id)
  384. if source:
  385. edges.append(_edge(source, step.step_id, "creates", "派发执行"))
  386. elif step.step_type == "validate" and step.attempt_id:
  387. source = execute_by_attempt.get(step.attempt_id)
  388. if source:
  389. edges.append(_edge(source, step.step_id, "validates", "提交验收"))
  390. elif step.step_type == "decide" and step.validation_id:
  391. source = validation_by_id.get(step.validation_id)
  392. if source:
  393. edges.append(_edge(source, step.step_id, "decides", "验收结论"))
  394. target = _loop_target(
  395. step.status.upper(),
  396. step.task_id,
  397. step.attempt_id or "",
  398. tasks,
  399. plan_by_task,
  400. execute_by_attempt,
  401. )
  402. if target:
  403. edges.append(_edge(step.step_id, target, "loop", _loop_label(step.status.upper())))
  404. return edges
  405. def _edge(
  406. source: str,
  407. target: str,
  408. relation: Literal["creates", "validates", "decides", "loop"],
  409. label: str,
  410. ) -> JourneyEdge:
  411. return JourneyEdge(
  412. edge_id=f"{relation}:{source}:{target}",
  413. source=source,
  414. target=target,
  415. relation=relation,
  416. label=label,
  417. )
  418. def _warnings(steps: list[JourneyStep]) -> list[str]:
  419. warnings = []
  420. if not steps:
  421. warnings.append("当前运行还没有可展示的创作步骤。")
  422. if any(step.reasoning_source == "contract" for step in steps):
  423. warnings.append("部分“思考”由冻结任务合同转述,不是隐藏思维链。")
  424. return warnings
  425. def _worker_process_label(tools: list[dict[str, Any]]) -> str:
  426. names = [str(item.get("name")) for item in tools if item.get("name")]
  427. if not names:
  428. return "根据任务合同生成候选成果"
  429. shown = "、".join(names[:3])
  430. suffix = f"等 {len(names)} 次工具调用" if len(names) > 3 else ""
  431. return f"执行 {shown}{suffix}"
  432. def _task_name(task: dict[str, Any], contract: dict[str, Any] | None) -> str:
  433. if contract:
  434. return task_label(str(contract.get("task_kind") or "task"))
  435. return _plain((task.get("current_spec") or {}).get("objective"))[:60] or "任务"
  436. def _action_label(action: str) -> str:
  437. return {
  438. "ACCEPT": "接受成果",
  439. "RETRY": "重新执行",
  440. "REVISE": "修订任务",
  441. "SPLIT": "拆分子任务",
  442. "BLOCK": "暂停在边界",
  443. "CANCEL": "取消任务",
  444. }.get(action, action)
  445. def _action_process(action: str) -> str:
  446. return {
  447. "ACCEPT": "接受当前成果,继续规划下一步",
  448. "RETRY": "保留任务合同,让 Worker 再执行一次",
  449. "REVISE": "修订任务合同后重新执行",
  450. "SPLIT": "把当前问题拆成多个子任务",
  451. "BLOCK": "保存当前成果,等待下一阶段",
  452. }.get(action, "更新任务状态")
  453. def _loop_label(action: str) -> str:
  454. return {"RETRY": "重试", "REVISE": "修订后返工", "SPLIT": "拆分后继续"}.get(action, "返工")
  455. def _event_sequences(events: list[dict[str, Any]]) -> dict[str, int]:
  456. result: dict[str, int] = {}
  457. for event in events:
  458. sequence = int(event.get("sequence") or 0)
  459. payload = event.get("payload") or {}
  460. ids = list(payload.get("task_ids") or [])
  461. ids.extend(
  462. payload.get(key)
  463. for key in ("task_id", "attempt_id", "validation_id", "decision_id")
  464. if payload.get(key)
  465. )
  466. for entity_id in ids:
  467. result.setdefault(str(entity_id), sequence)
  468. return result
  469. def _step_sort_key(step: JourneyStep, event_sequences: dict[str, int]) -> tuple[int, str, int, str]:
  470. priority = {"plan": 0, "execute": 1, "validate": 2, "decide": 3}
  471. entity_id = step.decision_id or step.validation_id or step.attempt_id or step.task_id
  472. event_sequence = event_sequences.get(entity_id, 2**31)
  473. return event_sequence, step.created_at, priority[step.step_type], step.step_id
  474. def _parse_result(content: Any) -> dict[str, Any]:
  475. if isinstance(content, dict):
  476. return _parse_json(content.get("result", content))
  477. return _parse_json(content)
  478. def _parse_json(value: Any) -> Any:
  479. if not isinstance(value, str):
  480. return value
  481. try:
  482. return json.loads(value)
  483. except json.JSONDecodeError:
  484. return value
  485. def _plain(value: Any) -> str:
  486. return " ".join(str(value or "").split())