extractor.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  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. from creation_knowledge.config import load_env_file
  11. from creation_knowledge.jsonio import extract_json_object, to_bool
  12. from creation_knowledge.models import ExtractedContent, Post
  13. from creation_knowledge.prompts import load_prompt
  14. DEFAULT_MODEL = "google/gemini-3-flash-preview"
  15. DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
  16. DEFAULT_TIMEOUT = 90.0
  17. MAX_IMAGES = 6
  18. _SYSTEM_PROMPT = (
  19. "你是创作知识提取助手。从给定的小红书帖子(标题、正文、图片、视频)中,"
  20. "提取真正能指导『如何创作内容』的知识;知识常在图片/视频里而非正文。"
  21. "忠实提取、不编造。只输出一个 JSON 对象,不要解释或 markdown。"
  22. )
  23. class ExtractorError(RuntimeError):
  24. pass
  25. class GeminiExtractor:
  26. def __init__(
  27. self,
  28. *,
  29. api_key: str,
  30. model: str = DEFAULT_MODEL,
  31. base_url: str = DEFAULT_BASE_URL,
  32. timeout_seconds: float = DEFAULT_TIMEOUT,
  33. http_post: Callable[..., Any] = httpx.post,
  34. max_images: int = MAX_IMAGES,
  35. ) -> None:
  36. if not api_key:
  37. raise ExtractorError("missing OPENROUTER_API_KEY")
  38. self.api_key = api_key
  39. self.model = model
  40. self.base_url = base_url.rstrip("/")
  41. self.timeout_seconds = timeout_seconds
  42. self.http_post = http_post
  43. self.max_images = max_images
  44. @classmethod
  45. def from_env(cls, env: Mapping[str, str] | None = None, env_file: str = ".env") -> "GeminiExtractor":
  46. source = dict(load_env_file(env_file))
  47. if env:
  48. source.update(env)
  49. api_key = source.get("OPENROUTER_API_KEY") or source.get("OPEN_ROUTER_API_KEY") or ""
  50. return cls(
  51. api_key=api_key,
  52. model=source.get("CONTENT_AGENT_VIDEO_LLM_MODEL") or DEFAULT_MODEL,
  53. base_url=source.get("OPENROUTER_BASE_URL") or DEFAULT_BASE_URL,
  54. timeout_seconds=float(source.get("CONTENT_AGENT_VIDEO_LLM_TIMEOUT_SECONDS") or DEFAULT_TIMEOUT),
  55. )
  56. def build_messages(self, post: Post) -> list[dict]:
  57. user_text = load_prompt("extract").format(
  58. title=post.title or "(无)",
  59. topics="、".join(post.topic_list) or "(无)",
  60. body=post.body_text or "(空)",
  61. )
  62. parts: list[dict] = [{"type": "text", "text": user_text}]
  63. for url in post.image_urls[: self.max_images]:
  64. parts.append({"type": "image_url", "image_url": {"url": url}})
  65. return [
  66. {"role": "system", "content": _SYSTEM_PROMPT},
  67. {"role": "user", "content": parts},
  68. ]
  69. def extract(self, post: Post) -> ExtractedContent:
  70. messages = self.build_messages(post)
  71. last_exc: Optional[Exception] = None
  72. for attempt in range(2):
  73. try:
  74. resp = self.http_post(
  75. f"{self.base_url}/chat/completions",
  76. headers={
  77. "Authorization": f"Bearer {self.api_key}",
  78. "Content-Type": "application/json",
  79. },
  80. json={"model": self.model, "messages": messages},
  81. timeout=self.timeout_seconds,
  82. )
  83. resp.raise_for_status()
  84. content = resp.json()["choices"][0]["message"]["content"]
  85. data = extract_json_object(content)
  86. return ExtractedContent(
  87. text=str(data.get("text") or ""),
  88. from_image=str(data.get("from_image") or ""),
  89. from_video=str(data.get("from_video") or ""),
  90. is_empty=to_bool(data.get("is_empty")),
  91. )
  92. except httpx.HTTPError as exc:
  93. last_exc = exc
  94. if attempt == 0:
  95. continue
  96. raise ExtractorError(f"gemini_http_error: {exc}") from exc
  97. except (KeyError, IndexError, TypeError, ValueError) as exc:
  98. last_exc = exc
  99. if attempt == 0:
  100. continue
  101. raise ExtractorError(f"gemini_response_invalid: {exc}") from exc
  102. raise ExtractorError(f"gemini_unknown_error: {last_exc}")
  103. def extract_content(
  104. post: Post,
  105. *,
  106. client: Optional[GeminiExtractor] = None,
  107. env_file: str = ".env",
  108. ) -> ExtractedContent:
  109. client = client or GeminiExtractor.from_env(env_file=env_file)
  110. return client.extract(post)