Files
012-kaopeilian/backend/app/services/ai/ai_service.py
111 998211c483 feat: 初始化考培练系统项目
- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
2026-01-24 19:33:28 +08:00

748 lines
27 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.
"""
本地 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 # 通用 KeyGemini/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(
# 通用 KeyGemini/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 KeyClaude 用专属 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 KeyClaude 用专属 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