|
@@ -3,17 +3,25 @@ from __future__ import annotations
|
|
|
import copy
|
|
import copy
|
|
|
import os
|
|
import os
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
-from typing import Any, Mapping
|
|
|
|
|
|
|
+from typing import Any, Callable, Mapping
|
|
|
|
|
|
|
|
import httpx
|
|
import httpx
|
|
|
|
|
|
|
|
from content_agent.errors import ContentAgentError, ErrorCode
|
|
from content_agent.errors import ContentAgentError, ErrorCode
|
|
|
|
|
+from content_agent.integrations import timeout_config
|
|
|
from content_agent.integrations.query_prompt_config import DEFAULT_PROFILE, load_profile
|
|
from content_agent.integrations.query_prompt_config import DEFAULT_PROFILE, load_profile
|
|
|
from content_agent.interfaces import QueryVariantClient, QueryVariantResult
|
|
from content_agent.interfaces import QueryVariantClient, QueryVariantResult
|
|
|
|
|
|
|
|
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
|
DEFAULT_QUERY_PROMPT_VERSION = "query_variant.v1"
|
|
DEFAULT_QUERY_PROMPT_VERSION = "query_variant.v1"
|
|
|
-DEFAULT_QUERY_TIMEOUT_SECONDS = 60.0
|
|
|
|
|
|
|
+DEFAULT_QUERY_TIMEOUT_SECONDS = 120.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _retryable_status(exc: httpx.HTTPStatusError) -> bool:
|
|
|
|
|
+ status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
|
|
|
+ return isinstance(status, int) and (status in (408, 429) or 500 <= status < 600)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
# M9D Gate 2:判搜索词是否易搜到中国 50+ 人群喜欢的视频(仅非抖音)。只回 yes/no。
|
|
# M9D Gate 2:判搜索词是否易搜到中国 50+ 人群喜欢的视频(仅非抖音)。只回 yes/no。
|
|
|
_FIFTY_PLUS_GATE_SYSTEM = (
|
|
_FIFTY_PLUS_GATE_SYSTEM = (
|
|
|
"你判断一个中文短视频搜索词,是否容易搜到中国 50 岁以上中老年人群喜欢的视频。"
|
|
"你判断一个中文短视频搜索词,是否容易搜到中国 50 岁以上中老年人群喜欢的视频。"
|
|
@@ -53,69 +61,88 @@ class OpenRouterQueryVariantClient:
|
|
|
timeout_seconds: float = DEFAULT_QUERY_TIMEOUT_SECONDS,
|
|
timeout_seconds: float = DEFAULT_QUERY_TIMEOUT_SECONDS,
|
|
|
prompt_version: str = DEFAULT_QUERY_PROMPT_VERSION,
|
|
prompt_version: str = DEFAULT_QUERY_PROMPT_VERSION,
|
|
|
profile: dict[str, Any] | None = None,
|
|
profile: dict[str, Any] | None = None,
|
|
|
|
|
+ http_post: Callable[..., Any] | None = None,
|
|
|
) -> None:
|
|
) -> None:
|
|
|
self.api_key = api_key
|
|
self.api_key = api_key
|
|
|
self.model = model
|
|
self.model = model
|
|
|
self.base_url = base_url.rstrip("/")
|
|
self.base_url = base_url.rstrip("/")
|
|
|
self.timeout_seconds = timeout_seconds
|
|
self.timeout_seconds = timeout_seconds
|
|
|
|
|
+ # None → 运行时取 httpx.post(便于测试 monkeypatch httpx.post);也可注入自定义。
|
|
|
|
|
+ self.http_post = http_post
|
|
|
self.profile = copy.deepcopy(profile or DEFAULT_PROFILE)
|
|
self.profile = copy.deepcopy(profile or DEFAULT_PROFILE)
|
|
|
self.prompt_version = str(self.profile.get("prompt_version") or prompt_version)
|
|
self.prompt_version = str(self.profile.get("prompt_version") or prompt_version)
|
|
|
|
|
|
|
|
|
|
+ def _timeout(self) -> httpx.Timeout:
|
|
|
|
|
+ return timeout_config.as_httpx_timeout(
|
|
|
|
|
+ self.timeout_seconds, read=timeout_config.read_timeout("query_llm")
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _post(self, *args: Any, **kwargs: Any) -> Any:
|
|
|
|
|
+ return (self.http_post or httpx.post)(*args, **kwargs)
|
|
|
|
|
+
|
|
|
def generate_variant(
|
|
def generate_variant(
|
|
|
self,
|
|
self,
|
|
|
*,
|
|
*,
|
|
|
seed_term: str,
|
|
seed_term: str,
|
|
|
evidence_context: dict[str, Any],
|
|
evidence_context: dict[str, Any],
|
|
|
) -> QueryVariantResult:
|
|
) -> QueryVariantResult:
|
|
|
- try:
|
|
|
|
|
- response = httpx.post(
|
|
|
|
|
- f"{self.base_url}/chat/completions",
|
|
|
|
|
- headers={
|
|
|
|
|
- "Authorization": f"Bearer {self.api_key}",
|
|
|
|
|
- "Content-Type": "application/json",
|
|
|
|
|
- },
|
|
|
|
|
- json={
|
|
|
|
|
- "model": self.model,
|
|
|
|
|
- "messages": _render_messages(self.profile, seed_term, evidence_context),
|
|
|
|
|
- "temperature": self.profile["temperature"],
|
|
|
|
|
- "max_tokens": self.profile["max_tokens"],
|
|
|
|
|
- },
|
|
|
|
|
- timeout=self.timeout_seconds,
|
|
|
|
|
|
|
+ # 120s 超时 + 网络/超时/5xx/429 重试一次;解析错误不重试(确定性)。
|
|
|
|
|
+ for attempt in range(2):
|
|
|
|
|
+ try:
|
|
|
|
|
+ response = self._post(
|
|
|
|
|
+ f"{self.base_url}/chat/completions",
|
|
|
|
|
+ headers={
|
|
|
|
|
+ "Authorization": f"Bearer {self.api_key}",
|
|
|
|
|
+ "Content-Type": "application/json",
|
|
|
|
|
+ },
|
|
|
|
|
+ json={
|
|
|
|
|
+ "model": self.model,
|
|
|
|
|
+ "messages": _render_messages(self.profile, seed_term, evidence_context),
|
|
|
|
|
+ "temperature": self.profile["temperature"],
|
|
|
|
|
+ "max_tokens": self.profile["max_tokens"],
|
|
|
|
|
+ },
|
|
|
|
|
+ timeout=self._timeout(),
|
|
|
|
|
+ )
|
|
|
|
|
+ response.raise_for_status()
|
|
|
|
|
+ query = _extract_query(response.json())
|
|
|
|
|
+ except ContentAgentError:
|
|
|
|
|
+ raise
|
|
|
|
|
+ except httpx.HTTPStatusError as exc:
|
|
|
|
|
+ if attempt == 0 and _retryable_status(exc):
|
|
|
|
|
+ continue
|
|
|
|
|
+ raise _generation_error(
|
|
|
|
|
+ "openrouter_http_status",
|
|
|
|
|
+ seed_term,
|
|
|
|
|
+ {"status_code": exc.response.status_code},
|
|
|
|
|
+ ) from exc
|
|
|
|
|
+ except httpx.HTTPError as exc:
|
|
|
|
|
+ if attempt == 0:
|
|
|
|
|
+ continue
|
|
|
|
|
+ raise _generation_error(
|
|
|
|
|
+ "openrouter_http_error",
|
|
|
|
|
+ seed_term,
|
|
|
|
|
+ {"exception_type": type(exc).__name__},
|
|
|
|
|
+ ) from exc
|
|
|
|
|
+ except (KeyError, TypeError, ValueError) as exc:
|
|
|
|
|
+ raise _generation_error(
|
|
|
|
|
+ "openrouter_response_invalid",
|
|
|
|
|
+ seed_term,
|
|
|
|
|
+ {"exception_type": type(exc).__name__},
|
|
|
|
|
+ ) from exc
|
|
|
|
|
+
|
|
|
|
|
+ return QueryVariantResult(
|
|
|
|
|
+ query=query,
|
|
|
|
|
+ model=self.model,
|
|
|
|
|
+ prompt_version=self.prompt_version,
|
|
|
|
|
+ input_evidence=evidence_context,
|
|
|
)
|
|
)
|
|
|
- response.raise_for_status()
|
|
|
|
|
- query = _extract_query(response.json())
|
|
|
|
|
- except ContentAgentError:
|
|
|
|
|
- raise
|
|
|
|
|
- except httpx.HTTPStatusError as exc:
|
|
|
|
|
- raise _generation_error(
|
|
|
|
|
- "openrouter_http_status",
|
|
|
|
|
- seed_term,
|
|
|
|
|
- {"status_code": exc.response.status_code},
|
|
|
|
|
- ) from exc
|
|
|
|
|
- except httpx.HTTPError as exc:
|
|
|
|
|
- raise _generation_error(
|
|
|
|
|
- "openrouter_http_error",
|
|
|
|
|
- seed_term,
|
|
|
|
|
- {"exception_type": type(exc).__name__},
|
|
|
|
|
- ) from exc
|
|
|
|
|
- except (KeyError, TypeError, ValueError) as exc:
|
|
|
|
|
- raise _generation_error(
|
|
|
|
|
- "openrouter_response_invalid",
|
|
|
|
|
- seed_term,
|
|
|
|
|
- {"exception_type": type(exc).__name__},
|
|
|
|
|
- ) from exc
|
|
|
|
|
-
|
|
|
|
|
- return QueryVariantResult(
|
|
|
|
|
- query=query,
|
|
|
|
|
- model=self.model,
|
|
|
|
|
- prompt_version=self.prompt_version,
|
|
|
|
|
- input_evidence=evidence_context,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ # 理论不可达(循环内必 return 或 raise);兜底。
|
|
|
|
|
+ raise _generation_error("openrouter_http_error", seed_term, {"exception_type": "Unknown"})
|
|
|
|
|
|
|
|
def judge_query_fifty_plus(self, query_text: str) -> bool:
|
|
def judge_query_fifty_plus(self, query_text: str) -> bool:
|
|
|
"""M9D Gate 2:返回 True=放行(含拿不准/异常);仅明确 no 才丢弃。"""
|
|
"""M9D Gate 2:返回 True=放行(含拿不准/异常);仅明确 no 才丢弃。"""
|
|
|
try:
|
|
try:
|
|
|
- response = httpx.post(
|
|
|
|
|
|
|
+ response = self._post(
|
|
|
f"{self.base_url}/chat/completions",
|
|
f"{self.base_url}/chat/completions",
|
|
|
headers={
|
|
headers={
|
|
|
"Authorization": f"Bearer {self.api_key}",
|
|
"Authorization": f"Bearer {self.api_key}",
|
|
@@ -130,7 +157,7 @@ class OpenRouterQueryVariantClient:
|
|
|
"temperature": 0,
|
|
"temperature": 0,
|
|
|
"max_tokens": 4,
|
|
"max_tokens": 4,
|
|
|
},
|
|
},
|
|
|
- timeout=self.timeout_seconds,
|
|
|
|
|
|
|
+ timeout=self._timeout(),
|
|
|
)
|
|
)
|
|
|
response.raise_for_status()
|
|
response.raise_for_status()
|
|
|
content = response.json()["choices"][0]["message"]["content"]
|
|
content = response.json()["choices"][0]["message"]["content"]
|