extractor.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. """多模态内容理解:把帖子的文本 + 图片(+视频) 交给 Gemini,提取真正的创作知识。
  2. 走 OpenRouter /chat/completions,模型 google/gemini-3-flash-preview。
  3. 消息格式(system + user.content 为 [text, image_url...] 列表)对齐 ContentFindAgentNew
  4. 的 gemini_video.GeminiVideoClient,但提示词换成「提取创作知识」,不做相关性审核。
  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 = "google/gemini-3-flash-preview"
  17. DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
  18. DEFAULT_TIMEOUT = 90.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 GeminiExtractor:
  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 OPENROUTER_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") -> "GeminiExtractor":
  54. source = dict(load_env_file(env_file))
  55. if env:
  56. source.update(env)
  57. api_key = source.get("OPENROUTER_API_KEY") or source.get("OPEN_ROUTER_API_KEY") or ""
  58. return cls(
  59. api_key=api_key,
  60. model=source.get("CONTENT_AGENT_VIDEO_LLM_MODEL") or DEFAULT_MODEL,
  61. base_url=source.get("OPENROUTER_BASE_URL") or DEFAULT_BASE_URL,
  62. timeout_seconds=float(source.get("CONTENT_AGENT_VIDEO_LLM_TIMEOUT_SECONDS") or DEFAULT_TIMEOUT),
  63. max_cards=int(source.get("CK_MAX_CARDS") or MAX_CARDS),
  64. )
  65. def _cards(self, post: Post) -> list[Card]:
  66. """取要送给模型的卡片:优先 post.cards;为空则回退 image_urls。"""
  67. cards = post.cards or [
  68. Card(index=i, kind="image", url=u)
  69. for i, u in enumerate(post.image_urls, start=1)
  70. ]
  71. if len(cards) > self.max_cards:
  72. dropped = [c.index for c in cards[self.max_cards:]]
  73. logger.warning("post %s 卡片数 %d 超过 MAX_CARDS=%d,截断丢弃卡片 %s",
  74. post.id, len(cards), self.max_cards, dropped)
  75. cards = cards[: self.max_cards]
  76. return cards
  77. def build_messages(self, post: Post) -> list[dict]:
  78. user_text = load_prompt("extract").format(
  79. title=post.title or "(无)",
  80. topics="、".join(post.topic_list) or "(无)",
  81. body=post.body_text or "(空)",
  82. )
  83. parts: list[dict] = [{"type": "text", "text": user_text}]
  84. # 每张卡片前插一个【卡片N】标签,再插图,便于模型按卡片归因
  85. for card in self._cards(post):
  86. parts.append({"type": "text", "text": _card_label(card)})
  87. parts.append({"type": "image_url", "image_url": {"url": card.url}})
  88. return [
  89. {"role": "system", "content": _SYSTEM_PROMPT},
  90. {"role": "user", "content": parts},
  91. ]
  92. def extract(self, post: Post) -> ExtractedContent:
  93. messages = self.build_messages(post)
  94. last_exc: Optional[Exception] = None
  95. for attempt in range(2):
  96. try:
  97. resp = self.http_post(
  98. f"{self.base_url}/chat/completions",
  99. headers={
  100. "Authorization": f"Bearer {self.api_key}",
  101. "Content-Type": "application/json",
  102. },
  103. json={"model": self.model, "messages": messages},
  104. timeout=self.timeout_seconds,
  105. )
  106. resp.raise_for_status()
  107. content = resp.json()["choices"][0]["message"]["content"]
  108. data = extract_json_object(content)
  109. cards = []
  110. for c in data.get("cards") or []:
  111. if isinstance(c, dict) and c.get("index") is not None:
  112. cards.append(CardExtract(
  113. index=int(c["index"]),
  114. content=str(c.get("content") or ""),
  115. ))
  116. return ExtractedContent(
  117. text=str(data.get("text") or ""),
  118. cards=cards,
  119. from_image=str(data.get("from_image") or ""),
  120. from_video=str(data.get("from_video") or ""),
  121. is_empty=to_bool(data.get("is_empty")),
  122. )
  123. except httpx.HTTPError as exc:
  124. last_exc = exc
  125. if attempt == 0:
  126. continue
  127. raise ExtractorError(f"gemini_http_error: {exc}") from exc
  128. except (KeyError, IndexError, TypeError, ValueError) as exc:
  129. last_exc = exc
  130. if attempt == 0:
  131. continue
  132. raise ExtractorError(f"gemini_response_invalid: {exc}") from exc
  133. raise ExtractorError(f"gemini_unknown_error: {last_exc}")
  134. def extract_content(
  135. post: Post,
  136. *,
  137. client: Optional[GeminiExtractor] = None,
  138. env_file: str = ".env",
  139. ) -> ExtractedContent:
  140. client = client or GeminiExtractor.from_env(env_file=env_file)
  141. return client.extract(post)