ingest_payloads.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. #!/usr/bin/env python3
  2. """Ingest existing payload drafts for one acquisition run.
  3. This script does not search, classify, or decode. It only reads payload_drafts
  4. already produced for candidate items under the given run_id.
  5. """
  6. from __future__ import annotations
  7. import argparse
  8. import json
  9. import sys
  10. from dataclasses import asdict, is_dataclass
  11. from pathlib import Path
  12. from uuid import UUID
  13. ROOT = Path(__file__).resolve().parents[1]
  14. if str(ROOT) not in sys.path:
  15. sys.path.insert(0, str(ROOT))
  16. from core.config import CreationDbConfig, IngestApiConfig # noqa: E402
  17. from core.db_session import transaction # noqa: E402
  18. from decode_content.ingest import KnowledgeIngestClient, ingest_payload_draft # noqa: E402
  19. from decode_content.models import PayloadDraft # noqa: E402
  20. from decode_content.repositories.postgres import PostgresDecodeRepository # noqa: E402
  21. from pipeline.postgres import PostgresPipelineRepository # noqa: E402
  22. from pipeline.tracing import TraceContext, new_trace_writer # noqa: E402
  23. def _model_dump(value):
  24. if hasattr(value, "model_dump"):
  25. return value.model_dump(mode="json")
  26. if is_dataclass(value):
  27. return asdict(value)
  28. if isinstance(value, dict):
  29. return value
  30. return dict(value)
  31. def list_payload_drafts_for_run(
  32. repo: PostgresDecodeRepository,
  33. *,
  34. run_id: UUID,
  35. include_real_ingested: bool = False,
  36. limit: int | None = None,
  37. ) -> list[PayloadDraft]:
  38. skip_real_ingested = "" if include_real_ingested else """
  39. AND NOT EXISTS (
  40. SELECT 1 FROM ingest_records ir
  41. WHERE ir.payload_draft_id = pd.id
  42. AND ir.target_system = 'knowledge-ingest-api'
  43. AND ir.status = 'ingested'
  44. )
  45. """
  46. limit_sql = "LIMIT %s" if limit is not None else ""
  47. params: tuple = (run_id, limit) if limit is not None else (run_id,)
  48. rows = repo._all(
  49. f"""
  50. SELECT pd.*
  51. FROM payload_drafts pd
  52. JOIN candidate_items ci ON ci.id = pd.item_id
  53. JOIN acquisition_jobs aj ON aj.id = ci.job_id
  54. WHERE aj.run_id = %s
  55. {skip_real_ingested}
  56. ORDER BY ci.created_at, pd.created_at, pd.id
  57. {limit_sql}
  58. """,
  59. params,
  60. )
  61. return [PayloadDraft.model_validate(row) for row in rows]
  62. def count_run_payloads(repo: PostgresDecodeRepository, *, run_id: UUID) -> dict[str, int]:
  63. row = repo._one(
  64. """
  65. SELECT
  66. COUNT(DISTINCT pd.id)::int AS total_payloads,
  67. COUNT(DISTINCT pd.id) FILTER (
  68. WHERE EXISTS (
  69. SELECT 1 FROM ingest_records ir
  70. WHERE ir.payload_draft_id = pd.id
  71. AND ir.target_system = 'knowledge-ingest-api'
  72. AND ir.status = 'ingested'
  73. )
  74. )::int AS already_real_ingested
  75. FROM payload_drafts pd
  76. JOIN candidate_items ci ON ci.id = pd.item_id
  77. JOIN acquisition_jobs aj ON aj.id = ci.job_id
  78. WHERE aj.run_id = %s
  79. """,
  80. (run_id,),
  81. )
  82. return {
  83. "total_payloads": int(row.get("total_payloads") or 0),
  84. "already_real_ingested": int(row.get("already_real_ingested") or 0),
  85. }
  86. def ingest_existing_payloads(
  87. repo: PostgresDecodeRepository,
  88. drafts: list[PayloadDraft],
  89. *,
  90. dry_run: bool,
  91. env_file: str,
  92. trace_writer=None,
  93. trace_context: TraceContext | None = None,
  94. ) -> list[dict]:
  95. client = None
  96. if not dry_run:
  97. config = IngestApiConfig.from_env(env_file)
  98. if not config.url:
  99. raise RuntimeError("CK_INGEST_API_URL 未配置,真实 ingest 已关闭")
  100. client = KnowledgeIngestClient(config)
  101. records: list[dict] = []
  102. for draft in drafts:
  103. if draft.id is None:
  104. continue
  105. context = (trace_context or TraceContext(stage="ingest")).child(
  106. item_id=draft.item_id,
  107. payload_draft_id=draft.id,
  108. stage="ingest",
  109. )
  110. if trace_writer is not None:
  111. trace_writer.event(
  112. context=context,
  113. stage="ingest",
  114. event_type="ingest_started",
  115. status="running",
  116. target_table="payload_drafts",
  117. target_id=draft.id,
  118. payload={"dry_run": dry_run, "entrypoint": "scripts/ingest_payloads.py"},
  119. )
  120. record = ingest_payload_draft(repo, draft, dry_run=dry_run, client=client)
  121. if trace_writer is not None:
  122. trace_writer.event(
  123. context=context.child(ingest_record_id=record.id),
  124. stage="ingest",
  125. event_type="ingest_recorded",
  126. status=record.status,
  127. target_table="ingest_records",
  128. target_id=record.id,
  129. payload={"dry_run": dry_run, "target_system": record.target_system},
  130. error_message=record.error_message,
  131. )
  132. records.append(_model_dump(record))
  133. return records
  134. def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
  135. parser = argparse.ArgumentParser(description=__doc__)
  136. parser.add_argument("--run-id", required=True, help="acquisition_runs.id whose existing payloads should be ingested")
  137. parser.add_argument("--pipeline-run-id", help="Optional pipeline_runs.id for trace events")
  138. parser.add_argument("--env-file", default=".env")
  139. parser.add_argument("--real", action="store_true", help="Call CK_INGEST_API_URL. Default is dry-run.")
  140. parser.add_argument("--limit", type=int, default=None, help="Optional max payload drafts to process")
  141. parser.add_argument(
  142. "--include-real-ingested",
  143. action="store_true",
  144. help="Also process drafts that already have successful knowledge-ingest-api records.",
  145. )
  146. return parser.parse_args(argv)
  147. def main(argv: list[str] | None = None) -> int:
  148. args = parse_args(argv)
  149. run_id = UUID(args.run_id)
  150. db_config = CreationDbConfig.from_env(args.env_file)
  151. pipeline_run_id = UUID(args.pipeline_run_id) if args.pipeline_run_id else None
  152. if pipeline_run_id is None:
  153. try:
  154. with transaction(db_config) as conn:
  155. pipeline_run_id = PostgresPipelineRepository(conn).get_pipeline_run_by_acquisition_run(run_id).id
  156. except Exception:
  157. pipeline_run_id = None
  158. trace_writer = new_trace_writer(db_config, env_file=args.env_file)
  159. trace_context = TraceContext(
  160. pipeline_run_id=pipeline_run_id,
  161. acquisition_run_id=run_id,
  162. stage="ingest",
  163. )
  164. with transaction(db_config) as conn:
  165. repo = PostgresDecodeRepository(conn)
  166. counts = count_run_payloads(repo, run_id=run_id)
  167. drafts = list_payload_drafts_for_run(
  168. repo,
  169. run_id=run_id,
  170. include_real_ingested=args.include_real_ingested,
  171. limit=args.limit,
  172. )
  173. records = ingest_existing_payloads(
  174. repo,
  175. drafts,
  176. dry_run=not args.real,
  177. env_file=args.env_file,
  178. trace_writer=trace_writer,
  179. trace_context=trace_context,
  180. )
  181. ingested_count = sum(1 for record in records if record.get("status") == "ingested")
  182. failed_count = sum(1 for record in records if record.get("status") == "failed")
  183. skipped_already_real = (
  184. 0
  185. if args.include_real_ingested
  186. else max(0, counts["already_real_ingested"])
  187. )
  188. print(json.dumps(
  189. {
  190. "run_id": str(run_id),
  191. "pipeline_run_id": str(pipeline_run_id) if pipeline_run_id else None,
  192. "dry_run": not args.real,
  193. "total_payloads": counts["total_payloads"],
  194. "already_real_ingested": counts["already_real_ingested"],
  195. "selected_payloads": len(drafts),
  196. "skipped_already_real_ingested": skipped_already_real,
  197. "ingested_count": ingested_count,
  198. "failed_count": failed_count,
  199. "records": records,
  200. },
  201. ensure_ascii=False,
  202. default=str,
  203. indent=2,
  204. ))
  205. return 0
  206. if __name__ == "__main__":
  207. raise SystemExit(main())