comparison.py 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052
  1. from __future__ import annotations
  2. import re
  3. from copy import deepcopy
  4. from typing import Any
  5. from .resolver import SourceResolver, pointer_lookup
  6. from .schema import SourceBinding, unresolved_binding
  7. # These mappings are keyed by the stable business projection shape, never by
  8. # fuzzy title matching. Values point at the richer source modules built by the
  9. # card-specific projectors.
  10. _MODULE_MAP: dict[str, dict[str, list[str]]] = {
  11. "objective": {
  12. "block:direction": ["direction"],
  13. "block:inputs": ["inputs"],
  14. },
  15. "round-goal": {
  16. "block:goal": ["goal", "why"],
  17. "block:dependencies": ["dependencies"],
  18. },
  19. "implementation-plan": {
  20. "block:plan": ["mode", "routes", "revisions"],
  21. "block:basis": ["basis", "revisions"],
  22. },
  23. "implementation-task": {
  24. "section:scope": ["scope"],
  25. "section:method": ["method"],
  26. "section:goal": ["goal"],
  27. "section:constraints": ["constraints"],
  28. "section:avoid": ["avoid"],
  29. "section:deliverable": ["deliverable"],
  30. "section:action": ["action"],
  31. "section:request": ["request"],
  32. "section:result": ["result"],
  33. },
  34. "creative": {
  35. "block:basis": ["basis"],
  36. "block:process": ["process"],
  37. "block:available": ["available"],
  38. "block:changes": ["changes"],
  39. "block:candidate": ["candidate"],
  40. "block:uncertainty": ["uncertainty"],
  41. "block:record-note": ["record-note"],
  42. },
  43. "data-decision": {
  44. "block:0": ["sources"],
  45. "block:1": ["decision", "tradeoff"],
  46. "block:2": ["reason"],
  47. },
  48. "multipath-decision": {
  49. "block:0": ["strength"],
  50. "block:1": ["scope"],
  51. "block:2": ["comparison"],
  52. "block:3": ["comparison"],
  53. "block:4": ["reason"],
  54. "block:5": ["strength"],
  55. },
  56. "domain-facts": {
  57. "section:output": ["facts", "verification", "sources", "usage"],
  58. "section:assessment": [],
  59. },
  60. "candidate-output-fallback": {
  61. "section:output": ["output"],
  62. "section:assessment": ["assessment"],
  63. },
  64. "round-result": {
  65. "section:goal": ["completion"],
  66. "section:outcomes": ["dispositions"],
  67. "section:facts": ["domain"],
  68. "section:evaluation": ["review"],
  69. "section:changes": ["changes"],
  70. "section:next": ["next"],
  71. },
  72. "final-result": {
  73. "section:conclusion": ["conclusion"],
  74. "section:summary": ["summary"],
  75. "section:statistics": ["stats"],
  76. "section:artifact": ["scale", "artifact"],
  77. "section:version": ["version"],
  78. },
  79. "retrieval-agent": {
  80. "section:task": ["task"],
  81. "section:queries": ["queries"],
  82. "section:hits": ["raw"],
  83. "section:screening": ["screening"],
  84. "special:activities": ["activities"],
  85. },
  86. "retrieval-direct-group": {
  87. "section:purpose": ["purpose"],
  88. "section:sources": ["calls"],
  89. "section:result": ["results", "failures"],
  90. "special:activities": ["calls", "results", "failures"],
  91. },
  92. "retrieval-event": {
  93. "special:conditions": ["purpose"],
  94. "special:outcome": ["status"],
  95. "special:results": ["result", "results"],
  96. "special:completeness": ["status"],
  97. },
  98. }
  99. def business_only_projection(detail: dict[str, Any]) -> dict[str, Any]:
  100. """Keep exactly what the readable Inspector uses, without duplicating raw JSON."""
  101. hidden = {"technical", "changes"}
  102. return deepcopy({key: value for key, value in detail.items() if key not in hidden})
  103. def align_business_projection(
  104. *,
  105. detail_ref: str,
  106. card_kind: str,
  107. business: dict[str, Any],
  108. source_modules: list[dict[str, Any]],
  109. resolver: SourceResolver,
  110. bundle: dict[str, Any],
  111. ) -> list[dict[str, Any]]:
  112. """Turn the readable Inspector projection into row-aligned source modules."""
  113. projection = business_only_projection(business)
  114. normalized = _normalize_business(projection, card_kind=card_kind)
  115. source_by_id = {str(module.get("id")): module for module in source_modules}
  116. aligned: list[dict[str, Any]] = []
  117. for module_index, module in enumerate(normalized, 1):
  118. semantic_key = str(module.pop("_semanticKey"))
  119. source_ids = _source_module_ids(card_kind, semantic_key, module_index)
  120. candidates = [source_by_id[source_id] for source_id in source_ids if source_id in source_by_id]
  121. if not candidates and card_kind == "implementation-task" and "historical-task" in source_by_id:
  122. candidates = [source_by_id["historical-task"]]
  123. rows: list[dict[str, Any]] = []
  124. module_bindings: list[SourceBinding] = []
  125. for row_index, row in enumerate(module.get("rows") or [], 1):
  126. found, business_value = pointer_lookup(
  127. projection, str(row.get("businessSelector") or "")
  128. )
  129. if card_kind == "domain-facts" and semantic_key == "section:assessment":
  130. branch_id = _branch_id(detail_ref)
  131. branch = next((item for item in bundle.get("branches") or [] if _integer(item.get("branch_id")) == branch_id), None)
  132. bindings = [resolver.binding_for_field({
  133. "id": f"branch:{branch_id}:assessment",
  134. "label": "实现者说明",
  135. "value": (branch or {}).get("self_assessment"),
  136. "source": {
  137. "kind": "database",
  138. "label": "script_build_branch",
  139. "ref": detail_ref,
  140. "fieldPath": "self_assessment",
  141. },
  142. "relation": "persisted-output",
  143. "completeness": "complete" if branch and branch.get("self_assessment") else "partial",
  144. }, binding_id=f"{row['id']}:source:1", role="output")]
  145. elif semantic_key in {"special:question", "block:record-note"}:
  146. bindings = [resolver.binding_for_field({
  147. "id": f"{module['id']}:display-text",
  148. "label": "业务详情确定性文案",
  149. "value": business_value if found else None,
  150. "source": {
  151. "kind": "calculation",
  152. "label": "业务详情确定性说明",
  153. "ref": f"calculation:{module['id']}:display-text",
  154. "fieldPath": "value",
  155. },
  156. "relation": "persisted-output",
  157. "completeness": "complete",
  158. }, binding_id=f"{row['id']}:source:1", role="status")]
  159. elif card_kind == "retrieval-event" and semantic_key in {
  160. "special:outcome",
  161. "special:completeness",
  162. }:
  163. event_id = _event_id(detail_ref)
  164. output_key = str(row.get("id") or "").rsplit(":", 1)[-1]
  165. path = "output.content" if semantic_key == "special:outcome" else ""
  166. binding = resolver.binding_for_event(
  167. event_id or 0,
  168. binding_id=f"{row['id']}:source:1",
  169. path=path,
  170. role="output" if semantic_key == "special:outcome" else "status",
  171. availability=(
  172. "returned-to-agent"
  173. if semantic_key == "special:outcome"
  174. else "produced-by-run"
  175. ),
  176. )
  177. binding["transform"] = {
  178. "kind": "parsed",
  179. "operation": (
  180. "retrieval-outcome-v1"
  181. if semantic_key == "special:outcome"
  182. else "event-body-completeness-v1"
  183. ),
  184. "outputKey": output_key,
  185. }
  186. binding["evidence"]["confidence"] = "deterministic"
  187. bindings = [binding]
  188. elif semantic_key == "special:activities":
  189. ref = ""
  190. if found and isinstance(business_value, dict):
  191. ref = str(
  192. business_value.get("detailRef")
  193. or business_value.get("id")
  194. or ""
  195. )
  196. event_id = _event_id(ref)
  197. if event_id is not None:
  198. binding = resolver.binding_for_event(
  199. event_id,
  200. binding_id=f"{row['id']}:source:1",
  201. role="process",
  202. availability="produced-by-run",
  203. )
  204. binding["transform"] = {
  205. "kind": "parsed",
  206. "operation": "activity-summary-v1",
  207. "outputKey": str(row.get("id") or "activity"),
  208. }
  209. binding["evidence"]["confidence"] = "deterministic"
  210. bindings = [binding]
  211. elif found and (
  212. business_value == []
  213. or str(row.get("businessSelector") or "")
  214. == "/missingActivityNotice"
  215. ):
  216. empty_binding = resolver.binding_for_field(
  217. {
  218. "id": f"{row['id']}:empty",
  219. "label": "可见查询运行记录数量",
  220. "value": business_value,
  221. "source": {
  222. "kind": "calculation",
  223. "label": "取数 Agent 结构化查询记录确定性检查",
  224. "ref": f"calculation:{row['id']}:empty",
  225. "fieldPath": "value",
  226. },
  227. "relation": "persisted-output",
  228. "completeness": "complete",
  229. },
  230. binding_id=f"{row['id']}:source:1",
  231. role="status",
  232. )
  233. empty_source = resolver.sources.get(empty_binding["sourceId"])
  234. if empty_source is not None:
  235. empty_source["resultState"] = "empty"
  236. empty_source.setdefault("locator", {})["resultCount"] = 0
  237. bindings = [empty_binding]
  238. else:
  239. bindings = []
  240. else:
  241. bindings = _bindings_for_row(
  242. module_id=str(module["id"]),
  243. row=row,
  244. row_index=row_index,
  245. candidates=candidates,
  246. resolver=resolver,
  247. business_value=business_value if found else None,
  248. narrow_event_text=card_kind == "implementation-task",
  249. )
  250. if card_kind == "implementation-plan" and semantic_key == "block:plan":
  251. revision = _implementation_plan_revision_binding(
  252. row=row,
  253. business_value=business_value if found else None,
  254. candidates=candidates,
  255. resolver=resolver,
  256. )
  257. if revision is not None:
  258. bindings.append(revision)
  259. if not bindings:
  260. binding = unresolved_binding(
  261. f"{module['id']}:row:{row_index}:unresolved",
  262. reason=f"业务详情「{module['title']}」的这一项没有保存可安全定位的原始记录",
  263. code="binding-map-missing",
  264. )
  265. resolver.add_unresolved_source(binding)
  266. bindings = [binding]
  267. _mark_row_transform(
  268. bindings,
  269. business_value if found else None,
  270. resolver,
  271. str(module["id"]),
  272. )
  273. row = {key: value for key, value in row.items() if not key.startswith("_")}
  274. row["bindingIds"] = [binding["id"] for binding in bindings]
  275. rows.append(row)
  276. module_bindings.extend(bindings)
  277. module["rows"] = rows
  278. module["bindings"] = _unique_bindings(module_bindings)
  279. module["runtimeRefs"] = list(dict.fromkeys(
  280. binding["sourceId"]
  281. for binding in module["bindings"]
  282. if binding["sourceId"].startswith("event:")
  283. ))
  284. aligned.append(module)
  285. return aligned
  286. def _source_module_ids(card_kind: str, semantic_key: str, index: int) -> list[str]:
  287. explicit = _MODULE_MAP.get(card_kind, {}).get(semantic_key)
  288. if explicit is not None:
  289. return explicit
  290. if semantic_key.startswith("section:"):
  291. return [semantic_key.split(":", 1)[1]]
  292. if semantic_key.startswith("block:"):
  293. try:
  294. return [f"block-{int(semantic_key.split(':', 1)[1]) + 1}"]
  295. except ValueError:
  296. return [f"block-{index}"]
  297. return [semantic_key.split(":", 1)[-1]]
  298. def _normalize_business(
  299. projection: dict[str, Any], *, card_kind: str = ""
  300. ) -> list[dict[str, Any]]:
  301. modules: list[dict[str, Any]] = []
  302. question = projection.get("question")
  303. if question not in (None, ""):
  304. modules.append(_module(
  305. "question",
  306. "这一步要回答什么",
  307. "/question",
  308. "special:question",
  309. [_row("question:value", None, "/question")],
  310. default_open=True,
  311. ))
  312. for index, section in enumerate(projection.get("businessSections") or []):
  313. if not isinstance(section, dict):
  314. continue
  315. section_id = str(section.get("id") or f"section-{index + 1}")
  316. path = f"/businessSections/{index}"
  317. rows = []
  318. if isinstance(section.get("items"), list):
  319. for item_index, item in enumerate(section.get("items") or []):
  320. label = str((item or {}).get("label") or f"第 {item_index + 1} 项") if isinstance(item, dict) else f"第 {item_index + 1} 项"
  321. item_path = f"{path}/items/{item_index}"
  322. rows.append(_row(f"{section_id}:item:{item_index + 1}", label, item_path, item_index=item_index))
  323. elif section.get("content") is not None:
  324. rows.append(_row(f"{section_id}:content", None, f"{path}/content"))
  325. semantic_id = _section_semantic_id(section_id)
  326. modules.append(_module(
  327. section_id,
  328. str(section.get("title") or f"业务模块 {index + 1}"),
  329. path,
  330. f"section:{semantic_id}",
  331. rows,
  332. presentation=str(section.get("presentation") or "text"),
  333. default_open=not bool(section.get("collapsible")) or bool(section.get("defaultOpen")),
  334. ))
  335. for index, block in enumerate(projection.get("blocks") or []):
  336. if not isinstance(block, dict):
  337. continue
  338. path = f"/blocks/{index}"
  339. semantic_id = _block_semantic_id(card_kind, block, index)
  340. module_id = semantic_id or f"block-{index + 1}"
  341. modules.append(_module(
  342. module_id,
  343. str(block.get("title") or "记录说明"),
  344. path,
  345. f"block:{semantic_id}" if semantic_id else f"block:{index}",
  346. _block_rows(module_id, path, block),
  347. presentation=str(block.get("presentation") or block.get("type") or "text"),
  348. default_open=not bool(block.get("collapsible")) or bool(block.get("defaultOpen")),
  349. ))
  350. conditions = projection.get("queryConditions")
  351. if projection.get("detailKind") == "retrieval-event" and isinstance(conditions, list):
  352. rows = [
  353. _row(
  354. f"conditions:item:{index + 1}",
  355. str(item.get("label") or item.get("key") or f"条件 {index + 1}"),
  356. f"/queryConditions/{index}/value",
  357. item_index=index,
  358. )
  359. for index, item in enumerate(conditions)
  360. if isinstance(item, dict)
  361. ]
  362. if not rows:
  363. rows = [_row("conditions:empty", None, "/queryConditions")]
  364. modules.append(_module("conditions", "查询条件", "/queryConditions", "special:conditions", rows))
  365. outcome = projection.get("outcome")
  366. if isinstance(outcome, dict):
  367. rows = []
  368. for key, label in (("message", "执行结果"), ("count", "返回数量"), ("errorKind", "失败类型")):
  369. if outcome.get(key) is not None:
  370. rows.append(_row(f"outcome:{key}", label, f"/outcome/{key}"))
  371. modules.append(_module("outcome", "查询结果", "/outcome", "special:outcome", rows))
  372. result_items = projection.get("items")
  373. if isinstance(result_items, list) and result_items:
  374. rows = [
  375. _row(
  376. f"results:item:{index + 1}",
  377. str((item or {}).get("title") or f"结果 {index + 1}") if isinstance(item, dict) else f"结果 {index + 1}",
  378. f"/items/{index}",
  379. item_index=index,
  380. )
  381. for index, item in enumerate(result_items)
  382. ]
  383. modules.append(_module("results", "返回结果", "/items", "special:results", rows))
  384. activities = projection.get("activities")
  385. if isinstance(activities, list):
  386. rows = [
  387. _row(
  388. f"activities:item:{index + 1}",
  389. str((item or {}).get("label") or f"运行记录 {index + 1}") if isinstance(item, dict) else f"运行记录 {index + 1}",
  390. f"/activities/{index}",
  391. item_index=index,
  392. )
  393. for index, item in enumerate(activities)
  394. ]
  395. if not rows:
  396. notice_path = "/missingActivityNotice" if projection.get("missingActivityNotice") else "/activities"
  397. rows = [_row("activities:empty", None, notice_path)]
  398. modules.append(_module("activities", "运行记录", "/activities", "special:activities", rows, presentation="process"))
  399. completeness = projection.get("completeness")
  400. if projection.get("detailKind") == "retrieval-event" and isinstance(completeness, dict):
  401. rows = []
  402. if completeness.get("bodyAvailable") is False:
  403. rows.append(_row("completeness:body", "完整返回", "/completeness/bodyAvailable"))
  404. if completeness.get("truncated"):
  405. rows.append(_row(
  406. "completeness:truncated",
  407. "截断说明",
  408. "/completeness/omittedCharacters" if completeness.get("omittedCharacters") is not None else "/completeness/truncated",
  409. ))
  410. if rows:
  411. modules.append(_module("completeness", "记录完整性", "/completeness", "special:completeness", rows, presentation="notice"))
  412. return modules
  413. def _block_rows(module_id: str, path: str, block: dict[str, Any]) -> list[dict[str, Any]]:
  414. block_type = str(block.get("type") or "summary")
  415. if block_type in {"summary", "notice"}:
  416. return [_row(f"{module_id}:value", None, f"{path}/value")]
  417. if block_type == "items":
  418. return [
  419. _row(
  420. f"{module_id}:item:{index + 1}",
  421. str(
  422. item.get("label")
  423. or item.get("title")
  424. or item.get("subject")
  425. or item.get("summary")
  426. or f"第 {index + 1} 项"
  427. ) if isinstance(item, dict) else f"第 {index + 1} 项",
  428. f"{path}/items/{index}",
  429. item_index=index,
  430. )
  431. for index, item in enumerate(block.get("items") or [])
  432. ]
  433. if block_type == "implementation-plan":
  434. rows = []
  435. if block.get("mode") is not None:
  436. rows.append(_row(f"{module_id}:mode", "规划方式", f"{path}/mode", binding_slot="mode:0"))
  437. for route_index, route in enumerate(block.get("routes") or []):
  438. if not isinstance(route, dict):
  439. continue
  440. route_number = route.get("pathIndex") or route_index + 1
  441. for key, label in (("target", "作用范围"), ("method", "实施方法"), ("action", "具体动作"), ("emphasis", "路线侧重"), ("pathType", "路线类型")):
  442. if route.get(key) is None:
  443. continue
  444. source_key = {"pathIndex": "path_index", "pathType": "path_type"}.get(key, key)
  445. rows.append(_row(
  446. f"{module_id}:route:{route_index + 1}:{key}",
  447. f"路线 {route_number} · {label}",
  448. f"{path}/routes/{route_index}/{key}",
  449. binding_slot="routes:0",
  450. source_suffix=f"/{route_index}/{source_key}",
  451. ))
  452. if block.get("reasoning") is not None:
  453. rows.append(_row(f"{module_id}:reasoning", "为什么这样规划", f"{path}/reasoning", binding_slot="mode:1"))
  454. return rows
  455. if block_type == "creative-process":
  456. rows = []
  457. for step_index, step in enumerate(block.get("steps") or []):
  458. if not isinstance(step, dict):
  459. continue
  460. number = step.get("stepIndex") or step_index + 1
  461. for key, label in (("action", "动作"), ("summary", "结果"), ("plan", "执行计划"), ("reasoning", "思考依据")):
  462. if step.get(key) is not None:
  463. row = _row(f"{module_id}:step:{step_index + 1}:{key}", f"步骤 {number} · {label}", f"{path}/steps/{step_index}/{key}", item_index=step_index)
  464. row["_fieldKey"] = key
  465. rows.append(row)
  466. return rows
  467. if block_type == "evaluation-branches":
  468. rows = []
  469. for branch_index, branch in enumerate(block.get("branches") or []):
  470. if not isinstance(branch, dict):
  471. continue
  472. subject = str(branch.get("subject") or f"方案 {branch_index + 1}")
  473. for key, label in (("conclusion", "结论"), ("achievements", "已达成"), ("problems", "需关注"), ("evidence", "依据")):
  474. if branch.get(key) not in (None, [], ""):
  475. rows.append(_row(f"{module_id}:branch:{branch_index + 1}:{key}", f"{subject} · {label}", f"{path}/branches/{branch_index}/{key}", item_index=branch_index))
  476. return rows
  477. if block_type == "evaluation-matrix":
  478. return [
  479. _row(f"{module_id}:row:{index + 1}", str((row or {}).get("criterion") or f"标准 {index + 1}"), f"{path}/rows/{index}", item_index=index)
  480. for index, row in enumerate(block.get("rows") or [])
  481. ]
  482. if block_type == "comparison":
  483. return [
  484. _row(f"{module_id}:row:{index + 1}", str((row or {}).get("candidate") or f"方案 {index + 1}"), f"{path}/rows/{index}", item_index=index)
  485. for index, row in enumerate(block.get("rows") or [])
  486. ]
  487. if block_type == "artifact":
  488. return [
  489. _row(f"{module_id}:artifact:{index + 1}", str((ref or {}).get("label") or f"产物 {index + 1}"), f"{path}/refs/{index}", item_index=index)
  490. for index, ref in enumerate(block.get("refs") or [])
  491. ]
  492. return [_row(f"{module_id}:record", None, path)]
  493. def _bindings_for_row(
  494. *,
  495. module_id: str,
  496. row: dict[str, Any],
  497. row_index: int,
  498. candidates: list[dict[str, Any]],
  499. resolver: SourceResolver,
  500. business_value: Any,
  501. narrow_event_text: bool = False,
  502. ) -> list[SourceBinding]:
  503. slot = str(row.get("_bindingSlot") or "")
  504. bindings: list[SourceBinding] = []
  505. if slot:
  506. source_id, _, raw_index = slot.partition(":")
  507. source_module = next((module for module in candidates if str(module.get("id")) == source_id), None)
  508. source_bindings = list((source_module or {}).get("bindings") or [])
  509. try:
  510. binding = source_bindings[int(raw_index or 0)]
  511. except (IndexError, ValueError):
  512. binding = None
  513. if binding is not None:
  514. cloned = _clone_binding(
  515. binding,
  516. binding_id=f"{row['id']}:source:1",
  517. source_suffix=str(row.get("_sourceSuffix") or ""),
  518. resolver=resolver,
  519. )
  520. bindings = [
  521. _narrow_event_text_binding(cloned, business_value, resolver, module_id)
  522. if narrow_event_text
  523. else cloned
  524. ]
  525. if not bindings:
  526. item_index = row.get("_itemIndex")
  527. for source_module in candidates:
  528. items = ((source_module.get("business") or {}).get("items") or [])
  529. source_bindings = list(source_module.get("bindings") or [])
  530. if isinstance(item_index, int) and item_index < len(items):
  531. item = items[item_index] or {}
  532. item_bindings = list(item.get("bindings") or [])
  533. field_key = str(row.get("_fieldKey") or "")
  534. if field_key and item_bindings:
  535. event_id = _event_id(item_bindings[0].get("sourceId"))
  536. event_paths = {
  537. "action": "input.content.action",
  538. "summary": "input.content.thought_summary",
  539. "plan": "input.content.plan",
  540. "reasoning": "input.content.thought",
  541. }
  542. if event_id and field_key in event_paths:
  543. binding = resolver.binding_for_event(
  544. event_id,
  545. binding_id=f"{row['id']}:source:1",
  546. path=event_paths[field_key],
  547. role="process",
  548. )
  549. found, selected = pointer_lookup(
  550. (resolver.sources.get(binding["sourceId"]) or {}).get("rawRecord"),
  551. str((binding.get("selector") or {}).get("path") or ""),
  552. )
  553. if not found or selected != business_value:
  554. binding["transform"] = {
  555. "kind": "parsed",
  556. "operation": "creative-step-business-label-v1",
  557. "outputKey": field_key,
  558. }
  559. binding["evidence"]["confidence"] = "deterministic"
  560. return [binding]
  561. matched = _bindings_matching_value(
  562. item.get("value"), item_bindings, business_value
  563. )
  564. bindings.extend(matched or item_bindings)
  565. elif (
  566. isinstance(item_index, int)
  567. and isinstance(((source_module.get("business") or {}).get("value")), list)
  568. and item_index < len(source_bindings)
  569. ):
  570. # The readable Inspector may split one source list into one row
  571. # per item while CardData keeps it as one aggregate module.
  572. # The persisted source order is explicit, so bind the same
  573. # index instead of repeating every source on every row.
  574. bindings.append(source_bindings[item_index])
  575. elif isinstance(item_index, int) and item_index < len(items):
  576. bindings.extend((items[item_index] or {}).get("bindings") or [])
  577. if not bindings:
  578. bindings = [
  579. binding
  580. for source_module in candidates
  581. for binding in source_module.get("bindings") or []
  582. ]
  583. physical_bindings = _unique_physical_bindings(bindings)
  584. usable_bindings = [
  585. binding
  586. for binding in physical_bindings
  587. if binding.get("resolution") != "missing"
  588. ]
  589. if usable_bindings:
  590. physical_bindings = usable_bindings
  591. bindings = [
  592. _narrow_event_text_binding(cloned, business_value, resolver, module_id)
  593. if narrow_event_text
  594. else cloned
  595. for index, binding in enumerate(physical_bindings, 1)
  596. for cloned in [
  597. _clone_binding(
  598. binding,
  599. binding_id=f"{row['id']}:source:{index}",
  600. source_suffix="",
  601. resolver=resolver,
  602. )
  603. ]
  604. ]
  605. return bindings
  606. def _mark_row_transform(
  607. bindings: list[SourceBinding],
  608. business_value: Any,
  609. resolver: SourceResolver,
  610. module_id: str,
  611. ) -> None:
  612. """Never call a normalized/combined business row a direct projection."""
  613. mismatched: list[SourceBinding] = []
  614. for binding in bindings:
  615. if (binding.get("transform") or {}).get("kind") != "direct":
  616. continue
  617. source = resolver.sources.get(str(binding.get("sourceId") or "")) or {}
  618. selector = binding.get("selector") or {}
  619. selected: Any = None
  620. found = False
  621. if selector.get("kind") == "json-pointer":
  622. found, selected = pointer_lookup(
  623. source.get("rawRecord"), str(selector.get("path") or "")
  624. )
  625. elif selector.get("kind") == "whole-record":
  626. found, selected = True, source.get("rawRecord")
  627. elif selector.get("kind") == "text-span":
  628. selected = selector.get("exactText")
  629. found = True
  630. if found and selected != business_value:
  631. mismatched.append(binding)
  632. for binding in mismatched:
  633. binding["transform"] = (
  634. {
  635. "kind": "combined",
  636. "operation": f"business-row:{module_id}",
  637. }
  638. if len(bindings) > 1
  639. else {
  640. "kind": "parsed",
  641. "operation": "business-row-projection-v1",
  642. "outputKey": module_id,
  643. }
  644. )
  645. binding["evidence"]["confidence"] = "deterministic"
  646. def _section_semantic_id(section_id: str) -> str:
  647. """Map task section instance ids to the stable business module ids."""
  648. if not section_id.startswith("task-"):
  649. return section_id
  650. match = re.match(r"^task-([a-z-]+)-\d+$", section_id)
  651. kind = match.group(1) if match else "notes"
  652. return {
  653. "scope": "scope",
  654. "method": "method",
  655. "goal": "goal",
  656. "materials": "constraints",
  657. "context": "constraints",
  658. "constraints": "constraints",
  659. "avoid": "avoid",
  660. "notes": "deliverable",
  661. }.get(kind, "deliverable")
  662. def _block_semantic_id(
  663. card_kind: str, block: dict[str, Any], index: int
  664. ) -> str | None:
  665. """Return a stable semantic id for optional/reordered decision blocks."""
  666. explicit = str(block.get("semanticId") or "").strip()
  667. if explicit:
  668. return explicit
  669. block_type = str(block.get("type") or "").strip()
  670. title = str(block.get("title") or "").strip()
  671. by_title = ({
  672. "当前保存的创作方向": "direction",
  673. "最终方向": "direction",
  674. "根据以下信息做出的决策": "inputs",
  675. } if card_kind == "objective" else {
  676. "明确依据": "basis",
  677. "明确采用的数据取舍": "basis",
  678. "创作处理": "process",
  679. "可确认的上游信息": "available",
  680. "实际脚本改动": "changes",
  681. "实际产出": "candidate",
  682. "产出的候选表": "candidate",
  683. "存疑项": "uncertainty",
  684. "记录说明": "record-note",
  685. } if card_kind == "creative" else {
  686. "本轮目标构成": "goal",
  687. "根据以下信息做出的决策": "dependencies",
  688. } if card_kind == "round-goal" else {
  689. "本轮实施路线": "plan",
  690. "根据以下信息做出的决策": "basis",
  691. } if card_kind == "implementation-plan" else {})
  692. if title in by_title:
  693. return by_title[title]
  694. if card_kind == "creative" and block_type == "creative-process":
  695. return "process"
  696. if card_kind == "creative" and block_type == "notice":
  697. return "record-note"
  698. return None
  699. def _narrow_event_text_binding(
  700. binding: SourceBinding,
  701. business_value: Any,
  702. resolver: SourceResolver,
  703. module_id: str = "",
  704. ) -> SourceBinding:
  705. """Narrow a complete implementation task binding to its exact visible section."""
  706. if not isinstance(business_value, str) or not business_value.strip():
  707. return binding
  708. event_id = _event_id(binding.get("sourceId"))
  709. selector = binding.get("selector") or {}
  710. if event_id is None or selector.get("kind") != "json-pointer":
  711. return binding
  712. path = str(selector.get("path") or "")
  713. source = resolver.sources.get(str(binding.get("sourceId") or "")) or {}
  714. found, raw_text = pointer_lookup(source.get("rawRecord"), path)
  715. if not found or not isinstance(raw_text, str):
  716. return binding
  717. exact_text = business_value if business_value in raw_text else _task_source_section(raw_text, module_id)
  718. if not exact_text:
  719. return binding
  720. narrowed = resolver.text_span_binding(
  721. event_id,
  722. binding_id=str(binding.get("id") or "implementation-task:span"),
  723. path=path.lstrip("/").replace("/", "."),
  724. exact_text=exact_text,
  725. role=str(binding.get("role") or "input"),
  726. )
  727. if exact_text != business_value:
  728. narrowed["transform"] = {
  729. "kind": "parsed",
  730. "operation": "implementation-task-section-normalization-v1",
  731. "outputKey": module_id,
  732. }
  733. narrowed["evidence"]["confidence"] = "deterministic"
  734. return narrowed
  735. def _task_source_section(task: str, module_id: str) -> str | None:
  736. """Return the exact raw task section behind a normalized business block."""
  737. module_id = _section_semantic_id(module_id)
  738. line_keys = {
  739. "scope": ("target", "实现范围"),
  740. "method": ("method", "实施方法"),
  741. }
  742. if module_id in line_keys:
  743. keys = "|".join(re.escape(key) for key in line_keys[module_id])
  744. match = re.search(rf"(?im)^(?:{keys})\s*[::]\s*(.+)$", task)
  745. return match.group(1).strip() if match else None
  746. headings = {
  747. "goal": (r"目标",),
  748. "constraints": (
  749. r"取证方向与约束",
  750. r"参考依据与约束",
  751. r"严格约束",
  752. r"约束(?:([^)]*))?",
  753. ),
  754. "avoid": (r"禁止事项", r"需要避免"),
  755. "deliverable": (r"预期交付", r"交付"),
  756. }
  757. constraint_pattern = "|".join(headings["constraints"])
  758. if module_id == "deliverable" and not re.search(
  759. rf"(?im)^(?:{'|'.join(headings['deliverable'])})\s*[::]", task
  760. ):
  761. # Unlabelled task notes are the exact prefix before a later explicit
  762. # constraint section. The readable Inspector may normalize terms
  763. # inside it, so retain the raw prefix as a parsed text span.
  764. match = re.search(
  765. rf"(?ims)^(.*?)(?=\n\s*\n(?:{constraint_pattern})\s*[::]|\Z)",
  766. task,
  767. )
  768. return match.group(1).strip() if match else task.strip()
  769. names = headings.get(module_id)
  770. if not names:
  771. return None
  772. all_names = [name for values in headings.values() for name in values]
  773. start_pattern = "|".join(names)
  774. next_pattern = "|".join(all_names)
  775. match = re.search(
  776. rf"(?ims)^(?:{start_pattern})\s*[::]\s*(.*?)(?=\n\s*\n(?:{next_pattern})\s*[::]|\Z)",
  777. task,
  778. )
  779. return match.group(1).strip() if match else None
  780. def _implementation_plan_revision_binding(
  781. *,
  782. row: dict[str, Any],
  783. business_value: Any,
  784. candidates: list[dict[str, Any]],
  785. resolver: SourceResolver,
  786. ) -> SourceBinding | None:
  787. """Bind the displayed final plan row to the revision that formed it.
  788. A round may call ``record_multipath_plan`` several times. The database
  789. value is authoritative, while the latest revision containing the exact
  790. displayed value is the formation record. This prevents round 4 from
  791. stopping at Events 4301/4369 and omitting the final Event 4434.
  792. """
  793. revision = next(
  794. (module for module in candidates if str(module.get("id")) == "revisions"),
  795. None,
  796. )
  797. event_ids = sorted(
  798. {
  799. event_id
  800. for binding in (revision or {}).get("bindings") or []
  801. for event_id in [_event_id(binding.get("sourceId"))]
  802. if event_id is not None
  803. },
  804. reverse=True,
  805. )
  806. row_id = str(row.get("id") or "")
  807. path = ""
  808. if row_id.endswith(":mode"):
  809. path = "input.content.mode"
  810. elif row_id.endswith(":reasoning"):
  811. path = "input.content.note"
  812. else:
  813. match = re.search(r":route:(\d+):([A-Za-z]+)$", row_id)
  814. if match:
  815. route_index = int(match.group(1)) - 1
  816. key = {"pathType": "path_type"}.get(match.group(2), match.group(2))
  817. path = f"input.content.paths.{route_index}.{key}"
  818. if not path:
  819. return None
  820. fallback_event: int | None = None
  821. for event_id in event_ids:
  822. event = resolver.event_details.get(event_id) or {}
  823. found, selected = pointer_lookup(event, "/" + path.replace(".", "/"))
  824. if not found:
  825. continue
  826. fallback_event = fallback_event or event_id
  827. if selected != business_value:
  828. continue
  829. return resolver.binding_for_event(
  830. event_id,
  831. binding_id=f"{row_id}:revision",
  832. path=path,
  833. role="process",
  834. availability="produced-by-run",
  835. )
  836. # The readable mode can normalize a one-route saved plan to "单路".
  837. # Retain the latest concrete revision but mark that normalization openly.
  838. if row_id.endswith(":mode") and fallback_event:
  839. binding = resolver.binding_for_event(
  840. fallback_event,
  841. binding_id=f"{row_id}:revision",
  842. path=path,
  843. role="process",
  844. availability="produced-by-run",
  845. )
  846. binding["transform"] = {
  847. "kind": "parsed",
  848. "operation": "single-route-mode-normalization-v1",
  849. "outputKey": "mode",
  850. }
  851. binding["evidence"]["confidence"] = "deterministic"
  852. return binding
  853. return None
  854. def _bindings_matching_value(
  855. source_value: Any,
  856. bindings: list[SourceBinding],
  857. business_value: Any,
  858. ) -> list[SourceBinding]:
  859. source_leaves = _leaf_texts(source_value)
  860. wanted = set(_leaf_texts(business_value))
  861. if not wanted or len(source_leaves) != len(bindings):
  862. return []
  863. return [
  864. binding
  865. for leaf, binding in zip(source_leaves, bindings)
  866. if leaf in wanted
  867. ]
  868. def _leaf_texts(value: Any) -> list[str]:
  869. if isinstance(value, str):
  870. return [value.strip()] if value.strip() else []
  871. if isinstance(value, dict):
  872. result: list[str] = []
  873. for child in value.values():
  874. result.extend(_leaf_texts(child))
  875. return result
  876. if isinstance(value, list):
  877. result = []
  878. for child in value:
  879. result.extend(_leaf_texts(child))
  880. return result
  881. return [str(value)] if value is not None else []
  882. def _event_id(source_id: Any) -> int | None:
  883. value = str(source_id or "")
  884. if not value.startswith("event:"):
  885. return None
  886. try:
  887. return int(value.split(":", 1)[1])
  888. except ValueError:
  889. return None
  890. def _clone_binding(
  891. binding: SourceBinding,
  892. *,
  893. binding_id: str,
  894. source_suffix: str,
  895. resolver: SourceResolver,
  896. ) -> SourceBinding:
  897. cloned: SourceBinding = deepcopy(binding)
  898. cloned["id"] = binding_id
  899. selector = cloned.get("selector") or {}
  900. if source_suffix and selector.get("kind") == "json-pointer":
  901. pointer = f"{str(selector.get('path') or '').rstrip('/')}{source_suffix}"
  902. source = resolver.sources.get(cloned["sourceId"]) or {}
  903. found, selected = pointer_lookup(source.get("rawRecord"), pointer)
  904. if found:
  905. cloned["selector"] = {"kind": "json-pointer", "path": pointer}
  906. source.setdefault("selectedValues", []).append({
  907. "bindingId": binding_id,
  908. "fieldId": binding_id,
  909. "label": binding_id,
  910. "path": pointer,
  911. "value": selected,
  912. })
  913. else:
  914. cloned["selector"] = {
  915. "kind": "unresolved",
  916. "reason": f"原始记录中无法定位字段 {pointer}",
  917. "code": "field-path-mismatch",
  918. }
  919. cloned["resolution"] = "partial"
  920. cloned["evidence"]["confidence"] = "unconfirmed"
  921. return cloned
  922. def _module(
  923. module_id: str,
  924. title: str,
  925. business_path: str,
  926. semantic_key: str,
  927. rows: list[dict[str, Any]],
  928. *,
  929. presentation: str = "text",
  930. default_open: bool = True,
  931. ) -> dict[str, Any]:
  932. return {
  933. "id": module_id,
  934. "title": title,
  935. "businessPath": business_path,
  936. "presentation": presentation,
  937. "rows": rows,
  938. "defaultOpen": default_open,
  939. "_semanticKey": semantic_key,
  940. }
  941. def _row(
  942. row_id: str,
  943. label: str | None,
  944. business_selector: str,
  945. *,
  946. item_index: int | None = None,
  947. binding_slot: str | None = None,
  948. source_suffix: str | None = None,
  949. ) -> dict[str, Any]:
  950. row: dict[str, Any] = {
  951. "id": row_id,
  952. "label": label,
  953. "businessSelector": business_selector,
  954. }
  955. if item_index is not None:
  956. row["_itemIndex"] = item_index
  957. if binding_slot is not None:
  958. row["_bindingSlot"] = binding_slot
  959. if source_suffix is not None:
  960. row["_sourceSuffix"] = source_suffix
  961. return row
  962. def _unique_bindings(bindings: list[SourceBinding]) -> list[SourceBinding]:
  963. result: list[SourceBinding] = []
  964. seen: set[str] = set()
  965. for binding in bindings:
  966. key = str(binding.get("id") or "")
  967. if not key or key in seen:
  968. continue
  969. seen.add(key)
  970. result.append(binding)
  971. return result
  972. def _unique_physical_bindings(bindings: list[SourceBinding]) -> list[SourceBinding]:
  973. result: list[SourceBinding] = []
  974. seen: set[str] = set()
  975. for binding in bindings:
  976. selector = binding.get("selector") or {}
  977. key = "|".join((
  978. str(binding.get("sourceId") or ""),
  979. str(selector.get("kind") or ""),
  980. str(selector.get("path") or selector.get("exactText") or selector.get("reason") or selector),
  981. str(binding.get("role") or ""),
  982. ))
  983. if not key or key in seen:
  984. continue
  985. seen.add(key)
  986. result.append(binding)
  987. return result
  988. def _branch_id(value: str) -> int | None:
  989. import re
  990. match = re.search(r":branch:(\d+)", value)
  991. return _integer(match.group(1)) if match else None
  992. def _integer(value: Any) -> int | None:
  993. try:
  994. return int(value)
  995. except (TypeError, ValueError):
  996. return None