extractor.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. """多模态内容理解:把帖子的文本 + 图片交给阿里云百炼/Qwen,提取真正的创作知识。
  2. 走百炼 OpenAI-compatible /chat/completions,默认 qwen-vl-plus。
  3. 消息格式(system + user.content 为 [text, image_url...] 列表)对齐 ContentFindAgentNew
  4. 的多模态消息格式,但提示词换成「提取创作知识」,不做相关性审核。
  5. 关键:知识常在图片里,不能只读 body_text —— 见 创作知识-重构设计.md。
  6. """
  7. from __future__ import annotations
  8. from typing import Any, Callable, Mapping, Optional
  9. import httpx
  10. import logging
  11. from core.config import load_env_file
  12. from core.jsonio import extract_json_object, to_bool
  13. from core.models import Card, CardExtract, ExtractedContent, Post
  14. from core.prompts import load_prompt
  15. logger = logging.getLogger(__name__)
  16. DEFAULT_MODEL = "qwen-vl-plus"
  17. DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
  18. DEFAULT_TIMEOUT = 120.0
  19. MAX_CARDS = 12 # 一次提取最多送多少张卡片(图/帧),控成本;超出截断并记日志
  20. def _card_label(card: Card) -> str:
  21. """给模型看的卡片标签,如 【卡片1】 或 【卡片3 · 00:12】。card.index 为 1-based。"""
  22. if card.kind == "frame" and card.timestamp is not None:
  23. ts = int(card.timestamp)
  24. return f"【卡片{card.index} · {ts // 60:02d}:{ts % 60:02d}】"
  25. return f"【卡片{card.index}】"
  26. _SYSTEM_PROMPT = (
  27. "你是创作知识提取助手。从给定的小红书帖子(标题、正文、图片、视频)中,"
  28. "提取真正能指导『如何创作内容』的知识;知识常在图片/视频里而非正文。"
  29. "忠实提取、不编造。只输出一个 JSON 对象,不要解释或 markdown。"
  30. )
  31. class ExtractorError(RuntimeError):
  32. pass
  33. class BailianExtractor:
  34. def __init__(
  35. self,
  36. *,
  37. api_key: str,
  38. model: str = DEFAULT_MODEL,
  39. base_url: str = DEFAULT_BASE_URL,
  40. timeout_seconds: float = DEFAULT_TIMEOUT,
  41. http_post: Callable[..., Any] = httpx.post,
  42. max_cards: int = MAX_CARDS,
  43. ) -> None:
  44. if not api_key:
  45. raise ExtractorError("missing ALIYUN_BAILIAN_API_KEY")
  46. self.api_key = api_key
  47. self.model = model
  48. self.base_url = base_url.rstrip("/")
  49. self.timeout_seconds = timeout_seconds
  50. self.http_post = http_post
  51. self.max_cards = max_cards
  52. @classmethod
  53. def from_env(cls, env: Mapping[str, str] | None = None, env_file: str = ".env") -> "BailianExtractor":
  54. source = dict(load_env_file(env_file))
  55. if env:
  56. source.update(env)
  57. api_key = source.get("ALIYUN_BAILIAN_API_KEY") or ""
  58. return cls(
  59. api_key=api_key,
  60. model=source.get("ALIYUN_BAILIAN_VL_MODEL")
  61. or source.get("ALIYUN_BAILIAN_MODEL")
  62. or DEFAULT_MODEL,
  63. base_url=source.get("ALIYUN_BAILIAN_BASE_URL") or DEFAULT_BASE_URL,
  64. timeout_seconds=float(source.get("ALIYUN_BAILIAN_TIMEOUT_SECONDS") or DEFAULT_TIMEOUT),
  65. max_cards=int(source.get("CK_MAX_CARDS") or MAX_CARDS),
  66. )
  67. def _cards(self, post: Post) -> list[Card]:
  68. """取要送给模型的卡片:优先 post.cards;为空则回退 image_urls。"""
  69. cards = post.cards or [
  70. Card(index=i, kind="image", url=u)
  71. for i, u in enumerate(post.image_urls, start=1)
  72. ]
  73. if len(cards) > self.max_cards:
  74. dropped = [c.index for c in cards[self.max_cards:]]
  75. logger.warning("post %s 卡片数 %d 超过 MAX_CARDS=%d,截断丢弃卡片 %s",
  76. post.id, len(cards), self.max_cards, dropped)
  77. cards = cards[: self.max_cards]
  78. return cards
  79. def build_messages(self, post: Post) -> list[dict]:
  80. user_text = load_prompt("extract").format(
  81. title=post.title or "(无)",
  82. topics="、".join(post.topic_list) or "(无)",
  83. body=post.body_text or "(空)",
  84. )
  85. parts: list[dict] = [{"type": "text", "text": user_text}]
  86. # 每张卡片前插一个【卡片N】标签,再插图,便于模型按卡片归因
  87. for card in self._cards(post):
  88. parts.append({"type": "text", "text": _card_label(card)})
  89. parts.append({"type": "image_url", "image_url": {"url": card.url}})
  90. return [
  91. {"role": "system", "content": _SYSTEM_PROMPT},
  92. {"role": "user", "content": parts},
  93. ]
  94. def extract(self, post: Post) -> ExtractedContent:
  95. messages = self.build_messages(post)
  96. last_exc: Optional[Exception] = None
  97. for attempt in range(2):
  98. try:
  99. resp = self.http_post(
  100. f"{self.base_url}/chat/completions",
  101. headers={
  102. "Authorization": f"Bearer {self.api_key}",
  103. "Content-Type": "application/json",
  104. },
  105. json={"model": self.model, "messages": messages},
  106. timeout=self.timeout_seconds,
  107. )
  108. resp.raise_for_status()
  109. content = resp.json()["choices"][0]["message"]["content"]
  110. data = extract_json_object(content)
  111. cards = []
  112. for c in data.get("cards") or []:
  113. if isinstance(c, dict) and c.get("index") is not None:
  114. cards.append(CardExtract(
  115. index=int(c["index"]),
  116. content=str(c.get("content") or ""),
  117. ))
  118. return ExtractedContent(
  119. text=str(data.get("text") or ""),
  120. cards=cards,
  121. from_image=str(data.get("from_image") or ""),
  122. from_video=str(data.get("from_video") or ""),
  123. is_empty=to_bool(data.get("is_empty")),
  124. )
  125. except httpx.HTTPError as exc:
  126. last_exc = exc
  127. if attempt == 0:
  128. continue
  129. raise ExtractorError(f"bailian_http_error: {exc}") from exc
  130. except (KeyError, IndexError, TypeError, ValueError) as exc:
  131. last_exc = exc
  132. if attempt == 0:
  133. continue
  134. raise ExtractorError(f"bailian_response_invalid: {exc}") from exc
  135. raise ExtractorError(f"bailian_unknown_error: {last_exc}")
  136. # 兼容旧测试和脚本导入名;实际实现已经切到百炼/Qwen。
  137. GeminiExtractor = BailianExtractor
  138. def extract_content(
  139. post: Post,
  140. *,
  141. client: Optional[BailianExtractor] = None,
  142. env_file: str = ".env",
  143. ) -> ExtractedContent:
  144. client = client or GeminiExtractor.from_env(env_file=env_file)
  145. return client.extract(post)