backfill_pipeline_traces.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. #!/usr/bin/env python3
  2. """Backfill pipeline trace ledger rows from existing acquisition runs.
  3. Dry-run is the default. This script never fabricates historical LLM traces:
  4. old runs only get structural events plus an explicit backfill note.
  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 # noqa: E402
  17. from core.db_session import transaction # noqa: E402
  18. from pipeline.postgres import PostgresPipelineRepository # noqa: E402
  19. from pipeline.tracing import TraceContext # noqa: E402
  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 _acquisition_runs(conn, *, limit: int | None = None) -> list[dict]:
  29. sql = """
  30. SELECT ar.*,
  31. COUNT(DISTINCT aj.id)::int AS job_count,
  32. COUNT(DISTINCT ci.id)::int AS candidate_count
  33. FROM acquisition_runs ar
  34. LEFT JOIN acquisition_jobs aj ON aj.run_id = ar.id
  35. LEFT JOIN candidate_items ci ON ci.job_id = aj.id
  36. GROUP BY ar.id
  37. ORDER BY ar.created_at
  38. """
  39. if limit is not None:
  40. sql += " LIMIT %s"
  41. params = (limit,)
  42. else:
  43. params = ()
  44. return PostgresPipelineRepository(conn)._all(sql, params)
  45. def _candidate_hits(conn, acquisition_run_id: UUID) -> list[dict]:
  46. return PostgresPipelineRepository(conn)._all(
  47. """
  48. SELECT
  49. aj.id AS acquisition_job_id,
  50. aj.query_id,
  51. aj.platform,
  52. ci.id AS item_id,
  53. ci.unique_key,
  54. ci.platform_item_id,
  55. ci.metadata,
  56. ci.source_payload
  57. FROM acquisition_jobs aj
  58. JOIN candidate_items ci ON ci.job_id = aj.id
  59. WHERE aj.run_id = %s
  60. ORDER BY aj.platform, ci.created_at
  61. """,
  62. (acquisition_run_id,),
  63. )
  64. def backfill(*, env_file: str, apply: bool, limit: int | None = None) -> dict:
  65. db_config = CreationDbConfig.from_env(env_file)
  66. with transaction(db_config) as conn:
  67. repo = PostgresPipelineRepository(conn)
  68. runs = _acquisition_runs(conn, limit=limit)
  69. if not apply:
  70. return {
  71. "dry_run": True,
  72. "acquisition_run_count": len(runs),
  73. "candidate_hit_count": sum(int(row.get("candidate_count") or 0) for row in runs),
  74. "runs": [
  75. {
  76. "acquisition_run_id": str(row["id"]),
  77. "run_key": row.get("run_key"),
  78. "status": row.get("status"),
  79. "job_count": row.get("job_count"),
  80. "candidate_count": row.get("candidate_count"),
  81. }
  82. for row in runs
  83. ],
  84. }
  85. pipeline_count = 0
  86. hit_count = 0
  87. for row in runs:
  88. acquisition_run_id = row["id"]
  89. run = repo.create_pipeline_run(
  90. run_key=f"backfill:{acquisition_run_id}",
  91. batch_id=row.get("batch_id"),
  92. status=row.get("status") or "done",
  93. current_stage="ingest",
  94. metadata={
  95. "source": "backfill_pipeline_traces",
  96. "acquisition_run_id": str(acquisition_run_id),
  97. "original_run_key": row.get("run_key"),
  98. },
  99. )
  100. pipeline_count += 1
  101. context = TraceContext(
  102. pipeline_run_id=run.id,
  103. acquisition_run_id=acquisition_run_id,
  104. stage="search",
  105. )
  106. repo.append_event(
  107. context=context,
  108. stage="query",
  109. event_type="backfilled_without_llm_trace",
  110. status="done",
  111. target_table="acquisition_runs",
  112. target_id=acquisition_run_id,
  113. payload={
  114. "reason": "historical raw LLM requests and responses were not stored",
  115. "job_count": row.get("job_count"),
  116. "candidate_count": row.get("candidate_count"),
  117. },
  118. )
  119. for hit in _candidate_hits(conn, acquisition_run_id):
  120. metadata = hit.get("metadata") or {}
  121. repo.record_candidate_hit(
  122. context=context.child(
  123. acquisition_job_id=hit.get("acquisition_job_id"),
  124. query_id=hit.get("query_id"),
  125. item_id=hit.get("item_id"),
  126. platform=hit.get("platform"),
  127. ),
  128. item_id=hit.get("item_id"),
  129. platform=hit.get("platform"),
  130. unique_key=hit.get("unique_key"),
  131. platform_item_id=hit.get("platform_item_id"),
  132. search_provider=metadata.get("search_provider"),
  133. detail_provider=metadata.get("detail_provider"),
  134. attempt_index=metadata.get("retry_attempt_index"),
  135. page_index=metadata.get("page_index"),
  136. page_rank=metadata.get("page_rank"),
  137. candidate_rank=metadata.get("candidate_rank"),
  138. source_cursor=metadata.get("source_cursor"),
  139. is_duplicate_hit=metadata.get("acquisition_match_status") == "existing",
  140. raw_candidate=metadata.get("matched_candidate") or {},
  141. metadata={"source": "backfill_pipeline_traces"},
  142. )
  143. hit_count += 1
  144. return {
  145. "dry_run": False,
  146. "pipeline_run_count": pipeline_count,
  147. "candidate_hit_count": hit_count,
  148. }
  149. def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
  150. parser = argparse.ArgumentParser(description=__doc__)
  151. parser.add_argument("--env-file", default=".env")
  152. parser.add_argument("--apply", action="store_true", help="Actually write trace rows. Default is dry-run.")
  153. parser.add_argument("--limit", type=int, default=None)
  154. return parser.parse_args(argv)
  155. def main(argv: list[str] | None = None) -> int:
  156. args = parse_args(argv)
  157. print(json.dumps(
  158. backfill(env_file=args.env_file, apply=args.apply, limit=args.limit),
  159. ensure_ascii=False,
  160. default=str,
  161. indent=2,
  162. ))
  163. return 0
  164. if __name__ == "__main__":
  165. raise SystemExit(main())