extractor.py 5.4 KB

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