run_creation_pipeline.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. #!/usr/bin/env python3
  2. """Run formal acquisition, decode every coarse-hit item, and build payload drafts."""
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. import logging
  7. from dataclasses import asdict, is_dataclass
  8. from uuid import UUID
  9. from acquisition.repositories.postgres import PostgresAcquisitionRepository
  10. from acquisition.runner import DEFAULT_PLATFORMS, run_batch
  11. from core.config import CreationDbConfig, IngestApiConfig, Settings
  12. from core.db_session import transaction
  13. from decode_content.ingest import KnowledgeIngestClient, ingest_payload_draft
  14. from decode_content.repositories.postgres import PostgresDecodeRepository
  15. from decode_content.service import DecodeService
  16. from pipeline.decode_runner import run_decode_stage
  17. from pipeline.postgres import PostgresPipelineRepository
  18. from pipeline.tracing import TraceContext, TraceWriter, new_trace_writer
  19. logger = logging.getLogger(__name__)
  20. def _model_dump(value):
  21. if hasattr(value, "model_dump"):
  22. return value.model_dump(mode="json")
  23. if is_dataclass(value):
  24. return asdict(value)
  25. if isinstance(value, dict):
  26. return value
  27. return dict(value)
  28. def _ingest_payloads(
  29. repo: PostgresDecodeRepository,
  30. outputs: list,
  31. *,
  32. dry_run: bool,
  33. env_file: str,
  34. trace_writer: TraceWriter | None = None,
  35. trace_context: TraceContext | None = None,
  36. ) -> list[dict]:
  37. records: list[dict] = []
  38. client = None if dry_run else KnowledgeIngestClient(IngestApiConfig.from_env(env_file))
  39. for output in outputs:
  40. for draft in output.payload_drafts:
  41. if draft.id is None:
  42. continue
  43. context = (trace_context or TraceContext()).child(
  44. item_id=output.item_id,
  45. payload_draft_id=draft.id,
  46. stage="ingest",
  47. )
  48. if trace_writer:
  49. trace_writer.event(
  50. context=context,
  51. stage="ingest",
  52. event_type="ingest_started",
  53. target_table="payload_drafts",
  54. target_id=draft.id,
  55. payload={"dry_run": dry_run},
  56. )
  57. record = ingest_payload_draft(repo, draft, dry_run=dry_run, client=client)
  58. if trace_writer:
  59. trace_writer.event(
  60. context=context.child(ingest_record_id=record.id),
  61. stage="ingest",
  62. event_type="ingest_recorded",
  63. status=record.status,
  64. target_table="ingest_records",
  65. target_id=record.id,
  66. payload={"dry_run": dry_run, "target_system": record.target_system},
  67. error_message=record.error_message,
  68. )
  69. records.append(_model_dump(record))
  70. return records
  71. def _ensure_pipeline_run(
  72. db_config: CreationDbConfig,
  73. *,
  74. batch_id: UUID,
  75. run_key: str | None,
  76. platforms: tuple[str, ...],
  77. args: argparse.Namespace,
  78. ) -> UUID | None:
  79. try:
  80. with transaction(db_config) as conn:
  81. run = PostgresPipelineRepository(conn).create_pipeline_run(
  82. run_key=run_key or f"creation-pipeline:{batch_id}",
  83. batch_id=batch_id,
  84. status="running",
  85. current_stage="search",
  86. config={
  87. "platforms": list(platforms),
  88. "search_limit": args.search_limit,
  89. "display_limit": args.display_limit,
  90. "decode_limit": args.decode_limit,
  91. "resume": not args.no_resume,
  92. "skip_done": not args.no_skip_done,
  93. "real_ingest": args.real_ingest,
  94. },
  95. metadata={"entrypoint": "scripts/run_creation_pipeline.py"},
  96. )
  97. return run.id
  98. except Exception as exc:
  99. logger.warning("pipeline run trace setup skipped: %s", exc)
  100. return None
  101. def _mark_pipeline_run(
  102. db_config: CreationDbConfig,
  103. run_id: UUID | None,
  104. *,
  105. status: str,
  106. current_stage: str | None = None,
  107. error_message: str | None = None,
  108. metadata: dict | None = None,
  109. ) -> None:
  110. if run_id is None:
  111. return
  112. try:
  113. with transaction(db_config) as conn:
  114. PostgresPipelineRepository(conn).mark_pipeline_run_status(
  115. run_id,
  116. status=status,
  117. current_stage=current_stage,
  118. error_message=error_message,
  119. metadata=metadata,
  120. )
  121. except Exception as exc:
  122. logger.warning("pipeline run trace status skipped: %s", exc)
  123. def _start_pipeline_job(
  124. db_config: CreationDbConfig,
  125. pipeline_run_id: UUID | None,
  126. *,
  127. stage: str,
  128. target_table: str | None = None,
  129. target_id: UUID | None = None,
  130. metadata: dict | None = None,
  131. ) -> UUID | None:
  132. if pipeline_run_id is None:
  133. return None
  134. try:
  135. with transaction(db_config) as conn:
  136. job = PostgresPipelineRepository(conn).save_pipeline_job(
  137. run_id=pipeline_run_id,
  138. stage=stage,
  139. target_table=target_table,
  140. target_id=target_id,
  141. status="running",
  142. metadata=metadata,
  143. )
  144. return job.id
  145. except Exception as exc:
  146. logger.warning("pipeline job trace setup skipped: %s", exc)
  147. return None
  148. def _mark_pipeline_job(
  149. db_config: CreationDbConfig,
  150. job_id: UUID | None,
  151. *,
  152. status: str,
  153. error_message: str | None = None,
  154. metadata: dict | None = None,
  155. ) -> None:
  156. if job_id is None:
  157. return
  158. try:
  159. with transaction(db_config) as conn:
  160. PostgresPipelineRepository(conn).mark_job_status(
  161. job_id,
  162. status=status,
  163. error_message=error_message,
  164. metadata=metadata,
  165. )
  166. except Exception as exc:
  167. logger.warning("pipeline job trace status skipped: %s", exc)
  168. def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
  169. parser = argparse.ArgumentParser(description=__doc__)
  170. parser.add_argument("--batch-id", required=True, help="Formal query batch UUID")
  171. parser.add_argument(
  172. "--platform",
  173. action="append",
  174. choices=DEFAULT_PLATFORMS,
  175. help="Platform to run. Repeat to run multiple platforms. Default: all.",
  176. )
  177. parser.add_argument("--search-limit", type=int, default=10)
  178. parser.add_argument("--display-limit", type=int, default=5)
  179. parser.add_argument("--decode-limit", type=int, default=100)
  180. parser.add_argument("--run-key")
  181. parser.add_argument("--env-file", default=".env")
  182. parser.add_argument("--no-resume", action="store_true")
  183. parser.add_argument("--no-skip-done", action="store_true")
  184. parser.add_argument("--no-dry-ingest-record", action="store_true")
  185. parser.add_argument("--real-ingest", action="store_true", help="Call CK_INGEST_API_URL instead of dry-run records.")
  186. return parser.parse_args(argv)
  187. def main(argv: list[str] | None = None) -> int:
  188. args = parse_args(argv)
  189. settings = Settings.from_env(args.env_file)
  190. db_config = CreationDbConfig.from_env(args.env_file)
  191. platforms = tuple(args.platform or DEFAULT_PLATFORMS)
  192. batch_id = UUID(args.batch_id)
  193. pipeline_run_id = _ensure_pipeline_run(
  194. db_config,
  195. batch_id=batch_id,
  196. run_key=args.run_key,
  197. platforms=platforms,
  198. args=args,
  199. )
  200. trace_writer = new_trace_writer(db_config, env_file=args.env_file)
  201. search_job_id = _start_pipeline_job(
  202. db_config,
  203. pipeline_run_id,
  204. stage="search",
  205. target_table="query_batches",
  206. target_id=batch_id,
  207. metadata={"platforms": list(platforms)},
  208. )
  209. trace_context = TraceContext(pipeline_run_id=pipeline_run_id, pipeline_job_id=search_job_id, stage="search")
  210. try:
  211. trace_writer.event(
  212. context=trace_context,
  213. stage="search",
  214. event_type="pipeline_started",
  215. status="running",
  216. payload={"batch_id": str(batch_id), "platforms": list(platforms)},
  217. )
  218. with transaction(db_config) as conn:
  219. acquisition_repo = PostgresAcquisitionRepository(conn)
  220. acquisition = run_batch(
  221. acquisition_repo,
  222. batch_id=batch_id,
  223. settings=settings,
  224. platforms=platforms,
  225. search_limit=args.search_limit,
  226. display_limit=args.display_limit,
  227. classify=True,
  228. resume=not args.no_resume,
  229. skip_done=not args.no_skip_done,
  230. run_key=args.run_key,
  231. trace_writer=trace_writer,
  232. trace_context=trace_context,
  233. )
  234. _mark_pipeline_job(
  235. db_config,
  236. search_job_id,
  237. status="partial" if acquisition.failed else "done",
  238. metadata={"acquisition_run_id": str(acquisition.run_id), "failed": acquisition.failed},
  239. )
  240. decode_job_id = _start_pipeline_job(
  241. db_config,
  242. pipeline_run_id,
  243. stage="decode",
  244. target_table="acquisition_runs",
  245. target_id=acquisition.run_id,
  246. metadata={"decode_limit": args.decode_limit},
  247. )
  248. decode_context = trace_context.child(
  249. pipeline_job_id=decode_job_id,
  250. acquisition_run_id=acquisition.run_id,
  251. stage="decode",
  252. )
  253. _mark_pipeline_run(db_config, pipeline_run_id, status="running", current_stage="decode")
  254. with transaction(db_config) as conn:
  255. acquisition_repo = PostgresAcquisitionRepository(conn)
  256. decode_repo = PostgresDecodeRepository(conn)
  257. decode_service = DecodeService(
  258. settings=settings,
  259. repository=decode_repo,
  260. trace_writer=trace_writer,
  261. trace_context=decode_context,
  262. )
  263. decode = run_decode_stage(
  264. candidate_repo=acquisition_repo,
  265. decode_service=decode_service,
  266. run_id=acquisition.run_id,
  267. limit=args.decode_limit,
  268. trace_writer=trace_writer,
  269. trace_context=decode_context,
  270. )
  271. _mark_pipeline_job(
  272. db_config,
  273. decode_job_id,
  274. status="partial" if decode.failed else "done",
  275. metadata={"decode_total": decode.total, "decode_failed": decode.failed},
  276. )
  277. ingest_job_id = _start_pipeline_job(
  278. db_config,
  279. pipeline_run_id,
  280. stage="ingest",
  281. target_table="acquisition_runs",
  282. target_id=acquisition.run_id,
  283. metadata={"real_ingest": args.real_ingest},
  284. )
  285. ingest_context = decode_context.child(pipeline_job_id=ingest_job_id, stage="ingest")
  286. _mark_pipeline_run(db_config, pipeline_run_id, status="running", current_stage="ingest")
  287. ingest_records = [] if args.no_dry_ingest_record else _ingest_payloads(
  288. decode_repo,
  289. decode.outputs,
  290. dry_run=not args.real_ingest,
  291. env_file=args.env_file,
  292. trace_writer=trace_writer,
  293. trace_context=ingest_context,
  294. )
  295. ingest_failed = sum(1 for record in ingest_records if record.get("status") == "failed")
  296. _mark_pipeline_job(
  297. db_config,
  298. ingest_job_id,
  299. status="partial" if ingest_failed else "done",
  300. metadata={"ingest_record_count": len(ingest_records), "failed": ingest_failed},
  301. )
  302. final_status = "partial" if acquisition.failed or decode.failed else "done"
  303. _mark_pipeline_run(
  304. db_config,
  305. pipeline_run_id,
  306. status=final_status,
  307. current_stage="ingest",
  308. metadata={
  309. "acquisition_run_id": str(acquisition.run_id),
  310. "decode_total": decode.total,
  311. "decode_failed": decode.failed,
  312. "ingest_record_count": len(ingest_records),
  313. },
  314. )
  315. trace_writer.event(
  316. context=trace_context.child(acquisition_run_id=acquisition.run_id),
  317. stage="ingest",
  318. event_type="pipeline_finished",
  319. status=final_status,
  320. payload={"decode": _model_dump(decode), "ingest_record_count": len(ingest_records)},
  321. )
  322. except Exception as exc:
  323. _mark_pipeline_run(
  324. db_config,
  325. pipeline_run_id,
  326. status="failed",
  327. error_message=str(exc),
  328. )
  329. trace_writer.event(
  330. context=trace_context,
  331. stage="ingest",
  332. event_type="pipeline_failed",
  333. status="failed",
  334. severity="error",
  335. error_message=str(exc),
  336. )
  337. raise
  338. print(json.dumps(
  339. {
  340. "batch_id": str(batch_id),
  341. "pipeline_run_id": str(pipeline_run_id) if pipeline_run_id else None,
  342. "run_id": str(acquisition.run_id),
  343. "platforms": list(platforms),
  344. "acquisition": _model_dump(acquisition),
  345. "decode": _model_dump(decode),
  346. "ingest_records": ingest_records,
  347. "dry_ingest_records": ingest_records if not args.real_ingest else [],
  348. },
  349. ensure_ascii=False,
  350. default=str,
  351. indent=2,
  352. ))
  353. return 0
  354. if __name__ == "__main__":
  355. raise SystemExit(main())