- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
748 lines
27 KiB
Python
748 lines
27 KiB
Python
"""
|
||
本地 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
|
||
|