Compare commits
164 Commits
main
...
codex/h5-q
| Author | SHA1 | Date | |
|---|---|---|---|
| 921b56cfb9 | |||
| 1b5fcc4d52 | |||
| 6c02662d3b | |||
| 245b6b3eca | |||
| cbbdb41b80 | |||
| c3f24d0ccd | |||
| 56c9f2f359 | |||
| ba1840233c | |||
| e4a391aa08 | |||
| 128d444a76 | |||
| 653317ca7d | |||
| 971440ebff | |||
| bbd19cf9ed | |||
| dda2e040ca | |||
| a8bb8b068c | |||
| 44ab895149 | |||
| cb3b91b101 | |||
| 750a57a390 | |||
| 00d53c2569 | |||
| 0864f9305b | |||
| 2453816364 | |||
| 120fe185f9 | |||
| 82e0c02bac | |||
| b5047ca7e4 | |||
| 390f0a0cbf | |||
| 64a5052351 | |||
| e337897b7a | |||
| 8266d845c0 | |||
| c3639fe30d | |||
| 16fa9d06c2 | |||
| 872233adc6 | |||
| 6cadca8c8e | |||
| d2d43de6d3 | |||
| 73fdfed0e5 | |||
| 0199dd8242 | |||
| 9bce095583 | |||
| 70be6ab657 | |||
| 85208972b7 | |||
| 3393d1a809 | |||
| a81f16f298 | |||
| da795b10c3 | |||
| 10c829e62d | |||
| 603fb70715 | |||
| 4f75e11a8d | |||
| d0d25d9e17 | |||
| b3cb4a1ca9 | |||
| 5844741f24 | |||
| d9a8f42522 | |||
| dc4b1bfce9 | |||
| fc8a593393 | |||
| 2e274dea51 | |||
| 808ba74fc5 | |||
| 1ab834733e | |||
| 851c2a532a | |||
| b82a5a464f | |||
| 5ead280df0 | |||
| 83f67936c8 | |||
| 085deef9d3 | |||
| 75c54466d4 | |||
| 367127504f | |||
| 6d164a77fe | |||
| 0bc767933e | |||
| d7d9514348 | |||
| ebaf3defa2 | |||
| b759409580 | |||
| 076fcb9f02 | |||
| 7f835ec699 | |||
| fb39c9c543 | |||
| 9fb3dad5bd | |||
| aedecb3aab | |||
| 5aca872129 | |||
| 5819830e33 | |||
| f07335dab4 | |||
| ef24868105 | |||
| 55333445f4 | |||
| 53febbbf91 | |||
| 7587da6d18 | |||
| 664d1acbce | |||
| e661e53d0b | |||
| 5bd737b064 | |||
| 769bd2b7b4 | |||
| 0b0ff6b9dc | |||
| 34e740bbda | |||
| d9c7f9a309 | |||
| 0ea75dea1d | |||
| facb3a6a56 | |||
| 6c53566a4e | |||
| 532d51370e | |||
| cde7da572b | |||
| c56a4b49e1 | |||
| 34d6dd01c7 | |||
| 0e2706a4f4 | |||
| 2dcb43b717 | |||
| c86b517063 | |||
| d78d0ea75f | |||
| e1a130177c | |||
| 42569a1057 | |||
| f39e51a4d4 | |||
| d6b7ec1e52 | |||
| 1c73e8dee6 | |||
| 67048a14e8 | |||
| 76da6dd65a | |||
| 3598e34819 | |||
| f4196612d4 | |||
| 5dfff52b04 | |||
| 61db0aa9b8 | |||
| efd03ff9df | |||
| 6464c61396 | |||
| c68af6f806 | |||
| 70466a6a31 | |||
| 23d5f42972 | |||
| db1d83631b | |||
| 812ae80536 | |||
| 7a6068590e | |||
| 72bd2e1e02 | |||
| 1aab53cb37 | |||
| 211cb6207e | |||
| 465a481e7a | |||
| d476c54580 | |||
| 6f3bbd3803 | |||
| 10b6de0622 | |||
| 925ef0238d | |||
| 7811e3ed1f | |||
| 13185ab42b | |||
| 35011d2679 | |||
| fb0bbc346b | |||
| 7be5dde1db | |||
| aed6d8ffbf | |||
| 9c76be70fe | |||
| eda2c5af76 | |||
| ac72b6a94a | |||
| ca50fa437c | |||
| 6ecdc8011d | |||
| 4b3ea7baa4 | |||
| cd01e45fbd | |||
| d18d9fe9d8 | |||
| 29f25dd767 | |||
| 4f11d8632c | |||
| 84da551cbe | |||
| 03b1810a90 | |||
| 8d5d36c5eb | |||
| 7be9847d65 | |||
| da262f4596 | |||
| dada4aafcd | |||
| bcdf91c85d | |||
| 55b7c5b0ea | |||
| 08c301c136 | |||
| 08dd371480 | |||
| 62eeb3186c | |||
| 301661e7b4 | |||
| 86cbed2437 | |||
| 207340973c | |||
| 96742de07b | |||
| e9f3db9ab2 | |||
| 17cba7cf61 | |||
| 44af745828 | |||
| b7dfa13872 | |||
| dbd59a84df | |||
| 4eac7351a9 | |||
| 708e0a424e | |||
| fa2d82333c | |||
| a4b59add07 | |||
| cd73ed3001 | |||
| 34aeb22760 |
38
ai_knowledge_base_v2/.env.example
Normal file
38
ai_knowledge_base_v2/.env.example
Normal file
@@ -0,0 +1,38 @@
|
||||
# ============================================
|
||||
# 飞书知识库配置
|
||||
# ============================================
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
FEISHU_SEARCH_URL=
|
||||
|
||||
# ============================================
|
||||
# 管理后台首个管理员初始化
|
||||
# 生产环境必须替换为安全账号密码,不能使用 admin123456
|
||||
# ============================================
|
||||
APP_ENV=production
|
||||
MYSQL_ROOT_PASSWORD=replace-with-strong-root-password
|
||||
MYSQL_PASSWORD=replace-with-strong-database-password
|
||||
REDIS_PASSWORD=replace-with-strong-redis-password
|
||||
DATABASE_URL=mysql+pymysql://ai_kb:URL编码后的数据库密码@mysql:3306/ai_knowledge_base_v2?charset=utf8mb4
|
||||
REDIS_URL=redis://:URL编码后的Redis密码@redis:6379/0
|
||||
JWT_SECRET_KEY=replace-with-at-least-32-random-characters
|
||||
# 使用命令生成:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
CONFIG_ENCRYPTION_KEY=replace-with-generated-fernet-key
|
||||
BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
BOOTSTRAP_ADMIN_PASSWORD=replace-with-strong-password
|
||||
BOOTSTRAP_ADMIN_NAME=系统管理员
|
||||
|
||||
# ============================================
|
||||
# 内网穿透(frpc sidecar)
|
||||
# 启动 docker compose 前,拷贝本文件为 .env 并填写
|
||||
# 或直接在 shell 中 export 这些变量
|
||||
# ============================================
|
||||
FRP_SERVER_ADDR=your-server-ip
|
||||
FRP_SERVER_PORT=7000
|
||||
FRP_AUTH_TOKEN=your-auth-token
|
||||
FRP_PROXY_NAME=qa-web
|
||||
# 默认穿透统一入口 gateway:80,由 gateway 按路径转发到用户端、后台和 API
|
||||
FRP_LOCAL_IP=gateway
|
||||
FRP_LOCAL_PORT=80
|
||||
# 自定义域名,逗号分隔,如:qa.huiyushuyuan.cn
|
||||
FRP_CUSTOM_DOMAINS=qa.huiyushuyuan.cn
|
||||
1
ai_knowledge_base_v2/.gitignore
vendored
Normal file
1
ai_knowledge_base_v2/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
51
ai_knowledge_base_v2/README.md
Normal file
51
ai_knowledge_base_v2/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# AI 知识库系统 V2 工作区
|
||||
|
||||
这个目录用于承载新需求版本:**AI 企业知识库问答系统**。
|
||||
|
||||
它和旧的 `hy_qa_asset_backend/docs` 目录刻意分开。旧项目的目标是“答疑资产后台系统”,核心是把大本营答疑内容清洗、审核、沉淀成可调用问答;新需求的目标是“企业飞书知识库 AI 问答系统”,核心是登录、权限、知识库、聊天会话、实时飞书检索和 RAG 问答。
|
||||
|
||||
## 目录说明
|
||||
|
||||
- `source_documents/`:保存原始需求资料,包括 PRD、SRS、API、数据库、RAG、后台原型、实施计划、测试、验收、部署、字段字典、权限矩阵。
|
||||
- `extracted/`:从原始 Word/Excel 中抽取出的 Markdown,主要用于开发检索和上下文阅读。
|
||||
- `development_records/`:保存新项目开发过程记录、范围决策、架构决策、实施计划和阶段性说明。
|
||||
- `reports/`:保存评估报告、方案报告和后续技术调研结论。
|
||||
|
||||
## 当前目标
|
||||
|
||||
按照已上传的 AI 知识库系统需求包,重新规划和开发一版新系统:
|
||||
|
||||
- 用户端:手机号验证码登录、学员名单准入、AI 聊天、多会话、历史聊天、流式回答、停止生成、Markdown 渲染、开放知识库过滤。
|
||||
- 管理后台:管理员登录、用户管理、学员手动添加、学员批量导入、管理员管理、知识库管理、Prompt 管理、模型管理、聊天记录、数据统计、系统配置、操作日志。
|
||||
- AI/RAG:一期以飞书知识库作为唯一知识来源;每次提问实时检索;按知识库开放状态过滤;已开放知识库默认所有用户可见;组装 Prompt;调用大模型;通过 SSE 流式输出;记录 AI 请求日志。
|
||||
- 一期不做:文件上传、图片理解、OCR、联网搜索、向量数据库同步、支付系统、课程系统、自动授权、多租户、多 Prompt、重新生成、编辑消息。
|
||||
|
||||
## 工作原则
|
||||
|
||||
- V2 先作为独立工作区推进,避免和旧项目文档混在一起。
|
||||
- 上传的原始文档是 V2 的需求依据。
|
||||
- 给用户、团队、交接和评审看的文档统一使用中文。
|
||||
- `extracted/` 里的机器抽取文档可以保留原始语言或工程化表达,优先服务开发检索。
|
||||
- 旧代码只在领域边界匹配时复用,不把旧的“答疑资产审核模型”硬套到新的“开放知识库问答模型”上。
|
||||
|
||||
## 本地开发启动
|
||||
|
||||
阶段二已经提供本地开发编排:
|
||||
|
||||
```bash
|
||||
cd ai_knowledge_base_v2
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
默认访问地址:
|
||||
|
||||
- 统一入口:`http://127.0.0.1:8080/`
|
||||
- 用户端 H5:`http://127.0.0.1:8080/`
|
||||
- 管理后台:`http://127.0.0.1:8080/login`
|
||||
- 后端 API:`http://127.0.0.1:8080/api/health`
|
||||
- 用户端直连调试:`http://127.0.0.1:5173/`
|
||||
- 管理后台直连调试:`http://127.0.0.1:5174/login/`
|
||||
- 后端直连调试:`http://127.0.0.1:8100/api/health`
|
||||
- MySQL:`127.0.0.1:3307`
|
||||
|
||||
开发阶段 mock 短信验证码为 `123456`。
|
||||
5
ai_knowledge_base_v2/apps/admin-web/.dockerignore
Normal file
5
ai_knowledge_base_v2/apps/admin-web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
1
ai_knowledge_base_v2/apps/admin-web/.env.example
Normal file
1
ai_knowledge_base_v2/apps/admin-web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
5
ai_knowledge_base_v2/apps/admin-web/.gitignore
vendored
Normal file
5
ai_knowledge_base_v2/apps/admin-web/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile
Normal file
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5174"]
|
||||
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile.prod
Normal file
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile.prod
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.prod.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
33
ai_knowledge_base_v2/apps/admin-web/README.md
Normal file
33
ai_knowledge_base_v2/apps/admin-web/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 大本营答疑助手管理后台
|
||||
|
||||
这是大本营答疑助手的管理后台,使用 Vue 3、Vite、TypeScript 和 Element Plus。
|
||||
|
||||
## 本地启动
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev -- --port 5174
|
||||
```
|
||||
|
||||
默认访问地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080/login
|
||||
```
|
||||
|
||||
默认后端地址:
|
||||
|
||||
```text
|
||||
/api
|
||||
```
|
||||
|
||||
## 管理员初始化
|
||||
|
||||
开发阶段后端会在首次管理员登录时自动创建默认管理员:
|
||||
|
||||
```text
|
||||
账号:admin
|
||||
密码:admin123456
|
||||
```
|
||||
|
||||
管理后台登录页不会预填账号密码。生产环境必须通过环境变量配置安全的首个管理员账号密码,不能使用开发默认密码。
|
||||
12
ai_knowledge_base_v2/apps/admin-web/index.html
Normal file
12
ai_knowledge_base_v2/apps/admin-web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>大本营千问千答</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
ai_knowledge_base_v2/apps/admin-web/nginx.prod.conf
Normal file
15
ai_knowledge_base_v2/apps/admin-web/nginx.prod.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
1572
ai_knowledge_base_v2/apps/admin-web/package-lock.json
generated
Normal file
1572
ai_knowledge_base_v2/apps/admin-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
ai_knowledge_base_v2/apps/admin-web/package.json
Normal file
21
ai_knowledge_base_v2/apps/admin-web/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "ai-kb-admin-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"element-plus": "^2.11.0",
|
||||
"vite": "^7.1.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.0",
|
||||
"vue-tsc": "^3.1.0"
|
||||
}
|
||||
}
|
||||
1316
ai_knowledge_base_v2/apps/admin-web/src/App.vue
Normal file
1316
ai_knowledge_base_v2/apps/admin-web/src/App.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from "vue";
|
||||
|
||||
defineProps<{ loading: boolean }>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [credentials: { username: string; password: string }];
|
||||
}>();
|
||||
|
||||
const form = reactive({ username: "", password: "" });
|
||||
|
||||
function submit() {
|
||||
emit("submit", { username: form.username.trim(), password: form.password });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-hero" aria-label="大本营千问千答后台登录">
|
||||
<section class="login-hero-main" aria-label="产品介绍">
|
||||
<header class="login-brand">
|
||||
<span class="login-brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64" role="presentation">
|
||||
<circle cx="32" cy="32" r="28" />
|
||||
<path d="M32 9c4 7 8 10 15 11-1 7 1 12 7 17-6 4-9 9-9 16-7-1-12 1-17 6-4-6-9-9-16-9 1-7-1-12-6-17 6-4 9-9 9-16 7 1 12-1 17-6 4 6 9 9 16 9Z" />
|
||||
<path d="M32 19c2.5 5 5.5 7.5 11 8-4 3.5-5.5 7.5-4 13-4.7-2.5-8.7-2.5-13 0 1.5-5.5 0-9.5-4-13 5.5-.5 8.5-3 10-8Z" />
|
||||
<circle cx="32" cy="32" r="3" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="login-brand-copy">
|
||||
<strong>大本营千问千答</strong>
|
||||
<small>智能知识管理与问答系统</small>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="login-orbit" aria-hidden="true"><i></i></div>
|
||||
<div class="login-dot-matrix" aria-hidden="true"></div>
|
||||
|
||||
<div class="login-brand-content">
|
||||
<h1>
|
||||
把大本营答疑、知识库和学员<br />
|
||||
运营放在<span>同一个控制台</span>里
|
||||
</h1>
|
||||
<p>
|
||||
统一维护学员名单、开放知识库、模型接入、Agent 调试、问答记录和系统运行参数,让日常运营更清晰,也让排查与交接更从容。
|
||||
</p>
|
||||
|
||||
<div class="login-highlights" aria-label="核心能力">
|
||||
<div class="login-highlight-item">
|
||||
<span aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><rect x="5" y="3.5" width="13" height="17" rx="2" /><path d="M8.5 7.5h5M8.5 11.5h6M8.5 15.5h3.5M18 7h2v11a2 2 0 0 1-2 2" /></svg>
|
||||
</span>
|
||||
<strong>大本营知识库</strong>
|
||||
</div>
|
||||
<div class="login-highlight-item">
|
||||
<span aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><rect x="4" y="7" width="16" height="12" rx="4" /><path d="M9 12h.01M15 12h.01M8 16h8M12 7V4M10.5 3h3" /></svg>
|
||||
</span>
|
||||
<strong>答疑 Agent 调试</strong>
|
||||
</div>
|
||||
<div class="login-highlight-item">
|
||||
<span aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M5 5.5h14v11H9l-4 3v-14Z" /><path d="M8.5 9.5h7M8.5 13h4" /></svg>
|
||||
</span>
|
||||
<strong>问答记录审计</strong>
|
||||
</div>
|
||||
<div class="login-highlight-item">
|
||||
<span aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 3.5 15 5l3.3-.2.2 3.3 1.7 2.9-2 2.7-.5 3.3-3.3.5-2.4 2.3-2.4-2.3-3.3-.5-.5-3.3-2-2.7 1.7-2.9.2-3.3L9 5l3-1.5Z" /><circle cx="12" cy="11.7" r="3" /></svg>
|
||||
</span>
|
||||
<strong>系统运行管理</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg class="login-wave" viewBox="0 0 920 250" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path d="M-20 45C105 25 110 150 250 151S410 85 530 118s185 97 410 18" />
|
||||
<path d="M-20 62C95 40 127 169 264 167s160-64 277-28 180 82 399 18" />
|
||||
<path d="M-20 80C90 57 140 188 278 184s159-60 273-23 176 69 389 17" />
|
||||
<path d="M-20 105C88 78 142 209 295 202s168-54 278-16 165 56 367 13" />
|
||||
<path d="M-20 133C95 102 157 224 315 220s164-46 282-11 158 43 343 12" />
|
||||
</svg>
|
||||
|
||||
<svg class="login-lotus" viewBox="0 0 360 300" aria-hidden="true">
|
||||
<g>
|
||||
<path d="M180 258C126 219 119 159 180 72c61 87 54 147 0 186Z" />
|
||||
<path d="M177 260C112 244 78 196 99 99c75 58 98 108 78 161Z" />
|
||||
<path d="M183 260c65-16 99-64 78-161-75 58-98 108-78 161Z" />
|
||||
<path d="M162 264C92 266 47 232 34 143c78 30 119 70 128 121Z" />
|
||||
<path d="M198 264c70 2 115-32 128-121-78 30-119 70-128 121Z" />
|
||||
<path d="M119 275c-45 5-80-8-111-41 56 3 97 15 111 41ZM241 275c45 5 80-8 111-41-56 3-97 15-111 41Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</section>
|
||||
|
||||
<section class="login-card" aria-labelledby="login-title">
|
||||
<div class="login-card-inner">
|
||||
<div class="login-card-head">
|
||||
<span class="login-card-eyebrow">ADMIN CONSOLE</span>
|
||||
<h2 id="login-title">欢迎登录</h2>
|
||||
<p>登录大本营答疑助手后台管理系统</p>
|
||||
</div>
|
||||
<el-form label-position="top" autocomplete="off" @submit.prevent="submit">
|
||||
<el-form-item label="管理员账号">
|
||||
<el-input v-model="form.username" size="large" placeholder="请输入管理员账号" autocomplete="off">
|
||||
<template #prefix>
|
||||
<svg class="login-input-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5" /><path d="M5.5 19c.6-4 2.8-6 6.5-6s5.9 2 6.5 6" /></svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
size="large"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
autocomplete="new-password"
|
||||
show-password
|
||||
>
|
||||
<template #prefix>
|
||||
<svg class="login-input-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v2.5" /></svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" class="full-btn login-submit">
|
||||
进入答疑后台
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
|
||||
</el-button>
|
||||
</el-form>
|
||||
<p class="login-security-note"><span aria-hidden="true"></span>仅限已授权的后台管理员访问</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<p class="login-copyright">© 2026 深圳市慧愈文化科技有限公司</p>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ page: number; pageSize: number; total: number }>();
|
||||
const emit = defineEmits<{ change: [page: number, pageSize: number] }>();
|
||||
|
||||
function changePage(page: number, pageSize: number) { emit("change", page, pageSize); }
|
||||
function changeSize(pageSize: number) { emit("change", 1, pageSize); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-pagination">
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="changePage($event, pageSize)"
|
||||
@size-change="changeSize"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
temperature: number | null;
|
||||
topP: number | null;
|
||||
topK: number | null;
|
||||
presencePenalty: number | null;
|
||||
frequencyPenalty: number | null;
|
||||
maxToken: number | null;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"update:temperature": [value: number | null];
|
||||
"update:topP": [value: number | null];
|
||||
"update:topK": [value: number | null];
|
||||
"update:presencePenalty": [value: number | null];
|
||||
"update:frequencyPenalty": [value: number | null];
|
||||
"update:maxToken": [value: number | null];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agent-config-grid">
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">回答随机性<el-tooltip placement="top" :show-after="200" content="取值 0–2。数值越小,回答越稳定、一致;数值越大,表达越多样,但也更容易发散。答疑场景建议使用较低数值。"><button type="button" class="agent-parameter-help" aria-label="回答随机性说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="temperature" :disabled="disabled" :min="0" :max="2" :step="0.1" @update:model-value="$emit('update:temperature', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">核采样概率<el-tooltip placement="top" :show-after="200" content="取值 0–1。模型只从累计概率达到该数值的候选词中选择。数值越小越专注,越大越多样。留空表示使用模型供应商默认值。"><button type="button" class="agent-parameter-help" aria-label="核采样概率说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="topP" :disabled="disabled" :min="0" :max="1" :step="0.05" @update:model-value="$emit('update:topP', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">候选词数量<el-tooltip placement="top" :show-after="200" content="取值 1–1000。每次生成只从概率最高的前 K 个候选词中选择。数值越小越保守,越大选择越多。留空表示使用供应商默认值,部分模型可能不支持。"><button type="button" class="agent-parameter-help" aria-label="候选词数量说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="topK" :disabled="disabled" :min="1" :max="1000" @update:model-value="$emit('update:topK', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">最大输出长度<el-tooltip placement="top" :show-after="200" content="限制模型本次最多生成的 Token 数,不等于字数。建议正式答疑使用 4096–8192;数值越大允许回答越长,也会增加耗时和费用,最终仍受模型自身上限约束。"><button type="button" class="agent-parameter-help" aria-label="最大输出长度说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="maxToken" :disabled="disabled" :min="256" :max="100000" :step="256" @update:model-value="$emit('update:maxToken', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">主题重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -2–2,0 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><button type="button" class="agent-parameter-help" aria-label="主题重复惩罚说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="presencePenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:presencePenalty', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">高频重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -2–2,0 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><button type="button" class="agent-parameter-help" aria-label="高频重复惩罚说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="frequencyPenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:frequencyPenalty', $event)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,473 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { AgentDebugResult, AgentRuntimeConfig, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem } from "../types/api";
|
||||
import AgentGenerationParameters from "./AgentGenerationParameters.vue";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
|
||||
const props = defineProps<{ previewKnowledgeId?: number | null }>();
|
||||
const emit = defineEmits<{ consumedPreview: [] }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const promptSaving = ref(false);
|
||||
const runtimeSaving = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const agentDebugging = ref(false);
|
||||
const activeConfigTab = ref("prompt");
|
||||
const prompt = ref<PromptDetail | null>(null);
|
||||
const promptContent = ref("");
|
||||
const savedPromptContent = ref("");
|
||||
const models = ref<ModelItem[]>([]);
|
||||
const knowledge = ref<KnowledgeItem[]>([]);
|
||||
const history = ref<PromptHistoryItem[]>([]);
|
||||
const historyPager = reactive({ page: 1, pageSize: 10, total: 0 });
|
||||
const historyDetailOpen = ref(false);
|
||||
const selectedHistory = ref<PromptDetail | null>(null);
|
||||
const historyDetailLoading = ref(false);
|
||||
const agentDebugTrace = ref<Record<string, any>[]>([]);
|
||||
const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([
|
||||
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
|
||||
]);
|
||||
|
||||
const agentForm = reactive({
|
||||
modelId: undefined as number | undefined,
|
||||
knowledgeIds: [] as number[],
|
||||
temperature: 0.2 as number | null,
|
||||
topP: null as number | null,
|
||||
topK: null as number | null,
|
||||
presencePenalty: null as number | null,
|
||||
frequencyPenalty: null as number | null,
|
||||
maxToken: 8192 as number | null,
|
||||
question: "",
|
||||
});
|
||||
const runtimeConfig = ref<AgentRuntimeConfig | null>(null);
|
||||
const runtimeForm = reactive({
|
||||
temperature: null as number | null,
|
||||
topP: null as number | null,
|
||||
topK: null as number | null,
|
||||
presencePenalty: null as number | null,
|
||||
frequencyPenalty: null as number | null,
|
||||
maxToken: 8192 as number | null,
|
||||
});
|
||||
|
||||
const promptDirty = computed(() => promptContent.value !== savedPromptContent.value);
|
||||
const selectedModelName = computed(() => {
|
||||
const model = models.value.find((item) => item.id === agentForm.modelId);
|
||||
return model?.displayName || model?.modelName || "未选择模型";
|
||||
});
|
||||
const selectedKnowledgeSummary = computed(() => {
|
||||
if (!agentForm.knowledgeIds.length) return "未选择知识库";
|
||||
if (agentForm.knowledgeIds.length === 1) {
|
||||
return knowledge.value.find((item) => item.id === agentForm.knowledgeIds[0])?.name || "1 个知识库";
|
||||
}
|
||||
return `${agentForm.knowledgeIds.length} 个知识库`;
|
||||
});
|
||||
|
||||
onMounted(load);
|
||||
|
||||
watch(() => props.previewKnowledgeId, (knowledgeId) => {
|
||||
if (knowledgeId && knowledge.value.length) applyPreviewKnowledge(knowledgeId);
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [promptResult, modelRows, knowledgeRows, formalConfig] = await Promise.all([
|
||||
api.prompt(), api.models(), api.knowledgeOptions(), api.agentRuntimeConfig(),
|
||||
]);
|
||||
applyPrompt(promptResult);
|
||||
models.value = modelRows;
|
||||
knowledge.value = knowledgeRows;
|
||||
agentForm.modelId = modelRows.find((item) => item.enabled === 1)?.id ?? modelRows[0]?.id;
|
||||
applyRuntimeConfig(formalConfig);
|
||||
applyDebugModelDefaults(agentForm.modelId);
|
||||
agentForm.knowledgeIds = knowledgeRows
|
||||
.filter((item) => item.status === 1 && item.lifecycleStatus === "active")
|
||||
.map((item) => item.id);
|
||||
if (props.previewKnowledgeId) applyPreviewKnowledge(props.previewKnowledgeId);
|
||||
await loadHistory();
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "Agent 配置加载失败"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyRuntimeConfig(value: AgentRuntimeConfig) {
|
||||
runtimeConfig.value = value;
|
||||
Object.assign(runtimeForm, {
|
||||
temperature: value.temperature,
|
||||
topP: value.topP,
|
||||
topK: value.topK,
|
||||
presencePenalty: value.presencePenalty,
|
||||
frequencyPenalty: value.frequencyPenalty,
|
||||
maxToken: value.maxToken,
|
||||
});
|
||||
}
|
||||
|
||||
function applyDebugModelDefaults(modelId?: number) {
|
||||
const model = models.value.find((item) => item.id === modelId);
|
||||
if (!model) return;
|
||||
Object.assign(agentForm, {
|
||||
temperature: model.temperature ?? null,
|
||||
topP: model.topP ?? null,
|
||||
topK: model.topK ?? null,
|
||||
presencePenalty: model.presencePenalty ?? null,
|
||||
frequencyPenalty: model.frequencyPenalty ?? null,
|
||||
maxToken: model.maxToken ?? (model.enabled === 1 ? runtimeConfig.value?.maxToken : null) ?? 8192,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveRuntimeConfig() {
|
||||
if (!runtimeConfig.value?.modelId) return ElMessage.error("请先在模型管理中启用一个正式模型");
|
||||
if (!runtimeForm.maxToken) return ElMessage.warning("最大输出长度不能为空");
|
||||
runtimeSaving.value = true;
|
||||
try {
|
||||
const saved = await api.saveAgentRuntimeConfig({
|
||||
temperature: runtimeForm.temperature,
|
||||
topP: runtimeForm.topP,
|
||||
topK: runtimeForm.topK,
|
||||
presencePenalty: runtimeForm.presencePenalty,
|
||||
frequencyPenalty: runtimeForm.frequencyPenalty,
|
||||
maxToken: runtimeForm.maxToken,
|
||||
});
|
||||
applyRuntimeConfig(saved);
|
||||
const model = models.value.find((item) => item.id === saved.modelId);
|
||||
if (model) Object.assign(model, runtimeForm);
|
||||
if (agentForm.modelId === saved.modelId) applyDebugModelDefaults(saved.modelId ?? undefined);
|
||||
ElMessage.success("正式运行参数已保存,将从用户端下一条消息开始生效");
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "正式运行参数保存失败"));
|
||||
} finally {
|
||||
runtimeSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreviewKnowledge(knowledgeId: number) {
|
||||
if (knowledge.value.some((item) => item.id === knowledgeId)) {
|
||||
agentForm.knowledgeIds = [knowledgeId];
|
||||
activeConfigTab.value = "debug";
|
||||
}
|
||||
emit("consumedPreview");
|
||||
}
|
||||
|
||||
function applyPrompt(value: PromptDetail) {
|
||||
prompt.value = value;
|
||||
promptContent.value = value.promptContent;
|
||||
savedPromptContent.value = value.promptContent;
|
||||
}
|
||||
|
||||
async function savePrompt() {
|
||||
if (!promptContent.value.trim()) return ElMessage.warning("主提示词不能为空");
|
||||
promptSaving.value = true;
|
||||
try {
|
||||
applyPrompt(await api.savePrompt(promptContent.value.trim()));
|
||||
ElMessage.success("主提示词已保存为新版本");
|
||||
await loadHistory(1);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "主提示词保存失败"));
|
||||
} finally {
|
||||
promptSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPrompt() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
"恢复默认提示词会新增一个版本,现有历史不会被删除。确认继续?",
|
||||
"恢复默认提示词",
|
||||
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
promptSaving.value = true;
|
||||
try {
|
||||
applyPrompt(await api.resetPrompt());
|
||||
ElMessage.success("已恢复默认提示词并保存为新版本");
|
||||
await loadHistory(1);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "恢复默认提示词失败"));
|
||||
} finally {
|
||||
promptSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory(page = historyPager.page, pageSize = historyPager.pageSize) {
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const result = await api.promptHistory({ page, pageSize });
|
||||
history.value = result.items;
|
||||
Object.assign(historyPager, { page: result.page, pageSize: result.pageSize, total: result.total });
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "提示词历史加载失败"));
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openHistory(item: PromptHistoryItem) {
|
||||
historyDetailOpen.value = true;
|
||||
historyDetailLoading.value = true;
|
||||
selectedHistory.value = null;
|
||||
try {
|
||||
selectedHistory.value = await api.promptHistoryDetail(item.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "提示词版本加载失败"));
|
||||
historyDetailOpen.value = false;
|
||||
} finally {
|
||||
historyDetailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreHistory(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认把版本 #${id} 恢复为当前主提示词?系统会新增一个恢复版本,不会覆盖历史。`,
|
||||
"恢复历史版本",
|
||||
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
promptSaving.value = true;
|
||||
try {
|
||||
applyPrompt(await api.restorePrompt(id));
|
||||
historyDetailOpen.value = false;
|
||||
activeConfigTab.value = "prompt";
|
||||
ElMessage.success(`版本 #${id} 已恢复为当前主提示词`);
|
||||
await loadHistory(1);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "历史版本恢复失败"));
|
||||
} finally {
|
||||
promptSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSelectedHistory() {
|
||||
if (selectedHistory.value?.id) await restoreHistory(selectedHistory.value.id);
|
||||
}
|
||||
|
||||
async function debugAgent() {
|
||||
if (!agentForm.modelId) return ElMessage.error("请选择调试模型");
|
||||
if (!agentForm.question.trim()) return ElMessage.error("请输入调试问题");
|
||||
if (!promptContent.value.trim()) return ElMessage.error("主提示词不能为空");
|
||||
const question = agentForm.question.trim();
|
||||
agentPreviewMessages.value.push({ role: "user", content: question });
|
||||
agentForm.question = "";
|
||||
agentDebugging.value = true;
|
||||
try {
|
||||
const result: AgentDebugResult = await api.debugAgent({
|
||||
promptContent: promptContent.value,
|
||||
modelId: agentForm.modelId,
|
||||
knowledgeIds: agentForm.knowledgeIds,
|
||||
question,
|
||||
temperature: agentForm.temperature,
|
||||
topP: agentForm.topP,
|
||||
topK: agentForm.topK,
|
||||
presencePenalty: agentForm.presencePenalty,
|
||||
frequencyPenalty: agentForm.frequencyPenalty,
|
||||
maxToken: agentForm.maxToken,
|
||||
});
|
||||
if (!result.ok) {
|
||||
agentPreviewMessages.value.push({ role: "assistant", content: result.message });
|
||||
return ElMessage.error(result.message);
|
||||
}
|
||||
agentPreviewMessages.value.push({ role: "assistant", content: result.answer });
|
||||
agentDebugTrace.value = result.retrievalTrace || [];
|
||||
ElMessage.success(result.message);
|
||||
} catch (error) {
|
||||
const message = errorMessage(error, "Agent 调试失败");
|
||||
agentPreviewMessages.value.push({ role: "assistant", content: message });
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
agentDebugging.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearAgentPreview() {
|
||||
agentPreviewMessages.value = [{ role: "assistant", content: "预览已清空,可以继续发起新的 Agent 调试。" }];
|
||||
agentDebugTrace.value = [];
|
||||
}
|
||||
|
||||
function agentMessageParts(content: string) {
|
||||
const matched = content.match(/<think>([\s\S]*?)<\/think>/i);
|
||||
if (!matched) return { reasoning: "", answer: content };
|
||||
return {
|
||||
reasoning: matched[1].trim(),
|
||||
answer: content.replace(matched[0], "").trim() || "模型未返回正式回答。",
|
||||
};
|
||||
}
|
||||
|
||||
function changeTypeLabel(value?: string) {
|
||||
return ({ save: "手动保存", reset: "恢复默认", restore: "历史恢复", default: "系统默认" } as Record<string, string>)[value || ""] || "保存";
|
||||
}
|
||||
|
||||
function changeTypeTag(value?: string) {
|
||||
if (value === "reset") return "warning";
|
||||
if (value === "restore") return "success";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return "尚未保存";
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-head agent-page-head">
|
||||
<div>
|
||||
<h2>Agent 管理</h2>
|
||||
<p>维护主提示词、用户端正式生成参数和历史版本,并验证真实检索与回答效果。</p>
|
||||
</div>
|
||||
<div class="agent-current-summary">
|
||||
<el-tag type="success">当前版本 {{ prompt?.id ? `#${prompt.id}` : "系统默认" }}</el-tag>
|
||||
<span>{{ prompt?.updatedByName || "系统默认" }} · {{ formatDate(prompt?.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-loading="loading" class="agent-workbench agent-workbench-v2">
|
||||
<div class="agent-config-panel agent-control-panel">
|
||||
<el-tabs v-model="activeConfigTab" class="agent-config-tabs">
|
||||
<el-tab-pane label="主提示词" name="prompt">
|
||||
<div class="agent-section-intro">
|
||||
<div><h3>当前主提示词</h3><p>保存、恢复默认或恢复历史版本时都会新增版本,历史记录不会被覆盖。</p></div>
|
||||
<el-tag v-if="promptDirty" type="warning">有未保存修改</el-tag>
|
||||
</div>
|
||||
<el-input v-model="promptContent" class="agent-prompt-editor" type="textarea" :rows="18" resize="vertical" placeholder="请输入 Agent 主提示词" />
|
||||
<div class="agent-editor-meta"><span>{{ promptContent.length }} 字符</span><span>调试会立即使用编辑框中的内容</span></div>
|
||||
<div class="actions agent-primary-actions">
|
||||
<el-button type="primary" :loading="promptSaving" :disabled="!promptDirty" @click="savePrompt">保存为新版本</el-button>
|
||||
<el-button :loading="promptSaving" @click="resetPrompt">恢复默认提示词</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="正式运行配置" name="runtime">
|
||||
<div class="agent-section-intro">
|
||||
<div><h3>用户端正式运行参数</h3><p>保存后从用户端下一条消息开始生效。当前正式模型:{{ runtimeConfig?.modelName || '未启用模型' }}</p></div>
|
||||
<el-button type="primary" :loading="runtimeSaving" :disabled="!runtimeConfig?.modelId" @click="saveRuntimeConfig">保存正式参数</el-button>
|
||||
</div>
|
||||
<el-alert v-if="!runtimeConfig?.modelId" title="当前没有已启用模型,请先到模型管理中启用一个正式模型。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else title="最大输出长度默认提升为 8192 Token;设置过低会导致长回答在完成前被模型截断。实际可用上限仍受模型供应商限制。" type="info" :closable="false" show-icon />
|
||||
<el-form label-position="top" class="agent-runtime-form">
|
||||
<section class="agent-advanced-settings">
|
||||
<div class="agent-subsection-title"><h4>正式生成参数</h4><span>用户端正式对话使用</span></div>
|
||||
<AgentGenerationParameters
|
||||
v-model:temperature="runtimeForm.temperature"
|
||||
v-model:top-p="runtimeForm.topP"
|
||||
v-model:top-k="runtimeForm.topK"
|
||||
v-model:presence-penalty="runtimeForm.presencePenalty"
|
||||
v-model:frequency-penalty="runtimeForm.frequencyPenalty"
|
||||
v-model:max-token="runtimeForm.maxToken"
|
||||
:disabled="!runtimeConfig?.modelId"
|
||||
/>
|
||||
</section>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="调试配置" name="debug">
|
||||
<div class="agent-section-intro"><div><h3>本次调试配置</h3><p>配置只作用于后台预览,不会修改模型和知识库的正式状态。</p></div></div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="调试模型">
|
||||
<el-select v-model="agentForm.modelId" placeholder="选择已接入模型" @change="applyDebugModelDefaults">
|
||||
<el-option v-for="model in models" :key="model.id" :label="model.displayName || model.modelName" :value="model.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="调试知识库">
|
||||
<el-select v-model="agentForm.knowledgeIds" class="debug-knowledge-select" multiple filterable collapse-tags collapse-tags-tooltip :max-collapse-tags="2" popper-class="debug-knowledge-popper" placeholder="默认使用全部已开放知识库">
|
||||
<el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<section class="agent-advanced-settings">
|
||||
<div class="agent-subsection-title"><h4>高级生成参数</h4><span>仅影响本次后台调试</span></div>
|
||||
<AgentGenerationParameters
|
||||
v-model:temperature="agentForm.temperature"
|
||||
v-model:top-p="agentForm.topP"
|
||||
v-model:top-k="agentForm.topK"
|
||||
v-model:presence-penalty="agentForm.presencePenalty"
|
||||
v-model:frequency-penalty="agentForm.frequencyPenalty"
|
||||
v-model:max-token="agentForm.maxToken"
|
||||
/>
|
||||
</section>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="历史版本" name="history">
|
||||
<div class="agent-section-intro"><div><h3>主提示词历史</h3><p>查看完整内容或恢复任意版本。恢复操作会作为一个新版本记录。</p></div></div>
|
||||
<div v-loading="historyLoading" class="prompt-history-list">
|
||||
<el-empty v-if="history.length === 0" description="还没有已保存的提示词版本" :image-size="64" />
|
||||
<article v-for="item in history" :key="item.id" class="prompt-history-item" :class="{ current: item.isCurrent }">
|
||||
<div class="prompt-history-head">
|
||||
<strong>版本 #{{ item.id }}</strong>
|
||||
<el-tag v-if="item.isCurrent" type="success" size="small">当前</el-tag>
|
||||
<el-tag :type="changeTypeTag(item.changeType)" size="small">{{ changeTypeLabel(item.changeType) }}</el-tag>
|
||||
<span>{{ item.updatedByName }} · {{ formatDate(item.updatedAt) }}</span>
|
||||
</div>
|
||||
<p>{{ item.preview || "空内容" }}</p>
|
||||
<div class="prompt-history-foot">
|
||||
<span>{{ item.charCount }} 字符<span v-if="item.sourcePromptId"> · 来源 #{{ item.sourcePromptId }}</span></span>
|
||||
<div><el-button link type="primary" @click="openHistory(item)">查看完整内容</el-button><el-button v-if="!item.isCurrent" link type="primary" @click="restoreHistory(item.id)">恢复此版本</el-button></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<AdminPagination :page="historyPager.page" :page-size="historyPager.pageSize" :total="historyPager.total" @change="loadHistory" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<aside class="agent-preview-panel">
|
||||
<div class="agent-preview-head">
|
||||
<div><h3>调试预览</h3><small>{{ selectedKnowledgeSummary }}</small></div>
|
||||
<div class="agent-preview-head-actions"><span>{{ selectedModelName }}</span><el-button link @click="clearAgentPreview">清空</el-button></div>
|
||||
</div>
|
||||
<div class="agent-preview-chat">
|
||||
<article v-for="(message, index) in agentPreviewMessages" :key="`${message.role}-${index}`" class="agent-preview-message-row" :class="message.role">
|
||||
<div class="agent-preview-avatar">{{ message.role === "user" ? "你" : "AI" }}</div>
|
||||
<div class="agent-preview-bubble">
|
||||
<strong>{{ message.role === "user" ? "你" : "Agent" }}</strong>
|
||||
<template v-if="message.role === 'assistant'">
|
||||
<details v-if="agentMessageParts(message.content).reasoning" class="agent-preview-thinking"><summary>思考过程</summary><pre>{{ agentMessageParts(message.content).reasoning }}</pre></details>
|
||||
<pre>{{ agentMessageParts(message.content).answer }}</pre>
|
||||
</template>
|
||||
<pre v-else>{{ message.content }}</pre>
|
||||
</div>
|
||||
</article>
|
||||
<article v-if="agentDebugging" class="agent-preview-message-row assistant thinking"><div class="agent-preview-avatar">AI</div><div class="agent-preview-bubble"><strong>Agent</strong><pre>正在思考中...</pre></div></article>
|
||||
</div>
|
||||
<div class="agent-preview-composer">
|
||||
<el-input v-model="agentForm.question" type="textarea" :rows="2" resize="none" placeholder="向 Agent 发送测试问题;Enter 发送,Shift+Enter 换行" @keydown.enter.exact.prevent="debugAgent" />
|
||||
<el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="agent-trace-panel agent-trace-wide">
|
||||
<div class="agent-preview-head"><div><h3>检索追踪</h3><small>按调用顺序查看请求、响应、耗时与错误</small></div><span>本轮 {{ agentDebugTrace.length }} 次 Tool 调用</span></div>
|
||||
<div class="agent-trace-list">
|
||||
<el-empty v-if="agentDebugTrace.length === 0" description="发送问题后查看目录、检索与章节回读" :image-size="64" />
|
||||
<details v-for="(step, index) in agentDebugTrace" :key="index" class="tool-trace-node">
|
||||
<summary><strong>{{ index + 1 }}. {{ step.tool }}</strong><el-tag size="small" :type="step.status === 'failed' ? 'danger' : 'success'">{{ step.status || '完成' }}</el-tag><span>{{ step.durationMs ?? 0 }} ms</span></summary>
|
||||
<h5>请求</h5><pre>{{ JSON.stringify(step.request ?? {}, null, 2) }}</pre>
|
||||
<h5>响应</h5><pre>{{ step.response == null ? '返回为空' : JSON.stringify(step.response, null, 2) }}</pre>
|
||||
<el-alert v-if="step.error" :title="step.error" type="error" :closable="false" />
|
||||
</details>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="historyDetailOpen" width="min(820px, 92vw)" title="主提示词历史版本" destroy-on-close>
|
||||
<div v-loading="historyDetailLoading" class="prompt-history-detail">
|
||||
<template v-if="selectedHistory">
|
||||
<div class="prompt-history-detail-meta"><el-tag :type="changeTypeTag(selectedHistory.changeType)">{{ changeTypeLabel(selectedHistory.changeType) }}</el-tag><strong>版本 #{{ selectedHistory.id }}</strong><span>{{ selectedHistory.updatedByName }} · {{ formatDate(selectedHistory.updatedAt) }} · {{ selectedHistory.charCount }} 字符</span></div>
|
||||
<pre>{{ selectedHistory.promptContent }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="historyDetailOpen = false">关闭</el-button><el-button v-if="selectedHistory?.id !== prompt?.id" type="primary" :loading="promptSaving" @click="restoreSelectedHistory">恢复为当前主提示词</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { KnowledgeVersion, KnowledgeVersionContent } from "../types/api";
|
||||
|
||||
const props = defineProps<{
|
||||
versions: KnowledgeVersion[];
|
||||
versionId: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ "update:versionId": [versionId: number] }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const content = ref<KnowledgeVersionContent | null>(null);
|
||||
const activeSectionId = ref<number | null>(null);
|
||||
let requestSequence = 0;
|
||||
|
||||
const activeSection = computed(() => (
|
||||
content.value?.sections.find((section) => section.id === activeSectionId.value)
|
||||
?? content.value?.sections[0]
|
||||
?? null
|
||||
));
|
||||
|
||||
const characterCount = computed(() => (
|
||||
content.value?.sections.reduce((total, section) => total + section.content.length, 0) ?? 0
|
||||
));
|
||||
|
||||
watch(() => props.versionId, loadVersion, { immediate: true });
|
||||
|
||||
async function loadVersion(versionId: number | null) {
|
||||
const sequence = ++requestSequence;
|
||||
content.value = null;
|
||||
activeSectionId.value = null;
|
||||
error.value = "";
|
||||
if (!versionId) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await api.knowledgeVersionContent(versionId);
|
||||
if (sequence !== requestSequence) return;
|
||||
content.value = result;
|
||||
activeSectionId.value = result.sections[0]?.id ?? null;
|
||||
} catch (loadError) {
|
||||
if (sequence !== requestSequence) return;
|
||||
error.value = loadError instanceof Error ? loadError.message : "知识库内容加载失败";
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectVersion(value: number) {
|
||||
emit("update:versionId", value);
|
||||
}
|
||||
|
||||
function versionStatus(version: KnowledgeVersion) {
|
||||
const labels: Record<string, string> = {
|
||||
published: "正式版本",
|
||||
pending_review: "待审核",
|
||||
approved: "待发布",
|
||||
rejected: "已拒绝",
|
||||
superseded: "历史版本",
|
||||
};
|
||||
return labels[version.status] ?? version.status;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="content-preview" v-loading="loading">
|
||||
<header class="content-preview-toolbar">
|
||||
<div>
|
||||
<h4>知识库内容预览</h4>
|
||||
<p>按同步后的章节结构查看实际进入检索链路的内容。</p>
|
||||
</div>
|
||||
<el-select
|
||||
:model-value="versionId"
|
||||
class="content-version-select"
|
||||
placeholder="选择预览版本"
|
||||
:disabled="versions.length === 0"
|
||||
@update:model-value="selectVersion"
|
||||
>
|
||||
<el-option
|
||||
v-for="version in versions"
|
||||
:key="version.id"
|
||||
:label="`V${version.versionNo} · ${versionStatus(version)}`"
|
||||
:value="version.id"
|
||||
/>
|
||||
</el-select>
|
||||
</header>
|
||||
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon>
|
||||
<template #default><el-button link type="primary" @click="loadVersion(versionId)">重新加载</el-button></template>
|
||||
</el-alert>
|
||||
|
||||
<el-empty v-else-if="!loading && !versionId" description="该知识库暂无可预览版本" />
|
||||
<el-empty v-else-if="!loading && content && content.sections.length === 0" description="当前版本没有可预览的章节内容" />
|
||||
|
||||
<template v-else-if="content && activeSection">
|
||||
<div class="content-preview-summary">
|
||||
<span>V{{ content.version.versionNo }}</span>
|
||||
<span>{{ content.sections.length }} 个章节</span>
|
||||
<span>{{ content.cards.length }} 张知识卡</span>
|
||||
<span>约 {{ characterCount.toLocaleString() }} 字</span>
|
||||
</div>
|
||||
|
||||
<div class="content-preview-layout">
|
||||
<aside class="content-outline" aria-label="章节目录">
|
||||
<strong>章节目录</strong>
|
||||
<button
|
||||
v-for="section in content.sections"
|
||||
:key="section.id"
|
||||
type="button"
|
||||
:class="{ active: section.id === activeSection.id }"
|
||||
:title="section.title"
|
||||
@click="activeSectionId = section.id"
|
||||
>
|
||||
<span>{{ section.sectionKey }}</span>
|
||||
<em>{{ section.title }}</em>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<article class="content-article">
|
||||
<div class="content-article-head">
|
||||
<div><small>{{ activeSection.sectionKey }}</small><h3>{{ activeSection.title }}</h3></div>
|
||||
<span>源内容位置 {{ activeSection.sourceStart }}–{{ activeSection.sourceEnd }}</span>
|
||||
</div>
|
||||
<pre>{{ activeSection.content }}</pre>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<el-collapse class="content-card-panel">
|
||||
<el-collapse-item :title="`查看知识卡(${content.cards.length})`" name="cards">
|
||||
<el-empty v-if="content.cards.length === 0" description="当前版本尚未生成知识卡" :image-size="72" />
|
||||
<el-table v-else :data="content.cards" max-height="360">
|
||||
<el-table-column prop="title" label="标题" min-width="180" />
|
||||
<el-table-column prop="summary" label="摘要" min-width="320" show-overflow-tooltip />
|
||||
<el-table-column prop="riskLevel" label="风险" width="100" />
|
||||
<el-table-column prop="reviewStatus" label="审核" width="110" />
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-preview { min-height: 360px; }
|
||||
.content-preview-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
|
||||
.content-preview-toolbar h4 { margin: 0; font-size: 17px; color: #203b32; }
|
||||
.content-preview-toolbar p { margin: 6px 0 0; color: #71817b; font-size: 13px; }
|
||||
.content-version-select { width: 220px; flex: 0 0 220px; }
|
||||
.content-preview-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||
.content-preview-summary span { padding: 5px 10px; border-radius: 999px; background: #f0f6f3; color: #526860; font-size: 12px; }
|
||||
.content-preview-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); height: clamp(420px, 62vh, 680px); min-height: 0; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; }
|
||||
.content-outline { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 14px 10px; border-right: 1px solid #e5ece9; background: #f7faf9; }
|
||||
.content-outline > strong { display: block; padding: 0 10px 10px; color: #455c53; font-size: 13px; }
|
||||
.content-outline button { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; width: 100%; gap: 8px; padding: 10px; border: 0; border-radius: 8px; background: transparent; color: #53645e; text-align: left; cursor: pointer; }
|
||||
.content-outline button:hover { background: #eaf3ef; }
|
||||
.content-outline button.active { background: #dcefe7; color: #176746; }
|
||||
.content-outline button span { font-size: 11px; color: #7c8d87; }
|
||||
.content-outline button em { overflow: hidden; font-style: normal; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.content-article { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 24px 28px; }
|
||||
.content-article-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #edf1ef; }
|
||||
.content-article-head small { color: #26815f; }
|
||||
.content-article-head h3 { margin: 5px 0 0; color: #203b32; font-size: 20px; }
|
||||
.content-article-head > span { color: #88958f; font-size: 12px; white-space: nowrap; }
|
||||
.content-article pre { margin: 20px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: #354840; font: inherit; font-size: 14px; line-height: 1.85; }
|
||||
.content-card-panel { margin-top: 16px; }
|
||||
@media (max-width: 900px) {
|
||||
.content-preview-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.content-version-select { width: 100%; flex-basis: auto; }
|
||||
.content-preview-layout { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
|
||||
.content-outline { display: flex; height: auto; gap: 8px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #e5ece9; }
|
||||
.content-outline > strong { display: none; }
|
||||
.content-outline button { min-width: 180px; width: 180px; }
|
||||
.content-article { height: auto; max-height: 58vh; padding: 20px; }
|
||||
.content-article-head { flex-direction: column; gap: 8px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,539 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import KnowledgeContentPreview from "./KnowledgeContentPreview.vue";
|
||||
import TableRowActions from "./TableRowActions.vue";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import type {
|
||||
KnowledgeDetail,
|
||||
KnowledgeItem,
|
||||
KnowledgeSyncJob,
|
||||
KnowledgeVersion,
|
||||
MigrationSummary,
|
||||
} from "../types/api";
|
||||
|
||||
const emit = defineEmits<{ preview: [knowledgeId: number] }>();
|
||||
|
||||
const rows = ref<KnowledgeItem[]>([]);
|
||||
const migration = ref<MigrationSummary | null>(null);
|
||||
const loading = ref(false);
|
||||
const operatingId = ref<number | null>(null);
|
||||
const detailOpen = ref(false);
|
||||
const detail = ref<KnowledgeDetail | null>(null);
|
||||
const versions = ref<KnowledgeVersion[]>([]);
|
||||
const jobs = ref<KnowledgeSyncJob[]>([]);
|
||||
const previewVersionId = ref<number | null>(null);
|
||||
const activeTab = ref("overview");
|
||||
const selectedRows = ref<KnowledgeItem[]>([]);
|
||||
const editOpen = ref(false);
|
||||
const editSaving = ref(false);
|
||||
const batchOpen = ref(false);
|
||||
const batchSaving = ref(false);
|
||||
const resolvingNode = ref(false);
|
||||
const nodeResolved = ref(false);
|
||||
const sourceTitle = ref("");
|
||||
const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" });
|
||||
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const form = reactive({
|
||||
name: "",
|
||||
feishuSpaceId: "",
|
||||
feishuNodeId: "",
|
||||
knowledgeType: "general",
|
||||
reviewMode: "manual",
|
||||
status: 0,
|
||||
remark: "",
|
||||
});
|
||||
const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" });
|
||||
const batchForm = reactive({ knowledgeType: "", reviewMode: "" });
|
||||
|
||||
onMounted(load);
|
||||
|
||||
async function load(page = pager.page, pageSize = pager.pageSize) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await api.knowledge({
|
||||
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
|
||||
sourceStatus: filter.source, pending: filter.pending === "" ? undefined : filter.pending === "yes",
|
||||
page, pageSize,
|
||||
});
|
||||
rows.value = result.items;
|
||||
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
|
||||
if (!migration.value) migration.value = await api.migrationSummary();
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "知识库列表加载失败"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (!detail.value) return;
|
||||
Object.assign(editForm, {
|
||||
id: detail.value.knowledge.id,
|
||||
name: detail.value.knowledge.name,
|
||||
knowledgeType: detail.value.knowledge.knowledgeType,
|
||||
reviewMode: detail.value.knowledge.reviewMode,
|
||||
});
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveMetadata() {
|
||||
if (!editForm.name.trim()) {
|
||||
ElMessage.warning("请输入后台知识库名称");
|
||||
return;
|
||||
}
|
||||
editSaving.value = true;
|
||||
try {
|
||||
await api.updateKnowledgeMetadata(editForm.id, {
|
||||
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType, reviewMode: editForm.reviewMode,
|
||||
});
|
||||
ElMessage.success("知识库基本信息已更新");
|
||||
editOpen.value = false;
|
||||
await Promise.all([load(), refreshDetail(editForm.id)]);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "基本信息保存失败"));
|
||||
} finally {
|
||||
editSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openBatchEdit() {
|
||||
if (!selectedRows.value.length) {
|
||||
ElMessage.warning("请先勾选需要修改的知识库");
|
||||
return;
|
||||
}
|
||||
Object.assign(batchForm, { knowledgeType: "", reviewMode: "" });
|
||||
batchOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveBatchMetadata() {
|
||||
if (!batchForm.knowledgeType && !batchForm.reviewMode) {
|
||||
ElMessage.warning("请至少选择一个修改项");
|
||||
return;
|
||||
}
|
||||
if (!await confirmAction(
|
||||
`将修改已选择的 ${selectedRows.value.length} 个知识库。该操作不影响正式版本和开放状态。`,
|
||||
"确认批量修改",
|
||||
{ confirmButtonText: "确认修改", cancelButtonText: "取消", type: "warning" },
|
||||
)) return;
|
||||
batchSaving.value = true;
|
||||
try {
|
||||
const result = await api.batchUpdateKnowledgeMetadata({
|
||||
knowledgeIds: selectedRows.value.map((item) => item.id),
|
||||
knowledgeType: batchForm.knowledgeType || undefined,
|
||||
reviewMode: batchForm.reviewMode || undefined,
|
||||
});
|
||||
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed} 个`);
|
||||
batchOpen.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "批量修改失败"));
|
||||
} finally {
|
||||
batchSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMore(command: string, item: KnowledgeItem) {
|
||||
try {
|
||||
if (command === "detail") return await openDetail(item);
|
||||
if (command === "preview") return emit("preview", item.id);
|
||||
if (command === "versions") {
|
||||
await openDetail(item);
|
||||
if (detailOpen.value) activeTab.value = "versions";
|
||||
return;
|
||||
}
|
||||
if (command === "archive") return await archive(item);
|
||||
if (command === "restore") return await restore(item);
|
||||
if (command === "delete") return await remove(item);
|
||||
} catch (error) {
|
||||
if (error !== "cancel" && error !== "close") ElMessage.error(errorMessage(error, "操作失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(item: KnowledgeItem) {
|
||||
if (!await confirmAction("恢复后知识库仍保持关闭,需要单独确认开放。", "恢复知识库", {
|
||||
confirmButtonText: "确认恢复", cancelButtonText: "取消", type: "warning",
|
||||
})) return;
|
||||
await api.setKnowledgeLifecycle(item.id, "active");
|
||||
ElMessage.success("知识库已恢复,当前仍为关闭状态");
|
||||
await load();
|
||||
}
|
||||
|
||||
async function createKnowledge() {
|
||||
if (!nodeResolved.value || !form.name || !form.feishuSpaceId || !form.feishuNodeId) {
|
||||
ElMessage.warning("请先通过 Node ID 获取并确认知识库信息");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.createKnowledge({ ...form });
|
||||
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" });
|
||||
nodeResolved.value = false; sourceTitle.value = "";
|
||||
ElMessage.success("知识库已新增,请进行首次同步");
|
||||
await load();
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "知识库新增失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveNode() {
|
||||
const nodeId = form.feishuNodeId.trim();
|
||||
if (!nodeId) return ElMessage.warning("请输入 Node ID");
|
||||
resolvingNode.value = true;
|
||||
try {
|
||||
const info = await api.resolveKnowledgeNode(nodeId);
|
||||
Object.assign(form, { feishuNodeId: info.nodeId, feishuSpaceId: info.spaceId, name: info.name, remark: info.remark || "" });
|
||||
sourceTitle.value = info.sourceTitle; nodeResolved.value = true;
|
||||
ElMessage.success("已获取飞书知识库信息");
|
||||
} catch (error) {
|
||||
nodeResolved.value = false; sourceTitle.value = "";
|
||||
ElMessage.error(errorMessage(error, "Node ID 信息获取失败"));
|
||||
} finally { resolvingNode.value = false; }
|
||||
}
|
||||
|
||||
async function openDetail(item: KnowledgeItem) {
|
||||
detailOpen.value = true;
|
||||
detail.value = null;
|
||||
activeTab.value = "overview";
|
||||
previewVersionId.value = null;
|
||||
try {
|
||||
const refreshedDetail = await refreshDetail(item.id);
|
||||
previewVersionId.value = refreshedDetail.currentVersion?.id ?? versions.value[0]?.id ?? null;
|
||||
} catch (error) {
|
||||
detailOpen.value = false;
|
||||
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDetail(id: number) {
|
||||
const [nextDetail, nextVersions, nextJobs] = await Promise.all([
|
||||
api.knowledgeDetail(id), api.knowledgeVersions(id), api.knowledgeSyncJobs(id),
|
||||
]);
|
||||
detail.value = nextDetail;
|
||||
versions.value = nextVersions;
|
||||
jobs.value = nextJobs;
|
||||
return nextDetail;
|
||||
}
|
||||
|
||||
async function sync(item: KnowledgeItem, mode: "review" | "replace") {
|
||||
operatingId.value = item.id;
|
||||
try {
|
||||
const result = await api.syncKnowledge(item.id, mode);
|
||||
const message = result.status === "unchanged"
|
||||
? "飞书内容未变化,未生成新版本"
|
||||
: result.status === "published"
|
||||
? "同步、质检和自动发布已完成"
|
||||
: "同步处理完成,已生成待审核版本";
|
||||
ElMessage.success(message);
|
||||
await load();
|
||||
if (detailOpen.value) await refreshDetail(item.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "知识库同步失败"));
|
||||
} finally {
|
||||
operatingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runMigration() {
|
||||
if (!await confirmAction(
|
||||
"将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。",
|
||||
"开始知识迁移",
|
||||
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
|
||||
)) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await api.runKnowledgeMigration();
|
||||
ElMessage.success(`迁移处理完成:${result.success}/${result.total} 成功`);
|
||||
await load();
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "知识迁移失败"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmManifest() {
|
||||
if (!detail.value?.pendingVersion) return;
|
||||
try {
|
||||
await api.confirmKnowledgeManifest(detail.value.knowledge.id, detail.value.pendingVersion.id);
|
||||
ElMessage.success("知识库说明卡已确认");
|
||||
await refreshDetail(detail.value.knowledge.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "说明卡确认失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function review(action: "approve" | "reject") {
|
||||
if (!detail.value?.pendingVersion) return;
|
||||
let reason = "";
|
||||
if (action === "reject") {
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(
|
||||
"请记录拒绝原因。知识内容不能在后台修改,请前往飞书源文章修改后重新同步。",
|
||||
"拒绝版本",
|
||||
{ confirmButtonText: "确认拒绝", cancelButtonText: "取消", inputValidator: (value) => Boolean(value?.trim()) || "请填写拒绝原因" },
|
||||
);
|
||||
reason = result.value;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await api.reviewKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, action, reason);
|
||||
ElMessage.success(action === "approve" ? "版本已审核通过" : "版本已拒绝");
|
||||
await refreshDetail(detail.value.knowledge.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "版本审核失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function publish(keepOpen: boolean) {
|
||||
if (!detail.value?.pendingVersion) return;
|
||||
if (!await confirmAction(
|
||||
`将发布 V${detail.value.pendingVersion.versionNo},从用户下一条消息开始生效;发布后知识库将${keepOpen ? "保持开放" : "处于关闭状态"}。`,
|
||||
"发布知识版本",
|
||||
{ confirmButtonText: "确认发布", cancelButtonText: "取消", type: "warning" },
|
||||
)) return;
|
||||
try {
|
||||
await api.publishKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, keepOpen);
|
||||
ElMessage.success("正式版本已发布");
|
||||
await load();
|
||||
await refreshDetail(detail.value.knowledge.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "版本发布失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function rollback(version: KnowledgeVersion) {
|
||||
if (!detail.value) return;
|
||||
if (!await confirmAction(
|
||||
`将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`,
|
||||
"回滚知识版本",
|
||||
{ confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" },
|
||||
)) return;
|
||||
try {
|
||||
await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1);
|
||||
ElMessage.success("知识版本已回滚");
|
||||
await load();
|
||||
await refreshDetail(detail.value.knowledge.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "版本回滚失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
|
||||
const next = item.status !== 1;
|
||||
if (!await confirmAction(
|
||||
next ? "开放后,该知识库将从用户下一条消息开始进入 Agent 动态知识目录。" : "关闭后,当前生成不受影响,从用户下一条消息开始无法检索该知识库。",
|
||||
next ? "开放知识库" : "关闭知识库",
|
||||
{ confirmButtonText: next ? "确认开放" : "确认关闭", cancelButtonText: "取消", type: "warning" },
|
||||
)) return false;
|
||||
operatingId.value = item.id;
|
||||
try {
|
||||
await api.setKnowledgeOpen(item.id, next);
|
||||
ElMessage.success(next ? "知识库已开放" : "知识库已关闭");
|
||||
if (detailOpen.value && detail.value?.knowledge.id === item.id) await refreshDetail(item.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, next ? "知识库开放失败" : "知识库关闭失败"));
|
||||
return false;
|
||||
} finally {
|
||||
operatingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function archive(item: KnowledgeItem) {
|
||||
if (!await confirmAction("归档后知识库会立即关闭,但历史正式版本和检索记录将保留。", "归档知识库", {
|
||||
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
|
||||
})) return;
|
||||
await api.setKnowledgeLifecycle(item.id, "archived");
|
||||
ElMessage.success("知识库已归档");
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(item: KnowledgeItem) {
|
||||
if (!await confirmAction(
|
||||
`将物理删除“${item.name}”及关联数据。该操作不可恢复,并可能影响历史检索记录定位。`,
|
||||
"物理删除知识库",
|
||||
{ confirmButtonText: "确认删除", cancelButtonText: "取消", type: "error" },
|
||||
)) return;
|
||||
await api.deleteKnowledge(item.id);
|
||||
ElMessage.success("知识库已删除");
|
||||
await load();
|
||||
}
|
||||
|
||||
async function showVersion(version: KnowledgeVersion) {
|
||||
previewVersionId.value = version.id;
|
||||
activeTab.value = "content";
|
||||
}
|
||||
|
||||
function statusType(status: string) {
|
||||
if (["published", "passed", "normal", "completed"].includes(status)) return "success";
|
||||
if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger";
|
||||
if (["pending_review", "processing", "pending"].includes(status)) return "warning";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function typeLabel(value: string) {
|
||||
return { course: "课程知识库", qa: "答疑知识库", general: "通用知识库" }[value] || value;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
async function confirmAction(
|
||||
message: string,
|
||||
title: string,
|
||||
options: Parameters<typeof ElMessageBox.confirm>[2],
|
||||
) {
|
||||
try {
|
||||
await ElMessageBox.confirm(message, title, options);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-head inline">
|
||||
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库;同步、审核和发布相互独立。</p></div>
|
||||
<el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button>
|
||||
</div>
|
||||
|
||||
<section class="migration-overview">
|
||||
<div><span>知识库总数</span><strong>{{ migration?.total ?? 0 }}</strong></div>
|
||||
<div><span>待迁移</span><strong>{{ migration?.counts.pending ?? 0 }}</strong></div>
|
||||
<div><span>待审核</span><strong>{{ migration?.counts.pending_review ?? 0 }}</strong></div>
|
||||
<div><span>迁移完成</span><strong>{{ migration?.counts.completed ?? 0 }}</strong></div>
|
||||
<div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div>
|
||||
</section>
|
||||
|
||||
<section class="knowledge-work-panel">
|
||||
<h3>通过 Node ID 快速新增</h3>
|
||||
<div class="node-resolve-row">
|
||||
<el-input v-model="form.feishuNodeId" placeholder="请输入飞书 Node ID" :disabled="resolvingNode" @input="nodeResolved = false" />
|
||||
<el-button type="primary" :loading="resolvingNode" @click="resolveNode">获取知识库信息</el-button>
|
||||
</div>
|
||||
<el-alert v-if="nodeResolved" :title="`已获取:${sourceTitle}`" type="success" :closable="false" show-icon />
|
||||
<div v-if="nodeResolved" class="knowledge-create-grid">
|
||||
<el-input v-model="form.feishuSpaceId" placeholder="Space ID" disabled />
|
||||
<el-input :model-value="sourceTitle" placeholder="飞书原标题" disabled />
|
||||
<el-input v-model="form.name" placeholder="后台显示名称" />
|
||||
<el-select v-model="form.knowledgeType">
|
||||
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
|
||||
</el-select>
|
||||
<el-select v-model="form.reviewMode">
|
||||
<el-option label="必须人工审核" value="manual" /><el-option label="允许自动发布" value="automatic" />
|
||||
</el-select>
|
||||
<el-input v-model="form.remark" placeholder="备注(可修改)" />
|
||||
<el-button type="primary" @click="createKnowledge">保存并准备同步</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="knowledge-filters">
|
||||
<el-input v-model="filter.keyword" placeholder="搜索后台名称或飞书标题" clearable @change="load(1)" />
|
||||
<el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option label="课程" value="course" /><el-option label="答疑" value="qa" /><el-option label="通用" value="general" /></el-select>
|
||||
<el-select v-model="filter.open" placeholder="开放状态" clearable @change="load(1)"><el-option label="已开放" value="1" /><el-option label="已关闭" value="0" /></el-select>
|
||||
<el-select v-model="filter.source" placeholder="源状态" clearable @change="load(1)"><el-option label="正常" value="normal" /><el-option label="源异常" value="error" /></el-select>
|
||||
<el-select v-model="filter.pending" placeholder="待发布版本" clearable @change="load(1)"><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select>
|
||||
</div>
|
||||
|
||||
<div class="knowledge-batch-bar">
|
||||
<span>已选择 {{ selectedRows.length }} 个知识库</span>
|
||||
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型/审核方式</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event">
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column label="知识库" min-width="240"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="table-subline">{{ row.sourceTitle || "尚未同步源标题" }}</small></template></el-table-column>
|
||||
<el-table-column label="类型/审核" width="150"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}<small class="table-subline">{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }}</small></template></el-table-column>
|
||||
<el-table-column label="版本" width="130"><template #default="{ row }"><span>正式 {{ row.currentVersionId ? `#${row.currentVersionId}` : '无' }}</span><small class="table-subline">待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }}</small></template></el-table-column>
|
||||
<el-table-column label="状态" width="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></template></el-table-column>
|
||||
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
|
||||
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row, 'review')">同步</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item command="versions">查看版本</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
|
||||
|
||||
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
|
||||
<template v-if="detail">
|
||||
<div class="knowledge-detail-head">
|
||||
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · {{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核' : '允许自动发布' }}</p></div>
|
||||
<div class="knowledge-detail-head-actions"><el-button type="primary" plain @click="activeTab = 'content'">预览内容</el-button><el-tag :type="detail.knowledge.status === 1 ? 'success' : 'info'">{{ detail.knowledge.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag :type="statusType(detail.knowledge.sourceStatus)">{{ detail.knowledge.sourceStatus === 'normal' ? '源文章正常' : '源文章异常' }}</el-tag></div>
|
||||
</div>
|
||||
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="基本信息" name="overview">
|
||||
<div class="knowledge-section-head"><h4>基本信息</h4><el-button type="primary" plain @click="openEdit">编辑基本信息</el-button></div>
|
||||
<dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>审核方式</dt><dd>{{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核发布' : '允许自动处理后发布' }}</dd><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>正式版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd><dt>待发布版本</dt><dd>{{ detail.pendingVersion ? `V${detail.pendingVersion.versionNo}` : '暂无' }}</dd></dl>
|
||||
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途:</strong>{{ detail.manifest.purpose }}</p><p><strong>适用:</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用:</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题:</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界:</strong>{{ detail.manifest.boundaries }}</p><el-button v-if="!detail.manifest.confirmed && detail.pendingVersion" type="primary" @click="confirmManifest">确认说明卡</el-button></section>
|
||||
<div v-if="detail.pendingVersion?.status === 'pending_review'" class="version-actions"><el-button type="success" @click="review('approve')">审核通过</el-button><el-button type="danger" @click="review('reject')">审核拒绝</el-button></div>
|
||||
<div v-else-if="detail.pendingVersion?.status === 'approved'" class="version-actions"><el-button type="primary" @click="publish(true)">发布并保持开放</el-button><el-button @click="publish(false)">发布后关闭</el-button></div>
|
||||
<el-alert v-else-if="detail.pendingVersion?.status === 'rejected'" title="该版本已被拒绝,请在飞书修改源文章后重新同步。" type="warning" :closable="false" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="内容预览" name="content" lazy>
|
||||
<KnowledgeContentPreview v-model:version-id="previewVersionId" :versions="versions" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="版本记录" name="versions"><el-table :data="versions"><el-table-column label="版本" width="90"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="qualityStatus" label="质检" width="120" /><el-table-column prop="createdAt" label="生成时间" /><el-table-column prop="publishedAt" label="发布时间" /><el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="showVersion(row)">查看内容</el-button><el-button v-if="['published','superseded'].includes(row.status) && row.id !== detail?.knowledge.currentVersionId" link @click="rollback(row)">回滚</el-button></template></el-table-column></el-table></el-tab-pane>
|
||||
<el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="mode" label="模式" width="110" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="editOpen" title="编辑知识库基本信息" width="min(520px, 92vw)" append-to-body destroy-on-close>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="后台知识库名称" required><el-input v-model="editForm.name" maxlength="100" show-word-limit /></el-form-item>
|
||||
<el-form-item label="知识库类型" required><el-select v-model="editForm.knowledgeType"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
|
||||
<el-form-item label="审核方式" required><el-select v-model="editForm.reviewMode"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
|
||||
<el-alert title="本次修改不会重新同步飞书,也不会改变当前正式版本和开放状态。" type="info" :closable="false" />
|
||||
</el-form>
|
||||
<template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="batchOpen" title="批量修改知识库" width="min(520px, 92vw)" append-to-body destroy-on-close>
|
||||
<el-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" />
|
||||
<el-form label-position="top" class="batch-edit-form">
|
||||
<el-form-item label="知识库类型"><el-select v-model="batchForm.knowledgeType" clearable placeholder="不修改"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
|
||||
<el-form-item label="审核方式"><el-select v-model="batchForm.reviewMode" clearable placeholder="不修改"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-batch-bar,
|
||||
.knowledge-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.knowledge-batch-bar {
|
||||
min-height: 48px;
|
||||
padding: 8px 0;
|
||||
color: #66756f;
|
||||
}
|
||||
|
||||
.knowledge-section-head h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.knowledge-status-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.knowledge-detail-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.batch-edit-form {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="table-row-actions"><slot /></div>
|
||||
</template>
|
||||
277
ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts
Normal file
277
ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
export type SystemSettingValue = string | number | boolean;
|
||||
type SystemSettingType = "number" | "switch" | "text" | "password";
|
||||
|
||||
export interface SystemSettingDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
type: SystemSettingType;
|
||||
defaultValue: SystemSettingValue;
|
||||
min?: number;
|
||||
max?: number;
|
||||
placeholder?: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SystemSettingSection {
|
||||
title: string;
|
||||
description: string;
|
||||
settings: SystemSettingDefinition[];
|
||||
}
|
||||
|
||||
export const systemSettingSections: SystemSettingSection[] = [
|
||||
{
|
||||
title: "用户与登录",
|
||||
description: "控制学员准入、默认资料、验证码和登录态有效时间。",
|
||||
settings: [
|
||||
{
|
||||
key: "daily_chat_limit",
|
||||
label: "默认每日聊天额度",
|
||||
type: "number",
|
||||
defaultValue: 100,
|
||||
min: 0,
|
||||
max: 100000,
|
||||
description: "新建或导入学员未单独填写额度时使用。",
|
||||
},
|
||||
{
|
||||
key: "access_token_expire_minutes",
|
||||
label: "登录 Token 有效期(分钟)",
|
||||
type: "number",
|
||||
defaultValue: 43200,
|
||||
min: 5,
|
||||
max: 43200,
|
||||
description: "用户端和管理端登录后的 Token 有效期。",
|
||||
},
|
||||
{
|
||||
key: "sms_code_expire_minutes",
|
||||
label: "验证码有效期(分钟)",
|
||||
type: "number",
|
||||
defaultValue: 5,
|
||||
min: 1,
|
||||
max: 60,
|
||||
description: "短信验证码生成后的可使用时间。",
|
||||
},
|
||||
{
|
||||
key: "mock_sms_enabled",
|
||||
label: "使用 mock 短信",
|
||||
type: "switch",
|
||||
defaultValue: true,
|
||||
description: "开发和演示环境可开启,生产环境应关闭并接入真实短信。",
|
||||
},
|
||||
{
|
||||
key: "mock_sms_code",
|
||||
label: "mock 短信验证码",
|
||||
type: "text",
|
||||
defaultValue: "123456",
|
||||
placeholder: "例如 123456",
|
||||
description: "mock 短信开启时使用的固定验证码。",
|
||||
},
|
||||
{
|
||||
key: "aliyun_sms_access_key_id",
|
||||
label: "阿里云 AccessKey ID",
|
||||
type: "password",
|
||||
defaultValue: "",
|
||||
placeholder: "请输入 RAM 用户 AccessKey ID",
|
||||
description: "关闭 mock 短信后用于调用阿里云短信服务。",
|
||||
},
|
||||
{
|
||||
key: "aliyun_sms_access_key_secret",
|
||||
label: "阿里云 AccessKey Secret",
|
||||
type: "password",
|
||||
defaultValue: "",
|
||||
placeholder: "请输入 RAM 用户 AccessKey Secret",
|
||||
description: "敏感配置,建议使用最小权限 RAM 用户。",
|
||||
},
|
||||
{
|
||||
key: "aliyun_sms_sign_name",
|
||||
label: "阿里云短信签名",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
placeholder: "例如 某某课堂",
|
||||
description: "需与阿里云短信控制台审核通过的签名一致。",
|
||||
},
|
||||
{
|
||||
key: "aliyun_sms_template_code",
|
||||
label: "阿里云模板 Code",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
placeholder: "例如 SMS_123456789",
|
||||
description: "验证码短信模板 Code。",
|
||||
},
|
||||
{
|
||||
key: "aliyun_sms_template_param_key",
|
||||
label: "验证码变量名",
|
||||
type: "text",
|
||||
defaultValue: "code",
|
||||
placeholder: "默认 code",
|
||||
description: "模板中验证码变量名,例如模板变量为 ${code} 时填写 code。",
|
||||
},
|
||||
{
|
||||
key: "aliyun_sms_endpoint",
|
||||
label: "阿里云短信 Endpoint",
|
||||
type: "text",
|
||||
defaultValue: "dysmsapi.aliyuncs.com",
|
||||
placeholder: "dysmsapi.aliyuncs.com",
|
||||
description: "一般保持默认值即可。",
|
||||
},
|
||||
{
|
||||
key: "default_user_name_prefix",
|
||||
label: "默认用户名前缀",
|
||||
type: "text",
|
||||
defaultValue: "用户",
|
||||
placeholder: "例如 用户",
|
||||
description: "系统需要生成默认名称时使用的前缀。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "AI 问答",
|
||||
description: "控制 Agent 调用、上下文和用户端回答展示策略。",
|
||||
settings: [
|
||||
{
|
||||
key: "ai_timeout_seconds",
|
||||
label: "AI 请求超时(秒)",
|
||||
type: "number",
|
||||
defaultValue: 60,
|
||||
min: 5,
|
||||
max: 300,
|
||||
description: "真实模型请求超过该时间应判定为超时。",
|
||||
},
|
||||
{
|
||||
key: "mock_model_enabled",
|
||||
label: "使用 mock 大模型",
|
||||
type: "switch",
|
||||
defaultValue: true,
|
||||
description: "开发和演示环境可开启,真实模型验收前应关闭。",
|
||||
},
|
||||
{
|
||||
key: "chat_context_message_count",
|
||||
label: "上下文消息数",
|
||||
type: "number",
|
||||
defaultValue: 10,
|
||||
min: 0,
|
||||
max: 100,
|
||||
description: "当前会话带入模型的最近历史消息条数;更早内容会滚动摘要,设为 0 将关闭会话记忆。修改后下一次提问立即生效。",
|
||||
},
|
||||
{
|
||||
key: "show_reference_sources",
|
||||
label: "用户端展示引用来源",
|
||||
type: "switch",
|
||||
defaultValue: true,
|
||||
description: "后台始终记录引用;此项控制用户端是否展示。",
|
||||
},
|
||||
{
|
||||
key: "chat_max_active_requests",
|
||||
label: "问答最大并发数",
|
||||
type: "number",
|
||||
defaultValue: 2,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
description: "同一后端进程内同时进入飞书和模型生成链路的最大请求数。",
|
||||
},
|
||||
{
|
||||
key: "chat_max_queue_size",
|
||||
label: "问答最大排队数",
|
||||
type: "number",
|
||||
defaultValue: 20,
|
||||
min: 0,
|
||||
max: 10000,
|
||||
description: "超过最大并发后允许等待的请求数量,超过后直接提示稍后再试。",
|
||||
},
|
||||
{
|
||||
key: "chat_queue_timeout_seconds",
|
||||
label: "问答排队超时(秒)",
|
||||
type: "number",
|
||||
defaultValue: 60,
|
||||
min: 1,
|
||||
max: 3600,
|
||||
description: "请求在排队中超过该时间后自动结束并提示用户稍后再试。",
|
||||
},
|
||||
{
|
||||
key: "chat_active_lease_seconds",
|
||||
label: "问答执行租约(秒)",
|
||||
type: "number",
|
||||
defaultValue: 900,
|
||||
min: 60,
|
||||
max: 86400,
|
||||
description: "Redis 全局队列中执行名额的自动过期时间,防止 worker 异常退出后长期占用并发名额。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "外部服务",
|
||||
description: "控制飞书搜索接口、飞书直读超时和重试策略。",
|
||||
settings: [
|
||||
{
|
||||
key: "feishu_search_url",
|
||||
label: "飞书检索服务地址",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
placeholder: "例如 https://example.com/search",
|
||||
description: "如使用独立飞书检索适配服务,在这里填写调用地址。",
|
||||
},
|
||||
{
|
||||
key: "feishu_app_id",
|
||||
label: "飞书应用 AppID",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
placeholder: "填写飞书开放平台应用 AppID",
|
||||
description: "用于后端直读飞书知识库;系统设置优先于环境变量。",
|
||||
},
|
||||
{
|
||||
key: "feishu_app_secret",
|
||||
label: "飞书应用 AppSecret",
|
||||
type: "password",
|
||||
defaultValue: "",
|
||||
placeholder: "填写飞书开放平台应用 AppSecret",
|
||||
description: "用于后端获取飞书 tenant_access_token,保存后立即生效。",
|
||||
},
|
||||
{
|
||||
key: "feishu_timeout_seconds",
|
||||
label: "飞书请求超时(秒)",
|
||||
type: "number",
|
||||
defaultValue: 20,
|
||||
min: 1,
|
||||
max: 120,
|
||||
description: "飞书相关接口调用超过该时间应判定为超时。",
|
||||
},
|
||||
{
|
||||
key: "feishu_retry_count",
|
||||
label: "飞书检索重试次数",
|
||||
type: "number",
|
||||
defaultValue: 2,
|
||||
min: 0,
|
||||
max: 10,
|
||||
description: "飞书检索失败时的最大重试次数。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "系统开关",
|
||||
description: "控制系统级展示和运行策略,不包含密钥类配置。",
|
||||
settings: [
|
||||
{
|
||||
key: "show_operation_log_detail",
|
||||
label: "展示操作日志详情",
|
||||
type: "switch",
|
||||
defaultValue: true,
|
||||
description: "开启后后台审计页可展示更完整的操作上下文。",
|
||||
},
|
||||
{
|
||||
key: "chat_export_enabled",
|
||||
label: "允许导出聊天记录",
|
||||
type: "switch",
|
||||
defaultValue: true,
|
||||
description: "关闭后后台不应提供聊天记录导出能力。",
|
||||
},
|
||||
{
|
||||
key: "maintenance_mode",
|
||||
label: "维护模式",
|
||||
type: "switch",
|
||||
defaultValue: false,
|
||||
description: "预留给后续停机维护提示和访问控制使用。",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const systemSettingDefinitions = systemSettingSections.flatMap((section) => section.settings);
|
||||
101
ai_knowledge_base_v2/apps/admin-web/src/main.ts
Normal file
101
ai_knowledge_base_v2/apps/admin-web/src/main.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElDatePicker,
|
||||
ElDivider,
|
||||
ElDialog,
|
||||
ElDrawer,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElEmpty,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElOption,
|
||||
ElPagination,
|
||||
ElProgress,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
ElTabPane,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTabs,
|
||||
ElTag,
|
||||
ElTooltip,
|
||||
vLoading,
|
||||
} from "element-plus";
|
||||
import "element-plus/theme-chalk/base.css";
|
||||
import "element-plus/theme-chalk/el-alert.css";
|
||||
import "element-plus/theme-chalk/el-button.css";
|
||||
import "element-plus/theme-chalk/el-collapse.css";
|
||||
import "element-plus/theme-chalk/el-date-picker.css";
|
||||
import "element-plus/theme-chalk/el-divider.css";
|
||||
import "element-plus/theme-chalk/el-dialog.css";
|
||||
import "element-plus/theme-chalk/el-drawer.css";
|
||||
import "element-plus/theme-chalk/el-dropdown.css";
|
||||
import "element-plus/theme-chalk/el-empty.css";
|
||||
import "element-plus/theme-chalk/el-form.css";
|
||||
import "element-plus/theme-chalk/el-input.css";
|
||||
import "element-plus/theme-chalk/el-input-number.css";
|
||||
import "element-plus/theme-chalk/el-loading.css";
|
||||
import "element-plus/theme-chalk/el-message.css";
|
||||
import "element-plus/theme-chalk/el-message-box.css";
|
||||
import "element-plus/theme-chalk/el-option.css";
|
||||
import "element-plus/theme-chalk/el-overlay.css";
|
||||
import "element-plus/theme-chalk/el-pagination.css";
|
||||
import "element-plus/theme-chalk/el-progress.css";
|
||||
import "element-plus/theme-chalk/el-popper.css";
|
||||
import "element-plus/theme-chalk/el-scrollbar.css";
|
||||
import "element-plus/theme-chalk/el-select.css";
|
||||
import "element-plus/theme-chalk/el-switch.css";
|
||||
import "element-plus/theme-chalk/el-table.css";
|
||||
import "element-plus/theme-chalk/el-tabs.css";
|
||||
import "element-plus/theme-chalk/el-tag.css";
|
||||
import "element-plus/theme-chalk/el-tooltip.css";
|
||||
import { createApp } from "vue";
|
||||
import zhCn from "element-plus/es/locale/lang/zh-cn";
|
||||
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
const app = createApp(App);
|
||||
app.config.globalProperties.$ELEMENT = { locale: zhCn };
|
||||
|
||||
[
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElDatePicker,
|
||||
ElDivider,
|
||||
ElDialog,
|
||||
ElDrawer,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElEmpty,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElOption,
|
||||
ElPagination,
|
||||
ElProgress,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
ElTabPane,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTabs,
|
||||
ElTag,
|
||||
ElTooltip,
|
||||
].forEach((component) => {
|
||||
app.use(component);
|
||||
});
|
||||
|
||||
app.directive("loading", vLoading);
|
||||
app.mount("#app");
|
||||
223
ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
Normal file
223
ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
AdminProfile,
|
||||
AdminUser,
|
||||
AgentDebugResult,
|
||||
AgentGenerationConfig,
|
||||
AgentRuntimeConfig,
|
||||
AiLogRecord,
|
||||
ApiResponse,
|
||||
ChatDetail,
|
||||
ChatRecord,
|
||||
ChatRecordQuery,
|
||||
DashboardStats,
|
||||
KnowledgeItem,
|
||||
KnowledgeDetail,
|
||||
KnowledgeSyncJob,
|
||||
KnowledgeVersion,
|
||||
KnowledgeVersionContent,
|
||||
MigrationSummary,
|
||||
KnowledgeBatchUpdateResult,
|
||||
RetrievalLogItem,
|
||||
AttentionItem,
|
||||
ModelItem,
|
||||
SystemConfigItem,
|
||||
UserImportResult,
|
||||
PageResult,
|
||||
PromptDetail,
|
||||
PromptHistoryItem,
|
||||
} from "../types/api";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
const TOKEN_KEY = "ai-kb-admin-token";
|
||||
|
||||
export function getToken() {
|
||||
return window.localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function saveToken(token: string) {
|
||||
window.localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||
const body = await readApiResponse<T>(response);
|
||||
if (!response.ok || body.code !== 0) {
|
||||
throw new Error(body.detail || body.message || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async function readApiResponse<T>(response: Response): Promise<ApiResponse<T> & { detail?: string }> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
throw new Error(response.ok ? "接口返回为空" : `服务暂时不可用(${response.status})`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as ApiResponse<T> & { detail?: string };
|
||||
} catch {
|
||||
throw new Error(response.ok ? "接口返回格式不正确" : `服务暂时不可用(${response.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function download(path: string, filename: string) {
|
||||
const headers = new Headers();
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const response = await fetch(`${API_BASE}${path}`, { headers });
|
||||
if (!response.ok) throw new Error("导出失败");
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function upload<T>(path: string, formData: FormData): Promise<T> {
|
||||
const headers = new Headers();
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const response = await fetch(`${API_BASE}${path}`, { method: "POST", body: formData, headers });
|
||||
const body = await readApiResponse<T>(response);
|
||||
if (!response.ok || body.code !== 0) {
|
||||
throw new Error(body.message || body.detail || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
|
||||
function queryString(query: object) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
|
||||
});
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ token: string; admin: AdminProfile }>("/admin/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
logout: () => request<null>("/admin/logout", { method: "POST", body: "{}" }),
|
||||
profile: () => request<AdminProfile>("/admin/profile"),
|
||||
dashboard: (start?: string, end?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (start) params.set("start", start);
|
||||
if (end) params.set("end", end);
|
||||
const qs = params.toString();
|
||||
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
|
||||
},
|
||||
users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
|
||||
createUser: (payload: Record<string, unknown>) =>
|
||||
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
|
||||
importUsers: (students: Record<string, unknown>[]) =>
|
||||
request<UserImportResult>("/admin/user/import", { method: "POST", body: JSON.stringify({ students }) }),
|
||||
downloadStudentTemplate: () => download("/admin/user/import/template", "学员导入模板.xlsx"),
|
||||
importUsersExcel: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return upload<UserImportResult>("/admin/user/import/excel", formData);
|
||||
},
|
||||
updateUser: (id: number, payload: Record<string, unknown>) =>
|
||||
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
|
||||
knowledge: (query: Record<string, unknown> = {}) => request<PageResult<KnowledgeItem>>(`/admin/knowledge/list${queryString(query)}`),
|
||||
knowledgeOptions: () => request<KnowledgeItem[]>("/admin/knowledge/options"),
|
||||
resolveKnowledgeNode: (nodeId: string) => request<{ nodeId: string; spaceId: string; sourceTitle: string; name: string; remark: string }>("/admin/knowledge/resolve-node", { method: "POST", body: JSON.stringify({ nodeId }) }),
|
||||
createKnowledge: (payload: Record<string, unknown>) =>
|
||||
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
|
||||
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||
updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string; reviewMode: string }) =>
|
||||
request<KnowledgeItem>(`/admin/knowledge/${id}/metadata`, { method: "PATCH", body: JSON.stringify(payload) }),
|
||||
batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string; reviewMode?: string }) =>
|
||||
request<KnowledgeBatchUpdateResult>("/admin/knowledge/batch/metadata", { method: "PATCH", body: JSON.stringify(payload) }),
|
||||
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
|
||||
importKnowledgeFromSpace: (body: { node_token: string }) =>
|
||||
request<{ imported: number; skipped: number; id: number; name: string }>(
|
||||
"/admin/knowledge/import",
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
),
|
||||
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
|
||||
knowledgeVersions: (id: number) => request<KnowledgeVersion[]>(`/admin/knowledge/${id}/versions`),
|
||||
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
|
||||
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
|
||||
syncKnowledge: (id: number, mode: "review" | "replace") =>
|
||||
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST", body: JSON.stringify({ mode }) }),
|
||||
confirmKnowledgeManifest: (id: number, versionId: number) =>
|
||||
request<null>(`/admin/knowledge/${id}/manifest/${versionId}/confirm`, { method: "POST", body: "{}" }),
|
||||
reviewKnowledgeVersion: (id: number, versionId: number, action: "approve" | "reject", reason?: string) =>
|
||||
request<null>(`/admin/knowledge/${id}/version/${versionId}/review`, { method: "POST", body: JSON.stringify({ action, reason }) }),
|
||||
publishKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
|
||||
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/publish`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
|
||||
rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
|
||||
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
|
||||
setKnowledgeOpen: (id: number, open: boolean) =>
|
||||
request<KnowledgeItem>(`/admin/knowledge/${id}/open-status`, { method: "PUT", body: JSON.stringify({ open }) }),
|
||||
setKnowledgeLifecycle: (id: number, status: "active" | "archived") =>
|
||||
request<KnowledgeItem>(`/admin/knowledge/${id}/lifecycle`, { method: "PUT", body: JSON.stringify({ status }) }),
|
||||
migrationSummary: () => request<MigrationSummary>("/admin/knowledge-migration/summary"),
|
||||
runKnowledgeMigration: (knowledgeId?: number) =>
|
||||
request<{ total: number; success: number; results: Record<string, unknown>[] }>(`/admin/knowledge-migration/run${knowledgeId ? `?knowledgeId=${knowledgeId}` : ""}`, { method: "POST", body: "{}" }),
|
||||
prompt: () => request<PromptDetail>("/admin/prompt"),
|
||||
savePrompt: (promptContent: string) =>
|
||||
request<PromptDetail>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }),
|
||||
resetPrompt: () => request<PromptDetail>("/admin/prompt/reset", { method: "POST", body: "{}" }),
|
||||
promptHistory: (query: { page?: number; pageSize?: number } = {}) =>
|
||||
request<PageResult<PromptHistoryItem>>(`/admin/prompt/history${queryString(query)}`),
|
||||
promptHistoryDetail: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}`),
|
||||
restorePrompt: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}/restore`, { method: "POST", body: "{}" }),
|
||||
debugAgent: (payload: Record<string, unknown>) =>
|
||||
request<AgentDebugResult>("/admin/agent/debug", { method: "POST", body: JSON.stringify(payload) }),
|
||||
agentRuntimeConfig: () => request<AgentRuntimeConfig>("/admin/agent/runtime-config"),
|
||||
saveAgentRuntimeConfig: (payload: AgentGenerationConfig) =>
|
||||
request<AgentRuntimeConfig>("/admin/agent/runtime-config", { method: "PUT", body: JSON.stringify(payload) }),
|
||||
models: () => request<ModelItem[]>("/admin/model/list"),
|
||||
createModel: (payload: Record<string, unknown>) =>
|
||||
request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateModel: (id: number, payload: Record<string, unknown>) =>
|
||||
request<ModelItem>(`/admin/model/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||
enableModel: (modelId: number) =>
|
||||
request<null>("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId }) }),
|
||||
deleteModel: (modelId: number) =>
|
||||
request<null>(`/admin/model/${modelId}`, { method: "DELETE" }),
|
||||
testModel: (modelId: number) =>
|
||||
request<{ ok: boolean; message: string; answer: string }>(`/admin/model/${modelId}/test`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
configs: () => request<SystemConfigItem[]>("/admin/config"),
|
||||
saveConfig: (payload: Record<string, unknown>) =>
|
||||
request<SystemConfigItem>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
|
||||
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
|
||||
chatDetail: (sessionId: number) => request<ChatDetail>(`/admin/chat/${sessionId}`),
|
||||
exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),
|
||||
aiLogs: (query: { sessionId?: number; userId?: number; status?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PageResult<AiLogRecord>>(`/admin/ai-log/list${queryString(query)}`),
|
||||
aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`),
|
||||
operationLogs: (query: { module?: string; page?: number; pageSize?: number } = {}) => request<PageResult<Record<string, unknown>>>(`/admin/log/list${queryString(query)}`),
|
||||
retrievalLogs: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<RetrievalLogItem>>(`/admin/retrieval-log/list${queryString(query)}`),
|
||||
estimateRetrievalCleanup: (before: string) => request<{ before: string; estimatedCount: number }>("/admin/retrieval-log/cleanup/estimate", { method: "POST", body: JSON.stringify({ before }) }),
|
||||
cleanupRetrievalLogs: (before: string) => request<{ before: string; deleted: number }>("/admin/retrieval-log/cleanup", { method: "POST", body: JSON.stringify({ before }) }),
|
||||
retrievalRetention: () => request<{ enabled: boolean; retentionDays: number | null; lastRunAt: string | null; lastResult: string | null }>("/admin/retrieval-log/retention"),
|
||||
saveRetrievalRetention: (retentionDays: number | null) => request<{ enabled: boolean; retentionDays: number | null }>("/admin/retrieval-log/retention", { method: "PUT", body: JSON.stringify({ retentionDays }) }),
|
||||
storageStats: (refresh = false) => request<Record<string, any>>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
|
||||
retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`),
|
||||
attentionList: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<AttentionItem>>(`/admin/attention/list${queryString(query)}`),
|
||||
updateAttention: (id: number, status: string, note?: string) =>
|
||||
request<AttentionItem>(`/admin/attention/${id}`, { method: "PUT", body: JSON.stringify({ status, note }) }),
|
||||
deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }),
|
||||
clearFeishuCache: () =>
|
||||
request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }),
|
||||
};
|
||||
1855
ai_knowledge_base_v2/apps/admin-web/src/styles.css
Normal file
1855
ai_knowledge_base_v2/apps/admin-web/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
324
ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
Normal file
324
ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface AdminProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
userCount: number;
|
||||
sessionCount: number;
|
||||
messageCount: number;
|
||||
aiRequestCount: number;
|
||||
knowledgeCount: number;
|
||||
inputToken: number;
|
||||
outputToken: number;
|
||||
totalToken: number;
|
||||
}
|
||||
|
||||
export interface PromptDetail {
|
||||
id: number | null;
|
||||
promptContent: string;
|
||||
changeType: "default" | "save" | "reset" | "restore";
|
||||
sourcePromptId?: number | null;
|
||||
updatedByName?: string | null;
|
||||
updatedAt?: string | null;
|
||||
charCount: number;
|
||||
}
|
||||
|
||||
export interface PromptHistoryItem {
|
||||
id: number;
|
||||
preview: string;
|
||||
charCount: number;
|
||||
changeType: "save" | "reset" | "restore";
|
||||
sourcePromptId?: number | null;
|
||||
updatedByName: string;
|
||||
updatedAt: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
phone: string;
|
||||
name: string;
|
||||
nickname?: string | null;
|
||||
status: number;
|
||||
dailyChatLimit: number;
|
||||
dailyChatUsed: number;
|
||||
expiredAt?: string | null;
|
||||
lastLoginAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UserImportFailure {
|
||||
row: number;
|
||||
phone: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface UserImportResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
failed: number;
|
||||
failures: UserImportFailure[];
|
||||
}
|
||||
|
||||
export interface SystemConfigItem {
|
||||
id: number;
|
||||
configKey: string;
|
||||
configValue: string;
|
||||
description?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeItem {
|
||||
id: number;
|
||||
name: string;
|
||||
feishuSpaceId: string;
|
||||
feishuNodeId: string;
|
||||
status: number;
|
||||
remark?: string | null;
|
||||
sourceTitle?: string | null;
|
||||
knowledgeType: "course" | "qa" | "general";
|
||||
reviewMode: "manual" | "automatic";
|
||||
lifecycleStatus: "active" | "archived";
|
||||
sourceStatus: "normal" | "error";
|
||||
sourceError?: string | null;
|
||||
manifestConfirmed: boolean;
|
||||
currentVersionId?: number | null;
|
||||
pendingVersionId?: number | null;
|
||||
lastSyncedAt?: string | null;
|
||||
lastPublishedAt?: string | null;
|
||||
migrationStatus: string;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ModelItem {
|
||||
id: number;
|
||||
provider: string;
|
||||
displayName?: string | null;
|
||||
apiType: string;
|
||||
modelName: string;
|
||||
baseUrl?: string | null;
|
||||
apiUrl: string;
|
||||
apiKeyMasked: string;
|
||||
authType: string;
|
||||
apiVersion?: string | null;
|
||||
temperature?: number | null;
|
||||
topP?: number | null;
|
||||
topK?: number | null;
|
||||
presencePenalty?: number | null;
|
||||
frequencyPenalty?: number | null;
|
||||
maxToken?: number | null;
|
||||
contextWindow?: number | null;
|
||||
streamEnabled: number;
|
||||
responseFormat?: string | null;
|
||||
extraParams?: string | null;
|
||||
remark?: string | null;
|
||||
timeoutSecond: number;
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
export interface AgentGenerationConfig {
|
||||
temperature: number | null;
|
||||
topP: number | null;
|
||||
topK: number | null;
|
||||
presencePenalty: number | null;
|
||||
frequencyPenalty: number | null;
|
||||
maxToken: number;
|
||||
}
|
||||
|
||||
export interface AgentRuntimeConfig extends AgentGenerationConfig {
|
||||
modelId: number | null;
|
||||
modelName: string | null;
|
||||
}
|
||||
|
||||
export interface AgentDebugResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
answer: string;
|
||||
prompt: string;
|
||||
modelName?: string;
|
||||
retrieveCount?: number;
|
||||
knowledgeIds?: string;
|
||||
retrievalTrace?: Record<string, unknown>[];
|
||||
retrievalLogId?: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeVersion {
|
||||
id: number;
|
||||
knowledgeId: number;
|
||||
snapshotId: number;
|
||||
versionNo: number;
|
||||
status: string;
|
||||
qualityStatus: string;
|
||||
changeSummary?: Record<string, unknown> | null;
|
||||
qualityReport?: Record<string, unknown> | null;
|
||||
processingRuleVersion: string;
|
||||
generationModel?: string | null;
|
||||
createdAt: string;
|
||||
publishedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeManifest {
|
||||
id: number;
|
||||
versionId: number;
|
||||
purpose: string;
|
||||
applicableQuestions: string;
|
||||
inapplicableQuestions: string;
|
||||
coreTopics: string;
|
||||
entities?: string | null;
|
||||
boundaries?: string | null;
|
||||
changeLevel: string;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
export interface KnowledgeDetail {
|
||||
knowledge: KnowledgeItem;
|
||||
currentVersion?: KnowledgeVersion | null;
|
||||
pendingVersion?: KnowledgeVersion | null;
|
||||
manifest?: KnowledgeManifest | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeVersionContent {
|
||||
version: KnowledgeVersion;
|
||||
manifest?: KnowledgeManifest | null;
|
||||
sections: Array<{ id: number; sectionKey: string; title: string; content: string; sourceStart: number; sourceEnd: number; contentHash: string }>;
|
||||
cards: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface KnowledgeSyncJob {
|
||||
id: number;
|
||||
knowledgeId: number;
|
||||
mode: string;
|
||||
status: string;
|
||||
stage: string;
|
||||
progress: number;
|
||||
sourceError?: string | null;
|
||||
resultSummary?: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
finishedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface MigrationSummary {
|
||||
total: number;
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface KnowledgeBatchUpdateResult {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
results: Array<{ knowledgeId: number; ok: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
export interface RetrievalLogItem {
|
||||
id: number;
|
||||
sessionId?: number | null;
|
||||
question: string;
|
||||
knowledgeCalled: boolean;
|
||||
selectedKnowledgeIds?: string | null;
|
||||
queryTerms?: string[] | null;
|
||||
status: string;
|
||||
toolCostMs?: number | null;
|
||||
totalCostMs?: number | null;
|
||||
attentionCreated: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AttentionItem {
|
||||
id: number;
|
||||
sessionId: number;
|
||||
messageId: number;
|
||||
userId: number;
|
||||
triggerMessage: string;
|
||||
problemSummary: string;
|
||||
triggerReason: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
handlerNote?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ChatRecord {
|
||||
id: number;
|
||||
userId: number;
|
||||
userPhone: string;
|
||||
userName: string;
|
||||
title: string;
|
||||
messageCount: number;
|
||||
lastMessageAt?: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageRecord {
|
||||
id: number;
|
||||
sessionId: number;
|
||||
userId: number;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
messageStatus: string;
|
||||
tokenInput?: number | null;
|
||||
tokenOutput?: number | null;
|
||||
responseTimeMs?: number | null;
|
||||
modelId?: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiLogRecord {
|
||||
id: number;
|
||||
sessionId?: number | null;
|
||||
messageId?: number | null;
|
||||
userId?: number | null;
|
||||
modelName?: string | null;
|
||||
knowledgeIds?: string | null;
|
||||
retrieveCount: number;
|
||||
retrievedChunks: RetrievedKnowledgeChunk[];
|
||||
inputToken?: number | null;
|
||||
outputToken?: number | null;
|
||||
totalToken?: number | null;
|
||||
costMs?: number | null;
|
||||
status: string;
|
||||
errorMessage?: string | null;
|
||||
prompt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RetrievedKnowledgeChunk {
|
||||
index: number;
|
||||
knowledgeId?: number | null;
|
||||
knowledgeName: string;
|
||||
title: string;
|
||||
content: string;
|
||||
sourceUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface ChatDetail {
|
||||
session: ChatRecord;
|
||||
messages: ChatMessageRecord[];
|
||||
aiLogs: AiLogRecord[];
|
||||
}
|
||||
|
||||
export interface ChatRecordQuery {
|
||||
keyword?: string;
|
||||
userId?: number | null;
|
||||
status?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
1
ai_knowledge_base_v2/apps/admin-web/src/vite-env.d.ts
vendored
Normal file
1
ai_knowledge_base_v2/apps/admin-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
17
ai_knowledge_base_v2/apps/admin-web/tsconfig.json
Normal file
17
ai_knowledge_base_v2/apps/admin-web/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
40
ai_knowledge_base_v2/apps/admin-web/vite.config.ts
Normal file
40
ai_knowledge_base_v2/apps/admin-web/vite.config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/login/",
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes("node_modules")) return undefined;
|
||||
if (id.includes("element-plus/es/components/table")) return "el-table";
|
||||
if (
|
||||
id.includes("/vue/") ||
|
||||
id.includes("@vue/") ||
|
||||
id.includes("@vueuse") ||
|
||||
id.includes("async-validator") ||
|
||||
id.includes("@popperjs") ||
|
||||
id.includes("@floating-ui") ||
|
||||
id.includes("dayjs")
|
||||
) {
|
||||
return "vendor";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
allowedHosts: ["qa.huiyushuyuan.cn"],
|
||||
host: "0.0.0.0",
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://backend:8100",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
7
ai_knowledge_base_v2/apps/backend/.dockerignore
Normal file
7
ai_knowledge_base_v2/apps/backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
47
ai_knowledge_base_v2/apps/backend/.env.example
Normal file
47
ai_knowledge_base_v2/apps/backend/.env.example
Normal file
@@ -0,0 +1,47 @@
|
||||
APP_NAME=AI Knowledge Base V2 Backend
|
||||
APP_ENV=local
|
||||
DEBUG=true
|
||||
API_PREFIX=/api
|
||||
|
||||
DATABASE_URL=mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4
|
||||
REDIS_URL=
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=20
|
||||
DB_POOL_TIMEOUT=30
|
||||
DB_POOL_RECYCLE=1800
|
||||
AUTO_CREATE_TABLES=false
|
||||
|
||||
JWT_SECRET_KEY=change-me-in-production
|
||||
CONFIG_ENCRYPTION_KEY=
|
||||
JWT_ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=43200
|
||||
|
||||
MOCK_SMS_ENABLED=true
|
||||
MOCK_SMS_CODE=123456
|
||||
SMS_CODE_EXPIRE_MINUTES=5
|
||||
ALIYUN_SMS_ACCESS_KEY_ID=
|
||||
ALIYUN_SMS_ACCESS_KEY_SECRET=
|
||||
ALIYUN_SMS_SIGN_NAME=
|
||||
ALIYUN_SMS_TEMPLATE_CODE=
|
||||
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
|
||||
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
|
||||
MOCK_RAG_ENABLED=false
|
||||
MOCK_MODEL_ENABLED=true
|
||||
FEISHU_MOCK_ENABLED=false
|
||||
FEISHU_SEARCH_URL=
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
FEISHU_TIMEOUT_SECONDS=20
|
||||
FEISHU_RETRY_COUNT=2
|
||||
|
||||
DEFAULT_DAILY_CHAT_LIMIT=100
|
||||
DEFAULT_USER_NAME_PREFIX=用户
|
||||
CHAT_MAX_ACTIVE_REQUESTS=2
|
||||
CHAT_MAX_QUEUE_SIZE=20
|
||||
CHAT_QUEUE_TIMEOUT_SECONDS=60
|
||||
CHAT_ACTIVE_LEASE_SECONDS=900
|
||||
|
||||
# 本地开发可以使用开发密码;生产环境必须改成高强度密码,且 APP_ENV=production 时不能使用 admin123456
|
||||
BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
BOOTSTRAP_ADMIN_PASSWORD=admin123456
|
||||
BOOTSTRAP_ADMIN_NAME=系统管理员
|
||||
7
ai_knowledge_base_v2/apps/backend/.gitignore
vendored
Normal file
7
ai_knowledge_base_v2/apps/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
19
ai_knowledge_base_v2/apps/backend/Dockerfile
Normal file
19
ai_knowledge_base_v2/apps/backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8100
|
||||
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8100 --workers ${BACKEND_WORKERS:-1} --no-access-log"]
|
||||
52
ai_knowledge_base_v2/apps/backend/README.md
Normal file
52
ai_knowledge_base_v2/apps/backend/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 大本营答疑助手后端
|
||||
|
||||
这是大本营答疑助手的后端服务目录,当前处于阶段二第一批建设:后端基础工程、核心数据模型、mock 登录能力。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- FastAPI
|
||||
- SQLAlchemy
|
||||
- Alembic
|
||||
- MySQL 8.x
|
||||
- Pydantic Settings
|
||||
- PyJWT
|
||||
|
||||
## 当前已包含
|
||||
|
||||
- 应用入口和健康检查
|
||||
- 统一 JSON 响应结构
|
||||
- 配置读取和 `.env` 示例
|
||||
- SQLAlchemy 数据库连接
|
||||
- Alembic 迁移骨架
|
||||
- 核心 `sys_*` 数据模型
|
||||
- mock 短信验证码
|
||||
- 用户手机号验证码登录
|
||||
- 用户 Token 鉴权
|
||||
- 当前用户信息接口
|
||||
|
||||
## 本地启动
|
||||
|
||||
方式一:使用 V2 统一编排。
|
||||
|
||||
```bash
|
||||
cd ai_knowledge_base_v2
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
方式二:本地 Python 直接启动。
|
||||
|
||||
```bash
|
||||
cd ai_knowledge_base_v2/apps/backend
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
uvicorn app.main:app --reload --host 127.0.0.1 --port 8100
|
||||
```
|
||||
|
||||
默认短信验证码为 `.env` 中的 `MOCK_SMS_CODE`,默认值是 `123456`。
|
||||
|
||||
## 下一步
|
||||
|
||||
- 补管理员登录接口。
|
||||
- 接入 Redis 替换内存短信验证码和 Token 黑名单。
|
||||
37
ai_knowledge_base_v2/apps/backend/alembic.ini
Normal file
37
ai_knowledge_base_v2/apps/backend/alembic.ini
Normal file
@@ -0,0 +1,37 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
51
ai_knowledge_base_v2/apps/backend/alembic/env.py
Normal file
51
ai_knowledge_base_v2/apps/backend/alembic/env.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
settings = get_settings()
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=settings.database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0001_initial_schema"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sys_user",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("phone", sa.String(length=20), nullable=False),
|
||||
sa.Column("name", sa.String(length=50), nullable=False),
|
||||
sa.Column("nickname", sa.String(length=50), nullable=True),
|
||||
sa.Column("status", sa.Integer(), nullable=False),
|
||||
sa.Column("daily_chat_limit", sa.Integer(), nullable=False),
|
||||
sa.Column("daily_chat_used", sa.Integer(), nullable=False),
|
||||
sa.Column("effective_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("expired_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("is_deleted", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sys_user_phone", "sys_user", ["phone"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"sys_role",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("code", sa.String(length=50), nullable=False),
|
||||
sa.Column("name", sa.String(length=50), nullable=False),
|
||||
sa.Column("remark", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sys_role_code", "sys_role", ["code"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"sys_admin",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("username", sa.String(length=50), nullable=False),
|
||||
sa.Column("password", sa.String(length=255), nullable=False),
|
||||
sa.Column("name", sa.String(length=50), nullable=False),
|
||||
sa.Column("role_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("status", sa.Integer(), nullable=False),
|
||||
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["role_id"], ["sys_role.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sys_admin_username", "sys_admin", ["username"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"sys_knowledge",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("feishu_space_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("feishu_node_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("status", sa.Integer(), nullable=False),
|
||||
sa.Column("remark", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"sys_user_kb",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("effective_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("expired_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sys_user_kb_user_id", "sys_user_kb", ["user_id"])
|
||||
op.create_index("ix_sys_user_kb_knowledge_id", "sys_user_kb", ["knowledge_id"])
|
||||
|
||||
op.create_table(
|
||||
"sys_model",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("provider", sa.String(length=50), nullable=False),
|
||||
sa.Column("model_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("api_url", sa.String(length=255), nullable=False),
|
||||
sa.Column("api_key", sa.Text(), nullable=False),
|
||||
sa.Column("temperature", sa.Numeric(4, 2), nullable=True),
|
||||
sa.Column("max_token", sa.Integer(), nullable=True),
|
||||
sa.Column("timeout_second", sa.Integer(), nullable=False),
|
||||
sa.Column("enabled", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"sys_prompt",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("prompt_content", sa.Text(), nullable=False),
|
||||
sa.Column("updated_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"sys_system_config",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("config_key", sa.String(length=100), nullable=False),
|
||||
sa.Column("config_value", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.String(length=255), nullable=True),
|
||||
sa.Column("updated_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sys_system_config_config_key", "sys_system_config", ["config_key"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"sys_chat_session",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("title", sa.String(length=100), nullable=False),
|
||||
sa.Column("message_count", sa.Integer(), nullable=False),
|
||||
sa.Column("last_message_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("is_deleted", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sys_chat_session_user_id", "sys_chat_session", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"sys_chat_message",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("session_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("message_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("token_input", sa.Integer(), nullable=True),
|
||||
sa.Column("token_output", sa.Integer(), nullable=True),
|
||||
sa.Column("response_time_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("model_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["model_id"], ["sys_model.id"]),
|
||||
sa.ForeignKeyConstraint(["session_id"], ["sys_chat_session.id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sys_chat_message_session_id", "sys_chat_message", ["session_id"])
|
||||
op.create_index("ix_sys_chat_message_user_id", "sys_chat_message", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"sys_ai_request_log",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("session_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("message_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("model_name", sa.String(length=100), nullable=True),
|
||||
sa.Column("prompt", sa.Text(), nullable=True),
|
||||
sa.Column("knowledge_ids", sa.String(length=500), nullable=True),
|
||||
sa.Column("retrieve_count", sa.Integer(), nullable=False),
|
||||
sa.Column("input_token", sa.Integer(), nullable=True),
|
||||
sa.Column("output_token", sa.Integer(), nullable=True),
|
||||
sa.Column("total_token", sa.Integer(), nullable=True),
|
||||
sa.Column("cost_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sys_ai_request_log_session_id", "sys_ai_request_log", ["session_id"])
|
||||
op.create_index("ix_sys_ai_request_log_message_id", "sys_ai_request_log", ["message_id"])
|
||||
op.create_index("ix_sys_ai_request_log_user_id", "sys_ai_request_log", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"sys_operation_log",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("admin_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("module", sa.String(length=50), nullable=False),
|
||||
sa.Column("action", sa.String(length=50), nullable=False),
|
||||
sa.Column("target_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("before_json", sa.Text(), nullable=True),
|
||||
sa.Column("after_json", sa.Text(), nullable=True),
|
||||
sa.Column("ip", sa.String(length=50), nullable=True),
|
||||
sa.Column("result", sa.String(length=20), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sys_operation_log_admin_id", "sys_operation_log", ["admin_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_sys_operation_log_admin_id", table_name="sys_operation_log")
|
||||
op.drop_table("sys_operation_log")
|
||||
op.drop_index("ix_sys_ai_request_log_user_id", table_name="sys_ai_request_log")
|
||||
op.drop_index("ix_sys_ai_request_log_message_id", table_name="sys_ai_request_log")
|
||||
op.drop_index("ix_sys_ai_request_log_session_id", table_name="sys_ai_request_log")
|
||||
op.drop_table("sys_ai_request_log")
|
||||
op.drop_index("ix_sys_chat_message_user_id", table_name="sys_chat_message")
|
||||
op.drop_index("ix_sys_chat_message_session_id", table_name="sys_chat_message")
|
||||
op.drop_table("sys_chat_message")
|
||||
op.drop_index("ix_sys_chat_session_user_id", table_name="sys_chat_session")
|
||||
op.drop_table("sys_chat_session")
|
||||
op.drop_index("ix_sys_system_config_config_key", table_name="sys_system_config")
|
||||
op.drop_table("sys_system_config")
|
||||
op.drop_table("sys_prompt")
|
||||
op.drop_table("sys_model")
|
||||
op.drop_index("ix_sys_user_kb_knowledge_id", table_name="sys_user_kb")
|
||||
op.drop_index("ix_sys_user_kb_user_id", table_name="sys_user_kb")
|
||||
op.drop_table("sys_user_kb")
|
||||
op.drop_table("sys_knowledge")
|
||||
op.drop_index("ix_sys_admin_username", table_name="sys_admin")
|
||||
op.drop_table("sys_admin")
|
||||
op.drop_index("ix_sys_role_code", table_name="sys_role")
|
||||
op.drop_table("sys_role")
|
||||
op.drop_index("ix_sys_user_phone", table_name="sys_user")
|
||||
op.drop_table("sys_user")
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0002_expand_model_config"
|
||||
down_revision = "0001_initial_schema"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("sys_model", sa.Column("display_name", sa.String(length=100), nullable=True))
|
||||
op.add_column(
|
||||
"sys_model",
|
||||
sa.Column("api_type", sa.String(length=50), nullable=False, server_default="openai_compatible"),
|
||||
)
|
||||
op.add_column("sys_model", sa.Column("base_url", sa.String(length=255), nullable=True))
|
||||
op.add_column(
|
||||
"sys_model",
|
||||
sa.Column("auth_type", sa.String(length=30), nullable=False, server_default="bearer"),
|
||||
)
|
||||
op.add_column("sys_model", sa.Column("api_version", sa.String(length=50), nullable=True))
|
||||
op.add_column("sys_model", sa.Column("top_p", sa.Numeric(4, 2), nullable=True))
|
||||
op.add_column("sys_model", sa.Column("top_k", sa.Integer(), nullable=True))
|
||||
op.add_column("sys_model", sa.Column("presence_penalty", sa.Numeric(4, 2), nullable=True))
|
||||
op.add_column("sys_model", sa.Column("frequency_penalty", sa.Numeric(4, 2), nullable=True))
|
||||
op.add_column("sys_model", sa.Column("context_window", sa.Integer(), nullable=True))
|
||||
op.add_column(
|
||||
"sys_model",
|
||||
sa.Column("stream_enabled", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
op.add_column("sys_model", sa.Column("response_format", sa.String(length=50), nullable=True))
|
||||
op.add_column("sys_model", sa.Column("extra_params", sa.Text(), nullable=True))
|
||||
op.add_column("sys_model", sa.Column("remark", sa.String(length=255), nullable=True))
|
||||
|
||||
op.alter_column("sys_model", "api_type", server_default=None)
|
||||
op.alter_column("sys_model", "auth_type", server_default=None)
|
||||
op.alter_column("sys_model", "stream_enabled", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sys_model", "remark")
|
||||
op.drop_column("sys_model", "extra_params")
|
||||
op.drop_column("sys_model", "response_format")
|
||||
op.drop_column("sys_model", "stream_enabled")
|
||||
op.drop_column("sys_model", "context_window")
|
||||
op.drop_column("sys_model", "frequency_penalty")
|
||||
op.drop_column("sys_model", "presence_penalty")
|
||||
op.drop_column("sys_model", "top_k")
|
||||
op.drop_column("sys_model", "top_p")
|
||||
op.drop_column("sys_model", "api_version")
|
||||
op.drop_column("sys_model", "auth_type")
|
||||
op.drop_column("sys_model", "base_url")
|
||||
op.drop_column("sys_model", "api_type")
|
||||
op.drop_column("sys_model", "display_name")
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0003_ai_log_retrieved_chunks"
|
||||
down_revision = "0002_expand_model_config"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("sys_ai_request_log", sa.Column("retrieved_chunks", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sys_ai_request_log", "retrieved_chunks")
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0004_daily_chat_reset_date"
|
||||
down_revision = "0003_ai_log_retrieved_chunks"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("sys_user", sa.Column("daily_chat_reset_date", sa.Date(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sys_user", "daily_chat_reset_date")
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0005_chat_session_summary"
|
||||
down_revision = "0004_daily_chat_reset_date"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("sys_chat_session", sa.Column("summary", sa.Text(), nullable=True))
|
||||
op.add_column("sys_chat_session", sa.Column("summary_up_to_message_id", sa.BigInteger(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sys_chat_session", "summary_up_to_message_id")
|
||||
op.drop_column("sys_chat_session", "summary")
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
revision = "0006_expand_ai_request_log_text"
|
||||
down_revision = "0005_chat_session_summary"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"sys_ai_request_log",
|
||||
"prompt",
|
||||
existing_type=sa.Text(),
|
||||
type_=mysql.MEDIUMTEXT(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"sys_ai_request_log",
|
||||
"retrieved_chunks",
|
||||
existing_type=sa.Text(),
|
||||
type_=mysql.MEDIUMTEXT(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"sys_ai_request_log",
|
||||
"retrieved_chunks",
|
||||
existing_type=mysql.MEDIUMTEXT(),
|
||||
type_=sa.Text(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"sys_ai_request_log",
|
||||
"prompt",
|
||||
existing_type=mysql.MEDIUMTEXT(),
|
||||
type_=sa.Text(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
revision = "0007_knowledge_agent_rebuild"
|
||||
down_revision = "0006_expand_ai_request_log_text"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for column in [
|
||||
sa.Column("source_title", sa.String(255), nullable=True),
|
||||
sa.Column("knowledge_type", sa.String(30), nullable=False, server_default="general"),
|
||||
sa.Column("review_mode", sa.String(30), nullable=False, server_default="manual"),
|
||||
sa.Column("lifecycle_status", sa.String(30), nullable=False, server_default="active"),
|
||||
sa.Column("source_status", sa.String(30), nullable=False, server_default="normal"),
|
||||
sa.Column("source_error", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("manifest_confirmed", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("current_version_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("pending_version_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("last_synced_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_published_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("migration_status", sa.String(30), nullable=False, server_default="pending"),
|
||||
]:
|
||||
op.add_column("sys_knowledge", column)
|
||||
|
||||
op.create_table(
|
||||
"sys_knowledge_source_snapshot",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("sync_job_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("source_title", sa.String(255), nullable=False),
|
||||
sa.Column("source_content", mysql.LONGTEXT(), nullable=False),
|
||||
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||
sa.Column("source_identifier", sa.String(255), nullable=False),
|
||||
sa.Column("fetched_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_knowledge_source_snapshot", "knowledge_id", "sync_job_id", "content_hash")
|
||||
|
||||
op.create_table(
|
||||
"sys_knowledge_version",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("snapshot_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("version_no", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(30), nullable=False),
|
||||
sa.Column("quality_status", sa.String(30), nullable=False, server_default="pending"),
|
||||
sa.Column("change_summary", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("quality_report", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("processing_rule_version", sa.String(50), nullable=False),
|
||||
sa.Column("generation_model", sa.String(100), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("published_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||
sa.ForeignKeyConstraint(["snapshot_id"], ["sys_knowledge_source_snapshot.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("knowledge_id", "version_no", name="uq_kb_version_no"),
|
||||
)
|
||||
_indexes("sys_knowledge_version", "knowledge_id", "status")
|
||||
|
||||
op.create_table(
|
||||
"sys_knowledge_manifest",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("purpose", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("applicable_questions", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("inapplicable_questions", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("core_topics", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("entities", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("boundaries", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||
sa.Column("change_level", sa.String(20), nullable=False, server_default="major"),
|
||||
sa.Column("confirmed", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("confirmed_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("confirmed_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_knowledge_manifest", "knowledge_id", "version_id")
|
||||
|
||||
op.create_table(
|
||||
"sys_knowledge_section",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("section_key", sa.String(64), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("content", mysql.LONGTEXT(), nullable=False),
|
||||
sa.Column("source_start", sa.Integer(), nullable=False),
|
||||
sa.Column("source_end", sa.Integer(), nullable=False),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False),
|
||||
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_knowledge_section", "knowledge_id", "version_id")
|
||||
|
||||
op.create_table(
|
||||
"sys_knowledge_chunk",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("section_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("content", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("normalized_text", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("keywords", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("synonyms", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("source_start", sa.Integer(), nullable=False),
|
||||
sa.Column("source_end", sa.Integer(), nullable=False),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False),
|
||||
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
|
||||
sa.ForeignKeyConstraint(["section_id"], ["sys_knowledge_section.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_knowledge_chunk", "knowledge_id", "version_id", "section_id")
|
||||
|
||||
op.create_table(
|
||||
"sys_knowledge_card",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("section_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("summary", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("core_conclusion", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("applicable_questions", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("inapplicable_questions", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("keywords", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("synonyms", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("entities", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("risk_level", sa.String(20), nullable=False, server_default="normal"),
|
||||
sa.Column("source_range", sa.String(100), nullable=False),
|
||||
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||
sa.Column("generation_model", sa.String(100), nullable=True),
|
||||
sa.Column("generation_rule_version", sa.String(50), nullable=False),
|
||||
sa.Column("review_status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
|
||||
sa.ForeignKeyConstraint(["section_id"], ["sys_knowledge_section.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_knowledge_card", "knowledge_id", "version_id", "section_id")
|
||||
|
||||
_create_job_and_log_tables()
|
||||
|
||||
|
||||
def _create_job_and_log_tables() -> None:
|
||||
op.create_table(
|
||||
"sys_knowledge_sync_job",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("mode", sa.String(30), nullable=False),
|
||||
sa.Column("status", sa.String(30), nullable=False),
|
||||
sa.Column("stage", sa.String(50), nullable=False),
|
||||
sa.Column("progress", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("source_error", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("result_summary", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("requested_by", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_knowledge_sync_job", "knowledge_id", "status")
|
||||
|
||||
op.create_table(
|
||||
"sys_knowledge_review_record",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("card_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("action", sa.String(30), nullable=False),
|
||||
sa.Column("reason", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("reviewed_by", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
|
||||
sa.ForeignKeyConstraint(["card_id"], ["sys_knowledge_card.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_knowledge_review_record", "knowledge_id", "version_id")
|
||||
|
||||
op.create_table(
|
||||
"sys_knowledge_publish_log",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("from_version_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("to_version_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("action", sa.String(30), nullable=False),
|
||||
sa.Column("keep_open", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("detail_json", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("operated_by", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_knowledge_publish_log", "knowledge_id")
|
||||
|
||||
op.create_table(
|
||||
"sys_knowledge_retrieval_log",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("session_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("message_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("question", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("catalog_snapshot", mysql.LONGTEXT(), nullable=False),
|
||||
sa.Column("catalog_hash", sa.String(64), nullable=False),
|
||||
sa.Column("safety_rule_version", sa.String(50), nullable=False),
|
||||
sa.Column("knowledge_called", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("selected_knowledge_ids", sa.String(500), nullable=True),
|
||||
sa.Column("query_terms", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("tool_trace", mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column("final_section_ids", sa.String(500), nullable=True),
|
||||
sa.Column("discarded_conflicts", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("final_answer", mysql.LONGTEXT(), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("tool_cost_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("total_cost_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("error_message", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("attention_created", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_knowledge_retrieval_log", "session_id", "message_id", "user_id")
|
||||
|
||||
op.create_table(
|
||||
"sys_knowledge_retrieval_candidate",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("retrieval_log_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("chunk_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("section_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("lexical_score", sa.String(30), nullable=False),
|
||||
sa.Column("rerank_score", sa.String(30), nullable=True),
|
||||
sa.Column("selected", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("discard_reason", sa.String(255), nullable=True),
|
||||
sa.ForeignKeyConstraint(["retrieval_log_id"], ["sys_knowledge_retrieval_log.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_knowledge_retrieval_candidate", "retrieval_log_id", "knowledge_id", "version_id")
|
||||
|
||||
op.create_table(
|
||||
"sys_human_attention_record",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("session_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("message_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("trigger_message", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("problem_summary", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("trigger_reason", mysql.MEDIUMTEXT(), nullable=False),
|
||||
sa.Column("priority", sa.String(20), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("handler_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("handler_note", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_human_attention_record", "session_id", "message_id", "user_id", "priority", "status")
|
||||
|
||||
op.create_table(
|
||||
"sys_human_attention_history",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("attention_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("from_status", sa.String(20), nullable=True),
|
||||
sa.Column("to_status", sa.String(20), nullable=False),
|
||||
sa.Column("note", mysql.MEDIUMTEXT(), nullable=True),
|
||||
sa.Column("operated_by", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["attention_id"], ["sys_human_attention_record.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_indexes("sys_human_attention_history", "attention_id")
|
||||
|
||||
|
||||
def _indexes(table: str, *columns: str) -> None:
|
||||
for column in columns:
|
||||
op.create_index(f"ix_{table}_{column}", table, [column])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in [
|
||||
"sys_human_attention_history",
|
||||
"sys_human_attention_record",
|
||||
"sys_knowledge_retrieval_candidate",
|
||||
"sys_knowledge_retrieval_log",
|
||||
"sys_knowledge_publish_log",
|
||||
"sys_knowledge_review_record",
|
||||
"sys_knowledge_sync_job",
|
||||
"sys_knowledge_card",
|
||||
"sys_knowledge_chunk",
|
||||
"sys_knowledge_section",
|
||||
"sys_knowledge_manifest",
|
||||
"sys_knowledge_version",
|
||||
"sys_knowledge_source_snapshot",
|
||||
]:
|
||||
op.drop_table(table)
|
||||
for column in [
|
||||
"migration_status", "last_published_at", "last_synced_at", "pending_version_id",
|
||||
"current_version_id", "manifest_confirmed", "source_error", "source_status",
|
||||
"lifecycle_status", "review_mode", "knowledge_type", "source_title",
|
||||
]:
|
||||
op.drop_column("sys_knowledge", column)
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0008_unique_kb_node"
|
||||
down_revision = "0007_knowledge_agent_rebuild"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
constraints = {item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")}
|
||||
if "uq_knowledge_feishu_node" not in constraints:
|
||||
op.create_unique_constraint(
|
||||
"uq_knowledge_feishu_node",
|
||||
"sys_knowledge",
|
||||
["feishu_space_id", "feishu_node_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
constraints = {item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")}
|
||||
if "uq_knowledge_feishu_node" in constraints:
|
||||
op.drop_constraint("uq_knowledge_feishu_node", "sys_knowledge", type_="unique")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""log retention, storage snapshots and audit indexes
|
||||
|
||||
Revision ID: 0009_logs_storage
|
||||
Revises: 0008_unique_kb_node
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0009_logs_storage"
|
||||
down_revision = "0008_unique_kb_node"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "sys_log_retention_policy" not in tables:
|
||||
op.create_table(
|
||||
"sys_log_retention_policy",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("retention_days", sa.Integer(), nullable=True),
|
||||
sa.Column("enabled", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("last_run_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_result", sa.Text(), nullable=True),
|
||||
sa.Column("updated_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
if "sys_storage_snapshot" not in tables:
|
||||
op.create_table(
|
||||
"sys_storage_snapshot",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("scope", sa.String(50), nullable=False),
|
||||
sa.Column("total_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("detail_json", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_status_created", ["status", "created_at"])
|
||||
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_created_id", ["created_at", "id"])
|
||||
_create_index_if_missing("sys_ai_request_log", "ix_ai_log_status_created", ["status", "created_at"])
|
||||
_create_index_if_missing("sys_operation_log", "ix_operation_module_created", ["module", "created_at"])
|
||||
_create_index_if_missing("sys_chat_session", "ix_chat_session_deleted_updated", ["is_deleted", "updated_at"])
|
||||
|
||||
|
||||
def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None:
|
||||
if name not in {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)}:
|
||||
op.create_index(name, table, columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_chat_session_deleted_updated", table_name="sys_chat_session")
|
||||
op.drop_index("ix_operation_module_created", table_name="sys_operation_log")
|
||||
op.drop_index("ix_ai_log_status_created", table_name="sys_ai_request_log")
|
||||
op.drop_index("ix_retrieval_created_id", table_name="sys_knowledge_retrieval_log")
|
||||
op.drop_index("ix_retrieval_status_created", table_name="sys_knowledge_retrieval_log")
|
||||
op.drop_table("sys_storage_snapshot")
|
||||
op.drop_table("sys_log_retention_policy")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add prompt history metadata and ordering index
|
||||
|
||||
Revision ID: 0010_prompt_history_management
|
||||
Revises: 0009_logs_storage
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0010_prompt_history_management"
|
||||
down_revision = "0009_logs_storage"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sys_prompt",
|
||||
sa.Column("change_type", sa.String(length=20), nullable=False, server_default="save"),
|
||||
)
|
||||
op.add_column("sys_prompt", sa.Column("source_prompt_id", sa.BigInteger(), nullable=True))
|
||||
op.create_index("ix_sys_prompt_updated_at_id", "sys_prompt", ["updated_at", "id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_sys_prompt_updated_at_id", table_name="sys_prompt")
|
||||
op.drop_column("sys_prompt", "source_prompt_id")
|
||||
op.drop_column("sys_prompt", "change_type")
|
||||
1
ai_knowledge_base_v2/apps/backend/app/__init__.py
Normal file
1
ai_knowledge_base_v2/apps/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
ai_knowledge_base_v2/apps/backend/app/api/__init__.py
Normal file
1
ai_knowledge_base_v2/apps/backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
260
ai_knowledge_base_v2/apps/backend/app/api/admin_agent_records.py
Normal file
260
ai_knowledge_base_v2/apps/backend/app/api/admin_agent_records.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.models.knowledge import (
|
||||
HumanAttentionHistory,
|
||||
HumanAttentionRecord,
|
||||
KnowledgeRetrievalCandidate,
|
||||
KnowledgeRetrievalLog,
|
||||
)
|
||||
from app.schemas.knowledge import AttentionUpdateRequest
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.models.logs import LogRetentionPolicy
|
||||
from app.services.maintenance_service import MaintenanceService
|
||||
from app.api.pagination import page_result
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/retrieval-log/list")
|
||||
def retrieval_logs(
|
||||
sessionId: int | None = Query(default=None),
|
||||
knowledgeId: int | None = Query(default=None),
|
||||
knowledgeCalled: bool | None = Query(default=None),
|
||||
statusValue: str = Query(default="", alias="status"),
|
||||
attentionCreated: bool | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc())
|
||||
if sessionId is not None:
|
||||
query = query.where(KnowledgeRetrievalLog.session_id == sessionId)
|
||||
if knowledgeId is not None:
|
||||
query = query.where(KnowledgeRetrievalLog.selected_knowledge_ids.like(f"%{knowledgeId}%"))
|
||||
if knowledgeCalled is not None:
|
||||
query = query.where(KnowledgeRetrievalLog.knowledge_called == (1 if knowledgeCalled else 0))
|
||||
if statusValue:
|
||||
query = query.where(KnowledgeRetrievalLog.status == statusValue)
|
||||
if attentionCreated is not None:
|
||||
query = query.where(KnowledgeRetrievalLog.attention_created == (1 if attentionCreated else 0))
|
||||
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
||||
rows = db.scalars(query.offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||
return api_success(page_result([_retrieval_dict(item, detail=False) for item in rows], total=total, page=page, page_size=pageSize))
|
||||
|
||||
|
||||
@router.post("/retrieval-log/cleanup/estimate")
|
||||
def estimate_cleanup(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
before = _cleanup_before(payload)
|
||||
count = db.scalar(select(func.count()).select_from(KnowledgeRetrievalLog).where(KnowledgeRetrievalLog.created_at < before)) or 0
|
||||
return api_success({"before": before, "estimatedCount": count})
|
||||
|
||||
|
||||
@router.post("/retrieval-log/cleanup")
|
||||
def cleanup_logs(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
before = _cleanup_before(payload)
|
||||
total = MaintenanceService.delete_retrieval_logs(db, before)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action=f"cleanup:{total}", target_id=None)
|
||||
db.commit()
|
||||
return api_success({"deleted": total, "before": before})
|
||||
|
||||
|
||||
@router.get("/retrieval-log/retention")
|
||||
def get_retention(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
|
||||
return api_success({"enabled": bool(policy and policy.enabled), "retentionDays": policy.retention_days if policy else None, "lastRunAt": policy.last_run_at if policy else None, "lastResult": policy.last_result if policy else None})
|
||||
|
||||
|
||||
@router.put("/retrieval-log/retention")
|
||||
def set_retention(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
days = payload.get("retentionDays")
|
||||
if days is not None and (not isinstance(days, int) or days < 1):
|
||||
raise HTTPException(status_code=400, detail="保留天数必须为正整数,永久保留请传 null")
|
||||
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1)) or LogRetentionPolicy()
|
||||
policy.retention_days, policy.enabled, policy.updated_by = days, 0 if days is None else 1, current_admin.id
|
||||
db.add(policy)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action="retention_update", target_id=None)
|
||||
db.commit()
|
||||
return api_success({"enabled": bool(policy.enabled), "retentionDays": policy.retention_days})
|
||||
|
||||
|
||||
def _cleanup_before(payload: dict) -> datetime:
|
||||
raw = str(payload.get("before") or "").strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="必须指定清理日期")
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="清理日期格式错误") from exc
|
||||
|
||||
|
||||
@router.get("/retrieval-log/{log_id}")
|
||||
def retrieval_detail(
|
||||
log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
log = db.get(KnowledgeRetrievalLog, log_id)
|
||||
if log is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="检索日志不存在")
|
||||
candidates = db.scalars(
|
||||
select(KnowledgeRetrievalCandidate)
|
||||
.where(KnowledgeRetrievalCandidate.retrieval_log_id == log.id)
|
||||
.order_by(KnowledgeRetrievalCandidate.selected.desc(), KnowledgeRetrievalCandidate.id)
|
||||
).all()
|
||||
data = _retrieval_dict(log, detail=True)
|
||||
data["candidates"] = [
|
||||
{
|
||||
"id": item.id,
|
||||
"knowledgeId": item.knowledge_id,
|
||||
"versionId": item.version_id,
|
||||
"chunkId": item.chunk_id,
|
||||
"sectionId": item.section_id,
|
||||
"lexicalScore": item.lexical_score,
|
||||
"rerankScore": item.rerank_score,
|
||||
"selected": item.selected == 1,
|
||||
"discardReason": item.discard_reason,
|
||||
}
|
||||
for item in candidates
|
||||
]
|
||||
return api_success(data)
|
||||
|
||||
|
||||
@router.get("/attention/list")
|
||||
def attention_list(
|
||||
priority: str = Query(default=""),
|
||||
statusValue: str = Query(default="", alias="status"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=20, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
query = select(HumanAttentionRecord)
|
||||
if priority:
|
||||
query = query.where(HumanAttentionRecord.priority == priority)
|
||||
if statusValue:
|
||||
query = query.where(HumanAttentionRecord.status == statusValue)
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
rows = db.scalars(query.order_by(HumanAttentionRecord.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||
return api_success(page_result([_attention_dict(item) for item in rows], total=total, page=page, page_size=pageSize))
|
||||
|
||||
|
||||
@router.put("/attention/{attention_id}")
|
||||
def update_attention(
|
||||
attention_id: int,
|
||||
payload: AttentionUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
item = db.get(HumanAttentionRecord, attention_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人工关注记录不存在")
|
||||
previous = item.status
|
||||
item.status = payload.status
|
||||
item.handler_id = current_admin.id
|
||||
item.handler_note = payload.note
|
||||
db.add_all(
|
||||
[
|
||||
item,
|
||||
HumanAttentionHistory(
|
||||
attention_id=item.id,
|
||||
from_status=previous,
|
||||
to_status=payload.status,
|
||||
note=payload.note,
|
||||
operated_by=current_admin.id,
|
||||
),
|
||||
]
|
||||
)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="attention", action="update", target_id=item.id)
|
||||
db.commit()
|
||||
return api_success(_attention_dict(item))
|
||||
|
||||
|
||||
@router.delete("/attention/{attention_id}")
|
||||
def delete_attention(
|
||||
attention_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
item = db.get(HumanAttentionRecord, attention_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人工关注记录不存在")
|
||||
histories = db.scalars(
|
||||
select(HumanAttentionHistory).where(HumanAttentionHistory.attention_id == item.id)
|
||||
).all()
|
||||
for history in histories:
|
||||
db.delete(history)
|
||||
db.delete(item)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="attention", action="delete", target_id=attention_id)
|
||||
db.commit()
|
||||
return api_success()
|
||||
|
||||
|
||||
def _retrieval_dict(item: KnowledgeRetrievalLog, *, detail: bool) -> dict:
|
||||
data = {
|
||||
"id": item.id,
|
||||
"sessionId": item.session_id,
|
||||
"messageId": item.message_id,
|
||||
"userId": item.user_id,
|
||||
"question": item.question,
|
||||
"knowledgeCalled": item.knowledge_called == 1,
|
||||
"selectedKnowledgeIds": item.selected_knowledge_ids,
|
||||
"queryTerms": _json(item.query_terms),
|
||||
"finalSectionIds": item.final_section_ids,
|
||||
"status": item.status,
|
||||
"toolCostMs": item.tool_cost_ms,
|
||||
"totalCostMs": item.total_cost_ms,
|
||||
"errorMessage": item.error_message,
|
||||
"attentionCreated": item.attention_created == 1,
|
||||
"createdAt": item.created_at,
|
||||
}
|
||||
if detail:
|
||||
data.update(
|
||||
{
|
||||
"catalogSnapshot": _json(item.catalog_snapshot),
|
||||
"catalogHash": item.catalog_hash,
|
||||
"safetyRuleVersion": item.safety_rule_version,
|
||||
"toolTrace": _json(item.tool_trace),
|
||||
"discardedConflicts": _json(item.discarded_conflicts),
|
||||
"finalAnswer": item.final_answer,
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _attention_dict(item: HumanAttentionRecord) -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"sessionId": item.session_id,
|
||||
"messageId": item.message_id,
|
||||
"userId": item.user_id,
|
||||
"triggerMessage": item.trigger_message,
|
||||
"problemSummary": item.problem_summary,
|
||||
"triggerReason": item.trigger_reason,
|
||||
"priority": item.priority,
|
||||
"status": item.status,
|
||||
"handlerId": item.handler_id,
|
||||
"handlerNote": item.handler_note,
|
||||
"createdAt": item.created_at,
|
||||
"updatedAt": item.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _json(raw: str | None):
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return raw
|
||||
33
ai_knowledge_base_v2/apps/backend/app/api/admin_auth.py
Normal file
33
ai_knowledge_base_v2/apps/backend/app/api/admin_auth.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.schemas.admin import AdminLoginRequest, AdminLoginResponse, AdminRead
|
||||
from app.services.admin_service import AdminAuthService
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.security_state_service import client_ip
|
||||
from app.core.dependencies import get_current_token_payload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(payload: AdminLoginRequest, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
result = AdminAuthService.login(db, payload.username, payload.password, client_ip(request))
|
||||
return api_success(AdminLoginResponse.model_validate(result).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
def profile(current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
return api_success(AdminRead.model_validate(current_admin).model_dump())
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
|
||||
AuthService.logout(token_payload)
|
||||
return api_success()
|
||||
87
ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py
Normal file
87
ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
import json
|
||||
from pathlib import Path
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.schemas.admin import DashboardStats
|
||||
from app.services.admin_service import AdminDashboardService
|
||||
from app.models.logs import StorageSnapshot
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def dashboard(
|
||||
start: str = Query(default="", description="开始日期 YYYY-MM-DD"),
|
||||
end: str = Query(default="", description="结束日期 YYYY-MM-DD"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
start_dt = datetime.strptime(start, "%Y-%m-%d") if start else None
|
||||
if end:
|
||||
end_dt = datetime.strptime(end, "%Y-%m-%d")
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||
else:
|
||||
end_dt = None
|
||||
stats = AdminDashboardService.stats(db, start_dt, end_dt)
|
||||
return api_success(DashboardStats.model_validate(stats).model_dump())
|
||||
|
||||
|
||||
@router.get("/dashboard/storage")
|
||||
def storage_stats(
|
||||
refresh: bool = Query(default=False),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
latest = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == "project").order_by(StorageSnapshot.id.desc()).limit(1))
|
||||
if latest is not None and not refresh:
|
||||
return api_success(_snapshot_dict(latest, db))
|
||||
detail: dict = {"database": {"status": "unavailable", "bytes": None, "tables": []}, "redis": {"status": "unavailable", "bytes": None}, "files": {"status": "unavailable", "bytes": None}}
|
||||
errors: list[str] = []
|
||||
try:
|
||||
if db.bind and db.bind.dialect.name == "mysql":
|
||||
rows = db.execute(text("SELECT table_name, data_length + index_length AS bytes FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY bytes DESC")).all()
|
||||
tables = [{"name": row[0], "bytes": int(row[1] or 0)} for row in rows]
|
||||
detail["database"] = {"status": "ok", "bytes": sum(x["bytes"] for x in tables), "tables": tables}
|
||||
else:
|
||||
detail["database"] = {"status": "unavailable", "bytes": None, "tables": [], "message": "当前数据库不支持精确表占用统计"}
|
||||
except Exception as exc:
|
||||
errors.append(f"database: {exc}")
|
||||
try:
|
||||
redis = get_sync_redis_client()
|
||||
if redis is not None:
|
||||
info = redis.info("memory")
|
||||
detail["redis"] = {"status": "ok", "bytes": int(info.get("used_memory", 0))}
|
||||
except Exception as exc:
|
||||
errors.append(f"redis: {exc}")
|
||||
try:
|
||||
root = Path(".uploads")
|
||||
if root.exists():
|
||||
detail["files"] = {"status": "ok", "bytes": sum(p.stat().st_size for p in root.rglob("*") if p.is_file())}
|
||||
else:
|
||||
detail["files"] = {"status": "unavailable", "bytes": None, "message": "项目未配置本地附件目录"}
|
||||
except Exception as exc:
|
||||
errors.append(f"files: {exc}")
|
||||
known = [x.get("bytes") for x in detail.values() if x.get("status") == "ok"]
|
||||
snapshot = StorageSnapshot(scope="project", total_bytes=sum(known) if known else None, detail_json=json.dumps(detail, ensure_ascii=False), status="partial" if errors or any(x.get("status") != "ok" for x in detail.values()) else "ok", error_message="; ".join(errors) or None)
|
||||
db.add(snapshot); db.commit(); db.refresh(snapshot)
|
||||
return api_success(_snapshot_dict(snapshot, db))
|
||||
|
||||
|
||||
def _snapshot_dict(item: StorageSnapshot, db: Session) -> dict:
|
||||
with_growth = {"totalBytes": item.total_bytes, "detail": json.loads(item.detail_json), "status": item.status, "errorMessage": item.error_message, "createdAt": item.created_at}
|
||||
# Growth is intentionally unknown until enough snapshots exist; never report a misleading zero.
|
||||
for days, key in ((7, "growth7DaysBytes"), (30, "growth30DaysBytes")):
|
||||
previous = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == item.scope, StorageSnapshot.created_at <= item.created_at - timedelta(days=days)).order_by(StorageSnapshot.created_at.desc()).limit(1))
|
||||
with_growth[key] = item.total_bytes - previous.total_bytes if previous and item.total_bytes is not None and previous.total_bytes is not None else None
|
||||
return with_growth
|
||||
258
ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py
Normal file
258
ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py
Normal file
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.models.knowledge import (
|
||||
Knowledge,
|
||||
KnowledgeCard,
|
||||
KnowledgeChunk,
|
||||
KnowledgeManifest,
|
||||
KnowledgePublishLog,
|
||||
KnowledgeReviewRecord,
|
||||
KnowledgeSection,
|
||||
KnowledgeSourceSnapshot,
|
||||
KnowledgeSyncJob,
|
||||
KnowledgeVersion,
|
||||
UserKnowledgePermission,
|
||||
)
|
||||
from app.schemas.admin import KnowledgeSaveRequest
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.services.feishu_service import FeishuKnowledgeService
|
||||
from app.api.pagination import page_result
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/knowledge/list")
|
||||
def list_knowledge(
|
||||
keyword: str = Query(default=""),
|
||||
knowledgeType: str = Query(default=""),
|
||||
openStatus: int | None = Query(default=None),
|
||||
sourceStatus: str = Query(default=""),
|
||||
pending: bool | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=20, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
query = select(Knowledge)
|
||||
if keyword:
|
||||
query = query.where((Knowledge.name.like(f"%{keyword}%")) | (Knowledge.source_title.like(f"%{keyword}%")))
|
||||
if knowledgeType:
|
||||
query = query.where(Knowledge.knowledge_type == knowledgeType)
|
||||
if openStatus is not None:
|
||||
query = query.where(Knowledge.status == openStatus)
|
||||
if sourceStatus:
|
||||
query = query.where(Knowledge.source_status == sourceStatus)
|
||||
if pending is not None:
|
||||
query = query.where(Knowledge.pending_version_id.is_not(None) if pending else Knowledge.pending_version_id.is_(None))
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
items = db.scalars(query.order_by(Knowledge.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||
return api_success(page_result([_knowledge_dict(item) for item in items], total=total, page=page, page_size=pageSize))
|
||||
|
||||
|
||||
@router.get("/knowledge/options")
|
||||
def knowledge_options(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
items = db.scalars(select(Knowledge).where(Knowledge.lifecycle_status == "active").order_by(Knowledge.id.desc())).all()
|
||||
return api_success([_knowledge_dict(item) for item in items])
|
||||
|
||||
|
||||
@router.post("/knowledge/resolve-node")
|
||||
def resolve_knowledge_node(
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
node_id = str(payload.get("nodeId") or "").strip()
|
||||
if not node_id or len(node_id) > 100 or not node_id.replace("_", "").replace("-", "").isalnum():
|
||||
raise HTTPException(status_code=400, detail="Node ID 格式错误")
|
||||
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == node_id))
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail=f"该 Node ID 已添加(知识库:{existing.name})")
|
||||
try:
|
||||
info = FeishuKnowledgeService.get_node_info(node_id, db)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"飞书节点信息获取失败:{exc}") from exc
|
||||
space_id, title = str(info.get("space_id") or "").strip(), str(info.get("title") or "").strip()
|
||||
if not space_id:
|
||||
raise HTTPException(status_code=422, detail="飞书接口未返回 Space ID,请检查节点是否移动或当前应用权限")
|
||||
if not title:
|
||||
raise HTTPException(status_code=422, detail="飞书接口未返回文章标题,请检查文章是否已删除")
|
||||
return api_success({"nodeId": node_id, "spaceId": space_id, "sourceTitle": title, "name": title, "remark": str(info.get("description") or ""), "objType": info.get("obj_type")})
|
||||
|
||||
|
||||
@router.post("/knowledge/import")
|
||||
def import_knowledge_from_space(
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
node_token = payload.get("node_token", "").strip()
|
||||
if not node_token:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="NodeToken 不能为空")
|
||||
|
||||
info = FeishuKnowledgeService.get_node_info(node_token, db)
|
||||
space_id = info.get("space_id", "")
|
||||
if not space_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无法获取节点 {node_token} 的 SpaceID",
|
||||
)
|
||||
|
||||
# 检查是否已存在
|
||||
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == node_token))
|
||||
if existing is not None:
|
||||
return api_success({"imported": 0, "skipped": 1, "id": existing.id, "name": existing.name})
|
||||
|
||||
knowledge = Knowledge(
|
||||
name=info.get("title", node_token),
|
||||
feishu_space_id=space_id,
|
||||
feishu_node_id=node_token,
|
||||
status=0,
|
||||
knowledge_type="general",
|
||||
review_mode="manual",
|
||||
remark=f"obj_type={info.get('obj_type', '')}",
|
||||
)
|
||||
db.add(knowledge)
|
||||
db.flush()
|
||||
OperationLogService.write(
|
||||
db, admin_id=current_admin.id, module="knowledge", action="import", target_id=knowledge.id
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc
|
||||
db.refresh(knowledge)
|
||||
return api_success({"imported": 1, "skipped": 0, "id": knowledge.id, "name": knowledge.name})
|
||||
|
||||
|
||||
@router.post("/knowledge")
|
||||
def create_knowledge(
|
||||
payload: KnowledgeSaveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == payload.feishuNodeId))
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库")
|
||||
knowledge = Knowledge(
|
||||
name=payload.name,
|
||||
feishu_space_id=payload.feishuSpaceId,
|
||||
feishu_node_id=payload.feishuNodeId,
|
||||
status=0,
|
||||
remark=payload.remark,
|
||||
knowledge_type=payload.knowledgeType,
|
||||
review_mode=payload.reviewMode,
|
||||
)
|
||||
db.add(knowledge)
|
||||
db.flush()
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="create", target_id=knowledge.id)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc
|
||||
db.refresh(knowledge)
|
||||
return api_success(_knowledge_dict(knowledge))
|
||||
|
||||
|
||||
@router.put("/knowledge/{knowledge_id}")
|
||||
def update_knowledge(
|
||||
knowledge_id: int,
|
||||
payload: KnowledgeSaveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _get_knowledge(db, knowledge_id)
|
||||
knowledge.name = payload.name
|
||||
knowledge.remark = payload.remark
|
||||
knowledge.knowledge_type = payload.knowledgeType
|
||||
knowledge.review_mode = payload.reviewMode
|
||||
db.add(knowledge)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
|
||||
db.commit()
|
||||
db.refresh(knowledge)
|
||||
return api_success(_knowledge_dict(knowledge))
|
||||
|
||||
|
||||
@router.delete("/knowledge/{knowledge_id}")
|
||||
def delete_knowledge(
|
||||
knowledge_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _get_knowledge(db, knowledge_id)
|
||||
if knowledge.lifecycle_status != "archived" or knowledge.status != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="物理删除前必须先关闭并归档知识库",
|
||||
)
|
||||
# 清理早期按用户授权口径留下的历史关联,避免外键阻止知识库删除。
|
||||
permissions = db.scalars(
|
||||
select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id)
|
||||
).all()
|
||||
for permission in permissions:
|
||||
db.delete(permission)
|
||||
for model in (KnowledgeReviewRecord, KnowledgeCard, KnowledgeChunk, KnowledgeSection, KnowledgeManifest, KnowledgePublishLog, KnowledgeSyncJob):
|
||||
for row in db.scalars(select(model).where(model.knowledge_id == knowledge.id)).all():
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
for row in db.scalars(select(KnowledgeVersion).where(KnowledgeVersion.knowledge_id == knowledge.id)).all():
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
for row in db.scalars(select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == knowledge.id)).all():
|
||||
db.delete(row)
|
||||
db.delete(knowledge)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id)
|
||||
db.commit()
|
||||
return api_success()
|
||||
|
||||
|
||||
def _get_knowledge(db: Session, knowledge_id: int) -> Knowledge:
|
||||
knowledge = db.get(Knowledge, knowledge_id)
|
||||
if knowledge is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在")
|
||||
return knowledge
|
||||
|
||||
|
||||
def _require_super_admin(admin: Admin) -> None:
|
||||
if admin.role is not None and admin.role.code != "super_admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可以管理知识库")
|
||||
|
||||
|
||||
def _knowledge_dict(knowledge: Knowledge) -> dict:
|
||||
return {
|
||||
"id": knowledge.id,
|
||||
"name": knowledge.name,
|
||||
"feishuSpaceId": knowledge.feishu_space_id,
|
||||
"feishuNodeId": knowledge.feishu_node_id,
|
||||
"status": knowledge.status,
|
||||
"remark": knowledge.remark,
|
||||
"sourceTitle": knowledge.source_title,
|
||||
"knowledgeType": knowledge.knowledge_type,
|
||||
"reviewMode": knowledge.review_mode,
|
||||
"lifecycleStatus": knowledge.lifecycle_status,
|
||||
"sourceStatus": knowledge.source_status,
|
||||
"sourceError": knowledge.source_error,
|
||||
"manifestConfirmed": knowledge.manifest_confirmed == 1,
|
||||
"currentVersionId": knowledge.current_version_id,
|
||||
"pendingVersionId": knowledge.pending_version_id,
|
||||
"lastSyncedAt": knowledge.last_synced_at,
|
||||
"lastPublishedAt": knowledge.last_published_at,
|
||||
"migrationStatus": knowledge.migration_status,
|
||||
"createdAt": knowledge.created_at,
|
||||
"updatedAt": knowledge.updated_at,
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.models.knowledge import (
|
||||
Knowledge,
|
||||
KnowledgeCard,
|
||||
KnowledgeChunk,
|
||||
KnowledgeManifest,
|
||||
KnowledgeSection,
|
||||
KnowledgeSyncJob,
|
||||
KnowledgeVersion,
|
||||
)
|
||||
from app.schemas.knowledge import (
|
||||
KnowledgeLifecycleRequest,
|
||||
KnowledgeBatchMetadataUpdateRequest,
|
||||
KnowledgeMetadataUpdateRequest,
|
||||
KnowledgePublishRequest,
|
||||
KnowledgeReviewRequest,
|
||||
KnowledgeStatusRequest,
|
||||
KnowledgeSyncRequest,
|
||||
)
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.services.knowledge_pipeline_service import KnowledgePipelineService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.patch("/knowledge/batch/metadata")
|
||||
def batch_update_metadata(
|
||||
payload: KnowledgeBatchMetadataUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
if payload.knowledgeType is None and payload.reviewMode is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少选择一个待修改字段")
|
||||
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
|
||||
rows = {
|
||||
item.id: item
|
||||
for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(knowledge_ids))).all()
|
||||
}
|
||||
results: list[dict] = []
|
||||
for knowledge_id in knowledge_ids:
|
||||
item = rows.get(knowledge_id)
|
||||
if item is None:
|
||||
results.append({"knowledgeId": knowledge_id, "ok": False, "error": "知识库不存在"})
|
||||
continue
|
||||
if payload.knowledgeType is not None:
|
||||
item.knowledge_type = payload.knowledgeType
|
||||
if payload.reviewMode is not None:
|
||||
item.review_mode = payload.reviewMode
|
||||
db.add(item)
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="knowledge",
|
||||
action="batch_update_metadata",
|
||||
target_id=item.id,
|
||||
)
|
||||
results.append({"knowledgeId": knowledge_id, "ok": True})
|
||||
db.commit()
|
||||
success = sum(1 for item in results if item["ok"])
|
||||
return api_success(
|
||||
{"total": len(results), "success": success, "failed": len(results) - success, "results": results}
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/knowledge/{knowledge_id}/metadata")
|
||||
def update_metadata(
|
||||
knowledge_id: int,
|
||||
payload: KnowledgeMetadataUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _knowledge(db, knowledge_id)
|
||||
knowledge.name = payload.name.strip()
|
||||
knowledge.knowledge_type = payload.knowledgeType
|
||||
knowledge.review_mode = payload.reviewMode
|
||||
db.add(knowledge)
|
||||
OperationLogService.write(
|
||||
db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(knowledge)
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
|
||||
|
||||
@router.get("/knowledge/{knowledge_id}/detail")
|
||||
def detail(
|
||||
knowledge_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _knowledge(db, knowledge_id)
|
||||
current = db.get(KnowledgeVersion, knowledge.current_version_id) if knowledge.current_version_id else None
|
||||
pending = db.get(KnowledgeVersion, knowledge.pending_version_id) if knowledge.pending_version_id else None
|
||||
manifest = None
|
||||
manifest_version_id = knowledge.pending_version_id or knowledge.current_version_id
|
||||
if manifest_version_id:
|
||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == manifest_version_id))
|
||||
return api_success(
|
||||
{
|
||||
"knowledge": _knowledge_detail(knowledge),
|
||||
"currentVersion": _version_dict(current),
|
||||
"pendingVersion": _version_dict(pending),
|
||||
"manifest": _manifest_dict(manifest),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/knowledge/{knowledge_id}/versions")
|
||||
def versions(
|
||||
knowledge_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
_knowledge(db, knowledge_id)
|
||||
rows = db.scalars(
|
||||
select(KnowledgeVersion)
|
||||
.where(KnowledgeVersion.knowledge_id == knowledge_id)
|
||||
.order_by(KnowledgeVersion.version_no.desc())
|
||||
).all()
|
||||
return api_success([_version_dict(item) for item in rows])
|
||||
|
||||
|
||||
@router.get("/knowledge/version/{version_id}/content")
|
||||
def version_content(
|
||||
version_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
version = _version(db, version_id)
|
||||
sections = db.scalars(
|
||||
select(KnowledgeSection).where(KnowledgeSection.version_id == version.id).order_by(KnowledgeSection.sort_order)
|
||||
).all()
|
||||
cards = db.scalars(
|
||||
select(KnowledgeCard).where(KnowledgeCard.version_id == version.id).order_by(KnowledgeCard.id)
|
||||
).all()
|
||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
|
||||
return api_success(
|
||||
{
|
||||
"version": _version_dict(version),
|
||||
"manifest": _manifest_dict(manifest),
|
||||
"sections": [
|
||||
{
|
||||
"id": item.id,
|
||||
"sectionKey": item.section_key,
|
||||
"title": item.title,
|
||||
"content": item.content,
|
||||
"sourceStart": item.source_start,
|
||||
"sourceEnd": item.source_end,
|
||||
"contentHash": item.content_hash,
|
||||
}
|
||||
for item in sections
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"id": item.id,
|
||||
"sectionId": item.section_id,
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"coreConclusion": item.core_conclusion,
|
||||
"applicableQuestions": item.applicable_questions,
|
||||
"inapplicableQuestions": item.inapplicable_questions,
|
||||
"keywords": _json_value(item.keywords),
|
||||
"synonyms": _json_value(item.synonyms),
|
||||
"riskLevel": item.risk_level,
|
||||
"sourceRange": item.source_range,
|
||||
"reviewStatus": item.review_status,
|
||||
}
|
||||
for item in cards
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/knowledge/{knowledge_id}/sync-jobs")
|
||||
def sync_jobs(
|
||||
knowledge_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
rows = db.scalars(
|
||||
select(KnowledgeSyncJob)
|
||||
.where(KnowledgeSyncJob.knowledge_id == knowledge_id)
|
||||
.order_by(KnowledgeSyncJob.id.desc())
|
||||
.limit(50)
|
||||
).all()
|
||||
return api_success([_job_dict(item) for item in rows])
|
||||
|
||||
|
||||
@router.post("/knowledge/{knowledge_id}/sync")
|
||||
async def synchronize(
|
||||
knowledge_id: int,
|
||||
payload: KnowledgeSyncRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _knowledge(db, knowledge_id)
|
||||
if knowledge.lifecycle_status == "archived":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能同步")
|
||||
try:
|
||||
job = await KnowledgePipelineService.synchronize(
|
||||
db, knowledge, admin_id=current_admin.id, mode=payload.mode
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from exc
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="sync", target_id=knowledge.id)
|
||||
db.commit()
|
||||
return api_success(_job_dict(job))
|
||||
|
||||
|
||||
@router.post("/knowledge/{knowledge_id}/manifest/{version_id}/confirm")
|
||||
def confirm_manifest(
|
||||
knowledge_id: int,
|
||||
version_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||
if knowledge.pending_version_id != version.id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能确认当前待发布版本的说明卡")
|
||||
try:
|
||||
KnowledgePipelineService.confirm_manifest(db, knowledge, version, current_admin.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="confirm_manifest", target_id=knowledge.id)
|
||||
db.commit()
|
||||
return api_success()
|
||||
|
||||
|
||||
@router.post("/knowledge/{knowledge_id}/version/{version_id}/review")
|
||||
def review(
|
||||
knowledge_id: int,
|
||||
version_id: int,
|
||||
payload: KnowledgeReviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||
if knowledge.pending_version_id != version.id or version.status != "pending_review":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能审核当前待审核版本")
|
||||
if version.quality_status == "blocked" and payload.action == "approve":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="自动质检存在阻断项,不能审核通过")
|
||||
KnowledgePipelineService.review_version(db, knowledge, version, current_admin.id, payload.action, payload.reason)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=f"review_{payload.action}", target_id=knowledge.id)
|
||||
db.commit()
|
||||
return api_success()
|
||||
|
||||
|
||||
@router.post("/knowledge/{knowledge_id}/version/{version_id}/publish")
|
||||
def publish(
|
||||
knowledge_id: int,
|
||||
version_id: int,
|
||||
payload: KnowledgePublishRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||
if knowledge.pending_version_id != version.id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能发布当前待发布版本")
|
||||
try:
|
||||
KnowledgePipelineService.publish_version(db, knowledge, version, current_admin.id, payload.keepOpen)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="publish", target_id=knowledge.id)
|
||||
db.commit()
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
|
||||
|
||||
@router.post("/knowledge/{knowledge_id}/version/{version_id}/rollback")
|
||||
def rollback(
|
||||
knowledge_id: int,
|
||||
version_id: int,
|
||||
payload: KnowledgePublishRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||
if knowledge.current_version_id == version.id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="目标版本已是当前正式版本")
|
||||
if payload.keepOpen and knowledge.lifecycle_status != "active":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能在回滚后开放")
|
||||
try:
|
||||
KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="rollback", target_id=knowledge.id)
|
||||
db.commit()
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
|
||||
|
||||
@router.put("/knowledge/{knowledge_id}/open-status")
|
||||
def open_status(
|
||||
knowledge_id: int,
|
||||
payload: KnowledgeStatusRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _knowledge(db, knowledge_id)
|
||||
if payload.open:
|
||||
if not knowledge.current_version_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="知识库没有正式发布版本")
|
||||
if knowledge.manifest_confirmed != 1:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="首次知识库说明卡尚未确认")
|
||||
if knowledge.source_status != "normal":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="飞书源文章异常,不能开放")
|
||||
if knowledge.lifecycle_status != "active":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能开放")
|
||||
knowledge.status = 1 if payload.open else 0
|
||||
db.add(knowledge)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="open" if payload.open else "close", target_id=knowledge.id)
|
||||
db.commit()
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
|
||||
|
||||
@router.put("/knowledge/{knowledge_id}/lifecycle")
|
||||
def lifecycle(
|
||||
knowledge_id: int,
|
||||
payload: KnowledgeLifecycleRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _knowledge(db, knowledge_id)
|
||||
if knowledge.lifecycle_status == payload.status:
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
knowledge.lifecycle_status = payload.status
|
||||
if payload.status == "archived":
|
||||
knowledge.status = 0
|
||||
db.add(knowledge)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=payload.status, target_id=knowledge.id)
|
||||
db.commit()
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
|
||||
|
||||
@router.get("/knowledge-migration/summary")
|
||||
def migration_summary(
|
||||
db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
rows = db.execute(
|
||||
select(Knowledge.migration_status, func.count(Knowledge.id)).group_by(Knowledge.migration_status)
|
||||
).all()
|
||||
counts = {migration_status: count for migration_status, count in rows}
|
||||
return api_success({"total": sum(counts.values()), "counts": counts})
|
||||
|
||||
|
||||
@router.post("/knowledge-migration/run")
|
||||
async def run_migration(
|
||||
knowledgeId: int | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
query = select(Knowledge).where(Knowledge.lifecycle_status == "active")
|
||||
if knowledgeId is not None:
|
||||
query = query.where(Knowledge.id == knowledgeId)
|
||||
rows = db.scalars(query.order_by(Knowledge.id)).all()
|
||||
results = []
|
||||
for knowledge in rows:
|
||||
try:
|
||||
job = await KnowledgePipelineService.synchronize(
|
||||
db, knowledge, admin_id=current_admin.id, mode="review"
|
||||
)
|
||||
results.append({"knowledgeId": knowledge.id, "ok": True, "job": _job_dict(job)})
|
||||
except Exception as exc:
|
||||
results.append({"knowledgeId": knowledge.id, "ok": False, "error": str(exc)})
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge_migration", action="run", target_id=knowledgeId)
|
||||
db.commit()
|
||||
return api_success({"total": len(results), "success": sum(1 for item in results if item["ok"]), "results": results})
|
||||
|
||||
|
||||
def _require_super_admin(admin: Admin) -> None:
|
||||
if admin.role is not None and admin.role.code != "super_admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可以执行此操作")
|
||||
|
||||
|
||||
def _knowledge(db: Session, knowledge_id: int) -> Knowledge:
|
||||
item = db.get(Knowledge, knowledge_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在")
|
||||
return item
|
||||
|
||||
|
||||
def _version(db: Session, version_id: int) -> KnowledgeVersion:
|
||||
item = db.get(KnowledgeVersion, version_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识版本不存在")
|
||||
return item
|
||||
|
||||
|
||||
def _knowledge_version(db: Session, knowledge_id: int, version_id: int) -> tuple[Knowledge, KnowledgeVersion]:
|
||||
knowledge = _knowledge(db, knowledge_id)
|
||||
version = _version(db, version_id)
|
||||
if version.knowledge_id != knowledge.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="版本不属于该知识库")
|
||||
return knowledge, version
|
||||
|
||||
|
||||
def _knowledge_detail(item: Knowledge) -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"status": item.status,
|
||||
"remark": item.remark,
|
||||
"sourceTitle": item.source_title,
|
||||
"feishuSpaceId": item.feishu_space_id,
|
||||
"feishuNodeId": item.feishu_node_id,
|
||||
"knowledgeType": item.knowledge_type,
|
||||
"reviewMode": item.review_mode,
|
||||
"open": item.status == 1,
|
||||
"lifecycleStatus": item.lifecycle_status,
|
||||
"sourceStatus": item.source_status,
|
||||
"sourceError": item.source_error,
|
||||
"manifestConfirmed": item.manifest_confirmed == 1,
|
||||
"currentVersionId": item.current_version_id,
|
||||
"pendingVersionId": item.pending_version_id,
|
||||
"lastSyncedAt": item.last_synced_at,
|
||||
"lastPublishedAt": item.last_published_at,
|
||||
"migrationStatus": item.migration_status,
|
||||
"createdAt": item.created_at,
|
||||
"updatedAt": item.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _version_dict(item: KnowledgeVersion | None) -> dict | None:
|
||||
if item is None:
|
||||
return None
|
||||
return {
|
||||
"id": item.id,
|
||||
"knowledgeId": item.knowledge_id,
|
||||
"snapshotId": item.snapshot_id,
|
||||
"versionNo": item.version_no,
|
||||
"status": item.status,
|
||||
"qualityStatus": item.quality_status,
|
||||
"changeSummary": _json_value(item.change_summary),
|
||||
"qualityReport": _json_value(item.quality_report),
|
||||
"processingRuleVersion": item.processing_rule_version,
|
||||
"generationModel": item.generation_model,
|
||||
"createdAt": item.created_at,
|
||||
"publishedAt": item.published_at,
|
||||
}
|
||||
|
||||
|
||||
def _manifest_dict(item: KnowledgeManifest | None) -> dict | None:
|
||||
if item is None:
|
||||
return None
|
||||
return {
|
||||
"id": item.id,
|
||||
"versionId": item.version_id,
|
||||
"purpose": item.purpose,
|
||||
"applicableQuestions": item.applicable_questions,
|
||||
"inapplicableQuestions": item.inapplicable_questions,
|
||||
"coreTopics": item.core_topics,
|
||||
"entities": item.entities,
|
||||
"boundaries": item.boundaries,
|
||||
"changeLevel": item.change_level,
|
||||
"confirmed": item.confirmed == 1,
|
||||
}
|
||||
|
||||
|
||||
def _job_dict(item: KnowledgeSyncJob) -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"knowledgeId": item.knowledge_id,
|
||||
"mode": item.mode,
|
||||
"status": item.status,
|
||||
"stage": item.stage,
|
||||
"progress": item.progress,
|
||||
"sourceError": item.source_error,
|
||||
"resultSummary": _json_value(item.result_summary),
|
||||
"createdAt": item.created_at,
|
||||
"startedAt": item.started_at,
|
||||
"finishedAt": item.finished_at,
|
||||
}
|
||||
|
||||
|
||||
def _json_value(raw: str | None):
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return raw
|
||||
307
ai_knowledge_base_v2/apps/backend/app/api/admin_records.py
Normal file
307
ai_knowledge_base_v2/apps/backend/app/api/admin_records.py
Normal file
@@ -0,0 +1,307 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.orm import Session, load_only
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.logs import AiRequestLog, OperationLog
|
||||
from app.models.user import User
|
||||
from app.api.pagination import page_result
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/chat/list")
|
||||
def chat_list(
|
||||
keyword: str = Query(default=""),
|
||||
userId: int | None = Query(default=None),
|
||||
status: str = Query(default=""),
|
||||
dateFrom: datetime | None = Query(default=None),
|
||||
dateTo: datetime | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=20, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
query = _chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
|
||||
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
||||
rows = db.execute(query.order_by(ChatSession.updated_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||
return api_success(page_result([_chat_row_dict(session, user) for session, user in rows], total=total, page=page, page_size=pageSize))
|
||||
|
||||
|
||||
@router.get("/chat/export")
|
||||
def export_chats(
|
||||
keyword: str = Query(default=""),
|
||||
userId: int | None = Query(default=None),
|
||||
status: str = Query(default=""),
|
||||
dateFrom: datetime | None = Query(default=None),
|
||||
dateTo: datetime | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> Response:
|
||||
rows = db.execute(
|
||||
_chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
|
||||
.order_by(ChatSession.updated_at.desc())
|
||||
.limit(1000)
|
||||
).all()
|
||||
output = StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "标题", "消息数", "最后消息时间", "更新时间"])
|
||||
for session, user in rows:
|
||||
writer.writerow(
|
||||
[
|
||||
session.id,
|
||||
session.user_id,
|
||||
user.phone if user else "",
|
||||
user.name if user else "",
|
||||
session.title,
|
||||
session.message_count,
|
||||
session.last_message_at,
|
||||
session.updated_at,
|
||||
]
|
||||
)
|
||||
content = "\ufeff" + output.getvalue()
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="chat_records.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/chat/{session_id}")
|
||||
def chat_detail(
|
||||
session_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
session_row = db.execute(
|
||||
select(ChatSession, User)
|
||||
.join(User, User.id == ChatSession.user_id, isouter=True)
|
||||
.where(ChatSession.id == session_id)
|
||||
).first()
|
||||
if session_row is None:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
|
||||
|
||||
session, user = session_row
|
||||
messages = db.scalars(
|
||||
select(ChatMessage)
|
||||
.where(ChatMessage.session_id == session_id)
|
||||
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||
).all()
|
||||
ai_logs = db.scalars(
|
||||
select(AiRequestLog)
|
||||
.where(AiRequestLog.session_id == session_id)
|
||||
.order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc())
|
||||
).all()
|
||||
return api_success(
|
||||
{
|
||||
"session": _chat_row_dict(session, user),
|
||||
"messages": [_message_dict(item) for item in messages],
|
||||
"aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/log/list")
|
||||
def operation_logs(
|
||||
module: str = Query(default=""),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=20, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
query = select(OperationLog)
|
||||
if module:
|
||||
query = query.where(OperationLog.module == module)
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
logs = db.scalars(
|
||||
query.options(load_only(OperationLog.id, OperationLog.admin_id, OperationLog.module, OperationLog.action, OperationLog.target_id, OperationLog.result, OperationLog.created_at))
|
||||
.order_by(OperationLog.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)
|
||||
).all()
|
||||
return api_success(page_result(
|
||||
[
|
||||
{
|
||||
"id": item.id,
|
||||
"adminId": item.admin_id,
|
||||
"module": item.module,
|
||||
"action": item.action,
|
||||
"targetId": item.target_id,
|
||||
"result": item.result,
|
||||
"createdAt": item.created_at,
|
||||
}
|
||||
for item in logs
|
||||
], total=total, page=page, page_size=pageSize
|
||||
))
|
||||
|
||||
|
||||
@router.get("/ai-log/list")
|
||||
def ai_logs(
|
||||
sessionId: int | None = Query(default=None),
|
||||
userId: int | None = Query(default=None),
|
||||
status: str = Query(default=""),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=20, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
query = select(AiRequestLog)
|
||||
if sessionId is not None:
|
||||
query = query.where(AiRequestLog.session_id == sessionId)
|
||||
if userId is not None:
|
||||
query = query.where(AiRequestLog.user_id == userId)
|
||||
if status:
|
||||
query = query.where(AiRequestLog.status == status)
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
logs = db.scalars(
|
||||
query.options(load_only(
|
||||
AiRequestLog.id, AiRequestLog.session_id, AiRequestLog.message_id, AiRequestLog.user_id,
|
||||
AiRequestLog.model_name, AiRequestLog.knowledge_ids, AiRequestLog.retrieve_count,
|
||||
AiRequestLog.input_token, AiRequestLog.output_token, AiRequestLog.total_token,
|
||||
AiRequestLog.cost_ms, AiRequestLog.status, AiRequestLog.error_message, AiRequestLog.created_at,
|
||||
)).order_by(AiRequestLog.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)
|
||||
).all()
|
||||
return api_success(page_result([_ai_log_dict(item, include_chunks=False) for item in logs], total=total, page=page, page_size=pageSize))
|
||||
|
||||
|
||||
@router.get("/ai-log/{log_id}")
|
||||
def ai_log_detail(
|
||||
log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
log = db.get(AiRequestLog, log_id)
|
||||
if log is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AI 请求日志不存在")
|
||||
return api_success(_ai_log_dict(log, include_prompt=True))
|
||||
|
||||
|
||||
def _chat_query(
|
||||
*,
|
||||
keyword: str,
|
||||
user_id: int | None,
|
||||
status: str,
|
||||
date_from: datetime | None,
|
||||
date_to: datetime | None,
|
||||
):
|
||||
query = (
|
||||
select(ChatSession, User)
|
||||
.join(User, User.id == ChatSession.user_id, isouter=True)
|
||||
.where(ChatSession.is_deleted == 0)
|
||||
)
|
||||
if user_id is not None:
|
||||
query = query.where(ChatSession.user_id == user_id)
|
||||
if date_from is not None:
|
||||
query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None))
|
||||
if date_to is not None:
|
||||
query = query.where(ChatSession.updated_at <= date_to.replace(tzinfo=None))
|
||||
if status:
|
||||
query = query.where(
|
||||
exists()
|
||||
.where(ChatMessage.session_id == ChatSession.id)
|
||||
.where(ChatMessage.message_status == status)
|
||||
)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
ChatSession.title.like(like),
|
||||
User.phone.like(like),
|
||||
User.name.like(like),
|
||||
exists()
|
||||
.where(ChatMessage.session_id == ChatSession.id)
|
||||
.where(ChatMessage.content.like(like)),
|
||||
)
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
def _chat_row_dict(session: ChatSession, user: User | None) -> dict:
|
||||
return {
|
||||
"id": session.id,
|
||||
"userId": session.user_id,
|
||||
"userPhone": user.phone if user else "",
|
||||
"userName": user.name if user else "",
|
||||
"title": session.title,
|
||||
"messageCount": session.message_count,
|
||||
"lastMessageAt": session.last_message_at,
|
||||
"updatedAt": session.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _message_dict(message: ChatMessage) -> dict:
|
||||
return {
|
||||
"id": message.id,
|
||||
"sessionId": message.session_id,
|
||||
"userId": message.user_id,
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
"messageStatus": message.message_status,
|
||||
"tokenInput": message.token_input,
|
||||
"tokenOutput": message.token_output,
|
||||
"responseTimeMs": message.response_time_ms,
|
||||
"modelId": message.model_id,
|
||||
"createdAt": message.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False, include_chunks: bool = True) -> dict:
|
||||
data = {
|
||||
"id": log.id,
|
||||
"sessionId": log.session_id,
|
||||
"messageId": log.message_id,
|
||||
"userId": log.user_id,
|
||||
"modelName": log.model_name,
|
||||
"knowledgeIds": log.knowledge_ids,
|
||||
"retrieveCount": log.retrieve_count,
|
||||
"retrievedChunks": _parse_retrieved_chunks(log.retrieved_chunks) if include_chunks else [],
|
||||
"inputToken": log.input_token,
|
||||
"outputToken": log.output_token,
|
||||
"totalToken": log.total_token,
|
||||
"costMs": log.cost_ms,
|
||||
"status": log.status,
|
||||
"errorMessage": log.error_message,
|
||||
"createdAt": log.created_at,
|
||||
}
|
||||
if include_prompt:
|
||||
data["prompt"] = log.prompt
|
||||
return data
|
||||
|
||||
|
||||
def _parse_retrieved_chunks(raw: str | None) -> list[dict[str, Any]]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
chunks = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(chunks, list):
|
||||
return []
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for index, item in enumerate(chunks, start=1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"index": item.get("index") or index,
|
||||
"knowledgeId": item.get("knowledgeId"),
|
||||
"knowledgeName": item.get("knowledgeName") or "",
|
||||
"title": item.get("title") or "",
|
||||
"content": item.get("content") or "",
|
||||
"sourceUrl": item.get("sourceUrl") or None,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
522
ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py
Normal file
522
ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py
Normal file
@@ -0,0 +1,522 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.pagination import page_result
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.models.ai_config import ModelConfig, Prompt, SystemConfig
|
||||
from app.models.knowledge import Knowledge
|
||||
from app.schemas.admin import (
|
||||
AgentDebugRequest,
|
||||
AgentRuntimeConfigSaveRequest,
|
||||
EnableModelRequest,
|
||||
ModelSaveRequest,
|
||||
PromptSaveRequest,
|
||||
SystemConfigSaveRequest,
|
||||
)
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.services.feishu_service import FeishuKnowledgeService
|
||||
from app.services.knowledge_agent_service import KnowledgeAgentService
|
||||
from app.services.knowledge_service import KnowledgeScope
|
||||
from app.services.model_service import ModelClientService
|
||||
from app.services.rag_service import PromptService, RagResult
|
||||
from app.services.secret_service import MASKED_SECRET, SENSITIVE_CONFIG_KEYS, SecretService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DEFAULT_PROMPT = "你是大本营答疑助手。你只能基于提供的知识片段回答,不能编造。"
|
||||
|
||||
|
||||
@router.get("/prompt")
|
||||
def get_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
row = db.execute(
|
||||
select(Prompt, Admin)
|
||||
.outerjoin(Admin, Admin.id == Prompt.updated_by)
|
||||
.order_by(Prompt.updated_at.desc(), Prompt.id.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
return api_success(_prompt_detail(row[0], row[1]) if row else _default_prompt_detail())
|
||||
|
||||
|
||||
@router.put("/prompt")
|
||||
def save_prompt(
|
||||
payload: PromptSaveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
prompt = Prompt(prompt_content=payload.promptContent, change_type="save", updated_by=current_admin.id)
|
||||
db.add(prompt)
|
||||
db.flush()
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="save", target_id=prompt.id)
|
||||
db.commit()
|
||||
db.refresh(prompt)
|
||||
return api_success(_prompt_detail(prompt, current_admin))
|
||||
|
||||
|
||||
@router.post("/prompt/reset")
|
||||
def reset_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
prompt = Prompt(prompt_content=DEFAULT_PROMPT, change_type="reset", updated_by=current_admin.id)
|
||||
db.add(prompt)
|
||||
db.flush()
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="reset", target_id=prompt.id)
|
||||
db.commit()
|
||||
db.refresh(prompt)
|
||||
return api_success(_prompt_detail(prompt, current_admin))
|
||||
|
||||
|
||||
@router.get("/prompt/history")
|
||||
def prompt_history(
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=10, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
total = db.scalar(select(func.count(Prompt.id))) or 0
|
||||
rows = db.execute(
|
||||
select(Prompt, Admin)
|
||||
.outerjoin(Admin, Admin.id == Prompt.updated_by)
|
||||
.order_by(Prompt.updated_at.desc(), Prompt.id.desc())
|
||||
.offset((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
).all()
|
||||
items = [
|
||||
_prompt_summary(prompt, admin, is_current=index == 0 and page == 1)
|
||||
for index, (prompt, admin) in enumerate(rows)
|
||||
]
|
||||
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
|
||||
|
||||
|
||||
@router.get("/prompt/history/{prompt_id}")
|
||||
def prompt_history_detail(
|
||||
prompt_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
row = db.execute(
|
||||
select(Prompt, Admin)
|
||||
.outerjoin(Admin, Admin.id == Prompt.updated_by)
|
||||
.where(Prompt.id == prompt_id)
|
||||
).first()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提示词版本不存在")
|
||||
return api_success(_prompt_detail(row[0], row[1]))
|
||||
|
||||
|
||||
@router.post("/prompt/history/{prompt_id}/restore")
|
||||
def restore_prompt(
|
||||
prompt_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
source = db.get(Prompt, prompt_id)
|
||||
if source is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提示词版本不存在")
|
||||
restored = Prompt(
|
||||
prompt_content=source.prompt_content,
|
||||
change_type="restore",
|
||||
source_prompt_id=source.id,
|
||||
updated_by=current_admin.id,
|
||||
)
|
||||
db.add(restored)
|
||||
db.flush()
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="prompt",
|
||||
action="restore",
|
||||
target_id=restored.id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(restored)
|
||||
return api_success(_prompt_detail(restored, current_admin))
|
||||
|
||||
|
||||
@router.post("/agent/debug")
|
||||
async def debug_agent(
|
||||
payload: AgentDebugRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
model = db.get(ModelConfig, payload.modelId)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
|
||||
|
||||
rag_result = await KnowledgeAgentService.build_result(
|
||||
db,
|
||||
question=payload.question,
|
||||
version_overrides=payload.knowledgeVersions,
|
||||
preview_knowledge_ids=payload.knowledgeIds,
|
||||
)
|
||||
debug_messages = [
|
||||
{"role": "system", "content": payload.promptContent.strip()},
|
||||
*(rag_result.messages or []),
|
||||
]
|
||||
rag_result = RagResult(
|
||||
question=rag_result.question,
|
||||
knowledge_scopes=rag_result.knowledge_scopes,
|
||||
chunks=rag_result.chunks,
|
||||
prompt=PromptService.render_messages(debug_messages),
|
||||
allow_general_knowledge=rag_result.allow_general_knowledge,
|
||||
retrieval_log_id=rag_result.retrieval_log_id,
|
||||
tool_trace=rag_result.tool_trace,
|
||||
messages=debug_messages,
|
||||
)
|
||||
result = await ModelClientService.debug_model_async(
|
||||
model,
|
||||
rag_result,
|
||||
{
|
||||
"temperature": payload.temperature,
|
||||
"top_p": payload.topP,
|
||||
"top_k": payload.topK,
|
||||
"presence_penalty": payload.presencePenalty,
|
||||
"frequency_penalty": payload.frequencyPenalty,
|
||||
"max_token": payload.maxToken,
|
||||
},
|
||||
)
|
||||
result["retrievalTrace"] = rag_result.tool_trace or []
|
||||
result["retrievalLogId"] = rag_result.retrieval_log_id
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="debug", target_id=model.id)
|
||||
db.commit()
|
||||
return api_success(result)
|
||||
|
||||
|
||||
@router.get("/agent/runtime-config")
|
||||
def get_agent_runtime_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
model = _enabled_model(db)
|
||||
return api_success(_agent_runtime_config_dict(model))
|
||||
|
||||
|
||||
@router.put("/agent/runtime-config")
|
||||
def save_agent_runtime_config(
|
||||
payload: AgentRuntimeConfigSaveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
model = _enabled_model(db)
|
||||
if model is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="当前没有已启用模型,请先在模型管理中启用一个模型",
|
||||
)
|
||||
model.temperature = payload.temperature
|
||||
model.top_p = payload.topP
|
||||
model.top_k = payload.topK
|
||||
model.presence_penalty = payload.presencePenalty
|
||||
model.frequency_penalty = payload.frequencyPenalty
|
||||
model.max_token = payload.maxToken
|
||||
db.add(model)
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="agent",
|
||||
action="save_runtime_config",
|
||||
target_id=model.id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
return api_success(_agent_runtime_config_dict(model))
|
||||
|
||||
|
||||
@router.get("/model/list")
|
||||
def list_models(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
models = db.scalars(select(ModelConfig).order_by(ModelConfig.id.desc())).all()
|
||||
return api_success([_model_dict(model) for model in models])
|
||||
|
||||
|
||||
@router.post("/model")
|
||||
def create_model(
|
||||
payload: ModelSaveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
if not payload.apiKey:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API Key 不能为空")
|
||||
model = ModelConfig(
|
||||
provider=payload.provider,
|
||||
display_name=payload.displayName,
|
||||
api_type=payload.apiType,
|
||||
model_name=payload.modelName,
|
||||
base_url=payload.baseUrl,
|
||||
api_url=payload.apiUrl,
|
||||
api_key=SecretService.encrypt(payload.apiKey),
|
||||
auth_type=payload.authType,
|
||||
api_version=payload.apiVersion,
|
||||
temperature=payload.temperature,
|
||||
top_p=payload.topP,
|
||||
top_k=payload.topK,
|
||||
presence_penalty=payload.presencePenalty,
|
||||
frequency_penalty=payload.frequencyPenalty,
|
||||
max_token=payload.maxToken,
|
||||
context_window=payload.contextWindow,
|
||||
stream_enabled=payload.streamEnabled,
|
||||
response_format=payload.responseFormat,
|
||||
extra_params=payload.extraParams,
|
||||
remark=payload.remark,
|
||||
timeout_second=payload.timeoutSecond,
|
||||
enabled=0,
|
||||
)
|
||||
db.add(model)
|
||||
db.flush()
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="create", target_id=model.id)
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
return api_success(_model_dict(model))
|
||||
|
||||
|
||||
@router.put("/model/{model_id}")
|
||||
def update_model(
|
||||
model_id: int,
|
||||
payload: ModelSaveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
model = db.get(ModelConfig, model_id)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
|
||||
|
||||
model.provider = payload.provider
|
||||
model.display_name = payload.displayName
|
||||
model.api_type = payload.apiType
|
||||
model.model_name = payload.modelName
|
||||
model.base_url = payload.baseUrl
|
||||
model.api_url = payload.apiUrl
|
||||
if payload.apiKey and payload.apiKey != MASKED_SECRET:
|
||||
model.api_key = SecretService.encrypt(payload.apiKey)
|
||||
model.auth_type = payload.authType
|
||||
model.api_version = payload.apiVersion
|
||||
model.temperature = payload.temperature
|
||||
model.top_p = payload.topP
|
||||
model.top_k = payload.topK
|
||||
model.presence_penalty = payload.presencePenalty
|
||||
model.frequency_penalty = payload.frequencyPenalty
|
||||
model.max_token = payload.maxToken
|
||||
model.context_window = payload.contextWindow
|
||||
model.stream_enabled = payload.streamEnabled
|
||||
model.response_format = payload.responseFormat
|
||||
model.extra_params = payload.extraParams
|
||||
model.remark = payload.remark
|
||||
model.timeout_second = payload.timeoutSecond
|
||||
db.add(model)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="update", target_id=model.id)
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
return api_success(_model_dict(model))
|
||||
|
||||
|
||||
@router.post("/model/enable")
|
||||
def enable_model(
|
||||
payload: EnableModelRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
target = db.get(ModelConfig, payload.modelId)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
|
||||
for model in db.scalars(select(ModelConfig)).all():
|
||||
model.enabled = 1 if model.id == target.id else 0
|
||||
db.add(model)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="enable", target_id=target.id)
|
||||
db.commit()
|
||||
return api_success()
|
||||
|
||||
|
||||
@router.delete("/model/{model_id}")
|
||||
def delete_model(
|
||||
model_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
model = db.get(ModelConfig, model_id)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
|
||||
db.delete(model)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="delete", target_id=model.id)
|
||||
db.commit()
|
||||
return api_success()
|
||||
|
||||
|
||||
@router.post("/model/{model_id}/test")
|
||||
def test_model(
|
||||
model_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
model = db.get(ModelConfig, model_id)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
|
||||
result = ModelClientService.test_model(model)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="test", target_id=model.id)
|
||||
db.commit()
|
||||
return api_success(result)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def list_config(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
configs = db.scalars(select(SystemConfig).order_by(SystemConfig.config_key.asc())).all()
|
||||
return api_success([_config_dict(config) for config in configs])
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
def save_config(
|
||||
payload: SystemConfigSaveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == payload.configKey))
|
||||
if config is None:
|
||||
config = SystemConfig(config_key=payload.configKey, config_value=payload.configValue)
|
||||
if payload.configKey in SENSITIVE_CONFIG_KEYS:
|
||||
if payload.configValue == MASKED_SECRET:
|
||||
return api_success(_config_dict(config))
|
||||
config.config_value = SecretService.encrypt(payload.configValue)
|
||||
else:
|
||||
config.config_value = payload.configValue
|
||||
config.description = payload.description
|
||||
config.updated_by = current_admin.id
|
||||
db.add(config)
|
||||
db.flush()
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="config", action="save", target_id=config.id)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
return api_success(_config_dict(config))
|
||||
|
||||
|
||||
def _model_dict(model: ModelConfig) -> dict:
|
||||
return {
|
||||
"id": model.id,
|
||||
"provider": model.provider,
|
||||
"displayName": model.display_name,
|
||||
"apiType": model.api_type,
|
||||
"modelName": model.model_name,
|
||||
"baseUrl": model.base_url,
|
||||
"apiUrl": model.api_url,
|
||||
"apiKeyMasked": SecretService.masked(model.api_key),
|
||||
"authType": model.auth_type,
|
||||
"apiVersion": model.api_version,
|
||||
"temperature": float(model.temperature) if model.temperature is not None else None,
|
||||
"topP": float(model.top_p) if model.top_p is not None else None,
|
||||
"topK": model.top_k,
|
||||
"presencePenalty": float(model.presence_penalty) if model.presence_penalty is not None else None,
|
||||
"frequencyPenalty": float(model.frequency_penalty) if model.frequency_penalty is not None else None,
|
||||
"maxToken": model.max_token,
|
||||
"contextWindow": model.context_window,
|
||||
"streamEnabled": model.stream_enabled,
|
||||
"responseFormat": model.response_format,
|
||||
"extraParams": model.extra_params,
|
||||
"remark": model.remark,
|
||||
"timeoutSecond": model.timeout_second,
|
||||
"enabled": model.enabled,
|
||||
}
|
||||
|
||||
|
||||
def _enabled_model(db: Session) -> ModelConfig | None:
|
||||
return db.scalar(
|
||||
select(ModelConfig)
|
||||
.where(ModelConfig.enabled == 1)
|
||||
.order_by(ModelConfig.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
|
||||
def _agent_runtime_config_dict(model: ModelConfig | None) -> dict:
|
||||
return {
|
||||
"modelId": model.id if model is not None else None,
|
||||
"modelName": (model.display_name or model.model_name) if model is not None else None,
|
||||
"temperature": float(model.temperature) if model is not None and model.temperature is not None else None,
|
||||
"topP": float(model.top_p) if model is not None and model.top_p is not None else None,
|
||||
"topK": model.top_k if model is not None else None,
|
||||
"presencePenalty": float(model.presence_penalty) if model is not None and model.presence_penalty is not None else None,
|
||||
"frequencyPenalty": float(model.frequency_penalty) if model is not None and model.frequency_penalty is not None else None,
|
||||
"maxToken": model.max_token if model is not None and model.max_token is not None else 8192,
|
||||
}
|
||||
|
||||
|
||||
def _default_prompt_detail() -> dict:
|
||||
return {
|
||||
"id": None,
|
||||
"promptContent": DEFAULT_PROMPT,
|
||||
"changeType": "default",
|
||||
"sourcePromptId": None,
|
||||
"updatedByName": "系统默认",
|
||||
"updatedAt": None,
|
||||
"charCount": len(DEFAULT_PROMPT),
|
||||
}
|
||||
|
||||
|
||||
def _prompt_summary(prompt: Prompt, admin: Admin | None, *, is_current: bool) -> dict:
|
||||
compact = " ".join(prompt.prompt_content.split())
|
||||
return {
|
||||
"id": prompt.id,
|
||||
"preview": compact[:140],
|
||||
"charCount": len(prompt.prompt_content),
|
||||
"changeType": prompt.change_type,
|
||||
"sourcePromptId": prompt.source_prompt_id,
|
||||
"updatedByName": admin.name if admin else "未知管理员",
|
||||
"updatedAt": prompt.updated_at,
|
||||
"isCurrent": is_current,
|
||||
}
|
||||
|
||||
|
||||
def _prompt_detail(prompt: Prompt, admin: Admin | None = None) -> dict:
|
||||
return {
|
||||
"id": prompt.id,
|
||||
"promptContent": prompt.prompt_content,
|
||||
"changeType": prompt.change_type,
|
||||
"sourcePromptId": prompt.source_prompt_id,
|
||||
"updatedByName": admin.name if admin else None,
|
||||
"updatedAt": prompt.updated_at,
|
||||
"charCount": len(prompt.prompt_content),
|
||||
}
|
||||
|
||||
|
||||
def _agent_knowledge_scopes(db: Session, knowledge_ids: list[int]) -> list[KnowledgeScope]:
|
||||
query = select(Knowledge).where(Knowledge.status == 1)
|
||||
if knowledge_ids:
|
||||
query = query.where(Knowledge.id.in_(knowledge_ids))
|
||||
rows = db.scalars(query.order_by(Knowledge.id.asc())).all()
|
||||
return [
|
||||
KnowledgeScope(
|
||||
id=item.id,
|
||||
name=item.name,
|
||||
feishu_space_id=item.feishu_space_id,
|
||||
feishu_node_id=item.feishu_node_id,
|
||||
)
|
||||
for item in rows
|
||||
]
|
||||
|
||||
|
||||
def _build_agent_debug_prompt(prompt_content: str, question: str, chunks: list) -> str:
|
||||
context = "\n\n".join(
|
||||
f"[知识片段 {index}] 来源:{chunk.knowledge_name} / {chunk.title}\n{chunk.content}"
|
||||
for index, chunk in enumerate(chunks, start=1)
|
||||
)
|
||||
if not context:
|
||||
context = "未检索到相关知识片段。"
|
||||
return f"{prompt_content.strip()}\n\n{context}\n\n用户问题:{question.strip()}"
|
||||
|
||||
|
||||
@router.post("/feishu/cache/clear")
|
||||
def clear_feishu_cache(current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||
count = FeishuKnowledgeService.clear_cache()
|
||||
return api_success({"cleared": count, "message": f"已清空 {count} 条飞书缓存"})
|
||||
|
||||
|
||||
def _config_dict(config: SystemConfig) -> dict:
|
||||
value = config.config_value
|
||||
if config.config_key in SENSITIVE_CONFIG_KEYS:
|
||||
value = SecretService.masked(value)
|
||||
return {
|
||||
"id": config.id,
|
||||
"configKey": config.config_key,
|
||||
"configValue": value,
|
||||
"description": config.description,
|
||||
"updatedAt": config.updated_at,
|
||||
}
|
||||
386
ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
Normal file
386
ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
Normal file
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from io import BytesIO
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.user import User
|
||||
from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.api.pagination import page_result
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STUDENT_TEMPLATE_HEADERS = ["手机号", "姓名", "昵称", "每日聊天额度", "状态", "有效期"]
|
||||
|
||||
|
||||
@router.get("/user/list")
|
||||
def list_users(
|
||||
keyword: str = Query(default=""),
|
||||
page: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=20, ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
query = select(User).where(User.is_deleted == 0).order_by(User.id.desc())
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.where((User.phone.like(like)) | (User.name.like(like)))
|
||||
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
||||
users = db.scalars(query.offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||
return api_success(page_result([_user_dict(user) for user in users], total=total, page=page, page_size=pageSize))
|
||||
|
||||
|
||||
@router.post("/user")
|
||||
def create_user(
|
||||
payload: AdminUserCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
phone = _normalize_phone(payload.phone)
|
||||
_validate_phone(phone)
|
||||
existing = db.scalar(select(User).where(User.phone == phone))
|
||||
if existing is not None and not existing.is_deleted:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="手机号已存在")
|
||||
|
||||
if existing is not None:
|
||||
user = existing
|
||||
user.is_deleted = 0
|
||||
user.daily_chat_used = 0
|
||||
else:
|
||||
user = User(phone=phone, daily_chat_used=0)
|
||||
|
||||
_apply_user_payload(user, payload, _default_daily_chat_limit(db))
|
||||
db.add(user)
|
||||
db.flush()
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="create", target_id=user.id)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return api_success(_user_dict(user))
|
||||
|
||||
|
||||
@router.post("/user/import")
|
||||
def import_users(
|
||||
payload: AdminUserImportRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
if not payload.students:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请提供学员数据")
|
||||
|
||||
return api_success(_import_user_items(list(enumerate(payload.students, start=1)), db, current_admin))
|
||||
|
||||
|
||||
@router.get("/user/import/template")
|
||||
def download_user_import_template(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> StreamingResponse:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "学员导入模板"
|
||||
sheet.append(STUDENT_TEMPLATE_HEADERS)
|
||||
sheet.append(["13800000001", "张三", "三三", _default_daily_chat_limit(db), "启用", "2026-12-31 23:59:59"])
|
||||
sheet.append(["13800000002", "李四", "", "", "启用", ""])
|
||||
|
||||
widths = [18, 14, 14, 16, 12, 22]
|
||||
for index, width in enumerate(widths, start=1):
|
||||
sheet.column_dimensions[chr(64 + index)].width = width
|
||||
|
||||
stream = BytesIO()
|
||||
workbook.save(stream)
|
||||
stream.seek(0)
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="student_import_template.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/user/import/excel")
|
||||
def import_users_excel(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
filename = (file.filename or "").lower()
|
||||
if not filename.endswith(".xlsx"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .xlsx 格式的 Excel 文件")
|
||||
|
||||
content = file.file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件为空")
|
||||
|
||||
try:
|
||||
workbook = load_workbook(BytesIO(content), data_only=True)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件解析失败") from exc
|
||||
|
||||
sheet = workbook.active
|
||||
header_map = _excel_header_map(next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), ()))
|
||||
for required in ("手机号", "姓名"):
|
||||
if required not in header_map:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"模板缺少必填列:{required}")
|
||||
|
||||
students: list[tuple[int, AdminUserImportItem]] = []
|
||||
parse_failures: list[dict] = []
|
||||
for row_number, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
|
||||
if not any(_cell_text(cell) for cell in row):
|
||||
continue
|
||||
try:
|
||||
item = AdminUserImportItem(
|
||||
phone=_cell_text(_excel_value(row, header_map, "手机号")),
|
||||
name=_cell_text(_excel_value(row, header_map, "姓名")),
|
||||
nickname=_cell_text(_excel_value(row, header_map, "昵称")) or None,
|
||||
dailyChatLimit=_parse_optional_int(_excel_value(row, header_map, "每日聊天额度")),
|
||||
status=_parse_status(_excel_value(row, header_map, "状态")),
|
||||
expiredAt=_parse_optional_datetime(_excel_value(row, header_map, "有效期")),
|
||||
)
|
||||
students.append((row_number, item))
|
||||
except (ValueError, ValidationError) as exc:
|
||||
parse_failures.append(
|
||||
{
|
||||
"row": row_number,
|
||||
"phone": _cell_text(_excel_value(row, header_map, "手机号")),
|
||||
"reason": _row_error_message(exc),
|
||||
}
|
||||
)
|
||||
|
||||
if not students and not parse_failures:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 中没有可导入的学员数据")
|
||||
|
||||
result = _import_user_items(students, db, current_admin, parse_failures)
|
||||
return api_success(result)
|
||||
|
||||
|
||||
def _import_user_items(
|
||||
students: list[tuple[int, AdminUserImportItem]],
|
||||
db: Session,
|
||||
current_admin: Admin,
|
||||
initial_failures: list[dict] | None = None,
|
||||
) -> dict:
|
||||
created = 0
|
||||
updated = 0
|
||||
failed: list[dict] = list(initial_failures or [])
|
||||
seen: set[str] = set()
|
||||
default_daily_limit = _default_daily_chat_limit(db)
|
||||
|
||||
for row_number, item in students:
|
||||
phone = _normalize_phone(item.phone)
|
||||
name = item.name.strip()
|
||||
try:
|
||||
_validate_phone(phone)
|
||||
if not name:
|
||||
raise ValueError("姓名不能为空")
|
||||
if phone in seen:
|
||||
raise ValueError("手机号在本次导入中重复")
|
||||
seen.add(phone)
|
||||
|
||||
user = db.scalar(select(User).where(User.phone == phone))
|
||||
if user is None:
|
||||
user = User(phone=phone, daily_chat_used=0)
|
||||
created += 1
|
||||
else:
|
||||
user.is_deleted = 0
|
||||
updated += 1
|
||||
_apply_user_payload(user, item, default_daily_limit)
|
||||
db.add(user)
|
||||
except ValueError as exc:
|
||||
failed.append({"row": row_number, "phone": item.phone, "reason": str(exc)})
|
||||
|
||||
if created or updated:
|
||||
db.flush()
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
module="user",
|
||||
action="import",
|
||||
target_id=None,
|
||||
result="SUCCESS",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {"created": created, "updated": updated, "failed": len(failed), "failures": failed}
|
||||
|
||||
|
||||
@router.get("/user/{user_id}")
|
||||
def user_detail(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = _get_user(db, user_id)
|
||||
return api_success(_user_dict(user))
|
||||
|
||||
|
||||
@router.put("/user/{user_id}")
|
||||
def update_user(
|
||||
user_id: int,
|
||||
payload: AdminUserUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = _get_user(db, user_id)
|
||||
if payload.status is not None:
|
||||
user.status = payload.status
|
||||
if payload.dailyChatLimit is not None:
|
||||
user.daily_chat_limit = payload.dailyChatLimit
|
||||
if payload.expiredAt is not None:
|
||||
user.expired_at = payload.expiredAt.replace(tzinfo=None)
|
||||
db.add(user)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="update", target_id=user.id)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return api_success(_user_dict(user))
|
||||
|
||||
|
||||
@router.delete("/user/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = _get_user(db, user_id)
|
||||
user.is_deleted = 1
|
||||
db.add(user)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="delete", target_id=user.id)
|
||||
db.commit()
|
||||
return api_success()
|
||||
|
||||
|
||||
def _get_user(db: Session, user_id: int) -> User:
|
||||
user = db.get(User, user_id)
|
||||
if user is None or user.is_deleted:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
def _user_dict(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"phone": user.phone,
|
||||
"name": user.name,
|
||||
"nickname": user.nickname,
|
||||
"status": user.status,
|
||||
"dailyChatLimit": user.daily_chat_limit,
|
||||
"dailyChatUsed": user.daily_chat_used,
|
||||
"expiredAt": user.expired_at,
|
||||
"lastLoginAt": user.last_login_at,
|
||||
"createdAt": user.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _apply_user_payload(
|
||||
user: User,
|
||||
payload: AdminUserCreateRequest | AdminUserImportItem,
|
||||
default_daily_limit: int,
|
||||
) -> None:
|
||||
user.name = payload.name.strip()
|
||||
user.nickname = payload.nickname.strip() if payload.nickname else None
|
||||
user.status = payload.status
|
||||
user.daily_chat_limit = payload.dailyChatLimit if payload.dailyChatLimit is not None else default_daily_limit
|
||||
user.expired_at = payload.expiredAt.replace(tzinfo=None) if payload.expiredAt is not None else None
|
||||
|
||||
|
||||
def _default_daily_chat_limit(db: Session) -> int:
|
||||
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == "daily_chat_limit"))
|
||||
if config is not None and config.config_value.strip():
|
||||
try:
|
||||
return max(0, min(100000, int(float(config.config_value.strip()))))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
from app.core.config import get_settings
|
||||
|
||||
return get_settings().default_daily_chat_limit
|
||||
except Exception:
|
||||
return 100
|
||||
|
||||
|
||||
def _excel_header_map(headers: tuple) -> dict[str, int]:
|
||||
return {_cell_text(header): index for index, header in enumerate(headers) if _cell_text(header)}
|
||||
|
||||
|
||||
def _excel_value(row: tuple, header_map: dict[str, int], header: str) -> object | None:
|
||||
index = header_map.get(header)
|
||||
if index is None or index >= len(row):
|
||||
return None
|
||||
return row[index]
|
||||
|
||||
|
||||
def _cell_text(value: object | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _parse_optional_int(value: object | None) -> int | None:
|
||||
text = _cell_text(value)
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
parsed = int(float(text))
|
||||
except ValueError as exc:
|
||||
raise ValueError("每日聊天额度必须是数字") from exc
|
||||
if parsed < 0 or parsed > 100000:
|
||||
raise ValueError("每日聊天额度必须在 0 到 100000 之间")
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_status(value: object | None) -> int:
|
||||
text = _cell_text(value).lower()
|
||||
if not text:
|
||||
return 1
|
||||
if text in {"1", "启用", "正常", "是", "true", "enabled"}:
|
||||
return 1
|
||||
if text in {"0", "禁用", "停用", "否", "false", "disabled"}:
|
||||
return 0
|
||||
raise ValueError("状态只能填写启用/禁用或 1/0")
|
||||
|
||||
|
||||
def _parse_optional_datetime(value: object | None) -> datetime | None:
|
||||
if value is None or _cell_text(value) == "":
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=None)
|
||||
if isinstance(value, date):
|
||||
return datetime.combine(value, datetime.min.time())
|
||||
|
||||
text = _cell_text(value)
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError("有效期格式应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss")
|
||||
|
||||
|
||||
def _row_error_message(exc: ValueError | ValidationError) -> str:
|
||||
if isinstance(exc, ValidationError):
|
||||
return "字段格式不正确"
|
||||
return str(exc)
|
||||
|
||||
|
||||
def _normalize_phone(phone: str) -> str:
|
||||
return re.sub(r"\D", "", phone or "")
|
||||
|
||||
|
||||
def _validate_phone(phone: str) -> None:
|
||||
if not re.fullmatch(r"1[3-9]\d{9}", phone):
|
||||
raise ValueError("手机号格式不正确")
|
||||
37
ai_knowledge_base_v2/apps/backend/app/api/auth.py
Normal file
37
ai_knowledge_base_v2/apps/backend/app/api/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_token_payload
|
||||
from app.core.responses import api_success
|
||||
from app.schemas.auth import CaptchaResponse, LoginRequest, LoginResponse, SendSmsRequest
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.captcha_service import CaptchaService
|
||||
from app.services.security_state_service import client_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/captcha")
|
||||
def captcha() -> dict:
|
||||
return api_success(CaptchaResponse.model_validate(CaptchaService.create()).model_dump())
|
||||
|
||||
|
||||
@router.post("/sms/send")
|
||||
def send_sms(payload: SendSmsRequest, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
AuthService.send_sms_code(db, payload.phone, payload.captchaId, payload.captchaCode, client_ip(request))
|
||||
return api_success(message="发送成功")
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
|
||||
result = AuthService.login_with_sms(db, payload.phone, payload.code)
|
||||
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
|
||||
AuthService.logout(token_payload)
|
||||
return api_success()
|
||||
175
ai_knowledge_base_v2/apps/backend/app/api/chat.py
Normal file
175
ai_knowledge_base_v2/apps/backend/app/api/chat.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.core.responses import api_success
|
||||
from app.models.user import User
|
||||
from app.schemas.chat import (
|
||||
ChatCompletionRequest,
|
||||
ChatMessageRead,
|
||||
ChatSessionRead,
|
||||
CreateSessionResponse,
|
||||
StopChatRequest,
|
||||
UpdateSessionTitleRequest,
|
||||
)
|
||||
from app.services.chat_service import ChatService
|
||||
from app.services.chat_queue_runtime import (
|
||||
cancel_chat_waiter,
|
||||
release_chat_slot,
|
||||
request_chat_slot,
|
||||
wait_for_chat_slot,
|
||||
)
|
||||
from app.services.chat_queue_service import load_chat_queue_config
|
||||
from app.services.chat_stream_service import ChatStreamService
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/session")
|
||||
def create_session(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
|
||||
session = ChatService.create_session(db, current_user)
|
||||
return api_success(CreateSessionResponse(sessionId=session.id).model_dump())
|
||||
|
||||
|
||||
@router.get("/session/list")
|
||||
def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
|
||||
sessions = ChatService.list_sessions(db, current_user)
|
||||
return api_success([ChatSessionRead.model_validate(session).model_dump() for session in sessions])
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def history(
|
||||
sessionId: int = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
messages = ChatService.get_history(db, current_user, sessionId)
|
||||
return api_success([ChatMessageRead.model_validate(message).model_dump(mode="json") for message in messages])
|
||||
|
||||
|
||||
@router.put("/session/title")
|
||||
def update_title(
|
||||
payload: UpdateSessionTitleRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
session = ChatService.update_title(db, current_user, payload.sessionId, payload.title)
|
||||
return api_success(ChatSessionRead.model_validate(session).model_dump())
|
||||
|
||||
|
||||
@router.delete("/session/{session_id}")
|
||||
def delete_session(
|
||||
session_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
ChatService.delete_session(db, current_user, session_id)
|
||||
return api_success()
|
||||
|
||||
|
||||
@router.post("/completions")
|
||||
def completions(
|
||||
payload: ChatCompletionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> StreamingResponse:
|
||||
return StreamingResponse(_chat_stream(payload, db, current_user), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
def stop(
|
||||
payload: StopChatRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
ChatService.stop_generation(db, current_user, payload.sessionId)
|
||||
return api_success()
|
||||
|
||||
|
||||
async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user: User) -> AsyncIterator[str]:
|
||||
config = load_chat_queue_config(db)
|
||||
queue_lease = await request_chat_slot(config)
|
||||
queue_request = queue_lease.request
|
||||
acquired = queue_request.status == "acquired"
|
||||
should_cancel_waiter = queue_request.status == "queued"
|
||||
|
||||
if queue_request.status == "rejected":
|
||||
yield _sse_event(
|
||||
"error",
|
||||
message="当前请求过多,请稍后再试。",
|
||||
activeCount=queue_request.active_count,
|
||||
waitingCount=queue_request.waiting_count,
|
||||
)
|
||||
yield _sse_done()
|
||||
return
|
||||
|
||||
if queue_request.status == "queued" and queue_request.token is not None:
|
||||
yield _sse_event(
|
||||
"queued",
|
||||
message=f"当前请求较多,正在排队中,前方约 {max(queue_request.position - 1, 0)} 个请求。",
|
||||
position=queue_request.position,
|
||||
activeCount=queue_request.active_count,
|
||||
waitingCount=queue_request.waiting_count,
|
||||
)
|
||||
queue_lease = await wait_for_chat_slot(queue_lease, config)
|
||||
queue_request = queue_lease.request
|
||||
acquired = queue_request.status == "acquired"
|
||||
should_cancel_waiter = queue_request.status == "queued"
|
||||
if queue_request.status == "rejected":
|
||||
yield _sse_event(
|
||||
"error",
|
||||
message="当前请求过多,请稍后再试。",
|
||||
activeCount=queue_request.active_count,
|
||||
waitingCount=queue_request.waiting_count,
|
||||
)
|
||||
yield _sse_done()
|
||||
return
|
||||
if queue_request.status == "timeout":
|
||||
yield _sse_event("error", message="排队等待超时,请稍后再试。")
|
||||
yield _sse_done()
|
||||
return
|
||||
if queue_request.status == "canceled":
|
||||
yield _sse_event("error", message="排队请求已取消,请稍后再试。")
|
||||
yield _sse_done()
|
||||
return
|
||||
|
||||
try:
|
||||
yield _sse_event(
|
||||
"generating",
|
||||
message="已进入生成队列,正在生成回答。",
|
||||
activeCount=queue_request.active_count,
|
||||
waitingCount=queue_request.waiting_count,
|
||||
)
|
||||
async for chunk in ChatStreamService.stream_answer_async(db, current_user, payload.sessionId, payload.message):
|
||||
yield _sse_event("content", content=chunk)
|
||||
except HTTPException as exc:
|
||||
yield _sse_event("error", message=str(exc.detail))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Chat stream failed")
|
||||
yield _sse_event("error", message="AI 回复生成失败,请稍后再试。")
|
||||
finally:
|
||||
if acquired:
|
||||
await release_chat_slot(queue_lease)
|
||||
elif should_cancel_waiter:
|
||||
await cancel_chat_waiter(queue_lease)
|
||||
|
||||
yield _sse_done()
|
||||
|
||||
def _sse_event(event_type: str, **payload: object) -> str:
|
||||
return f"data: {json.dumps({'type': event_type, **payload}, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def _sse_done() -> str:
|
||||
return "data: [DONE]\n\n"
|
||||
45
ai_knowledge_base_v2/apps/backend/app/api/health.py
Normal file
45
ai_knowledge_base_v2/apps/backend/app/api/health.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.responses import api_success
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict:
|
||||
return api_success({"status": "ok"})
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
def ready():
|
||||
checks = {"database": _database_ready(), "redis": _redis_ready()}
|
||||
redis_required = get_settings().readiness_requires_redis
|
||||
ready_state = checks["database"] and (checks["redis"] or not redis_required)
|
||||
payload = api_success({"status": "ready" if ready_state else "not_ready", "checks": checks})
|
||||
return payload if ready_state else JSONResponse(status_code=503, content=payload)
|
||||
|
||||
|
||||
def _database_ready() -> bool:
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _redis_ready() -> bool:
|
||||
client = get_sync_redis_client()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
return bool(client.ping())
|
||||
except Exception:
|
||||
return False
|
||||
7
ai_knowledge_base_v2/apps/backend/app/api/pagination.py
Normal file
7
ai_knowledge_base_v2/apps/backend/app/api/pagination.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def page_result(items: list[Any], *, total: int, page: int, page_size: int) -> dict[str, Any]:
|
||||
return {"items": items, "total": total, "page": page, "pageSize": page_size}
|
||||
32
ai_knowledge_base_v2/apps/backend/app/api/router.py
Normal file
32
ai_knowledge_base_v2/apps/backend/app/api/router.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import (
|
||||
admin_auth,
|
||||
admin_agent_records,
|
||||
admin_dashboard,
|
||||
admin_knowledge,
|
||||
admin_knowledge_lifecycle,
|
||||
admin_records,
|
||||
admin_settings,
|
||||
admin_users,
|
||||
auth,
|
||||
chat,
|
||||
health,
|
||||
user,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(user.router, prefix="/user", tags=["user"])
|
||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"])
|
||||
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"])
|
||||
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"])
|
||||
15
ai_knowledge_base_v2/apps/backend/app/api/user.py
Normal file
15
ai_knowledge_base_v2/apps/backend/app/api/user.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.core.responses import api_success
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserProfile
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
def profile(current_user: User = Depends(get_current_user)) -> dict:
|
||||
return api_success(UserProfile.model_validate(current_user).model_dump(mode="json"))
|
||||
1
ai_knowledge_base_v2/apps/backend/app/core/__init__.py
Normal file
1
ai_knowledge_base_v2/apps/backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
73
ai_knowledge_base_v2/apps/backend/app/core/config.py
Normal file
73
ai_knowledge_base_v2/apps/backend/app/core/config.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
app_name: str = "AI Knowledge Base V2 Backend"
|
||||
app_env: str = "local"
|
||||
debug: bool = True
|
||||
log_level: str = "INFO"
|
||||
api_prefix: str = "/api"
|
||||
cors_origins: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:5174",
|
||||
"http://127.0.0.1:5174",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
)
|
||||
|
||||
database_url: str = "mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4"
|
||||
redis_url: str = ""
|
||||
readiness_requires_redis: bool = True
|
||||
db_pool_size: int = 10
|
||||
db_max_overflow: int = 20
|
||||
db_pool_timeout: int = 30
|
||||
db_pool_recycle: int = 1800
|
||||
auto_create_tables: bool = False
|
||||
|
||||
jwt_secret_key: str = "change-me-in-production"
|
||||
config_encryption_key: str = ""
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 * 30
|
||||
|
||||
mock_sms_enabled: bool = True
|
||||
mock_sms_code: str = "123456"
|
||||
sms_code_expire_minutes: int = 5
|
||||
aliyun_sms_access_key_id: str = ""
|
||||
aliyun_sms_access_key_secret: str = ""
|
||||
aliyun_sms_sign_name: str = ""
|
||||
aliyun_sms_template_code: str = ""
|
||||
aliyun_sms_template_param_key: str = "code"
|
||||
aliyun_sms_endpoint: str = "dysmsapi.aliyuncs.com"
|
||||
mock_rag_enabled: bool = False
|
||||
mock_model_enabled: bool = True
|
||||
feishu_mock_enabled: bool = False
|
||||
feishu_search_url: str = ""
|
||||
feishu_app_id: str = ""
|
||||
feishu_app_secret: str = ""
|
||||
feishu_timeout_seconds: int = 20
|
||||
feishu_retry_count: int = 2
|
||||
|
||||
default_daily_chat_limit: int = 100
|
||||
default_user_name_prefix: str = "用户"
|
||||
chat_max_active_requests: int = 2
|
||||
chat_max_queue_size: int = 20
|
||||
chat_queue_timeout_seconds: int = 60
|
||||
chat_active_lease_seconds: int = 900
|
||||
bootstrap_admin_username: str = ""
|
||||
bootstrap_admin_password: str = ""
|
||||
bootstrap_admin_name: str = "系统管理员"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
36
ai_knowledge_base_v2/apps/backend/app/core/database.py
Normal file
36
ai_knowledge_base_v2/apps/backend/app/core/database.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models import Base
|
||||
from app.services.knowledge_catalog_cache_service import register_knowledge_cache_invalidation_events
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine_options = {"pool_pre_ping": True, "future": True}
|
||||
if not settings.database_url.startswith("sqlite"):
|
||||
engine_options.update(
|
||||
pool_size=settings.db_pool_size,
|
||||
max_overflow=settings.db_max_overflow,
|
||||
pool_timeout=settings.db_pool_timeout,
|
||||
pool_recycle=settings.db_pool_recycle,
|
||||
)
|
||||
engine = create_engine(settings.database_url, **engine_options)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
register_knowledge_cache_invalidation_events()
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def create_tables() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
59
ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
Normal file
59
ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_access_token
|
||||
from app.models.admin import Admin
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=True)
|
||||
|
||||
|
||||
def get_current_token_payload(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if AuthService.is_token_revoked(payload):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已退出")
|
||||
return payload
|
||||
|
||||
|
||||
def get_current_user(
|
||||
payload: dict = Depends(get_current_token_payload),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
if payload.get("type") != "user":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前凭证不是用户登录态")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
user = db.get(User, int(user_id)) if user_id else None
|
||||
if user is None or user.is_deleted:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||
if user.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
if user.effective_at and user.effective_at > now:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号尚未生效")
|
||||
if user.expired_at and user.expired_at < now:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期")
|
||||
return user
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
payload: dict = Depends(get_current_token_payload),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Admin:
|
||||
if payload.get("type") != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前凭证不是管理员登录态")
|
||||
|
||||
admin_id = payload.get("sub")
|
||||
admin = db.get(Admin, int(admin_id)) if admin_id else None
|
||||
if admin is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员不存在")
|
||||
if admin.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
|
||||
return admin
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.responses import api_error
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
return JSONResponse(status_code=exc.status_code, content=api_error(_code_from_status(exc.status_code), str(exc.detail)))
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
return JSONResponse(status_code=422, content=api_error(10000, "请求参数不正确", exc.errors()))
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
return JSONResponse(status_code=500, content=api_error(50000, "系统内部错误,请稍后重试"))
|
||||
|
||||
|
||||
def _code_from_status(status_code: int) -> int:
|
||||
if status_code in {401, 403}:
|
||||
return 20000 if status_code == 401 else 40000
|
||||
if status_code == 400:
|
||||
return 10000
|
||||
return 50000
|
||||
99
ai_knowledge_base_v2/apps/backend/app/core/observability.py
Normal file
99
ai_knowledge_base_v2/apps/backend/app/core/observability.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import logging
|
||||
from logging.config import dictConfig
|
||||
from time import perf_counter
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
request_id_context: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"request_id": getattr(record, "request_id", None) or request_id_context.get(),
|
||||
}
|
||||
for key in ("method", "path", "status_code", "duration_ms", "client_ip"):
|
||||
value = getattr(record, key, None)
|
||||
if value is not None:
|
||||
payload[key] = value
|
||||
if record.exc_info:
|
||||
payload["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO") -> None:
|
||||
dictConfig(
|
||||
{
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {"json": {"()": JsonFormatter}},
|
||||
"handlers": {"console": {"class": "logging.StreamHandler", "formatter": "json", "stream": "ext://sys.stdout"}},
|
||||
"loggers": {
|
||||
"app": {"handlers": ["console"], "level": level, "propagate": False},
|
||||
"app.access": {"handlers": ["console"], "level": level, "propagate": False},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RequestObservabilityMiddleware:
|
||||
def __init__(self, app) -> None:
|
||||
self.app = app
|
||||
self.logger = logging.getLogger("app.access")
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = {key.lower(): value for key, value in scope.get("headers", [])}
|
||||
incoming_id = headers.get(b"x-request-id", b"").decode("ascii", errors="ignore").strip()
|
||||
request_id = incoming_id[:64] if incoming_id else uuid4().hex
|
||||
token = request_id_context.set(request_id)
|
||||
started_at = perf_counter()
|
||||
status_code = 500
|
||||
response_finished = False
|
||||
|
||||
async def send_with_request_id(message) -> None:
|
||||
nonlocal status_code, response_finished
|
||||
if message["type"] == "http.response.start":
|
||||
status_code = int(message["status"])
|
||||
message.setdefault("headers", []).append((b"x-request-id", request_id.encode("ascii")))
|
||||
elif message["type"] == "http.response.body" and not message.get("more_body", False):
|
||||
response_finished = True
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send_with_request_id)
|
||||
except Exception:
|
||||
self._log(scope, request_id, 500, started_at, logging.ERROR, "request_failed", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
if response_finished:
|
||||
self._log(scope, request_id, status_code, started_at, logging.INFO, "request_completed")
|
||||
request_id_context.reset(token)
|
||||
|
||||
def _log(self, scope, request_id: str, status_code: int, started_at: float, level: int, message: str, **kwargs) -> None:
|
||||
client = scope.get("client")
|
||||
self.logger.log(
|
||||
level,
|
||||
message,
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": scope.get("method"),
|
||||
"path": scope.get("path"),
|
||||
"status_code": status_code,
|
||||
"duration_ms": round((perf_counter() - started_at) * 1000, 2),
|
||||
"client_ip": client[0] if client else "unknown",
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
11
ai_knowledge_base_v2/apps/backend/app/core/responses.py
Normal file
11
ai_knowledge_base_v2/apps/backend/app/core/responses.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def api_success(data: Any = None, message: str = "success") -> dict[str, Any]:
|
||||
return {"code": 0, "message": message, "data": data}
|
||||
|
||||
|
||||
def api_error(code: int, message: str, data: Any = None) -> dict[str, Any]:
|
||||
return {"code": code, "message": message, "data": data}
|
||||
53
ai_knowledge_base_v2/apps/backend/app/core/security.py
Normal file
53
ai_knowledge_base_v2/apps/backend/app/core/security.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
from uuid import uuid4
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
def create_access_token(subject: str, token_type: str = "user") -> tuple[str, datetime]:
|
||||
settings = get_settings()
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"type": token_type,
|
||||
"jti": uuid4().hex,
|
||||
"exp": expires_at,
|
||||
"iat": datetime.now(UTC),
|
||||
}
|
||||
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||
return token, expires_at
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
settings = get_settings()
|
||||
try:
|
||||
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效登录凭证") from exc
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = os.urandom(16).hex()
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000)
|
||||
return f"pbkdf2_sha256${salt}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
algorithm, salt, expected = password_hash.split("$", 2)
|
||||
except ValueError:
|
||||
return False
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000)
|
||||
return hmac.compare_digest(digest.hex(), expected)
|
||||
55
ai_knowledge_base_v2/apps/backend/app/main.py
Normal file
55
ai_knowledge_base_v2/apps/backend/app/main.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import create_tables
|
||||
from app.core.exception_handlers import register_exception_handlers
|
||||
from app.core.observability import RequestObservabilityMiddleware, configure_logging
|
||||
from app.services.secret_service import SecretService
|
||||
from app.services.maintenance_service import MaintenanceService
|
||||
import asyncio
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
SecretService.validate_production_key()
|
||||
if settings.auto_create_tables:
|
||||
create_tables()
|
||||
maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
maintenance_task.cancel()
|
||||
try:
|
||||
await maintenance_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level)
|
||||
|
||||
app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
|
||||
register_exception_handlers(app)
|
||||
app.add_middleware(RequestObservabilityMiddleware)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix=settings.api_prefix)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict[str, str]:
|
||||
return {"name": settings.app_name, "status": "ok"}
|
||||
54
ai_knowledge_base_v2/apps/backend/app/models/__init__.py
Normal file
54
ai_knowledge_base_v2/apps/backend/app/models/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from app.models.admin import Admin, Role
|
||||
from app.models.ai_config import ModelConfig, Prompt, SystemConfig
|
||||
from app.models.base import Base
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.knowledge import (
|
||||
HumanAttentionHistory,
|
||||
HumanAttentionRecord,
|
||||
Knowledge,
|
||||
KnowledgeCard,
|
||||
KnowledgeChunk,
|
||||
KnowledgeManifest,
|
||||
KnowledgePublishLog,
|
||||
KnowledgeRetrievalCandidate,
|
||||
KnowledgeRetrievalLog,
|
||||
KnowledgeReviewRecord,
|
||||
KnowledgeSection,
|
||||
KnowledgeSourceSnapshot,
|
||||
KnowledgeSyncJob,
|
||||
KnowledgeVersion,
|
||||
UserKnowledgePermission,
|
||||
)
|
||||
from app.models.logs import AiRequestLog, LogRetentionPolicy, OperationLog, StorageSnapshot
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"Admin",
|
||||
"AiRequestLog",
|
||||
"Base",
|
||||
"ChatMessage",
|
||||
"ChatSession",
|
||||
"Knowledge",
|
||||
"KnowledgeCard",
|
||||
"KnowledgeChunk",
|
||||
"KnowledgeManifest",
|
||||
"KnowledgePublishLog",
|
||||
"KnowledgeRetrievalCandidate",
|
||||
"KnowledgeRetrievalLog",
|
||||
"KnowledgeReviewRecord",
|
||||
"KnowledgeSection",
|
||||
"KnowledgeSourceSnapshot",
|
||||
"KnowledgeSyncJob",
|
||||
"KnowledgeVersion",
|
||||
"HumanAttentionHistory",
|
||||
"HumanAttentionRecord",
|
||||
"ModelConfig",
|
||||
"OperationLog",
|
||||
"LogRetentionPolicy",
|
||||
"StorageSnapshot",
|
||||
"Prompt",
|
||||
"Role",
|
||||
"SystemConfig",
|
||||
"User",
|
||||
"UserKnowledgePermission",
|
||||
]
|
||||
33
ai_knowledge_base_v2/apps/backend/app/models/admin.py
Normal file
33
ai_knowledge_base_v2/apps/backend/app/models/admin.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class Role(Base, TimestampMixin):
|
||||
__tablename__ = "sys_role"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
admins: Mapped[list["Admin"]] = relationship("Admin", back_populates="role")
|
||||
|
||||
|
||||
class Admin(Base, TimestampMixin):
|
||||
__tablename__ = "sys_admin"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||
password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
role_id: Mapped[int | None] = mapped_column(ForeignKey("sys_role.id"), nullable=True)
|
||||
status: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
role: Mapped[Role | None] = relationship("Role", back_populates="admins")
|
||||
62
ai_knowledge_base_v2/apps/backend/app/models/ai_config.py
Normal file
62
ai_knowledge_base_v2/apps/backend/app/models/ai_config.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Index, Integer, Numeric, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Prompt(Base):
|
||||
__tablename__ = "sys_prompt"
|
||||
__table_args__ = (Index("ix_sys_prompt_updated_at_id", "updated_at", "id"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True
|
||||
)
|
||||
prompt_content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
change_type: Mapped[str] = mapped_column(String(20), default="save", nullable=False)
|
||||
source_prompt_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
|
||||
class ModelConfig(Base):
|
||||
__tablename__ = "sys_model"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
api_type: Mapped[str] = mapped_column(String(50), default="openai_compatible", nullable=False)
|
||||
model_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
base_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
api_url: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
api_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
auth_type: Mapped[str] = mapped_column(String(30), default="bearer", nullable=False)
|
||||
api_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
temperature: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
|
||||
top_p: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
|
||||
top_k: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
presence_penalty: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
|
||||
frequency_penalty: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
|
||||
max_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
context_window: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
stream_enabled: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||
response_format: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
extra_params: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
timeout_second: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
|
||||
enabled: Mapped[int] = mapped_column(default=0, nullable=False)
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "sys_system_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
config_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
config_value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
19
ai_knowledge_base_v2/apps/backend/app/models/base.py
Normal file
19
ai_knowledge_base_v2/apps/backend/app/models/base.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
is_deleted: Mapped[int] = mapped_column(default=0, nullable=False)
|
||||
42
ai_knowledge_base_v2/apps/backend/app/models/chat.py
Normal file
42
ai_knowledge_base_v2/apps/backend/app/models/chat.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class ChatSession(Base, TimestampMixin):
|
||||
__tablename__ = "sys_chat_session"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
summary_up_to_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
message_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_message_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
is_deleted: Mapped[int] = mapped_column(default=0, nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship("User", back_populates="chat_sessions")
|
||||
messages: Mapped[list["ChatMessage"]] = relationship("ChatMessage", back_populates="session")
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "sys_chat_message"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), index=True, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
message_status: Mapped[str] = mapped_column(String(20), default="FINISHED", nullable=False)
|
||||
token_input: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
token_output: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
model_id: Mapped[int | None] = mapped_column(ForeignKey("sys_model.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
session: Mapped[ChatSession] = relationship("ChatSession", back_populates="messages")
|
||||
276
ai_knowledge_base_v2/apps/backend/app/models/knowledge.py
Normal file
276
ai_knowledge_base_v2/apps/backend/app/models/knowledge.py
Normal file
@@ -0,0 +1,276 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class Knowledge(Base, TimestampMixin):
|
||||
__tablename__ = "sys_knowledge"
|
||||
__table_args__ = (UniqueConstraint("feishu_space_id", "feishu_node_id", name="uq_knowledge_feishu_node"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
feishu_space_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
feishu_node_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
status: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
source_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
knowledge_type: Mapped[str] = mapped_column(String(30), default="general", nullable=False)
|
||||
review_mode: Mapped[str] = mapped_column(String(30), default="manual", nullable=False)
|
||||
lifecycle_status: Mapped[str] = mapped_column(String(30), default="active", nullable=False)
|
||||
source_status: Mapped[str] = mapped_column(String(30), default="normal", nullable=False)
|
||||
source_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
manifest_confirmed: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
current_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
pending_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
migration_status: Mapped[str] = mapped_column(String(30), default="pending", nullable=False)
|
||||
|
||||
user_permissions: Mapped[list["UserKnowledgePermission"]] = relationship(
|
||||
"UserKnowledgePermission", back_populates="knowledge"
|
||||
)
|
||||
|
||||
|
||||
class UserKnowledgePermission(Base, TimestampMixin):
|
||||
__tablename__ = "sys_user_kb"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
|
||||
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||
effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
expired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
user: Mapped["User"] = relationship("User", back_populates="knowledge_permissions")
|
||||
knowledge: Mapped[Knowledge] = relationship("Knowledge", back_populates="user_permissions")
|
||||
|
||||
|
||||
class KnowledgeSourceSnapshot(Base):
|
||||
__tablename__ = "sys_knowledge_source_snapshot"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||
sync_job_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
source_title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
source_content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
source_identifier: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
|
||||
class KnowledgeVersion(Base):
|
||||
__tablename__ = "sys_knowledge_version"
|
||||
__table_args__ = (UniqueConstraint("knowledge_id", "version_no", name="uq_kb_version_no"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||
snapshot_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_source_snapshot.id"), nullable=False)
|
||||
version_no: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), index=True, nullable=False)
|
||||
quality_status: Mapped[str] = mapped_column(String(30), default="pending", nullable=False)
|
||||
change_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
quality_report: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
processing_rule_version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
generation_model: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
published_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class KnowledgeManifest(Base):
|
||||
__tablename__ = "sys_knowledge_manifest"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
|
||||
purpose: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
applicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
inapplicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
core_topics: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
entities: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
boundaries: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
change_level: Mapped[str] = mapped_column(String(20), default="major", nullable=False)
|
||||
confirmed: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
confirmed_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class KnowledgeSection(Base):
|
||||
__tablename__ = "sys_knowledge_section"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
|
||||
section_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source_start: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
source_end: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
class KnowledgeChunk(Base):
|
||||
__tablename__ = "sys_knowledge_chunk"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
|
||||
section_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_section.id"), index=True, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
normalized_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
keywords: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
synonyms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_start: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
source_end: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
class KnowledgeCard(Base):
|
||||
__tablename__ = "sys_knowledge_card"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
|
||||
section_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_section.id"), index=True, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
core_conclusion: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
applicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
inapplicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
keywords: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
synonyms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
entities: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
risk_level: Mapped[str] = mapped_column(String(20), default="normal", nullable=False)
|
||||
source_range: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
generation_model: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
generation_rule_version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
review_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
|
||||
|
||||
|
||||
class KnowledgeSyncJob(Base):
|
||||
__tablename__ = "sys_knowledge_sync_job"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||
mode: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), index=True, nullable=False)
|
||||
stage: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
progress: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
source_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
result_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
requested_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class KnowledgeReviewRecord(Base):
|
||||
__tablename__ = "sys_knowledge_review_record"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
|
||||
card_id: Mapped[int | None] = mapped_column(ForeignKey("sys_knowledge_card.id"), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reviewed_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class KnowledgePublishLog(Base):
|
||||
__tablename__ = "sys_knowledge_publish_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||
from_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
to_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
keep_open: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
operated_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class KnowledgeRetrievalLog(Base):
|
||||
__tablename__ = "sys_knowledge_retrieval_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
question: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
catalog_snapshot: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
catalog_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
safety_rule_version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
knowledge_called: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
selected_knowledge_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
query_terms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tool_trace: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
final_section_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
discarded_conflicts: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
final_answer: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
tool_cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
total_cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
attention_created: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class KnowledgeRetrievalCandidate(Base):
|
||||
__tablename__ = "sys_knowledge_retrieval_candidate"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
retrieval_log_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_retrieval_log.id"), index=True, nullable=False)
|
||||
knowledge_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||
version_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||
chunk_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
section_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
lexical_score: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
rerank_score: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
selected: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
discard_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
class HumanAttentionRecord(Base):
|
||||
__tablename__ = "sys_human_attention_record"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||
message_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||
trigger_message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
problem_summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
trigger_reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
priority: Mapped[str] = mapped_column(String(20), index=True, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), index=True, default="pending", nullable=False)
|
||||
handler_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
handler_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
|
||||
class HumanAttentionHistory(Base):
|
||||
__tablename__ = "sys_human_attention_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
attention_id: Mapped[int] = mapped_column(ForeignKey("sys_human_attention_record.id"), index=True, nullable=False)
|
||||
from_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
to_status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
operated_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
68
ai_knowledge_base_v2/apps/backend/app/models/logs.py
Normal file
68
ai_knowledge_base_v2/apps/backend/app/models/logs.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class AiRequestLog(Base):
|
||||
__tablename__ = "sys_ai_request_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
model_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
knowledge_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
retrieve_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
retrieved_chunks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
input_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
output_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
total_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class OperationLog(Base):
|
||||
__tablename__ = "sys_operation_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
admin_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
module: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
target_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
before_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
after_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
result: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
|
||||
class LogRetentionPolicy(Base):
|
||||
__tablename__ = "sys_log_retention_policy"
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
enabled: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
|
||||
class StorageSnapshot(Base):
|
||||
__tablename__ = "sys_storage_snapshot"
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
scope: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
total_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
detail_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
29
ai_knowledge_base_v2/apps/backend/app/models/user.py
Normal file
29
ai_knowledge_base_v2/apps/backend/app/models/user.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Date, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class User(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "sys_user"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
phone: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
nickname: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
status: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||
daily_chat_limit: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
|
||||
daily_chat_used: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
daily_chat_reset_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
expired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
knowledge_permissions: Mapped[list["UserKnowledgePermission"]] = relationship(
|
||||
"UserKnowledgePermission", back_populates="user"
|
||||
)
|
||||
chat_sessions: Mapped[list["ChatSession"]] = relationship("ChatSession", back_populates="user")
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
135
ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
Normal file
135
ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.common import ORMModel
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=50)
|
||||
password: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class AdminLoginResponse(BaseModel):
|
||||
token: str
|
||||
expiredAt: datetime
|
||||
admin: dict
|
||||
|
||||
|
||||
class AdminRead(ORMModel):
|
||||
id: int
|
||||
username: str
|
||||
name: str
|
||||
status: int
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
userCount: int
|
||||
sessionCount: int
|
||||
messageCount: int
|
||||
aiRequestCount: int
|
||||
knowledgeCount: int
|
||||
inputToken: int
|
||||
outputToken: int
|
||||
totalToken: int
|
||||
|
||||
|
||||
class AdminUserUpdateRequest(BaseModel):
|
||||
status: int | None = Field(default=None, ge=0, le=1)
|
||||
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
|
||||
expiredAt: datetime | None = None
|
||||
|
||||
|
||||
class AdminUserCreateRequest(BaseModel):
|
||||
phone: str = Field(min_length=11, max_length=20)
|
||||
name: str = Field(min_length=1, max_length=50)
|
||||
nickname: str | None = Field(default=None, max_length=50)
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
|
||||
expiredAt: datetime | None = None
|
||||
|
||||
|
||||
class AdminUserImportItem(BaseModel):
|
||||
phone: str = Field(default="", max_length=20)
|
||||
name: str = Field(default="", max_length=50)
|
||||
nickname: str | None = Field(default=None, max_length=50)
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
|
||||
expiredAt: datetime | None = None
|
||||
|
||||
|
||||
class AdminUserImportRequest(BaseModel):
|
||||
students: list[AdminUserImportItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgeSaveRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
feishuSpaceId: str = Field(min_length=1, max_length=100)
|
||||
feishuNodeId: str = Field(min_length=1, max_length=100)
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
knowledgeType: str = Field(default="general", pattern="^(course|qa|general)$")
|
||||
reviewMode: str = Field(default="manual", pattern="^(manual|automatic)$")
|
||||
|
||||
|
||||
class PromptSaveRequest(BaseModel):
|
||||
promptContent: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AgentDebugRequest(BaseModel):
|
||||
promptContent: str = Field(min_length=1)
|
||||
modelId: int = Field(gt=0)
|
||||
knowledgeIds: list[int] = Field(default_factory=list)
|
||||
knowledgeVersions: dict[int, int] = Field(default_factory=dict)
|
||||
question: str = Field(min_length=1, max_length=2000)
|
||||
temperature: float | None = Field(default=None, ge=0, le=2)
|
||||
topP: float | None = Field(default=None, ge=0, le=1)
|
||||
topK: int | None = Field(default=None, ge=1, le=1000)
|
||||
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||
maxToken: int | None = Field(default=8192, ge=1, le=100000)
|
||||
|
||||
|
||||
class AgentRuntimeConfigSaveRequest(BaseModel):
|
||||
temperature: float | None = Field(default=None, ge=0, le=2)
|
||||
topP: float | None = Field(default=None, ge=0, le=1)
|
||||
topK: int | None = Field(default=None, ge=1, le=1000)
|
||||
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||
maxToken: int = Field(default=8192, ge=256, le=100000)
|
||||
|
||||
|
||||
class ModelSaveRequest(BaseModel):
|
||||
provider: str = Field(min_length=1, max_length=50)
|
||||
displayName: str | None = Field(default=None, max_length=100)
|
||||
apiType: str = Field(default="openai_compatible", max_length=50)
|
||||
modelName: str = Field(min_length=1, max_length=100)
|
||||
baseUrl: str | None = Field(default=None, max_length=255)
|
||||
apiUrl: str = Field(default="", max_length=255)
|
||||
apiKey: str = ""
|
||||
authType: str = Field(default="bearer", max_length=30)
|
||||
apiVersion: str | None = Field(default=None, max_length=50)
|
||||
temperature: float | None = Field(default=0.2, ge=0, le=2)
|
||||
topP: float | None = Field(default=None, ge=0, le=1)
|
||||
topK: int | None = Field(default=None, ge=1, le=1000)
|
||||
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||
maxToken: int | None = Field(default=1024, ge=1, le=100000)
|
||||
contextWindow: int | None = Field(default=None, ge=1, le=2000000)
|
||||
streamEnabled: int = Field(default=1, ge=0, le=1)
|
||||
responseFormat: str | None = Field(default=None, max_length=50)
|
||||
extraParams: str | None = None
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
timeoutSecond: int = Field(default=30, ge=1, le=300)
|
||||
|
||||
|
||||
class EnableModelRequest(BaseModel):
|
||||
modelId: int
|
||||
|
||||
|
||||
class SystemConfigSaveRequest(BaseModel):
|
||||
configKey: str = Field(min_length=1, max_length=100)
|
||||
configValue: str
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
30
ai_knowledge_base_v2/apps/backend/app/schemas/auth.py
Normal file
30
ai_knowledge_base_v2/apps/backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.user import UserProfile
|
||||
|
||||
|
||||
class SendSmsRequest(BaseModel):
|
||||
phone: str = Field(min_length=11, max_length=20)
|
||||
captchaId: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
captchaCode: str | None = Field(default=None, min_length=4, max_length=8)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
phone: str = Field(min_length=11, max_length=20)
|
||||
code: str = Field(min_length=4, max_length=8)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
expiredAt: datetime
|
||||
user: UserProfile
|
||||
|
||||
|
||||
class CaptchaResponse(BaseModel):
|
||||
captchaId: str
|
||||
imageBase64: str
|
||||
expiresInSeconds: int
|
||||
42
ai_knowledge_base_v2/apps/backend/app/schemas/chat.py
Normal file
42
ai_knowledge_base_v2/apps/backend/app/schemas/chat.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.schemas.common import ORMModel
|
||||
|
||||
|
||||
class CreateSessionResponse(BaseModel):
|
||||
sessionId: int
|
||||
|
||||
|
||||
class ChatSessionRead(ORMModel):
|
||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||
|
||||
id: int
|
||||
title: str
|
||||
messageCount: int = Field(alias="message_count")
|
||||
updatedAt: datetime = Field(alias="updated_at")
|
||||
|
||||
|
||||
class ChatMessageRead(ORMModel):
|
||||
id: int
|
||||
role: str
|
||||
content: str
|
||||
message_status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UpdateSessionTitleRequest(BaseModel):
|
||||
sessionId: int
|
||||
title: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class ChatCompletionRequest(BaseModel):
|
||||
sessionId: int
|
||||
message: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
class StopChatRequest(BaseModel):
|
||||
sessionId: int
|
||||
7
ai_knowledge_base_v2/apps/backend/app/schemas/common.py
Normal file
7
ai_knowledge_base_v2/apps/backend/app/schemas/common.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ORMModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
41
ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py
Normal file
41
ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KnowledgeSyncRequest(BaseModel):
|
||||
mode: str = Field(default="review", pattern="^(review|replace)$")
|
||||
|
||||
|
||||
class KnowledgeReviewRequest(BaseModel):
|
||||
action: str = Field(pattern="^(approve|reject)$")
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class KnowledgePublishRequest(BaseModel):
|
||||
keepOpen: bool = False
|
||||
|
||||
|
||||
class KnowledgeStatusRequest(BaseModel):
|
||||
open: bool
|
||||
|
||||
|
||||
class KnowledgeLifecycleRequest(BaseModel):
|
||||
status: str = Field(pattern="^(active|archived)$")
|
||||
|
||||
|
||||
class KnowledgeMetadataUpdateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
knowledgeType: str = Field(pattern="^(course|qa|general)$")
|
||||
reviewMode: str = Field(pattern="^(manual|automatic)$")
|
||||
|
||||
|
||||
class KnowledgeBatchMetadataUpdateRequest(BaseModel):
|
||||
knowledgeIds: list[int] = Field(min_length=1, max_length=500)
|
||||
knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$")
|
||||
reviewMode: str | None = Field(default=None, pattern="^(manual|automatic)$")
|
||||
|
||||
|
||||
class AttentionUpdateRequest(BaseModel):
|
||||
status: str = Field(pattern="^(pending|processing|resolved|ignored)$")
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
20
ai_knowledge_base_v2/apps/backend/app/schemas/user.py
Normal file
20
ai_knowledge_base_v2/apps/backend/app/schemas/user.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.common import ORMModel
|
||||
|
||||
|
||||
class UserProfile(ORMModel):
|
||||
id: int
|
||||
phone: str
|
||||
name: str
|
||||
nickname: str | None = None
|
||||
dailyLimit: int = Field(alias="daily_chat_limit")
|
||||
todayUsed: int = Field(alias="daily_chat_used")
|
||||
status: int
|
||||
effective_at: datetime | None = None
|
||||
expired_at: datetime | None = None
|
||||
last_login_at: datetime | None = None
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
167
ai_knowledge_base_v2/apps/backend/app/services/admin_service.py
Normal file
167
ai_knowledge_base_v2/apps/backend/app/services/admin_service.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import create_access_token, hash_password, verify_password
|
||||
from app.models.admin import Admin
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.knowledge import Knowledge
|
||||
from app.models.logs import AiRequestLog, OperationLog
|
||||
from app.models.user import User
|
||||
from app.services.security_state_service import SecurityStateService
|
||||
|
||||
|
||||
DEVELOPMENT_ENVS = {"local", "dev", "development", "docker", "test", "testing"}
|
||||
UNSAFE_BOOTSTRAP_PASSWORDS = {
|
||||
"123456",
|
||||
"admin",
|
||||
"admin123456",
|
||||
"change-me-in-production",
|
||||
"password",
|
||||
"replace-with-strong-password",
|
||||
}
|
||||
|
||||
|
||||
def _is_production_env(app_env: str) -> bool:
|
||||
return app_env.strip().lower() not in DEVELOPMENT_ENVS
|
||||
|
||||
|
||||
def _get_bootstrap_admin_config() -> tuple[str, str, str]:
|
||||
settings = get_settings()
|
||||
username = settings.bootstrap_admin_username.strip()
|
||||
password = settings.bootstrap_admin_password
|
||||
name = settings.bootstrap_admin_name.strip() or username
|
||||
|
||||
if not username or not password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="未配置首个管理员初始化账号,请配置 BOOTSTRAP_ADMIN_USERNAME/BOOTSTRAP_ADMIN_PASSWORD",
|
||||
)
|
||||
|
||||
if _is_production_env(settings.app_env):
|
||||
normalized_password = password.strip().lower()
|
||||
if normalized_password in UNSAFE_BOOTSTRAP_PASSWORDS or len(password) < 12:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="生产环境首个管理员初始化密码不安全,请重新配置 BOOTSTRAP_ADMIN_PASSWORD",
|
||||
)
|
||||
|
||||
return username, password, name
|
||||
|
||||
|
||||
class AdminAuthService:
|
||||
@classmethod
|
||||
def login(cls, db: Session, username: str, password: str, ip: str = "unknown") -> dict:
|
||||
failure_key = f"auth:admin:fail:{username.lower()}:{ip}"
|
||||
SecurityStateService.enforce_limit(
|
||||
f"rate:admin_login:ip:{ip}", limit=30, window_seconds=600, message="登录请求过于频繁,请稍后再试"
|
||||
)
|
||||
SecurityStateService.ensure_not_locked(
|
||||
failure_key, limit=5, message="账号或当前网络连续登录失败次数过多,请十五分钟后再试"
|
||||
)
|
||||
cls.ensure_bootstrap_admin(db)
|
||||
admin = db.scalar(select(Admin).where(Admin.username == username))
|
||||
if admin is None or not verify_password(password, admin.password):
|
||||
SecurityStateService.record_failure(
|
||||
failure_key,
|
||||
limit=5,
|
||||
lock_seconds=900,
|
||||
message="账号或当前网络连续登录失败次数过多,请十五分钟后再试",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员账号或密码错误")
|
||||
if admin.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
|
||||
SecurityStateService.clear(failure_key)
|
||||
|
||||
admin.last_login_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
db.refresh(admin)
|
||||
|
||||
token, expired_at = create_access_token(str(admin.id), "admin")
|
||||
return {
|
||||
"token": token,
|
||||
"expiredAt": expired_at,
|
||||
"admin": {
|
||||
"id": admin.id,
|
||||
"username": admin.username,
|
||||
"name": admin.name,
|
||||
"status": admin.status,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def ensure_bootstrap_admin(db: Session) -> None:
|
||||
has_admin = db.scalar(select(func.count(Admin.id))) or 0
|
||||
if has_admin:
|
||||
return
|
||||
username, password, name = _get_bootstrap_admin_config()
|
||||
admin = Admin(
|
||||
username=username,
|
||||
password=hash_password(password),
|
||||
name=name,
|
||||
status=1,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
|
||||
|
||||
class AdminDashboardService:
|
||||
@staticmethod
|
||||
def stats(db: Session, start: datetime | None = None, end: datetime | None = None) -> dict:
|
||||
from sqlalchemy import and_
|
||||
|
||||
# 基础表查询条件(时间范围)
|
||||
user_filter = [User.is_deleted == 0]
|
||||
session_filter = [ChatSession.is_deleted == 0]
|
||||
msg_filter = []
|
||||
ai_filter = []
|
||||
|
||||
if start:
|
||||
user_filter.append(User.created_at >= start)
|
||||
session_filter.append(ChatSession.created_at >= start)
|
||||
msg_filter.append(ChatMessage.created_at >= start)
|
||||
ai_filter.append(AiRequestLog.created_at >= start)
|
||||
if end:
|
||||
user_filter.append(User.created_at <= end)
|
||||
session_filter.append(ChatSession.created_at <= end)
|
||||
msg_filter.append(ChatMessage.created_at <= end)
|
||||
ai_filter.append(AiRequestLog.created_at <= end)
|
||||
|
||||
return {
|
||||
"userCount": db.scalar(select(func.count(User.id)).where(and_(*user_filter))) or 0,
|
||||
"sessionCount": db.scalar(select(func.count(ChatSession.id)).where(and_(*session_filter))) or 0,
|
||||
"messageCount": db.scalar(select(func.count(ChatMessage.id)).where(and_(*msg_filter))) or 0,
|
||||
"aiRequestCount": db.scalar(select(func.count(AiRequestLog.id)).where(and_(*ai_filter))) or 0,
|
||||
"knowledgeCount": db.scalar(select(func.count(Knowledge.id))) or 0,
|
||||
"inputToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.input_token), 0)).where(and_(*ai_filter))) or 0,
|
||||
"outputToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.output_token), 0)).where(and_(*ai_filter))) or 0,
|
||||
"totalToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.total_token), 0)).where(and_(*ai_filter))) or 0,
|
||||
}
|
||||
|
||||
|
||||
class OperationLogService:
|
||||
@staticmethod
|
||||
def write(
|
||||
db: Session,
|
||||
*,
|
||||
admin_id: int | None,
|
||||
module: str,
|
||||
action: str,
|
||||
target_id: int | None = None,
|
||||
result: str = "SUCCESS",
|
||||
) -> None:
|
||||
db.add(
|
||||
OperationLog(
|
||||
admin_id=admin_id,
|
||||
module=module,
|
||||
action=action,
|
||||
target_id=target_id,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.logs import AiRequestLog
|
||||
from app.services.rag_service import RetrievedChunk
|
||||
|
||||
|
||||
class AiRequestLogService:
|
||||
@staticmethod
|
||||
def write_success(
|
||||
db: Session,
|
||||
*,
|
||||
session_id: int,
|
||||
message_id: int | None,
|
||||
user_id: int,
|
||||
model_name: str,
|
||||
prompt: str,
|
||||
knowledge_ids: str,
|
||||
retrieve_count: int,
|
||||
input_token: int,
|
||||
output_token: int,
|
||||
cost_ms: int,
|
||||
retrieved_chunks: Sequence[RetrievedChunk] | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AiRequestLog(
|
||||
session_id=session_id,
|
||||
message_id=message_id,
|
||||
user_id=user_id,
|
||||
model_name=model_name,
|
||||
prompt=prompt,
|
||||
knowledge_ids=knowledge_ids,
|
||||
retrieve_count=retrieve_count,
|
||||
retrieved_chunks=_dump_retrieved_chunks(retrieved_chunks),
|
||||
input_token=input_token,
|
||||
output_token=output_token,
|
||||
total_token=input_token + output_token,
|
||||
cost_ms=cost_ms,
|
||||
status="SUCCESS",
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def write_failed(
|
||||
db: Session,
|
||||
*,
|
||||
session_id: int,
|
||||
message_id: int | None,
|
||||
user_id: int,
|
||||
model_name: str | None,
|
||||
prompt: str | None,
|
||||
knowledge_ids: str | None,
|
||||
retrieve_count: int,
|
||||
cost_ms: int,
|
||||
error_message: str,
|
||||
retrieved_chunks: Sequence[RetrievedChunk] | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AiRequestLog(
|
||||
session_id=session_id,
|
||||
message_id=message_id,
|
||||
user_id=user_id,
|
||||
model_name=model_name,
|
||||
prompt=prompt,
|
||||
knowledge_ids=knowledge_ids,
|
||||
retrieve_count=retrieve_count,
|
||||
retrieved_chunks=_dump_retrieved_chunks(retrieved_chunks),
|
||||
cost_ms=cost_ms,
|
||||
status="FAILED",
|
||||
error_message=error_message,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _dump_retrieved_chunks(chunks: Sequence[RetrievedChunk] | None) -> str | None:
|
||||
if not chunks:
|
||||
return None
|
||||
payload = [
|
||||
{
|
||||
"index": index,
|
||||
"knowledgeId": chunk.knowledge_id,
|
||||
"knowledgeName": chunk.knowledge_name,
|
||||
"title": chunk.title,
|
||||
"content": chunk.content,
|
||||
"sourceUrl": chunk.source_url,
|
||||
}
|
||||
for index, chunk in enumerate(chunks, start=1)
|
||||
]
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
110
ai_knowledge_base_v2/apps/backend/app/services/auth_service.py
Normal file
110
ai_knowledge_base_v2/apps/backend/app/services/auth_service.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import create_access_token
|
||||
from app.models.user import User
|
||||
from app.services.captcha_service import CaptchaService
|
||||
from app.services.sms_code_service import SmsCodeService
|
||||
from app.services.security_state_service import SecurityStateService
|
||||
|
||||
|
||||
class AuthService:
|
||||
CAPTCHA_REQUIRED_SECONDS = 60 * 60
|
||||
|
||||
@classmethod
|
||||
def send_sms_code(
|
||||
cls,
|
||||
db: Session,
|
||||
phone: str,
|
||||
captcha_id: str | None,
|
||||
captcha_code: str | None,
|
||||
ip: str,
|
||||
) -> None:
|
||||
user = cls._get_existing_user(db, phone)
|
||||
cls._ensure_user_can_login(user)
|
||||
captcha_key = cls._captcha_required_key(phone)
|
||||
has_captcha = bool(captcha_id or captcha_code)
|
||||
if has_captcha:
|
||||
if not captcha_id or not captcha_code:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请完整填写图形验证码")
|
||||
CaptchaService.verify(captcha_id, captcha_code)
|
||||
SecurityStateService.clear(captcha_key)
|
||||
elif SecurityStateService.has_flag(captcha_key):
|
||||
raise HTTPException(status_code=428, detail="需要完成图形验证码后再发送短信")
|
||||
|
||||
SecurityStateService.enforce_limit(
|
||||
f"rate:sms:ip:{ip}", limit=20, window_seconds=3600, message="当前网络发送验证码过于频繁,请稍后再试"
|
||||
)
|
||||
SecurityStateService.enforce_limit(
|
||||
f"rate:sms:phone:{phone}", limit=1, window_seconds=60, message="验证码发送过于频繁,请一分钟后再试"
|
||||
)
|
||||
SmsCodeService.send_code(db, phone)
|
||||
SecurityStateService.mark_flag(captcha_key, cls.CAPTCHA_REQUIRED_SECONDS)
|
||||
|
||||
@classmethod
|
||||
def login_with_sms(cls, db: Session, phone: str, code: str) -> dict:
|
||||
failure_key = f"auth:sms:fail:{phone}"
|
||||
SecurityStateService.ensure_not_locked(
|
||||
failure_key, limit=5, message="验证码连续错误次数过多,请十五分钟后重新获取"
|
||||
)
|
||||
try:
|
||||
SmsCodeService.verify_code(db, phone, code)
|
||||
except HTTPException:
|
||||
SecurityStateService.mark_flag(cls._captcha_required_key(phone), cls.CAPTCHA_REQUIRED_SECONDS)
|
||||
SecurityStateService.record_failure(
|
||||
failure_key,
|
||||
limit=5,
|
||||
lock_seconds=900,
|
||||
message="验证码连续错误次数过多,请十五分钟后重新获取",
|
||||
)
|
||||
raise
|
||||
SecurityStateService.clear(failure_key)
|
||||
user = cls._get_existing_user(db, phone)
|
||||
cls._ensure_user_can_login(user)
|
||||
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
user.last_login_at = now
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
token, expired_at = create_access_token(str(user.id), "user")
|
||||
return {"token": token, "expiredAt": expired_at, "user": user}
|
||||
|
||||
@classmethod
|
||||
def logout(cls, token_payload: dict) -> None:
|
||||
jti = token_payload.get("jti")
|
||||
if jti:
|
||||
SecurityStateService.revoke_token(str(jti), token_payload.get("exp"))
|
||||
|
||||
@classmethod
|
||||
def is_token_revoked(cls, token_payload: dict) -> bool:
|
||||
jti = token_payload.get("jti")
|
||||
return bool(jti and SecurityStateService.is_token_revoked(str(jti)))
|
||||
|
||||
@classmethod
|
||||
def _get_existing_user(cls, db: Session, phone: str) -> User:
|
||||
user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0))
|
||||
if user is not None:
|
||||
return user
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中,请联系管理员")
|
||||
|
||||
@staticmethod
|
||||
def _ensure_user_can_login(user: User) -> None:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
if user.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
||||
if user.effective_at and user.effective_at > now:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号尚未生效")
|
||||
if user.expired_at and user.expired_at < now:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期")
|
||||
|
||||
@staticmethod
|
||||
def _captcha_required_key(phone: str) -> str:
|
||||
return f"auth:sms:captcha_required:{phone}"
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from io import BytesIO
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CAPTCHA_EXPIRES_SECONDS = 300
|
||||
CAPTCHA_WIDTH = 168
|
||||
CAPTCHA_HEIGHT = 64
|
||||
CAPTCHA_LENGTH = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptchaRecord:
|
||||
code: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class CaptchaService:
|
||||
_records: dict[str, CaptchaRecord] = {}
|
||||
|
||||
@classmethod
|
||||
def create(cls) -> dict:
|
||||
cls._cleanup_expired()
|
||||
captcha_id = str(uuid4())
|
||||
code = _random_code()
|
||||
if _set_redis_captcha(captcha_id, code):
|
||||
cls._records.pop(captcha_id, None)
|
||||
else:
|
||||
cls._records[captcha_id] = CaptchaRecord(
|
||||
code=code,
|
||||
expires_at=datetime.now(UTC) + timedelta(seconds=CAPTCHA_EXPIRES_SECONDS),
|
||||
)
|
||||
return {
|
||||
"captchaId": captcha_id,
|
||||
"imageBase64": _render_captcha_image(code),
|
||||
"expiresInSeconds": CAPTCHA_EXPIRES_SECONDS,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def verify(cls, captcha_id: str, captcha_code: str) -> None:
|
||||
redis_available, redis_code = _get_redis_captcha(captcha_id)
|
||||
if redis_available and redis_code is not None:
|
||||
if redis_code.lower() != captcha_code.strip().lower():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码错误")
|
||||
_delete_redis_captcha(captcha_id)
|
||||
cls._records.pop(captcha_id, None)
|
||||
return
|
||||
|
||||
record = cls._records.get(captcha_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码不存在或已过期")
|
||||
if record.expires_at < datetime.now(UTC):
|
||||
cls._records.pop(captcha_id, None)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码已过期")
|
||||
if record.code.lower() != captcha_code.strip().lower():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码错误")
|
||||
cls._records.pop(captcha_id, None)
|
||||
|
||||
@classmethod
|
||||
def _cleanup_expired(cls) -> None:
|
||||
now = datetime.now(UTC)
|
||||
expired_ids = [captcha_id for captcha_id, record in cls._records.items() if record.expires_at < now]
|
||||
for captcha_id in expired_ids:
|
||||
cls._records.pop(captcha_id, None)
|
||||
|
||||
|
||||
def _random_code() -> str:
|
||||
alphabet = "".join(ch for ch in string.ascii_uppercase + string.digits if ch not in "0O1IL")
|
||||
return "".join(random.SystemRandom().choice(alphabet) for _ in range(CAPTCHA_LENGTH))
|
||||
|
||||
|
||||
def _captcha_key(captcha_id: str) -> str:
|
||||
return f"auth:captcha:{captcha_id}"
|
||||
|
||||
|
||||
def _set_redis_captcha(captcha_id: str, code: str) -> bool:
|
||||
client = get_sync_redis_client()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
return bool(client.set(_captcha_key(captcha_id), code, ex=CAPTCHA_EXPIRES_SECONDS))
|
||||
except Exception:
|
||||
logger.warning("Redis captcha write failed for %s", captcha_id, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _get_redis_captcha(captcha_id: str) -> tuple[bool, str | None]:
|
||||
client = get_sync_redis_client()
|
||||
if client is None:
|
||||
return False, None
|
||||
try:
|
||||
value = client.get(_captcha_key(captcha_id))
|
||||
except Exception:
|
||||
logger.warning("Redis captcha read failed for %s", captcha_id, exc_info=True)
|
||||
return False, None
|
||||
return True, value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _delete_redis_captcha(captcha_id: str) -> None:
|
||||
client = get_sync_redis_client()
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.delete(_captcha_key(captcha_id))
|
||||
except Exception:
|
||||
logger.warning("Redis captcha delete failed for %s", captcha_id, exc_info=True)
|
||||
|
||||
|
||||
def _render_captcha_image(code: str) -> str:
|
||||
image = Image.new("RGB", (CAPTCHA_WIDTH, CAPTCHA_HEIGHT), "#f7fbfa")
|
||||
draw = ImageDraw.Draw(image)
|
||||
rng = random.SystemRandom()
|
||||
|
||||
for _ in range(70):
|
||||
x = rng.randint(0, CAPTCHA_WIDTH - 1)
|
||||
y = rng.randint(0, CAPTCHA_HEIGHT - 1)
|
||||
color = rng.choice(["#d7e5df", "#c7ddd5", "#e4ece9"])
|
||||
draw.point((x, y), fill=color)
|
||||
|
||||
for _ in range(4):
|
||||
start = (rng.randint(0, CAPTCHA_WIDTH // 2), rng.randint(0, CAPTCHA_HEIGHT))
|
||||
end = (rng.randint(CAPTCHA_WIDTH // 2, CAPTCHA_WIDTH), rng.randint(0, CAPTCHA_HEIGHT))
|
||||
draw.line([start, end], fill=rng.choice(["#b7cec5", "#cbdad5"]), width=1)
|
||||
|
||||
font = _load_font()
|
||||
char_width = CAPTCHA_WIDTH // CAPTCHA_LENGTH
|
||||
for index, char in enumerate(code):
|
||||
layer = Image.new("RGBA", (char_width, CAPTCHA_HEIGHT), (255, 255, 255, 0))
|
||||
layer_draw = ImageDraw.Draw(layer)
|
||||
bbox = layer_draw.textbbox((0, 0), char, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
x = (char_width - text_width) // 2
|
||||
y = (CAPTCHA_HEIGHT - text_height) // 2 - 3
|
||||
layer_draw.text((x, y), char, font=font, fill=rng.choice(["#0f735d", "#173f35", "#31564d"]))
|
||||
layer = layer.rotate(rng.randint(-14, 14), resample=Image.Resampling.BICUBIC, expand=False)
|
||||
image.paste(layer, (index * char_width, 0), layer)
|
||||
|
||||
image = image.filter(ImageFilter.SMOOTH)
|
||||
output = BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
encoded = base64.b64encode(output.getvalue()).decode("ascii")
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
|
||||
|
||||
def _load_font():
|
||||
for path in (
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/local/share/fonts/DejaVuSans-Bold.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||||
"/Library/Fonts/Arial Bold.ttf",
|
||||
):
|
||||
try:
|
||||
return ImageFont.truetype(path, 38)
|
||||
except OSError:
|
||||
continue
|
||||
logger.warning("Captcha font fallback to Pillow default font")
|
||||
return ImageFont.load_default()
|
||||
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from time import perf_counter
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ai_config import SystemConfig
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONTEXT_MESSAGE_COUNT = 10
|
||||
MAX_CONTEXT_MESSAGE_COUNT = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SummaryUpdateResult:
|
||||
trace: dict | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SummaryWork:
|
||||
messages: list[ChatMessage]
|
||||
messages_text: str
|
||||
request: dict
|
||||
|
||||
|
||||
class ChatContextService:
|
||||
"""Owns the runtime policy for a session's conversational memory."""
|
||||
|
||||
@staticmethod
|
||||
def message_limit(db: Session) -> int:
|
||||
config = db.scalar(
|
||||
select(SystemConfig).where(SystemConfig.config_key == "chat_context_message_count")
|
||||
)
|
||||
if config is None or not config.config_value.strip():
|
||||
return DEFAULT_CONTEXT_MESSAGE_COUNT
|
||||
try:
|
||||
value = int(float(config.config_value.strip()))
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid chat_context_message_count=%r; falling back to %s",
|
||||
config.config_value,
|
||||
DEFAULT_CONTEXT_MESSAGE_COUNT,
|
||||
)
|
||||
return DEFAULT_CONTEXT_MESSAGE_COUNT
|
||||
return max(0, min(MAX_CONTEXT_MESSAGE_COUNT, value))
|
||||
|
||||
@classmethod
|
||||
def select_memory(
|
||||
cls,
|
||||
db: Session,
|
||||
history: list[ChatMessage] | None,
|
||||
summary: str | None,
|
||||
summary_up_to_message_id: int | None = None,
|
||||
) -> tuple[list[ChatMessage], str | None]:
|
||||
limit = cls.message_limit(db)
|
||||
if limit == 0:
|
||||
return [], None
|
||||
recent = list((history or [])[-limit:])
|
||||
if summary and summary_up_to_message_id is not None:
|
||||
recent = [message for message in recent if message.id > summary_up_to_message_id]
|
||||
return recent, summary
|
||||
|
||||
@classmethod
|
||||
def update_summary(
|
||||
cls,
|
||||
db: Session,
|
||||
session: ChatSession,
|
||||
history: list[ChatMessage],
|
||||
) -> SummaryUpdateResult:
|
||||
"""Roll older messages into one bounded summary as soon as they leave the raw window."""
|
||||
work = cls._prepare_summary(db, session, history)
|
||||
if work is None:
|
||||
return SummaryUpdateResult()
|
||||
started = perf_counter()
|
||||
try:
|
||||
# Local import avoids a rag_service -> context_service -> model_service cycle.
|
||||
from app.services.model_service import ModelClientService
|
||||
|
||||
new_summary = ModelClientService.summarize_or_raise(
|
||||
db,
|
||||
work.messages_text,
|
||||
previous_summary=session.summary,
|
||||
)
|
||||
return cls._apply_summary(session, work, new_summary, started)
|
||||
except Exception as exc:
|
||||
return cls._summary_failure(session, work, exc, started)
|
||||
|
||||
@classmethod
|
||||
async def update_summary_async(
|
||||
cls,
|
||||
db: Session,
|
||||
session: ChatSession,
|
||||
history: list[ChatMessage],
|
||||
) -> SummaryUpdateResult:
|
||||
work = cls._prepare_summary(db, session, history)
|
||||
if work is None:
|
||||
return SummaryUpdateResult()
|
||||
started = perf_counter()
|
||||
try:
|
||||
from app.services.model_service import ModelClientService
|
||||
|
||||
new_summary = await ModelClientService.summarize_or_raise_async(
|
||||
db,
|
||||
work.messages_text,
|
||||
previous_summary=session.summary,
|
||||
)
|
||||
return cls._apply_summary(session, work, new_summary, started)
|
||||
except Exception as exc:
|
||||
return cls._summary_failure(session, work, exc, started)
|
||||
|
||||
@classmethod
|
||||
def _prepare_summary(
|
||||
cls,
|
||||
db: Session,
|
||||
session: ChatSession,
|
||||
history: list[ChatMessage],
|
||||
) -> _SummaryWork | None:
|
||||
limit = cls.message_limit(db)
|
||||
if limit == 0 or len(history) <= limit:
|
||||
return None
|
||||
cutoff = len(history) - limit
|
||||
start_idx = cls._covered_history_index(session, history)
|
||||
if cutoff <= start_idx:
|
||||
return None
|
||||
messages = history[start_idx:cutoff]
|
||||
messages_text = cls.messages_text(messages)
|
||||
if not messages_text:
|
||||
return None
|
||||
return _SummaryWork(
|
||||
messages=messages,
|
||||
messages_text=messages_text,
|
||||
request={
|
||||
"previousSummary": session.summary or "",
|
||||
"messages": messages_text,
|
||||
"messageCount": len(messages),
|
||||
"contextMessageLimit": limit,
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _apply_summary(
|
||||
cls,
|
||||
session: ChatSession,
|
||||
work: _SummaryWork,
|
||||
summary: str,
|
||||
started: float,
|
||||
) -> SummaryUpdateResult:
|
||||
summary = summary.strip()
|
||||
if not summary:
|
||||
raise ValueError("模型返回了空的历史摘要")
|
||||
session.summary = summary
|
||||
session.summary_up_to_message_id = work.messages[-1].id
|
||||
duration_ms = int((perf_counter() - started) * 1000)
|
||||
return SummaryUpdateResult(
|
||||
trace=cls._trace(
|
||||
work.request,
|
||||
{"summary": summary, "count": len(work.messages)},
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _summary_failure(
|
||||
cls,
|
||||
session: ChatSession,
|
||||
work: _SummaryWork,
|
||||
exc: Exception,
|
||||
started: float,
|
||||
) -> SummaryUpdateResult:
|
||||
duration_ms = int((perf_counter() - started) * 1000)
|
||||
logger.exception(
|
||||
"Chat history summary failed for session_id=%s message_count=%s",
|
||||
session.id,
|
||||
len(work.messages),
|
||||
exc_info=exc,
|
||||
)
|
||||
return SummaryUpdateResult(
|
||||
trace=cls._trace(
|
||||
work.request,
|
||||
{"count": 0, "fallback": "recent_messages_only"},
|
||||
duration_ms=duration_ms,
|
||||
status="fallback",
|
||||
error=str(exc)[:500],
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _covered_history_index(session: ChatSession, history: list[ChatMessage]) -> int:
|
||||
if session.summary_up_to_message_id is None:
|
||||
return 0
|
||||
for index, message in enumerate(history):
|
||||
if message.id > session.summary_up_to_message_id:
|
||||
return index
|
||||
return len(history)
|
||||
|
||||
@staticmethod
|
||||
def messages_text(messages: list[ChatMessage]) -> str:
|
||||
role_labels = {"user": "用户", "assistant": "大本营答疑助手"}
|
||||
parts: list[str] = []
|
||||
for message in messages:
|
||||
content = re.sub(
|
||||
r"<think[\s\S]*?</think\s*>",
|
||||
"",
|
||||
message.content,
|
||||
flags=re.IGNORECASE,
|
||||
).strip()
|
||||
if content:
|
||||
parts.append(f"{role_labels.get(message.role, message.role)}:{content}")
|
||||
return "\n".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _trace(
|
||||
request: dict,
|
||||
response: dict,
|
||||
*,
|
||||
duration_ms: int,
|
||||
status: str = "success",
|
||||
error: str | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"tool": "chat_memory_summary",
|
||||
"order": 0,
|
||||
"request": request,
|
||||
"response": response,
|
||||
**response,
|
||||
"status": status,
|
||||
"startedAtMs": 0,
|
||||
"endedAtMs": duration_ms,
|
||||
"durationMs": duration_ms,
|
||||
"responseCount": response.get("count"),
|
||||
"error": error,
|
||||
"retries": 0,
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.services.chat_queue_service import ChatQueueConfig, QueueRequest
|
||||
from app.services.redis_client import get_redis_client
|
||||
|
||||
|
||||
class RedisChatQueueManager:
|
||||
_active_key = "ai_kb:chat_queue:active"
|
||||
_waiting_key = "ai_kb:chat_queue:waiting"
|
||||
_request_script = """
|
||||
local active_key = KEYS[1]
|
||||
local waiting_key = KEYS[2]
|
||||
local now = tonumber(ARGV[1])
|
||||
local token = ARGV[2]
|
||||
local max_active = tonumber(ARGV[3])
|
||||
local max_waiting = tonumber(ARGV[4])
|
||||
local active_expire_at = tonumber(ARGV[5])
|
||||
local key_ttl = tonumber(ARGV[6])
|
||||
|
||||
redis.call('ZREMRANGEBYSCORE', active_key, '-inf', now)
|
||||
local active_count = redis.call('ZCARD', active_key)
|
||||
local waiting_count = redis.call('LLEN', waiting_key)
|
||||
|
||||
if active_count < max_active and waiting_count == 0 then
|
||||
redis.call('ZADD', active_key, active_expire_at, token)
|
||||
redis.call('EXPIRE', active_key, key_ttl)
|
||||
return {'acquired', token, 0, active_count + 1, 0}
|
||||
end
|
||||
|
||||
if waiting_count >= max_waiting then
|
||||
redis.call('EXPIRE', active_key, key_ttl)
|
||||
redis.call('EXPIRE', waiting_key, key_ttl)
|
||||
return {'rejected', token, 0, active_count, waiting_count}
|
||||
end
|
||||
|
||||
redis.call('RPUSH', waiting_key, token)
|
||||
redis.call('EXPIRE', waiting_key, key_ttl)
|
||||
redis.call('EXPIRE', active_key, key_ttl)
|
||||
return {'queued', token, waiting_count + 1, active_count, waiting_count + 1}
|
||||
"""
|
||||
_promote_script = """
|
||||
local active_key = KEYS[1]
|
||||
local waiting_key = KEYS[2]
|
||||
local now = tonumber(ARGV[1])
|
||||
local token = ARGV[2]
|
||||
local max_active = tonumber(ARGV[3])
|
||||
local active_expire_at = tonumber(ARGV[4])
|
||||
local key_ttl = tonumber(ARGV[5])
|
||||
|
||||
redis.call('ZREMRANGEBYSCORE', active_key, '-inf', now)
|
||||
local active_count = redis.call('ZCARD', active_key)
|
||||
local waiting_count = redis.call('LLEN', waiting_key)
|
||||
local head = redis.call('LINDEX', waiting_key, 0)
|
||||
|
||||
if head == token and active_count < max_active then
|
||||
redis.call('LPOP', waiting_key)
|
||||
redis.call('ZADD', active_key, active_expire_at, token)
|
||||
redis.call('EXPIRE', active_key, key_ttl)
|
||||
redis.call('EXPIRE', waiting_key, key_ttl)
|
||||
return {'acquired', token, 0, active_count + 1, waiting_count - 1}
|
||||
end
|
||||
|
||||
local position = redis.call('LPOS', waiting_key, token)
|
||||
if not position then
|
||||
return {'canceled', token, 0, active_count, waiting_count}
|
||||
end
|
||||
|
||||
redis.call('EXPIRE', active_key, key_ttl)
|
||||
redis.call('EXPIRE', waiting_key, key_ttl)
|
||||
return {'queued', token, position + 1, active_count, waiting_count}
|
||||
"""
|
||||
|
||||
async def request_slot(self, config: ChatQueueConfig) -> QueueRequest:
|
||||
client = _require_client()
|
||||
now = time.time()
|
||||
token = uuid4().hex
|
||||
result = await client.eval(
|
||||
self._request_script,
|
||||
2,
|
||||
self._active_key,
|
||||
self._waiting_key,
|
||||
now,
|
||||
token,
|
||||
config.max_active_requests,
|
||||
config.max_queue_size,
|
||||
now + config.active_lease_seconds,
|
||||
_key_ttl(config),
|
||||
)
|
||||
return _queue_request(result)
|
||||
|
||||
async def wait_for_slot(self, token: str, config: ChatQueueConfig) -> QueueRequest:
|
||||
client = _require_client()
|
||||
deadline = time.monotonic() + config.queue_timeout_seconds
|
||||
while True:
|
||||
now = time.time()
|
||||
result = await client.eval(
|
||||
self._promote_script,
|
||||
2,
|
||||
self._active_key,
|
||||
self._waiting_key,
|
||||
now,
|
||||
token,
|
||||
config.max_active_requests,
|
||||
now + config.active_lease_seconds,
|
||||
_key_ttl(config),
|
||||
)
|
||||
request = _queue_request(result)
|
||||
if request.status in {"acquired", "canceled"}:
|
||||
return request
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
await self.cancel_waiter(token)
|
||||
return QueueRequest(status="timeout", token=token)
|
||||
await asyncio.sleep(min(0.25, remaining))
|
||||
|
||||
async def cancel_waiter(self, token: str) -> None:
|
||||
client = _require_client()
|
||||
await client.lrem(self._waiting_key, 0, token)
|
||||
|
||||
async def release_slot(self, token: str) -> None:
|
||||
client = _require_client()
|
||||
await client.zrem(self._active_key, token)
|
||||
|
||||
|
||||
redis_chat_queue_manager = RedisChatQueueManager()
|
||||
|
||||
|
||||
def _require_client() -> Redis:
|
||||
client = get_redis_client()
|
||||
if client is None:
|
||||
raise RuntimeError("Redis is not configured")
|
||||
return client
|
||||
|
||||
|
||||
def _queue_request(result: list) -> QueueRequest:
|
||||
status = str(result[0])
|
||||
token = str(result[1]) if len(result) > 1 and result[1] else None
|
||||
return QueueRequest(
|
||||
status=status,
|
||||
token=token,
|
||||
position=int(result[2]) if len(result) > 2 else 0,
|
||||
active_count=int(result[3]) if len(result) > 3 else 0,
|
||||
waiting_count=int(result[4]) if len(result) > 4 else 0,
|
||||
)
|
||||
|
||||
|
||||
def _key_ttl(config: ChatQueueConfig) -> int:
|
||||
return max(config.active_lease_seconds, config.queue_timeout_seconds) + 300
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.services.chat_queue_redis_service import redis_chat_queue_manager
|
||||
from app.services.chat_queue_service import ChatQueueConfig, QueueRequest, chat_queue_manager
|
||||
from app.services.redis_client import ping_redis
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatQueueLease:
|
||||
backend: str
|
||||
request: QueueRequest
|
||||
|
||||
|
||||
async def request_chat_slot(config: ChatQueueConfig) -> ChatQueueLease:
|
||||
if await ping_redis():
|
||||
try:
|
||||
return ChatQueueLease("redis", await redis_chat_queue_manager.request_slot(config))
|
||||
except Exception:
|
||||
pass
|
||||
request = await asyncio.to_thread(chat_queue_manager.request_slot, config)
|
||||
return ChatQueueLease("memory", request)
|
||||
|
||||
|
||||
async def wait_for_chat_slot(lease: ChatQueueLease, config: ChatQueueConfig) -> ChatQueueLease:
|
||||
token = lease.request.token
|
||||
if lease.backend == "redis" and isinstance(token, str):
|
||||
try:
|
||||
lease.request = await redis_chat_queue_manager.wait_for_slot(token, config)
|
||||
return lease
|
||||
except Exception:
|
||||
await cancel_chat_waiter(lease)
|
||||
lease.backend = "memory"
|
||||
lease.request = await asyncio.to_thread(chat_queue_manager.request_slot, config)
|
||||
if lease.request.status == "queued":
|
||||
lease.request = await asyncio.to_thread(chat_queue_manager.wait_for_slot, lease.request.token, config)
|
||||
return lease
|
||||
|
||||
lease.request = await asyncio.to_thread(chat_queue_manager.wait_for_slot, token, config)
|
||||
return lease
|
||||
|
||||
|
||||
async def cancel_chat_waiter(lease: ChatQueueLease) -> None:
|
||||
token = lease.request.token
|
||||
if lease.backend == "redis" and isinstance(token, str):
|
||||
try:
|
||||
await redis_chat_queue_manager.cancel_waiter(token)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if token is not None:
|
||||
await asyncio.to_thread(chat_queue_manager.cancel_waiter, token)
|
||||
|
||||
|
||||
async def release_chat_slot(lease: ChatQueueLease) -> None:
|
||||
token = lease.request.token
|
||||
if lease.backend == "redis" and isinstance(token, str):
|
||||
try:
|
||||
await redis_chat_queue_manager.release_slot(token)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
await asyncio.to_thread(chat_queue_manager.release_slot)
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from threading import Condition
|
||||
import time
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.ai_config import SystemConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatQueueConfig:
|
||||
max_active_requests: int
|
||||
max_queue_size: int
|
||||
queue_timeout_seconds: int
|
||||
active_lease_seconds: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QueueRequest:
|
||||
status: str
|
||||
token: object | None = None
|
||||
position: int = 0
|
||||
active_count: int = 0
|
||||
waiting_count: int = 0
|
||||
|
||||
|
||||
class ChatQueueManager:
|
||||
def __init__(self) -> None:
|
||||
self._condition = Condition()
|
||||
self._active_count = 0
|
||||
self._waiters: deque[object] = deque()
|
||||
|
||||
def request_slot(self, config: ChatQueueConfig) -> QueueRequest:
|
||||
with self._condition:
|
||||
if self._active_count < config.max_active_requests and not self._waiters:
|
||||
self._active_count += 1
|
||||
return QueueRequest(
|
||||
status="acquired",
|
||||
active_count=self._active_count,
|
||||
waiting_count=len(self._waiters),
|
||||
)
|
||||
|
||||
if len(self._waiters) >= config.max_queue_size:
|
||||
return QueueRequest(
|
||||
status="rejected",
|
||||
active_count=self._active_count,
|
||||
waiting_count=len(self._waiters),
|
||||
)
|
||||
|
||||
token = object()
|
||||
self._waiters.append(token)
|
||||
return QueueRequest(
|
||||
status="queued",
|
||||
token=token,
|
||||
position=len(self._waiters),
|
||||
active_count=self._active_count,
|
||||
waiting_count=len(self._waiters),
|
||||
)
|
||||
|
||||
def wait_for_slot(self, token: object, config: ChatQueueConfig) -> QueueRequest:
|
||||
deadline = time.monotonic() + config.queue_timeout_seconds
|
||||
with self._condition:
|
||||
while True:
|
||||
if self._waiters and self._waiters[0] is token and self._active_count < config.max_active_requests:
|
||||
self._waiters.popleft()
|
||||
self._active_count += 1
|
||||
self._condition.notify_all()
|
||||
return QueueRequest(
|
||||
status="acquired",
|
||||
active_count=self._active_count,
|
||||
waiting_count=len(self._waiters),
|
||||
)
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
try:
|
||||
self._waiters.remove(token)
|
||||
except ValueError:
|
||||
pass
|
||||
self._condition.notify_all()
|
||||
return QueueRequest(
|
||||
status="timeout",
|
||||
active_count=self._active_count,
|
||||
waiting_count=len(self._waiters),
|
||||
)
|
||||
|
||||
self._condition.wait(timeout=remaining)
|
||||
|
||||
def cancel_waiter(self, token: object) -> None:
|
||||
with self._condition:
|
||||
try:
|
||||
self._waiters.remove(token)
|
||||
except ValueError:
|
||||
return
|
||||
self._condition.notify_all()
|
||||
|
||||
def release_slot(self) -> None:
|
||||
with self._condition:
|
||||
if self._active_count > 0:
|
||||
self._active_count -= 1
|
||||
self._condition.notify_all()
|
||||
|
||||
|
||||
chat_queue_manager = ChatQueueManager()
|
||||
|
||||
|
||||
def load_chat_queue_config(db: Session) -> ChatQueueConfig:
|
||||
settings = get_settings()
|
||||
return ChatQueueConfig(
|
||||
max_active_requests=_config_int(
|
||||
db,
|
||||
"chat_max_active_requests",
|
||||
settings.chat_max_active_requests,
|
||||
minimum=1,
|
||||
maximum=1000,
|
||||
),
|
||||
max_queue_size=_config_int(
|
||||
db,
|
||||
"chat_max_queue_size",
|
||||
settings.chat_max_queue_size,
|
||||
minimum=0,
|
||||
maximum=10000,
|
||||
),
|
||||
queue_timeout_seconds=_config_int(
|
||||
db,
|
||||
"chat_queue_timeout_seconds",
|
||||
settings.chat_queue_timeout_seconds,
|
||||
minimum=1,
|
||||
maximum=3600,
|
||||
),
|
||||
active_lease_seconds=_config_int(
|
||||
db,
|
||||
"chat_active_lease_seconds",
|
||||
settings.chat_active_lease_seconds,
|
||||
minimum=60,
|
||||
maximum=86400,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _config_int(db: Session, key: str, default: int, *, minimum: int, maximum: int) -> int:
|
||||
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == key))
|
||||
if config is None or not config.config_value.strip():
|
||||
return default
|
||||
try:
|
||||
value = int(float(config.config_value.strip()))
|
||||
except ValueError:
|
||||
return default
|
||||
return max(minimum, min(maximum, value))
|
||||
222
ai_knowledge_base_v2/apps/backend/app/services/chat_service.py
Normal file
222
ai_knowledge_base_v2/apps/backend/app/services/chat_service.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from time import perf_counter
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.user import User
|
||||
from app.services.ai_request_log_service import AiRequestLogService
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
from app.services.chat_context_service import ChatContextService
|
||||
from app.services.model_service import ModelClientService
|
||||
from app.services.rag_service import RagService
|
||||
|
||||
|
||||
class ChatService:
|
||||
@staticmethod
|
||||
def create_session(db: Session, user: User) -> ChatSession:
|
||||
now = _now()
|
||||
session = ChatSession(
|
||||
user_id=user.id,
|
||||
title="新聊天",
|
||||
message_count=0,
|
||||
last_message_at=now,
|
||||
is_deleted=0,
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def list_sessions(db: Session, user: User) -> list[ChatSession]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(ChatSession)
|
||||
.where(ChatSession.user_id == user.id, ChatSession.is_deleted == 0)
|
||||
.order_by(ChatSession.updated_at.desc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_history(db: Session, user: User, session_id: int) -> list[ChatMessage]:
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
return list(
|
||||
db.scalars(
|
||||
select(ChatMessage)
|
||||
.where(ChatMessage.session_id == session.id, ChatMessage.user_id == user.id)
|
||||
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_title(db: Session, user: User, session_id: int, title: str) -> ChatSession:
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
session.title = title.strip()
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def delete_session(db: Session, user: User, session_id: int) -> None:
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
session.is_deleted = 1
|
||||
db.add(session)
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def create_answer(db: Session, user: User, session_id: int, question: str) -> str:
|
||||
user = ChatService.prepare_daily_quota(db, user)
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
ChatService._ensure_quota(user)
|
||||
|
||||
now = _now()
|
||||
normalized_question = question.strip()
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=normalized_question,
|
||||
message_status="FINISHED",
|
||||
created_at=now,
|
||||
)
|
||||
db.add(user_message)
|
||||
db.flush()
|
||||
|
||||
started_at = perf_counter()
|
||||
rag_result = None
|
||||
try:
|
||||
# 获取历史消息(不含刚插入的 user_message,它还没 flush id)
|
||||
history = list(
|
||||
db.scalars(
|
||||
select(ChatMessage)
|
||||
.where(
|
||||
ChatMessage.session_id == session.id,
|
||||
ChatMessage.user_id == user.id,
|
||||
ChatMessage.id < user_message.id,
|
||||
)
|
||||
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||
)
|
||||
)
|
||||
|
||||
summary_result = ChatContextService.update_summary(db, session, history)
|
||||
context_trace = [summary_result.trace] if summary_result.trace else None
|
||||
|
||||
# 构建 prompt(传入历史 + 摘要)
|
||||
rag_result = RagService.build_result(
|
||||
db, user, normalized_question,
|
||||
history=history,
|
||||
session_summary=session.summary,
|
||||
summary_up_to_message_id=session.summary_up_to_message_id,
|
||||
context_trace=context_trace,
|
||||
)
|
||||
completion = ModelClientService.complete(db, rag_result)
|
||||
except ExternalServiceError as exc:
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
AiRequestLogService.write_failed(
|
||||
db,
|
||||
session_id=session.id,
|
||||
message_id=user_message.id,
|
||||
user_id=user.id,
|
||||
model_name=None,
|
||||
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
|
||||
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||
cost_ms=cost_ms,
|
||||
error_message=str(exc),
|
||||
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||
)
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=completion.answer,
|
||||
message_status="FINISHED",
|
||||
token_input=completion.input_token,
|
||||
token_output=completion.output_token,
|
||||
response_time_ms=cost_ms,
|
||||
model_id=completion.model_id,
|
||||
created_at=now,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
db.flush()
|
||||
|
||||
session.message_count += 2
|
||||
session.last_message_at = now
|
||||
if session.title == "新聊天":
|
||||
session.title = _title_from_question(normalized_question)
|
||||
|
||||
user.daily_chat_used += 1
|
||||
db.add_all([session, user])
|
||||
AiRequestLogService.write_success(
|
||||
db,
|
||||
session_id=session.id,
|
||||
message_id=assistant_message.id,
|
||||
user_id=user.id,
|
||||
model_name=completion.model_name,
|
||||
prompt=rag_result.prompt,
|
||||
knowledge_ids=rag_result.knowledge_ids,
|
||||
retrieve_count=len(rag_result.chunks),
|
||||
input_token=completion.input_token,
|
||||
output_token=completion.output_token,
|
||||
cost_ms=cost_ms,
|
||||
retrieved_chunks=rag_result.chunks,
|
||||
)
|
||||
db.commit()
|
||||
return completion.answer
|
||||
|
||||
@staticmethod
|
||||
def stop_generation(db: Session, user: User, session_id: int) -> None:
|
||||
ChatService._get_user_session(db, user, session_id)
|
||||
|
||||
@staticmethod
|
||||
def _get_user_session(db: Session, user: User, session_id: int) -> ChatSession:
|
||||
session = db.get(ChatSession, session_id)
|
||||
if session is None or session.user_id != user.id or session.is_deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def _maybe_update_summary(db: Session, session: ChatSession, history: list[ChatMessage]) -> None:
|
||||
ChatContextService.update_summary(db, session, history)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_quota(user: User) -> None:
|
||||
if user.daily_chat_used >= user.daily_chat_limit:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="今日提问次数已用完")
|
||||
|
||||
@staticmethod
|
||||
def prepare_daily_quota(db: Session, user: User) -> User:
|
||||
locked_user = db.scalar(select(User).where(User.id == user.id).with_for_update())
|
||||
if locked_user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||
|
||||
today = datetime.now(ZoneInfo("Asia/Shanghai")).date()
|
||||
if locked_user.daily_chat_reset_date != today:
|
||||
locked_user.daily_chat_used = 0
|
||||
locked_user.daily_chat_reset_date = today
|
||||
db.add(locked_user)
|
||||
db.flush()
|
||||
return locked_user
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _title_from_question(question: str) -> str:
|
||||
title = question.strip().replace("\n", " ")
|
||||
return title[:20] if title else "新聊天"
|
||||
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from inspect import signature
|
||||
from time import perf_counter
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.chat import ChatMessage
|
||||
from app.models.knowledge import KnowledgeRetrievalLog
|
||||
from app.models.user import User
|
||||
from app.services.ai_request_log_service import AiRequestLogService
|
||||
from app.services.chat_service import ChatService, _title_from_question
|
||||
from app.services.chat_context_service import ChatContextService
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
from app.services.human_attention_service import HumanAttentionService
|
||||
from app.services.model_stream_service import ModelStreamService
|
||||
from app.services.rag_async_service import AsyncRagService
|
||||
from app.services.rag_service import RagService
|
||||
|
||||
|
||||
class ChatStreamService:
|
||||
@staticmethod
|
||||
def stream_answer(db: Session, user: User, session_id: int, question: str) -> Iterator[str]:
|
||||
user = ChatService.prepare_daily_quota(db, user)
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
ChatService._ensure_quota(user)
|
||||
|
||||
now = _now()
|
||||
normalized_question = question.strip()
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=normalized_question,
|
||||
message_status="FINISHED",
|
||||
created_at=now,
|
||||
)
|
||||
db.add(user_message)
|
||||
db.flush()
|
||||
|
||||
history = list(
|
||||
db.scalars(
|
||||
select(ChatMessage)
|
||||
.where(
|
||||
ChatMessage.session_id == session.id,
|
||||
ChatMessage.user_id == user.id,
|
||||
ChatMessage.id < user_message.id,
|
||||
)
|
||||
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||
)
|
||||
)
|
||||
summary_result = ChatContextService.update_summary(db, session, history)
|
||||
context_trace = [summary_result.trace] if summary_result.trace else None
|
||||
|
||||
started_at = perf_counter()
|
||||
rag_result = None
|
||||
model_response = None
|
||||
answer_parts: list[str] = []
|
||||
|
||||
try:
|
||||
rag_result = _build_rag_result(
|
||||
db,
|
||||
user,
|
||||
normalized_question,
|
||||
history,
|
||||
getattr(session, "summary", None),
|
||||
getattr(session, "summary_up_to_message_id", None),
|
||||
context_trace,
|
||||
)
|
||||
model_response = ModelStreamService.stream(db, rag_result)
|
||||
for chunk in model_response.chunks:
|
||||
if chunk:
|
||||
answer_parts.append(chunk)
|
||||
yield chunk
|
||||
except GeneratorExit:
|
||||
db.rollback()
|
||||
raise
|
||||
except ExternalServiceError as exc:
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
AiRequestLogService.write_failed(
|
||||
db,
|
||||
session_id=session.id,
|
||||
message_id=user_message.id,
|
||||
user_id=user.id,
|
||||
model_name=model_response.model_name if model_response is not None else None,
|
||||
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
|
||||
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||
cost_ms=cost_ms,
|
||||
error_message=str(exc),
|
||||
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||
)
|
||||
_mark_retrieval_failed(db, rag_result, str(exc), cost_ms)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
|
||||
answer = "".join(answer_parts)
|
||||
if not answer.strip():
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
AiRequestLogService.write_failed(
|
||||
db,
|
||||
session_id=session.id,
|
||||
message_id=user_message.id,
|
||||
user_id=user.id,
|
||||
model_name=model_response.model_name if model_response is not None else None,
|
||||
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
|
||||
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||
cost_ms=cost_ms,
|
||||
error_message="模型未返回有效内容",
|
||||
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||
)
|
||||
_mark_retrieval_failed(db, rag_result, "模型未返回有效内容", cost_ms)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
|
||||
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=answer,
|
||||
message_status="FINISHED",
|
||||
token_input=model_response.input_token if model_response is not None else None,
|
||||
token_output=_rough_token_count(answer),
|
||||
response_time_ms=cost_ms,
|
||||
model_id=model_response.model_id if model_response is not None else None,
|
||||
created_at=now,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
db.flush()
|
||||
|
||||
session.message_count += 2
|
||||
session.last_message_at = now
|
||||
if session.title == "新聊天":
|
||||
session.title = _title_from_question(normalized_question)
|
||||
|
||||
user.daily_chat_used += 1
|
||||
db.add_all([session, user])
|
||||
AiRequestLogService.write_success(
|
||||
db,
|
||||
session_id=session.id,
|
||||
message_id=assistant_message.id,
|
||||
user_id=user.id,
|
||||
model_name=model_response.model_name if model_response is not None else "unknown",
|
||||
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else "",
|
||||
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||
input_token=model_response.input_token if model_response is not None else 0,
|
||||
output_token=_rough_token_count(answer),
|
||||
cost_ms=cost_ms,
|
||||
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
async def stream_answer_async(db: Session, user: User, session_id: int, question: str) -> AsyncIterator[str]:
|
||||
user = ChatService.prepare_daily_quota(db, user)
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
ChatService._ensure_quota(user)
|
||||
|
||||
now = _now()
|
||||
normalized_question = question.strip()
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=normalized_question,
|
||||
message_status="FINISHED",
|
||||
created_at=now,
|
||||
)
|
||||
db.add(user_message)
|
||||
db.flush()
|
||||
|
||||
history = list(
|
||||
db.scalars(
|
||||
select(ChatMessage)
|
||||
.where(
|
||||
ChatMessage.session_id == session.id,
|
||||
ChatMessage.user_id == user.id,
|
||||
ChatMessage.id < user_message.id,
|
||||
)
|
||||
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||
)
|
||||
)
|
||||
summary_result = await ChatContextService.update_summary_async(db, session, history)
|
||||
context_trace = [summary_result.trace] if summary_result.trace else None
|
||||
|
||||
started_at = perf_counter()
|
||||
rag_result = None
|
||||
model_response = None
|
||||
answer_parts: list[str] = []
|
||||
|
||||
try:
|
||||
rag_result = await AsyncRagService.build_result(
|
||||
db,
|
||||
user,
|
||||
normalized_question,
|
||||
history=history,
|
||||
session_summary=getattr(session, "summary", None),
|
||||
summary_up_to_message_id=getattr(session, "summary_up_to_message_id", None),
|
||||
session_id=session.id,
|
||||
context_trace=context_trace,
|
||||
)
|
||||
model_response = ModelStreamService.stream_async(db, rag_result)
|
||||
async for chunk in model_response.chunks:
|
||||
if chunk:
|
||||
answer_parts.append(chunk)
|
||||
yield chunk
|
||||
except asyncio.CancelledError:
|
||||
db.rollback()
|
||||
raise
|
||||
except ExternalServiceError as exc:
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
AiRequestLogService.write_failed(
|
||||
db,
|
||||
session_id=session.id,
|
||||
message_id=user_message.id,
|
||||
user_id=user.id,
|
||||
model_name=model_response.model_name if model_response is not None else None,
|
||||
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
|
||||
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||
cost_ms=cost_ms,
|
||||
error_message=str(exc),
|
||||
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||
)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
|
||||
answer = "".join(answer_parts)
|
||||
if not answer.strip():
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
AiRequestLogService.write_failed(
|
||||
db,
|
||||
session_id=session.id,
|
||||
message_id=user_message.id,
|
||||
user_id=user.id,
|
||||
model_name=model_response.model_name if model_response is not None else None,
|
||||
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
|
||||
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||
cost_ms=cost_ms,
|
||||
error_message="模型未返回有效内容",
|
||||
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||
)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
|
||||
|
||||
_write_success(
|
||||
db,
|
||||
user=user,
|
||||
session=session,
|
||||
question=normalized_question,
|
||||
answer=answer,
|
||||
rag_result=rag_result,
|
||||
model_response=model_response,
|
||||
started_at=started_at,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _rough_token_count(text: str) -> int:
|
||||
return max(1, len(text.strip()) // 2)
|
||||
|
||||
|
||||
def _write_success(
|
||||
db: Session,
|
||||
*,
|
||||
user: User,
|
||||
session,
|
||||
question: str,
|
||||
answer: str,
|
||||
rag_result,
|
||||
model_response,
|
||||
started_at: float,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=answer,
|
||||
message_status="FINISHED",
|
||||
token_input=model_response.input_token if model_response is not None else None,
|
||||
token_output=_rough_token_count(answer),
|
||||
response_time_ms=cost_ms,
|
||||
model_id=model_response.model_id if model_response is not None else None,
|
||||
created_at=now,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
db.flush()
|
||||
|
||||
session.message_count += 2
|
||||
session.last_message_at = now
|
||||
if session.title == "新聊天":
|
||||
session.title = _title_from_question(question)
|
||||
|
||||
user.daily_chat_used += 1
|
||||
db.add_all([session, user])
|
||||
AiRequestLogService.write_success(
|
||||
db,
|
||||
session_id=session.id,
|
||||
message_id=assistant_message.id,
|
||||
user_id=user.id,
|
||||
model_name=model_response.model_name if model_response is not None else "unknown",
|
||||
prompt=rag_result.prompt if rag_result is not None else question,
|
||||
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else "",
|
||||
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||
input_token=model_response.input_token if model_response is not None else 0,
|
||||
output_token=_rough_token_count(answer),
|
||||
cost_ms=cost_ms,
|
||||
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||
)
|
||||
if rag_result is not None and rag_result.retrieval_log_id:
|
||||
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)
|
||||
if retrieval_log:
|
||||
attention = HumanAttentionService.create_if_needed(
|
||||
db,
|
||||
session_id=session.id,
|
||||
message_id=user_message_id_for_attention(db, session.id, assistant_message.id),
|
||||
user_id=user.id,
|
||||
question=question,
|
||||
answer=answer,
|
||||
knowledge_missing=not rag_result.allow_general_knowledge and not rag_result.is_hit,
|
||||
)
|
||||
retrieval_log.message_id = assistant_message.id
|
||||
retrieval_log.final_answer = answer
|
||||
retrieval_log.status = "success"
|
||||
retrieval_log.total_cost_ms = cost_ms
|
||||
retrieval_log.attention_created = 1 if attention else 0
|
||||
db.add(retrieval_log)
|
||||
db.commit()
|
||||
|
||||
|
||||
def user_message_id_for_attention(db: Session, session_id: int, assistant_message_id: int) -> int:
|
||||
message_id = db.scalar(
|
||||
select(ChatMessage.id)
|
||||
.where(
|
||||
ChatMessage.session_id == session_id,
|
||||
ChatMessage.role == "user",
|
||||
ChatMessage.id < assistant_message_id,
|
||||
)
|
||||
.order_by(ChatMessage.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return int(message_id or assistant_message_id)
|
||||
|
||||
|
||||
def _mark_retrieval_failed(db: Session, rag_result, error_message: str, cost_ms: int) -> None:
|
||||
if rag_result is None or not rag_result.retrieval_log_id:
|
||||
return
|
||||
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)
|
||||
if retrieval_log:
|
||||
retrieval_log.status = "failed"
|
||||
retrieval_log.error_message = error_message
|
||||
retrieval_log.total_cost_ms = cost_ms
|
||||
db.add(retrieval_log)
|
||||
|
||||
|
||||
def _build_rag_result(
|
||||
db: Session,
|
||||
user: User,
|
||||
question: str,
|
||||
history: list[ChatMessage],
|
||||
summary: str | None,
|
||||
summary_up_to_message_id: int | None,
|
||||
context_trace: list[dict] | None = None,
|
||||
):
|
||||
parameters = signature(RagService.build_result).parameters
|
||||
if "history" in parameters:
|
||||
return RagService.build_result(
|
||||
db,
|
||||
user,
|
||||
question,
|
||||
history=history,
|
||||
session_summary=summary,
|
||||
summary_up_to_message_id=summary_up_to_message_id,
|
||||
context_trace=context_trace,
|
||||
)
|
||||
return RagService.build_result(db, user, question)
|
||||
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ExternalServiceError(RuntimeError):
|
||||
def __init__(self, message: str, *, provider: str) -> None:
|
||||
super().__init__(message)
|
||||
self.provider = provider
|
||||
@@ -0,0 +1,436 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
from app.services.feishu_service import (
|
||||
FEISHU_OPEN_API,
|
||||
FeishuKnowledgeService,
|
||||
FeishuRetrievalConfig,
|
||||
_build_search_payload,
|
||||
_feishu_retrieval_config,
|
||||
_parse_docx_content,
|
||||
_parse_search_chunks,
|
||||
_response_error_detail,
|
||||
_token_cache,
|
||||
_token_cache_key,
|
||||
)
|
||||
from app.services.knowledge_service import KnowledgeScope
|
||||
from app.services.redis_cache_service import redis_get_json, redis_get_text, redis_set_json, redis_set_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.rag_service import RetrievedChunk
|
||||
|
||||
|
||||
class AsyncFeishuKnowledgeService:
|
||||
"""飞书知识库异步检索服务,保持和同步检索相同的结果结构。"""
|
||||
|
||||
@classmethod
|
||||
async def retrieve(
|
||||
cls,
|
||||
question: str,
|
||||
scopes: list[KnowledgeScope],
|
||||
db: Session | None = None,
|
||||
) -> list[RetrievedChunk]:
|
||||
if not scopes:
|
||||
return []
|
||||
|
||||
config = _feishu_retrieval_config(db)
|
||||
if config.search_url:
|
||||
try:
|
||||
return await cls._retrieve_from_search(question, scopes, config)
|
||||
except ExternalServiceError as exc:
|
||||
try:
|
||||
return await cls._retrieve_from_feishu(question, scopes, config)
|
||||
except ExternalServiceError as fallback_exc:
|
||||
raise ExternalServiceError(
|
||||
f"{exc};飞书直读也失败:{fallback_exc}",
|
||||
provider="feishu",
|
||||
) from fallback_exc
|
||||
return await cls._retrieve_from_feishu(question, scopes, config)
|
||||
|
||||
@classmethod
|
||||
async def _retrieve_from_search(
|
||||
cls,
|
||||
question: str,
|
||||
scopes: list[KnowledgeScope],
|
||||
config: FeishuRetrievalConfig,
|
||||
) -> list[RetrievedChunk]:
|
||||
from app.services.rag_service import RetrievedChunk
|
||||
|
||||
payload = _build_search_payload(question, scopes)
|
||||
last_error: Exception | None = None
|
||||
async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
|
||||
for _ in range(max(1, config.retry_count + 1)):
|
||||
try:
|
||||
response = await client.post(config.search_url, json=payload)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
return _parse_search_chunks(body, scopes, RetrievedChunk)
|
||||
except (ValueError, httpx.HTTPError) as exc:
|
||||
last_error = exc
|
||||
|
||||
raise ExternalServiceError(f"飞书搜索接口调用失败:{last_error}", provider="feishu-search")
|
||||
|
||||
@classmethod
|
||||
async def _retrieve_from_feishu(
|
||||
cls,
|
||||
question: str,
|
||||
scopes: list[KnowledgeScope],
|
||||
config: FeishuRetrievalConfig,
|
||||
) -> list[RetrievedChunk]:
|
||||
from app.services.rag_service import RetrievedChunk
|
||||
|
||||
if not config.app_id or not config.app_secret:
|
||||
raise ExternalServiceError(
|
||||
"未配置飞书应用凭证,无法读取飞书知识库。请配置 FEISHU_APP_ID/FEISHU_APP_SECRET,"
|
||||
"或在系统设置中填写飞书 AppID/AppSecret。",
|
||||
provider="feishu",
|
||||
)
|
||||
|
||||
semaphore = asyncio.Semaphore(3)
|
||||
|
||||
async def load_scope(scope: KnowledgeScope):
|
||||
async with semaphore:
|
||||
return await cls._load_documents(scope, config)
|
||||
|
||||
results = await asyncio.gather(*(load_scope(scope) for scope in scopes), return_exceptions=True)
|
||||
|
||||
all_chunks: list[tuple[KnowledgeScope, str, str, str | None]] = []
|
||||
load_errors: list[str] = []
|
||||
for scope, result in zip(scopes, results, strict=False):
|
||||
if isinstance(result, ExternalServiceError):
|
||||
load_errors.append(f"{scope.name}:{result}")
|
||||
continue
|
||||
if isinstance(result, Exception):
|
||||
load_errors.append(f"{scope.name}:{result}")
|
||||
continue
|
||||
for title, content, source_url in result:
|
||||
all_chunks.append((scope, title, content, source_url))
|
||||
|
||||
if not all_chunks:
|
||||
if load_errors:
|
||||
raise ExternalServiceError(
|
||||
"飞书知识库读取失败:" + ";".join(load_errors[:3]),
|
||||
provider="feishu",
|
||||
)
|
||||
return []
|
||||
|
||||
scored_chunks = FeishuKnowledgeService._score_and_rank(question, all_chunks)
|
||||
return [
|
||||
RetrievedChunk(
|
||||
knowledge_id=scope.id,
|
||||
knowledge_name=scope.name,
|
||||
title=title,
|
||||
content=content,
|
||||
source_url=source_url,
|
||||
)
|
||||
for scope, title, content, source_url in scored_chunks[:5]
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def _load_documents(
|
||||
cls,
|
||||
scope: KnowledgeScope,
|
||||
config: FeishuRetrievalConfig,
|
||||
) -> list[tuple[str, str, str | None]]:
|
||||
node_id = scope.feishu_node_id
|
||||
base_url = "https://zcn7hk6n047c.feishu.cn"
|
||||
|
||||
if not node_id:
|
||||
return []
|
||||
|
||||
try:
|
||||
node_info = await cls._get_node_info_with_cache(node_id, config)
|
||||
except ExternalServiceError as node_exc:
|
||||
try:
|
||||
content = await cls._get_document_content_with_cache(node_id, config)
|
||||
return [(node_id, content, f"{base_url}/docx/{node_id}")]
|
||||
except ExternalServiceError as docx_exc:
|
||||
raise ExternalServiceError(
|
||||
f"飞书节点读取失败:{node_exc};按普通 docx token 读取也失败:{docx_exc}",
|
||||
provider="feishu",
|
||||
) from docx_exc
|
||||
|
||||
obj_type = node_info.get("obj_type", "")
|
||||
obj_token = node_info.get("obj_token", "")
|
||||
title = node_info.get("title", node_id)
|
||||
space_id = node_info.get("space_id", scope.feishu_space_id)
|
||||
|
||||
if obj_type == "docx":
|
||||
content = await cls._get_document_content_with_cache(obj_token, config)
|
||||
return [(title, content, f"{base_url}/wiki/{node_id}")]
|
||||
if obj_type == "file":
|
||||
content = await cls._get_file_content_with_cache(obj_token, config)
|
||||
return [(title, content, f"{base_url}/wiki/{node_id}")]
|
||||
|
||||
children = await cls._get_wiki_children_with_cache(space_id, node_id, config)
|
||||
documents: list[tuple[str, str, str | None]] = []
|
||||
for child in children:
|
||||
child_node_token = child.get("node_token", "")
|
||||
child_obj_type = child.get("obj_type", "")
|
||||
child_obj_token = child.get("obj_token", "")
|
||||
child_title = child.get("title", "")
|
||||
|
||||
try:
|
||||
if child_obj_type == "docx" and child_obj_token:
|
||||
content = await cls._get_document_content_with_cache(child_obj_token, config)
|
||||
elif child_obj_type == "file" and child_obj_token:
|
||||
content = await cls._get_file_content_with_cache(child_obj_token, config)
|
||||
else:
|
||||
continue
|
||||
documents.append((child_title, content, f"{base_url}/wiki/{child_node_token}"))
|
||||
except ExternalServiceError:
|
||||
continue
|
||||
|
||||
if obj_type == "docx" and obj_token:
|
||||
try:
|
||||
content = await cls._get_document_content_with_cache(obj_token, config)
|
||||
documents.insert(0, (title, content, f"{base_url}/wiki/{node_id}"))
|
||||
except ExternalServiceError:
|
||||
pass
|
||||
|
||||
return documents
|
||||
|
||||
@classmethod
|
||||
async def _get_node_info_with_cache(cls, node_token: str, config: FeishuRetrievalConfig) -> dict:
|
||||
cache_key = f"{config.app_id}:node_info:{node_token}"
|
||||
redis_key = _redis_cache_key(config, "node", node_token)
|
||||
now = time.time()
|
||||
|
||||
if cache_key in FeishuKnowledgeService._content_cache:
|
||||
cached_data, ts = FeishuKnowledgeService._content_cache[cache_key]
|
||||
if now - ts < FeishuKnowledgeService._CACHE_TTL:
|
||||
return eval(cached_data) # noqa: S307
|
||||
cached_info = await redis_get_json(redis_key)
|
||||
if isinstance(cached_info, dict):
|
||||
FeishuKnowledgeService._content_cache[cache_key] = (str(cached_info), now)
|
||||
return cached_info
|
||||
|
||||
info = await _get_wiki_node_info_async(node_token, config)
|
||||
FeishuKnowledgeService._content_cache[cache_key] = (str(info), now)
|
||||
await redis_set_json(redis_key, info, FeishuKnowledgeService._CACHE_TTL)
|
||||
return info
|
||||
|
||||
@classmethod
|
||||
async def _get_document_content_with_cache(cls, doc_token: str, config: FeishuRetrievalConfig) -> str:
|
||||
cache_key = f"{config.app_id}:doc:{doc_token}"
|
||||
redis_key = _redis_cache_key(config, "doc", doc_token)
|
||||
now = time.time()
|
||||
|
||||
if cache_key in FeishuKnowledgeService._content_cache:
|
||||
content, ts = FeishuKnowledgeService._content_cache[cache_key]
|
||||
if now - ts < FeishuKnowledgeService._CACHE_TTL:
|
||||
return content
|
||||
cached_content = await redis_get_text(redis_key)
|
||||
if cached_content:
|
||||
FeishuKnowledgeService._content_cache[cache_key] = (cached_content, now)
|
||||
return cached_content
|
||||
|
||||
content = await _get_docx_content_async(doc_token, config)
|
||||
FeishuKnowledgeService._content_cache[cache_key] = (content, now)
|
||||
await redis_set_text(redis_key, content, FeishuKnowledgeService._CACHE_TTL)
|
||||
return content
|
||||
|
||||
@classmethod
|
||||
async def _get_file_content_with_cache(cls, file_token: str, config: FeishuRetrievalConfig) -> str:
|
||||
cache_key = f"{config.app_id}:file:{file_token}"
|
||||
redis_key = _redis_cache_key(config, "file", file_token)
|
||||
now = time.time()
|
||||
|
||||
if cache_key in FeishuKnowledgeService._content_cache:
|
||||
content, ts = FeishuKnowledgeService._content_cache[cache_key]
|
||||
if now - ts < FeishuKnowledgeService._CACHE_TTL:
|
||||
return content
|
||||
cached_content = await redis_get_text(redis_key)
|
||||
if cached_content:
|
||||
FeishuKnowledgeService._content_cache[cache_key] = (cached_content, now)
|
||||
return cached_content
|
||||
|
||||
content = await _get_file_content_async(file_token, config)
|
||||
FeishuKnowledgeService._content_cache[cache_key] = (content, now)
|
||||
await redis_set_text(redis_key, content, FeishuKnowledgeService._CACHE_TTL)
|
||||
return content
|
||||
|
||||
@classmethod
|
||||
async def _get_wiki_children_with_cache(
|
||||
cls,
|
||||
space_id: str,
|
||||
node_token: str,
|
||||
config: FeishuRetrievalConfig,
|
||||
) -> list[dict]:
|
||||
cache_key = f"{config.app_id}:children:{space_id}:{node_token}"
|
||||
redis_key = _redis_cache_key(config, "children", space_id, node_token)
|
||||
now = time.time()
|
||||
|
||||
if cache_key in FeishuKnowledgeService._content_cache:
|
||||
cached_data, ts = FeishuKnowledgeService._content_cache[cache_key]
|
||||
if now - ts < FeishuKnowledgeService._CACHE_TTL:
|
||||
return eval(cached_data) # noqa: S307
|
||||
cached_children = await redis_get_json(redis_key)
|
||||
if isinstance(cached_children, list):
|
||||
FeishuKnowledgeService._content_cache[cache_key] = (str(cached_children), now)
|
||||
return cached_children
|
||||
|
||||
children = await _get_wiki_children_async(space_id, node_token, config)
|
||||
FeishuKnowledgeService._content_cache[cache_key] = (str(children), now)
|
||||
await redis_set_json(redis_key, children, FeishuKnowledgeService._CACHE_TTL)
|
||||
return children
|
||||
|
||||
|
||||
async def _get_tenant_token_async(config: FeishuRetrievalConfig) -> str:
|
||||
now = time.time()
|
||||
cache_key = _token_cache_key(config.app_id, config.app_secret)
|
||||
redis_key = _redis_cache_key(config, "tenant_token")
|
||||
cached = _token_cache.get(cache_key)
|
||||
|
||||
if cached and cached["token"] and now < cached["expires_at"]:
|
||||
return cached["token"]
|
||||
cached_token = await redis_get_text(redis_key)
|
||||
if cached_token:
|
||||
_token_cache[cache_key] = {"token": cached_token, "expires_at": now + 300}
|
||||
return cached_token
|
||||
|
||||
url = f"{FEISHU_OPEN_API}/open-apis/auth/v3/tenant_access_token/internal"
|
||||
payload = {"app_id": config.app_id, "app_secret": config.app_secret}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
|
||||
resp = await client.post(url, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
raise ExternalServiceError(
|
||||
f"获取飞书Token失败:{data.get('msg', '未知错误')}",
|
||||
provider="feishu",
|
||||
)
|
||||
|
||||
token = data["tenant_access_token"]
|
||||
expire = data.get("expire", 7200)
|
||||
_token_cache[cache_key] = {"token": token, "expires_at": now + expire - 300}
|
||||
await redis_set_text(redis_key, token, max(60, int(expire) - 300))
|
||||
return token
|
||||
except httpx.HTTPError as exc:
|
||||
raise ExternalServiceError(f"获取飞书Token网络错误:{exc}", provider="feishu") from exc
|
||||
|
||||
|
||||
async def _feishu_get_async(path: str, *, params: dict | None = None, config: FeishuRetrievalConfig) -> dict:
|
||||
token = await _get_tenant_token_async(config)
|
||||
url = f"{FEISHU_OPEN_API}{path}"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
|
||||
resp = await client.get(url, headers=headers, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ExternalServiceError(
|
||||
f"飞书HTTP请求失败 [{path}]:{exc.response.status_code} {_response_error_detail(exc.response)}",
|
||||
provider="feishu",
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ExternalServiceError(f"飞书网络请求失败 [{path}]:{exc}", provider="feishu") from exc
|
||||
|
||||
if data.get("code") != 0:
|
||||
raise ExternalServiceError(
|
||||
f"飞书API调用失败 [{path}]:{data.get('msg', '未知错误')}",
|
||||
provider="feishu",
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def _feishu_download_async(path: str, config: FeishuRetrievalConfig) -> bytes:
|
||||
token = await _get_tenant_token_async(config)
|
||||
url = f"{FEISHU_OPEN_API}{path}"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ExternalServiceError(
|
||||
f"飞书文件下载失败 [{path}]:{exc.response.status_code} {_response_error_detail(exc.response)}",
|
||||
provider="feishu",
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ExternalServiceError(f"飞书文件下载网络错误 [{path}]:{exc}", provider="feishu") from exc
|
||||
|
||||
|
||||
async def _get_docx_content_async(doc_token: str, config: FeishuRetrievalConfig) -> str:
|
||||
path = f"/open-apis/docx/v1/documents/{doc_token}/blocks"
|
||||
items: list[dict] = []
|
||||
page_token = ""
|
||||
while True:
|
||||
params: dict[str, Any] = {"page_size": 500}
|
||||
if page_token:
|
||||
params["page_token"] = page_token
|
||||
data = await _feishu_get_async(path, params=params, config=config)
|
||||
page = data.get("data", {})
|
||||
items.extend(page.get("items", []))
|
||||
page_token = str(page.get("page_token") or "")
|
||||
if not page.get("has_more") or not page_token:
|
||||
break
|
||||
return _parse_docx_content(items)
|
||||
|
||||
|
||||
async def _get_file_content_async(file_token: str, config: FeishuRetrievalConfig) -> str:
|
||||
content = await _feishu_download_async(f"/open-apis/drive/v1/files/{file_token}/download", config)
|
||||
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
|
||||
try:
|
||||
return content.decode(encoding).strip()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
raise ExternalServiceError("飞书文件不是可解析的文本内容", provider="feishu")
|
||||
|
||||
|
||||
async def _get_wiki_node_info_async(node_token: str, config: FeishuRetrievalConfig) -> dict:
|
||||
data = await _feishu_get_async("/open-apis/wiki/v2/spaces/get_node", params={"token": node_token}, config=config)
|
||||
node = data.get("data", {}).get("node", {})
|
||||
return {
|
||||
"space_id": node.get("space_id", ""),
|
||||
"obj_token": node.get("obj_token", ""),
|
||||
"obj_type": node.get("obj_type", ""),
|
||||
"title": node.get("title", ""),
|
||||
"node_token": node.get("node_token", ""),
|
||||
}
|
||||
|
||||
|
||||
async def _get_wiki_children_async(
|
||||
space_id: str,
|
||||
node_token: str,
|
||||
config: FeishuRetrievalConfig,
|
||||
page_size: int = 50,
|
||||
) -> list[dict]:
|
||||
if not space_id:
|
||||
raise ExternalServiceError("飞书知识库缺少 SpaceID,无法读取子节点", provider="feishu")
|
||||
|
||||
all_children = []
|
||||
page_token = ""
|
||||
while True:
|
||||
params: dict[str, Any] = {"page_size": page_size, "parent_node_token": node_token}
|
||||
if page_token:
|
||||
params["page_token"] = page_token
|
||||
|
||||
data = await _feishu_get_async(f"/open-apis/wiki/v2/spaces/{space_id}/nodes", params=params, config=config)
|
||||
children = data.get("data", {}).get("items", [])
|
||||
all_children.extend(children)
|
||||
|
||||
page_token = data.get("data", {}).get("page_token", "")
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
return all_children
|
||||
|
||||
|
||||
def _redis_cache_key(config: FeishuRetrievalConfig, *parts: str) -> str:
|
||||
namespace = _token_cache_key(config.app_id, config.app_secret)
|
||||
suffix = ":".join(str(part) for part in parts)
|
||||
return f"ai_kb:feishu:{namespace}:{suffix}"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user