test_pipeline_e2e.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. """M5 离线编排测试:注入 fake store/fetch/extract/chat,验证 done 与 rejected 两条路径。
  2. 真实落库的 e2e 见 scripts/run_batch.py(在能连 DB 的云端跑)。
  3. """
  4. from __future__ import annotations
  5. import json
  6. from pathlib import Path
  7. from creation_knowledge.config import PgConfig, Settings
  8. from creation_knowledge.integrations.crawler import parse_detail_response
  9. from creation_knowledge.models import ExtractedContent, Post
  10. from creation_knowledge.pipeline import run_pipeline
  11. FIXTURES = Path(__file__).parent / "fixtures"
  12. CID = "67e4bdf50000000006028a59"
  13. def _settings(data_dir: str = "") -> Settings:
  14. return Settings(
  15. pg=PgConfig(host="h", port=5432, user="u", password="p", database="d"),
  16. crawler_base_url="http://x", crawler_key="", crawler_timeout=30,
  17. video_model="m", gemini_api_key="", openrouter_base_url="http://x",
  18. openrouter_api_key="k", llm_model="m", knowhub_api="http://x",
  19. ingest_enabled=False, max_cards=12, frames_dir="runtime/frames",
  20. douyin_ratio="540p", data_dir=data_dir,
  21. )
  22. class FakeStore:
  23. def __init__(self):
  24. self.posts: dict = {}
  25. self.items: list = []
  26. def upsert_post(self, post):
  27. self.posts[post.id] = {"stage": "fetched"}
  28. def set_extracted(self, pid, ex):
  29. self.posts[pid].update(stage="extracted", extracted=ex)
  30. def set_screening(self, pid, sc):
  31. self.posts[pid].update(stage="screened", screening=sc)
  32. def update_stage(self, pid, stage):
  33. self.posts[pid]["stage"] = stage
  34. def save_item(self, pid, item, deco, payload):
  35. self.items.append({"post_id": pid, "item": item, "deco": deco, "payload": payload})
  36. return len(self.items)
  37. def update_item_ingest(self, iid, status, kid):
  38. self.items[iid - 1].update(ingest_status=status, knowledge_id=kid)
  39. def clear_items(self, pid):
  40. before = len(self.items)
  41. self.items = [it for it in self.items if it["post_id"] != pid]
  42. return before - len(self.items)
  43. def _fetch(url):
  44. resp = json.loads((FIXTURES / f"xhs_case_{CID}.json").read_text("utf-8"))
  45. return parse_detail_response(resp, fallback_content_id=CID)
  46. def _extract(post):
  47. return ExtractedContent(text=post.body_text or "内容", is_empty=False)
  48. def _chat_pass(system, user):
  49. if "筛选" in system:
  50. return {"passed": True, "score": 8, "reason": "ok"}
  51. if "拆分" in system:
  52. return {"items": [{"title": "脚本要素", "knowledge_types": ["how"],
  53. "what": "null", "why": "null", "how": "逐个填要素",
  54. "evidence": ["原句"]}]}
  55. if "解构" in system:
  56. return {"stages": ["脚本"], "scopes": [{"scope_type": "form", "value": "操作流程"}],
  57. "stage_reason": "r", "scope_reason": "r"}
  58. raise AssertionError(f"unexpected system: {system}")
  59. def _chat_reject(system, user):
  60. if "筛选" in system:
  61. return {"passed": False, "score": 2, "reason": "只是作品本身"}
  62. raise AssertionError("rejected post 不应进入拆分/解构")
  63. def test_pipeline_done_path():
  64. store = FakeStore()
  65. results = run_pipeline(
  66. ["https://www.xiaohongshu.com/explore/" + CID],
  67. settings=_settings(), ingest_enabled=False, store=store,
  68. fetch_fn=_fetch, extract_fn=_extract, chat=_chat_pass,
  69. )
  70. r = results[0]
  71. assert r["status"] == "done" and r["items"] == 1
  72. pid = f"xhs_{CID}"
  73. assert store.posts[pid]["stage"] == "done"
  74. assert len(store.items) == 1
  75. saved = store.items[0]
  76. assert saved["payload"]["source"]["id"] == pid
  77. assert saved["payload"]["dim_attributes"] == ["how"]
  78. def test_pipeline_rejected_path():
  79. store = FakeStore()
  80. results = run_pipeline(
  81. [CID], settings=_settings(), ingest_enabled=False, store=store,
  82. fetch_fn=_fetch, extract_fn=_extract, chat=_chat_reject,
  83. )
  84. r = results[0]
  85. assert r["status"] == "rejected"
  86. assert store.posts[f"xhs_{CID}"]["stage"] == "rejected"
  87. assert store.items == []
  88. def _extract_must_not_run(post):
  89. raise AssertionError("skip 的帖子不应进入提取")
  90. def test_pipeline_skip_xhs_video():
  91. """小红书视频帖:content_type=video 但无视频直链 → skip,不进提取/筛选。"""
  92. store = FakeStore()
  93. post = Post(id="xhs_vid1", platform="xiaohongshu", url="u", content_id="vid1",
  94. content_type="video", video_urls=[], image_urls=["http://cover.jpg"])
  95. results = run_pipeline(
  96. ["vid1"], settings=_settings(), ingest_enabled=False, store=store,
  97. fetch_fn=lambda url: post, extract_fn=_extract_must_not_run, chat=_chat_pass,
  98. )
  99. r = results[0]
  100. assert r["status"] == "skipped" and r["reason"] == "video_no_direct_url"
  101. assert store.posts["xhs_vid1"]["stage"] == "skipped"
  102. assert store.items == []
  103. def test_pipeline_skip_empty_media():
  104. """既无图也无视频 → skip(empty_media)。"""
  105. store = FakeStore()
  106. post = Post(id="xhs_empty", platform="xiaohongshu", url="u", content_id="empty",
  107. content_type="normal", video_urls=[], image_urls=[])
  108. results = run_pipeline(
  109. ["empty"], settings=_settings(), ingest_enabled=False, store=store,
  110. fetch_fn=lambda url: post, extract_fn=_extract_must_not_run, chat=_chat_pass,
  111. )
  112. assert results[0]["status"] == "skipped" and results[0]["reason"] == "empty_media"
  113. assert store.items == []
  114. def test_pipeline_idempotent_rerun():
  115. """同帖重跑:知识片段先清后写,不叠加。"""
  116. store = FakeStore()
  117. for _ in range(3):
  118. run_pipeline(
  119. [CID], settings=_settings(), ingest_enabled=False, store=store,
  120. fetch_fn=_fetch, extract_fn=_extract, chat=_chat_pass,
  121. )
  122. assert len(store.items) == 1, "重跑三次仍应只有 1 条(先清后写)"