#!/usr/bin/env python3 """Run formal acquisition, decode every coarse-hit item, and build payload drafts.""" from __future__ import annotations import argparse import json import logging from dataclasses import asdict, is_dataclass from uuid import UUID from acquisition.repositories.postgres import PostgresAcquisitionRepository from acquisition.runner import DEFAULT_PLATFORMS, run_batch from core.config import CreationDbConfig, IngestApiConfig, Settings from core.db_session import transaction from decode_content.ingest import KnowledgeIngestClient, ingest_payload_draft from decode_content.repositories.postgres import PostgresDecodeRepository from decode_content.service import DecodeService from pipeline.decode_runner import run_decode_stage from pipeline.postgres import PostgresPipelineRepository from pipeline.tracing import TraceContext, TraceWriter, new_trace_writer logger = logging.getLogger(__name__) def _model_dump(value): if hasattr(value, "model_dump"): return value.model_dump(mode="json") if is_dataclass(value): return asdict(value) if isinstance(value, dict): return value return dict(value) def _ingest_payloads( repo: PostgresDecodeRepository, outputs: list, *, dry_run: bool, env_file: str, trace_writer: TraceWriter | None = None, trace_context: TraceContext | None = None, ) -> list[dict]: records: list[dict] = [] client = None if dry_run else KnowledgeIngestClient(IngestApiConfig.from_env(env_file)) for output in outputs: for draft in output.payload_drafts: if draft.id is None: continue context = (trace_context or TraceContext()).child( item_id=output.item_id, payload_draft_id=draft.id, stage="ingest", ) if trace_writer: trace_writer.event( context=context, stage="ingest", event_type="ingest_started", target_table="payload_drafts", target_id=draft.id, payload={"dry_run": dry_run}, ) record = ingest_payload_draft(repo, draft, dry_run=dry_run, client=client) if trace_writer: trace_writer.event( context=context.child(ingest_record_id=record.id), stage="ingest", event_type="ingest_recorded", status=record.status, target_table="ingest_records", target_id=record.id, payload={"dry_run": dry_run, "target_system": record.target_system}, error_message=record.error_message, ) records.append(_model_dump(record)) return records def _ensure_pipeline_run( db_config: CreationDbConfig, *, batch_id: UUID, run_key: str | None, platforms: tuple[str, ...], args: argparse.Namespace, ) -> UUID | None: try: with transaction(db_config) as conn: run = PostgresPipelineRepository(conn).create_pipeline_run( run_key=run_key or f"creation-pipeline:{batch_id}", batch_id=batch_id, status="running", current_stage="search", config={ "platforms": list(platforms), "search_limit": args.search_limit, "display_limit": args.display_limit, "decode_limit": args.decode_limit, "resume": not args.no_resume, "skip_done": not args.no_skip_done, "real_ingest": args.real_ingest, }, metadata={"entrypoint": "scripts/run_creation_pipeline.py"}, ) return run.id except Exception as exc: logger.warning("pipeline run trace setup skipped: %s", exc) return None def _mark_pipeline_run( db_config: CreationDbConfig, run_id: UUID | None, *, status: str, current_stage: str | None = None, error_message: str | None = None, metadata: dict | None = None, ) -> None: if run_id is None: return try: with transaction(db_config) as conn: PostgresPipelineRepository(conn).mark_pipeline_run_status( run_id, status=status, current_stage=current_stage, error_message=error_message, metadata=metadata, ) except Exception as exc: logger.warning("pipeline run trace status skipped: %s", exc) def _start_pipeline_job( db_config: CreationDbConfig, pipeline_run_id: UUID | None, *, stage: str, target_table: str | None = None, target_id: UUID | None = None, metadata: dict | None = None, ) -> UUID | None: if pipeline_run_id is None: return None try: with transaction(db_config) as conn: job = PostgresPipelineRepository(conn).save_pipeline_job( run_id=pipeline_run_id, stage=stage, target_table=target_table, target_id=target_id, status="running", metadata=metadata, ) return job.id except Exception as exc: logger.warning("pipeline job trace setup skipped: %s", exc) return None def _mark_pipeline_job( db_config: CreationDbConfig, job_id: UUID | None, *, status: str, error_message: str | None = None, metadata: dict | None = None, ) -> None: if job_id is None: return try: with transaction(db_config) as conn: PostgresPipelineRepository(conn).mark_job_status( job_id, status=status, error_message=error_message, metadata=metadata, ) except Exception as exc: logger.warning("pipeline job trace status skipped: %s", exc) def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--batch-id", required=True, help="Formal query batch UUID") parser.add_argument( "--platform", action="append", choices=DEFAULT_PLATFORMS, help="Platform to run. Repeat to run multiple platforms. Default: all.", ) parser.add_argument("--search-limit", type=int, default=10) parser.add_argument("--display-limit", type=int, default=5) parser.add_argument("--decode-limit", type=int, default=100) parser.add_argument("--run-key") parser.add_argument("--env-file", default=".env") parser.add_argument("--no-resume", action="store_true") parser.add_argument("--no-skip-done", action="store_true") parser.add_argument("--no-dry-ingest-record", action="store_true") parser.add_argument("--real-ingest", action="store_true", help="Call CK_INGEST_API_URL instead of dry-run records.") return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) settings = Settings.from_env(args.env_file) db_config = CreationDbConfig.from_env(args.env_file) platforms = tuple(args.platform or DEFAULT_PLATFORMS) batch_id = UUID(args.batch_id) pipeline_run_id = _ensure_pipeline_run( db_config, batch_id=batch_id, run_key=args.run_key, platforms=platforms, args=args, ) trace_writer = new_trace_writer(db_config, env_file=args.env_file) search_job_id = _start_pipeline_job( db_config, pipeline_run_id, stage="search", target_table="query_batches", target_id=batch_id, metadata={"platforms": list(platforms)}, ) trace_context = TraceContext(pipeline_run_id=pipeline_run_id, pipeline_job_id=search_job_id, stage="search") try: trace_writer.event( context=trace_context, stage="search", event_type="pipeline_started", status="running", payload={"batch_id": str(batch_id), "platforms": list(platforms)}, ) with transaction(db_config) as conn: acquisition_repo = PostgresAcquisitionRepository(conn) acquisition = run_batch( acquisition_repo, batch_id=batch_id, settings=settings, platforms=platforms, search_limit=args.search_limit, display_limit=args.display_limit, classify=True, resume=not args.no_resume, skip_done=not args.no_skip_done, run_key=args.run_key, trace_writer=trace_writer, trace_context=trace_context, ) _mark_pipeline_job( db_config, search_job_id, status="partial" if acquisition.failed else "done", metadata={"acquisition_run_id": str(acquisition.run_id), "failed": acquisition.failed}, ) decode_job_id = _start_pipeline_job( db_config, pipeline_run_id, stage="decode", target_table="acquisition_runs", target_id=acquisition.run_id, metadata={"decode_limit": args.decode_limit}, ) decode_context = trace_context.child( pipeline_job_id=decode_job_id, acquisition_run_id=acquisition.run_id, stage="decode", ) _mark_pipeline_run(db_config, pipeline_run_id, status="running", current_stage="decode") with transaction(db_config) as conn: acquisition_repo = PostgresAcquisitionRepository(conn) decode_repo = PostgresDecodeRepository(conn) decode_service = DecodeService( settings=settings, repository=decode_repo, trace_writer=trace_writer, trace_context=decode_context, ) decode = run_decode_stage( candidate_repo=acquisition_repo, decode_service=decode_service, run_id=acquisition.run_id, limit=args.decode_limit, trace_writer=trace_writer, trace_context=decode_context, ) _mark_pipeline_job( db_config, decode_job_id, status="partial" if decode.failed else "done", metadata={"decode_total": decode.total, "decode_failed": decode.failed}, ) ingest_job_id = _start_pipeline_job( db_config, pipeline_run_id, stage="ingest", target_table="acquisition_runs", target_id=acquisition.run_id, metadata={"real_ingest": args.real_ingest}, ) ingest_context = decode_context.child(pipeline_job_id=ingest_job_id, stage="ingest") _mark_pipeline_run(db_config, pipeline_run_id, status="running", current_stage="ingest") ingest_records = [] if args.no_dry_ingest_record else _ingest_payloads( decode_repo, decode.outputs, dry_run=not args.real_ingest, env_file=args.env_file, trace_writer=trace_writer, trace_context=ingest_context, ) ingest_failed = sum(1 for record in ingest_records if record.get("status") == "failed") _mark_pipeline_job( db_config, ingest_job_id, status="partial" if ingest_failed else "done", metadata={"ingest_record_count": len(ingest_records), "failed": ingest_failed}, ) final_status = "partial" if acquisition.failed or decode.failed else "done" _mark_pipeline_run( db_config, pipeline_run_id, status=final_status, current_stage="ingest", metadata={ "acquisition_run_id": str(acquisition.run_id), "decode_total": decode.total, "decode_failed": decode.failed, "ingest_record_count": len(ingest_records), }, ) trace_writer.event( context=trace_context.child(acquisition_run_id=acquisition.run_id), stage="ingest", event_type="pipeline_finished", status=final_status, payload={"decode": _model_dump(decode), "ingest_record_count": len(ingest_records)}, ) except Exception as exc: _mark_pipeline_run( db_config, pipeline_run_id, status="failed", error_message=str(exc), ) trace_writer.event( context=trace_context, stage="ingest", event_type="pipeline_failed", status="failed", severity="error", error_message=str(exc), ) raise print(json.dumps( { "batch_id": str(batch_id), "pipeline_run_id": str(pipeline_run_id) if pipeline_run_id else None, "run_id": str(acquisition.run_id), "platforms": list(platforms), "acquisition": _model_dump(acquisition), "decode": _model_dump(decode), "ingest_records": ingest_records, "dry_ingest_records": ingest_records if not args.real_ingest else [], }, ensure_ascii=False, default=str, indent=2, )) return 0 if __name__ == "__main__": raise SystemExit(main())