pipeline.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. """流水线:把 6 步串起来,每步落库。INGEST_ENABLED=False 时只组装+存,不真实发送。
  2. 依赖都可注入(fetch/extract/chat/store),便于离线测试与替换平台。
  3. """
  4. from __future__ import annotations
  5. import logging
  6. from typing import Callable, Optional
  7. from creation_knowledge import media
  8. from creation_knowledge.config import Settings
  9. from creation_knowledge.integrations.crawler import CrawlerError, fetch_post_detail
  10. from creation_knowledge.integrations.db import CkStore
  11. from creation_knowledge.integrations.extractor import ExtractorError, GeminiExtractor
  12. from creation_knowledge.integrations.llm import ChatFn, default_chat
  13. from creation_knowledge.integrations.video_extract import VideoExtractError, extract_video
  14. from creation_knowledge.ingest import IngestError, ingest as real_ingest
  15. from creation_knowledge.models import ExtractedContent, Post
  16. from creation_knowledge.stages import (
  17. build_ingest_payload,
  18. deconstruct_item,
  19. screen_post,
  20. split_post,
  21. )
  22. logger = logging.getLogger(__name__)
  23. FetchFn = Callable[[str], Post]
  24. ExtractFn = Callable[[Post], ExtractedContent]
  25. def _skip_reason(post: Post) -> Optional[str]:
  26. """拿不到可提炼素材的帖子 → 跳过(不报错、不留脏数据)。在提取前判定。
  27. - video_no_direct_url:声明 video 但无视频直链(小红书视频帖结构性如此,详情只给封面)。
  28. - empty_media:既无图也无视频(接口降级 / 空帖)。
  29. """
  30. if (post.content_type or "").lower() == "video" and not post.video_urls:
  31. return "video_no_direct_url"
  32. if not post.image_urls and not post.video_urls:
  33. return "empty_media"
  34. return None
  35. def _process_one(
  36. url: str,
  37. *,
  38. settings: Settings,
  39. store: CkStore,
  40. fetch_fn: FetchFn,
  41. extract_fn: ExtractFn,
  42. chat: ChatFn,
  43. ingest_enabled: bool,
  44. ) -> dict:
  45. # 1) 拉取
  46. try:
  47. post = fetch_fn(url)
  48. except CrawlerError as exc:
  49. return {"url": url, "status": "fetch_failed", "error": str(exc)}
  50. store.upsert_post(post) # 图文卡片在此落;视频段卡在提取后补落
  51. # 1.5) 跳过判定:素材够不着的帖子直接 skip(如小红书视频帖)
  52. skip = _skip_reason(post)
  53. if skip:
  54. store.clear_items(post.id) # 幂等:本帖若是重跑,清掉上轮派生的片段
  55. store.update_stage(post.id, "skipped")
  56. return {"url": url, "post_id": post.id, "status": "skipped", "reason": skip}
  57. # 2) 多模态提取(图文=逐图;视频=原生整段视频→段卡,extract 内写 post.cards)
  58. try:
  59. content = extract_fn(post)
  60. except (ExtractorError, VideoExtractError) as exc:
  61. store.update_stage(post.id, "failed")
  62. return {"url": url, "post_id": post.id, "status": "extract_failed", "error": str(exc)}
  63. if post.cards:
  64. store.upsert_post(post) # 视频段卡落库(图文重复 upsert 无害)
  65. store.set_extracted(post.id, content.model_dump()) # stage=extracted
  66. store.clear_items(post.id) # 幂等:重跑先清旧片段,再写本轮(rejected 路径也保持干净)
  67. # 3) 筛选
  68. screening = screen_post(post, content, chat=chat)
  69. store.set_screening(post.id, screening.model_dump()) # stage=screened
  70. if not screening.passed:
  71. store.update_stage(post.id, "rejected")
  72. return {"url": url, "post_id": post.id, "status": "rejected",
  73. "score": screening.score, "reason": screening.reason}
  74. # 4) 拆分 -> 5) 解构 -> 6) 组装(+可选入库)
  75. items = split_post(post, content, chat=chat)
  76. item_ids = []
  77. for item in items:
  78. deco = deconstruct_item(item, chat=chat)
  79. payload = build_ingest_payload(post, item, deco)
  80. item_id = store.save_item(
  81. post.id, item.model_dump(), deco.model_dump(), payload.model_dump()
  82. )
  83. item_ids.append(item_id)
  84. if ingest_enabled:
  85. try:
  86. res = real_ingest(payload, settings=settings)
  87. store.update_item_ingest(item_id, "ingested", res.get("knowledge_id"))
  88. except IngestError:
  89. store.update_item_ingest(item_id, "failed", None)
  90. store.update_stage(post.id, "done")
  91. return {"url": url, "post_id": post.id, "status": "done", "items": len(item_ids)}
  92. def run_pipeline(
  93. urls: list[str],
  94. *,
  95. settings: Optional[Settings] = None,
  96. env_file: str = ".env",
  97. ingest_enabled: Optional[bool] = None,
  98. run_id: Optional[str] = None,
  99. store: Optional[CkStore] = None,
  100. fetch_fn: Optional[FetchFn] = None,
  101. extract_fn: Optional[ExtractFn] = None,
  102. chat: Optional[ChatFn] = None,
  103. ) -> list[dict]:
  104. settings = settings or Settings.from_env(env_file)
  105. ingest_enabled = settings.ingest_enabled if ingest_enabled is None else ingest_enabled
  106. batch = run_id or media.run_id()
  107. store = store or CkStore(settings.pg)
  108. fetch_fn = fetch_fn or (lambda url: fetch_post_detail(url, settings=settings))
  109. if extract_fn is None:
  110. _image_client = GeminiExtractor.from_env(env_file=env_file)
  111. def _dispatch_extract(post: Post) -> ExtractedContent:
  112. # settings.data_dir 非空 → 媒体落盘到 data/<batch>/<platform>/<post_id>/(解耦开关)
  113. tgt = (media.media_target(settings.data_dir, batch, post.platform, post.id)
  114. if settings.data_dir else None)
  115. # 视频帖 → 原生整段视频(OpenRouter base64);图文帖 → 逐图提取
  116. if (post.content_type or "").lower() == "video" or post.video_urls:
  117. return extract_video(
  118. post, settings=settings,
  119. save_path=(tgt.dir / "video.mp4") if tgt else None,
  120. public_url=(f"{tgt.public_base}/video.mp4") if tgt else None,
  121. )
  122. content = _image_client.extract(post)
  123. if tgt: # 图文帖:下载图片到本地并把 card.url 改写为 /data 路径
  124. media.save_images(post, tgt.dir, tgt.public_base)
  125. return content
  126. extract_fn = _dispatch_extract
  127. chat = chat or default_chat(env_file)
  128. results = []
  129. for url in urls:
  130. results.append(_process_one(
  131. url, settings=settings, store=store, fetch_fn=fetch_fn,
  132. extract_fn=extract_fn, chat=chat, ingest_enabled=ingest_enabled,
  133. ))
  134. return results