query_variant.py 8.4 KB

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