main_agent_decision_projection.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from datetime import datetime, timezone
  5. from typing import Any, Iterable
  6. from .evaluation_report_parser import EvaluationReportParser
  7. from .main_decision_text import parse_goal_text, parse_objective_text
  8. from .runtime_event_index import (
  9. EventBounds,
  10. RuntimeEventIndex,
  11. detail_ref,
  12. event_input,
  13. event_output,
  14. event_sequence,
  15. event_summary,
  16. event_text_output,
  17. integer,
  18. )
  19. from .runtime_tool_catalog import business_tool_label
  20. class MainAgentDecisionProjector:
  21. """Build honest decision contexts for the first three main-Agent stages.
  22. Database rows are passed in by the caller and remain the current business
  23. truth. Runtime events only describe how that persisted result was reached.
  24. """
  25. def project(
  26. self,
  27. index: RuntimeEventIndex,
  28. *,
  29. script_direction: str | None,
  30. rounds: Iterable[dict[str, Any]],
  31. multipath_decisions: Iterable[dict[str, Any]] = (),
  32. ) -> dict[str, Any]:
  33. round_rows = sorted(
  34. (dict(row) for row in rounds),
  35. key=lambda row: integer(row.get("round_index")) or 0,
  36. )
  37. decisions = [dict(item) for item in multipath_decisions]
  38. planning_analysis = PlanningAnalysisProjector().project(index)
  39. objective = ObjectiveDecisionProjector().project(
  40. index, script_direction=script_direction
  41. )
  42. goals: dict[int, dict[str, Any]] = {}
  43. plans: dict[int, dict[str, Any]] = {}
  44. for position, row in enumerate(round_rows):
  45. round_index = integer(row.get("round_index"))
  46. if round_index is None:
  47. continue
  48. goals[round_index] = RoundGoalDecisionProjector().project(
  49. index,
  50. round_row=row,
  51. objective_context=objective,
  52. previous_round=(round_rows[position - 1] if position else None),
  53. multipath_decisions=decisions,
  54. )
  55. plans[round_index] = ImplementationPlanDecisionProjector().project(
  56. index, round_row=row, goal_context=goals[round_index]
  57. )
  58. projection = {
  59. "planningAnalysis": planning_analysis,
  60. "objective": objective,
  61. "roundGoalsByRound": goals,
  62. "implementationPlansByRound": plans,
  63. }
  64. assigned_technical_refs = {
  65. ref
  66. for context in [planning_analysis, objective, *goals.values(), *plans.values()]
  67. for ref in context.get("technicalRefs") or []
  68. }
  69. projection["unassigned"] = [
  70. event_summary(event, "failed-main-decision-call-unassigned")
  71. for name in ("save_script_direction", "begin_round", "record_multipath_plan")
  72. for event in index.anchors(name, successful=False)
  73. if detail_ref(event) not in assigned_technical_refs
  74. ]
  75. return projection
  76. class PlanningAnalysisProjector:
  77. """Project the last real main-Agent planning note before direction is saved."""
  78. def project(self, index: RuntimeEventIndex) -> dict[str, Any]:
  79. direction_saves = index.anchors("save_script_direction", successful=True)
  80. first_save_seq = event_sequence(direction_saves[0]) if direction_saves else None
  81. candidates = index.anchors(
  82. "think_and_plan",
  83. successful=True,
  84. bounds=EventBounds(before_seq=first_save_seq),
  85. )
  86. event = candidates[-1] if candidates else None
  87. raw_input = event_input(event) if event else None
  88. data = raw_input if isinstance(raw_input, dict) else {}
  89. event_ref = detail_ref(event) if event else None
  90. return {
  91. "stage": "planning-analysis",
  92. "summary": _detail_text(data.get("thought_summary")),
  93. "thought": _detail_text(data.get("thought")),
  94. "plan": _detail_text(data.get("plan")),
  95. "action": _detail_text(data.get("action")),
  96. "eventRef": event_ref,
  97. "technicalRefs": [event_ref] if event_ref else [],
  98. "completeness": "complete" if event_ref and data else "missing",
  99. }
  100. class ObjectiveDecisionProjector:
  101. def project(
  102. self, index: RuntimeEventIndex, *, script_direction: str | None
  103. ) -> dict[str, Any]:
  104. successful = index.anchors("save_script_direction", successful=True)
  105. failed = index.anchors("save_script_direction", successful=False)
  106. current_anchor = successful[-1] if successful else None
  107. previous_seq = event_sequence(successful[-2]) if len(successful) > 1 else None
  108. current_seq = event_sequence(current_anchor) if current_anchor else None
  109. scope_id = integer(current_anchor.get("scope_event_id")) if current_anchor else None
  110. bounds = EventBounds(after_seq=previous_seq, before_seq=current_seq)
  111. reads = index.main_direct_reads(
  112. scope_id=scope_id,
  113. bounds=bounds,
  114. )
  115. failed_reads = index.main_direct_reads(
  116. scope_id=scope_id, bounds=bounds, successful=False
  117. )
  118. parsed = parse_script_direction(script_direction)
  119. revisions = [_direction_revision(event, script_direction) for event in successful]
  120. return {
  121. "stage": "objective",
  122. "inputs": [_decision_read(event) for event in reads],
  123. "decisionItems": parsed["goals"],
  124. "explicitReasoning": parsed["valueReasoning"],
  125. "constraints": parsed["constraints"],
  126. "revisionRefs": [ref for event in successful if (ref := detail_ref(event))],
  127. "completeness": _completeness(bool(script_direction), bool(current_anchor)),
  128. "revisions": revisions,
  129. "currentRevisionRef": _current_revision_ref(revisions),
  130. "technicalRefs": [
  131. ref
  132. for event in [*failed_reads, *failed]
  133. if (ref := detail_ref(event))
  134. ],
  135. "currentSavedValue": script_direction,
  136. "sourceDocument": parsed["raw"],
  137. "valueJudgements": parsed["valueJudgements"],
  138. }
  139. class RoundGoalDecisionProjector:
  140. def project(
  141. self,
  142. index: RuntimeEventIndex,
  143. *,
  144. round_row: dict[str, Any],
  145. objective_context: dict[str, Any],
  146. previous_round: dict[str, Any] | None,
  147. multipath_decisions: Iterable[dict[str, Any]],
  148. ) -> dict[str, Any]:
  149. round_index = integer(round_row.get("round_index"))
  150. goal = _text(round_row.get("goal"))
  151. begin_events = index.successful_begin_rounds().get(round_index or -1, [])
  152. current_anchor = begin_events[-1] if begin_events else None
  153. current_seq = event_sequence(current_anchor) if current_anchor else None
  154. inputs: list[dict[str, Any]] = []
  155. constraints: list[str] = []
  156. late_decision_refs: list[str] = []
  157. if round_index == 1:
  158. if objective_context.get("decisionItems"):
  159. inputs.append(
  160. {
  161. "label": "当前保存的创作目标",
  162. "summary": _detail_text(
  163. "\n\n".join(
  164. str(item)
  165. for item in objective_context["decisionItems"]
  166. )
  167. ),
  168. "actor": "main",
  169. "relation": "standing-constraint",
  170. "detailRef": objective_context.get("currentRevisionRef"),
  171. }
  172. )
  173. lower_seq = _last_objective_save_seq(index)
  174. else:
  175. previous_index = integer((previous_round or {}).get("round_index"))
  176. lower_seq = None
  177. reports = index.agent_returns(
  178. "script_evaluator",
  179. round_index=previous_index,
  180. before_seq=current_seq,
  181. )
  182. if reports:
  183. evaluator, relation = reports[-1]
  184. parsed_report = EvaluationReportParser().parse(
  185. event_text_output(evaluator) or "", kind="overall"
  186. )
  187. inputs.append(
  188. {
  189. "label": "上一轮整体评审",
  190. "summary": _parsed_report_summary(parsed_report),
  191. "actor": "overall-evaluator",
  192. "relation": relation,
  193. "detailRef": detail_ref(evaluator),
  194. }
  195. )
  196. constraints.extend(_parsed_report_guidance(parsed_report))
  197. lower_seq = event_sequence(evaluator)
  198. previous_decisions = sorted(
  199. [
  200. item
  201. for item in multipath_decisions
  202. if integer(item.get("round_index")) == previous_index
  203. ],
  204. key=lambda item: (str(item.get("created_at") or ""), integer(item.get("id")) or 0),
  205. )
  206. for decision in previous_decisions:
  207. decision_ref = (
  208. f"multipath-decision:{decision.get('id')}"
  209. if decision.get("id") is not None
  210. else None
  211. )
  212. if not _record_precedes_event(decision, current_anchor):
  213. if decision_ref:
  214. late_decision_refs.append(decision_ref)
  215. continue
  216. inputs.append(
  217. {
  218. "label": "上一轮主 Agent 多路决策",
  219. "summary": _detail_text(decision.get("decision")),
  220. "actor": "main",
  221. "relation": "persisted-record",
  222. "detailRef": decision_ref,
  223. }
  224. )
  225. if current_anchor is not None:
  226. scope_id = integer(current_anchor.get("scope_event_id"))
  227. read_bounds = EventBounds(after_seq=lower_seq, before_seq=current_seq)
  228. reads = index.main_direct_reads(
  229. scope_id=scope_id,
  230. bounds=read_bounds,
  231. )
  232. inputs.extend(_decision_read(event) for event in reads)
  233. failed_reads = index.main_direct_reads(
  234. scope_id=scope_id, bounds=read_bounds, successful=False
  235. )
  236. else:
  237. failed_reads = []
  238. failed = [
  239. event
  240. for event in index.anchors("begin_round", successful=False)
  241. if _explicit_output_round_hint(event) == round_index
  242. ]
  243. return {
  244. "stage": "round-goal",
  245. "inputs": _dedupe_inputs(inputs),
  246. "decisionItems": [goal] if goal else [],
  247. "sourceDocument": goal,
  248. "explicitReasoning": _labelled_reasoning(goal),
  249. "constraints": _unique(constraints),
  250. "revisionRefs": [ref for event in begin_events if (ref := detail_ref(event))],
  251. "completeness": _completeness(bool(goal), bool(current_anchor)),
  252. "technicalRefs": [
  253. ref
  254. for event in [*failed_reads, *failed]
  255. if (ref := detail_ref(event))
  256. ] + late_decision_refs,
  257. "currentSavedValue": goal,
  258. "roundIndex": round_index,
  259. }
  260. class ImplementationPlanDecisionProjector:
  261. def project(
  262. self,
  263. index: RuntimeEventIndex,
  264. *,
  265. round_row: dict[str, Any],
  266. goal_context: dict[str, Any],
  267. ) -> dict[str, Any]:
  268. round_index = integer(round_row.get("round_index"))
  269. paths = round_row.get("multipath_plan")
  270. paths = paths if isinstance(paths, list) else []
  271. mode = _text(round_row.get("race_or_divide"))
  272. note = _text(round_row.get("plan_note"))
  273. successful: list[dict[str, Any]] = []
  274. failed: list[dict[str, Any]] = []
  275. for event in index.anchors("record_multipath_plan", successful=None):
  276. resolved_round = _round_hint(event)
  277. if resolved_round != round_index:
  278. continue
  279. (successful if _anchor_success(event) else failed).append(event)
  280. current_anchor = successful[-1] if successful else None
  281. begin_events = index.successful_begin_rounds().get(round_index or -1, [])
  282. lower_seq = event_sequence(begin_events[-1]) if begin_events else None
  283. current_seq = event_sequence(current_anchor) if current_anchor else None
  284. scope_id = integer(current_anchor.get("scope_event_id")) if current_anchor else None
  285. inputs: list[dict[str, Any]] = []
  286. goal_items = goal_context.get("decisionItems") or []
  287. if goal_items:
  288. inputs.append(
  289. {
  290. "label": "本轮目标",
  291. "summary": _detail_text("\n".join(str(item) for item in goal_items)),
  292. "actor": "main",
  293. "relation": "standing-constraint",
  294. "detailRef": (goal_context.get("revisionRefs") or [None])[-1],
  295. }
  296. )
  297. if current_anchor is not None:
  298. read_bounds = EventBounds(after_seq=lower_seq, before_seq=current_seq)
  299. inputs.extend(
  300. _decision_read(event)
  301. for event in index.main_direct_reads(
  302. scope_id=scope_id,
  303. bounds=read_bounds,
  304. )
  305. )
  306. failed_reads = index.main_direct_reads(
  307. scope_id=scope_id, bounds=read_bounds, successful=False
  308. )
  309. else:
  310. failed_reads = []
  311. revisions = [_plan_revision(event, paths, mode, note) for event in successful]
  312. display_mode = "单路" if len(paths) == 1 else mode
  313. return {
  314. "stage": "implementation-plan",
  315. "inputs": _dedupe_inputs(inputs),
  316. "decisionItems": [_path_summary(path, index + 1) for index, path in enumerate(paths)],
  317. "explicitReasoning": note,
  318. "constraints": [],
  319. "revisionRefs": [ref for event in successful if (ref := detail_ref(event))],
  320. "completeness": _completeness(bool(paths), bool(current_anchor)),
  321. "revisions": revisions,
  322. "currentRevisionRef": _current_revision_ref(revisions),
  323. "technicalRefs": [
  324. ref
  325. for event in [*failed_reads, *failed]
  326. if (ref := detail_ref(event))
  327. ],
  328. "roundIndex": round_index,
  329. "displayMode": display_mode,
  330. "rawMode": mode,
  331. "pathCount": len(paths),
  332. "routeItems": [_path_item(path, index + 1) for index, path in enumerate(paths)],
  333. }
  334. def parse_script_direction(value: str | None) -> dict[str, Any]:
  335. parsed = parse_objective_text(value)
  336. goals = list(parsed.get("targets") or [])
  337. if not goals and parsed.get("raw"):
  338. goals = [str(parsed.get("headline"))]
  339. constraints = _unique(
  340. [
  341. *(str(item) for item in parsed.get("domainDimensions") or []),
  342. *(str(item) for item in parsed.get("constraints") or []),
  343. ]
  344. )
  345. value_judgements = [
  346. {"label": label, "value": content}
  347. for label, content in (
  348. ("选题价值主张", parsed.get("topicValue")),
  349. ("账号价值主张", parsed.get("accountValue")),
  350. )
  351. if content
  352. ]
  353. reasoning = "\n".join(
  354. f"{item['label']}:{item['value']}" for item in value_judgements
  355. ) or None
  356. return {
  357. "goals": _unique(goals),
  358. "constraints": _unique(constraints),
  359. "valueJudgements": value_judgements,
  360. "valueReasoning": reasoning,
  361. "raw": str(parsed.get("raw") or ""),
  362. }
  363. def parse_goal_items(value: str | None) -> list[str]:
  364. parsed = parse_goal_text(value)
  365. if not parsed.get("raw"):
  366. return []
  367. return [str(parsed["raw"])]
  368. def _decision_read(event: dict[str, Any]) -> dict[str, Any]:
  369. return {
  370. "label": business_tool_label(str(event.get("event_name") or "")),
  371. "summary": _read_result_summary(event),
  372. "actor": "main",
  373. "relation": "direct-read",
  374. "detailRef": detail_ref(event),
  375. }
  376. def _read_result_summary(event: dict[str, Any]) -> str:
  377. name = str(event.get("event_name") or "")
  378. output = event_output(event)
  379. if isinstance(output, dict):
  380. if name == "get_topic_detail":
  381. topic = output.get("topic") if isinstance(output.get("topic"), dict) else {}
  382. points = output.get("points") if isinstance(output.get("points"), list) else []
  383. point_name = next(
  384. (
  385. _text(point.get("point_result"))
  386. for point in points
  387. if isinstance(point, dict) and _text(point.get("point_result"))
  388. ),
  389. None,
  390. )
  391. topic_result = _text(topic.get("result"))
  392. summary = ":".join(value for value in (point_name, topic_result) if value)
  393. if summary:
  394. return _detail_text(summary) or "已读取选题"
  395. if name == "get_account_script_section_patterns":
  396. summary = _detail_text(output.get("分段规律摘要"))
  397. if summary:
  398. return summary
  399. if name == "get_account_script_persona_points":
  400. persona = _persona_summary(output)
  401. if persona:
  402. return persona
  403. for key in ("count", "returned_count", "result_count", "total"):
  404. count = integer(output.get(key))
  405. if count is not None:
  406. return f"返回 {count} 条记录"
  407. for key in ("summary", "message"):
  408. value = _detail_text(output.get(key))
  409. if value:
  410. return value
  411. raw_preview = str(event.get("output_preview") or "")
  412. if name == "get_topic_detail":
  413. topic_result = _json_string_field(raw_preview, "result")
  414. point_name = _json_string_field(raw_preview, "point_result")
  415. summary = ":".join(value for value in (point_name, topic_result) if value)
  416. if summary:
  417. return _detail_text(summary) or "已读取选题"
  418. if name == "get_account_script_section_patterns":
  419. summary = _json_string_field(raw_preview, "分段规律摘要")
  420. if summary:
  421. return _detail_text(summary) or "已读取账号段落模式"
  422. if name == "get_account_script_persona_points":
  423. count_match = re.search(r'"returned_count"\s*:\s*(\d+)', raw_preview)
  424. columns = _json_string_fields(raw_preview, "列")
  425. if count_match:
  426. suffix = f",主要涉及{'、'.join(columns[:3])}" if columns else ""
  427. return f"返回 {count_match.group(1)} 个账号表达特征{suffix}"
  428. return "已读取"
  429. def _persona_summary(output: dict[str, Any]) -> str | None:
  430. count = integer(output.get("returned_count"))
  431. records = output.get("records") if isinstance(output.get("records"), list) else []
  432. columns = _unique(
  433. str(item.get("列"))
  434. for item in records
  435. if isinstance(item, dict) and item.get("列")
  436. )
  437. if count is None:
  438. count = len(records) if records else None
  439. if count is None:
  440. return None
  441. suffix = f",主要涉及{'、'.join(columns[:3])}" if columns else ""
  442. return f"返回 {count} 个账号表达特征{suffix}"
  443. def _json_string_field(value: str, key: str) -> str | None:
  444. values = _json_string_fields(value, key)
  445. return values[0] if values else None
  446. def _json_string_fields(value: str, key: str) -> list[str]:
  447. results: list[str] = []
  448. pattern = re.compile(
  449. rf'"{re.escape(key)}"\s*:\s*"((?:\\.|[^"\\])*)"'
  450. )
  451. for match in pattern.finditer(value):
  452. try:
  453. decoded = json.loads(f'"{match.group(1)}"')
  454. except json.JSONDecodeError:
  455. decoded = match.group(1)
  456. text = _text(decoded)
  457. if text and text not in results:
  458. results.append(text)
  459. return results
  460. def _direction_revision(
  461. event: dict[str, Any], current_direction: str | None
  462. ) -> dict[str, Any]:
  463. payload = event_input(event)
  464. saved = payload.get("script_direction") if isinstance(payload, dict) else None
  465. return {
  466. "eventId": integer(event.get("id")),
  467. "eventSeq": event_sequence(event),
  468. "savedValue": saved,
  469. "current": _normalized_compare(saved) == _normalized_compare(current_direction),
  470. "detailRef": detail_ref(event),
  471. }
  472. def _plan_revision(
  473. event: dict[str, Any], current_paths: list[Any], current_mode: str | None, current_note: str | None
  474. ) -> dict[str, Any]:
  475. payload = event_input(event)
  476. payload = payload if isinstance(payload, dict) else {}
  477. paths = payload.get("paths") if isinstance(payload.get("paths"), list) else []
  478. mode = _normalized_plan_mode(payload.get("mode"))
  479. normalized_current_mode = _normalized_plan_mode(current_mode)
  480. note = _text(payload.get("note"))
  481. current = (
  482. _canonical_json(paths) == _canonical_json(current_paths)
  483. and (mode or normalized_current_mode) == normalized_current_mode
  484. and (note if note is not None else current_note) == current_note
  485. )
  486. return {
  487. "eventId": integer(event.get("id")),
  488. "eventSeq": event_sequence(event),
  489. "paths": paths,
  490. "mode": mode,
  491. "note": note,
  492. "current": current,
  493. "detailRef": detail_ref(event),
  494. }
  495. def _normalized_plan_mode(value: Any) -> str | None:
  496. mode = _text(value)
  497. aliases = {"race": "竞争", "赛马": "竞争", "divide": "分工"}
  498. return aliases.get((mode or "").lower(), mode)
  499. def _path_item(path: Any, fallback_index: int) -> dict[str, Any]:
  500. data = path if isinstance(path, dict) else {}
  501. return {
  502. "pathIndex": integer(data.get("path_index")) or fallback_index,
  503. "pathType": _text(data.get("path_type")),
  504. "action": _text(data.get("action")),
  505. "target": _text(data.get("target")),
  506. "method": _text(data.get("method")),
  507. "emphasis": _text(data.get("emphasis")),
  508. }
  509. def _path_summary(path: Any, fallback_index: int) -> str:
  510. item = _path_item(path, fallback_index)
  511. parts = [part for part in (item["action"], item["target"]) if part]
  512. headline = ":".join(parts) if parts else f"方案 {item['pathIndex']}"
  513. return f"方案 {item['pathIndex']} · {headline}"
  514. def _parsed_report_summary(report: dict[str, Any]) -> str | None:
  515. if report.get("conclusion"):
  516. return _detail_text(report["conclusion"])
  517. if report.get("recommendation"):
  518. return _detail_text(report["recommendation"])
  519. problems = report.get("problems") or []
  520. if problems and isinstance(problems[0], dict):
  521. return _detail_text(problems[0].get("summary"))
  522. return None
  523. def _parsed_report_guidance(report: dict[str, Any]) -> list[str]:
  524. values: list[str] = []
  525. for item in report.get("problems") or []:
  526. if isinstance(item, dict) and (summary := _text(item.get("summary"))):
  527. values.append(f"问题:{summary}")
  528. if next_goal := _text(report.get("nextGoal")):
  529. values.append(f"下一步:{next_goal}")
  530. return _unique(values)
  531. def _record_precedes_event(
  532. record: dict[str, Any], event: dict[str, Any] | None
  533. ) -> bool:
  534. if event is None:
  535. return False
  536. record_time = _date_value(record.get("created_at"))
  537. event_time = _date_value(event.get("started_at") or event.get("ended_at"))
  538. return record_time is not None and event_time is not None and record_time <= event_time
  539. def _date_value(value: Any) -> datetime | None:
  540. if isinstance(value, datetime):
  541. parsed = value
  542. elif isinstance(value, str) and value.strip():
  543. try:
  544. parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
  545. except ValueError:
  546. return None
  547. else:
  548. return None
  549. if parsed.tzinfo is None:
  550. parsed = parsed.replace(tzinfo=timezone.utc)
  551. return parsed.astimezone(timezone.utc)
  552. def _labelled_reasoning(text: str | None) -> str | None:
  553. value = _normalize_markdown(text)
  554. match = re.search(r"(?im)^\s*(?:\u7406\u7531|\u76ee\u6807\u6765\u6e90)\s*[\uff1a:]\s*(.+)$", value)
  555. return _clean_line(match.group(1)) if match else None
  556. def _last_objective_save_seq(index: RuntimeEventIndex) -> int | None:
  557. saves = index.anchors("save_script_direction", successful=True)
  558. return event_sequence(saves[-1]) if saves else None
  559. def _round_hint(event: dict[str, Any]) -> int | None:
  560. output = event_output(event)
  561. if isinstance(output, dict):
  562. value = integer(output.get("round_index"))
  563. if value is not None:
  564. return value
  565. return integer(event.get("round_index"))
  566. def _explicit_output_round_hint(event: dict[str, Any]) -> int | None:
  567. output = event_output(event)
  568. return integer(output.get("round_index")) if isinstance(output, dict) else None
  569. def _anchor_success(event: dict[str, Any]) -> bool:
  570. # Imported lazily to keep the public API above concise and avoid duplicating
  571. # success/error interpretation.
  572. from .runtime_event_index import event_succeeded
  573. return event_succeeded(event)
  574. def _current_revision_ref(revisions: list[dict[str, Any]]) -> str | None:
  575. for revision in reversed(revisions):
  576. if revision.get("current"):
  577. return revision.get("detailRef")
  578. return None
  579. def _normalize_markdown(value: Any) -> str:
  580. text = str(value or "").strip()
  581. if "\\n" in text:
  582. text = text.replace("\\n", "\n")
  583. return text.replace("\r\n", "\n").replace("\r", "\n")
  584. def _clean_line(value: Any) -> str:
  585. text = str(value or "")
  586. text = re.sub(r"^\s*(?:[-*+]\s+|\d+[.、]\s+|[\u2460-\u2473]\s*)", "", text)
  587. text = re.sub(r"[`*#]", "", text)
  588. return re.sub(r"\s+", " ", text).strip(" ::")
  589. def _plain_text(value: Any) -> str:
  590. lines = [_clean_line(line) for line in _normalize_markdown(value).splitlines()]
  591. return " ".join(line for line in lines if line)
  592. def _detail_text(value: Any) -> str:
  593. text = _normalize_markdown(value)
  594. text = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", text)
  595. text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
  596. text = re.sub(r"`([^`]*)`", r"\1", text)
  597. return text.strip()
  598. def _text(value: Any) -> str | None:
  599. text = str(value).strip() if value is not None else ""
  600. return text or None
  601. def _canonical_json(value: Any) -> str:
  602. return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
  603. def _normalized_compare(value: Any) -> str:
  604. return re.sub(r"\s+", "", str(value or ""))
  605. def _completeness(has_fact: bool, has_runtime_anchor: bool) -> str:
  606. if has_fact and has_runtime_anchor:
  607. return "complete"
  608. if has_fact or has_runtime_anchor:
  609. return "partial"
  610. return "missing"
  611. def _unique(values: Iterable[str]) -> list[str]:
  612. result: list[str] = []
  613. for value in values:
  614. if value and value not in result:
  615. result.append(value)
  616. return result
  617. def _dedupe_inputs(values: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
  618. result: list[dict[str, Any]] = []
  619. seen: set[tuple[Any, ...]] = set()
  620. for value in values:
  621. key = (value.get("label"), value.get("detailRef"), value.get("relation"))
  622. if key in seen:
  623. continue
  624. seen.add(key)
  625. result.append(value)
  626. return result