|
@@ -12,15 +12,27 @@ from typing import Any, Callable, Mapping, Optional
|
|
|
|
|
|
|
|
import httpx
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
+import logging
|
|
|
|
|
+
|
|
|
from creation_knowledge.config import load_env_file
|
|
from creation_knowledge.config import load_env_file
|
|
|
from creation_knowledge.jsonio import extract_json_object, to_bool
|
|
from creation_knowledge.jsonio import extract_json_object, to_bool
|
|
|
-from creation_knowledge.models import ExtractedContent, Post
|
|
|
|
|
|
|
+from creation_knowledge.models import Card, CardExtract, ExtractedContent, Post
|
|
|
from creation_knowledge.prompts import load_prompt
|
|
from creation_knowledge.prompts import load_prompt
|
|
|
|
|
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
DEFAULT_MODEL = "google/gemini-3-flash-preview"
|
|
DEFAULT_MODEL = "google/gemini-3-flash-preview"
|
|
|
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
|
DEFAULT_TIMEOUT = 90.0
|
|
DEFAULT_TIMEOUT = 90.0
|
|
|
-MAX_IMAGES = 6
|
|
|
|
|
|
|
+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 = (
|
|
_SYSTEM_PROMPT = (
|
|
|
"你是创作知识提取助手。从给定的小红书帖子(标题、正文、图片、视频)中,"
|
|
"你是创作知识提取助手。从给定的小红书帖子(标题、正文、图片、视频)中,"
|
|
@@ -42,7 +54,7 @@ class GeminiExtractor:
|
|
|
base_url: str = DEFAULT_BASE_URL,
|
|
base_url: str = DEFAULT_BASE_URL,
|
|
|
timeout_seconds: float = DEFAULT_TIMEOUT,
|
|
timeout_seconds: float = DEFAULT_TIMEOUT,
|
|
|
http_post: Callable[..., Any] = httpx.post,
|
|
http_post: Callable[..., Any] = httpx.post,
|
|
|
- max_images: int = MAX_IMAGES,
|
|
|
|
|
|
|
+ max_cards: int = MAX_CARDS,
|
|
|
) -> None:
|
|
) -> None:
|
|
|
if not api_key:
|
|
if not api_key:
|
|
|
raise ExtractorError("missing OPENROUTER_API_KEY")
|
|
raise ExtractorError("missing OPENROUTER_API_KEY")
|
|
@@ -51,7 +63,7 @@ class GeminiExtractor:
|
|
|
self.base_url = base_url.rstrip("/")
|
|
self.base_url = base_url.rstrip("/")
|
|
|
self.timeout_seconds = timeout_seconds
|
|
self.timeout_seconds = timeout_seconds
|
|
|
self.http_post = http_post
|
|
self.http_post = http_post
|
|
|
- self.max_images = max_images
|
|
|
|
|
|
|
+ self.max_cards = max_cards
|
|
|
|
|
|
|
|
@classmethod
|
|
@classmethod
|
|
|
def from_env(cls, env: Mapping[str, str] | None = None, env_file: str = ".env") -> "GeminiExtractor":
|
|
def from_env(cls, env: Mapping[str, str] | None = None, env_file: str = ".env") -> "GeminiExtractor":
|
|
@@ -64,8 +76,22 @@ class GeminiExtractor:
|
|
|
model=source.get("CONTENT_AGENT_VIDEO_LLM_MODEL") or DEFAULT_MODEL,
|
|
model=source.get("CONTENT_AGENT_VIDEO_LLM_MODEL") or DEFAULT_MODEL,
|
|
|
base_url=source.get("OPENROUTER_BASE_URL") or DEFAULT_BASE_URL,
|
|
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),
|
|
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]:
|
|
def build_messages(self, post: Post) -> list[dict]:
|
|
|
user_text = load_prompt("extract").format(
|
|
user_text = load_prompt("extract").format(
|
|
|
title=post.title or "(无)",
|
|
title=post.title or "(无)",
|
|
@@ -73,8 +99,10 @@ class GeminiExtractor:
|
|
|
body=post.body_text or "(空)",
|
|
body=post.body_text or "(空)",
|
|
|
)
|
|
)
|
|
|
parts: list[dict] = [{"type": "text", "text": user_text}]
|
|
parts: list[dict] = [{"type": "text", "text": user_text}]
|
|
|
- for url in post.image_urls[: self.max_images]:
|
|
|
|
|
- parts.append({"type": "image_url", "image_url": {"url": url}})
|
|
|
|
|
|
|
+ # 每张卡片前插一个【卡片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 [
|
|
return [
|
|
|
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
|
{"role": "user", "content": parts},
|
|
{"role": "user", "content": parts},
|
|
@@ -97,8 +125,16 @@ class GeminiExtractor:
|
|
|
resp.raise_for_status()
|
|
resp.raise_for_status()
|
|
|
content = resp.json()["choices"][0]["message"]["content"]
|
|
content = resp.json()["choices"][0]["message"]["content"]
|
|
|
data = extract_json_object(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(
|
|
return ExtractedContent(
|
|
|
text=str(data.get("text") or ""),
|
|
text=str(data.get("text") or ""),
|
|
|
|
|
+ cards=cards,
|
|
|
from_image=str(data.get("from_image") or ""),
|
|
from_image=str(data.get("from_image") or ""),
|
|
|
from_video=str(data.get("from_video") or ""),
|
|
from_video=str(data.get("from_video") or ""),
|
|
|
is_empty=to_bool(data.get("is_empty")),
|
|
is_empty=to_bool(data.get("is_empty")),
|