resolver.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import re
  5. from typing import Any
  6. from .schema import EvidenceSpec, SourceBinding, SourceRecord, unresolved_binding
  7. _INDEX = re.compile(r"\[([0-9]+)\]")
  8. def json_pointer(path: str | None) -> str:
  9. """Convert the visualization's legacy dotted paths to RFC 6901 pointers."""
  10. value = str(path or "").strip()
  11. if not value:
  12. return ""
  13. if " / " in value:
  14. return ""
  15. value = _INDEX.sub(r".\1", value)
  16. parts = [part for part in value.split(".") if part]
  17. return "/" + "/".join(
  18. part.replace("~", "~0").replace("/", "~1") for part in parts
  19. )
  20. def pointer_lookup(value: Any, pointer: str) -> tuple[bool, Any]:
  21. if not pointer:
  22. return True, value
  23. current = value
  24. for raw in pointer.lstrip("/").split("/"):
  25. part = raw.replace("~1", "/").replace("~0", "~")
  26. if isinstance(current, list):
  27. try:
  28. current = current[int(part)]
  29. except (ValueError, IndexError):
  30. return False, None
  31. elif isinstance(current, dict):
  32. if part not in current:
  33. return False, None
  34. current = current[part]
  35. else:
  36. return False, None
  37. return True, current
  38. def pointer_value(value: Any, pointer: str) -> Any:
  39. return pointer_lookup(value, pointer)[1]
  40. def source_digest(value: Any) -> str:
  41. serialized = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
  42. return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
  43. class SourceResolver:
  44. """Maps semantic CardData fields to deduplicated physical source records.
  45. It deliberately does not infer adoption from matching text. The only
  46. evidence inputs are the field's declared relation and physical origin.
  47. """
  48. def __init__(
  49. self,
  50. *,
  51. bundle: dict[str, Any],
  52. event_details: dict[int, dict[str, Any]],
  53. ) -> None:
  54. self.bundle = bundle
  55. self.event_details = event_details
  56. self.sources: dict[str, SourceRecord] = {}
  57. self._span_offsets: dict[tuple[int, str, str], int] = {}
  58. def binding_for_field(
  59. self,
  60. field: dict[str, Any],
  61. *,
  62. binding_id: str,
  63. role: str | None = None,
  64. ) -> SourceBinding:
  65. source = field.get("source") if isinstance(field.get("source"), dict) else {}
  66. source_kind = str(source.get("kind") or "")
  67. source_ref = str(source.get("ref") or "")
  68. raw, physical = self._raw_source(source_kind, source_ref, source)
  69. if source_kind == "calculation":
  70. raw = {
  71. "value": field.get("value"),
  72. "calculation": source.get("label"),
  73. }
  74. source_id = self._source_id(source_kind, source_ref, source, physical)
  75. field_path = str(source.get("fieldPath") or "")
  76. pointer = json_pointer(field_path)
  77. resolution = "resolved"
  78. if raw is None:
  79. missing = self._ensure_missing_source(
  80. source_id,
  81. source,
  82. reason=f"未找到 {source.get('label') or source_ref or '原始记录'}",
  83. )
  84. return unresolved_binding(
  85. binding_id,
  86. reason=missing["locator"]["reason"],
  87. role=role or self._role(field),
  88. source_id=source_id,
  89. code="source-record-not-saved",
  90. )
  91. selector: dict[str, Any]
  92. found, selected = pointer_lookup(raw, pointer) if pointer else (True, raw)
  93. if source_kind == "calculation":
  94. selector = {"kind": "json-pointer", "path": "/value"}
  95. selected = field.get("value")
  96. elif source_kind == "artifact" and field_path in {"snapshotRef", "recordCounts", "historicalVersion"}:
  97. selector = {"kind": "whole-record"}
  98. selected = field.get("value")
  99. elif pointer and found:
  100. selector = {"kind": "json-pointer", "path": pointer}
  101. elif pointer:
  102. # The legacy CardData path may refer to a derived projection rather
  103. # than a persisted record. Keep the source visible, but do not
  104. # pretend that the pointer resolves against it.
  105. selector = {
  106. "kind": "unresolved",
  107. "reason": f"原始记录中无法安全定位字段 {field_path}",
  108. "code": "field-path-mismatch",
  109. }
  110. selected = field.get("value")
  111. resolution = "partial"
  112. else:
  113. selector = {"kind": "whole-record"}
  114. selected = field.get("value")
  115. record = self._ensure_source(source_id, source_kind, source, raw, physical)
  116. record.setdefault("selectedValues", []).append(
  117. {
  118. "bindingId": binding_id,
  119. "fieldId": field.get("id"),
  120. "label": field.get("label"),
  121. "path": pointer or field_path or None,
  122. "value": selected,
  123. }
  124. )
  125. transform = self._transform(source_kind, source)
  126. if (
  127. transform.get("kind") == "direct"
  128. and found
  129. and not _equivalent_value(field.get("value"), selected)
  130. ):
  131. transform = {
  132. "kind": "parsed",
  133. "operation": "business-projection-v1",
  134. "outputKey": str(field.get("id") or binding_id),
  135. }
  136. binding: SourceBinding = {
  137. "id": binding_id,
  138. "sourceId": source_id,
  139. "role": role or self._role(field), # type: ignore[typeddict-item]
  140. "selector": selector,
  141. "transform": transform,
  142. "evidence": self._evidence(field, source_kind),
  143. "resolution": resolution, # type: ignore[typeddict-item]
  144. }
  145. if transform.get("kind") == "parsed":
  146. binding["evidence"]["confidence"] = "deterministic"
  147. if binding["resolution"] != "resolved" and transform.get("kind") == "direct":
  148. binding["transform"] = {
  149. "kind": "parsed",
  150. "operation": "unresolved-source-projection-v1",
  151. "outputKey": str(field.get("id") or binding_id),
  152. }
  153. binding["evidence"]["confidence"] = "unconfirmed"
  154. if "未安全关联" in str(source.get("label") or ""):
  155. binding["resolution"] = "unsafe"
  156. binding["evidence"]["confidence"] = "unconfirmed"
  157. return binding
  158. def binding_for_event(
  159. self,
  160. event_id: int,
  161. *,
  162. binding_id: str,
  163. path: str = "",
  164. role: str = "process",
  165. availability: str = "produced-by-run",
  166. ) -> SourceBinding:
  167. event = self.event_details.get(int(event_id))
  168. source_id = f"event:{event_id}"
  169. if event is None:
  170. self._ensure_missing_source(
  171. source_id,
  172. {"label": f"Event {event_id}", "ref": source_id},
  173. reason=f"Event {event_id} 或 Event Body 未保存",
  174. )
  175. return unresolved_binding(
  176. binding_id,
  177. reason=f"Event {event_id} 或 Event Body 未保存",
  178. role=role,
  179. source_id=source_id,
  180. code="event-body-not-saved",
  181. )
  182. source = {
  183. "kind": "runtime-event",
  184. "label": str(event.get("event_name") or f"Event {event_id}"),
  185. "ref": source_id,
  186. "fieldPath": path,
  187. }
  188. pointer = json_pointer(path)
  189. found, selected = pointer_lookup(event, pointer) if pointer else (True, event)
  190. resolution = "resolved" if found else "partial"
  191. if not pointer:
  192. selector = {"kind": "whole-record"}
  193. elif found:
  194. selector = {"kind": "json-pointer", "path": pointer}
  195. else:
  196. selector = {
  197. "kind": "unresolved",
  198. "reason": f"Event {event_id} 中不存在字段 {path}",
  199. "code": "field-path-mismatch",
  200. }
  201. record = self._ensure_source(source_id, "runtime-event", source, event, event)
  202. record.setdefault("selectedValues", []).append(
  203. {"bindingId": binding_id, "fieldId": binding_id, "label": source["label"], "path": pointer or None, "value": selected}
  204. )
  205. return {
  206. "id": binding_id,
  207. "sourceId": source_id,
  208. "role": role, # type: ignore[typeddict-item]
  209. "selector": selector,
  210. "transform": {"kind": "direct"},
  211. "evidence": {
  212. "availability": availability,
  213. "adoption": "not-applicable" if availability == "produced-by-run" else "not-recorded",
  214. "confidence": "exact",
  215. }, # type: ignore[typeddict-item]
  216. "resolution": resolution, # type: ignore[typeddict-item]
  217. }
  218. def text_span_binding(
  219. self,
  220. event_id: int,
  221. *,
  222. binding_id: str,
  223. path: str,
  224. exact_text: Any,
  225. role: str,
  226. ) -> SourceBinding:
  227. event = self.event_details.get(int(event_id))
  228. source_id = f"event:{event_id}"
  229. if event is None:
  230. self._ensure_missing_source(
  231. source_id,
  232. {"label": f"Event {event_id}", "ref": source_id},
  233. reason=f"Event {event_id} 或 Event Body 未保存",
  234. )
  235. return unresolved_binding(
  236. binding_id,
  237. reason=f"Event {event_id} 或 Event Body 未保存",
  238. role=role,
  239. source_id=source_id,
  240. code="event-body-not-saved",
  241. )
  242. pointer = json_pointer(path)
  243. raw_text = pointer_value(event, pointer)
  244. needle = str(exact_text or "")
  245. if not isinstance(raw_text, str) or not needle:
  246. binding = self.binding_for_event(
  247. event_id,
  248. binding_id=binding_id,
  249. path=path,
  250. role=role,
  251. availability="returned-to-agent" if role == "output" else "direct-read",
  252. )
  253. binding["transform"] = {
  254. "kind": "parsed",
  255. "operation": "structured-text-section-v1",
  256. "outputKey": binding_id,
  257. }
  258. binding["evidence"]["confidence"] = "deterministic"
  259. return binding
  260. span_key = (int(event_id), pointer, needle)
  261. search_from = self._span_offsets.get(span_key, 0)
  262. start = raw_text.find(needle, search_from)
  263. if start < 0 and search_from:
  264. start = raw_text.find(needle)
  265. if start < 0:
  266. binding = self.binding_for_event(
  267. event_id,
  268. binding_id=binding_id,
  269. path=path,
  270. role=role,
  271. availability="returned-to-agent" if role == "output" else "direct-read",
  272. )
  273. binding["transform"] = {
  274. "kind": "parsed",
  275. "operation": "normalized-text-section-v1",
  276. "outputKey": binding_id,
  277. }
  278. binding["evidence"]["confidence"] = "deterministic"
  279. return binding
  280. record = self._ensure_source(
  281. source_id,
  282. "runtime-event",
  283. {"label": str(event.get("event_name") or f"Event {event_id}"), "ref": source_id},
  284. event,
  285. event,
  286. )
  287. record.setdefault("selectedValues", []).append(
  288. {"bindingId": binding_id, "fieldId": binding_id, "label": binding_id, "path": pointer, "value": needle}
  289. )
  290. self._span_offsets[span_key] = start + len(needle)
  291. return {
  292. "id": binding_id,
  293. "sourceId": source_id,
  294. "role": role, # type: ignore[typeddict-item]
  295. "selector": {
  296. "kind": "text-span",
  297. "path": pointer,
  298. "startCodePoint": start,
  299. "endCodePoint": start + len(needle),
  300. "exactText": needle,
  301. "sourceDigest": source_digest(raw_text),
  302. },
  303. "transform": {"kind": "direct"},
  304. "evidence": {
  305. "availability": "returned-to-agent" if role == "output" else "direct-read",
  306. "adoption": "not-recorded",
  307. "confidence": "exact",
  308. },
  309. "resolution": "resolved",
  310. }
  311. def add_unresolved_source(self, binding: SourceBinding) -> None:
  312. source_id = binding["sourceId"]
  313. reason = str(binding.get("selector", {}).get("reason") or "原始记录无法定位")
  314. self._ensure_missing_source(
  315. source_id,
  316. {"label": "记录缺失"},
  317. reason=reason,
  318. )
  319. def add_log_anchor(
  320. self, event_id: int, module: dict[str, Any], *, event_msg_id: str
  321. ) -> str:
  322. source_id = f"log:event:{event_id}"
  323. self.sources[source_id] = {
  324. "id": source_id,
  325. "kind": "log-anchor",
  326. "label": f"锚定日志模块 · Event {event_id}",
  327. "locator": {
  328. "table": "script_build_log",
  329. "eventId": event_id,
  330. "msgId": event_msg_id,
  331. "logAnchor": module.get("anchor"),
  332. },
  333. "completeness": "complete",
  334. "resultState": "present",
  335. "truncated": False,
  336. "selectedValues": [],
  337. "rawRecord": module.get("content"),
  338. }
  339. return source_id
  340. def empty_database_result_binding(
  341. self,
  342. *,
  343. binding_id: str,
  344. table: str,
  345. filters: dict[str, Any],
  346. label: str,
  347. role: str = "output",
  348. ) -> SourceBinding:
  349. """Represent a successful zero-row lookup without calling it missing."""
  350. filter_key = ",".join(f"{key}={filters[key]}" for key in sorted(filters))
  351. source_id = f"db-result:{table}:{filter_key or 'all'}"
  352. self.sources[source_id] = {
  353. "id": source_id,
  354. "kind": "database",
  355. "label": label,
  356. "locator": {
  357. "table": table,
  358. "filters": filters,
  359. "resultCount": 0,
  360. },
  361. "completeness": "complete",
  362. "resultState": "empty",
  363. "truncated": False,
  364. "selectedValues": [{
  365. "bindingId": binding_id,
  366. "fieldId": binding_id,
  367. "label": "查询结果",
  368. "path": "",
  369. "value": [],
  370. }],
  371. "rawRecord": [],
  372. }
  373. return {
  374. "id": binding_id,
  375. "sourceId": source_id,
  376. "role": role, # type: ignore[typeddict-item]
  377. "selector": {"kind": "whole-record"},
  378. "transform": {
  379. "kind": "calculated",
  380. "operation": "数据库查询结果计数为 0",
  381. },
  382. "evidence": {
  383. "availability": "visualization-derived",
  384. "adoption": "not-applicable",
  385. "confidence": "deterministic",
  386. },
  387. "resolution": "resolved",
  388. }
  389. def members_binding(
  390. self,
  391. field: dict[str, Any],
  392. *,
  393. binding_id: str,
  394. members: list[dict[str, Any]],
  395. reducer: str,
  396. role: str = "output",
  397. ) -> SourceBinding:
  398. """Create a deterministic aggregate while retaining every member ref."""
  399. base_field = {
  400. **field,
  401. "source": {
  402. "kind": "calculation",
  403. "label": reducer,
  404. "ref": f"calculation:{binding_id}",
  405. "fieldPath": "value",
  406. },
  407. "relation": "persisted-output",
  408. }
  409. base = self.binding_for_field(base_field, binding_id=binding_id, role=role)
  410. member_selectors: list[dict[str, Any]] = []
  411. member_resolutions: list[str] = []
  412. for index, member in enumerate(members, 1):
  413. member_binding = self.binding_for_field(
  414. member,
  415. binding_id=f"{binding_id}:member:{index}",
  416. role=role,
  417. )
  418. member_selectors.append(
  419. {
  420. "sourceId": member_binding["sourceId"],
  421. "selector": member_binding["selector"],
  422. }
  423. )
  424. member_resolutions.append(str(member_binding.get("resolution") or "missing"))
  425. member_source = self.sources.get(member_binding["sourceId"])
  426. if member_source and member_source.get("selectedValues"):
  427. member_source["selectedValues"][-1]["aggregateBindingId"] = binding_id
  428. if not member_selectors:
  429. base["selector"] = {
  430. "kind": "unresolved",
  431. "reason": "确定性聚合没有找到可定位的参与记录",
  432. "code": "binding-map-missing",
  433. }
  434. base["resolution"] = "missing"
  435. base["evidence"]["confidence"] = "unconfirmed"
  436. return base
  437. base["selector"] = {
  438. "kind": "members",
  439. "members": member_selectors,
  440. "reducer": reducer,
  441. }
  442. base["transform"] = {"kind": "calculated", "operation": reducer}
  443. base["evidence"] = {
  444. "availability": "visualization-derived",
  445. "adoption": "not-applicable",
  446. "confidence": "deterministic",
  447. }
  448. if any(value in {"missing", "unsafe"} for value in member_resolutions):
  449. base["resolution"] = "partial"
  450. base["evidence"]["confidence"] = "unconfirmed"
  451. elif any(value == "partial" for value in member_resolutions):
  452. base["resolution"] = "partial"
  453. return base
  454. def _raw_source(
  455. self, source_kind: str, source_ref: str, source: dict[str, Any]
  456. ) -> tuple[Any, dict[str, Any]]:
  457. if source_kind == "runtime-event":
  458. event_id = _suffix_int(source_ref)
  459. if event_id is not None:
  460. event = self.event_details.get(event_id)
  461. return event, event or {}
  462. # A runtime source is only trustworthy when it names one concrete
  463. # Event. Treating an empty ref as "all related Events" previously
  464. # let a multipath evaluator impersonate a missing script_evaluator
  465. # in round summaries.
  466. return None, {"detailRef": source_ref, "reason": "未保存可定位的 Event ref"}
  467. if source_kind == "database":
  468. return self._database_record(source_ref, str(source.get("label") or ""))
  469. if source_kind == "artifact":
  470. if "branch:" in source_ref:
  471. branch_id = _suffix_int(source_ref)
  472. row = _by_int(self.bundle.get("branches"), "branch_id", branch_id)
  473. # CardData paths are rooted at the branch record
  474. # (`candidate_snapshot.snapshot`), so retain that root rather
  475. # than pre-extracting candidate_snapshot and breaking the
  476. # declared JSON Pointer.
  477. return row, {"table": "script_build_branch", "recordId": (row or {}).get("id")}
  478. artifact = self.bundle.get("currentArtifact")
  479. return artifact, {"artifactRef": source_ref or "artifact:base:current"}
  480. if source_kind == "calculation":
  481. return {"value": None, "calculation": source.get("label")}, {"calculation": source.get("label")}
  482. return None, {"ref": source_ref}
  483. def _database_record(self, source_ref: str, label: str) -> tuple[Any, dict[str, Any]]:
  484. normalized = label.lower()
  485. if source_ref.startswith("missing-convergence:"):
  486. parts = source_ref.split(":", 4)
  487. record_type = parts[1] if len(parts) > 1 else "unknown"
  488. round_index = _integer(parts[3]) if len(parts) > 3 else None
  489. branch_ids = [
  490. _integer(value)
  491. for value in (parts[4].split(",") if len(parts) > 4 else [])
  492. if _integer(value) is not None
  493. ]
  494. return [], {
  495. "tables": (
  496. ["script_build_event", "script_build_event_body"]
  497. if record_type == "review"
  498. else ["script_build_multipath_decision"]
  499. ),
  500. "filters": {
  501. "round_index": round_index,
  502. "branch_ids": branch_ids,
  503. },
  504. "resultCount": 0,
  505. }
  506. if "script_build_record" in normalized or source_ref == "run:objective" or source_ref == "run:final-result":
  507. row = self.bundle.get("record")
  508. return row, {"table": "script_build_record", "recordId": (row or {}).get("id")}
  509. if "data_decision" in normalized or source_ref.startswith("data-decision:"):
  510. row = _by_int(self.bundle.get("dataDecisions"), "id", _suffix_int(source_ref))
  511. return row, {"table": "script_build_data_decision", "recordId": (row or {}).get("id")}
  512. if "multipath" in normalized or source_ref.startswith("multipath-decision:"):
  513. row = _by_int(self.bundle.get("multipathDecisions"), "id", _suffix_int(source_ref))
  514. return row, {"table": "script_build_multipath_decision", "recordId": (row or {}).get("id")}
  515. if "domain_info" in normalized or source_ref.startswith("domain-info:"):
  516. row = _by_int(self.bundle.get("domainInfo"), "id", _suffix_int(source_ref))
  517. return row, {"table": "script_build_domain_info", "recordId": (row or {}).get("id")}
  518. if "script_build_round" in normalized or source_ref.startswith("round:") and ":branch:" not in source_ref:
  519. round_index = _round_index(source_ref)
  520. row = _by_int(self.bundle.get("rounds"), "round_index", round_index)
  521. return row, {"table": "script_build_round", "recordId": (row or {}).get("id")}
  522. if "script_build_branch" in normalized or ":branch:" in source_ref:
  523. branch_id = _branch_id(source_ref)
  524. row = _by_int(self.bundle.get("branches"), "branch_id", branch_id)
  525. return row, {"table": "script_build_branch", "recordId": (row or {}).get("id")}
  526. return None, {"table": label or "unknown", "ref": source_ref}
  527. def _ensure_source(
  528. self,
  529. source_id: str,
  530. source_kind: str,
  531. source: dict[str, Any],
  532. raw: Any,
  533. physical: dict[str, Any],
  534. ) -> SourceRecord:
  535. existing = self.sources.get(source_id)
  536. if existing is not None:
  537. return existing
  538. kind = {
  539. "database": "database",
  540. "runtime-event": "runtime-event",
  541. "artifact": "artifact",
  542. "calculation": "calculation",
  543. "log-anchor": "log-anchor",
  544. }.get(source_kind, "missing")
  545. if "历史回退" in str(source.get("label") or ""):
  546. kind = "historical-fallback"
  547. completeness = "complete"
  548. truncated = False
  549. omitted = 0
  550. if kind == "runtime-event" and isinstance(raw, dict):
  551. sides = [raw.get("input"), raw.get("output")]
  552. if raw.get("input") is None and raw.get("output") is None:
  553. completeness = "partial"
  554. for side in sides:
  555. if isinstance(side, dict) and side.get("truncated"):
  556. truncated = True
  557. omitted += int(side.get("omittedCharacters") or 0)
  558. record: SourceRecord = {
  559. "id": source_id,
  560. "kind": kind, # type: ignore[typeddict-item]
  561. "label": str(source.get("label") or source.get("ref") or source_id),
  562. "locator": physical,
  563. "completeness": "partial" if truncated else completeness, # type: ignore[typeddict-item]
  564. "resultState": (
  565. "empty"
  566. if raw == []
  567. or (
  568. kind == "calculation"
  569. and isinstance(raw, dict)
  570. and raw.get("value") == []
  571. )
  572. else "present"
  573. ),
  574. "truncated": truncated,
  575. "selectedValues": [],
  576. "rawRecord": raw,
  577. }
  578. if omitted:
  579. record["omittedCharacters"] = omitted
  580. if isinstance(raw, dict) and kind == "runtime-event":
  581. record["status"] = str(raw.get("status") or "unknown")
  582. record["durationMs"] = _integer(raw.get("duration_ms"))
  583. record["locator"] = {
  584. "eventId": raw.get("id"),
  585. "eventName": raw.get("event_name"),
  586. "eventType": raw.get("event_type"),
  587. "table": "script_build_event + script_build_event_body",
  588. }
  589. self.sources[source_id] = record
  590. return record
  591. def _ensure_missing_source(
  592. self, source_id: str, source: dict[str, Any], *, reason: str
  593. ) -> SourceRecord:
  594. record = self.sources.get(source_id)
  595. if record is None:
  596. record = {
  597. "id": source_id,
  598. "kind": "missing",
  599. "label": str(source.get("label") or source_id),
  600. "locator": {"ref": source.get("ref"), "reason": reason},
  601. "completeness": "missing",
  602. "resultState": "missing",
  603. "truncated": False,
  604. "selectedValues": [],
  605. "rawRecord": None,
  606. }
  607. self.sources[source_id] = record
  608. return record
  609. @staticmethod
  610. def _source_id(
  611. source_kind: str,
  612. source_ref: str,
  613. source: dict[str, Any],
  614. physical: dict[str, Any],
  615. ) -> str:
  616. if source_kind == "runtime-event" and _suffix_int(source_ref) is not None:
  617. return f"event:{_suffix_int(source_ref)}"
  618. if source_kind == "database" and physical.get("table"):
  619. return f"db:{physical['table']}:{physical.get('recordId') or source_ref or 'unknown'}"
  620. if source_kind == "artifact":
  621. return f"artifact:{source_ref or physical.get('artifactRef') or 'unknown'}"
  622. key = f"{source_kind}:{source_ref}:{source.get('label')}"
  623. return f"source:{hashlib.sha1(key.encode('utf-8')).hexdigest()[:12]}"
  624. @staticmethod
  625. def _role(field: dict[str, Any]) -> str:
  626. relation = str(field.get("relation") or "")
  627. return {
  628. "direct-input": "input",
  629. "previous-result": "basis",
  630. "standing-constraint": "constraint",
  631. "explicit-basis": "basis",
  632. "available-upstream": "basis",
  633. "persisted-output": "output",
  634. "run-output": "output",
  635. }.get(relation, "basis")
  636. @staticmethod
  637. def _transform(source_kind: str, source: dict[str, Any]) -> dict[str, Any]:
  638. if source_kind == "calculation":
  639. return {"kind": "calculated", "operation": str(source.get("label") or "确定性计算")}
  640. if "历史回退" in str(source.get("label") or ""):
  641. return {
  642. "kind": "fallback",
  643. "selectedSourceId": str(source.get("ref") or ""),
  644. "candidateSourceIds": [str(source.get("ref") or "")],
  645. }
  646. return {"kind": "direct"}
  647. @staticmethod
  648. def _evidence(field: dict[str, Any], source_kind: str) -> EvidenceSpec:
  649. relation = str(field.get("relation") or "")
  650. if source_kind == "calculation":
  651. return {
  652. "availability": "visualization-derived",
  653. "adoption": "not-applicable",
  654. "confidence": "deterministic",
  655. }
  656. if relation == "explicit-basis":
  657. return {
  658. "availability": "direct-read",
  659. "adoption": "explicitly-adopted",
  660. "confidence": "exact",
  661. }
  662. if relation in {"direct-input", "standing-constraint"}:
  663. return {
  664. "availability": "direct-read",
  665. "adoption": "not-recorded",
  666. "confidence": "exact",
  667. }
  668. if relation == "previous-result":
  669. return {
  670. "availability": "returned-to-agent" if source_kind == "runtime-event" else "available-upstream",
  671. "adoption": "not-recorded",
  672. "confidence": "exact" if source_kind == "runtime-event" else "safe-association",
  673. }
  674. if relation == "available-upstream":
  675. return {
  676. "availability": "available-upstream",
  677. "adoption": "not-recorded",
  678. "confidence": "safe-association",
  679. }
  680. if relation == "persisted-output":
  681. availability = "returned-to-agent" if source_kind == "runtime-event" else "produced-by-run"
  682. return {
  683. "availability": availability,
  684. "adoption": "not-recorded" if availability == "returned-to-agent" else "not-applicable",
  685. "confidence": "exact",
  686. }
  687. if relation == "run-output":
  688. return {
  689. "availability": "produced-by-run",
  690. "adoption": "not-applicable",
  691. "confidence": "exact",
  692. }
  693. return {
  694. "availability": "available-upstream",
  695. "adoption": "not-recorded",
  696. "confidence": "unconfirmed",
  697. }
  698. def _integer(value: Any) -> int | None:
  699. try:
  700. return int(value)
  701. except (TypeError, ValueError):
  702. return None
  703. def _suffix_int(value: str) -> int | None:
  704. return _integer(str(value or "").rsplit(":", 1)[-1])
  705. def _round_index(ref: str) -> int | None:
  706. match = re.search(r"(?:^|:)round:(\d+)", f":{ref}")
  707. return _integer(match.group(1)) if match else None
  708. def _branch_id(ref: str) -> int | None:
  709. match = re.search(r":branch:(\d+)", ref)
  710. return _integer(match.group(1)) if match else None
  711. def _by_int(items: Any, key: str, wanted: int | None) -> dict[str, Any] | None:
  712. if wanted is None:
  713. return None
  714. return next(
  715. (
  716. item
  717. for item in items or []
  718. if isinstance(item, dict) and _integer(item.get(key)) == wanted
  719. ),
  720. None,
  721. )
  722. def _equivalent_value(left: Any, right: Any) -> bool:
  723. try:
  724. return json.dumps(left, ensure_ascii=False, sort_keys=True, default=str) == json.dumps(
  725. right, ensure_ascii=False, sort_keys=True, default=str
  726. )
  727. except (TypeError, ValueError):
  728. return str(left) == str(right)