test_pipeline_tracing.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. from __future__ import annotations
  2. from pathlib import Path
  3. from contextlib import nullcontext
  4. from uuid import uuid4
  5. from acquisition.domain import CandidateItem, MediaAsset
  6. from core.models import Post
  7. from decode_content.models import GateResult, ReadResult
  8. from decode_content.service import DecodeWorkflowOutput
  9. from pipeline.decode_runner import run_decode_stage
  10. from pipeline.tracing import TraceConfig, TraceContext, sanitize_payload
  11. from scripts import backfill_pipeline_traces
  12. def test_formal_schema_declares_append_only_trace_tables():
  13. text = Path("db/migrations/001_creation_knowledge_schema.sql").read_text(encoding="utf-8")
  14. for table in (
  15. "pipeline_runs",
  16. "pipeline_jobs",
  17. "pipeline_run_events",
  18. "candidate_item_hits",
  19. "llm_call_traces",
  20. ):
  21. assert f"CREATE TABLE IF NOT EXISTS creation_knowledge.{table}" in text
  22. assert "ALTER TABLE creation_knowledge.candidate_items" not in text
  23. assert not Path("db/migrations/005_pipeline_trace_schema.sql").exists()
  24. def test_formal_schema_uses_deduped_contract_artifacts():
  25. text = Path("db/migrations/001_creation_knowledge_schema.sql").read_text(encoding="utf-8")
  26. assert "CREATE TABLE IF NOT EXISTS creation_knowledge.contract_artifacts" in text
  27. assert "CREATE TABLE IF NOT EXISTS creation_knowledge.run_contract_artifacts" in text
  28. assert "UNIQUE (contract_name, contract_type, source_path, content_hash)" in text
  29. assert "CREATE TABLE IF NOT EXISTS creation_knowledge.contract_snapshots" not in text
  30. assert not Path("db/migrations/005_pipeline_trace_schema.sql").exists()
  31. def test_postgres_decode_repository_writes_deduped_contract_artifacts():
  32. text = Path("decode_content/repositories/postgres.py").read_text(encoding="utf-8")
  33. assert "INSERT INTO contract_artifacts" in text
  34. assert "INSERT INTO run_contract_artifacts" in text
  35. assert "ON CONFLICT (contract_name, contract_type, source_path, content_hash)" in text
  36. assert "INSERT INTO contract_snapshots" not in text
  37. def test_sanitize_payload_omits_base64_data_urls_but_keeps_metadata():
  38. data_url = "data:video/mp4;base64," + ("a" * 120)
  39. payload = sanitize_payload(
  40. {"video": data_url, "url": "https://cdn.test/video.mp4"},
  41. max_chars=50,
  42. )
  43. assert payload["url"] == "https://cdn.test/video.mp4"
  44. assert payload["video"]["omitted"] == "base64_data_url"
  45. assert payload["video"]["chars"] == len(data_url)
  46. assert payload["video"]["sha256"]
  47. def test_trace_config_invalid_integer_falls_back_to_default(monkeypatch):
  48. monkeypatch.setenv("CK_TRACE_MAX_PAYLOAD_CHARS", "not-a-number")
  49. config = TraceConfig.from_env(".env.missing")
  50. assert config.max_payload_chars == 50_000
  51. class CollectingTraceWriter:
  52. def __init__(self):
  53. self.events = []
  54. self.hits = []
  55. self.calls = []
  56. def event(self, *, context, stage, event_type, **kwargs):
  57. self.events.append({"context": context, "stage": stage, "event_type": event_type, **kwargs})
  58. def candidate_hit(self, *, context, **kwargs):
  59. self.hits.append({"context": context, **kwargs})
  60. def llm_call(self, *, context, stage, substage=None, **kwargs):
  61. self.calls.append({"context": context, "stage": stage, "substage": substage, **kwargs})
  62. class CandidateRepo:
  63. def __init__(self):
  64. self.item_id = uuid4()
  65. self.job_id = uuid4()
  66. self.query_id = uuid4()
  67. def list_creation_candidate_items(self, *, run_id=None, limit=100):
  68. return [
  69. CandidateItem(
  70. id=self.item_id,
  71. job_id=self.job_id,
  72. query_id=self.query_id,
  73. platform="xiaohongshu",
  74. platform_item_id="x1",
  75. unique_key="xhs:x1",
  76. canonical_url="https://xhs.test/1",
  77. title="拍照姿势",
  78. status="candidate",
  79. )
  80. ]
  81. def list_media_assets_for_item(self, item_id):
  82. return [
  83. MediaAsset(
  84. id=uuid4(),
  85. item_id=item_id,
  86. media_type="image",
  87. cdn_url="https://cdn.test/1.jpg",
  88. position=1,
  89. status="done",
  90. )
  91. ]
  92. class DecodeService:
  93. def decode_post(self, *, item_id, post: Post):
  94. assert post.image_urls == ["https://cdn.test/1.jpg"]
  95. return DecodeWorkflowOutput(
  96. item_id=item_id,
  97. read_result=ReadResult(text="ok"),
  98. gate_result=GateResult(passed=True),
  99. status="decoded",
  100. )
  101. def test_decode_stage_emits_trace_events_without_changing_result():
  102. writer = CollectingTraceWriter()
  103. repo = CandidateRepo()
  104. pipeline_run_id = uuid4()
  105. result = run_decode_stage(
  106. candidate_repo=repo,
  107. decode_service=DecodeService(),
  108. trace_writer=writer,
  109. trace_context=TraceContext(pipeline_run_id=pipeline_run_id, stage="decode"),
  110. )
  111. assert result.decoded == 1
  112. event_types = [event["event_type"] for event in writer.events]
  113. assert event_types[0] == "decode_stage_started"
  114. assert "decode_item_started" in event_types
  115. assert "decode_item_finished" in event_types
  116. assert event_types[-1] == "decode_stage_finished"
  117. assert writer.events[1]["context"].pipeline_run_id == pipeline_run_id
  118. def test_backfill_pipeline_traces_dry_run_does_not_write(monkeypatch):
  119. acquisition_run_id = uuid4()
  120. monkeypatch.setattr(backfill_pipeline_traces.CreationDbConfig, "from_env", lambda _env_file: object())
  121. monkeypatch.setattr(backfill_pipeline_traces, "transaction", lambda _config: nullcontext(object()))
  122. monkeypatch.setattr(
  123. backfill_pipeline_traces,
  124. "_acquisition_runs",
  125. lambda _conn, limit=None: [
  126. {
  127. "id": acquisition_run_id,
  128. "run_key": "manual-api:test",
  129. "status": "done",
  130. "job_count": 3,
  131. "candidate_count": 12,
  132. }
  133. ],
  134. )
  135. result = backfill_pipeline_traces.backfill(env_file=".env", apply=False)
  136. assert result["dry_run"] is True
  137. assert result["acquisition_run_count"] == 1
  138. assert result["candidate_hit_count"] == 12
  139. assert result["runs"][0]["acquisition_run_id"] == str(acquisition_run_id)