geo/backend/app/workers/platforms/wenxin.py

214 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
文心一言 (百度千帆) 平台适配器 - 使用百度千帆 API 获取真实AI回答
API文档: https://cloud.baidu.com/doc/WENXINWORKSHOP/s/flfmc9do2
使用 ERNIE-Bot (completions) chat completion API
认证方式: API Key + Secret Key 换取 access_token
"""
import asyncio
import logging
import time
from typing import Optional
import httpx
from app.config import settings
from app.workers.platforms.base import BasePlatformAdapter
from app.workers.platforms.search_engine import fetch_search_content
logger = logging.getLogger(__name__)
# 模块级频率限制器
_last_request_time: float = 0.0
_min_interval: float = 6.0 # 每分钟10次 -> 6秒间隔
# access_token 缓存
_cached_token: Optional[str] = None
_token_expires_at: float = 0.0
async def _rate_limit_wait():
"""确保请求间隔不低于最小间隔"""
global _last_request_time
now = time.monotonic()
elapsed = now - _last_request_time
if elapsed < _min_interval:
wait_time = _min_interval - elapsed
logger.debug(f"文心一言 频率限制等待 {wait_time:.1f}s")
await asyncio.sleep(wait_time)
_last_request_time = time.monotonic()
class WenxinAdapter(BasePlatformAdapter):
"""文心一言平台适配器 - 使用百度千帆 API 获取真实AI回答"""
platform_name = "wenxin"
platform_url = "https://yiyan.baidu.com"
# 百度千帆 API 端点
_token_url = "https://aip.baidubce.com/oauth/2.0/token"
# ERNIE-Bot 4.0 (completions_pro) 或 ERNIE-Bot (completions)
_chat_url_template = (
"https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/{model}"
"?access_token={token}"
)
_default_model = "completions_pro" # ERNIE-Bot 4.0
def __init__(self):
self._api_key: Optional[str] = None
self._secret_key: Optional[str] = None
self._client: Optional[httpx.AsyncClient] = None
@property
def api_key(self) -> Optional[str]:
if self._api_key is None:
self._api_key = settings.BAIDU_QIANFAN_API_KEY
return self._api_key
@property
def secret_key(self) -> Optional[str]:
if self._secret_key is None:
self._secret_key = settings.BAIDU_QIANFAN_SECRET_KEY
return self._secret_key
@property
def is_configured(self) -> bool:
return bool(
self.api_key and self.api_key.strip()
and self.secret_key and self.secret_key.strip()
)
def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(60.0, connect=10.0),
)
return self._client
async def _get_access_token(self) -> str:
"""获取百度千帆 access_token带缓存"""
global _cached_token, _token_expires_at
now = time.monotonic()
if _cached_token and now < _token_expires_at:
return _cached_token
client = self._get_client()
response = await client.post(
self._token_url,
params={
"grant_type": "client_credentials",
"client_id": self.api_key,
"client_secret": self.secret_key,
},
)
if response.status_code != 200:
raise RuntimeError(
f"百度千帆获取 access_token 失败: {response.status_code} {response.text[:300]}"
)
data = response.json()
token = data.get("access_token")
if not token:
error_desc = data.get("error_description", "未知错误")
raise RuntimeError(f"百度千帆获取 access_token 失败: {error_desc}")
# 缓存 token提前5分钟过期
expires_in = data.get("expires_in", 2592000) # 默认30天
_cached_token = token
_token_expires_at = now + expires_in - 300
logger.info("百度千帆 access_token 获取成功")
return token
async def query(self, keyword: str) -> str:
"""在文心一言查询关键词,返回原始响应文本"""
last_error = None
for attempt in range(3):
try:
return await self._do_query(keyword)
except Exception as e:
last_error = e
logger.warning(f"文心一言查询第 {attempt + 1} 次尝试失败: {e}")
if attempt < 2:
await asyncio.sleep(2 ** attempt)
# 所有尝试失败后回退到搜索引擎
logger.warning(f"文心一言 API 调用全部失败,回退到搜索引擎: {last_error}")
return await self._fallback_search(keyword)
async def _do_query(self, keyword: str) -> str:
"""通过百度千帆 API 获取真实AI回答"""
if not self.is_configured:
logger.warning("百度千帆 API Key 未配置,回退到搜索引擎")
return await self._fallback_search(keyword)
await _rate_limit_wait()
access_token = await self._get_access_token()
chat_url = self._chat_url_template.format(
model=self._default_model,
token=access_token,
)
client = self._get_client()
payload = {
"messages": [
{
"role": "user",
"content": keyword,
},
],
"system": (
"你是一个专业的AI搜索助手。请基于你的知识"
"详细回答用户的问题。如果引用了外部来源,"
"请在回答中标注来源URL或出处名称。"
),
"temperature": 0.7,
"max_output_tokens": 2000,
}
response = await client.post(chat_url, json=payload)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", "10"))
logger.warning(f"百度千帆 API 限流,等待 {retry_after}s 后重试")
await asyncio.sleep(retry_after)
raise RuntimeError(f"百度千帆 API 限流")
if response.status_code != 200:
error_body = response.text[:500]
raise RuntimeError(
f"百度千帆 API 返回错误 {response.status_code}: {error_body}"
)
data = response.json()
# 检查API错误码
error_code = data.get("error_code")
if error_code:
error_msg = data.get("error_msg", "未知错误")
raise RuntimeError(f"百度千帆 API 错误 {error_code}: {error_msg}")
content = data.get("result", "")
if not content:
raise RuntimeError("百度千帆 API 返回空内容")
# 标记数据来源为AI平台
result = f"[data_source: ai_platform]\n{content}"
logger.info(f"文心一言 API 调用成功,返回 {len(content)} 字符")
return result
async def _fallback_search(self, keyword: str) -> str:
"""回退到搜索引擎模式,标记数据来源"""
content = await fetch_search_content(self.platform_name, keyword)
return f"[data_source: search_engine]\n{content}"
async def close(self):
"""清理资源"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None