test_pipeline_formal.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. from __future__ import annotations
  2. from uuid import UUID, uuid4
  3. import pytest
  4. from acquisition.domain import CandidateItem, MediaAsset
  5. from acquisition.runner import RunBatchResult
  6. from core.models import Post
  7. from decode_content.models import GateResult, ReadResult
  8. from decode_content.service import DecodeWorkflowOutput
  9. from pipeline import acquisition_runner, creation_pipeline
  10. from pipeline.acquisition_runner import AcquisitionStageResult
  11. from pipeline.decode_runner import run_decode_stage
  12. from pipeline.decode_runner import DecodeBatchResult
  13. from pipeline.dedupe import dedupe_candidate_items, item_dedupe_key, should_decode_item
  14. from pipeline.models import PipelineJob, PipelineRun
  15. class FakePipelineRepo:
  16. def __init__(self):
  17. self.jobs = []
  18. def create_pipeline_run(self, **kwargs):
  19. return PipelineRun(id=uuid4(), **kwargs)
  20. def save_pipeline_job(self, **kwargs):
  21. job = PipelineJob(id=uuid4(), **kwargs)
  22. self.jobs.append(job)
  23. return job
  24. def mark_job_status(self, job_id, **kwargs):
  25. job = next(row for row in self.jobs if row.id == job_id)
  26. updated = job.model_copy(update=kwargs)
  27. self.jobs.append(updated)
  28. return updated
  29. def get_resume_cursor(self, run_id):
  30. return None
  31. def test_dedupe_candidate_items_prefers_unique_key_then_platform_item_id_then_url():
  32. first = CandidateItem(
  33. id=uuid4(),
  34. platform="xiaohongshu",
  35. platform_item_id="x1",
  36. unique_key="xhs:x1",
  37. canonical_url="https://a",
  38. )
  39. dup = CandidateItem(
  40. id=uuid4(),
  41. platform="xiaohongshu",
  42. platform_item_id="x1",
  43. unique_key="xhs:x1",
  44. canonical_url="https://b",
  45. )
  46. platform_id_only = CandidateItem(id=uuid4(), platform="douyin", platform_item_id="dy1")
  47. url_only = CandidateItem(id=uuid4(), platform="weixin", canonical_url="HTTPS://MP.TEST/A")
  48. assert item_dedupe_key(first) == "unique_key:xhs:x1"
  49. assert item_dedupe_key(platform_id_only) == "douyin:id:dy1"
  50. assert item_dedupe_key(url_only) == "url:https://mp.test/a"
  51. assert dedupe_candidate_items([first, dup, platform_id_only, url_only]) == [
  52. first,
  53. platform_id_only,
  54. url_only,
  55. ]
  56. assert should_decode_item(first, decoded_item_ids={str(first.id)}).keep is False
  57. def test_acquisition_stage_wraps_existing_runner(monkeypatch):
  58. batch_id = uuid4()
  59. pipeline_run = PipelineRun(id=uuid4(), status="running", current_stage="search")
  60. pipeline_repo = FakePipelineRepo()
  61. def fake_run_batch(acquisition_repo, *, batch_id, settings, **kwargs):
  62. return RunBatchResult(run_id=uuid4(), total_jobs=1, done=1, partial=0, failed=0, skipped=0)
  63. monkeypatch.setattr(acquisition_runner, "run_batch", fake_run_batch)
  64. result = acquisition_runner.run_acquisition_stage(
  65. acquisition_repo=object(),
  66. batch_id=batch_id,
  67. settings=object(),
  68. pipeline_repo=pipeline_repo,
  69. pipeline_run=pipeline_run,
  70. )
  71. assert result.acquisition.done == 1
  72. assert pipeline_repo.jobs[0].stage == "search"
  73. assert pipeline_repo.jobs[-1].status == "done"
  74. def test_acquisition_stage_marks_pipeline_job_failed_and_reraises(monkeypatch):
  75. pipeline_run = PipelineRun(id=uuid4(), status="running", current_stage="search")
  76. pipeline_repo = FakePipelineRepo()
  77. def fake_run_batch(*args, **kwargs):
  78. raise RuntimeError("acquisition exploded")
  79. monkeypatch.setattr(acquisition_runner, "run_batch", fake_run_batch)
  80. with pytest.raises(RuntimeError, match="acquisition exploded"):
  81. acquisition_runner.run_acquisition_stage(
  82. acquisition_repo=object(),
  83. batch_id=uuid4(),
  84. settings=object(),
  85. pipeline_repo=pipeline_repo,
  86. pipeline_run=pipeline_run,
  87. )
  88. assert pipeline_repo.jobs[0].status == "running"
  89. assert pipeline_repo.jobs[-1].status == "failed"
  90. assert pipeline_repo.jobs[-1].error_message == "acquisition exploded"
  91. class FakeCandidateRepo:
  92. def __init__(self):
  93. self.item_id = uuid4()
  94. self.items = [
  95. CandidateItem(
  96. id=self.item_id,
  97. platform="xiaohongshu",
  98. platform_item_id="x1",
  99. canonical_url="https://xhs/1",
  100. title="脚本",
  101. status="candidate",
  102. ),
  103. CandidateItem(
  104. id=uuid4(),
  105. platform="xiaohongshu",
  106. platform_item_id="x1",
  107. title="重复",
  108. status="candidate",
  109. ),
  110. ]
  111. def list_creation_candidate_items(self, *, run_id: UUID | None = None, limit: int = 100):
  112. return self.items
  113. def list_media_assets_for_item(self, item_id: UUID):
  114. return [
  115. MediaAsset(
  116. id=uuid4(),
  117. item_id=item_id,
  118. media_type="image",
  119. cdn_url="https://cdn.test/1.jpg",
  120. position=1,
  121. status="done",
  122. )
  123. ]
  124. class FakeDecodeService:
  125. def __init__(self):
  126. self.posts: list[Post] = []
  127. def decode_post(self, *, item_id: UUID, post: Post):
  128. self.posts.append(post)
  129. return DecodeWorkflowOutput(
  130. item_id=item_id,
  131. read_result=ReadResult(text="ok"),
  132. gate_result=GateResult(passed=True),
  133. status="decoded",
  134. )
  135. def test_decode_stage_loads_candidate_media_and_dedupes():
  136. repo = FakeCandidateRepo()
  137. service = FakeDecodeService()
  138. result = run_decode_stage(candidate_repo=repo, decode_service=service)
  139. assert result.total == 1
  140. assert result.decoded == 1
  141. assert result.failed == 0
  142. assert service.posts[0].image_urls == ["https://cdn.test/1.jpg"]
  143. def test_creation_pipeline_orchestrates_acquisition_then_decode(monkeypatch):
  144. batch_id = uuid4()
  145. acquisition_run_id = uuid4()
  146. pipeline_repo = FakePipelineRepo()
  147. calls = {}
  148. def fake_acquisition_stage(**kwargs):
  149. calls["acquisition"] = kwargs
  150. return AcquisitionStageResult(
  151. pipeline_job=None,
  152. acquisition=RunBatchResult(
  153. run_id=acquisition_run_id,
  154. total_jobs=3,
  155. done=3,
  156. partial=0,
  157. failed=0,
  158. skipped=0,
  159. ),
  160. )
  161. def fake_decode_stage(**kwargs):
  162. calls["decode"] = kwargs
  163. return DecodeBatchResult(total=2, decoded=2, skipped=0, failed=0, outputs=[])
  164. monkeypatch.setattr(creation_pipeline, "run_acquisition_stage", fake_acquisition_stage)
  165. monkeypatch.setattr(creation_pipeline, "run_decode_stage", fake_decode_stage)
  166. result = creation_pipeline.run_creation_pipeline(
  167. acquisition_repo=object(),
  168. batch_id=batch_id,
  169. settings=object(),
  170. pipeline_repo=pipeline_repo,
  171. decode_service=object(),
  172. decode_limit=20,
  173. )
  174. assert result.pipeline_run is not None
  175. assert result.pipeline_run.batch_id == batch_id
  176. assert result.acquisition.acquisition.done == 3
  177. assert result.decode is not None
  178. assert result.decode.decoded == 2
  179. assert calls["acquisition"]["pipeline_run"] == result.pipeline_run
  180. assert calls["decode"]["run_id"] == acquisition_run_id
  181. assert calls["decode"]["limit"] == 20