service.py 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165
  1. from __future__ import annotations
  2. import json
  3. from hashlib import sha1
  4. from dataclasses import dataclass
  5. from threading import RLock
  6. from time import monotonic
  7. from typing import Any, Iterable
  8. from ..execution_builder import ExecutionViewBuilder
  9. from ..inspector_projection import (
  10. project_activity_detail,
  11. project_event_detail,
  12. project_round_detail,
  13. )
  14. from ..providers import LocalDatabaseProvider
  15. from ..sanitizer import sanitize
  16. from .log_context import locate_log_module
  17. from .repository import AuditRepository
  18. class ModuleNotFound(LookupError):
  19. pass
  20. @dataclass(frozen=True)
  21. class _RunSnapshot:
  22. bundle: dict[str, Any]
  23. view: dict[str, Any]
  24. modules: list[dict[str, Any]]
  25. expires_at: float
  26. class ModuleAuditService:
  27. def __init__(
  28. self,
  29. provider: LocalDatabaseProvider | None = None,
  30. builder: ExecutionViewBuilder | None = None,
  31. repository: AuditRepository | None = None,
  32. ):
  33. self.provider = provider or LocalDatabaseProvider()
  34. self.builder = builder or ExecutionViewBuilder()
  35. self.repository = repository or AuditRepository()
  36. self._snapshot_cache: dict[int, _RunSnapshot] = {}
  37. self._detail_cache: dict[tuple[int, str], tuple[float, dict[str, Any]]] = {}
  38. self._event_cache: dict[tuple[int, int], tuple[float, dict[str, Any] | None]] = {}
  39. self._log_cache: dict[int, tuple[float, str | None]] = {}
  40. self._snapshot_lock = RLock()
  41. def _snapshot(self, script_build_id: int) -> _RunSnapshot:
  42. now = monotonic()
  43. with self._snapshot_lock:
  44. cached = self._snapshot_cache.get(script_build_id)
  45. if cached is not None and cached.expires_at > now:
  46. return cached
  47. bundle = self.provider.load_bundle(script_build_id)
  48. view = self.builder.from_bundle(bundle)
  49. modules = _collect_modules(view)
  50. status = str((view.get("header") or {}).get("status") or "").lower()
  51. ttl = 300.0 if status in {"completed", "success", "failed", "interrupted", "cancelled"} else 3.0
  52. snapshot = _RunSnapshot(
  53. bundle=bundle,
  54. view=view,
  55. modules=modules,
  56. expires_at=monotonic() + ttl,
  57. )
  58. with self._snapshot_lock:
  59. self._snapshot_cache[script_build_id] = snapshot
  60. if len(self._snapshot_cache) > 12:
  61. stale_ids = sorted(
  62. self._snapshot_cache,
  63. key=lambda item: self._snapshot_cache[item].expires_at,
  64. )[:-12]
  65. for stale_id in stale_ids:
  66. self._snapshot_cache.pop(stale_id, None)
  67. return snapshot
  68. def index(self, script_build_id: int) -> dict[str, Any]:
  69. snapshot = self._snapshot(script_build_id)
  70. view = snapshot.view
  71. modules = snapshot.modules
  72. return sanitize(
  73. {
  74. "schemaVersion": "1",
  75. "run": {
  76. "id": script_build_id,
  77. "status": (view.get("header") or {}).get("status"),
  78. "capturedAt": view.get("capturedAt"),
  79. "dataShape": view.get("dataShape"),
  80. },
  81. "columns": ["模块实际使用", "模块实际读取", "原始记录与上下文"],
  82. "modules": [
  83. {
  84. "id": item["id"],
  85. "title": item["title"],
  86. "group": item["group"],
  87. "path": item["path"],
  88. "kind": item["kind"],
  89. "role": item.get("role"),
  90. "roundIndex": item.get("roundIndex"),
  91. "branchId": item.get("branchId"),
  92. "detailRef": item.get("detailRef"),
  93. "displayVariant": item.get("displayVariant", "expanded"),
  94. "cardCharacters": _characters(_card_use(item)),
  95. "completeness": "available",
  96. }
  97. for item in modules
  98. ],
  99. "summary": {
  100. "moduleCount": len(modules),
  101. "groupCount": len({item["group"] for item in modules}),
  102. "cardCharacters": sum(_characters(_card_use(item)) for item in modules),
  103. },
  104. },
  105. max_text=None,
  106. )
  107. def detail(self, script_build_id: int, module_id: str) -> dict[str, Any]:
  108. snapshot = self._snapshot(script_build_id)
  109. cache_key = (script_build_id, module_id)
  110. now = monotonic()
  111. with self._snapshot_lock:
  112. cached_detail = self._detail_cache.get(cache_key)
  113. if cached_detail is not None and cached_detail[0] > now:
  114. return cached_detail[1]
  115. bundle = snapshot.bundle
  116. view = snapshot.view
  117. module = next((item for item in snapshot.modules if item["id"] == module_id), None)
  118. if module is None:
  119. raise ModuleNotFound(module_id)
  120. inspector, inspector_notice = self._inspector(script_build_id, module, view, bundle)
  121. card = _card_use(module)
  122. reads = self._reads(script_build_id, module, inspector, bundle)
  123. reads = _annotate_usage_areas(reads, inspector)
  124. use_by_consumer = {
  125. "card": _strings(card),
  126. "inspector": _strings(inspector),
  127. }
  128. use_snippets = sorted(
  129. set(use_by_consumer["card"] + use_by_consumer["inspector"]),
  130. key=len,
  131. reverse=True,
  132. )
  133. contexts = self._contexts(
  134. script_build_id,
  135. reads,
  136. use_snippets,
  137. expires_at=snapshot.expires_at,
  138. )
  139. reads, contexts = _annotate_lineage(module["id"], reads, contexts)
  140. completeness = _completeness(inspector_notice, reads, contexts)
  141. payload = sanitize(
  142. {
  143. "schemaVersion": "1",
  144. "id": module["id"],
  145. "title": module["title"],
  146. "group": module["group"],
  147. "path": module["path"],
  148. "actualUse": {
  149. "card": card,
  150. "inspector": inspector,
  151. "inspectorNotice": inspector_notice,
  152. "characters": {
  153. "card": _characters(card),
  154. "inspectorBusiness": _characters((inspector or {}).get("businessSections")),
  155. "inspectorDecision": _characters((inspector or {}).get("blocks")),
  156. "inspectorChanges": _characters((inspector or {}).get("changes")),
  157. "inspectorTechnical": _characters((inspector or {}).get("technical")),
  158. },
  159. },
  160. "actualReads": [
  161. _public_read(item, use_by_consumer) for item in reads
  162. ],
  163. "contexts": contexts,
  164. "completeness": completeness,
  165. },
  166. max_text=None,
  167. )
  168. with self._snapshot_lock:
  169. self._detail_cache[cache_key] = (snapshot.expires_at, payload)
  170. if len(self._detail_cache) > 512:
  171. stale_keys = sorted(
  172. self._detail_cache,
  173. key=lambda item: self._detail_cache[item][0],
  174. )[:-512]
  175. for stale_key in stale_keys:
  176. self._detail_cache.pop(stale_key, None)
  177. return payload
  178. def _inspector(
  179. self,
  180. script_build_id: int,
  181. module: dict[str, Any],
  182. view: dict[str, Any],
  183. bundle: dict[str, Any],
  184. ) -> tuple[dict[str, Any] | None, str | None]:
  185. detail_ref = str(module.get("detailRef") or "")
  186. if not detail_ref:
  187. return None, "该模块没有 Inspector 入口。"
  188. if module["kind"] == "final-result" or detail_ref.startswith("artifact:"):
  189. return None, "该按钮进入脚本表查看器,不属于 Inspector。"
  190. try:
  191. if module["kind"] == "round-summary":
  192. return project_round_detail(
  193. script_build_id,
  194. module["payload"],
  195. bundle,
  196. ), None
  197. if detail_ref.startswith("event:"):
  198. event_id = int(detail_ref.rsplit(":", 1)[1])
  199. event_detail = self.provider.load_event_detail(script_build_id, event_id)
  200. try:
  201. return project_activity_detail(
  202. script_build_id,
  203. detail_ref,
  204. view,
  205. bundle,
  206. event_detail=event_detail,
  207. ), None
  208. except KeyError:
  209. return project_event_detail(
  210. script_build_id,
  211. event_detail,
  212. run_status=(view.get("header") or {}).get("status"),
  213. ), None
  214. return project_activity_detail(
  215. script_build_id,
  216. detail_ref,
  217. view,
  218. bundle,
  219. ), None
  220. except (KeyError, ValueError, LookupError) as exc:
  221. return None, f"Inspector 记录不可用:{type(exc).__name__}"
  222. def _reads(
  223. self,
  224. script_build_id: int,
  225. module: dict[str, Any],
  226. inspector: dict[str, Any] | None,
  227. bundle: dict[str, Any],
  228. ) -> list[dict[str, Any]]:
  229. reads = _explicit_reads(module, bundle)
  230. technical = (inspector or {}).get("technical")
  231. if isinstance(technical, dict):
  232. for key, value in technical.items():
  233. if key in {"scriptBuildId", "table"} or value in (None, "", [], {}):
  234. continue
  235. source = _source_from_technical(key, value)
  236. reads.append(
  237. {
  238. "consumer": "inspector",
  239. "label": _technical_label(key),
  240. "disposition": "selected",
  241. "relation": "exact-record" if isinstance(value, (dict, list)) else "projected",
  242. "source": source,
  243. "fieldPath": f"technical.{key}",
  244. "value": value,
  245. "characters": _characters(value),
  246. "context": value,
  247. }
  248. )
  249. event_ids = _event_ids(module, reads)
  250. known = {
  251. int(item["source"]["eventId"])
  252. for item in reads
  253. if isinstance(item.get("source"), dict)
  254. and item["source"].get("eventId") is not None
  255. }
  256. for event_id in event_ids:
  257. if event_id in known:
  258. continue
  259. event = self.repository.load_event(script_build_id, event_id)
  260. if event:
  261. reads.append(_event_read(event, "module", "关联运行事件", "calculation-input"))
  262. return _dedupe_reads(reads)
  263. def _contexts(
  264. self,
  265. script_build_id: int,
  266. reads: list[dict[str, Any]],
  267. use_snippets: list[str],
  268. *,
  269. expires_at: float,
  270. ) -> list[dict[str, Any]]:
  271. contexts: list[dict[str, Any]] = []
  272. event_ids: set[int] = set()
  273. for read in reads:
  274. source = read.get("source") or {}
  275. event_id = _integer(source.get("eventId"))
  276. if event_id is not None:
  277. event_ids.add(event_id)
  278. if source.get("sourceKind") == "database":
  279. continue
  280. content = read.get("context")
  281. if content in (None, "", [], {}):
  282. continue
  283. function_result = source.get("sourceKind") == "function"
  284. contexts.append(
  285. {
  286. "id": _context_id(source, read.get("fieldPath")),
  287. "kind": "function-result" if function_result else "database-record",
  288. "title": (
  289. f"函数处理结果 · {read.get('label') or '数据'}"
  290. if function_result
  291. else read.get("label") or "原始数据库记录"
  292. ),
  293. "source": source,
  294. "sourceKey": _source_key(source),
  295. "relatedSourceKey": (
  296. f"database:event:{source.get('eventId')}"
  297. if function_result and source.get("eventId") is not None
  298. else None
  299. ),
  300. "content": content,
  301. "characters": _characters(content),
  302. "readPaths": ["$"] if function_result else [read.get("fieldPath")],
  303. "usedFragments": _matching_snippets(content, use_snippets),
  304. "match": read.get("relation"),
  305. "completeness": "complete",
  306. }
  307. )
  308. log_content = self._load_log(script_build_id, expires_at) if event_ids else None
  309. for event_id in sorted(event_ids):
  310. event = self._load_event(script_build_id, event_id, expires_at)
  311. if not event:
  312. contexts.append(
  313. {
  314. "id": f"event:{event_id}:missing",
  315. "kind": "event",
  316. "title": f"Event {event_id}",
  317. "source": {
  318. "sourceKind": "database",
  319. "table": "script_build_event + script_build_event_body",
  320. "eventId": event_id,
  321. },
  322. "sourceKey": f"database:event:{event_id}",
  323. "content": "事件记录不存在。",
  324. "characters": 8,
  325. "readPaths": [],
  326. "usedFragments": [],
  327. "match": "missing",
  328. "completeness": "missing",
  329. }
  330. )
  331. continue
  332. contexts.append(
  333. {
  334. "id": f"event:{event_id}",
  335. "kind": "event-record",
  336. "title": f"script_build_event #{event_id}",
  337. "source": {
  338. "sourceKind": "database",
  339. "table": "script_build_event + script_build_event_body",
  340. "eventId": event_id,
  341. },
  342. "sourceKey": f"database:event:{event_id}",
  343. "content": event,
  344. "characters": _characters(event),
  345. "readPaths": ["eventBody.input_content", "eventBody.output_content", "inputData", "outputPreview"],
  346. "usedFragments": _matching_snippets(event, use_snippets),
  347. "match": "exact-record",
  348. "completeness": "complete" if event.get("eventBody") else "body-missing",
  349. }
  350. )
  351. log_module = locate_log_module(log_content, event)
  352. if log_module:
  353. contexts.append(
  354. {
  355. "id": f"event:{event_id}:log",
  356. "kind": "log-module",
  357. "title": f"锚定日志模块 · Event {event_id}",
  358. "source": {
  359. "sourceKind": "log",
  360. "table": "script_build_log",
  361. "eventId": event_id,
  362. "anchor": log_module["anchor"],
  363. },
  364. "sourceKey": f"log:event:{event_id}",
  365. "relatedSourceKey": f"database:event:{event_id}",
  366. "content": log_module["content"],
  367. "characters": log_module["characters"],
  368. "readPaths": [],
  369. "usedFragments": _matching_snippets(log_module["content"], use_snippets),
  370. "match": "anchored",
  371. "completeness": "complete",
  372. }
  373. )
  374. return _dedupe_contexts(contexts)
  375. def _load_event(
  376. self,
  377. script_build_id: int,
  378. event_id: int,
  379. expires_at: float,
  380. ) -> dict[str, Any] | None:
  381. key = (script_build_id, event_id)
  382. now = monotonic()
  383. with self._snapshot_lock:
  384. cached = self._event_cache.get(key)
  385. if cached is not None and cached[0] > now:
  386. return cached[1]
  387. event = self.repository.load_event(script_build_id, event_id)
  388. self._event_cache[key] = (expires_at, event)
  389. if len(self._event_cache) > 512:
  390. stale_keys = sorted(
  391. self._event_cache,
  392. key=lambda item: self._event_cache[item][0],
  393. )[:-512]
  394. for stale_key in stale_keys:
  395. self._event_cache.pop(stale_key, None)
  396. return event
  397. def _load_log(self, script_build_id: int, expires_at: float) -> str | None:
  398. now = monotonic()
  399. with self._snapshot_lock:
  400. cached = self._log_cache.get(script_build_id)
  401. if cached is not None and cached[0] > now:
  402. return cached[1]
  403. log_content = self.repository.load_log(script_build_id)
  404. self._log_cache[script_build_id] = (expires_at, log_content)
  405. if len(self._log_cache) > 12:
  406. stale_ids = sorted(
  407. self._log_cache,
  408. key=lambda item: self._log_cache[item][0],
  409. )[:-12]
  410. for stale_id in stale_ids:
  411. self._log_cache.pop(stale_id, None)
  412. return log_content
  413. def _collect_modules(view: dict[str, Any]) -> list[dict[str, Any]]:
  414. modules: list[dict[str, Any]] = []
  415. def add(
  416. payload: Any,
  417. *,
  418. group: str,
  419. path: list[str],
  420. kind: str,
  421. round_index: int | None = None,
  422. branch_id: int | None = None,
  423. display_variant: str = "expanded",
  424. forced_id: str | None = None,
  425. fallback_id: str | None = None,
  426. fallback_title: str | None = None,
  427. fallback_detail_ref: str | None = None,
  428. ) -> None:
  429. if not isinstance(payload, dict):
  430. return
  431. module_id = str(forced_id or payload.get("id") or fallback_id or "").strip()
  432. if not module_id:
  433. return
  434. modules.append(
  435. {
  436. "id": module_id,
  437. "title": str(payload.get("title") or fallback_title or module_id),
  438. "group": group,
  439. "path": path,
  440. "kind": kind,
  441. "role": payload.get("role"),
  442. "roundIndex": round_index,
  443. "branchId": branch_id,
  444. "detailRef": payload.get("detailRef") or fallback_detail_ref,
  445. "displayVariant": display_variant,
  446. "payload": payload,
  447. }
  448. )
  449. add(view.get("planning"), group="构建前", path=["构建前"], kind="story")
  450. add(view.get("objective"), group="构建前", path=["构建前"], kind="story")
  451. for round_ in view.get("rounds") or []:
  452. round_index = _integer(round_.get("roundIndex")) or 0
  453. group = f"第 {round_index} 轮"
  454. add(round_.get("goal"), group=group, path=[group, "主 Agent"], kind="story", round_index=round_index)
  455. add((round_.get("plan") or {}).get("node"), group=group, path=[group, "主 Agent"], kind="story", round_index=round_index)
  456. for branch in round_.get("branches") or []:
  457. branch_id = _integer(branch.get("branchId")) or 0
  458. branch_label = f"方案 {branch_id}"
  459. add(branch.get("task"), group=group, path=[group, branch_label], kind="story", round_index=round_index, branch_id=branch_id)
  460. stage = branch.get("retrievalStage") or {}
  461. add(stage, group=group, path=[group, branch_label, "取数"], kind="retrieval-stage", round_index=round_index, branch_id=branch_id, fallback_title="取数阶段")
  462. for direct in stage.get("directToolGroups") or []:
  463. add(direct, group=group, path=[group, branch_label, "取数", "工具取数"], kind="retrieval-direct", round_index=round_index, branch_id=branch_id)
  464. for call in direct.get("calls") or []:
  465. add(call, group=group, path=[group, branch_label, "取数", "工具调用"], kind="retrieval-call", round_index=round_index, branch_id=branch_id, fallback_title=call.get("businessLabel"))
  466. for agent in stage.get("agentRuns") or []:
  467. add(agent, group=group, path=[group, branch_label, "取数", "Agent 取数"], kind="retrieval-agent", round_index=round_index, branch_id=branch_id, fallback_title=agent.get("businessLabel"))
  468. for attempt in agent.get("attempts") or []:
  469. add(attempt, group=group, path=[group, branch_label, "取数", "单次查询"], kind="query-attempt", round_index=round_index, branch_id=branch_id, fallback_title=attempt.get("queryLabel"))
  470. for decision in branch.get("dataDecisions") or []:
  471. add(decision.get("node") or decision, group=group, path=[group, branch_label], kind="story", round_index=round_index, branch_id=branch_id)
  472. add((branch.get("output") or {}).get("node"), group=group, path=[group, branch_label], kind="story", round_index=round_index, branch_id=branch_id)
  473. for batch in round_.get("convergenceBatches") or []:
  474. add(batch.get("review") or batch.get("missingReview"), group=group, path=[group, "收敛"], kind="review", round_index=round_index)
  475. add((batch.get("decision") or {}).get("node") or batch.get("decision") or batch.get("missingDecision"), group=group, path=[group, "收敛"], kind="decision", round_index=round_index)
  476. add(round_.get("overallEvaluation"), group=group, path=[group, "整体评审"], kind="story", round_index=round_index)
  477. add(round_.get("result"), group=group, path=[group, "本轮产出"], kind="story", round_index=round_index)
  478. add(view.get("finalResult"), group="最终结果", path=["最终结果"], kind="final-result")
  479. return _unique_modules(modules)
  480. def _card_use(module: dict[str, Any]) -> dict[str, Any]:
  481. payload = module["payload"]
  482. card = payload.get("card")
  483. if not isinstance(card, dict):
  484. card = ((payload.get("decisionProjection") or {}).get("card") if isinstance(payload.get("decisionProjection"), dict) else None)
  485. if isinstance(card, dict):
  486. fields = []
  487. primary = card.get("primary")
  488. if isinstance(primary, dict):
  489. fields.append({"label": primary.get("label"), "value": primary.get("value"), "kind": "primary"})
  490. for item in card.get("secondary") or []:
  491. if isinstance(item, dict):
  492. fields.append({"label": item.get("label"), "value": item.get("value"), "kind": "secondary"})
  493. return {"title": module["title"], "fields": fields}
  494. kind = module["kind"]
  495. if kind == "round-summary":
  496. summary = payload.get("summary") or {}
  497. return {
  498. "title": f"第 {module.get('roundIndex')} 轮摘要",
  499. "fields": [
  500. {"label": "本轮目标", "value": (summary.get("goal") or {}).get("headline")},
  501. {"label": "尝试方案", "value": summary.get("approach")},
  502. {"label": "主 Agent 决策", "value": summary.get("decision")},
  503. {"label": "本轮产出", "value": summary.get("output")},
  504. ],
  505. }
  506. if kind == "branch-summary":
  507. summary = payload.get("summary") or {}
  508. return {"title": module["title"], "fields": [{"label": "任务", "value": summary.get("task")}, {"label": "产出", "value": summary.get("output")}, {"label": "状态", "value": payload.get("status")}]}
  509. if kind == "retrieval-stage":
  510. return {"title": "取数阶段", "fields": [{"label": "模式", "value": payload.get("observedMode")}, {"label": "状态", "value": payload.get("status")}, {"label": "取数模块", "value": f"{len(payload.get('directToolGroups') or [])} 个工具组、{len(payload.get('agentRuns') or [])} 个 Agent"}]}
  511. if kind == "retrieval-direct":
  512. sources = "、".join(str(item.get("businessLabel") or "") for item in payload.get("sources") or [] if isinstance(item, dict))
  513. result = f"{payload.get('failureCount')} 次失败" if payload.get("failureCount") else f"{payload.get('interruptedCount')} 次中断" if payload.get("interruptedCount") else "进行中" if payload.get("runningCount") else "已完成"
  514. return {"title": sources or "工具取数", "fields": [{"label": "读取", "value": f"{payload.get('callCount') or 0} 次"}, {"label": "结果", "value": result}]}
  515. if kind == "retrieval-agent":
  516. query = payload.get("querySummary") or {}
  517. return {"title": payload.get("businessLabel") or "Agent 取数", "fields": [{"label": "取数目标", "value": payload.get("taskPreview")}, {"label": "查询过程", "value": query}, {"label": "初筛整理", "value": (payload.get("screening") or {}).get("preview")}]}
  518. if kind in {"query-attempt", "retrieval-call"}:
  519. return {"title": module["title"], "fields": [{"label": "状态", "value": payload.get("status")}, {"label": "结果数量", "value": payload.get("resultCount")}, {"label": "结果摘要", "value": payload.get("resultSummary")}]}
  520. if kind == "final-result":
  521. return {"title": module["title"], "fields": [{"label": "执行结果", "value": payload.get("summary")}, {"label": "状态", "value": payload.get("status")}, {"label": "轮次", "value": payload.get("roundCount")}, {"label": "方案", "value": payload.get("branchCounts")}]}
  522. return {"title": module["title"], "fields": [{"label": "模块数据", "value": payload}]}
  523. def _explicit_reads(module: dict[str, Any], bundle: dict[str, Any]) -> list[dict[str, Any]]:
  524. reads: list[dict[str, Any]] = []
  525. role = str(module.get("role") or "")
  526. round_index = module.get("roundIndex")
  527. branch_id = module.get("branchId")
  528. raw_round = _find(bundle.get("rounds"), "round_index", round_index)
  529. raw_branch = _find(bundle.get("branches"), "branch_id", branch_id)
  530. if role == "planning-analysis":
  531. _append_related_events(reads, module, bundle, "card", "规划事件")
  532. elif role == "objective":
  533. record = bundle.get("record") or {}
  534. reads.append(_record_read("card", "创作目标", "script_build_record", record, "script_direction"))
  535. elif role == "goal" and raw_round:
  536. reads.append(_record_read("card", "本轮目标", "script_build_round", raw_round, "goal"))
  537. elif role == "plan" and raw_round:
  538. for field, label in (("multipath_plan", "实现路线"), ("race_or_divide", "组织方式"), ("plan_note", "规划理由")):
  539. if raw_round.get(field) not in (None, "", [], {}):
  540. reads.append(_record_read("card", label, "script_build_round", raw_round, field))
  541. elif role == "branch-task" and raw_branch:
  542. reads.append(_record_read("card", "实现任务", "script_build_branch", raw_branch, "impl_task"))
  543. task_event = next(
  544. (
  545. event
  546. for event in reversed(bundle.get("events") or [])
  547. if event.get("event_name") == "script_implementer"
  548. and _integer(event.get("round_index")) == round_index
  549. and _integer(event.get("branch_id")) == branch_id
  550. ),
  551. None,
  552. )
  553. if task_event:
  554. event_value = (task_event.get("inputData") or {}).get("task") if isinstance(task_event.get("inputData"), dict) else None
  555. reads.append(_event_summary_read(task_event, "inspector", "实现 Agent 派发任务", "inputData.task", event_value, "selected" if event_value else "missing"))
  556. reads.append({**_record_read("inspector", "历史回退任务", "script_build_branch", raw_branch, "impl_task"), "disposition": "fallback" if event_value else "selected"})
  557. else:
  558. reads.append({**_record_read("inspector", "实现任务", "script_build_branch", raw_branch, "impl_task"), "disposition": "selected"})
  559. elif role == "data-decision":
  560. row = _find(bundle.get("dataDecisions"), "id", _suffix_int(module["id"]))
  561. if row:
  562. reads.append(_whole_record_read("card", "取数取舍记录", "script_build_data_decision", row))
  563. elif role in {"domain-output", "candidate-output"} and raw_branch:
  564. reads.append(_whole_record_read("card", "实现方案记录", "script_build_branch", raw_branch))
  565. if role == "domain-output":
  566. for row in bundle.get("domainInfo") or []:
  567. if _integer(row.get("branch_id")) == branch_id:
  568. reads.append(_whole_record_read("card", "领域事实", "script_build_domain_info", row))
  569. elif role == "multipath-decision":
  570. row = _find(bundle.get("multipathDecisions"), "id", _suffix_int(module["id"]))
  571. if row:
  572. reads.append(_whole_record_read("card", "多方案决定", "script_build_multipath_decision", row))
  573. elif role in {"round-evaluation", "round-result"} and raw_round:
  574. reads.append(_whole_record_read("card", "轮次记录", "script_build_round", raw_round))
  575. _append_related_events(reads, module, bundle, "card", "轮次运行事件")
  576. kind = module["kind"]
  577. payload = module["payload"]
  578. if kind == "round-summary" and raw_round:
  579. reads.append(_whole_record_read("card", "折叠态轮次记录", "script_build_round", raw_round))
  580. elif kind == "branch-summary" and raw_branch:
  581. reads.append(_whole_record_read("card", "折叠态方案记录", "script_build_branch", raw_branch))
  582. elif kind.startswith("retrieval") or kind == "query-attempt":
  583. event_ids = _event_ids_from_payload(payload)
  584. for event_id in event_ids:
  585. event = next((item for item in bundle.get("events") or [] if _integer(item.get("id")) == event_id), None)
  586. if event:
  587. reads.append(_event_summary_read(event, "card", "取数运行事件", "event", event, "calculation-input"))
  588. elif kind == "review":
  589. _append_related_events(reads, module, bundle, "card", "评审事件")
  590. elif kind == "final-result":
  591. reads.append(_whole_record_read("card", "构建记录", "script_build_record", bundle.get("record") or {}))
  592. reads.append(_whole_record_read("card", "当前主脚本快照", "script_build_artifact", bundle.get("currentArtifact") or {}))
  593. if not reads:
  594. reads.append(
  595. {
  596. "consumer": "module",
  597. "label": "来源未确认",
  598. "disposition": "unconfirmed",
  599. "relation": "unconfirmed",
  600. "source": {"type": "unknown", "sourceKind": "unknown"},
  601. "fieldPath": None,
  602. "value": "当前审计规则无法可靠确认这个模块的字段来源。",
  603. "characters": 24,
  604. "context": None,
  605. }
  606. )
  607. return reads
  608. def _record_read(consumer: str, label: str, table: str, row: dict[str, Any], field: str) -> dict[str, Any]:
  609. value = row.get(field)
  610. return {
  611. "consumer": consumer,
  612. "label": label,
  613. "disposition": "selected",
  614. "relation": (
  615. "projected"
  616. if consumer == "card"
  617. else "exact" if isinstance(value, str) else "structured"
  618. ),
  619. "source": {
  620. "type": "business-record",
  621. "sourceKind": "database",
  622. "table": table,
  623. "recordId": row.get("id"),
  624. },
  625. "fieldPath": field,
  626. "value": value,
  627. "characters": _characters(value),
  628. "context": row,
  629. }
  630. def _whole_record_read(consumer: str, label: str, table: str, row: dict[str, Any]) -> dict[str, Any]:
  631. return {
  632. "consumer": consumer,
  633. "label": label,
  634. "disposition": "selected",
  635. "relation": (
  636. "combined"
  637. if table == "script_build_artifact"
  638. else "projected" if consumer == "card" else "exact-record"
  639. ),
  640. "source": {
  641. "type": "business-record",
  642. "sourceKind": "database",
  643. "table": table,
  644. "recordId": row.get("id"),
  645. },
  646. "fieldPath": "$",
  647. "value": row,
  648. "characters": _characters(row),
  649. "context": row,
  650. }
  651. def _event_summary_read(event: dict[str, Any], consumer: str, label: str, field_path: str, value: Any, disposition: str) -> dict[str, Any]:
  652. return {
  653. "consumer": consumer,
  654. "label": label,
  655. "disposition": disposition,
  656. "relation": (
  657. "calculation-input"
  658. if disposition == "calculation-input"
  659. else "exact" if isinstance(value, str) else "structured"
  660. ),
  661. "source": {
  662. "type": "runtime-event",
  663. "sourceKind": "database",
  664. "table": "script_build_event + script_build_event_body",
  665. "eventId": event.get("id"),
  666. "eventName": event.get("event_name"),
  667. },
  668. "fieldPath": field_path,
  669. "value": value,
  670. "characters": _characters(value),
  671. "context": None,
  672. }
  673. def _event_read(event: dict[str, Any], consumer: str, label: str, disposition: str) -> dict[str, Any]:
  674. return {
  675. "consumer": consumer,
  676. "label": label,
  677. "disposition": disposition,
  678. "relation": "exact-record",
  679. "source": {
  680. "type": "runtime-event",
  681. "sourceKind": "database",
  682. "table": "script_build_event + script_build_event_body",
  683. "eventId": event.get("id"),
  684. "eventName": event.get("event_name"),
  685. },
  686. "fieldPath": "$",
  687. "value": event,
  688. "characters": _characters(event),
  689. "context": None,
  690. }
  691. def _append_related_events(reads: list[dict[str, Any]], module: dict[str, Any], bundle: dict[str, Any], consumer: str, label: str) -> None:
  692. for event_id in _event_ids_from_payload(module.get("payload") or {}):
  693. event = next((item for item in bundle.get("events") or [] if _integer(item.get("id")) == event_id), None)
  694. if event:
  695. reads.append(_event_summary_read(event, consumer, label, "event", event, "calculation-input"))
  696. def _source_from_technical(key: str, value: Any) -> dict[str, Any]:
  697. event_id = None
  698. table = None
  699. if isinstance(value, dict):
  700. event_id = _integer(value.get("eventId") or value.get("id")) if "event" in key.lower() else None
  701. table = value.get("table")
  702. return {
  703. "type": "inspector-projection",
  704. "sourceKind": "function",
  705. "functionName": "Inspector 详情组装",
  706. "table": table,
  707. "eventId": event_id,
  708. "technicalGroup": key,
  709. }
  710. def _event_ids(module: dict[str, Any], reads: list[dict[str, Any]]) -> list[int]:
  711. values = _event_ids_from_payload(module.get("payload") or {})
  712. values.extend(
  713. _integer((item.get("source") or {}).get("eventId"))
  714. for item in reads
  715. )
  716. return sorted({value for value in values if value is not None})
  717. def _event_ids_from_payload(value: Any) -> list[int]:
  718. result: list[int] = []
  719. if isinstance(value, dict):
  720. for key, nested in value.items():
  721. if key in {"eventId", "implementerEventId", "eventRef"}:
  722. parsed = _integer(nested)
  723. if parsed is None:
  724. parsed = _suffix_int(nested)
  725. if parsed is not None:
  726. result.append(parsed)
  727. elif key == "detailRef" and str(nested or "").startswith("event:"):
  728. parsed = _suffix_int(nested)
  729. if parsed is not None:
  730. result.append(parsed)
  731. elif key in {"technicalRefs", "runtimeRevisionRefs"} and isinstance(nested, list):
  732. result.extend(_suffix_int(item) for item in nested if _suffix_int(item) is not None)
  733. elif key not in {"card", "summary", "body", "content_snapshot", "candidate_snapshot"}:
  734. result.extend(_event_ids_from_payload(nested))
  735. elif isinstance(value, list):
  736. for item in value:
  737. result.extend(_event_ids_from_payload(item))
  738. return sorted({item for item in result if item is not None})
  739. def _public_read(
  740. value: dict[str, Any], use_by_consumer: dict[str, list[str]]
  741. ) -> dict[str, Any]:
  742. public = {key: item for key, item in value.items() if key != "context"}
  743. source = value.get("source") or {}
  744. public["sourceKey"] = _source_key(source)
  745. disposition = str(value.get("disposition") or "")
  746. relation = str(value.get("relation") or "")
  747. public["sourcePriority"] = {
  748. "selected": "已采用来源",
  749. "fallback": "备用来源",
  750. "calculation-input": "计算输入",
  751. "unconfirmed": "来源未确认",
  752. "missing": "来源缺失",
  753. }.get(disposition, "已读取来源")
  754. public["transformation"] = {
  755. "exact": "直接摘取",
  756. "exact-record": "完整记录读取",
  757. "structured": "结构化字段读取",
  758. "projected": "展示处理",
  759. "combined": "多来源重组",
  760. "calculation-input": "参与计算",
  761. "unconfirmed": "无法确认",
  762. }.get(relation, relation or "未标记")
  763. ratio = _usage_ratio(
  764. value.get("value"),
  765. use_by_consumer.get(str(value.get("consumer") or ""), []),
  766. relation,
  767. disposition,
  768. )
  769. public["usageRatio"] = (
  770. {
  771. "calculable": True,
  772. "value": ratio,
  773. "label": f"{ratio * 100:.1f}%",
  774. }
  775. if ratio is not None
  776. else {
  777. "calculable": False,
  778. "value": None,
  779. "label": "不可直接计算",
  780. }
  781. )
  782. return public
  783. def _annotate_lineage(
  784. module_id: str,
  785. reads: list[dict[str, Any]],
  786. contexts: list[dict[str, Any]],
  787. ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
  788. """Attach field-level pointers without inventing lineage for derived values."""
  789. contexts_by_source: dict[str, list[dict[str, Any]]] = {}
  790. for context in contexts:
  791. contexts_by_source.setdefault(str(context.get("sourceKey") or ""), []).append(context)
  792. for index, read in enumerate(reads):
  793. source_key = _source_key(read.get("source") or {})
  794. identity = "|".join(
  795. (
  796. module_id,
  797. str(read.get("consumer") or "module"),
  798. source_key,
  799. str(read.get("fieldPath") or "$"),
  800. str(read.get("label") or "data"),
  801. str(index),
  802. )
  803. )
  804. read_id = f"read:{sha1(identity.encode('utf-8')).hexdigest()[:14]}"
  805. read["readId"] = read_id
  806. refs: list[dict[str, Any]] = []
  807. for context in contexts_by_source.get(source_key, []):
  808. context_path, relation = _lineage_path(read, context)
  809. refs.append(
  810. {
  811. "contextId": context.get("id"),
  812. "sourceKey": source_key,
  813. "contextFieldPath": context_path,
  814. "relation": relation,
  815. "exact": relation in {"direct", "normalized-alias", "whole-record"},
  816. }
  817. )
  818. read["contextRefs"] = refs
  819. read_ids_by_context: dict[str, list[str]] = {}
  820. paths_by_context: dict[str, list[str]] = {}
  821. for read in reads:
  822. for ref in read.get("contextRefs") or []:
  823. context_id = str(ref.get("contextId") or "")
  824. if not context_id:
  825. continue
  826. read_ids_by_context.setdefault(context_id, []).append(str(read["readId"]))
  827. field_path = str(ref.get("contextFieldPath") or "$")
  828. paths_by_context.setdefault(context_id, []).append(field_path)
  829. for context in contexts:
  830. context_id = str(context.get("id") or "")
  831. context["readIds"] = list(dict.fromkeys(read_ids_by_context.get(context_id, [])))
  832. mapped_paths = list(dict.fromkeys(paths_by_context.get(context_id, [])))
  833. if mapped_paths:
  834. context["readPaths"] = mapped_paths
  835. return reads, contexts
  836. def _lineage_path(
  837. read: dict[str, Any], context: dict[str, Any]
  838. ) -> tuple[str, str]:
  839. field_path = str(read.get("fieldPath") or "$")
  840. content = context.get("content")
  841. source_kind = str((read.get("source") or {}).get("sourceKind") or "")
  842. if source_kind == "function":
  843. return "$", "transformed"
  844. if field_path in {"$", "event"}:
  845. return "$", "whole-record"
  846. candidates = [field_path]
  847. if field_path.startswith("inputData."):
  848. suffix = field_path.removeprefix("inputData.")
  849. candidates.append(f"eventBody.input_content.{suffix}")
  850. elif field_path == "inputData":
  851. candidates.append("eventBody.input_content")
  852. if field_path.startswith("agentOutputData."):
  853. suffix = field_path.removeprefix("agentOutputData.")
  854. candidates.append(f"eventBody.output_content.{suffix}")
  855. elif field_path in {"agentOutputData", "outputPreview", "output_preview"}:
  856. candidates.append("eventBody.output_content")
  857. for candidate in candidates:
  858. if _value_at_path(content, candidate) is not _MISSING:
  859. relation = "direct" if candidate == field_path else "normalized-alias"
  860. return candidate, relation
  861. return "$", "record-only"
  862. _MISSING = object()
  863. def _value_at_path(value: Any, field_path: str) -> Any:
  864. if not field_path or field_path == "$":
  865. return value
  866. current = value
  867. for part in field_path.removeprefix("$.").split("."):
  868. if isinstance(current, str):
  869. text = current.strip()
  870. if text[:1] in {"{", "["}:
  871. try:
  872. current = json.loads(text)
  873. except (TypeError, ValueError, json.JSONDecodeError):
  874. return _MISSING
  875. if not isinstance(current, dict) or part not in current:
  876. return _MISSING
  877. current = current[part]
  878. return current
  879. def _annotate_usage_areas(
  880. reads: list[dict[str, Any]], inspector: dict[str, Any] | None
  881. ) -> list[dict[str, Any]]:
  882. has_business = bool(
  883. (inspector or {}).get("businessSections") or (inspector or {}).get("blocks")
  884. )
  885. has_changes = bool((inspector or {}).get("changes"))
  886. has_technical = isinstance((inspector or {}).get("technical"), dict)
  887. for read in reads:
  888. consumer = read.get("consumer")
  889. field_path = str(read.get("fieldPath") or "")
  890. disposition = read.get("disposition")
  891. areas: list[str] = []
  892. if disposition == "fallback":
  893. read["usageAreas"] = areas
  894. continue
  895. if consumer == "card":
  896. areas.append("卡片")
  897. if has_business:
  898. areas.append("Inspector 业务详情")
  899. if has_changes:
  900. areas.append("Inspector 产出与改动")
  901. elif consumer == "inspector":
  902. areas.append(
  903. "Inspector 技术详情"
  904. if field_path.startswith("technical.")
  905. else "Inspector 业务详情"
  906. )
  907. elif consumer == "module":
  908. areas.append("模块处理")
  909. read["usageAreas"] = areas
  910. if has_technical:
  911. technical_refs = {
  912. _underlying_record_key(read.get("source") or {})
  913. for read in reads
  914. if str(read.get("fieldPath") or "").startswith("technical.")
  915. }
  916. technical_refs.discard(None)
  917. for read in reads:
  918. source = read.get("source") or {}
  919. if (
  920. source.get("sourceKind") == "database"
  921. and _underlying_record_key(source) in technical_refs
  922. and read.get("disposition") != "fallback"
  923. and "Inspector 技术详情" not in read["usageAreas"]
  924. ):
  925. read["usageAreas"].append("Inspector 技术详情")
  926. return reads
  927. def _underlying_record_key(source: dict[str, Any]) -> str | None:
  928. event_id = _integer(source.get("eventId"))
  929. if event_id is not None:
  930. return f"event:{event_id}"
  931. table = source.get("table")
  932. record_id = source.get("recordId")
  933. if table and record_id is not None:
  934. return f"record:{table}:{record_id}"
  935. return None
  936. def _source_key(source: dict[str, Any]) -> str:
  937. kind = str(source.get("sourceKind") or "unknown")
  938. underlying = _underlying_record_key(source)
  939. if kind == "database" and underlying:
  940. return f"database:{underlying}"
  941. if kind == "log":
  942. return f"log:event:{source.get('eventId') or 'unknown'}"
  943. if kind == "function":
  944. group = source.get("technicalGroup") or source.get("functionName") or "detail"
  945. event = source.get("eventId")
  946. return f"function:{group}:{event or 'module'}"
  947. return f"unknown:{source.get('type') or 'source'}"
  948. def _usage_ratio(
  949. value: Any, snippets: list[str], relation: str, disposition: str
  950. ) -> float | None:
  951. if (
  952. disposition != "selected"
  953. or relation != "exact"
  954. or not isinstance(value, str)
  955. or not value
  956. ):
  957. return None
  958. normalized_source = value.strip()
  959. if not normalized_source:
  960. return None
  961. candidates = []
  962. for snippet in snippets:
  963. normalized = snippet.strip().removesuffix("…").rstrip()
  964. if len(normalized) >= 8 and normalized in normalized_source:
  965. candidates.append(normalized)
  966. if not candidates:
  967. return None
  968. return min(1.0, len(max(candidates, key=len)) / len(normalized_source))
  969. def _dedupe_reads(reads: list[dict[str, Any]]) -> list[dict[str, Any]]:
  970. seen: set[tuple[str, str, str, str]] = set()
  971. result = []
  972. for item in reads:
  973. source = item.get("source") or {}
  974. key = (str(item.get("consumer")), str(source.get("table")), str(source.get("eventId") or source.get("recordId")), str(item.get("fieldPath")))
  975. if key in seen:
  976. continue
  977. seen.add(key)
  978. result.append(item)
  979. return result
  980. def _dedupe_contexts(contexts: list[dict[str, Any]]) -> list[dict[str, Any]]:
  981. result = []
  982. seen = set()
  983. for item in contexts:
  984. key = item.get("id")
  985. if key in seen:
  986. continue
  987. seen.add(key)
  988. result.append(item)
  989. return result
  990. def _unique_modules(modules: list[dict[str, Any]]) -> list[dict[str, Any]]:
  991. seen = set()
  992. result = []
  993. for item in modules:
  994. if item["id"] in seen:
  995. continue
  996. seen.add(item["id"])
  997. result.append(item)
  998. return result
  999. def _find(values: Any, key: str, expected: Any) -> dict[str, Any] | None:
  1000. if expected is None:
  1001. return None
  1002. return next((item for item in values or [] if _integer(item.get(key)) == _integer(expected)), None)
  1003. def _characters(value: Any) -> int:
  1004. if value is None:
  1005. return 0
  1006. if isinstance(value, str):
  1007. return len(value)
  1008. try:
  1009. return len(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str, separators=(",", ":")))
  1010. except (TypeError, ValueError):
  1011. return len(str(value))
  1012. def _strings(value: Any) -> list[str]:
  1013. result: list[str] = []
  1014. if isinstance(value, str):
  1015. if len(value.strip()) >= 8:
  1016. result.append(value.strip())
  1017. elif isinstance(value, dict):
  1018. for nested in value.values():
  1019. result.extend(_strings(nested))
  1020. elif isinstance(value, list):
  1021. for nested in value:
  1022. result.extend(_strings(nested))
  1023. return sorted(set(result), key=len, reverse=True)
  1024. def _matching_snippets(value: Any, snippets: list[str]) -> list[str]:
  1025. text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
  1026. return [snippet for snippet in snippets if snippet in text][:20]
  1027. def _technical_label(key: str) -> str:
  1028. return {
  1029. "source": "数据来源与定位",
  1030. "association": "关联方式与完整性",
  1031. "input": "原始输入",
  1032. "output": "原始输出",
  1033. "cost": "耗时与成本",
  1034. "record": "原始业务记录",
  1035. "event": "原始运行事件",
  1036. "taskEvent": "实现任务事件",
  1037. "taskSource": "任务来源判定",
  1038. "rawEvent": "运行事件摘要",
  1039. "branch": "实现方案记录",
  1040. "round": "轮次记录",
  1041. }.get(key, key)
  1042. def _context_id(source: dict[str, Any], field_path: Any) -> str:
  1043. return ":".join(str(item) for item in (source.get("table") or source.get("type"), source.get("recordId") or source.get("eventId") or "record", field_path or "$") )
  1044. def _completeness(notice: str | None, reads: list[dict[str, Any]], contexts: list[dict[str, Any]]) -> dict[str, Any]:
  1045. missing = [item for item in contexts if item.get("completeness") in {"missing", "body-missing"}]
  1046. unconfirmed = [item for item in reads if item.get("disposition") == "unconfirmed"]
  1047. return {
  1048. "state": "partial" if notice or missing or unconfirmed else "complete",
  1049. "inspectorAvailable": notice is None,
  1050. "sourceCount": len(reads),
  1051. "contextCount": len(contexts),
  1052. "missingContextCount": len(missing),
  1053. "unconfirmedSourceCount": len(unconfirmed),
  1054. "notice": notice,
  1055. "redacted": True,
  1056. }
  1057. def _suffix_int(value: Any) -> int | None:
  1058. text = str(value or "")
  1059. try:
  1060. return int(text.rsplit(":", 1)[-1])
  1061. except (TypeError, ValueError):
  1062. return None
  1063. def _integer(value: Any) -> int | None:
  1064. try:
  1065. return int(value) if value is not None else None
  1066. except (TypeError, ValueError):
  1067. return None