query_variant.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. from __future__ import annotations
  2. import copy
  3. import os
  4. from pathlib import Path
  5. from typing import Any, Callable, Mapping
  6. import httpx
  7. from content_agent.errors import ContentAgentError, ErrorCode
  8. from content_agent.integrations import timeout_config
  9. from content_agent.integrations.query_prompt_config import DEFAULT_PROFILE, load_profile
  10. from content_agent.interfaces import QueryVariantClient, QueryVariantResult
  11. DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
  12. DEFAULT_QUERY_PROMPT_VERSION = "query_variant.v1"
  13. DEFAULT_QUERY_TIMEOUT_SECONDS = 120.0
  14. def _retryable_status(exc: httpx.HTTPStatusError) -> bool:
  15. status = getattr(getattr(exc, "response", None), "status_code", None)
  16. return isinstance(status, int) and (status in (408, 429) or 500 <= status < 600)
  17. # M9D Gate 2:判搜索词是否易搜到中国 50+ 人群喜欢的视频(仅非抖音)。只回 yes/no。
  18. _FIFTY_PLUS_GATE_SYSTEM = (
  19. "你判断一个中文短视频搜索词,是否容易搜到中国 50 岁以上中老年人群喜欢的视频。"
  20. "只回答 yes 或 no,不要解释。"
  21. )
  22. class MissingQueryVariantClient:
  23. def __init__(self, reason: str, detail: dict[str, Any] | None = None) -> None:
  24. self.reason = reason
  25. self.detail = detail or {}
  26. def generate_variant(
  27. self,
  28. *,
  29. seed_term: str,
  30. evidence_context: dict[str, Any],
  31. ) -> QueryVariantResult:
  32. raise ContentAgentError(
  33. ErrorCode.QUERY_GENERATION_FAILED,
  34. "query generation failed",
  35. {
  36. "reason": self.reason,
  37. "seed_term": seed_term,
  38. **self.detail,
  39. },
  40. )
  41. class OpenRouterQueryVariantClient:
  42. def __init__(
  43. self,
  44. *,
  45. api_key: str,
  46. model: str,
  47. base_url: str = DEFAULT_OPENROUTER_BASE_URL,
  48. timeout_seconds: float = DEFAULT_QUERY_TIMEOUT_SECONDS,
  49. prompt_version: str = DEFAULT_QUERY_PROMPT_VERSION,
  50. profile: dict[str, Any] | None = None,
  51. http_post: Callable[..., Any] | None = None,
  52. ) -> None:
  53. self.api_key = api_key
  54. self.model = model
  55. self.base_url = base_url.rstrip("/")
  56. self.timeout_seconds = timeout_seconds
  57. # None → 运行时取 httpx.post(便于测试 monkeypatch httpx.post);也可注入自定义。
  58. self.http_post = http_post
  59. self.profile = copy.deepcopy(profile or DEFAULT_PROFILE)
  60. self.prompt_version = str(self.profile.get("prompt_version") or prompt_version)
  61. def _timeout(self) -> httpx.Timeout:
  62. return timeout_config.as_httpx_timeout(
  63. self.timeout_seconds, read=timeout_config.read_timeout("query_llm")
  64. )
  65. def _post(self, *args: Any, **kwargs: Any) -> Any:
  66. return (self.http_post or httpx.post)(*args, **kwargs)
  67. def generate_variant(
  68. self,
  69. *,
  70. seed_term: str,
  71. evidence_context: dict[str, Any],
  72. ) -> QueryVariantResult:
  73. # 120s 超时 + 网络/超时/5xx/429 重试一次;解析错误不重试(确定性)。
  74. for attempt in range(2):
  75. try:
  76. response = self._post(
  77. f"{self.base_url}/chat/completions",
  78. headers={
  79. "Authorization": f"Bearer {self.api_key}",
  80. "Content-Type": "application/json",
  81. },
  82. json={
  83. "model": self.model,
  84. "messages": _render_messages(self.profile, seed_term, evidence_context),
  85. "temperature": self.profile["temperature"],
  86. "max_tokens": self.profile["max_tokens"],
  87. },
  88. timeout=self._timeout(),
  89. )
  90. response.raise_for_status()
  91. query = _extract_query(response.json())
  92. except ContentAgentError:
  93. raise
  94. except httpx.HTTPStatusError as exc:
  95. if attempt == 0 and _retryable_status(exc):
  96. continue
  97. raise _generation_error(
  98. "openrouter_http_status",
  99. seed_term,
  100. {"status_code": exc.response.status_code},
  101. ) from exc
  102. except httpx.HTTPError as exc:
  103. if attempt == 0:
  104. continue
  105. raise _generation_error(
  106. "openrouter_http_error",
  107. seed_term,
  108. {"exception_type": type(exc).__name__},
  109. ) from exc
  110. except (KeyError, TypeError, ValueError) as exc:
  111. raise _generation_error(
  112. "openrouter_response_invalid",
  113. seed_term,
  114. {"exception_type": type(exc).__name__},
  115. ) from exc
  116. return QueryVariantResult(
  117. query=query,
  118. model=self.model,
  119. prompt_version=self.prompt_version,
  120. input_evidence=evidence_context,
  121. )
  122. # 理论不可达(循环内必 return 或 raise);兜底。
  123. raise _generation_error("openrouter_http_error", seed_term, {"exception_type": "Unknown"})
  124. def judge_query_fifty_plus(self, query_text: str) -> bool:
  125. """M9D Gate 2:返回 True=放行(含拿不准/异常);仅明确 no 才丢弃。"""
  126. try:
  127. response = self._post(
  128. f"{self.base_url}/chat/completions",
  129. headers={
  130. "Authorization": f"Bearer {self.api_key}",
  131. "Content-Type": "application/json",
  132. },
  133. json={
  134. "model": self.model,
  135. "messages": [
  136. {"role": "system", "content": _FIFTY_PLUS_GATE_SYSTEM},
  137. {"role": "user", "content": f"搜索词:{query_text}"},
  138. ],
  139. "temperature": 0,
  140. "max_tokens": 4,
  141. },
  142. timeout=self._timeout(),
  143. )
  144. response.raise_for_status()
  145. content = response.json()["choices"][0]["message"]["content"]
  146. except Exception:
  147. return True
  148. return not str(content or "").strip().lower().startswith("no")
  149. def query_variant_client_from_env(
  150. env: Mapping[str, str] | None = None,
  151. *,
  152. platform: str = "douyin",
  153. strategy_version: str = "V1",
  154. root_dir: Path | str = Path("."),
  155. ) -> QueryVariantClient:
  156. source = os.environ if env is None else env
  157. # query LLM 也支持切 DashScope/通义千问:优先 CONTENT_AGENT_QUERY_LLM_* / DASHSCOPE,回退 OpenRouter(海外不变)。
  158. api_key = (
  159. _env_value(source, "CONTENT_AGENT_QUERY_LLM_API_KEY")
  160. or _env_value(source, "DASHSCOPE_API_KEY")
  161. or _env_value(source, "OPENROUTER_API_KEY")
  162. or _env_value(source, "OPEN_ROUTER_API_KEY")
  163. )
  164. model = _env_value(source, "CONTENT_AGENT_QUERY_LLM_MODEL") or _env_value(source, "MODEL")
  165. base_url = (
  166. _env_value(source, "CONTENT_AGENT_QUERY_LLM_BASE_URL")
  167. or _env_value(source, "OPENROUTER_BASE_URL")
  168. or DEFAULT_OPENROUTER_BASE_URL
  169. )
  170. prompt_version = _env_value(source, "CONTENT_AGENT_QUERY_LLM_PROMPT_VERSION") or DEFAULT_QUERY_PROMPT_VERSION
  171. timeout_seconds = _float_env(
  172. source,
  173. "CONTENT_AGENT_QUERY_LLM_TIMEOUT_SECONDS",
  174. DEFAULT_QUERY_TIMEOUT_SECONDS,
  175. )
  176. missing = []
  177. if not api_key:
  178. missing.append("CONTENT_AGENT_QUERY_LLM_API_KEY/OPENROUTER_API_KEY")
  179. if not model:
  180. missing.append("CONTENT_AGENT_QUERY_LLM_MODEL")
  181. if missing:
  182. return MissingQueryVariantClient(
  183. "query variant LLM config missing",
  184. {"missing_env_keys": missing},
  185. )
  186. return OpenRouterQueryVariantClient(
  187. api_key=api_key,
  188. model=model,
  189. base_url=base_url,
  190. timeout_seconds=timeout_seconds,
  191. prompt_version=prompt_version,
  192. profile=load_profile(platform, strategy_version, root_dir=root_dir),
  193. )
  194. def _messages(seed_term: str, evidence_context: dict[str, Any]) -> list[dict[str, str]]:
  195. return _render_messages(DEFAULT_PROFILE, seed_term, evidence_context)
  196. def _render_messages(
  197. profile: dict[str, Any],
  198. seed_term: str,
  199. evidence_context: dict[str, Any],
  200. ) -> list[dict[str, str]]:
  201. return [
  202. {
  203. "role": "system",
  204. "content": str(profile["system"]),
  205. },
  206. {
  207. "role": "user",
  208. "content": str(profile["user"])
  209. .replace("{seed_term}", seed_term)
  210. .replace("{evidence_context}", str(evidence_context)),
  211. },
  212. ]
  213. def _extract_query(payload: dict[str, Any]) -> str:
  214. content = payload["choices"][0]["message"]["content"]
  215. if not isinstance(content, str):
  216. raise ValueError("OpenRouter content is not a string")
  217. query = _normalize_query(content)
  218. if not query:
  219. raise ValueError("OpenRouter content is empty")
  220. if "\n" in query:
  221. raise ValueError("OpenRouter content has multiple lines")
  222. return query
  223. def _normalize_query(value: str) -> str:
  224. query = " ".join(value.split()).strip()
  225. return query.strip("`'\"“”‘’")
  226. def _generation_error(
  227. reason: str,
  228. seed_term: str,
  229. detail: dict[str, Any] | None = None,
  230. ) -> ContentAgentError:
  231. return ContentAgentError(
  232. ErrorCode.QUERY_GENERATION_FAILED,
  233. "query generation failed",
  234. {
  235. "reason": reason,
  236. "seed_term": seed_term,
  237. **(detail or {}),
  238. },
  239. )
  240. def _env_value(env: Mapping[str, str], key: str) -> str:
  241. return str(env.get(key, "")).strip()
  242. def _float_env(env: Mapping[str, str], key: str, default: float) -> float:
  243. value = _env_value(env, key)
  244. if not value:
  245. return default
  246. try:
  247. return float(value)
  248. except ValueError:
  249. return default