main.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. from __future__ import annotations
  2. import os
  3. import time
  4. from threading import Lock
  5. from typing import Optional
  6. from fastapi import FastAPI, HTTPException, Query
  7. from fastapi.middleware.cors import CORSMiddleware
  8. from fastapi.middleware.gzip import GZipMiddleware
  9. from .execution_builder import ExecutionViewBuilder
  10. from .execution_view_payload import compact_execution_view
  11. from .inspector_projection import (
  12. project_activity_detail,
  13. project_artifact_detail,
  14. project_event_detail,
  15. project_round_detail,
  16. )
  17. from .providers import LocalDatabaseProvider
  18. from .repositories import ActivityNotFound, BuildNotFound
  19. from .runtime_bridge import database_host_override, runtime_dir
  20. from .prompt_projection import project_prompt
  21. from .sanitizer import sanitize
  22. from .module_audit import router as module_audit_router
  23. from .module_audit.repository import AuditRepository
  24. from .card_details import CardBusinessDataProjector, CardDataNotFound
  25. from .card_details.common import find_detail
  26. from .source_lineage import InspectorWorkbenchProjector, InspectorViewNotFound
  27. app = FastAPI(
  28. title="Script Build Runtime Visualization",
  29. description="Read-only V8 projection of script-build decisions and structured runtime events.",
  30. version="8.0.0",
  31. )
  32. origins = [
  33. value.strip()
  34. for value in os.getenv(
  35. "VISUALIZATION_CORS_ORIGINS",
  36. "http://127.0.0.1:3008,http://localhost:3008",
  37. ).split(",")
  38. if value.strip()
  39. ]
  40. app.add_middleware(
  41. CORSMiddleware,
  42. allow_origins=origins,
  43. allow_credentials=False,
  44. allow_methods=["GET"],
  45. allow_headers=["Accept", "Content-Type"],
  46. )
  47. app.add_middleware(GZipMiddleware, minimum_size=1_000, compresslevel=5)
  48. app.include_router(module_audit_router)
  49. provider = LocalDatabaseProvider()
  50. builder = ExecutionViewBuilder()
  51. card_data_projector = CardBusinessDataProjector()
  52. inspector_workbench_projector = InspectorWorkbenchProjector()
  53. audit_repository = AuditRepository()
  54. _inspector_cache: dict[tuple[int, str], tuple[float, dict]] = {}
  55. _inspector_cache_lock = Lock()
  56. _build_view_cache: dict[int, tuple[float, dict, dict]] = {}
  57. _build_view_cache_lock = Lock()
  58. def _load_bundle_and_view(script_build_id: int) -> tuple[dict, dict]:
  59. """Share one read-only run snapshot across Inspector source requests."""
  60. now = time.monotonic()
  61. with _build_view_cache_lock:
  62. cached = _build_view_cache.get(script_build_id)
  63. if cached and cached[0] > now:
  64. return cached[1], cached[2]
  65. # The first parallel Inspector burst for a run must not issue the same
  66. # ORM bundle query from several worker threads. Build the immutable
  67. # snapshot once while holding the short per-cache critical section.
  68. bundle = provider.load_bundle(script_build_id)
  69. view = builder.from_bundle(bundle)
  70. terminal = str((bundle.get("record") or {}).get("status") or "").lower() in {
  71. "success", "completed", "partial", "failed", "stopped", "cancelled"
  72. }
  73. ttl = 300.0 if terminal else 3.0
  74. _build_view_cache[script_build_id] = (now + ttl, bundle, view)
  75. if len(_build_view_cache) > 16:
  76. expired = [key for key, value in _build_view_cache.items() if value[0] <= now]
  77. for key in expired or list(_build_view_cache)[:4]:
  78. _build_view_cache.pop(key, None)
  79. return bundle, view
  80. def execution_view(script_build_id: int) -> dict:
  81. _bundle, view = _load_bundle_and_view(script_build_id)
  82. return compact_execution_view(view)
  83. def _safe_load_log_windows(
  84. script_build_id: int,
  85. event_details: dict[int, dict],
  86. ) -> dict[int, str]:
  87. try:
  88. anchors = [
  89. (
  90. event_id,
  91. str(event.get("msg_id") or "").strip(),
  92. int(event.get("event_seq")) if event.get("event_seq") is not None else None,
  93. )
  94. for event_id, event in event_details.items()
  95. if str(event.get("msg_id") or "").strip()
  96. ]
  97. return audit_repository.load_event_log_windows(script_build_id, anchors)
  98. except Exception:
  99. # Log context is supplemental; DB/Event lineage must remain usable
  100. # when the historical log table is absent or temporarily unavailable.
  101. return {}
  102. @app.get("/api/health")
  103. def health():
  104. source_ok = False
  105. source_error = None
  106. try:
  107. provider.list_builds(limit=1)
  108. source_ok = True
  109. except Exception as exc: # Health remains inspectable when DB is unavailable.
  110. source_error = f"{type(exc).__name__}: {exc}"
  111. return {
  112. "ok": True,
  113. "source": {
  114. "mode": provider.mode,
  115. "available": source_ok,
  116. "error": sanitize(source_error),
  117. },
  118. "runtimeDir": str(runtime_dir()),
  119. "readOnly": True,
  120. "databaseHostOverrideEnabled": bool(database_host_override()),
  121. "databaseSessionReadOnly": True,
  122. }
  123. @app.get("/api/capabilities")
  124. def capabilities():
  125. return {
  126. "schemaVersion": "8",
  127. "sourceMode": provider.mode,
  128. "businessSources": [
  129. "script_build_record",
  130. "script_build_round",
  131. "script_build_branch",
  132. "script_build_data_decision",
  133. "script_build_multipath_decision",
  134. "script_build_domain_info",
  135. "script_build_paragraph",
  136. "script_build_element",
  137. "script_build_paragraph_element",
  138. ],
  139. "runtimeSupplement": ["script_build_event", "script_build_event_body"],
  140. "readOnly": True,
  141. "databaseHostOverrideEnabled": bool(database_host_override()),
  142. "databaseSessionReadOnly": True,
  143. }
  144. @app.get("/api/script-builds")
  145. def list_script_builds(
  146. limit: int = Query(30, ge=1, le=100), status: Optional[str] = None
  147. ):
  148. try:
  149. return {
  150. "items": provider.list_builds(limit=limit, status=status),
  151. "sourceMode": provider.mode,
  152. "warnings": [],
  153. }
  154. except Exception as exc:
  155. raise HTTPException(
  156. status_code=503,
  157. detail=f"读取构建列表失败:{type(exc).__name__}: {sanitize(str(exc))}",
  158. ) from exc
  159. @app.get("/api/script-builds/{script_build_id}/execution-view")
  160. def get_execution_view(script_build_id: int):
  161. try:
  162. return execution_view(script_build_id)
  163. except BuildNotFound as exc:
  164. raise HTTPException(status_code=404, detail=str(exc)) from exc
  165. except Exception as exc:
  166. raise HTTPException(
  167. status_code=503,
  168. detail=f"读取构建失败:{type(exc).__name__}: {sanitize(str(exc))}",
  169. ) from exc
  170. @app.get("/api/script-builds/{script_build_id}/card-data/{detail_ref:path}")
  171. def get_card_data(script_build_id: int, detail_ref: str):
  172. try:
  173. bundle, view = _load_bundle_and_view(script_build_id)
  174. return sanitize(card_data_projector.project(
  175. detail_ref,
  176. bundle=bundle,
  177. view=view,
  178. load_event=lambda event_id: provider.load_event_detail(
  179. script_build_id, event_id
  180. ),
  181. ))
  182. except BuildNotFound as exc:
  183. raise HTTPException(status_code=404, detail=str(exc)) from exc
  184. except (ActivityNotFound, CardDataNotFound) as exc:
  185. raise HTTPException(status_code=404, detail=str(exc)) from exc
  186. except Exception as exc:
  187. raise HTTPException(
  188. status_code=503,
  189. detail=f"读取卡片数据流失败:{type(exc).__name__}: {sanitize(str(exc))}",
  190. ) from exc
  191. @app.get("/api/script-builds/{script_build_id}/inspector-view/{detail_ref:path}")
  192. def get_inspector_view(script_build_id: int, detail_ref: str):
  193. """Return business, evidence and raw runtime columns in one response."""
  194. try:
  195. key = (script_build_id, detail_ref)
  196. now = time.monotonic()
  197. with _inspector_cache_lock:
  198. cached = _inspector_cache.get(key)
  199. if cached and cached[0] > now:
  200. return cached[1]
  201. bundle, view = _load_bundle_and_view(script_build_id)
  202. record = bundle.get("record") or {}
  203. event_ids = inspector_workbench_projector.related_event_ids(
  204. detail_ref,
  205. bundle=bundle,
  206. view=view,
  207. )
  208. event_details = provider.load_event_details(script_build_id, event_ids)
  209. # Event bodies have already been redacted and bounded by the
  210. # repository. Do not impose a second lower text limit here, which
  211. # would silently invalidate selectors without updating completeness.
  212. result = sanitize(
  213. inspector_workbench_projector.project(
  214. detail_ref,
  215. script_build_id=script_build_id,
  216. bundle=bundle,
  217. view=view,
  218. event_details=event_details,
  219. log_contents=_safe_load_log_windows(script_build_id, event_details),
  220. ),
  221. max_text=None,
  222. )
  223. terminal = str(record.get("status") or "").lower() in {
  224. "success", "completed", "partial", "failed", "stopped", "cancelled"
  225. }
  226. ttl = 300.0 if terminal else 3.0
  227. with _inspector_cache_lock:
  228. _inspector_cache[key] = (now + ttl, result)
  229. if len(_inspector_cache) > 256:
  230. expired = [item for item, value in _inspector_cache.items() if value[0] <= now]
  231. for item in expired or list(_inspector_cache)[:64]:
  232. _inspector_cache.pop(item, None)
  233. return result
  234. except BuildNotFound as exc:
  235. raise HTTPException(status_code=404, detail=str(exc)) from exc
  236. except (ActivityNotFound, InspectorViewNotFound) as exc:
  237. raise HTTPException(status_code=404, detail=str(exc)) from exc
  238. except Exception as exc:
  239. raise HTTPException(
  240. status_code=503,
  241. detail=f"读取 Inspector 数据来源失败:{type(exc).__name__}: {sanitize(str(exc))}",
  242. ) from exc
  243. @app.get("/api/script-builds/{script_build_id}/rounds/{round_index}")
  244. def get_round_detail(script_build_id: int, round_index: int):
  245. try:
  246. bundle = provider.load_bundle(script_build_id)
  247. view = builder.from_bundle(bundle)
  248. except BuildNotFound as exc:
  249. raise HTTPException(status_code=404, detail=str(exc)) from exc
  250. except Exception as exc:
  251. raise HTTPException(
  252. status_code=503,
  253. detail=f"读取轮次失败:{type(exc).__name__}: {sanitize(str(exc))}",
  254. ) from exc
  255. round_ = next(
  256. (
  257. item
  258. for item in view.get("rounds", [])
  259. if int(item.get("roundIndex") or 0) == round_index
  260. ),
  261. None,
  262. )
  263. if round_ is None:
  264. raise HTTPException(status_code=404, detail=f"未找到第 {round_index} 轮")
  265. return project_round_detail(script_build_id, round_, bundle)
  266. @app.get("/api/script-builds/{script_build_id}/activities/{activity_id:path}")
  267. def get_activity_detail(script_build_id: int, activity_id: str):
  268. if activity_id.startswith("event:"):
  269. try:
  270. bundle, view = _load_bundle_and_view(script_build_id)
  271. event_id = int(activity_id.rsplit(":", 1)[1])
  272. event_detail = provider.load_event_detail(script_build_id, event_id)
  273. try:
  274. return project_activity_detail(
  275. script_build_id,
  276. activity_id,
  277. view,
  278. bundle,
  279. event_detail=event_detail,
  280. )
  281. except KeyError:
  282. pass
  283. return project_event_detail(
  284. script_build_id,
  285. event_detail,
  286. run_status=(view.get("header") or {}).get("status"),
  287. )
  288. except (ValueError, ActivityNotFound) as exc:
  289. raise HTTPException(status_code=404, detail=f"未找到活动 {activity_id}") from exc
  290. except Exception as exc:
  291. raise HTTPException(
  292. status_code=503,
  293. detail=f"读取运行事件失败:{type(exc).__name__}: {sanitize(str(exc))}",
  294. ) from exc
  295. try:
  296. bundle, view = _load_bundle_and_view(script_build_id)
  297. event_detail = None
  298. if activity_id.startswith("retrieval-agent:"):
  299. run = find_detail(view, activity_id)
  300. event_id = int((run or {}).get("eventId") or 0)
  301. if event_id:
  302. event_detail = provider.load_event_detail(script_build_id, event_id)
  303. return project_activity_detail(
  304. script_build_id,
  305. activity_id,
  306. view,
  307. bundle,
  308. event_detail=event_detail,
  309. )
  310. except BuildNotFound as exc:
  311. raise HTTPException(status_code=404, detail=str(exc)) from exc
  312. except KeyError as exc:
  313. raise HTTPException(status_code=404, detail=f"未找到活动 {activity_id}") from exc
  314. except Exception as exc:
  315. raise HTTPException(
  316. status_code=503,
  317. detail=f"读取活动详情失败:{type(exc).__name__}: {sanitize(str(exc))}",
  318. ) from exc
  319. @app.get("/api/script-builds/{script_build_id}/decision-prompts/{prompt_ref:path}")
  320. def get_decision_prompt(script_build_id: int, prompt_ref: str):
  321. try:
  322. return project_prompt(
  323. provider.load_prompt_context(script_build_id, prompt_ref)
  324. )
  325. except BuildNotFound as exc:
  326. raise HTTPException(status_code=404, detail=str(exc)) from exc
  327. except ActivityNotFound as exc:
  328. raise HTTPException(status_code=404, detail=str(exc)) from exc
  329. except Exception as exc:
  330. raise HTTPException(
  331. status_code=503,
  332. detail=f"读取 Agent 提示词失败:{type(exc).__name__}: {sanitize(str(exc))}",
  333. ) from exc
  334. @app.get("/api/script-builds/{script_build_id}/artifacts/{snapshot_ref:path}")
  335. def get_artifact(script_build_id: int, snapshot_ref: str):
  336. if snapshot_ref in {"base:current", "artifact:base:current"}:
  337. try:
  338. artifact = provider.load_current_artifact(script_build_id)
  339. except BuildNotFound as exc:
  340. raise HTTPException(status_code=404, detail=str(exc)) from exc
  341. except Exception as exc:
  342. raise HTTPException(
  343. status_code=503,
  344. detail=f"读取创作表失败:{type(exc).__name__}: {sanitize(str(exc))}",
  345. ) from exc
  346. return project_artifact_detail(
  347. script_build_id,
  348. "artifact:base:current",
  349. {
  350. **artifact,
  351. "artifactContext": {
  352. "type": "final",
  353. "versionKind": "current-base",
  354. "note": "当前保存的最终主脚本",
  355. },
  356. },
  357. )
  358. round_ref = snapshot_ref.removeprefix("artifact:").removeprefix("round:")
  359. if round_ref.isdigit() and "round:" in snapshot_ref:
  360. round_index = int(round_ref)
  361. try:
  362. bundle = provider.load_bundle(script_build_id)
  363. except BuildNotFound as exc:
  364. raise HTTPException(status_code=404, detail=str(exc)) from exc
  365. except Exception as exc:
  366. raise HTTPException(
  367. status_code=503,
  368. detail=f"读取本轮产出脚本表失败:{type(exc).__name__}: {sanitize(str(exc))}",
  369. ) from exc
  370. if not any(int(item.get("round_index") or 0) == round_index for item in bundle.get("rounds", [])):
  371. raise HTTPException(status_code=404, detail=f"未找到第 {round_index} 轮")
  372. return project_artifact_detail(
  373. script_build_id,
  374. f"artifact:round:{round_index}",
  375. {
  376. **(bundle.get("currentArtifact") or {}),
  377. "artifactContext": {
  378. "type": "round",
  379. "roundIndex": round_index,
  380. "versionKind": "current-base",
  381. "note": f"当前数据库未保存第 {round_index} 轮结束时的独立快照;这里展示当前保存的主脚本。",
  382. },
  383. },
  384. )
  385. try:
  386. bundle = provider.load_bundle(script_build_id)
  387. except BuildNotFound as exc:
  388. raise HTTPException(status_code=404, detail=str(exc)) from exc
  389. except Exception as exc:
  390. raise HTTPException(
  391. status_code=503,
  392. detail=f"读取创作表失败:{type(exc).__name__}: {sanitize(str(exc))}",
  393. ) from exc
  394. match = snapshot_ref.removeprefix("artifact:").removeprefix("branch:")
  395. if match.isdigit():
  396. branch = next(
  397. (
  398. item
  399. for item in bundle.get("branches", [])
  400. if int(item.get("branch_id") or 0) == int(match)
  401. ),
  402. None,
  403. )
  404. if branch:
  405. if str(branch.get("path_type") or "").strip() == "领域信息":
  406. raise HTTPException(
  407. status_code=404,
  408. detail="领域信息方案不产出候选脚本表",
  409. )
  410. candidate = branch.get("candidate_snapshot") or {}
  411. if not isinstance(candidate, dict) or not isinstance(candidate.get("snapshot"), dict):
  412. raise HTTPException(
  413. status_code=404,
  414. detail="该实现方案没有可用的候选脚本快照",
  415. )
  416. return project_artifact_detail(
  417. script_build_id,
  418. f"artifact:branch:{match}",
  419. {
  420. **candidate,
  421. "artifactContext": {
  422. "type": "candidate",
  423. "roundIndex": branch.get("round_index"),
  424. "branchId": branch.get("branch_id"),
  425. "branchStatus": branch.get("status"),
  426. "pathType": branch.get("path_type"),
  427. "versionKind": candidate.get("snapshotKind"),
  428. "historicalAccuracy": candidate.get("historicalAccuracy"),
  429. "currentProjectionAccuracy": candidate.get("currentProjectionAccuracy"),
  430. "note": candidate.get("note"),
  431. },
  432. },
  433. )
  434. raise HTTPException(status_code=404, detail=f"未找到创作表快照 {snapshot_ref}")