test_pipeline_tracing.py 8.1 KB

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