| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162 |
- """多模态内容理解:把帖子的文本 + 图片(+视频) 交给 Gemini,提取真正的创作知识。
- 走 OpenRouter /chat/completions,模型 google/gemini-3-flash-preview。
- 消息格式(system + user.content 为 [text, image_url...] 列表)对齐 ContentFindAgentNew
- 的 gemini_video.GeminiVideoClient,但提示词换成「提取创作知识」,不做相关性审核。
- 关键:知识常在图片里,不能只读 body_text —— 见 创作知识-重构设计.md。
- """
- from __future__ import annotations
- from typing import Any, Callable, Mapping, Optional
- import httpx
- import logging
- from core.config import load_env_file
- from core.jsonio import extract_json_object, to_bool
- from core.models import Card, CardExtract, ExtractedContent, Post
- from core.prompts import load_prompt
- logger = logging.getLogger(__name__)
- DEFAULT_MODEL = "google/gemini-3-flash-preview"
- DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
- DEFAULT_TIMEOUT = 90.0
- MAX_CARDS = 12 # 一次提取最多送多少张卡片(图/帧),控成本;超出截断并记日志
- def _card_label(card: Card) -> str:
- """给模型看的卡片标签,如 【卡片1】 或 【卡片3 · 00:12】。card.index 为 1-based。"""
- if card.kind == "frame" and card.timestamp is not None:
- ts = int(card.timestamp)
- return f"【卡片{card.index} · {ts // 60:02d}:{ts % 60:02d}】"
- return f"【卡片{card.index}】"
- _SYSTEM_PROMPT = (
- "你是创作知识提取助手。从给定的小红书帖子(标题、正文、图片、视频)中,"
- "提取真正能指导『如何创作内容』的知识;知识常在图片/视频里而非正文。"
- "忠实提取、不编造。只输出一个 JSON 对象,不要解释或 markdown。"
- )
- class ExtractorError(RuntimeError):
- pass
- class GeminiExtractor:
- def __init__(
- self,
- *,
- api_key: str,
- model: str = DEFAULT_MODEL,
- base_url: str = DEFAULT_BASE_URL,
- timeout_seconds: float = DEFAULT_TIMEOUT,
- http_post: Callable[..., Any] = httpx.post,
- max_cards: int = MAX_CARDS,
- ) -> None:
- if not api_key:
- raise ExtractorError("missing OPENROUTER_API_KEY")
- self.api_key = api_key
- self.model = model
- self.base_url = base_url.rstrip("/")
- self.timeout_seconds = timeout_seconds
- self.http_post = http_post
- self.max_cards = max_cards
- @classmethod
- def from_env(cls, env: Mapping[str, str] | None = None, env_file: str = ".env") -> "GeminiExtractor":
- source = dict(load_env_file(env_file))
- if env:
- source.update(env)
- api_key = source.get("OPENROUTER_API_KEY") or source.get("OPEN_ROUTER_API_KEY") or ""
- return cls(
- api_key=api_key,
- model=source.get("CONTENT_AGENT_VIDEO_LLM_MODEL") or DEFAULT_MODEL,
- base_url=source.get("OPENROUTER_BASE_URL") or DEFAULT_BASE_URL,
- timeout_seconds=float(source.get("CONTENT_AGENT_VIDEO_LLM_TIMEOUT_SECONDS") or DEFAULT_TIMEOUT),
- max_cards=int(source.get("CK_MAX_CARDS") or MAX_CARDS),
- )
- def _cards(self, post: Post) -> list[Card]:
- """取要送给模型的卡片:优先 post.cards;为空则回退 image_urls。"""
- cards = post.cards or [
- Card(index=i, kind="image", url=u)
- for i, u in enumerate(post.image_urls, start=1)
- ]
- if len(cards) > self.max_cards:
- dropped = [c.index for c in cards[self.max_cards:]]
- logger.warning("post %s 卡片数 %d 超过 MAX_CARDS=%d,截断丢弃卡片 %s",
- post.id, len(cards), self.max_cards, dropped)
- cards = cards[: self.max_cards]
- return cards
- def build_messages(self, post: Post) -> list[dict]:
- user_text = load_prompt("extract").format(
- title=post.title or "(无)",
- topics="、".join(post.topic_list) or "(无)",
- body=post.body_text or "(空)",
- )
- parts: list[dict] = [{"type": "text", "text": user_text}]
- # 每张卡片前插一个【卡片N】标签,再插图,便于模型按卡片归因
- for card in self._cards(post):
- parts.append({"type": "text", "text": _card_label(card)})
- parts.append({"type": "image_url", "image_url": {"url": card.url}})
- return [
- {"role": "system", "content": _SYSTEM_PROMPT},
- {"role": "user", "content": parts},
- ]
- def extract(self, post: Post) -> ExtractedContent:
- messages = self.build_messages(post)
- last_exc: Optional[Exception] = None
- for attempt in range(2):
- try:
- resp = self.http_post(
- f"{self.base_url}/chat/completions",
- headers={
- "Authorization": f"Bearer {self.api_key}",
- "Content-Type": "application/json",
- },
- json={"model": self.model, "messages": messages},
- timeout=self.timeout_seconds,
- )
- resp.raise_for_status()
- content = resp.json()["choices"][0]["message"]["content"]
- data = extract_json_object(content)
- cards = []
- for c in data.get("cards") or []:
- if isinstance(c, dict) and c.get("index") is not None:
- cards.append(CardExtract(
- index=int(c["index"]),
- content=str(c.get("content") or ""),
- ))
- return ExtractedContent(
- text=str(data.get("text") or ""),
- cards=cards,
- from_image=str(data.get("from_image") or ""),
- from_video=str(data.get("from_video") or ""),
- is_empty=to_bool(data.get("is_empty")),
- )
- except httpx.HTTPError as exc:
- last_exc = exc
- if attempt == 0:
- continue
- raise ExtractorError(f"gemini_http_error: {exc}") from exc
- except (KeyError, IndexError, TypeError, ValueError) as exc:
- last_exc = exc
- if attempt == 0:
- continue
- raise ExtractorError(f"gemini_response_invalid: {exc}") from exc
- raise ExtractorError(f"gemini_unknown_error: {last_exc}")
- def extract_content(
- post: Post,
- *,
- client: Optional[GeminiExtractor] = None,
- env_file: str = ".env",
- ) -> ExtractedContent:
- client = client or GeminiExtractor.from_env(env_file=env_file)
- return client.extract(post)
|