| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- from __future__ import annotations
- from uuid import UUID, uuid4
- import pytest
- from acquisition.domain import CandidateItem, MediaAsset
- from acquisition.runner import RunBatchResult
- from core.models import Post
- from decode_content.models import GateResult, ReadResult
- from decode_content.service import DecodeWorkflowOutput
- from pipeline import acquisition_runner, creation_pipeline
- from pipeline.acquisition_runner import AcquisitionStageResult
- from pipeline.decode_runner import run_decode_stage
- from pipeline.decode_runner import DecodeBatchResult
- from pipeline.dedupe import dedupe_candidate_items, item_dedupe_key, should_decode_item
- from pipeline.models import PipelineJob, PipelineRun
- class FakePipelineRepo:
- def __init__(self):
- self.jobs = []
- def create_pipeline_run(self, **kwargs):
- return PipelineRun(id=uuid4(), **kwargs)
- def save_pipeline_job(self, **kwargs):
- job = PipelineJob(id=uuid4(), **kwargs)
- self.jobs.append(job)
- return job
- def mark_job_status(self, job_id, **kwargs):
- job = next(row for row in self.jobs if row.id == job_id)
- updated = job.model_copy(update=kwargs)
- self.jobs.append(updated)
- return updated
- def get_resume_cursor(self, run_id):
- return None
- def test_dedupe_candidate_items_prefers_unique_key_then_platform_item_id_then_url():
- first = CandidateItem(
- id=uuid4(),
- platform="xiaohongshu",
- platform_item_id="x1",
- unique_key="xhs:x1",
- canonical_url="https://a",
- )
- dup = CandidateItem(
- id=uuid4(),
- platform="xiaohongshu",
- platform_item_id="x1",
- unique_key="xhs:x1",
- canonical_url="https://b",
- )
- platform_id_only = CandidateItem(id=uuid4(), platform="douyin", platform_item_id="dy1")
- url_only = CandidateItem(id=uuid4(), platform="weixin", canonical_url="HTTPS://MP.TEST/A")
- assert item_dedupe_key(first) == "unique_key:xhs:x1"
- assert item_dedupe_key(platform_id_only) == "douyin:id:dy1"
- assert item_dedupe_key(url_only) == "url:https://mp.test/a"
- assert dedupe_candidate_items([first, dup, platform_id_only, url_only]) == [
- first,
- platform_id_only,
- url_only,
- ]
- assert should_decode_item(first, decoded_item_ids={str(first.id)}).keep is False
- def test_acquisition_stage_wraps_existing_runner(monkeypatch):
- batch_id = uuid4()
- pipeline_run = PipelineRun(id=uuid4(), status="running", current_stage="search")
- pipeline_repo = FakePipelineRepo()
- def fake_run_batch(acquisition_repo, *, batch_id, settings, **kwargs):
- return RunBatchResult(run_id=uuid4(), total_jobs=1, done=1, partial=0, failed=0, skipped=0)
- monkeypatch.setattr(acquisition_runner, "run_batch", fake_run_batch)
- result = acquisition_runner.run_acquisition_stage(
- acquisition_repo=object(),
- batch_id=batch_id,
- settings=object(),
- pipeline_repo=pipeline_repo,
- pipeline_run=pipeline_run,
- )
- assert result.acquisition.done == 1
- assert pipeline_repo.jobs[0].stage == "search"
- assert pipeline_repo.jobs[-1].status == "done"
- def test_acquisition_stage_marks_pipeline_job_failed_and_reraises(monkeypatch):
- pipeline_run = PipelineRun(id=uuid4(), status="running", current_stage="search")
- pipeline_repo = FakePipelineRepo()
- def fake_run_batch(*args, **kwargs):
- raise RuntimeError("acquisition exploded")
- monkeypatch.setattr(acquisition_runner, "run_batch", fake_run_batch)
- with pytest.raises(RuntimeError, match="acquisition exploded"):
- acquisition_runner.run_acquisition_stage(
- acquisition_repo=object(),
- batch_id=uuid4(),
- settings=object(),
- pipeline_repo=pipeline_repo,
- pipeline_run=pipeline_run,
- )
- assert pipeline_repo.jobs[0].status == "running"
- assert pipeline_repo.jobs[-1].status == "failed"
- assert pipeline_repo.jobs[-1].error_message == "acquisition exploded"
- class FakeCandidateRepo:
- def __init__(self):
- self.item_id = uuid4()
- self.items = [
- CandidateItem(
- id=self.item_id,
- platform="xiaohongshu",
- platform_item_id="x1",
- canonical_url="https://xhs/1",
- title="脚本",
- status="candidate",
- ),
- CandidateItem(
- id=uuid4(),
- platform="xiaohongshu",
- platform_item_id="x1",
- title="重复",
- status="candidate",
- ),
- ]
- def list_creation_candidate_items(self, *, run_id: UUID | None = None, limit: int = 100):
- return self.items
- def list_media_assets_for_item(self, item_id: UUID):
- return [
- MediaAsset(
- id=uuid4(),
- item_id=item_id,
- media_type="image",
- cdn_url="https://cdn.test/1.jpg",
- position=1,
- status="done",
- )
- ]
- class FakeDecodeService:
- def __init__(self):
- self.posts: list[Post] = []
- def decode_post(self, *, item_id: UUID, post: Post):
- self.posts.append(post)
- return DecodeWorkflowOutput(
- item_id=item_id,
- read_result=ReadResult(text="ok"),
- gate_result=GateResult(passed=True),
- status="decoded",
- )
- def test_decode_stage_loads_candidate_media_and_dedupes():
- repo = FakeCandidateRepo()
- service = FakeDecodeService()
- result = run_decode_stage(candidate_repo=repo, decode_service=service)
- assert result.total == 1
- assert result.decoded == 1
- assert result.failed == 0
- assert service.posts[0].image_urls == ["https://cdn.test/1.jpg"]
- def test_creation_pipeline_orchestrates_acquisition_then_decode(monkeypatch):
- batch_id = uuid4()
- acquisition_run_id = uuid4()
- pipeline_repo = FakePipelineRepo()
- calls = {}
- def fake_acquisition_stage(**kwargs):
- calls["acquisition"] = kwargs
- return AcquisitionStageResult(
- pipeline_job=None,
- acquisition=RunBatchResult(
- run_id=acquisition_run_id,
- total_jobs=3,
- done=3,
- partial=0,
- failed=0,
- skipped=0,
- ),
- )
- def fake_decode_stage(**kwargs):
- calls["decode"] = kwargs
- return DecodeBatchResult(total=2, decoded=2, skipped=0, failed=0, outputs=[])
- monkeypatch.setattr(creation_pipeline, "run_acquisition_stage", fake_acquisition_stage)
- monkeypatch.setattr(creation_pipeline, "run_decode_stage", fake_decode_stage)
- result = creation_pipeline.run_creation_pipeline(
- acquisition_repo=object(),
- batch_id=batch_id,
- settings=object(),
- pipeline_repo=pipeline_repo,
- decode_service=object(),
- decode_limit=20,
- )
- assert result.pipeline_run is not None
- assert result.pipeline_run.batch_id == batch_id
- assert result.acquisition.acquisition.done == 3
- assert result.decode is not None
- assert result.decode.decoded == 2
- assert calls["acquisition"]["pipeline_run"] == result.pipeline_run
- assert calls["decode"]["run_id"] == acquisition_run_id
- assert calls["decode"]["limit"] == 20
|