feat: 初始化考培练系统项目

- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
111
2026-01-24 19:33:28 +08:00
commit 998211c483
1197 changed files with 228429 additions and 0 deletions

View 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 # 通用 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