repositories.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797
  1. from __future__ import annotations
  2. import json
  3. from datetime import date, datetime
  4. from collections.abc import Mapping
  5. from threading import Lock
  6. from typing import Any
  7. from sqlalchemy import MetaData, Table
  8. from .runtime_bridge import load_runtime_modules, new_session, runtime_dir
  9. from .runtime_tool_catalog import RETRIEVAL_AGENT_LABELS
  10. from .retrieval_detail_projection import (
  11. QUERY_TOOL_NAMES,
  12. summarize_retrieval_output,
  13. )
  14. from .sanitizer import sanitize
  15. _DETAIL_BODY_MAX = 240_000
  16. class BuildNotFound(LookupError):
  17. pass
  18. class ActivityNotFound(LookupError):
  19. pass
  20. def _json_value(value: Any) -> Any:
  21. if isinstance(value, (datetime, date)):
  22. return value.isoformat()
  23. return sanitize(value)
  24. def row_dict(row: Any) -> dict[str, Any]:
  25. if row is None:
  26. return {}
  27. if isinstance(row, Mapping):
  28. return {str(key): _json_value(value) for key, value in row.items()}
  29. mapping = getattr(row, "_mapping", None)
  30. if mapping is not None:
  31. return {str(key): _json_value(value) for key, value in mapping.items()}
  32. return {
  33. column.name: _json_value(getattr(row, column.name))
  34. for column in row.__table__.columns
  35. }
  36. class ScriptBuildRepository:
  37. """The only database adapter used by V8.
  38. The adapter is deliberately query-only. Sessions come from a dedicated
  39. MySQL read-only pool and this class never flushes, commits or mutates ORM
  40. objects.
  41. """
  42. def __init__(self, session_factory=new_session):
  43. self._session_factory = session_factory
  44. self._metadata = MetaData()
  45. self._reflected_tables: dict[str, Table] = {}
  46. self._table_lock = Lock()
  47. def list_builds(
  48. self, *, limit: int = 30, status: str | None = None
  49. ) -> list[dict[str, Any]]:
  50. _, models = load_runtime_modules()
  51. session = self._session_factory()
  52. try:
  53. query = session.query(models.ScriptBuildRecord).filter(
  54. models.ScriptBuildRecord.is_deleted.is_(False)
  55. )
  56. if status:
  57. query = query.filter(models.ScriptBuildRecord.status == status)
  58. rows = (
  59. query.order_by(models.ScriptBuildRecord.id.desc())
  60. .limit(max(1, min(limit, 100)))
  61. .all()
  62. )
  63. return [self._build_summary(row) for row in rows]
  64. finally:
  65. session.close()
  66. def load_bundle(self, script_build_id: int) -> dict[str, Any]:
  67. _, models = load_runtime_modules()
  68. session = self._session_factory()
  69. try:
  70. record = (
  71. session.query(models.ScriptBuildRecord)
  72. .filter(models.ScriptBuildRecord.id == script_build_id)
  73. .first()
  74. )
  75. if record is None:
  76. raise BuildNotFound(f"未找到脚本构建 #{script_build_id}")
  77. rounds = (
  78. session.query(models.ScriptBuildRound)
  79. .filter(models.ScriptBuildRound.script_build_id == script_build_id)
  80. .order_by(models.ScriptBuildRound.round_index.asc())
  81. .all()
  82. )
  83. branches = (
  84. session.query(models.ScriptBuildBranch)
  85. .filter(models.ScriptBuildBranch.script_build_id == script_build_id)
  86. .order_by(
  87. models.ScriptBuildBranch.round_index.asc(),
  88. models.ScriptBuildBranch.branch_id.asc(),
  89. )
  90. .all()
  91. )
  92. data_decisions = (
  93. session.query(models.ScriptBuildDataDecision)
  94. .filter(
  95. models.ScriptBuildDataDecision.script_build_id == script_build_id,
  96. models.ScriptBuildDataDecision.branch_id > 0,
  97. )
  98. .order_by(
  99. models.ScriptBuildDataDecision.created_at.asc(),
  100. models.ScriptBuildDataDecision.id.asc(),
  101. )
  102. .all()
  103. )
  104. ignored_legacy_decisions = (
  105. session.query(models.ScriptBuildDataDecision)
  106. .filter(
  107. models.ScriptBuildDataDecision.script_build_id == script_build_id,
  108. models.ScriptBuildDataDecision.branch_id <= 0,
  109. )
  110. .count()
  111. )
  112. multipath_decisions = (
  113. session.query(models.ScriptBuildMultipathDecision)
  114. .filter(
  115. models.ScriptBuildMultipathDecision.script_build_id
  116. == script_build_id
  117. )
  118. .order_by(
  119. models.ScriptBuildMultipathDecision.created_at.asc(),
  120. models.ScriptBuildMultipathDecision.id.asc(),
  121. )
  122. .all()
  123. )
  124. domain_info = (
  125. session.query(models.ScriptBuildDomainInfo)
  126. .filter(models.ScriptBuildDomainInfo.script_build_id == script_build_id)
  127. .order_by(
  128. models.ScriptBuildDomainInfo.created_at.asc(),
  129. models.ScriptBuildDomainInfo.id.asc(),
  130. )
  131. .all()
  132. )
  133. event_table = self._table(session, "script_build_event")
  134. events = (
  135. session.query(event_table)
  136. .filter(event_table.c.script_build_id == script_build_id)
  137. .order_by(
  138. event_table.c.event_seq.asc(),
  139. event_table.c.id.asc(),
  140. )
  141. .all()
  142. )
  143. event_payloads = [self._event_summary(row) for row in events]
  144. self._attach_light_event_bodies(session, event_payloads)
  145. paragraphs = (
  146. session.query(models.ScriptBuildParagraph)
  147. .filter(models.ScriptBuildParagraph.script_build_id == script_build_id)
  148. .order_by(
  149. models.ScriptBuildParagraph.branch_id.asc(),
  150. models.ScriptBuildParagraph.id.asc(),
  151. )
  152. .all()
  153. )
  154. elements = (
  155. session.query(models.ScriptBuildElement)
  156. .filter(models.ScriptBuildElement.script_build_id == script_build_id)
  157. .order_by(
  158. models.ScriptBuildElement.branch_id.asc(),
  159. models.ScriptBuildElement.id.asc(),
  160. )
  161. .all()
  162. )
  163. links = (
  164. session.query(models.ScriptBuildParagraphElement)
  165. .filter(
  166. models.ScriptBuildParagraphElement.script_build_id
  167. == script_build_id
  168. )
  169. .order_by(
  170. models.ScriptBuildParagraphElement.branch_id.asc(),
  171. models.ScriptBuildParagraphElement.id.asc(),
  172. )
  173. .all()
  174. )
  175. branch_payloads: list[dict[str, Any]] = []
  176. for branch in branches:
  177. payload = row_dict(branch)
  178. payload["candidate_snapshot"] = self._candidate_snapshot(
  179. branch, paragraphs=paragraphs, elements=elements, links=links
  180. )
  181. branch_payloads.append(payload)
  182. return {
  183. "record": row_dict(record),
  184. "rounds": [row_dict(row) for row in rounds],
  185. "branches": branch_payloads,
  186. "dataDecisions": [row_dict(row) for row in data_decisions],
  187. "multipathDecisions": [
  188. row_dict(row) for row in multipath_decisions
  189. ],
  190. "domainInfo": [row_dict(row) for row in domain_info],
  191. "events": event_payloads,
  192. "ignoredLegacyDataDecisionCount": int(
  193. ignored_legacy_decisions or 0
  194. ),
  195. "currentArtifact": self._snapshot_for_branch(
  196. 0, paragraphs, elements, links
  197. ),
  198. }
  199. finally:
  200. session.close()
  201. def load_current_artifact(self, script_build_id: int) -> dict[str, Any]:
  202. """Read only the active Base rows needed by the full script viewer."""
  203. _, models = load_runtime_modules()
  204. session = self._session_factory()
  205. try:
  206. exists = (
  207. session.query(models.ScriptBuildRecord.id)
  208. .filter(models.ScriptBuildRecord.id == script_build_id)
  209. .first()
  210. )
  211. if exists is None:
  212. raise BuildNotFound(f"未找到脚本构建 #{script_build_id}")
  213. paragraphs = (
  214. session.query(models.ScriptBuildParagraph)
  215. .filter(
  216. models.ScriptBuildParagraph.script_build_id == script_build_id,
  217. models.ScriptBuildParagraph.branch_id == 0,
  218. models.ScriptBuildParagraph.is_active.is_not(False),
  219. )
  220. .order_by(
  221. models.ScriptBuildParagraph.paragraph_index.asc(),
  222. models.ScriptBuildParagraph.id.asc(),
  223. )
  224. .all()
  225. )
  226. elements = (
  227. session.query(models.ScriptBuildElement)
  228. .filter(
  229. models.ScriptBuildElement.script_build_id == script_build_id,
  230. models.ScriptBuildElement.branch_id == 0,
  231. models.ScriptBuildElement.is_active.is_not(False),
  232. )
  233. .order_by(models.ScriptBuildElement.id.asc())
  234. .all()
  235. )
  236. paragraph_ids = [row.id for row in paragraphs]
  237. element_ids = [row.id for row in elements]
  238. links = []
  239. if paragraph_ids and element_ids:
  240. links = (
  241. session.query(models.ScriptBuildParagraphElement)
  242. .filter(
  243. models.ScriptBuildParagraphElement.script_build_id
  244. == script_build_id,
  245. models.ScriptBuildParagraphElement.branch_id == 0,
  246. models.ScriptBuildParagraphElement.paragraph_id.in_(paragraph_ids),
  247. models.ScriptBuildParagraphElement.element_id.in_(element_ids),
  248. )
  249. .order_by(models.ScriptBuildParagraphElement.id.asc())
  250. .all()
  251. )
  252. return self._snapshot_for_branch(0, paragraphs, elements, links)
  253. finally:
  254. session.close()
  255. def load_event_detail(
  256. self, script_build_id: int, event_id: int
  257. ) -> dict[str, Any]:
  258. values = self.load_event_details(script_build_id, [event_id])
  259. event = values.get(int(event_id))
  260. if event is None:
  261. raise ActivityNotFound(f"未找到运行事件 {event_id}")
  262. return event
  263. def load_event_details(
  264. self, script_build_id: int, event_ids: list[int]
  265. ) -> dict[int, dict[str, Any]]:
  266. """Batch-load full Event + Event Body records for one Inspector.
  267. A creative card can reference dozens of events. Loading the rows and
  268. bodies in two queries keeps the three-column endpoint independent of
  269. that cardinality.
  270. """
  271. wanted: list[int] = []
  272. for value in event_ids:
  273. try:
  274. event_id = int(value)
  275. except (TypeError, ValueError):
  276. continue
  277. if event_id > 0 and event_id not in wanted:
  278. wanted.append(event_id)
  279. wanted.sort()
  280. if not wanted:
  281. return {}
  282. session = self._session_factory()
  283. try:
  284. event_table = self._table(session, "script_build_event")
  285. events = (
  286. session.query(event_table)
  287. .filter(
  288. event_table.c.script_build_id == script_build_id,
  289. event_table.c.id.in_(wanted),
  290. )
  291. .order_by(event_table.c.event_seq.asc(), event_table.c.id.asc())
  292. .all()
  293. )
  294. body_table = self._table(session, "script_build_event_body")
  295. bodies = (
  296. session.query(body_table)
  297. .filter(
  298. body_table.c.script_build_id == script_build_id,
  299. body_table.c.event_id.in_(wanted),
  300. )
  301. .all()
  302. )
  303. body_by_event = {
  304. int(parsed["eventId"]): parsed
  305. for row in bodies
  306. for parsed in [self._event_body(row)]
  307. if parsed.get("eventId") is not None
  308. }
  309. result: dict[int, dict[str, Any]] = {}
  310. for row in events:
  311. payload = self._event_summary(row)
  312. event_id = int(payload["id"])
  313. parsed = body_by_event.get(event_id)
  314. payload["input"] = parsed.get("input") if parsed else None
  315. payload["output"] = parsed.get("output") if parsed else None
  316. result[event_id] = payload
  317. return result
  318. finally:
  319. session.close()
  320. def load_prompt_context(
  321. self, script_build_id: int, prompt_ref: str
  322. ) -> dict[str, Any]:
  323. """Read the exact delegated task plus the *current* prompt config.
  324. Prompt history is intentionally not guessed from event time: runtime
  325. events do not store a prompt snapshot or version reference.
  326. """
  327. ref = str(prompt_ref or "").strip()
  328. event: dict[str, Any] | None = None
  329. if ref.startswith("prompt:event:") or ref.startswith("event:"):
  330. try:
  331. event_id = int(ref.rsplit(":", 1)[1])
  332. except ValueError as exc:
  333. raise ActivityNotFound(f"无效的规则引用 {prompt_ref}") from exc
  334. event = self.load_event_detail(script_build_id, event_id)
  335. event_name = str(event.get("event_name") or "")
  336. elif ref in {"prompt:main", "main"}:
  337. event_name = "main"
  338. else:
  339. raise ActivityNotFound(f"未找到规则引用 {prompt_ref}")
  340. config = _prompt_config(event_name)
  341. task = None
  342. if event:
  343. side = event.get("input")
  344. content = side.get("content") if isinstance(side, dict) else None
  345. if isinstance(content, dict):
  346. task = content.get("task")
  347. elif isinstance(content, str):
  348. task = content
  349. session = self._session_factory()
  350. try:
  351. _, models = load_runtime_modules()
  352. exists = (
  353. session.query(models.ScriptBuildRecord.id)
  354. .filter(models.ScriptBuildRecord.id == script_build_id)
  355. .first()
  356. )
  357. if exists is None:
  358. raise BuildNotFound(f"未找到脚本构建 #{script_build_id}")
  359. prompt_row = None
  360. for biz_type in config["bizTypes"]:
  361. prompt_row = (
  362. session.query(models.Prompt)
  363. .filter(models.Prompt.biz_type == biz_type)
  364. .order_by(models.Prompt.id.asc())
  365. .first()
  366. )
  367. if prompt_row is not None:
  368. break
  369. if prompt_row is not None:
  370. content = prompt_row.prompt_content
  371. source = "current-db"
  372. version = prompt_row.current_version
  373. else:
  374. path = runtime_dir() / "prompts" / "script" / config["file"]
  375. content = path.read_text(encoding="utf-8") if path.exists() else None
  376. source = "current-file"
  377. version = None
  378. return {
  379. "promptRef": ref,
  380. "actor": config["actor"],
  381. "task": task,
  382. "promptContent": content,
  383. "promptSource": source,
  384. "promptVersion": version,
  385. "modeNotice": config.get("modeNotice"),
  386. }
  387. finally:
  388. session.close()
  389. def _table(self, session: Any, name: str) -> Table:
  390. table = self._reflected_tables.get(name)
  391. if table is not None:
  392. return table
  393. # SQLAlchemy registers a Table in MetaData before reflection has
  394. # populated all columns. Parallel Inspector requests must not observe
  395. # that half-built object.
  396. with self._table_lock:
  397. table = self._reflected_tables.get(name)
  398. if table is None:
  399. table = Table(name, self._metadata, autoload_with=session.get_bind())
  400. self._reflected_tables[name] = table
  401. return table
  402. @staticmethod
  403. def _build_summary(row: Any) -> dict[str, Any]:
  404. return {
  405. "id": str(row.id),
  406. "status": row.status,
  407. "title": f"脚本构建 #{row.id}",
  408. "scriptDirection": row.script_direction,
  409. "createdAt": _json_value(row.start_time),
  410. "updatedAt": _json_value(row.end_time),
  411. }
  412. @staticmethod
  413. def _event_summary(row: Any) -> dict[str, Any]:
  414. payload = row_dict(row)
  415. payload["detailRef"] = f"event:{payload.get('id')}"
  416. return payload
  417. @staticmethod
  418. def _event_body(row: Any) -> dict[str, Any]:
  419. mapping = getattr(row, "_mapping", None)
  420. data = dict(mapping) if mapping is not None else {
  421. column.name: getattr(row, column.name)
  422. for column in row.__table__.columns
  423. }
  424. return {
  425. "id": data.get("id"),
  426. "eventId": data.get("event_id"),
  427. "input": ScriptBuildRepository._body_side(
  428. data.get("input_content_type"), data.get("input_content")
  429. ),
  430. "output": ScriptBuildRepository._body_side(
  431. data.get("output_content_type"), data.get("output_content")
  432. ),
  433. }
  434. @staticmethod
  435. def _body_side(content_type: Any, raw: Any) -> dict[str, Any] | None:
  436. if raw is None:
  437. return None
  438. raw_text = str(raw)
  439. content: Any = raw
  440. parsed = False
  441. if content_type == "json" or raw_text.lstrip().startswith(("{", "[")):
  442. try:
  443. content = json.loads(raw_text)
  444. parsed = True
  445. except (TypeError, json.JSONDecodeError):
  446. content = raw
  447. truncated = len(raw_text) > _DETAIL_BODY_MAX
  448. safe_content = (
  449. sanitize(raw_text, max_text=_DETAIL_BODY_MAX)
  450. if truncated
  451. # A valid JSON body below the declared detail limit must not have
  452. # an individual string silently clipped at a second, lower limit.
  453. else sanitize(content, max_text=_DETAIL_BODY_MAX)
  454. )
  455. return {
  456. "contentType": content_type,
  457. "content": safe_content,
  458. "truncated": truncated,
  459. **(
  460. {"omittedCharacters": len(raw_text) - _DETAIL_BODY_MAX}
  461. if truncated
  462. else {}
  463. ),
  464. }
  465. def _attach_light_event_bodies(
  466. self, session: Any, events: list[dict[str, Any]]
  467. ) -> None:
  468. """Attach only the small event fields needed by the business projector.
  469. Tool outputs can be very large. The execution view intentionally reads
  470. only tool inputs and the event's bounded output preview; full output is
  471. loaded by ``load_event_detail`` after the user opens one query.
  472. """
  473. agent_ids = [
  474. int(item["id"])
  475. for item in events
  476. if item.get("id") is not None and self._needs_light_agent_body(item)
  477. ]
  478. tool_ids = [
  479. int(item["id"])
  480. for item in events
  481. if item.get("id") is not None and item.get("event_type") == "tool_call"
  482. ]
  483. query_tool_ids = [
  484. int(item["id"])
  485. for item in events
  486. if item.get("id") is not None
  487. and item.get("event_type") == "tool_call"
  488. and str(item.get("event_name") or "") in QUERY_TOOL_NAMES
  489. ]
  490. if not agent_ids and not tool_ids:
  491. return
  492. body_table = self._table(session, "script_build_event_body")
  493. build_ids = {
  494. int(item["script_build_id"])
  495. for item in events
  496. if item.get("script_build_id") is not None
  497. }
  498. body_by_event: dict[int, dict[str, Any]] = {}
  499. if agent_ids:
  500. rows = (
  501. session.query(
  502. body_table.c.event_id,
  503. body_table.c.input_content_type,
  504. body_table.c.input_content,
  505. body_table.c.output_content_type,
  506. body_table.c.output_content,
  507. )
  508. .filter(
  509. body_table.c.event_id.in_(agent_ids),
  510. body_table.c.script_build_id.in_(build_ids),
  511. )
  512. .all()
  513. )
  514. for row in rows:
  515. data = row_dict(row)
  516. body_by_event[int(data["event_id"])] = {
  517. "inputData": self._light_content(
  518. data.get("input_content_type"), data.get("input_content")
  519. ),
  520. "agentOutputData": self._light_content(
  521. data.get("output_content_type"), data.get("output_content")
  522. ),
  523. }
  524. if tool_ids:
  525. rows = (
  526. session.query(
  527. body_table.c.event_id,
  528. body_table.c.input_content_type,
  529. body_table.c.input_content,
  530. )
  531. .filter(
  532. body_table.c.event_id.in_(tool_ids),
  533. body_table.c.script_build_id.in_(build_ids),
  534. )
  535. .all()
  536. )
  537. for row in rows:
  538. data = row_dict(row)
  539. body_by_event.setdefault(int(data["event_id"]), {})["inputData"] = self._light_content(
  540. data.get("input_content_type"), data.get("input_content")
  541. )
  542. if query_tool_ids:
  543. rows = (
  544. session.query(
  545. body_table.c.event_id,
  546. body_table.c.output_content,
  547. )
  548. .filter(
  549. body_table.c.event_id.in_(query_tool_ids),
  550. body_table.c.script_build_id.in_(build_ids),
  551. )
  552. .all()
  553. )
  554. event_by_id = {
  555. int(item["id"]): item
  556. for item in events
  557. if item.get("id") is not None
  558. }
  559. for row in rows:
  560. mapping = getattr(row, "_mapping", {})
  561. event_id = int(mapping.get("event_id"))
  562. event = event_by_id.get(event_id) or {}
  563. body_by_event.setdefault(event_id, {})["retrievalOutcome"] = (
  564. summarize_retrieval_output(
  565. str(event.get("event_name") or ""),
  566. raw_status=event.get("status"),
  567. ended_at=event.get("ended_at"),
  568. output=mapping.get("output_content"),
  569. )
  570. )
  571. for event in events:
  572. event.update(body_by_event.get(int(event.get("id") or 0), {}))
  573. @staticmethod
  574. def _needs_light_agent_body(event: dict[str, Any]) -> bool:
  575. if event.get("event_type") != "agent_invoke":
  576. return False
  577. name = str(event.get("event_name") or "")
  578. return name in RETRIEVAL_AGENT_LABELS or name in {
  579. "script_implementer",
  580. "script_multipath_evaluator",
  581. "script_evaluator",
  582. }
  583. @staticmethod
  584. def _light_content(content_type: Any, raw: Any) -> Any:
  585. if raw is None:
  586. return None
  587. if content_type == "json":
  588. try:
  589. return sanitize(json.loads(str(raw)), max_text=20_000)
  590. except (TypeError, json.JSONDecodeError):
  591. return sanitize(raw, max_text=20_000)
  592. return sanitize(raw, max_text=20_000)
  593. def _candidate_snapshot(
  594. self,
  595. branch: Any,
  596. *,
  597. paragraphs: list[Any],
  598. elements: list[Any],
  599. links: list[Any],
  600. ) -> dict[str, Any]:
  601. if branch.status in {"merged", "discarded"}:
  602. if branch.content_snapshot:
  603. return {
  604. "snapshot": sanitize(branch.content_snapshot),
  605. "sourceOrigin": "database",
  606. "snapshotKind": "saved-at-disposition",
  607. "historicalAccuracy": "exact",
  608. "currentProjectionAccuracy": "not-applicable",
  609. "note": "分支处置前保存的候选快照",
  610. }
  611. return {
  612. "snapshot": None,
  613. "sourceOrigin": "missing",
  614. "snapshotKind": "missing-disposition-snapshot",
  615. "historicalAccuracy": "missing",
  616. "currentProjectionAccuracy": "not-applicable",
  617. "note": "终态候选未保存处置前快照,不能用当前主脚本反推当时候选。",
  618. }
  619. return {
  620. "snapshot": self._snapshot_for_branch(
  621. int(branch.branch_id), paragraphs, elements, links
  622. ),
  623. "sourceOrigin": "database",
  624. "snapshotKind": "current-overlay",
  625. "historicalAccuracy": "unknown",
  626. "currentProjectionAccuracy": "exact",
  627. "note": (
  628. "基于当前主脚本计算的 overlay;不是暂存时的历史快照"
  629. if branch.status == "parked"
  630. else "基于当前主脚本计算的分支 overlay"
  631. ),
  632. }
  633. @staticmethod
  634. def _snapshot_for_branch(
  635. branch_id: int,
  636. paragraphs: list[Any],
  637. elements: list[Any],
  638. links: list[Any],
  639. ) -> dict[str, Any]:
  640. base_paras = [
  641. row
  642. for row in paragraphs
  643. if int(row.branch_id or 0) == 0 and row.is_active is not False
  644. ]
  645. base_elems = [
  646. row
  647. for row in elements
  648. if int(row.branch_id or 0) == 0 and row.is_active is not False
  649. ]
  650. active_base_para_ids = {row.id for row in base_paras}
  651. active_base_elem_ids = {row.id for row in base_elems}
  652. base_links = [
  653. row
  654. for row in links
  655. if int(row.branch_id or 0) == 0
  656. and row.paragraph_id in active_base_para_ids
  657. and row.element_id in active_base_elem_ids
  658. ]
  659. if branch_id == 0:
  660. para_rows, elem_rows, link_rows = base_paras, base_elems, base_links
  661. origin = "database"
  662. else:
  663. branch_paras = [
  664. row
  665. for row in paragraphs
  666. if int(row.branch_id or 0) == branch_id
  667. and row.is_active is not False
  668. ]
  669. branch_elems = [
  670. row
  671. for row in elements
  672. if int(row.branch_id or 0) == branch_id
  673. and row.is_active is not False
  674. ]
  675. overridden_para_ids = {
  676. row.base_ref_id
  677. for row in branch_paras
  678. if row.base_ref_id is not None
  679. }
  680. overridden_elem_ids = {
  681. row.base_ref_id
  682. for row in branch_elems
  683. if row.base_ref_id is not None
  684. }
  685. para_rows = [
  686. row for row in base_paras if row.id not in overridden_para_ids
  687. ] + branch_paras
  688. elem_rows = [
  689. row for row in base_elems if row.id not in overridden_elem_ids
  690. ] + branch_elems
  691. visible_para_ids = {row.id for row in para_rows} | overridden_para_ids
  692. visible_elem_ids = {row.id for row in elem_rows} | overridden_elem_ids
  693. branch_links = [
  694. row
  695. for row in links
  696. if int(row.branch_id or 0) == branch_id
  697. and row.paragraph_id in visible_para_ids
  698. and row.element_id in visible_elem_ids
  699. ]
  700. seen = {(row.paragraph_id, row.element_id) for row in base_links}
  701. link_rows = base_links + [
  702. row
  703. for row in branch_links
  704. if (row.paragraph_id, row.element_id) not in seen
  705. ]
  706. origin = "database-overlay"
  707. return {
  708. "paragraphs": [_artifact_row(row) for row in para_rows],
  709. "elements": [_artifact_row(row) for row in elem_rows],
  710. "paragraphElements": [row_dict(row) for row in link_rows],
  711. "sourceOrigin": origin,
  712. }
  713. def _artifact_row(row: Any) -> dict[str, Any]:
  714. payload = row_dict(row)
  715. row_id = payload.get("id")
  716. base_ref_id = payload.get("base_ref_id")
  717. payload["rowId"] = row_id
  718. payload["baseRefId"] = base_ref_id
  719. payload["effectiveId"] = base_ref_id if base_ref_id is not None else row_id
  720. return payload
  721. def _prompt_config(event_name: str) -> dict[str, Any]:
  722. configs = {
  723. "main": {
  724. "bizTypes": ("script_build_system",),
  725. "file": "script_build_system_v2.md",
  726. "actor": {"role": "main", "label": "主 Agent"},
  727. },
  728. "script_implementer": {
  729. "bizTypes": ("script_implementer",),
  730. "file": "script_implementer.md",
  731. "actor": {"role": "implementer", "label": "实现 Agent"},
  732. },
  733. "script_multipath_evaluator": {
  734. "bizTypes": ("script_build_eval_agent", "script_build_evaluator"),
  735. "file": "script_build_eval_agent.md",
  736. "actor": {"role": "multipath-evaluator", "label": "多方案评审 Agent"},
  737. "modeNotice": "当前调用使用多方案对比评审模式。",
  738. },
  739. "script_evaluator": {
  740. "bizTypes": ("script_build_eval_agent", "script_build_evaluator"),
  741. "file": "script_build_eval_agent.md",
  742. "actor": {"role": "overall-evaluator", "label": "整体评审 Agent"},
  743. "modeNotice": "当前调用使用主脚本整体评审模式。",
  744. },
  745. }
  746. config = configs.get(str(event_name))
  747. if config is None:
  748. raise ActivityNotFound(f"该活动没有可展示的 Agent 规则:{event_name}")
  749. return config