feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
747
backend/app/services/ai/ai_service.py
Normal file
747
backend/app/services/ai/ai_service.py
Normal file
@@ -0,0 +1,747 @@
|
||||
"""
|
||||
本地 AI 服务 - 遵循瑞小美 AI 接入规范
|
||||
|
||||
功能:
|
||||
- 支持 4sapi.com(首选)和 OpenRouter(备选)自动降级
|
||||
- 统一的请求/响应格式
|
||||
- 调用日志记录
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
|
||||
from enum import Enum
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIProvider(Enum):
|
||||
"""AI 服务商"""
|
||||
PRIMARY = "4sapi" # 首选:4sapi.com
|
||||
FALLBACK = "openrouter" # 备选:OpenRouter
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIResponse:
|
||||
"""AI 响应结果"""
|
||||
content: str # AI 回复内容
|
||||
model: str = "" # 使用的模型
|
||||
provider: str = "" # 实际使用的服务商
|
||||
input_tokens: int = 0 # 输入 token 数
|
||||
output_tokens: int = 0 # 输出 token 数
|
||||
total_tokens: int = 0 # 总 token 数
|
||||
cost: float = 0.0 # 费用(美元)
|
||||
latency_ms: int = 0 # 响应延迟(毫秒)
|
||||
raw_response: Dict[str, Any] = field(default_factory=dict) # 原始响应
|
||||
images: List[str] = field(default_factory=list) # 图像生成结果
|
||||
annotations: Dict[str, Any] = field(default_factory=dict) # PDF 解析注释
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIConfig:
|
||||
"""AI 服务配置"""
|
||||
primary_api_key: str # 通用 Key(Gemini/DeepSeek 等)
|
||||
anthropic_api_key: str = "" # Claude 专属 Key
|
||||
primary_base_url: str = "https://4sapi.com/v1"
|
||||
fallback_api_key: str = ""
|
||||
fallback_base_url: str = "https://openrouter.ai/api/v1"
|
||||
default_model: str = "claude-opus-4-5-20251101-thinking" # 默认使用最强模型
|
||||
timeout: float = 120.0
|
||||
max_retries: int = 2
|
||||
|
||||
|
||||
# Claude 模型列表(需要使用 anthropic_api_key)
|
||||
CLAUDE_MODELS = [
|
||||
"claude-opus-4-5-20251101-thinking",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3-opus",
|
||||
"claude-3-sonnet",
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
|
||||
def is_claude_model(model: str) -> bool:
|
||||
"""判断是否为 Claude 模型"""
|
||||
model_lower = model.lower()
|
||||
return any(claude in model_lower for claude in ["claude", "anthropic"])
|
||||
|
||||
|
||||
# 模型名称映射:4sapi -> OpenRouter
|
||||
MODEL_MAPPING = {
|
||||
# 4sapi 使用简短名称,OpenRouter 使用完整路径
|
||||
"gemini-3-flash-preview": "google/gemini-3-flash-preview",
|
||||
"gemini-3-pro-preview": "google/gemini-3-pro-preview",
|
||||
"claude-opus-4-5-20251101-thinking": "anthropic/claude-opus-4.5",
|
||||
"gemini-2.5-flash-image-preview": "google/gemini-2.0-flash-exp:free",
|
||||
}
|
||||
|
||||
# 反向映射:OpenRouter -> 4sapi
|
||||
MODEL_MAPPING_REVERSE = {v: k for k, v in MODEL_MAPPING.items()}
|
||||
|
||||
|
||||
class AIServiceError(Exception):
|
||||
"""AI 服务错误"""
|
||||
def __init__(self, message: str, provider: str = "", status_code: int = 0):
|
||||
super().__init__(message)
|
||||
self.provider = provider
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class AIService:
|
||||
"""
|
||||
本地 AI 服务
|
||||
|
||||
遵循瑞小美 AI 接入规范:
|
||||
- 首选 4sapi.com,失败自动降级到 OpenRouter
|
||||
- 统一的响应格式
|
||||
- 自动模型名称转换
|
||||
|
||||
使用示例:
|
||||
```python
|
||||
ai = AIService(module_code="knowledge_analysis")
|
||||
response = await ai.chat(
|
||||
messages=[
|
||||
{"role": "system", "content": "你是助手"},
|
||||
{"role": "user", "content": "你好"}
|
||||
],
|
||||
prompt_name="greeting"
|
||||
)
|
||||
print(response.content)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
module_code: str = "default",
|
||||
config: Optional[AIConfig] = None,
|
||||
db_session: Any = None
|
||||
):
|
||||
"""
|
||||
初始化 AI 服务
|
||||
|
||||
配置加载优先级(遵循瑞小美 AI 接入规范):
|
||||
1. 显式传入的 config 参数
|
||||
2. 数据库 ai_config 表(推荐)
|
||||
3. 环境变量(fallback)
|
||||
|
||||
Args:
|
||||
module_code: 模块标识,用于统计
|
||||
config: AI 配置,None 则从数据库/环境变量读取
|
||||
db_session: 数据库会话,用于记录调用日志和读取配置
|
||||
"""
|
||||
self.module_code = module_code
|
||||
self.db_session = db_session
|
||||
self.config = config or self._load_config(db_session)
|
||||
|
||||
logger.info(f"AIService 初始化: module={module_code}, primary={self.config.primary_base_url}")
|
||||
|
||||
def _load_config(self, db_session: Any) -> AIConfig:
|
||||
"""
|
||||
加载配置
|
||||
|
||||
配置加载优先级(遵循瑞小美 AI 接入规范):
|
||||
1. 管理库 tenant_configs 表(推荐,通过 DynamicConfig)
|
||||
2. 环境变量(fallback)
|
||||
|
||||
Args:
|
||||
db_session: 数据库会话(可选,用于日志记录)
|
||||
|
||||
Returns:
|
||||
AIConfig 配置对象
|
||||
"""
|
||||
# 优先从管理库加载(同步方式)
|
||||
try:
|
||||
config = self._load_config_from_admin_db()
|
||||
if config:
|
||||
logger.info("✅ AI 配置已从管理库(tenant_configs)加载")
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.debug(f"从管理库加载 AI 配置失败: {e}")
|
||||
|
||||
# Fallback 到环境变量
|
||||
logger.info("AI 配置从环境变量加载")
|
||||
return self._load_config_from_env()
|
||||
|
||||
def _load_config_from_admin_db(self) -> Optional[AIConfig]:
|
||||
"""
|
||||
从管理库 tenant_configs 表加载配置
|
||||
|
||||
使用同步方式直接查询 kaopeilian_admin.tenant_configs 表
|
||||
|
||||
Returns:
|
||||
AIConfig 配置对象,如果无数据则返回 None
|
||||
"""
|
||||
import os
|
||||
|
||||
# 获取当前租户编码
|
||||
tenant_code = os.getenv("TENANT_CODE", "demo")
|
||||
|
||||
# 获取管理库连接信息
|
||||
admin_db_host = os.getenv("ADMIN_DB_HOST", "prod-mysql")
|
||||
admin_db_port = int(os.getenv("ADMIN_DB_PORT", "3306"))
|
||||
admin_db_user = os.getenv("ADMIN_DB_USER", "root")
|
||||
admin_db_password = os.getenv("ADMIN_DB_PASSWORD", "")
|
||||
admin_db_name = os.getenv("ADMIN_DB_NAME", "kaopeilian_admin")
|
||||
|
||||
if not admin_db_password:
|
||||
logger.debug("ADMIN_DB_PASSWORD 未配置,跳过管理库配置加载")
|
||||
return None
|
||||
|
||||
try:
|
||||
from sqlalchemy import create_engine, text
|
||||
import urllib.parse
|
||||
|
||||
# 构建连接 URL
|
||||
encoded_password = urllib.parse.quote_plus(admin_db_password)
|
||||
admin_db_url = f"mysql+pymysql://{admin_db_user}:{encoded_password}@{admin_db_host}:{admin_db_port}/{admin_db_name}?charset=utf8mb4"
|
||||
|
||||
engine = create_engine(admin_db_url, pool_pre_ping=True)
|
||||
|
||||
with engine.connect() as conn:
|
||||
# 1. 获取租户 ID
|
||||
result = conn.execute(
|
||||
text("SELECT id FROM tenants WHERE code = :code AND status = 'active'"),
|
||||
{"code": tenant_code}
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
logger.debug(f"租户 {tenant_code} 不存在或未激活")
|
||||
engine.dispose()
|
||||
return None
|
||||
|
||||
tenant_id = row[0]
|
||||
|
||||
# 2. 获取 AI 配置
|
||||
result = conn.execute(
|
||||
text("""
|
||||
SELECT config_key, config_value
|
||||
FROM tenant_configs
|
||||
WHERE tenant_id = :tenant_id AND config_group = 'ai'
|
||||
"""),
|
||||
{"tenant_id": tenant_id}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
engine.dispose()
|
||||
|
||||
if not rows:
|
||||
logger.debug(f"租户 {tenant_code} 无 AI 配置")
|
||||
return None
|
||||
|
||||
# 转换为字典
|
||||
config_dict = {row[0]: row[1] for row in rows}
|
||||
|
||||
# 检查必要的配置是否存在
|
||||
primary_key = config_dict.get("AI_PRIMARY_API_KEY", "")
|
||||
if not primary_key:
|
||||
logger.warning(f"租户 {tenant_code} 的 AI_PRIMARY_API_KEY 为空")
|
||||
return None
|
||||
|
||||
logger.info(f"✅ 从管理库加载租户 {tenant_code} 的 AI 配置成功")
|
||||
|
||||
return AIConfig(
|
||||
primary_api_key=primary_key,
|
||||
anthropic_api_key=config_dict.get("AI_ANTHROPIC_API_KEY", ""),
|
||||
primary_base_url=config_dict.get("AI_PRIMARY_BASE_URL", "https://4sapi.com/v1"),
|
||||
fallback_api_key=config_dict.get("AI_FALLBACK_API_KEY", ""),
|
||||
fallback_base_url=config_dict.get("AI_FALLBACK_BASE_URL", "https://openrouter.ai/api/v1"),
|
||||
default_model=config_dict.get("AI_DEFAULT_MODEL", "claude-opus-4-5-20251101-thinking"),
|
||||
timeout=float(config_dict.get("AI_TIMEOUT", "120")),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"从管理库读取 AI 配置异常: {e}")
|
||||
return None
|
||||
|
||||
def _load_config_from_env(self) -> AIConfig:
|
||||
"""
|
||||
从环境变量加载配置
|
||||
|
||||
⚠️ 强制要求(遵循瑞小美 AI 接入规范):
|
||||
- 禁止在代码中硬编码 API Key
|
||||
- 必须通过环境变量配置 Key
|
||||
|
||||
必须配置的环境变量:
|
||||
- AI_PRIMARY_API_KEY: 通用 Key(用于 Gemini/DeepSeek 等)
|
||||
- AI_ANTHROPIC_API_KEY: Claude 专属 Key
|
||||
"""
|
||||
import os
|
||||
|
||||
primary_api_key = os.getenv("AI_PRIMARY_API_KEY", "")
|
||||
anthropic_api_key = os.getenv("AI_ANTHROPIC_API_KEY", "")
|
||||
|
||||
# 检查必要的 Key 是否已配置
|
||||
if not primary_api_key:
|
||||
logger.warning("⚠️ AI_PRIMARY_API_KEY 未配置,AI 服务可能无法正常工作")
|
||||
if not anthropic_api_key:
|
||||
logger.warning("⚠️ AI_ANTHROPIC_API_KEY 未配置,Claude 模型调用将失败")
|
||||
|
||||
return AIConfig(
|
||||
# 通用 Key(Gemini/DeepSeek 等非 Anthropic 模型)
|
||||
primary_api_key=primary_api_key,
|
||||
# Claude 专属 Key
|
||||
anthropic_api_key=anthropic_api_key,
|
||||
primary_base_url=os.getenv("AI_PRIMARY_BASE_URL", "https://4sapi.com/v1"),
|
||||
fallback_api_key=os.getenv("AI_FALLBACK_API_KEY", ""),
|
||||
fallback_base_url=os.getenv("AI_FALLBACK_BASE_URL", "https://openrouter.ai/api/v1"),
|
||||
# 默认模型:遵循"优先最强"原则,使用 Claude Opus 4.5
|
||||
default_model=os.getenv("AI_DEFAULT_MODEL", "claude-opus-4-5-20251101-thinking"),
|
||||
timeout=float(os.getenv("AI_TIMEOUT", "120")),
|
||||
)
|
||||
|
||||
def _convert_model_name(self, model: str, provider: AIProvider) -> str:
|
||||
"""
|
||||
转换模型名称以匹配服务商格式
|
||||
|
||||
Args:
|
||||
model: 原始模型名称
|
||||
provider: 目标服务商
|
||||
|
||||
Returns:
|
||||
转换后的模型名称
|
||||
"""
|
||||
if provider == AIProvider.FALLBACK:
|
||||
# 4sapi -> OpenRouter
|
||||
return MODEL_MAPPING.get(model, f"google/{model}" if "/" not in model else model)
|
||||
else:
|
||||
# OpenRouter -> 4sapi
|
||||
return MODEL_MAPPING_REVERSE.get(model, model.split("/")[-1] if "/" in model else model)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
prompt_name: str = "default",
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""
|
||||
文本聊天
|
||||
|
||||
Args:
|
||||
messages: 消息列表 [{"role": "system/user/assistant", "content": "..."}]
|
||||
model: 模型名称,None 使用默认模型
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大输出 token 数
|
||||
prompt_name: 提示词名称,用于统计
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
AIResponse 响应对象
|
||||
"""
|
||||
model = model or self.config.default_model
|
||||
|
||||
# 构建请求体
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens:
|
||||
payload["max_tokens"] = max_tokens
|
||||
|
||||
# 首选服务商
|
||||
try:
|
||||
return await self._call_provider(
|
||||
provider=AIProvider.PRIMARY,
|
||||
endpoint="/chat/completions",
|
||||
payload=payload,
|
||||
prompt_name=prompt_name
|
||||
)
|
||||
except AIServiceError as e:
|
||||
logger.warning(f"首选服务商调用失败: {e}, 尝试降级到备选服务商")
|
||||
|
||||
# 如果没有备选 API Key,直接抛出异常
|
||||
if not self.config.fallback_api_key:
|
||||
raise
|
||||
|
||||
# 降级到备选服务商
|
||||
# 转换模型名称
|
||||
fallback_model = self._convert_model_name(model, AIProvider.FALLBACK)
|
||||
payload["model"] = fallback_model
|
||||
|
||||
return await self._call_provider(
|
||||
provider=AIProvider.FALLBACK,
|
||||
endpoint="/chat/completions",
|
||||
payload=payload,
|
||||
prompt_name=prompt_name
|
||||
)
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
prompt_name: str = "default",
|
||||
**kwargs
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
流式文本聊天
|
||||
|
||||
Args:
|
||||
messages: 消息列表 [{"role": "system/user/assistant", "content": "..."}]
|
||||
model: 模型名称,None 使用默认模型
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大输出 token 数
|
||||
prompt_name: 提示词名称,用于统计
|
||||
**kwargs: 其他参数
|
||||
|
||||
Yields:
|
||||
str: 文本块(逐字返回)
|
||||
"""
|
||||
model = model or self.config.default_model
|
||||
|
||||
# 构建请求体
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
}
|
||||
if max_tokens:
|
||||
payload["max_tokens"] = max_tokens
|
||||
|
||||
# 首选服务商
|
||||
try:
|
||||
async for chunk in self._call_provider_stream(
|
||||
provider=AIProvider.PRIMARY,
|
||||
endpoint="/chat/completions",
|
||||
payload=payload,
|
||||
prompt_name=prompt_name
|
||||
):
|
||||
yield chunk
|
||||
return
|
||||
except AIServiceError as e:
|
||||
logger.warning(f"首选服务商流式调用失败: {e}, 尝试降级到备选服务商")
|
||||
|
||||
# 如果没有备选 API Key,直接抛出异常
|
||||
if not self.config.fallback_api_key:
|
||||
raise
|
||||
|
||||
# 降级到备选服务商
|
||||
# 转换模型名称
|
||||
fallback_model = self._convert_model_name(model, AIProvider.FALLBACK)
|
||||
payload["model"] = fallback_model
|
||||
|
||||
async for chunk in self._call_provider_stream(
|
||||
provider=AIProvider.FALLBACK,
|
||||
endpoint="/chat/completions",
|
||||
payload=payload,
|
||||
prompt_name=prompt_name
|
||||
):
|
||||
yield chunk
|
||||
|
||||
async def _call_provider_stream(
|
||||
self,
|
||||
provider: AIProvider,
|
||||
endpoint: str,
|
||||
payload: Dict[str, Any],
|
||||
prompt_name: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
流式调用指定服务商
|
||||
|
||||
Args:
|
||||
provider: 服务商
|
||||
endpoint: API 端点
|
||||
payload: 请求体
|
||||
prompt_name: 提示词名称
|
||||
|
||||
Yields:
|
||||
str: 文本块
|
||||
"""
|
||||
# 获取配置
|
||||
if provider == AIProvider.PRIMARY:
|
||||
base_url = self.config.primary_base_url
|
||||
# 根据模型选择 API Key:Claude 用专属 Key,其他用通用 Key
|
||||
model = payload.get("model", "")
|
||||
if is_claude_model(model) and self.config.anthropic_api_key:
|
||||
api_key = self.config.anthropic_api_key
|
||||
logger.debug(f"[Stream] 使用 Claude 专属 Key 调用模型: {model}")
|
||||
else:
|
||||
api_key = self.config.primary_api_key
|
||||
else:
|
||||
api_key = self.config.fallback_api_key
|
||||
base_url = self.config.fallback_base_url
|
||||
|
||||
url = f"{base_url.rstrip('/')}{endpoint}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# OpenRouter 需要额外的 header
|
||||
if provider == AIProvider.FALLBACK:
|
||||
headers["HTTP-Referer"] = "https://kaopeilian.ireborn.com.cn"
|
||||
headers["X-Title"] = "KaoPeiLian"
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
timeout = httpx.Timeout(self.config.timeout, connect=10.0)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
logger.info(f"流式调用 AI 服务: provider={provider.value}, model={payload.get('model')}")
|
||||
|
||||
async with client.stream("POST", url, json=payload, headers=headers) as response:
|
||||
# 检查响应状态
|
||||
if response.status_code != 200:
|
||||
error_text = await response.aread()
|
||||
logger.error(f"AI 服务流式返回错误: status={response.status_code}, body={error_text[:500]}")
|
||||
raise AIServiceError(
|
||||
f"API 流式请求失败: HTTP {response.status_code}",
|
||||
provider=provider.value,
|
||||
status_code=response.status_code
|
||||
)
|
||||
|
||||
# 处理 SSE 流
|
||||
async for line in response.aiter_lines():
|
||||
if not line or not line.strip():
|
||||
continue
|
||||
|
||||
# 解析 SSE 数据行
|
||||
if line.startswith("data: "):
|
||||
data_str = line[6:] # 移除 "data: " 前缀
|
||||
|
||||
# 检查是否是结束标记
|
||||
if data_str.strip() == "[DONE]":
|
||||
logger.info(f"流式响应完成: provider={provider.value}")
|
||||
return
|
||||
|
||||
try:
|
||||
event_data = json.loads(data_str)
|
||||
|
||||
# 提取 delta 内容
|
||||
choices = event_data.get("choices", [])
|
||||
if choices:
|
||||
delta = choices[0].get("delta", {})
|
||||
content = delta.get("content", "")
|
||||
if content:
|
||||
yield content
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug(f"解析流式数据失败: {e} - 数据: {data_str[:100]}")
|
||||
continue
|
||||
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
logger.info(f"流式调用完成: provider={provider.value}, latency={latency_ms}ms")
|
||||
|
||||
except httpx.TimeoutException:
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
logger.error(f"AI 服务流式超时: provider={provider.value}, latency={latency_ms}ms")
|
||||
raise AIServiceError(f"流式请求超时({self.config.timeout}秒)", provider=provider.value)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"AI 服务流式网络错误: provider={provider.value}, error={e}")
|
||||
raise AIServiceError(f"流式网络错误: {e}", provider=provider.value)
|
||||
|
||||
async def _call_provider(
|
||||
self,
|
||||
provider: AIProvider,
|
||||
endpoint: str,
|
||||
payload: Dict[str, Any],
|
||||
prompt_name: str
|
||||
) -> AIResponse:
|
||||
"""
|
||||
调用指定服务商
|
||||
|
||||
Args:
|
||||
provider: 服务商
|
||||
endpoint: API 端点
|
||||
payload: 请求体
|
||||
prompt_name: 提示词名称
|
||||
|
||||
Returns:
|
||||
AIResponse 响应对象
|
||||
"""
|
||||
# 获取配置
|
||||
if provider == AIProvider.PRIMARY:
|
||||
base_url = self.config.primary_base_url
|
||||
# 根据模型选择 API Key:Claude 用专属 Key,其他用通用 Key
|
||||
model = payload.get("model", "")
|
||||
if is_claude_model(model) and self.config.anthropic_api_key:
|
||||
api_key = self.config.anthropic_api_key
|
||||
logger.debug(f"使用 Claude 专属 Key 调用模型: {model}")
|
||||
else:
|
||||
api_key = self.config.primary_api_key
|
||||
else:
|
||||
api_key = self.config.fallback_api_key
|
||||
base_url = self.config.fallback_base_url
|
||||
|
||||
url = f"{base_url.rstrip('/')}{endpoint}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# OpenRouter 需要额外的 header
|
||||
if provider == AIProvider.FALLBACK:
|
||||
headers["HTTP-Referer"] = "https://kaopeilian.ireborn.com.cn"
|
||||
headers["X-Title"] = "KaoPeiLian"
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.config.timeout) as client:
|
||||
logger.info(f"调用 AI 服务: provider={provider.value}, model={payload.get('model')}")
|
||||
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 检查响应状态
|
||||
if response.status_code != 200:
|
||||
error_text = response.text
|
||||
logger.error(f"AI 服务返回错误: status={response.status_code}, body={error_text[:500]}")
|
||||
raise AIServiceError(
|
||||
f"API 请求失败: HTTP {response.status_code}",
|
||||
provider=provider.value,
|
||||
status_code=response.status_code
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
# 解析响应
|
||||
ai_response = self._parse_response(data, provider, latency_ms)
|
||||
|
||||
# 记录日志
|
||||
logger.info(
|
||||
f"AI 调用成功: provider={provider.value}, model={ai_response.model}, "
|
||||
f"tokens={ai_response.total_tokens}, latency={latency_ms}ms"
|
||||
)
|
||||
|
||||
# 保存到数据库(如果有 session)
|
||||
await self._log_call(prompt_name, ai_response)
|
||||
|
||||
return ai_response
|
||||
|
||||
except httpx.TimeoutException:
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
logger.error(f"AI 服务超时: provider={provider.value}, latency={latency_ms}ms")
|
||||
raise AIServiceError(f"请求超时({self.config.timeout}秒)", provider=provider.value)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"AI 服务网络错误: provider={provider.value}, error={e}")
|
||||
raise AIServiceError(f"网络错误: {e}", provider=provider.value)
|
||||
|
||||
def _parse_response(
|
||||
self,
|
||||
data: Dict[str, Any],
|
||||
provider: AIProvider,
|
||||
latency_ms: int
|
||||
) -> AIResponse:
|
||||
"""解析 API 响应"""
|
||||
# 提取内容
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
raise AIServiceError("响应中没有 choices")
|
||||
|
||||
message = choices[0].get("message", {})
|
||||
content = message.get("content", "")
|
||||
|
||||
# 提取 usage
|
||||
usage = data.get("usage", {})
|
||||
input_tokens = usage.get("prompt_tokens", 0)
|
||||
output_tokens = usage.get("completion_tokens", 0)
|
||||
total_tokens = usage.get("total_tokens", input_tokens + output_tokens)
|
||||
|
||||
# 提取费用(如果有)
|
||||
cost = usage.get("total_cost", 0.0)
|
||||
|
||||
return AIResponse(
|
||||
content=content,
|
||||
model=data.get("model", ""),
|
||||
provider=provider.value,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
cost=cost,
|
||||
latency_ms=latency_ms,
|
||||
raw_response=data
|
||||
)
|
||||
|
||||
async def _log_call(self, prompt_name: str, response: AIResponse) -> None:
|
||||
"""记录调用日志到数据库"""
|
||||
if not self.db_session:
|
||||
return
|
||||
|
||||
try:
|
||||
# TODO: 实现调用日志记录
|
||||
# 可以参考 ai_call_logs 表结构
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"记录 AI 调用日志失败: {e}")
|
||||
|
||||
async def analyze_document(
|
||||
self,
|
||||
content: str,
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
prompt_name: str = "document_analysis"
|
||||
) -> AIResponse:
|
||||
"""
|
||||
分析文档内容
|
||||
|
||||
Args:
|
||||
content: 文档内容
|
||||
prompt: 分析提示词
|
||||
model: 模型名称
|
||||
prompt_name: 提示词名称
|
||||
|
||||
Returns:
|
||||
AIResponse 响应对象
|
||||
"""
|
||||
messages = [
|
||||
{"role": "user", "content": f"{prompt}\n\n文档内容:\n{content}"}
|
||||
]
|
||||
|
||||
return await self.chat(
|
||||
messages=messages,
|
||||
model=model,
|
||||
temperature=0.1, # 文档分析使用低温度
|
||||
prompt_name=prompt_name
|
||||
)
|
||||
|
||||
|
||||
# 便捷函数
|
||||
async def quick_chat(
|
||||
messages: List[Dict[str, str]],
|
||||
model: Optional[str] = None,
|
||||
module_code: str = "quick"
|
||||
) -> str:
|
||||
"""
|
||||
快速聊天,返回纯文本
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
model: 模型名称
|
||||
module_code: 模块标识
|
||||
|
||||
Returns:
|
||||
AI 回复的文本内容
|
||||
"""
|
||||
ai = AIService(module_code=module_code)
|
||||
response = await ai.chat(messages, model=model)
|
||||
return response.content
|
||||
|
||||
|
||||
# 模型常量(遵循瑞小美 AI 接入规范)
|
||||
# 按优先级排序:首选 > 标准 > 快速
|
||||
MODEL_PRIMARY = "claude-opus-4-5-20251101-thinking" # 🥇 首选:所有任务首先尝试
|
||||
MODEL_STANDARD = "gemini-3-pro-preview" # 🥈 标准:Claude 失败后降级
|
||||
MODEL_FAST = "gemini-3-flash-preview" # 🥉 快速:最终保底
|
||||
MODEL_IMAGE = "gemini-2.5-flash-image-preview" # 🖼️ 图像生成专用
|
||||
MODEL_VIDEO = "veo3.1-pro" # 🎬 视频生成专用
|
||||
|
||||
# 兼容旧代码的别名
|
||||
DEFAULT_MODEL = MODEL_PRIMARY # 默认使用最强模型
|
||||
MODEL_ANALYSIS = MODEL_PRIMARY
|
||||
MODEL_CREATIVE = MODEL_STANDARD
|
||||
MODEL_IMAGE_GEN = MODEL_IMAGE
|
||||
|
||||
Reference in New Issue
Block a user