feat: add rag backend and review access fixes
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
# RAG 后端上线清单
|
||||
|
||||
适用范围:
|
||||
- 仓库:`leaudit-platform`
|
||||
- 本次上线内容:自有 RAG 聊天后端、`/api/v3/rag/*` 接口、`rag_*` 表结构、`rag:*` 权限
|
||||
|
||||
## 1. 上线前确认
|
||||
|
||||
- 代码已包含以下变更:
|
||||
- `fastapi_modules/fastapi_leaudit/controllers/ragChatController.py`
|
||||
- `fastapi_modules/fastapi_leaudit/services/impl/ragChatServiceImpl.py`
|
||||
- `fastapi_modules/fastapi_leaudit/services/impl/ragDatasetServiceImpl.py`
|
||||
- `scripts/schema_add_rag_chat.sql`
|
||||
- `scripts/user_rbac_seed.sql`
|
||||
- `pyproject.toml` 已加入 `chromadb`
|
||||
- 发布分支已经合并到目标部署分支。
|
||||
- 数据库备份已完成,至少备份:
|
||||
- `permissions`
|
||||
- `role_permissions`
|
||||
- `role_route`
|
||||
- `sys_routes`
|
||||
- 全部 `rag_*` 表
|
||||
|
||||
## 2. 配置优先级
|
||||
|
||||
后端配置加载顺序见 `fastapi_admin/config/_loader.py`:
|
||||
|
||||
1. `app.toml`
|
||||
2. `app.{APP_ENV}.toml`
|
||||
3. `app.ai.toml`
|
||||
4. 已存在的环境变量
|
||||
|
||||
结论:
|
||||
- 生产环境如果通过环境变量注入,优先级最高。
|
||||
- 如果继续使用 TOML,建议把生产密钥放到 `app.ai.toml` 或外部环境变量,不要直接把敏感值写死在默认 `app.toml`。
|
||||
|
||||
## 3. 必备环境变量 / 配置项
|
||||
|
||||
### 3.1 基础服务配置
|
||||
|
||||
来自 `fastapi_admin/config/_settings.py`:
|
||||
|
||||
- `APP_HOST`
|
||||
- `APP_PORT`
|
||||
- `JWT_SECRET_KEY`
|
||||
- `DB_HOST`
|
||||
- `DB_PORT`
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `REDIS_HOST`
|
||||
- `REDIS_PORT`
|
||||
- `REDIS_DB`
|
||||
- `REDIS_PASSWORD`
|
||||
- `OSS_ENDPOINT`
|
||||
- `OSS_BASE_URL`
|
||||
- `OSS_ACCESS_KEY`
|
||||
- `OSS_SECRET_KEY`
|
||||
- `OSS_BUCKET`
|
||||
|
||||
### 3.2 RAG / LLM 配置
|
||||
|
||||
来自 `fastapi_modules/fastapi_leaudit/rag_engine/config.py`:
|
||||
|
||||
- `LLM_BASE_URL`
|
||||
- `LLM_MODEL`
|
||||
- `LLM_API_KEY`
|
||||
- `RAG_LLM_TEMPERATURE`
|
||||
- `RAG_LLM_MAX_TOKENS`
|
||||
- `RAG_LLM_TIMEOUT`
|
||||
|
||||
### 3.3 Chroma 配置
|
||||
|
||||
二选一:
|
||||
|
||||
- 远程 Chroma:
|
||||
- `RAG_CHROMA_HOST`
|
||||
- `RAG_CHROMA_PORT`
|
||||
- `RAG_CHROMA_TOKEN`(如有鉴权)
|
||||
- `RAG_CHROMA_AUTH_HEADER`(默认 `X-Chroma-Token`)
|
||||
- 本地持久化 Chroma:
|
||||
- `RAG_CHROMA_PERSIST_DIR`
|
||||
|
||||
说明:
|
||||
- 如果不配置 `RAG_CHROMA_HOST`,代码会走本地 `PersistentClient`。
|
||||
- 如果 Chroma 依赖缺失或不可用,服务能启动,但检索会退化成“无上下文回答”。
|
||||
|
||||
### 3.4 可选检索参数
|
||||
|
||||
- `RAG_VECTOR_TOP_K`
|
||||
- `RAG_RERANK_TOP_K`
|
||||
- `RAG_BM25_TOP_K`
|
||||
- `RAG_RRF_K`
|
||||
- `RAG_QUERY_REWRITING`
|
||||
- `RAG_HYBRID_SEARCH`
|
||||
- `RAG_RERANKING`
|
||||
- `RAG_EMBED_URL`
|
||||
- `RAG_EMBED_KEY`
|
||||
- `RAG_EMBED_MODEL`
|
||||
- `RAG_EMBED_DIM`
|
||||
- `RAG_EMBED_BATCH_SIZE`
|
||||
- `RAG_RERANKER_URL`
|
||||
- `RAG_RERANKER_KEY`
|
||||
- `RAG_RERANKER_MODEL`
|
||||
|
||||
说明:
|
||||
- 当前聊天主流程已经能工作;即使没有完整 embedding/reranker 配置,只要现有 Chroma 集合可用,也可以先上线基础聊天。
|
||||
- 更细的召回参数也可以放在 `rag_dataset.retrieval_model` 里按知识库配置。
|
||||
|
||||
## 4. 依赖安装
|
||||
|
||||
### 4.1 Python 依赖
|
||||
|
||||
本次新增依赖:
|
||||
|
||||
- `chromadb>=0.5.23`
|
||||
|
||||
如果线上使用虚拟环境:
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
如果只补增量依赖:
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
pip install chromadb
|
||||
```
|
||||
|
||||
建议最终仍执行一次完整依赖同步,避免环境漂移。
|
||||
|
||||
## 5. 数据库执行顺序
|
||||
|
||||
### 5.1 先执行结构脚本
|
||||
|
||||
```bash
|
||||
psql -v ON_ERROR_STOP=1 \
|
||||
-h <DB_HOST> -p <DB_PORT> -U <DB_USER> -d <DB_NAME> \
|
||||
-f scripts/schema_add_rag_chat.sql
|
||||
```
|
||||
|
||||
作用:
|
||||
- 创建 `rag_dataset / rag_document / rag_chat_app / rag_conversation / rag_message`
|
||||
- 对已存在表补字段、补索引、补 `updated_at` 触发器
|
||||
|
||||
### 5.2 再执行权限种子
|
||||
|
||||
```bash
|
||||
psql -v ON_ERROR_STOP=1 \
|
||||
-h <DB_HOST> -p <DB_PORT> -U <DB_USER> -d <DB_NAME> \
|
||||
-f scripts/user_rbac_seed.sql
|
||||
```
|
||||
|
||||
作用:
|
||||
- 补 `rag:*` 权限
|
||||
- 补角色授权
|
||||
- 同步需要的菜单/路由元数据
|
||||
|
||||
### 5.3 当前已验证结果
|
||||
|
||||
本地已在目标库执行并验证:
|
||||
|
||||
- 已存在 5 张 `rag_*` 表
|
||||
- 已存在 7 条 `rag:*` 权限
|
||||
- `super_admin / provincial_admin / admin / common` 均已获得这 7 条权限
|
||||
|
||||
## 6. 重启顺序
|
||||
|
||||
推荐顺序如下。
|
||||
|
||||
### 步骤 1:停 Celery worker
|
||||
|
||||
如果当前有 worker 在跑,先停掉,避免旧代码消费任务:
|
||||
|
||||
```bash
|
||||
pkill -f "celery.*fastapi_admin.celery_app:celery_app" || true
|
||||
```
|
||||
|
||||
如果你们线上是用进程管理器托管 worker,则用对应管理命令停止。
|
||||
|
||||
### 步骤 2:更新代码并安装依赖
|
||||
|
||||
```bash
|
||||
git pull
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 步骤 3:执行数据库脚本
|
||||
|
||||
按“第 5 节”的先后顺序执行:
|
||||
|
||||
1. `scripts/schema_add_rag_chat.sql`
|
||||
2. `scripts/user_rbac_seed.sql`
|
||||
|
||||
### 步骤 4:重启 FastAPI 主服务
|
||||
|
||||
如果用 `uvicorn` 直接启动,参考 `run.py`:
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
python run.py
|
||||
```
|
||||
|
||||
如果线上不是 `reload=True` 的开发模式,建议使用你们现有的生产启动命令重启主进程,不要直接照抄开发命令。
|
||||
|
||||
### 步骤 5:重启 Celery worker
|
||||
|
||||
仓库已提供脚本 `scripts/start_worker.sh`:
|
||||
|
||||
```bash
|
||||
./scripts/start_worker.sh
|
||||
```
|
||||
|
||||
它会自动读取:
|
||||
- `LEAUDIT_WORKER_CONCURRENCY`
|
||||
- `LEAUDIT_WORKER_QUEUE_URGENT`
|
||||
- `LEAUDIT_WORKER_QUEUE_NORMAL`
|
||||
|
||||
### 步骤 6:如有反向代理,再做一次健康检查
|
||||
|
||||
- Nginx / 网关 upstream 是否恢复
|
||||
- 80/443 -> FastAPI upstream 是否正常
|
||||
|
||||
## 7. 上线后验证
|
||||
|
||||
### 7.1 代码级快速检查
|
||||
|
||||
```bash
|
||||
python -m compileall fastapi_modules/fastapi_leaudit
|
||||
```
|
||||
|
||||
### 7.2 路由检查
|
||||
|
||||
已知应存在 10 条 `/v3/rag` 路由:
|
||||
|
||||
- `GET /api/v3/rag/apps`
|
||||
- `GET /api/v3/rag/apps/default`
|
||||
- `GET /api/v3/rag/datasets/my`
|
||||
- `GET /api/v3/rag/chat/parameters`
|
||||
- `POST /api/v3/rag/chat/messages`
|
||||
- `GET /api/v3/rag/chat/conversations`
|
||||
- `GET /api/v3/rag/chat/conversations/{ConversationId}/messages`
|
||||
- `PATCH /api/v3/rag/chat/conversations/{ConversationId}`
|
||||
- `DELETE /api/v3/rag/chat/conversations/{ConversationId}`
|
||||
- `POST /api/v3/rag/chat/messages/{MessageId}/feedback`
|
||||
|
||||
### 7.3 数据库检查
|
||||
|
||||
```sql
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename LIKE 'rag_%'
|
||||
ORDER BY tablename;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT count(*)
|
||||
FROM permissions
|
||||
WHERE permission_key LIKE 'rag:%';
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT r.role_key, count(*)
|
||||
FROM role_permissions rp
|
||||
JOIN roles r ON r.id = rp.role_id
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE p.permission_key LIKE 'rag:%'
|
||||
GROUP BY r.role_key
|
||||
ORDER BY r.role_key;
|
||||
```
|
||||
|
||||
### 7.4 接口联通检查
|
||||
|
||||
至少验证这 4 个接口:
|
||||
|
||||
1. `GET /api/v3/rag/apps`
|
||||
2. `GET /api/v3/rag/apps/default`
|
||||
3. `GET /api/v3/rag/chat/parameters`
|
||||
4. `POST /api/v3/rag/chat/messages`
|
||||
|
||||
关注点:
|
||||
- 401:JWT 或登录态异常
|
||||
- 403:`rag:*` 权限未生效
|
||||
- 404:控制器未注册 / 网关路径未转发
|
||||
- 500:LLM、Chroma、数据库连接或 JSON 字段异常
|
||||
|
||||
## 8. 常见风险
|
||||
|
||||
- `chromadb` 未安装:服务启动不一定报错,但首次检索时会退化
|
||||
- Chroma 集合为空:可以聊天,但没有知识库上下文
|
||||
- `LLM_BASE_URL / LLM_API_KEY / LLM_MODEL` 配错:流式对话会直接失败
|
||||
- 只执行权限脚本、不执行结构脚本:接口可能启动,但落库时报错
|
||||
- 只重启 API、不重启 worker:后台异步任务仍可能跑旧代码
|
||||
|
||||
## 9. 建议的最短上线动作
|
||||
|
||||
按最快可落地顺序:
|
||||
|
||||
1. 备份数据库
|
||||
2. `git pull`
|
||||
3. `source .venv/bin/activate && pip install -e .`
|
||||
4. 执行 `scripts/schema_add_rag_chat.sql`
|
||||
5. 执行 `scripts/user_rbac_seed.sql`
|
||||
6. 重启 FastAPI 主服务
|
||||
7. 重启 `scripts/start_worker.sh`
|
||||
8. 验证 `/api/v3/rag/apps` 和 `/api/v3/rag/chat/messages`
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
# 团队 Git 协作完整规范
|
||||
|
||||
> 适用对象:个人项目、小项目、大项目
|
||||
> 版本:v1.0 · 2026-05-07
|
||||
|
||||
---
|
||||
|
||||
## 一、核心原则
|
||||
|
||||
不论团队规模大小,所有 Git 协作都遵守以下 5 条铁律:
|
||||
|
||||
1. **主干(main)永远可发布**
|
||||
2. **凡是会影响他人的操作,事前通知、事中可见、事后可恢复**
|
||||
3. **分支属于"任务",不属于"人"**(详见第六章)
|
||||
4. **推送前先同步**:`git pull --rebase` 后再 `git push`
|
||||
5. **重要分支禁止直接 push,必须走 PR**
|
||||
|
||||
---
|
||||
|
||||
## 二、如何选择工作流
|
||||
|
||||
### 决策树
|
||||
|
||||
```
|
||||
你一个人做这个项目吗?
|
||||
├─ 是 → 【方案 A】个人极简流
|
||||
└─ 否 → 团队 ≥ 6 人 或 同时维护多版本?
|
||||
├─ 是 → 【方案 C】Git Flow Lite(带 develop)
|
||||
└─ 否 → 【方案 B】GitHub Flow(90% 小项目适用)
|
||||
```
|
||||
|
||||
### 三种方案对比
|
||||
|
||||
| 维度 | A·个人 | B·小项目 | C·大项目 |
|
||||
|------|--------|---------|---------|
|
||||
| 团队规模 | 1 人 | 2-5 人 | 6+ 人 |
|
||||
| 主分支 | main | main | main + develop |
|
||||
| 是否需 develop | ❌ | ❌ | ✅ |
|
||||
| 是否需 release 分支 | ❌ | ❌ | ✅(可选) |
|
||||
| 是否需 hotfix 分支 | ❌ | ✅ | ✅ |
|
||||
| 部署策略 | 手动 / tag | 持续部署 | 阶段部署 |
|
||||
| 发布周期 | 不固定 | 随时 | 双周 / 月度 |
|
||||
| PR Review | 自看 | ≥1 人 | ≥2 人 |
|
||||
| 复杂度 | ★ | ★★ | ★★★★ |
|
||||
|
||||
### 升级信号
|
||||
|
||||
**A → B**:第二个人加入,立刻配 main 保护 + PR 流程。
|
||||
|
||||
**B → C**:满足以下任一即升级
|
||||
- 同时维护多个版本(v1 还在用,v2 在开发)
|
||||
- 有专门 QA 阶段,main 不能合并即上线
|
||||
- 发布周期 ≥ 2 周
|
||||
- 团队 ≥ 6 人
|
||||
|
||||
> ⚠️ **宁可滞后升级,也不要预防性复杂化**。多数团队过度设计,把简单事搞复杂。
|
||||
|
||||
---
|
||||
|
||||
## 三、方案 A:个人项目工作流
|
||||
|
||||
### 分支模型
|
||||
|
||||
```
|
||||
main (主线 + 发布锚点)
|
||||
└─ feature/* (按需,仅用于实验性大改动)
|
||||
```
|
||||
|
||||
### 日常工作流
|
||||
|
||||
**小改动 → 直接上 main**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "fix: 修复登录按钮样式"
|
||||
git push
|
||||
```
|
||||
|
||||
**大改动 / 不确定的实验 → 开分支保命**
|
||||
|
||||
```bash
|
||||
git checkout -b feature/重构数据层
|
||||
# ...开发...
|
||||
|
||||
# 没问题 → 合并
|
||||
git checkout main
|
||||
git merge --no-ff feature/重构数据层
|
||||
git push
|
||||
git branch -d feature/重构数据层
|
||||
|
||||
# 实验失败 → 直接丢弃
|
||||
git checkout main
|
||||
git branch -D feature/重构数据层
|
||||
```
|
||||
|
||||
### 发布流程
|
||||
|
||||
```bash
|
||||
git tag -a v1.2.0 -m "release: v1.2.0 - 评查详情页上线"
|
||||
git push --tags
|
||||
```
|
||||
|
||||
**版本号遵循 [SemVer](https://semver.org/)**:
|
||||
- `v主版本.次版本.补丁`
|
||||
- 主版本:不兼容改动
|
||||
- 次版本:新功能(向下兼容)
|
||||
- 补丁:bug 修复
|
||||
|
||||
### 个人项目最低纪律
|
||||
|
||||
| 习惯 | 价值 |
|
||||
|------|------|
|
||||
| 每天 push 到远程 | 硬盘坏了不丢工 |
|
||||
| commit 信息写人话 | 半年后能看懂 |
|
||||
| 重大改动前打 tag | 有回滚锚点 |
|
||||
| README 记录关键决策 | 时间久了不抓瞎 |
|
||||
| `.gitignore` 配齐 | 不把密钥推上去 |
|
||||
|
||||
### 不要做的事
|
||||
|
||||
- ❌ `git commit -m "update"` / `"修改"` / `"123"`
|
||||
- ❌ 一周不 push
|
||||
- ❌ 在 main 上做不确定的实验
|
||||
- ❌ 把 `.env` 推到远程
|
||||
|
||||
---
|
||||
|
||||
## 四、方案 B:小项目工作流(GitHub Flow)
|
||||
|
||||
**适用:2-5 人,持续部署**
|
||||
|
||||
### 分支模型
|
||||
|
||||
```
|
||||
main (受保护,PR 合并,自动部署到生产)
|
||||
├─ feature/评查详情页
|
||||
├─ fix/登录闪退
|
||||
└─ hotfix/支付失败 (紧急修生产)
|
||||
```
|
||||
|
||||
**核心思想**:
|
||||
- main 永远可发布
|
||||
- feature 分支**生命周期短**(1-3 天最佳,最长 1 周)
|
||||
- 所有改动走 PR
|
||||
|
||||
### 日常工作流(每个任务都按这个跑)
|
||||
|
||||
```bash
|
||||
# 1. 开分支前先同步 main
|
||||
git checkout main
|
||||
git pull --rebase
|
||||
|
||||
# 2. 创建任务分支(任务命名,不要带人名)
|
||||
git checkout -b feature/评查详情页
|
||||
|
||||
# 3. 开发,频繁小步提交,频繁推送
|
||||
git add .
|
||||
git commit -m "feat(评查): 新增详情页骨架"
|
||||
git push -u origin feature/评查详情页
|
||||
|
||||
# 4. 推送前同步 main,避免冲突堆积
|
||||
git fetch origin
|
||||
git rebase origin/main
|
||||
# 解决冲突 → 本地测试通过
|
||||
|
||||
# 5. 推送(rebase 后需要 force-with-lease)
|
||||
git push --force-with-lease
|
||||
|
||||
# 6. 提 PR → Review → 合并 → 自动部署
|
||||
|
||||
# 7. 合并后清理本地
|
||||
git checkout main
|
||||
git pull
|
||||
git branch -d feature/评查详情页
|
||||
```
|
||||
|
||||
### PR 流程规范
|
||||
|
||||
**提 PR 时**:
|
||||
- 标题:`<type>(<scope>): <简短描述>`,例:`feat(评查): 新增详情页`
|
||||
- 描述模板:
|
||||
```markdown
|
||||
## 改动内容
|
||||
- ...
|
||||
|
||||
## 测试方法
|
||||
- ...
|
||||
|
||||
## 截图(如有 UI 改动)
|
||||
|
||||
## 关联 Issue
|
||||
Closes #123
|
||||
```
|
||||
- 自检:lint 通过、测试通过、不含调试代码
|
||||
|
||||
**Review 时**:
|
||||
- 24 小时内响应
|
||||
- 给具体可操作的反馈,不要"建议优化一下"
|
||||
- Approve 前自己心里跑一遍代码逻辑
|
||||
|
||||
### 紧急修复(hotfix)
|
||||
|
||||
```bash
|
||||
git checkout main && git pull
|
||||
git checkout -b hotfix/支付失败
|
||||
# 修 → 测 → push → PR → 紧急合并 → 立即部署
|
||||
```
|
||||
|
||||
### 仓库一次性配置(落地关键)
|
||||
|
||||
**main 分支保护(GitHub/GitLab 后台)**:
|
||||
- ✅ 禁止直接 push
|
||||
- ✅ 禁止 force push
|
||||
- ✅ 必须 PR + ≥1 Approve
|
||||
- ✅ 必须通过 CI(lint + test)
|
||||
- ✅ 合并后自动删除分支
|
||||
|
||||
---
|
||||
|
||||
## 五、方案 C:大项目工作流(Git Flow Lite)
|
||||
|
||||
**适用:6+ 人 / 多版本并存 / 有 QA 测试期**
|
||||
|
||||
### 分支模型
|
||||
|
||||
```
|
||||
main (生产版本,受保护,只接受来自 release/hotfix 的合并)
|
||||
└─ develop (开发主线,受保护,所有 feature 合并到这里)
|
||||
├─ feature/评查详情页 (个人功能分支)
|
||||
├─ feature/登录优化
|
||||
├─ release/v2.3.0 (准备发布的快照分支)
|
||||
└─ hotfix/支付失败 (从 main 拉,修完合并回 main + develop)
|
||||
```
|
||||
|
||||
### 各分支角色
|
||||
|
||||
| 分支 | 来源 | 合并去向 | 生命周期 |
|
||||
|------|------|---------|---------|
|
||||
| `main` | 永久 | — | 永久 |
|
||||
| `develop` | 永久 | — | 永久 |
|
||||
| `feature/*` | develop | develop | 任务期 |
|
||||
| `release/*` | develop | main + develop | 发布周期 |
|
||||
| `hotfix/*` | main | main + develop | 紧急修复 |
|
||||
|
||||
### develop 的角色
|
||||
|
||||
- **开发主线**:所有 feature 合并到 develop
|
||||
- **集成测试环境**:QA 在 develop 上测试
|
||||
- **不直接发布**:发布前从 develop 拉 release 分支封板
|
||||
|
||||
### release 分支:发布前的"封板"
|
||||
|
||||
```bash
|
||||
# 1. 从 develop 拉 release 分支(功能冻结)
|
||||
git checkout develop && git pull
|
||||
git checkout -b release/v2.3.0
|
||||
|
||||
# 2. 在 release 上只修 bug,不加新功能
|
||||
# QA 测试 → 修 bug → 改版本号 → 写 changelog
|
||||
|
||||
# 3. 测试通过,合并到 main 并打 tag
|
||||
git checkout main && git pull
|
||||
git merge --no-ff release/v2.3.0
|
||||
git tag -a v2.3.0 -m "release: v2.3.0"
|
||||
git push --tags
|
||||
|
||||
# 4. 合并回 develop(带上 release 上的 bug 修复)
|
||||
git checkout develop
|
||||
git merge --no-ff release/v2.3.0
|
||||
git push
|
||||
|
||||
# 5. 删除 release 分支
|
||||
git branch -d release/v2.3.0
|
||||
```
|
||||
|
||||
### hotfix 分支:紧急修生产
|
||||
|
||||
```bash
|
||||
# 1. 从 main 拉 hotfix 分支
|
||||
git checkout main && git pull
|
||||
git checkout -b hotfix/支付失败
|
||||
|
||||
# 2. 修 → 测 → 提 PR
|
||||
|
||||
# 3. 合并到 main 并打 patch tag
|
||||
git checkout main
|
||||
git merge --no-ff hotfix/支付失败
|
||||
git tag -a v2.3.1 -m "hotfix: 修复支付失败"
|
||||
git push --tags
|
||||
|
||||
# 4. 必须合并回 develop!否则下次发布 bug 复发
|
||||
git checkout develop
|
||||
git merge --no-ff hotfix/支付失败
|
||||
git push
|
||||
|
||||
# 5. 删除 hotfix 分支
|
||||
git branch -d hotfix/支付失败
|
||||
```
|
||||
|
||||
### 日常 feature 工作流
|
||||
|
||||
```bash
|
||||
# 从 develop(不是 main!)开分支
|
||||
git checkout develop && git pull --rebase
|
||||
git checkout -b feature/评查详情页
|
||||
|
||||
# 开发 → 推送 → PR 到 develop(不是 main!)
|
||||
git push -u origin feature/评查详情页
|
||||
# 提 PR:feature/评查详情页 → develop
|
||||
```
|
||||
|
||||
### 大项目仓库配置
|
||||
|
||||
- **main**:禁止 push、禁止 force push、只接受 release/hotfix 合并、必须 ≥2 Approve
|
||||
- **develop**:禁止 push、必须 PR + ≥1 Approve、必须通过 CI
|
||||
- **CI 要求**:lint + 单元测试 + 集成测试 + 静态分析
|
||||
|
||||
---
|
||||
|
||||
## 六、为什么禁止使用个人分支【专题】
|
||||
|
||||
### 反模式(团队中常见错误)
|
||||
|
||||
```
|
||||
❌ feature/评查详情页-zhangsan
|
||||
❌ zhangsan-dev
|
||||
❌ feature/zhangsan/xxx
|
||||
❌ 张三的分支
|
||||
❌ wy-dev / wy-test
|
||||
```
|
||||
|
||||
### 5 大问题
|
||||
|
||||
#### 1️⃣ 强化"个人所有权",破坏协作精神
|
||||
|
||||
分支带人名 → 同事潜意识"这是张三的地盘,我别动" → 协作变独行 → 出问题没人帮看。
|
||||
|
||||
**好的协作是任务驱动**:分支属于任务,谁有空谁接手。
|
||||
|
||||
#### 2️⃣ 离职、休假、轮岗时难交接
|
||||
|
||||
**真实场景**:
|
||||
> 张三休假一周,留下 5 条 `feature/xxx-zhangsan` 分支。李四接手时是改名(影响远程)、还是新开 `feature/xxx-lisi`(造成分裂)?怎么处理都别扭。
|
||||
|
||||
如果分支叫 `feature/评查详情页`,李四直接 `git checkout` 接着干,**零摩擦**。
|
||||
|
||||
#### 3️⃣ 分支语义模糊,看名字不知道做什么
|
||||
|
||||
`zhangsan-dev` 这条分支在做啥?
|
||||
- 评查模块?
|
||||
- 登录优化?
|
||||
- 性能调优?
|
||||
- 还是张三的"我啥都往这塞"草稿?
|
||||
|
||||
**好的分支名 = 一句话任务说明**,看到 `feature/评查详情页` 立刻明白。
|
||||
|
||||
#### 4️⃣ 容易堆积成"杂物分支"
|
||||
|
||||
人名分支没有边界,张三会把"评查详情页 + 顺手优化的登录 + 试验性能改动"都塞进 `feature/zhangsan-dev`。结果:
|
||||
- PR 改动巨大,没人能 Review
|
||||
- 一个 bug 拖延了三个功能
|
||||
- 不能单独 revert 某项改动
|
||||
|
||||
**任务分支天然有边界**:`feature/评查详情页` 只装评查相关改动,超出范围就该开新分支。
|
||||
|
||||
#### 5️⃣ 多人协作同一功能时命名混乱
|
||||
|
||||
**真实场景**:
|
||||
> 评查详情页很大,张三李四王五一起做。分支会变成:
|
||||
> - `feature/评查详情页-zhangsan`
|
||||
> - `feature/评查详情页-lisi`
|
||||
> - `feature/评查详情页-wangwu`
|
||||
>
|
||||
> 三人改动如何同步?合并顺序?谁先合谁后合?混乱。
|
||||
|
||||
**正确做法**:开一条父分支 `feature/评查详情页`,每人开**子任务分支**:
|
||||
- `feature/评查详情页-头部`
|
||||
- `feature/评查详情页-列表`
|
||||
- `feature/评查详情页-筛选`
|
||||
|
||||
子任务都合并回父分支,最后父分支整体合到 develop。
|
||||
|
||||
---
|
||||
|
||||
### 真实案例:人名分支造成的事故
|
||||
|
||||
**案例 1:交接黑洞**
|
||||
> 某团队张三离职前留下 `zhangsan/refactor-payment` 分支,里面有他重构支付的工作(约 60% 完成)。半年后团队想接着做,发现:分支与 main 已差异巨大,无法 rebase;分支内 commit 信息全是"WIP",没人知道每个 commit 在做什么;最终只能放弃,**重做**。
|
||||
|
||||
**案例 2:紧急修复无法落地**
|
||||
> 周五晚上生产支付功能挂了。值班发现修复需要改一个文件,但该文件在 `feature/支付重构-zhangsan` 分支上张三已改了一周。要么强行修生产(与张三的分支冲突会变大),要么等张三周一来。最终用了一个肮脏的 hotfix,**技术债累计**。
|
||||
|
||||
**案例 3:PR Review 无人能审**
|
||||
> `feature/zhangsan-dev` 一个 PR 改了 2000 行,包含评查、登录、列表、性能 4 个不相关的改动。Review 同事看了一上午放弃,盖章通过。两天后线上 bug,**无法定位是哪个改动引起的**。
|
||||
|
||||
---
|
||||
|
||||
### 正确做法
|
||||
|
||||
#### ✅ 任务命名规范
|
||||
|
||||
```
|
||||
feature/<任务描述> feature/评查详情页
|
||||
feature/<issue号>-<描述> feature/PROJ-123-评查详情页
|
||||
fix/<bug简述> fix/登录闪退
|
||||
hotfix/<紧急bug> hotfix/支付失败
|
||||
refactor/<范围> refactor/数据层
|
||||
```
|
||||
|
||||
#### ✅ 任务归属体现在 PR/Issue Assignee
|
||||
|
||||
谁负责这个任务 → 在 **PR/Issue 的 Assignee** 字段标记,不要污染分支名。
|
||||
|
||||
#### ✅ 分支只与「任务/目标」关联
|
||||
|
||||
看到 `feature/评查详情页` 应当能立刻回答:
|
||||
- 这条分支在做什么
|
||||
- 合并目标是什么
|
||||
- 何时该删除
|
||||
|
||||
---
|
||||
|
||||
### 例外:可以带人名的场景
|
||||
|
||||
**仅限以下短期、可丢弃的情况**:
|
||||
|
||||
#### 1. 个人探索/草稿分支(明确不打算合并)
|
||||
|
||||
```
|
||||
zhangsan/试个性能方案
|
||||
zhangsan/sandbox
|
||||
```
|
||||
|
||||
用斜线作为"个人 sandbox 命名空间"前缀,与正式 feature 分支区分。
|
||||
|
||||
#### 2. Fork 工作流(开源项目常见)
|
||||
|
||||
你 fork 仓库到自己账号后,分支名随意,提 PR 时再说。这是**远程 fork 模式**,不属于"单仓库协作"场景。
|
||||
|
||||
#### 3. 多人并行试方案的临时区分
|
||||
|
||||
```
|
||||
feature/评查详情页/方案A-zhangsan
|
||||
feature/评查详情页/方案B-lisi
|
||||
```
|
||||
|
||||
验证完保留赢的方案,删除全部分支,重命名为正式分支。
|
||||
|
||||
> **核心:这些场景都是短期、明确不污染主流分支的。**
|
||||
|
||||
---
|
||||
|
||||
## 七、通用规范
|
||||
|
||||
### 分支命名
|
||||
|
||||
```
|
||||
<type>/<description>
|
||||
<type>/<issue-id>-<description>
|
||||
```
|
||||
|
||||
**type 类型**:
|
||||
- `feature/` 新功能
|
||||
- `fix/` bug 修复
|
||||
- `hotfix/` 紧急修复(生产)
|
||||
- `refactor/` 重构
|
||||
- `docs/` 文档
|
||||
- `test/` 测试
|
||||
- `chore/` 杂项(依赖升级、配置)
|
||||
- `release/` 发布分支(仅 Git Flow)
|
||||
|
||||
**示例**:
|
||||
```
|
||||
feature/评查详情页
|
||||
feature/PROJ-123-评查详情页
|
||||
fix/登录闪退
|
||||
hotfix/v2.3-支付失败
|
||||
```
|
||||
|
||||
**禁止**:
|
||||
- ❌ 人名前缀/后缀
|
||||
- ❌ 拼音缩写如 `wy-dev`
|
||||
- ❌ 没有 type 前缀
|
||||
- ❌ 空格、特殊字符
|
||||
|
||||
### 提交信息(Conventional Commits)
|
||||
|
||||
```
|
||||
<type>(<scope>): <简短描述>
|
||||
|
||||
<可选详细说明>
|
||||
|
||||
<可选 footer>
|
||||
```
|
||||
|
||||
**type**:
|
||||
- `feat:` 新功能
|
||||
- `fix:` 修 bug
|
||||
- `refactor:` 重构(不改外部行为)
|
||||
- `perf:` 性能优化
|
||||
- `test:` 测试
|
||||
- `docs:` 文档
|
||||
- `style:` 格式(不改代码逻辑)
|
||||
- `chore:` 杂项
|
||||
- `build:` 构建相关
|
||||
- `ci:` CI 相关
|
||||
|
||||
**示例**:
|
||||
```
|
||||
feat(评查): 新增详情页骨架屏
|
||||
|
||||
- 添加加载占位组件
|
||||
- 实现进入动画
|
||||
- 适配移动端
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
**禁止**:
|
||||
- ❌ `update`、`fix`、`修改`、`123`
|
||||
- ❌ 一个 commit 改 N 个不相关的事
|
||||
- ❌ `WIP`(合并前应 squash 掉)
|
||||
|
||||
### 标签(Tag)规范
|
||||
|
||||
**版本标签**遵循 [SemVer](https://semver.org/):
|
||||
```
|
||||
v1.2.3 正式版
|
||||
v1.2.3-beta.1 beta 版
|
||||
v1.2.3-rc.1 发布候选版
|
||||
```
|
||||
|
||||
**打 tag 时机**:
|
||||
- 个人项目:每次发布
|
||||
- 小项目:每次部署到生产
|
||||
- 大项目:release 合并到 main 时
|
||||
|
||||
### .gitignore 通用模板
|
||||
|
||||
```gitignore
|
||||
# 依赖
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# 环境变量(永远不要提交)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
|
||||
# 构建产物
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
coverage/
|
||||
|
||||
# 本地配置
|
||||
.cache/
|
||||
*.local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、共享资源协作守则
|
||||
|
||||
### 通用原则
|
||||
|
||||
> **凡是会影响他人的操作,事前通知、事中可见、事后可恢复。**
|
||||
|
||||
### 共享资源守则表
|
||||
|
||||
| 共享资源 | 守则 |
|
||||
|---------|------|
|
||||
| 共享开发/测试机 | 默认主分支;切换通知;用完还原;优先 `git worktree` |
|
||||
| 共享数据库 | 不在共用库做破坏性改动;每人独立 schema;测试用 docker 起本地 |
|
||||
| 共享 API Key / Secret | 用 secret manager;禁止贴 IM/代码/截图 |
|
||||
| CI/CD pipeline | 改流水线前通知;不在大家忙时触发昂贵任务 |
|
||||
| 第三方账号 | SSO + 审计;操作记录可追溯 |
|
||||
| 部署环境 | 部署窗口公告;锁机制(谁部署谁举手) |
|
||||
|
||||
### 切换共享资源的标准动作
|
||||
|
||||
```
|
||||
1. 群里通知:"我要 [操作] [资源],预计 [时间]"
|
||||
2. 操作
|
||||
3. 用完恢复默认状态
|
||||
4. 群里告知:"已恢复"
|
||||
```
|
||||
|
||||
### 优先用隔离手段,而不是切换
|
||||
|
||||
```bash
|
||||
# git worktree:在不同目录跑不同分支,主目录不动
|
||||
git worktree add ../preview-xxx feature/xxx
|
||||
git worktree remove ../preview-xxx
|
||||
|
||||
# Docker:每个分支起独立容器
|
||||
docker compose -p preview-xxx up
|
||||
```
|
||||
|
||||
### 终极方案:消除"共享"本身
|
||||
|
||||
- 每个 PR 自动起一个独立预览环境(Vercel / Netlify / K8s preview)
|
||||
- 每人独立数据库 schema(自动迁移)
|
||||
- 每人独立云资源(云厂商沙盒账号)
|
||||
|
||||
> **根本不需要"共享调试机"。**
|
||||
|
||||
---
|
||||
|
||||
## 九、常见问题 FAQ
|
||||
|
||||
### Q1:合并前应该用 merge 还是 rebase?
|
||||
|
||||
| 场景 | 推荐 |
|
||||
|------|------|
|
||||
| 个人 feature 分支同步 main | `git rebase`(历史干净) |
|
||||
| 多人协作的分支 | `git merge`(不重写他人 commit) |
|
||||
| 合并 PR 到 main | **Squash and merge**(main 历史干净) |
|
||||
| 已推送的分支 | 谨慎使用 rebase,需 `--force-with-lease` |
|
||||
|
||||
### Q2:feature 分支多久合并一次?
|
||||
|
||||
- **理想**:1-3 天
|
||||
- **可接受**:1 周
|
||||
- **超过 1 周必须警惕**:分支过大 → 拆分;同步频率提高(每天 rebase main)
|
||||
|
||||
### Q3:commit 太多太乱怎么办?
|
||||
|
||||
合并到主分支前 squash 整理:
|
||||
```bash
|
||||
git rebase -i HEAD~5
|
||||
# 把第二个开始的 pick 改成 squash 或 fixup
|
||||
```
|
||||
|
||||
或在 PR 合并时选择 **"Squash and merge"**。
|
||||
|
||||
### Q4:误推了敏感信息(密钥)怎么办?
|
||||
|
||||
1. **立即作废**这个密钥(去后台重新生成)
|
||||
2. 用 `git filter-repo` 或 BFG 清除历史
|
||||
3. **强制推送**所有分支
|
||||
4. **通知所有人**重新 clone
|
||||
|
||||
预防:
|
||||
- `.gitignore` 配齐 `.env`
|
||||
- 用 secret manager
|
||||
- 装 `git-secrets` / `gitleaks` pre-commit 钩子
|
||||
|
||||
### Q5:rebase 时遇到冲突怎么办?
|
||||
|
||||
```bash
|
||||
# 1. 解决冲突文件
|
||||
# 2. git add <冲突文件>
|
||||
# 3. git rebase --continue
|
||||
|
||||
# 实在搞不定就放弃
|
||||
git rebase --abort
|
||||
```
|
||||
|
||||
### Q6:误删了分支怎么恢复?
|
||||
|
||||
```bash
|
||||
# 找到最后一次 commit
|
||||
git reflog
|
||||
|
||||
# 恢复
|
||||
git checkout -b feature/xxx <commit-sha>
|
||||
```
|
||||
|
||||
### Q7:`git push` 被拒(rejected)
|
||||
|
||||
意味着远程分支有新 commit 你本地没有。
|
||||
|
||||
```bash
|
||||
# 同步后再推
|
||||
git pull --rebase
|
||||
git push
|
||||
|
||||
# 如果是 rebase 后推自己的分支
|
||||
git push --force-with-lease # 注意是 --force-with-lease,不是 --force
|
||||
```
|
||||
|
||||
> ⚠️ **永远不要对 main / develop 用 `--force`!**
|
||||
|
||||
### Q8:什么时候删分支?
|
||||
|
||||
- 合并到主干后**立即删除**远程和本地
|
||||
- GitHub/GitLab 可设置"合并后自动删除"
|
||||
- 本地:`git branch -d feature/xxx`
|
||||
|
||||
### Q9:长期废弃的分支能直接删吗?
|
||||
|
||||
先 `git log feature/xxx` 看是否有未合并 commit。若有:
|
||||
- 询问作者是否还需要
|
||||
- 或导出 patch:`git format-patch main..feature/xxx`
|
||||
|
||||
### Q10:Reviewer 检查清单
|
||||
|
||||
审视 PR 是否值得合并:
|
||||
|
||||
- [ ] 分支命名规范,无人名
|
||||
- [ ] 改动只做一件事,与 PR 标题一致
|
||||
- [ ] 提交信息规范
|
||||
- [ ] 测试覆盖关键路径
|
||||
- [ ] CI 全绿
|
||||
- [ ] 无调试代码(console.log、注释掉的代码)
|
||||
- [ ] 无敏感信息(API Key、密码)
|
||||
- [ ] 文档/注释 同步更新
|
||||
- [ ] 跑过本地测试
|
||||
|
||||
---
|
||||
|
||||
## 十、配套工具与一键配置
|
||||
|
||||
### 全员 Git 配置(每台机器跑一次)
|
||||
|
||||
```bash
|
||||
# pull 默认 rebase(避免无意义 merge commit)
|
||||
git config --global pull.rebase true
|
||||
|
||||
# push 自动追踪上游
|
||||
git config --global push.autoSetupRemote true
|
||||
|
||||
# rebase 自动 stash
|
||||
git config --global rebase.autoStash true
|
||||
|
||||
# 默认分支名
|
||||
git config --global init.defaultBranch main
|
||||
|
||||
# 换行符处理
|
||||
git config --global core.autocrlf input # Mac/Linux
|
||||
git config --global core.autocrlf true # Windows
|
||||
```
|
||||
|
||||
### 推荐工具
|
||||
|
||||
| 类型 | 工具 | 作用 |
|
||||
|------|------|------|
|
||||
| 提交检查 | husky + lint-staged | commit 前自动跑 lint/format |
|
||||
| 提交规范 | commitlint | 强制 Conventional Commits |
|
||||
| 密钥扫描 | gitleaks / git-secrets | 防止推送密钥 |
|
||||
| 图形化客户端 | Fork / SourceTree / GitKraken | 新人友好 |
|
||||
| CLI 增强 | gh / lazygit | 命令行高效操作 |
|
||||
| CI/CD | GitHub Actions / GitLab CI | 自动测试和部署 |
|
||||
| PR 预览 | Vercel / Netlify | 每 PR 一个独立环境 |
|
||||
|
||||
### 一键安装 husky + lint-staged + commitlint(Node 项目)
|
||||
|
||||
```bash
|
||||
bun add -D husky lint-staged @commitlint/cli @commitlint/config-conventional
|
||||
|
||||
# 初始化 husky
|
||||
bunx husky init
|
||||
|
||||
# 配置 commitlint
|
||||
echo "export default { extends: ['@commitlint/config-conventional'] }" > commitlint.config.mjs
|
||||
|
||||
# 添加 hooks
|
||||
echo "bunx commitlint --edit \$1" > .husky/commit-msg
|
||||
echo "bunx lint-staged" > .husky/pre-commit
|
||||
```
|
||||
|
||||
`package.json` 添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"lint-staged": {
|
||||
"*.{js,ts,tsx}": ["eslint --fix", "prettier --write"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:决策速查表
|
||||
|
||||
### 我该开什么类型的分支?
|
||||
|
||||
| 我要做的事 | 分支前缀 | 例子 |
|
||||
|----------|---------|------|
|
||||
| 新功能 | `feature/` | `feature/评查详情页` |
|
||||
| 修 bug | `fix/` | `fix/登录闪退` |
|
||||
| 紧急修生产 | `hotfix/` | `hotfix/支付失败` |
|
||||
| 重构 | `refactor/` | `refactor/数据层` |
|
||||
| 写文档 | `docs/` | `docs/api文档` |
|
||||
| 升级依赖 | `chore/` | `chore/升级react18` |
|
||||
|
||||
### 我该写什么 commit type?
|
||||
|
||||
| 我做了什么 | type |
|
||||
|----------|------|
|
||||
| 写了新功能 | `feat` |
|
||||
| 修 bug | `fix` |
|
||||
| 重构(不改行为) | `refactor` |
|
||||
| 性能优化 | `perf` |
|
||||
| 改文档 | `docs` |
|
||||
| 改格式(空格、缩进) | `style` |
|
||||
| 加测试 | `test` |
|
||||
| 杂项 | `chore` |
|
||||
|
||||
### 我该用哪个工作流?
|
||||
|
||||
```
|
||||
1 人 → 方案 A(main + 偶尔 feature)
|
||||
2-5 人 → 方案 B(GitHub Flow,main + feature + PR)
|
||||
6+ 人 / 多版本 → 方案 C(Git Flow Lite,main + develop + ...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:一页纸速查(打印贴墙)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 团队 Git 协作 · 一页纸速查 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. 永远从最新主干开任务分支 │
|
||||
│ git checkout main && git pull --rebase │
|
||||
│ git checkout -b feature/任务描述 │
|
||||
│ │
|
||||
│ 2. 分支命名 = 任务,不带人名! │
|
||||
│ ✅ feature/评查详情页 │
|
||||
│ ❌ feature/评查详情页-zhangsan │
|
||||
│ │
|
||||
│ 3. commit 写人话 │
|
||||
│ ✅ feat(评查): 新增详情页骨架 │
|
||||
│ ❌ update │
|
||||
│ │
|
||||
│ 4. 推送前先同步 │
|
||||
│ git fetch && git rebase origin/main │
|
||||
│ │
|
||||
│ 5. 主分支 = PR 入口,禁止直推 │
|
||||
│ │
|
||||
│ 6. 共享资源 = 通知 → 操作 → 还原 → 告知 │
|
||||
│ │
|
||||
│ 7. 永远不对 main/develop --force │
|
||||
│ │
|
||||
│ 8. 合并后立刻删分支 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
# RAG 聊天接口
|
||||
|
||||
> 最后整理:2026-05-07
|
||||
> 对应后端:`fastapi_modules/fastapi_leaudit/controllers/ragChatController.py`
|
||||
> 统一前缀:`/api/v3/rag`
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
本组接口用于替代旧的 `dify_chat/*` 对话代理,直接提供自有 RAG 聊天能力。
|
||||
|
||||
当前已落地能力:
|
||||
|
||||
- 获取当前用户可见的 RAG 应用
|
||||
- 获取默认 RAG 应用
|
||||
- 获取当前用户可见知识库
|
||||
- 获取聊天页面参数
|
||||
- 发起流式对话
|
||||
- 查询会话列表
|
||||
- 查询会话消息
|
||||
- 重命名会话
|
||||
- 删除会话
|
||||
- 消息反馈
|
||||
|
||||
当前不在本文档范围内:
|
||||
|
||||
- 知识库 CRUD 管理
|
||||
- 文档切分 / 入库任务
|
||||
- Chroma 集合构建脚本
|
||||
|
||||
## 2. 鉴权与权限
|
||||
|
||||
### 2.1 鉴权方式
|
||||
|
||||
所有接口都要求请求头带:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
JWT 解析逻辑见:
|
||||
|
||||
- `fastapi_common/fastapi_common_security/security.py`
|
||||
- `fastapi_common/fastapi_common_security/jwtService.py`
|
||||
|
||||
JWT payload 至少会被后端消费这些字段:
|
||||
|
||||
- `user_id`
|
||||
- `username`
|
||||
- `area`
|
||||
- `user_role`
|
||||
- `type`,必须为 `access`
|
||||
|
||||
### 2.2 权限键
|
||||
|
||||
| 接口 | 权限 |
|
||||
|------|------|
|
||||
| `GET /api/v3/rag/apps` | `rag:app:read` |
|
||||
| `GET /api/v3/rag/apps/default` | `rag:app:read` |
|
||||
| `GET /api/v3/rag/datasets/my` | `rag:dataset:read` |
|
||||
| `GET /api/v3/rag/chat/parameters` | `rag:chat:use` 或 `rag:app:read` 其一即可 |
|
||||
| `POST /api/v3/rag/chat/messages` | `rag:chat:use` |
|
||||
| `GET /api/v3/rag/chat/conversations` | `rag:conversation:read` |
|
||||
| `GET /api/v3/rag/chat/conversations/{ConversationId}/messages` | `rag:conversation:read` |
|
||||
| `PATCH /api/v3/rag/chat/conversations/{ConversationId}` | `rag:conversation:update` |
|
||||
| `DELETE /api/v3/rag/chat/conversations/{ConversationId}` | `rag:conversation:delete` |
|
||||
| `POST /api/v3/rag/chat/messages/{MessageId}/feedback` | `rag:message:feedback` |
|
||||
|
||||
说明:
|
||||
|
||||
- 权限检查使用 `HasAnyPermission`,即“列表中的任一权限命中即可通过”。
|
||||
- 具体实现见 `fastapi_modules/fastapi_leaudit/services/impl/permissionServiceImpl.py`。
|
||||
|
||||
## 3. 通用返回格式
|
||||
|
||||
除流式接口外,统一返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
权限不足时通常返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 403,
|
||||
"msg": "当前用户没有对应权限",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 接口列表
|
||||
|
||||
### 4.1 获取可见应用列表
|
||||
|
||||
`GET /api/v3/rag/apps`
|
||||
|
||||
用途:
|
||||
|
||||
- 聊天页面加载应用下拉
|
||||
- 根据地区 / 省级角色做应用可见性过滤
|
||||
|
||||
请求参数:无
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"appId": "1",
|
||||
"appName": "法务问答",
|
||||
"description": "默认烟草法务知识问答",
|
||||
"isDefault": true
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可见性规则:
|
||||
|
||||
- `provincial_admin` 可见全部启用应用
|
||||
- 其他角色仅可见:
|
||||
- `rag_chat_app.area = 用户 area`
|
||||
- `rag_chat_app.area = '省级'`
|
||||
- `rag_chat_app.area = ''`
|
||||
- 或关联数据集 `rag_dataset.is_public = true`
|
||||
|
||||
### 4.2 获取默认应用
|
||||
|
||||
`GET /api/v3/rag/apps/default`
|
||||
|
||||
用途:
|
||||
|
||||
- 聊天页初始化默认应用
|
||||
|
||||
请求参数:无
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"appId": "1",
|
||||
"appName": "法务问答",
|
||||
"description": "默认烟草法务知识问答",
|
||||
"isDefault": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
补充说明:
|
||||
|
||||
- 如果没有显式默认应用,会退回到当前用户可见的第一条应用。
|
||||
- 如果完全没有可见应用,返回 `data: null`。
|
||||
|
||||
### 4.3 获取当前用户可见知识库
|
||||
|
||||
`GET /api/v3/rag/datasets/my`
|
||||
|
||||
用途:
|
||||
|
||||
- 聊天页展示“当前可用知识库”时使用
|
||||
|
||||
请求参数:无
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "广东烟草法规库",
|
||||
"description": "省级法规与制度",
|
||||
"area": "省级",
|
||||
"isPublic": true,
|
||||
"isDefault": true,
|
||||
"documentCount": 120,
|
||||
"totalChunks": 8345,
|
||||
"status": 1
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 获取聊天参数
|
||||
|
||||
`GET /api/v3/rag/chat/parameters`
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `appId` | int | 否 | 指定应用 ID;不传则取默认应用 |
|
||||
|
||||
用途:
|
||||
|
||||
- 初始化开场白
|
||||
- 初始化建议问题
|
||||
- 初始化上传能力配置
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"openingStatement": "你好,我可以帮你解答烟草行业法务问题。",
|
||||
"suggestedQuestions": [
|
||||
"烟草专卖许可延续的法定条件是什么?",
|
||||
"行政处罚文书审查重点有哪些?"
|
||||
],
|
||||
"userInputForm": [],
|
||||
"fileUpload": {
|
||||
"image": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 发起流式对话
|
||||
|
||||
`POST /api/v3/rag/chat/messages`
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "烟草专卖许可证延续申请的审查要点是什么?",
|
||||
"conversationId": null,
|
||||
"appId": 1
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `query` | string | 是 | 用户问题,不能为空 |
|
||||
| `conversationId` | string \| null | 否 | 会话 ID;新对话可传 `null` 或不传 |
|
||||
| `appId` | int \| null | 否 | 应用 ID;不传则自动回退默认应用 |
|
||||
|
||||
返回类型:
|
||||
|
||||
- `text/event-stream`
|
||||
|
||||
SSE 事件 1:流式正文片段
|
||||
|
||||
```text
|
||||
data: {"event":"message","task_id":"...","message_id":"...","conversation_id":"...","answer":"第一段内容","created_at":1746580000}
|
||||
|
||||
```
|
||||
|
||||
SSE 事件 2:流式结束
|
||||
|
||||
```text
|
||||
data: {"event":"message_end","task_id":"...","message_id":"...","conversation_id":"...","metadata":{"usage":{"total_tokens":1234},"retriever_resources":[...],"suggested_questions":["问题1","问题2"]}}
|
||||
|
||||
```
|
||||
|
||||
SSE 事件 3:模型异常
|
||||
|
||||
```text
|
||||
data: {"event":"error","task_id":"...","message_id":"...","code":"llm_error","message":"..."}
|
||||
|
||||
```
|
||||
|
||||
服务端行为说明:
|
||||
|
||||
- 若 `conversationId` 为空,会自动创建新会话
|
||||
- 会先落一条 `role = user` 消息,再流式生成回答
|
||||
- 流结束后会落一条 `role = assistant` 消息
|
||||
- 若命中知识库,会把引用结果写入 `sources / metadata`
|
||||
- 会根据对话内容追加 `suggested_questions`
|
||||
|
||||
### 4.6 获取会话列表
|
||||
|
||||
`GET /api/v3/rag/chat/conversations`
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `appId` | int | 否 | - | 按应用过滤 |
|
||||
| `page` | int | 否 | `1` | 页码,从 1 开始 |
|
||||
| `pageSize` | int | 否 | `20` | 每页数量,最大 100 |
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"id": "b17d3b0b-xxxx-xxxx",
|
||||
"name": "新对话",
|
||||
"introduction": "",
|
||||
"createdAt": 1746580000,
|
||||
"updatedAt": 1746580066
|
||||
}
|
||||
],
|
||||
"hasMore": false,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.7 获取会话消息
|
||||
|
||||
`GET /api/v3/rag/chat/conversations/{ConversationId}/messages`
|
||||
|
||||
路径参数:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `ConversationId` | string | 会话 ID |
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 |
|
||||
|------|------|------|--------|
|
||||
| `page` | int | 否 | `1` |
|
||||
| `pageSize` | int | 否 | `20` |
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"id": "assistant-message-id",
|
||||
"conversationId": "b17d3b0b-xxxx-xxxx",
|
||||
"query": "烟草专卖许可证延续申请的审查要点是什么?",
|
||||
"answer": "先核对主体资格,再核对经营条件……",
|
||||
"feedback": {
|
||||
"rating": "like"
|
||||
},
|
||||
"retrieverResources": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataset_id": "1",
|
||||
"dataset_name": "广东烟草法规库",
|
||||
"document_id": "12",
|
||||
"document_name": "行政许可审查规范.pdf",
|
||||
"segment_id": "chunk-1",
|
||||
"score": 0.9132
|
||||
}
|
||||
],
|
||||
"createdAt": 1746580000
|
||||
}
|
||||
],
|
||||
"hasMore": false,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 返回结构是按“问答对”聚合后的结果,不是底层 `rag_message` 原始逐条结果。
|
||||
|
||||
### 4.8 重命名会话
|
||||
|
||||
`PATCH /api/v3/rag/chat/conversations/{ConversationId}`
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "许可证延续审查要点"
|
||||
}
|
||||
```
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"result": "success",
|
||||
"name": "许可证延续审查要点"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.9 删除会话
|
||||
|
||||
`DELETE /api/v3/rag/chat/conversations/{ConversationId}`
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"result": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 逻辑删除,实际是设置 `rag_conversation.deleted_at`
|
||||
|
||||
### 4.10 消息反馈
|
||||
|
||||
`POST /api/v3/rag/chat/messages/{MessageId}/feedback`
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"rating": "like"
|
||||
}
|
||||
```
|
||||
|
||||
可选值:
|
||||
|
||||
- `like`
|
||||
- `dislike`
|
||||
- `null`
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"result": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 数据表对应关系
|
||||
|
||||
| 表 | 用途 |
|
||||
|----|------|
|
||||
| `rag_dataset` | 知识库定义、地区可见性、检索参数 |
|
||||
| `rag_document` | 知识库文档、启停状态、命中次数 |
|
||||
| `rag_chat_app` | 聊天应用配置、默认应用、应用绑定知识库 |
|
||||
| `rag_conversation` | 用户会话 |
|
||||
| `rag_message` | 底层消息落库,包含引用与反馈 |
|
||||
|
||||
## 6. 当前实现约束
|
||||
|
||||
- 当前只提供 `GET /datasets/my`,不提供知识库管理 CRUD
|
||||
- `fileUpload.image.enabled` 固定为 `false`
|
||||
- 检索依赖 Chroma;Chroma 不可用时,接口仍可回答,但会退化成无知识库上下文
|
||||
- 建议问题 `suggestedQuestions` 由二次模型调用生成,失败时会降级为空数组
|
||||
|
||||
## 7. 联调建议
|
||||
|
||||
最小联调顺序:
|
||||
|
||||
1. `GET /api/v3/rag/apps`
|
||||
2. `GET /api/v3/rag/apps/default`
|
||||
3. `GET /api/v3/rag/chat/parameters`
|
||||
4. `POST /api/v3/rag/chat/messages`
|
||||
5. `GET /api/v3/rag/chat/conversations`
|
||||
6. `GET /api/v3/rag/chat/conversations/{ConversationId}/messages`
|
||||
|
||||
优先检查:
|
||||
|
||||
- 401:JWT 是否有效
|
||||
- 403:`rag:*` 权限是否已写入并分配
|
||||
- 500:LLM、数据库、Chroma、知识库配置是否可用
|
||||
@@ -17,6 +17,7 @@
|
||||
|------|------|------|
|
||||
| 首页入口 / 菜单 | `入口模块绑定最终设计方案.md` | 入口模块、文档类型、规则链路绑定模型 |
|
||||
| 文档上传 / 列表 / 评查 | `文档上传与列表接口分析.md` | 上传、列表、详情、更新、删除、评查触发、数据隔离 |
|
||||
| RAG 聊天 | `RAG聊天接口.md` | `/api/v3/rag/*` 自有聊天接口、SSE、权限、表结构映射 |
|
||||
| 文档类型 / 评查组 | `评查点分组目标结构与迁移方案.md` | 文档类型、一级分组、二级分组、规则集与迁移口径 |
|
||||
| 评查点分组迁移 | `评查点分组目标结构与迁移方案.md` | 新老分组结构对齐方案 |
|
||||
| 评查点分组迁移 | `评查点分组迁移执行前检查清单.md` | 正式迁移前检查项 |
|
||||
|
||||
@@ -61,6 +61,8 @@ class JwtService:
|
||||
jti = str(uuid.uuid4())
|
||||
|
||||
# Access Token
|
||||
# Token 只保留鉴权链路真正需要的最小字段,避免省局/管理员权限过多时
|
||||
# 把 permissions / roles 全塞进 JWT,最终导致前端 Cookie Session 超过 4KB。
|
||||
accessPayload = {
|
||||
"jti": jti,
|
||||
"user_id": userId,
|
||||
@@ -68,8 +70,6 @@ class JwtService:
|
||||
"nick_name": nickName,
|
||||
"ou_id": ouId,
|
||||
"ou_name": ouName,
|
||||
"roles": roles or [],
|
||||
"permissions": permissions or [],
|
||||
"area": area,
|
||||
"user_role": userRole,
|
||||
"iat": now,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, Query
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from fastapi_common.fastapi_common_security.security import verify_access_token
|
||||
from fastapi_common.fastapi_common_web.controller import BaseController
|
||||
from fastapi_common.fastapi_common_web.domain.responses import Result
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.domian.Dto.ragChatDto import (
|
||||
RagConversationRenameDTO,
|
||||
RagChatSendMessageDTO,
|
||||
RagMessageFeedbackDTO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.ragChatVo import (
|
||||
RagAppParametersVO,
|
||||
RagChatAppListVO,
|
||||
RagChatAppVO,
|
||||
RagConversationPageVO,
|
||||
RagConversationRenameVO,
|
||||
RagMessagePageVO,
|
||||
RagOperationResultVO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.ragDatasetVo import RagDatasetPageVO
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.ragChatServiceImpl import RagChatServiceImpl
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.ragDatasetServiceImpl import RagDatasetServiceImpl
|
||||
from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService
|
||||
from fastapi_modules.fastapi_leaudit.services.ragChatService import IRagChatService
|
||||
from fastapi_modules.fastapi_leaudit.services.ragDatasetService import IRagDatasetService
|
||||
|
||||
|
||||
class RagChatController(BaseController):
|
||||
_PERMISSIONS = {
|
||||
"chat_use": "rag:chat:use",
|
||||
"conversation_read": "rag:conversation:read",
|
||||
"conversation_update": "rag:conversation:update",
|
||||
"conversation_delete": "rag:conversation:delete",
|
||||
"message_feedback": "rag:message:feedback",
|
||||
"app_read": "rag:app:read",
|
||||
"dataset_read": "rag:dataset:read",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/v3/rag", tags=["RAG 聊天"])
|
||||
self.RagChatService: IRagChatService = RagChatServiceImpl()
|
||||
self.RagDatasetService: IRagDatasetService = RagDatasetServiceImpl()
|
||||
self.PermissionService: IPermissionService = PermissionServiceImpl()
|
||||
|
||||
@self.router.get("/apps", response_model=Result[RagChatAppListVO])
|
||||
async def GetApps(payload: dict[str, Any] = Depends(verify_access_token)):
|
||||
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["app_read"]]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看聊天应用权限", "data": None})
|
||||
data = await self.RagChatService.GetApps(
|
||||
CurrentUserId=int(payload["user_id"]),
|
||||
UserArea=payload.get("area"),
|
||||
UserRole=payload.get("user_role"),
|
||||
)
|
||||
return Result.success(data=data)
|
||||
|
||||
@self.router.get("/apps/default", response_model=Result[RagChatAppVO | None])
|
||||
async def GetDefaultApp(payload: dict[str, Any] = Depends(verify_access_token)):
|
||||
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["app_read"]]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看默认聊天应用权限", "data": None})
|
||||
data = await self.RagChatService.GetDefaultApp(
|
||||
CurrentUserId=int(payload["user_id"]),
|
||||
UserArea=payload.get("area"),
|
||||
UserRole=payload.get("user_role"),
|
||||
)
|
||||
return Result.success(data=data)
|
||||
|
||||
@self.router.get("/datasets/my", response_model=Result[RagDatasetPageVO])
|
||||
async def GetMyDatasets(payload: dict[str, Any] = Depends(verify_access_token)):
|
||||
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["dataset_read"]]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看知识库权限", "data": None})
|
||||
data = await self.RagDatasetService.GetMyDatasets(
|
||||
CurrentUserId=int(payload["user_id"]),
|
||||
UserArea=payload.get("area"),
|
||||
UserRole=payload.get("user_role"),
|
||||
)
|
||||
return Result.success(data=data)
|
||||
|
||||
@self.router.get("/chat/parameters", response_model=Result[RagAppParametersVO])
|
||||
async def GetAppParameters(
|
||||
appId: int | None = Query(None, description="聊天应用ID"),
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["chat_use"], self._PERMISSIONS["app_read"]]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看聊天配置权限", "data": None})
|
||||
data = await self.RagChatService.GetAppParameters(
|
||||
CurrentUserId=int(payload["user_id"]),
|
||||
UserArea=payload.get("area"),
|
||||
UserRole=payload.get("user_role"),
|
||||
AppId=appId,
|
||||
)
|
||||
return Result.success(data=data)
|
||||
|
||||
@self.router.post("/chat/messages")
|
||||
async def SendMessage(Body: RagChatSendMessageDTO, payload: dict[str, Any] = Depends(verify_access_token)):
|
||||
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["chat_use"]]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有使用 RAG 对话权限", "data": None})
|
||||
stream = self.RagChatService.SendMessage(
|
||||
CurrentUserId=int(payload["user_id"]),
|
||||
UserName=payload.get("username") or str(payload.get("user_id")),
|
||||
UserArea=payload.get("area"),
|
||||
UserRole=payload.get("user_role"),
|
||||
Query=Body.query,
|
||||
ConversationId=Body.conversationId,
|
||||
AppId=Body.appId,
|
||||
)
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
@self.router.get("/chat/conversations", response_model=Result[RagConversationPageVO])
|
||||
async def GetConversations(
|
||||
appId: int | None = Query(None, description="聊天应用ID"),
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(20, ge=1, le=100),
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["conversation_read"]]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看聊天会话权限", "data": None})
|
||||
data = await self.RagChatService.GetConversations(int(payload["user_id"]), appId, page, pageSize)
|
||||
return Result.success(data=data)
|
||||
|
||||
@self.router.get("/chat/conversations/{ConversationId}/messages", response_model=Result[RagMessagePageVO])
|
||||
async def GetConversationMessages(
|
||||
ConversationId: str,
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(20, ge=1, le=100),
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["conversation_read"]]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看聊天消息权限", "data": None})
|
||||
data = await self.RagChatService.GetConversationMessages(int(payload["user_id"]), ConversationId, page, pageSize)
|
||||
return Result.success(data=data)
|
||||
|
||||
@self.router.patch("/chat/conversations/{ConversationId}", response_model=Result[RagConversationRenameVO])
|
||||
async def RenameConversation(
|
||||
ConversationId: str,
|
||||
Body: RagConversationRenameDTO,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["conversation_update"]]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有修改聊天会话权限", "data": None})
|
||||
data = await self.RagChatService.RenameConversation(int(payload["user_id"]), ConversationId, Body)
|
||||
return Result.success(data=data)
|
||||
|
||||
@self.router.delete("/chat/conversations/{ConversationId}", response_model=Result[RagOperationResultVO])
|
||||
async def DeleteConversation(ConversationId: str, payload: dict[str, Any] = Depends(verify_access_token)):
|
||||
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["conversation_delete"]]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除聊天会话权限", "data": None})
|
||||
data = await self.RagChatService.DeleteConversation(int(payload["user_id"]), ConversationId)
|
||||
return Result.success(data=data)
|
||||
|
||||
@self.router.post("/chat/messages/{MessageId}/feedback", response_model=Result[RagOperationResultVO])
|
||||
async def UpdateFeedback(
|
||||
MessageId: str,
|
||||
Body: RagMessageFeedbackDTO,
|
||||
payload: dict[str, Any] = Depends(verify_access_token),
|
||||
):
|
||||
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["message_feedback"]]):
|
||||
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有反馈聊天消息权限", "data": None})
|
||||
data = await self.RagChatService.UpdateFeedback(int(payload["user_id"]), MessageId, Body)
|
||||
return Result.success(data=data)
|
||||
|
||||
async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool:
|
||||
return await self.PermissionService.HasAnyPermission(UserId=user_id, PermissionKeys=permission_keys)
|
||||
@@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RagChatSendMessageDTO(BaseModel):
|
||||
query: str = Field(..., min_length=1, description="用户问题")
|
||||
conversationId: str | None = Field(None, description="会话ID")
|
||||
appId: int | None = Field(None, description="聊天应用ID")
|
||||
|
||||
|
||||
class RagConversationRenameDTO(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=500, description="新会话名称")
|
||||
|
||||
|
||||
class RagMessageFeedbackDTO(BaseModel):
|
||||
rating: str | None = Field(None, description="反馈: like/dislike/None")
|
||||
@@ -0,0 +1,59 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RagChatAppVO(BaseModel):
|
||||
appId: str = Field(..., description="应用ID")
|
||||
appName: str = Field(..., description="应用名称")
|
||||
description: str = Field("", description="应用描述")
|
||||
isDefault: bool = Field(False, description="是否默认应用")
|
||||
|
||||
|
||||
class RagChatAppListVO(BaseModel):
|
||||
data: list[RagChatAppVO] = Field(default_factory=list)
|
||||
total: int = Field(0)
|
||||
|
||||
|
||||
class RagConversationItemVO(BaseModel):
|
||||
id: str = Field(..., description="会话ID")
|
||||
name: str = Field(..., description="会话名称")
|
||||
introduction: str = Field("", description="会话简介")
|
||||
createdAt: int = Field(0, description="创建时间戳")
|
||||
updatedAt: int = Field(0, description="更新时间戳")
|
||||
|
||||
|
||||
class RagConversationPageVO(BaseModel):
|
||||
data: list[RagConversationItemVO] = Field(default_factory=list)
|
||||
hasMore: bool = Field(False)
|
||||
limit: int = Field(20)
|
||||
|
||||
|
||||
class RagMessageItemVO(BaseModel):
|
||||
id: str = Field(...)
|
||||
conversationId: str = Field(...)
|
||||
query: str = Field(...)
|
||||
answer: str = Field(...)
|
||||
feedback: dict | None = Field(None)
|
||||
retrieverResources: list[dict] | None = Field(None)
|
||||
createdAt: int = Field(0)
|
||||
|
||||
|
||||
class RagMessagePageVO(BaseModel):
|
||||
data: list[RagMessageItemVO] = Field(default_factory=list)
|
||||
hasMore: bool = Field(False)
|
||||
limit: int = Field(20)
|
||||
|
||||
|
||||
class RagConversationRenameVO(BaseModel):
|
||||
result: str = Field("success")
|
||||
name: str = Field(...)
|
||||
|
||||
|
||||
class RagOperationResultVO(BaseModel):
|
||||
result: str = Field("success")
|
||||
|
||||
|
||||
class RagAppParametersVO(BaseModel):
|
||||
openingStatement: str = Field("", description="开场白")
|
||||
suggestedQuestions: list[str] = Field(default_factory=list)
|
||||
userInputForm: list[dict] = Field(default_factory=list)
|
||||
fileUpload: dict = Field(default_factory=lambda: {"image": {"enabled": False}})
|
||||
@@ -0,0 +1,18 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RagDatasetItemVO(BaseModel):
|
||||
id: int = Field(...)
|
||||
name: str = Field(...)
|
||||
description: str = Field("")
|
||||
area: str = Field("")
|
||||
isPublic: bool = Field(False)
|
||||
isDefault: bool = Field(False)
|
||||
documentCount: int = Field(0)
|
||||
totalChunks: int = Field(0)
|
||||
status: int = Field(1)
|
||||
|
||||
|
||||
class RagDatasetPageVO(BaseModel):
|
||||
data: list[RagDatasetItemVO] = Field(default_factory=list)
|
||||
total: int = Field(0)
|
||||
@@ -8,6 +8,11 @@ from fastapi_modules.fastapi_leaudit.models.leauditCrossReviewTask import Leaudi
|
||||
from fastapi_modules.fastapi_leaudit.models.leauditCrossReviewTaskDocument import LeauditCrossReviewTaskDocument
|
||||
from fastapi_modules.fastapi_leaudit.models.leauditCrossReviewTaskMember import LeauditCrossReviewTaskMember
|
||||
from fastapi_modules.fastapi_leaudit.models.leauditCrossReviewVote import LeauditCrossReviewVote
|
||||
from fastapi_modules.fastapi_leaudit.models.leauditRagDataset import LeauditRagDataset
|
||||
from fastapi_modules.fastapi_leaudit.models.leauditRagDocument import LeauditRagDocument
|
||||
from fastapi_modules.fastapi_leaudit.models.leauditRagChatApp import LeauditRagChatApp
|
||||
from fastapi_modules.fastapi_leaudit.models.leauditRagConversation import LeauditRagConversation
|
||||
from fastapi_modules.fastapi_leaudit.models.leauditRagMessage import LeauditRagMessage
|
||||
|
||||
__all__ = [
|
||||
"LeauditDocument",
|
||||
@@ -18,4 +23,9 @@ __all__ = [
|
||||
"LeauditCrossReviewTaskDocument",
|
||||
"LeauditCrossReviewProposal",
|
||||
"LeauditCrossReviewVote",
|
||||
"LeauditRagDataset",
|
||||
"LeauditRagDocument",
|
||||
"LeauditRagChatApp",
|
||||
"LeauditRagConversation",
|
||||
"LeauditRagMessage",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Float, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fastapi_common.fastapi_common_web.models import BaseModel
|
||||
|
||||
|
||||
class LeauditRagChatApp(BaseModel):
|
||||
__tablename__ = "rag_chat_app"
|
||||
|
||||
Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
area: Mapped[str] = mapped_column(String(50), default="")
|
||||
datasetId: Mapped[int | None] = mapped_column("dataset_id", BigInteger)
|
||||
systemPrompt: Mapped[str] = mapped_column("system_prompt", Text, default="")
|
||||
llmModel: Mapped[str] = mapped_column("llm_model", String(100), default="")
|
||||
temperature: Mapped[float] = mapped_column(Float, default=0.3)
|
||||
maxTokens: Mapped[int] = mapped_column("max_tokens", Integer, default=2048)
|
||||
openingStatement: Mapped[str] = mapped_column("opening_statement", Text, default="")
|
||||
suggestedQuestions: Mapped[str] = mapped_column("suggested_questions", Text, default="[]")
|
||||
isDefault: Mapped[bool] = mapped_column("is_default", Boolean, default=False)
|
||||
sortOrder: Mapped[int] = mapped_column("sort_order", Integer, default=0)
|
||||
status: Mapped[int] = mapped_column(Integer, default=1)
|
||||
createdBy: Mapped[int | None] = mapped_column("created_by", BigInteger)
|
||||
updatedBy: Mapped[int | None] = mapped_column("updated_by", BigInteger)
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fastapi_common.fastapi_common_web.models import BaseModel
|
||||
|
||||
|
||||
class LeauditRagConversation(BaseModel):
|
||||
__tablename__ = "rag_conversation"
|
||||
|
||||
Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True)
|
||||
conversationId: Mapped[str] = mapped_column("conversation_id", String(100), unique=True)
|
||||
userId: Mapped[int] = mapped_column("user_id", BigInteger)
|
||||
appId: Mapped[int | None] = mapped_column("app_id", BigInteger)
|
||||
name: Mapped[str] = mapped_column(String(500), default="新对话")
|
||||
introduction: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fastapi_common.fastapi_common_web.models import BaseModel
|
||||
|
||||
|
||||
class LeauditRagDataset(BaseModel):
|
||||
__tablename__ = "rag_dataset"
|
||||
|
||||
Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), comment="知识库名称")
|
||||
description: Mapped[str] = mapped_column(Text, default="", comment="知识库描述")
|
||||
area: Mapped[str] = mapped_column(String(50), default="", comment="地区")
|
||||
isPublic: Mapped[bool] = mapped_column("is_public", Boolean, default=False)
|
||||
isDefault: Mapped[bool] = mapped_column("is_default", Boolean, default=False)
|
||||
collectionName: Mapped[str] = mapped_column("collection_name", String(100), unique=True)
|
||||
embeddingModel: Mapped[str] = mapped_column("embedding_model", String(100), default="text-embedding-v4")
|
||||
embeddingDim: Mapped[int] = mapped_column("embedding_dim", Integer, default=1024)
|
||||
chunkMaxSize: Mapped[int] = mapped_column("chunk_max_size", Integer, default=800)
|
||||
chunkMinSize: Mapped[int] = mapped_column("chunk_min_size", Integer, default=20)
|
||||
documentCount: Mapped[int] = mapped_column("document_count", Integer, default=0)
|
||||
totalChunks: Mapped[int] = mapped_column("total_chunks", Integer, default=0)
|
||||
retrievalModel: Mapped[dict] = mapped_column("retrieval_model", JSONB, default=dict)
|
||||
sortOrder: Mapped[int] = mapped_column("sort_order", Integer, default=0)
|
||||
status: Mapped[int] = mapped_column(Integer, default=1)
|
||||
createdBy: Mapped[int | None] = mapped_column("created_by", BigInteger)
|
||||
updatedBy: Mapped[int | None] = mapped_column("updated_by", BigInteger)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fastapi_common.fastapi_common_web.models import BaseModel
|
||||
|
||||
|
||||
class LeauditRagDocument(BaseModel):
|
||||
__tablename__ = "rag_document"
|
||||
|
||||
Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True)
|
||||
datasetId: Mapped[int] = mapped_column("dataset_id", BigInteger)
|
||||
filename: Mapped[str] = mapped_column(String(500))
|
||||
originalName: Mapped[str] = mapped_column("original_name", String(500))
|
||||
minioPath: Mapped[str] = mapped_column("minio_path", String(1000))
|
||||
fileType: Mapped[str] = mapped_column("file_type", String(20))
|
||||
fileSize: Mapped[int] = mapped_column("file_size", BigInteger, default=0)
|
||||
chunkCount: Mapped[int] = mapped_column("chunk_count", Integer, default=0)
|
||||
indexingStatus: Mapped[str] = mapped_column("indexing_status", String(20), default="pending")
|
||||
indexingError: Mapped[str | None] = mapped_column("indexing_error", Text)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
hitCount: Mapped[int] = mapped_column("hit_count", Integer, default=0)
|
||||
createdBy: Mapped[int | None] = mapped_column("created_by", BigInteger)
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fastapi_common.fastapi_common_web.models import BaseModel
|
||||
|
||||
|
||||
class LeauditRagMessage(BaseModel):
|
||||
__tablename__ = "rag_message"
|
||||
|
||||
Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True)
|
||||
messageId: Mapped[str] = mapped_column("message_id", String(100), unique=True)
|
||||
conversationId: Mapped[str] = mapped_column("conversation_id", String(100))
|
||||
role: Mapped[str] = mapped_column(String(20))
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
sources: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
metadataJson: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
|
||||
feedback: Mapped[str | None] = mapped_column(String(20))
|
||||
@@ -0,0 +1 @@
|
||||
"""RAG 聊天内核兼容层。"""
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.rag_engine.config import RAG_CONFIG
|
||||
|
||||
_instance: Any | None = None
|
||||
|
||||
|
||||
def init_chroma() -> Any:
|
||||
global _instance
|
||||
if _instance is not None:
|
||||
return _instance
|
||||
|
||||
import chromadb # lazy import to avoid hard failure before feature is enabled
|
||||
import chromadb.config
|
||||
|
||||
host = RAG_CONFIG["CHROMA_HOST"]
|
||||
if host:
|
||||
token = RAG_CONFIG.get("CHROMA_TOKEN", "")
|
||||
header = RAG_CONFIG.get("CHROMA_AUTH_HEADER", "X-Chroma-Token")
|
||||
settings = (
|
||||
chromadb.config.Settings(
|
||||
chroma_client_auth_provider="chromadb.auth.token_authn.TokenAuthClientProvider",
|
||||
chroma_client_auth_credentials=token,
|
||||
chroma_auth_token_transport_header=header,
|
||||
)
|
||||
if token
|
||||
else chromadb.config.Settings()
|
||||
)
|
||||
_instance = chromadb.HttpClient(host=host, port=RAG_CONFIG["CHROMA_PORT"], settings=settings)
|
||||
else:
|
||||
_instance = chromadb.PersistentClient(path=RAG_CONFIG["CHROMA_PERSIST_DIR"])
|
||||
return _instance
|
||||
|
||||
|
||||
def get_chroma() -> Any:
|
||||
if _instance is None:
|
||||
return init_chroma()
|
||||
return _instance
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi_admin.config._settings import llm
|
||||
|
||||
|
||||
def _get_str(name: str, default: str = "") -> str:
|
||||
import os
|
||||
return os.getenv(name, default)
|
||||
|
||||
|
||||
def _get_bool(name: str, default: bool = False) -> bool:
|
||||
import os
|
||||
return os.getenv(name, str(default).lower()).lower() == "true"
|
||||
|
||||
|
||||
def _get_int(name: str, default: int) -> int:
|
||||
import os
|
||||
try:
|
||||
return int(os.getenv(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _get_float(name: str, default: float) -> float:
|
||||
import os
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
RAG_CONFIG = {
|
||||
"USE_SELF_HOSTED": True,
|
||||
"CHROMA_PERSIST_DIR": _get_str("RAG_CHROMA_PERSIST_DIR", ".chromadb_rag"),
|
||||
"CHROMA_HOST": _get_str("RAG_CHROMA_HOST", ""),
|
||||
"CHROMA_PORT": _get_int("RAG_CHROMA_PORT", 8010),
|
||||
"CHROMA_TOKEN": _get_str("RAG_CHROMA_TOKEN", ""),
|
||||
"CHROMA_AUTH_HEADER": _get_str("RAG_CHROMA_AUTH_HEADER", "X-Chroma-Token"),
|
||||
"EMBED_URL": _get_str("RAG_EMBED_URL", _get_str("GRAPH_RAG_EMBED_URL", "")),
|
||||
"EMBED_KEY": _get_str("RAG_EMBED_KEY", _get_str("GRAPH_RAG_EMBED_KEY", "")),
|
||||
"EMBED_MODEL": _get_str("RAG_EMBED_MODEL", _get_str("GRAPH_RAG_EMBED_MODEL", "")),
|
||||
"EMBED_DIM": _get_int("RAG_EMBED_DIM", 1024),
|
||||
"EMBED_BATCH_SIZE": _get_int("RAG_EMBED_BATCH_SIZE", 10),
|
||||
"RERANKER_URL": _get_str("RAG_RERANKER_URL", _get_str("GRAPH_RAG_RERANKER_URL", "")),
|
||||
"RERANKER_KEY": _get_str("RAG_RERANKER_KEY", _get_str("GRAPH_RAG_RERANKER_KEY", "")),
|
||||
"RERANKER_MODEL": _get_str("RAG_RERANKER_MODEL", _get_str("GRAPH_RAG_RERANKER_MODEL", "")),
|
||||
"LLM_BASE_URL": _get_str("LLM_BASE_URL", llm.LLM_BASE_URL),
|
||||
"LLM_MODEL": _get_str("LLM_MODEL", llm.LLM_MODEL),
|
||||
"LLM_API_KEY": _get_str("LLM_API_KEY", llm.LLM_API_KEY),
|
||||
"VECTOR_TOP_K": _get_int("RAG_VECTOR_TOP_K", 15),
|
||||
"RERANK_TOP_K": _get_int("RAG_RERANK_TOP_K", 5),
|
||||
"BM25_TOP_K": _get_int("RAG_BM25_TOP_K", 15),
|
||||
"RRF_K": _get_int("RAG_RRF_K", 60),
|
||||
"LLM_TEMPERATURE": _get_float("RAG_LLM_TEMPERATURE", 0.3),
|
||||
"LLM_MAX_TOKENS": _get_int("RAG_LLM_MAX_TOKENS", 2048),
|
||||
"LLM_TIMEOUT": _get_int("RAG_LLM_TIMEOUT", 120),
|
||||
"QUERY_REWRITING": _get_bool("RAG_QUERY_REWRITING", False),
|
||||
"HYBRID_SEARCH": _get_bool("RAG_HYBRID_SEARCH", True),
|
||||
"RERANKING": _get_bool("RAG_RERANKING", True),
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.rag_engine.config import RAG_CONFIG
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = """你是烟草行业智慧法务小助手,专注于烟草专卖法规、合同管理、行政处罚等相关法律法规。\n\n回答要求:\n- 先用一句话直接回答,再展开详细说明\n- 多个要点用编号列表\n- 关键法条和数字用 **加粗**\n- 分类信息用表格\n- 层级结构用缩进子列表\n- 不要加标题,直接输出正文"""
|
||||
|
||||
|
||||
async def generate_stream(
|
||||
query: str,
|
||||
context_chunks: list[dict],
|
||||
conversation_id: str,
|
||||
message_id: str,
|
||||
system_prompt: str = "",
|
||||
model: str = "",
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
dataset_name: str = "",
|
||||
) -> AsyncGenerator[str, None]:
|
||||
task_id = str(uuid.uuid4())
|
||||
created_at = int(time.time())
|
||||
_model = model or RAG_CONFIG["LLM_MODEL"]
|
||||
_temp = temperature if temperature is not None else RAG_CONFIG["LLM_TEMPERATURE"]
|
||||
_max_tok = max_tokens or RAG_CONFIG["LLM_MAX_TOKENS"]
|
||||
_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
max_context_chars = 8000
|
||||
if context_chunks:
|
||||
parts: list[str] = []
|
||||
total_len = 0
|
||||
for chunk in context_chunks:
|
||||
part = f"[来源: {chunk.get('source', '未知')}]\\n{chunk.get('text', '')}"
|
||||
if total_len + len(part) > max_context_chars:
|
||||
break
|
||||
parts.append(part)
|
||||
total_len += len(part)
|
||||
context_text = "\\n\\n---\\n\\n".join(parts)
|
||||
user_content = f"知识库内容:\\n{context_text}\\n\\n用户问题: {query}"
|
||||
else:
|
||||
user_content = query
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": _prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
total_tokens = 0
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=RAG_CONFIG["LLM_TIMEOUT"]) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{RAG_CONFIG['LLM_BASE_URL'].rstrip('/')}" + "/chat/completions",
|
||||
json={
|
||||
"model": _model,
|
||||
"messages": messages,
|
||||
"temperature": _temp,
|
||||
"max_tokens": _max_tok,
|
||||
"stream": True,
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {RAG_CONFIG['LLM_API_KEY']}",
|
||||
},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
payload = line[6:].strip()
|
||||
if payload == "[DONE]":
|
||||
break
|
||||
chunk = json.loads(payload)
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
text = delta.get("content", "")
|
||||
if text:
|
||||
yield _sse_line(
|
||||
{
|
||||
"event": "message",
|
||||
"task_id": task_id,
|
||||
"message_id": message_id,
|
||||
"conversation_id": conversation_id,
|
||||
"answer": text,
|
||||
"created_at": created_at,
|
||||
}
|
||||
)
|
||||
usage = chunk.get("usage")
|
||||
if usage:
|
||||
total_tokens = usage.get("total_tokens", total_tokens)
|
||||
except Exception as exc:
|
||||
yield _sse_line(
|
||||
{
|
||||
"event": "error",
|
||||
"task_id": task_id,
|
||||
"message_id": message_id,
|
||||
"code": "llm_error",
|
||||
"message": str(exc),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
retriever_resources = [
|
||||
{
|
||||
"position": i + 1,
|
||||
"dataset_id": "",
|
||||
"dataset_name": dataset_name,
|
||||
"document_id": "",
|
||||
"document_name": chunk.get("source", ""),
|
||||
"data_source_type": "upload_file",
|
||||
"segment_id": chunk.get("id", ""),
|
||||
"retriever_from": "rag",
|
||||
"score": round(chunk.get("score", 0.0), 4),
|
||||
"hit_count": 0,
|
||||
"word_count": len(chunk.get("text", "")),
|
||||
"segment_position": i + 1,
|
||||
"index_node_hash": "",
|
||||
"content": chunk.get("text", "")[:500],
|
||||
"page": None,
|
||||
}
|
||||
for i, chunk in enumerate(context_chunks)
|
||||
]
|
||||
|
||||
yield _sse_line(
|
||||
{
|
||||
"event": "message_end",
|
||||
"task_id": task_id,
|
||||
"message_id": message_id,
|
||||
"conversation_id": conversation_id,
|
||||
"metadata": {
|
||||
"usage": {"total_tokens": total_tokens},
|
||||
"retriever_resources": retriever_resources,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _sse_line(data: dict) -> str:
|
||||
return f"data: {json.dumps(data, ensure_ascii=False)}\\n\\n"
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.rag_engine.config import RAG_CONFIG
|
||||
|
||||
|
||||
async def generate_followups(query: str, answer: str) -> list[str]:
|
||||
prompt = (
|
||||
"基于用户问题和已有回答,生成 3 个适合继续追问的简短问题。"
|
||||
"仅返回 JSON 数组字符串,例如 [\"问题1\", \"问题2\"]。\\n"
|
||||
f"用户问题: {query}\\n回答: {answer[:1200]}"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{RAG_CONFIG['LLM_BASE_URL'].rstrip('/')}" + "/chat/completions",
|
||||
json={
|
||||
"model": RAG_CONFIG["LLM_MODEL"],
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 256,
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {RAG_CONFIG['LLM_API_KEY']}",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
if isinstance(parsed, list):
|
||||
return [str(item).strip() for item in parsed if str(item).strip()][:3]
|
||||
except Exception:
|
||||
pass
|
||||
return [line.strip("- 1234567890.\t") for line in content.splitlines() if line.strip()][:3]
|
||||
@@ -14,6 +14,8 @@ from fastapi_modules.fastapi_leaudit.services.rbacAdminService import IRbacAdmin
|
||||
from fastapi_modules.fastapi_leaudit.services.rbacService import IRbacService
|
||||
from fastapi_modules.fastapi_leaudit.services.ruleConfigService import IRuleConfigService
|
||||
from fastapi_modules.fastapi_leaudit.services.ruleService import IRuleService
|
||||
from fastapi_modules.fastapi_leaudit.services.ragDatasetService import IRagDatasetService
|
||||
from fastapi_modules.fastapi_leaudit.services.ragChatService import IRagChatService
|
||||
|
||||
__all__ = [
|
||||
"IAuditService",
|
||||
@@ -30,4 +32,6 @@ __all__ = [
|
||||
"IRbacService",
|
||||
"IRuleConfigService",
|
||||
"IRuleService",
|
||||
"IRagDatasetService",
|
||||
"IRagChatService",
|
||||
]
|
||||
|
||||
@@ -618,6 +618,15 @@ class DocumentServiceImpl(IDocumentService):
|
||||
currentUser = await self._getCurrentUserContext(CurrentUserId)
|
||||
documentColumns = await self._loadDocumentColumns(Session)
|
||||
detail = await self._getDocumentDetail(Session, DocumentId, CurrentUserId, currentUser, documentColumns)
|
||||
if not detail and await self._hasCrossReviewDocumentAccess(Session, DocumentId, CurrentUserId):
|
||||
detail = await self._getDocumentDetail(
|
||||
Session,
|
||||
DocumentId,
|
||||
CurrentUserId,
|
||||
currentUser,
|
||||
documentColumns,
|
||||
BypassScopeCheck=True,
|
||||
)
|
||||
if not detail:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问")
|
||||
|
||||
@@ -679,6 +688,15 @@ class DocumentServiceImpl(IDocumentService):
|
||||
currentUser = await self._getCurrentUserContext(CurrentUserId)
|
||||
documentColumns = await self._loadDocumentColumns(Session)
|
||||
detail = await self._getDocumentDetail(Session, documentId, CurrentUserId, currentUser, documentColumns)
|
||||
if not detail and await self._hasCrossReviewDocumentAccess(Session, documentId, CurrentUserId):
|
||||
detail = await self._getDocumentDetail(
|
||||
Session,
|
||||
documentId,
|
||||
CurrentUserId,
|
||||
currentUser,
|
||||
documentColumns,
|
||||
BypassScopeCheck=True,
|
||||
)
|
||||
if not detail:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问")
|
||||
|
||||
@@ -742,6 +760,15 @@ class DocumentServiceImpl(IDocumentService):
|
||||
currentUser = await self._getCurrentUserContext(CurrentUserId)
|
||||
documentColumns = await self._loadDocumentColumns(Session)
|
||||
detail = await self._getDocumentDetail(Session, DocumentId, CurrentUserId, currentUser, documentColumns)
|
||||
if not detail and await self._hasCrossReviewDocumentAccess(Session, DocumentId, CurrentUserId):
|
||||
detail = await self._getDocumentDetail(
|
||||
Session,
|
||||
DocumentId,
|
||||
CurrentUserId,
|
||||
currentUser,
|
||||
documentColumns,
|
||||
BypassScopeCheck=True,
|
||||
)
|
||||
if not detail:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问")
|
||||
|
||||
@@ -1601,19 +1628,21 @@ class DocumentServiceImpl(IDocumentService):
|
||||
CurrentUserId: int,
|
||||
CurrentUser: dict[str, Any],
|
||||
DocumentColumns: set[str],
|
||||
BypassScopeCheck: bool = False,
|
||||
) -> DocumentDetailVO | None:
|
||||
"""查询单文档详情,并附带历史版本。"""
|
||||
params: dict[str, object] = {"id": DocumentId}
|
||||
filters = ["d.id = :id", "d.deleted_at IS NULL", "f.is_active = true", "f.file_role = 'primary'"]
|
||||
filters.extend(
|
||||
self._buildDocumentScopeFilters(
|
||||
CurrentUserId=CurrentUserId,
|
||||
CurrentUser=CurrentUser,
|
||||
Params=params,
|
||||
DocumentAlias="d",
|
||||
FileAlias="f",
|
||||
if not BypassScopeCheck:
|
||||
filters.extend(
|
||||
self._buildDocumentScopeFilters(
|
||||
CurrentUserId=CurrentUserId,
|
||||
CurrentUser=CurrentUser,
|
||||
Params=params,
|
||||
DocumentAlias="d",
|
||||
FileAlias="f",
|
||||
)
|
||||
)
|
||||
)
|
||||
whereClause = " AND ".join(filters)
|
||||
|
||||
groupIdSelectExpr = "d.group_id" if "group_id" in DocumentColumns else "NULL::bigint"
|
||||
@@ -1832,6 +1861,38 @@ class DocumentServiceImpl(IDocumentService):
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
async def _hasCrossReviewDocumentAccess(self, Session, DocumentId: int, CurrentUserId: int) -> bool:
|
||||
"""判断当前用户是否作为交叉评查任务成员拥有文档访问权。"""
|
||||
if not await self._tableExists(Session, "leaudit_cross_review_task_documents"):
|
||||
return False
|
||||
if not await self._tableExists(Session, "leaudit_cross_review_task_members"):
|
||||
return False
|
||||
if not await self._tableExists(Session, "leaudit_cross_review_tasks"):
|
||||
return False
|
||||
|
||||
row = (
|
||||
await Session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM leaudit_cross_review_task_documents td
|
||||
JOIN leaudit_cross_review_task_members tm
|
||||
ON tm.task_id = td.task_id
|
||||
JOIN leaudit_cross_review_tasks t
|
||||
ON t.id = td.task_id
|
||||
WHERE td.document_id = :document_id
|
||||
AND tm.user_id = :user_id
|
||||
AND td.delete_time IS NULL
|
||||
AND tm.delete_time IS NULL
|
||||
AND t.delete_time IS NULL
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"document_id": DocumentId, "user_id": CurrentUserId},
|
||||
)
|
||||
).first()
|
||||
return bool(row)
|
||||
|
||||
def _buildDocumentScopeFilters(
|
||||
self,
|
||||
CurrentUserId: int,
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
|
||||
from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
|
||||
from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.domian.Dto.ragChatDto import (
|
||||
RagConversationRenameDTO,
|
||||
RagMessageFeedbackDTO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.ragChatVo import (
|
||||
RagAppParametersVO,
|
||||
RagChatAppListVO,
|
||||
RagChatAppVO,
|
||||
RagConversationItemVO,
|
||||
RagConversationPageVO,
|
||||
RagConversationRenameVO,
|
||||
RagMessageItemVO,
|
||||
RagMessagePageVO,
|
||||
RagOperationResultVO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.rag_engine.generator import generate_stream
|
||||
from fastapi_modules.fastapi_leaudit.rag_engine.question_chains import generate_followups
|
||||
from fastapi_modules.fastapi_leaudit.services.ragChatService import IRagChatService
|
||||
|
||||
|
||||
class RagChatServiceImpl(IRagChatService):
|
||||
async def GetApps(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagChatAppListVO:
|
||||
apps = await self._load_apps(UserArea, UserRole, only_default=False)
|
||||
return RagChatAppListVO(data=apps, total=len(apps))
|
||||
|
||||
async def GetDefaultApp(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagChatAppVO | None:
|
||||
apps = await self._load_apps(UserArea, UserRole, only_default=True)
|
||||
if apps:
|
||||
return apps[0]
|
||||
all_apps = await self._load_apps(UserArea, UserRole, only_default=False)
|
||||
return all_apps[0] if all_apps else None
|
||||
|
||||
async def SendMessage(
|
||||
self,
|
||||
CurrentUserId: int,
|
||||
UserName: str,
|
||||
UserArea: str | None,
|
||||
UserRole: str | None,
|
||||
Query: str,
|
||||
ConversationId: str | None,
|
||||
AppId: int | None,
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
if not Query.strip():
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "问题不能为空")
|
||||
|
||||
app = await self._resolve_app(AppId, UserArea, UserRole)
|
||||
if not app:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "未配置可用聊天应用")
|
||||
|
||||
conversationId = await self._ensure_conversation(CurrentUserId, ConversationId, app["id"])
|
||||
messageId = str(uuid.uuid4())
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO rag_message (message_id, conversation_id, role, content, sources, metadata)
|
||||
VALUES (:message_id, :conversation_id, 'user', :content, '[]'::jsonb, '{}'::jsonb)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"message_id": str(uuid.uuid4()),
|
||||
"conversation_id": conversationId,
|
||||
"content": Query,
|
||||
},
|
||||
)
|
||||
|
||||
context_chunks, dataset_name = await self._retrieve_context(app.get("dataset_id"), Query)
|
||||
collected_answer = ""
|
||||
held_message_end: bytes | None = None
|
||||
|
||||
async for chunk in generate_stream(
|
||||
query=Query,
|
||||
context_chunks=context_chunks,
|
||||
conversation_id=conversationId,
|
||||
message_id=messageId,
|
||||
system_prompt=app.get("system_prompt") or "",
|
||||
model=app.get("llm_model") or "",
|
||||
temperature=app.get("temperature"),
|
||||
max_tokens=app.get("max_tokens"),
|
||||
dataset_name=dataset_name,
|
||||
):
|
||||
chunk_bytes = chunk.encode("utf-8")
|
||||
for line in chunk.strip().split("\n"):
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
data = json.loads(line[6:])
|
||||
if data.get("event") == "message":
|
||||
collected_answer += data.get("answer", "")
|
||||
elif data.get("event") == "message_end":
|
||||
held_message_end = chunk_bytes
|
||||
continue
|
||||
if held_message_end is None:
|
||||
yield chunk_bytes
|
||||
|
||||
followups: list[str] = []
|
||||
try:
|
||||
followups = await generate_followups(Query, collected_answer)
|
||||
except Exception:
|
||||
followups = []
|
||||
|
||||
if held_message_end:
|
||||
try:
|
||||
for line in held_message_end.decode("utf-8").strip().split("\n"):
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
end_data = json.loads(line[6:])
|
||||
if end_data.get("event") == "message_end":
|
||||
end_data.setdefault("metadata", {})["suggested_questions"] = followups
|
||||
yield f"data: {json.dumps(end_data, ensure_ascii=False)}\\n\\n".encode("utf-8")
|
||||
except Exception:
|
||||
yield held_message_end
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO rag_message (message_id, conversation_id, role, content, sources, metadata)
|
||||
VALUES (:message_id, :conversation_id, 'assistant', :content, CAST(:sources AS jsonb), CAST(:metadata AS jsonb))
|
||||
"""
|
||||
),
|
||||
{
|
||||
"message_id": messageId,
|
||||
"conversation_id": conversationId,
|
||||
"content": collected_answer,
|
||||
"sources": json.dumps(self._build_sources(context_chunks, dataset_name), ensure_ascii=False),
|
||||
"metadata": json.dumps({"suggested_questions": followups}, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE rag_conversation SET updated_at = NOW() WHERE conversation_id = :conversation_id"
|
||||
),
|
||||
{"conversation_id": conversationId},
|
||||
)
|
||||
|
||||
async def GetConversations(self, CurrentUserId: int, AppId: int | None, Page: int, PageSize: int) -> RagConversationPageVO:
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT conversation_id, name, introduction, created_at, updated_at
|
||||
FROM rag_conversation
|
||||
WHERE user_id = :user_id
|
||||
AND deleted_at IS NULL
|
||||
AND (:app_id IS NULL OR app_id = :app_id)
|
||||
ORDER BY updated_at DESC
|
||||
OFFSET :offset LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{
|
||||
"user_id": CurrentUserId,
|
||||
"app_id": AppId,
|
||||
"offset": max(Page - 1, 0) * PageSize,
|
||||
"limit": PageSize + 1,
|
||||
},
|
||||
)
|
||||
).mappings().all()
|
||||
has_more = len(rows) > PageSize
|
||||
items = rows[:PageSize]
|
||||
return RagConversationPageVO(
|
||||
data=[
|
||||
RagConversationItemVO(
|
||||
id=row["conversation_id"],
|
||||
name=row["name"],
|
||||
introduction=row.get("introduction") or "",
|
||||
createdAt=int(row["created_at"].timestamp()) if row.get("created_at") else 0,
|
||||
updatedAt=int(row["updated_at"].timestamp()) if row.get("updated_at") else 0,
|
||||
)
|
||||
for row in items
|
||||
],
|
||||
hasMore=has_more,
|
||||
limit=PageSize,
|
||||
)
|
||||
|
||||
async def GetConversationMessages(self, CurrentUserId: int, ConversationId: str, Page: int, PageSize: int) -> RagMessagePageVO:
|
||||
await self._ensure_conversation_owner(CurrentUserId, ConversationId)
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT message_id, role, content, sources, feedback, created_at
|
||||
FROM rag_message
|
||||
WHERE conversation_id = :conversation_id
|
||||
ORDER BY created_at ASC
|
||||
OFFSET :offset LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{
|
||||
"conversation_id": ConversationId,
|
||||
"offset": max(Page - 1, 0) * PageSize,
|
||||
"limit": PageSize + 1,
|
||||
},
|
||||
)
|
||||
).mappings().all()
|
||||
has_more = len(rows) > PageSize
|
||||
items = rows[:PageSize]
|
||||
data: list[RagMessageItemVO] = []
|
||||
idx = 0
|
||||
while idx < len(items):
|
||||
row = items[idx]
|
||||
if row["role"] == "user":
|
||||
answer = items[idx + 1] if idx + 1 < len(items) and items[idx + 1]["role"] == "assistant" else None
|
||||
data.append(
|
||||
RagMessageItemVO(
|
||||
id=(answer["message_id"] if answer else row["message_id"]),
|
||||
conversationId=ConversationId,
|
||||
query=row["content"],
|
||||
answer=answer["content"] if answer else "",
|
||||
feedback=({"rating": answer["feedback"]} if answer and answer.get("feedback") else None),
|
||||
retrieverResources=(answer.get("sources") if answer else None),
|
||||
createdAt=int(row["created_at"].timestamp()) if row.get("created_at") else 0,
|
||||
)
|
||||
)
|
||||
idx += 2 if answer else 1
|
||||
else:
|
||||
idx += 1
|
||||
return RagMessagePageVO(data=data, hasMore=has_more, limit=PageSize)
|
||||
|
||||
async def RenameConversation(self, CurrentUserId: int, ConversationId: str, Body: RagConversationRenameDTO) -> RagConversationRenameVO:
|
||||
await self._ensure_conversation_owner(CurrentUserId, ConversationId)
|
||||
async with GetAsyncSession() as session:
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE rag_conversation SET name = :name, updated_at = NOW() WHERE conversation_id = :conversation_id"
|
||||
),
|
||||
{"name": Body.name, "conversation_id": ConversationId},
|
||||
)
|
||||
return RagConversationRenameVO(result="success", name=Body.name)
|
||||
|
||||
async def DeleteConversation(self, CurrentUserId: int, ConversationId: str) -> RagOperationResultVO:
|
||||
await self._ensure_conversation_owner(CurrentUserId, ConversationId)
|
||||
async with GetAsyncSession() as session:
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE rag_conversation SET deleted_at = NOW(), updated_at = NOW() WHERE conversation_id = :conversation_id"
|
||||
),
|
||||
{"conversation_id": ConversationId},
|
||||
)
|
||||
return RagOperationResultVO(result="success")
|
||||
|
||||
async def UpdateFeedback(self, CurrentUserId: int, MessageId: str, Body: RagMessageFeedbackDTO) -> RagOperationResultVO:
|
||||
async with GetAsyncSession() as session:
|
||||
owner = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT c.user_id
|
||||
FROM rag_message m
|
||||
JOIN rag_conversation c ON c.conversation_id = m.conversation_id
|
||||
WHERE m.message_id = :message_id AND c.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"message_id": MessageId},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if owner is None:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "消息不存在")
|
||||
if int(owner) != CurrentUserId:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户无权修改该消息反馈")
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
text("UPDATE rag_message SET feedback = :feedback WHERE message_id = :message_id"),
|
||||
{"feedback": Body.rating, "message_id": MessageId},
|
||||
)
|
||||
return RagOperationResultVO(result="success")
|
||||
|
||||
async def GetAppParameters(
|
||||
self,
|
||||
CurrentUserId: int,
|
||||
UserArea: str | None,
|
||||
UserRole: str | None,
|
||||
AppId: int | None,
|
||||
) -> RagAppParametersVO:
|
||||
app = await self._resolve_app(AppId, UserArea, UserRole)
|
||||
if not app:
|
||||
return RagAppParametersVO()
|
||||
try:
|
||||
suggested = json.loads(app.get("suggested_questions") or "[]")
|
||||
if not isinstance(suggested, list):
|
||||
suggested = []
|
||||
except Exception:
|
||||
suggested = []
|
||||
return RagAppParametersVO(
|
||||
openingStatement=app.get("opening_statement") or "",
|
||||
suggestedQuestions=[str(item) for item in suggested[:6]],
|
||||
userInputForm=[],
|
||||
fileUpload={"image": {"enabled": False}},
|
||||
)
|
||||
|
||||
async def _load_apps(self, user_area: str | None, user_role: str | None, only_default: bool) -> list[RagChatAppVO]:
|
||||
async with GetAsyncSession() as session:
|
||||
sql = (
|
||||
"""
|
||||
SELECT a.id, a.name, a.description, a.is_default
|
||||
FROM rag_chat_app a
|
||||
LEFT JOIN rag_dataset d ON d.id = a.dataset_id AND d.deleted_at IS NULL
|
||||
WHERE a.deleted_at IS NULL
|
||||
AND a.status = 1
|
||||
AND (:only_default = FALSE OR a.is_default = TRUE)
|
||||
AND (
|
||||
:is_provincial = TRUE
|
||||
OR a.area IN (:user_area, '省级', '')
|
||||
OR COALESCE(d.is_public, FALSE) = TRUE
|
||||
)
|
||||
ORDER BY a.sort_order ASC, a.created_at DESC
|
||||
"""
|
||||
)
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(sql),
|
||||
{
|
||||
"only_default": only_default,
|
||||
"is_provincial": user_role == "provincial_admin",
|
||||
"user_area": user_area or "",
|
||||
},
|
||||
)
|
||||
).mappings().all()
|
||||
return [
|
||||
RagChatAppVO(
|
||||
appId=str(row["id"]),
|
||||
appName=row["name"],
|
||||
description=row.get("description") or "",
|
||||
isDefault=bool(row.get("is_default")),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def _resolve_app(self, app_id: int | None, user_area: str | None, user_role: str | None) -> dict | None:
|
||||
async with GetAsyncSession() as session:
|
||||
params = {
|
||||
"app_id": app_id,
|
||||
"user_area": user_area or "",
|
||||
"is_provincial": user_role == "provincial_admin",
|
||||
}
|
||||
base_sql = (
|
||||
"""
|
||||
SELECT a.id, a.name, a.description, a.area, a.dataset_id, a.system_prompt,
|
||||
a.llm_model, a.temperature, a.max_tokens, a.opening_statement,
|
||||
a.suggested_questions, a.is_default, COALESCE(d.is_public, FALSE) AS dataset_public,
|
||||
COALESCE(d.name, '') AS dataset_name
|
||||
FROM rag_chat_app a
|
||||
LEFT JOIN rag_dataset d ON d.id = a.dataset_id AND d.deleted_at IS NULL
|
||||
WHERE a.deleted_at IS NULL AND a.status = 1
|
||||
"""
|
||||
)
|
||||
if app_id is not None:
|
||||
row = (
|
||||
await session.execute(
|
||||
text(base_sql + " AND a.id = :app_id LIMIT 1"),
|
||||
params,
|
||||
)
|
||||
).mappings().first()
|
||||
if row and self._app_visible(row, user_area, user_role):
|
||||
return dict(row)
|
||||
row = (
|
||||
await session.execute(
|
||||
text(base_sql + " AND a.is_default = TRUE ORDER BY a.sort_order ASC, a.created_at DESC LIMIT 1"),
|
||||
params,
|
||||
)
|
||||
).mappings().first()
|
||||
if row and self._app_visible(row, user_area, user_role):
|
||||
return dict(row)
|
||||
row = (
|
||||
await session.execute(
|
||||
text(base_sql + " ORDER BY a.sort_order ASC, a.created_at DESC LIMIT 1"),
|
||||
params,
|
||||
)
|
||||
).mappings().first()
|
||||
return dict(row) if row and self._app_visible(row, user_area, user_role) else None
|
||||
|
||||
def _app_visible(self, row: dict, user_area: str | None, user_role: str | None) -> bool:
|
||||
if user_role == "provincial_admin":
|
||||
return True
|
||||
area = row.get("area") or ""
|
||||
return area in ("", "省级", user_area or "") or bool(row.get("dataset_public"))
|
||||
|
||||
async def _ensure_conversation(self, user_id: int, conversation_id: str | None, app_id: int | None) -> str:
|
||||
if conversation_id and conversation_id != "-1":
|
||||
async with GetAsyncSession() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT conversation_id, user_id
|
||||
FROM rag_conversation
|
||||
WHERE conversation_id = :conversation_id
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"conversation_id": conversation_id},
|
||||
)
|
||||
).mappings().first()
|
||||
if row:
|
||||
if int(row["user_id"]) != user_id:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户无权使用该会话")
|
||||
return str(row["conversation_id"])
|
||||
conversation_id = str(uuid.uuid4())
|
||||
async with GetAsyncSession() as session:
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO rag_conversation (conversation_id, user_id, app_id, name, introduction)
|
||||
VALUES (:conversation_id, :user_id, :app_id, '新对话', '')
|
||||
"""
|
||||
),
|
||||
{"conversation_id": conversation_id, "user_id": user_id, "app_id": app_id},
|
||||
)
|
||||
return conversation_id
|
||||
|
||||
async def _ensure_conversation_owner(self, user_id: int, conversation_id: str) -> None:
|
||||
async with GetAsyncSession() as session:
|
||||
owner = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT user_id FROM rag_conversation WHERE conversation_id = :conversation_id AND deleted_at IS NULL LIMIT 1"
|
||||
),
|
||||
{"conversation_id": conversation_id},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if owner is None:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "会话不存在")
|
||||
if int(owner) != user_id:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户无权访问该会话")
|
||||
|
||||
async def _retrieve_context(self, dataset_id: int | None, query: str) -> tuple[list[dict], str]:
|
||||
if not dataset_id:
|
||||
return [], ""
|
||||
async with GetAsyncSession() as session:
|
||||
dataset = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, name, collection_name, retrieval_model
|
||||
FROM rag_dataset
|
||||
WHERE id = :dataset_id AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"dataset_id": dataset_id},
|
||||
)
|
||||
).mappings().first()
|
||||
if not dataset:
|
||||
return [], ""
|
||||
retrieval_model = dataset.get("retrieval_model") or {}
|
||||
top_k = int(retrieval_model.get("top_k") or 5)
|
||||
score_threshold = None
|
||||
if retrieval_model.get("score_threshold_enabled"):
|
||||
try:
|
||||
score_threshold = float(retrieval_model.get("score_threshold"))
|
||||
except (TypeError, ValueError):
|
||||
score_threshold = None
|
||||
try:
|
||||
from fastapi_modules.fastapi_leaudit.rag_engine.chroma_client import get_chroma
|
||||
except Exception:
|
||||
return [], dataset.get("name") or ""
|
||||
try:
|
||||
collection = get_chroma().get_or_create_collection(dataset["collection_name"])
|
||||
result = collection.query(query_texts=[query], n_results=max(top_k, 1))
|
||||
docs = (result.get("documents") or [[]])[0]
|
||||
metas = (result.get("metadatas") or [[]])[0]
|
||||
distances = (result.get("distances") or [[]])[0]
|
||||
chunks: list[dict] = []
|
||||
for idx, doc in enumerate(docs):
|
||||
meta = metas[idx] if idx < len(metas) else {}
|
||||
dist = distances[idx] if idx < len(distances) else 0.0
|
||||
score = 1 - float(dist or 0.0)
|
||||
if score_threshold is not None and score < score_threshold:
|
||||
continue
|
||||
chunks.append(
|
||||
{
|
||||
"id": str(meta.get("id") or idx),
|
||||
"text": doc,
|
||||
"source": meta.get("source") or meta.get("document_name") or dataset.get("name") or "",
|
||||
"score": score,
|
||||
"chunk_index": idx,
|
||||
"document_name": meta.get("document_name") or meta.get("source") or "",
|
||||
}
|
||||
)
|
||||
chunks = await self._hydrate_document_hits(dataset_id, chunks)
|
||||
return chunks[:top_k], dataset.get("name") or ""
|
||||
except Exception:
|
||||
return [], dataset.get("name") or ""
|
||||
|
||||
def _build_sources(self, context_chunks: list[dict], dataset_name: str) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"position": index + 1,
|
||||
"dataset_id": str(chunk.get("dataset_id") or ""),
|
||||
"dataset_name": dataset_name,
|
||||
"document_id": str(chunk.get("document_id") or ""),
|
||||
"document_name": chunk.get("document_name") or chunk.get("source", ""),
|
||||
"data_source_type": "upload_file",
|
||||
"segment_id": chunk.get("id", ""),
|
||||
"retriever_from": "rag",
|
||||
"score": round(chunk.get("score", 0.0), 4),
|
||||
"hit_count": chunk.get("hit_count", 0),
|
||||
"word_count": len(chunk.get("text", "")),
|
||||
"segment_position": index + 1,
|
||||
"index_node_hash": "",
|
||||
"content": chunk.get("text", "")[:500],
|
||||
"page": None,
|
||||
}
|
||||
for index, chunk in enumerate(context_chunks)
|
||||
]
|
||||
|
||||
async def _hydrate_document_hits(self, dataset_id: int, chunks: list[dict]) -> list[dict]:
|
||||
source_names = sorted(
|
||||
{
|
||||
str(chunk.get("document_name") or chunk.get("source") or "").strip()
|
||||
for chunk in chunks
|
||||
if str(chunk.get("document_name") or chunk.get("source") or "").strip()
|
||||
}
|
||||
)
|
||||
if not source_names:
|
||||
return chunks
|
||||
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, original_name, enabled, hit_count
|
||||
FROM rag_document
|
||||
WHERE dataset_id = :dataset_id
|
||||
AND deleted_at IS NULL
|
||||
AND original_name = ANY(:source_names)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"dataset_id": dataset_id,
|
||||
"source_names": source_names,
|
||||
},
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
document_map = {str(row["original_name"]): row for row in rows}
|
||||
visible_chunks: list[dict] = []
|
||||
hit_document_ids: list[int] = []
|
||||
for chunk in chunks:
|
||||
source_name = str(chunk.get("document_name") or chunk.get("source") or "").strip()
|
||||
document = document_map.get(source_name)
|
||||
if document and not bool(document.get("enabled")):
|
||||
continue
|
||||
if document:
|
||||
chunk["document_id"] = document["id"]
|
||||
chunk["dataset_id"] = dataset_id
|
||||
chunk["document_name"] = document["original_name"]
|
||||
chunk["hit_count"] = document.get("hit_count") or 0
|
||||
hit_document_ids.append(int(document["id"]))
|
||||
visible_chunks.append(chunk)
|
||||
|
||||
if hit_document_ids:
|
||||
async with GetAsyncSession() as session:
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE rag_document
|
||||
SET hit_count = hit_count + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = ANY(:document_ids)
|
||||
"""
|
||||
),
|
||||
{"document_ids": sorted(set(hit_document_ids))},
|
||||
)
|
||||
|
||||
return visible_chunks
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.ragDatasetVo import RagDatasetItemVO, RagDatasetPageVO
|
||||
from fastapi_modules.fastapi_leaudit.services.ragDatasetService import IRagDatasetService
|
||||
|
||||
|
||||
class RagDatasetServiceImpl(IRagDatasetService):
|
||||
async def GetMyDatasets(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagDatasetPageVO:
|
||||
async with GetAsyncSession() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, name, description, area, is_public, is_default, document_count, total_chunks, status
|
||||
FROM rag_dataset
|
||||
WHERE deleted_at IS NULL
|
||||
AND status = 1
|
||||
AND (
|
||||
:is_provincial = TRUE
|
||||
OR area IN (:user_area, '省级', '')
|
||||
OR is_public = TRUE
|
||||
)
|
||||
ORDER BY sort_order ASC, created_at DESC
|
||||
"""
|
||||
),
|
||||
{
|
||||
"is_provincial": UserRole == "provincial_admin",
|
||||
"user_area": UserArea or "",
|
||||
},
|
||||
)
|
||||
).mappings().all()
|
||||
return RagDatasetPageVO(
|
||||
data=[
|
||||
RagDatasetItemVO(
|
||||
id=row["id"],
|
||||
name=row["name"],
|
||||
description=row.get("description") or "",
|
||||
area=row.get("area") or "",
|
||||
isPublic=bool(row.get("is_public")),
|
||||
isDefault=bool(row.get("is_default")),
|
||||
documentCount=row.get("document_count") or 0,
|
||||
totalChunks=row.get("total_chunks") or 0,
|
||||
status=row.get("status") or 1,
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
total=len(rows),
|
||||
)
|
||||
@@ -223,6 +223,13 @@ class RbacAdminServiceImpl(IRbacAdminService):
|
||||
{"permission_key": "rbac:user_roles:write", "display_name": "分配用户角色", "module": "rbac", "resource": "user_roles", "action": "write", "api_method": "POST", "api_path": "/api/v3/rbac/users/{user_id}/roles", "route_path": "/role-permissions"},
|
||||
{"permission_key": "rbac:role_routes:write", "display_name": "配置角色菜单", "module": "rbac", "resource": "role_routes", "action": "write", "api_method": "PUT", "api_path": "/api/rbac/roles/{role_id}/routes", "route_path": "/role-permissions"},
|
||||
{"permission_key": "rbac:role_permissions:write", "display_name": "配置角色权限", "module": "rbac", "resource": "role_permissions", "action": "write", "api_method": "POST", "api_path": "/api/v3/rbac/role-permissions", "route_path": "/role-permissions"},
|
||||
{"permission_key": "rag:app:read", "display_name": "查看 RAG 应用", "module": "rag", "resource": "app", "action": "read", "api_method": "GET", "api_path": "/api/v3/rag/apps", "route_path": "/chat-with-llm"},
|
||||
{"permission_key": "rag:chat:use", "display_name": "使用 RAG 对话", "module": "rag", "resource": "chat", "action": "use", "api_method": "POST", "api_path": "/api/v3/rag/chat/messages", "route_path": "/chat-with-llm"},
|
||||
{"permission_key": "rag:conversation:read", "display_name": "查看 RAG 会话", "module": "rag", "resource": "conversation", "action": "read", "api_method": "GET", "api_path": "/api/v3/rag/chat/conversations", "route_path": "/chat-with-llm"},
|
||||
{"permission_key": "rag:conversation:update", "display_name": "重命名 RAG 会话", "module": "rag", "resource": "conversation", "action": "update", "api_method": "PATCH", "api_path": "/api/v3/rag/chat/conversations/{ConversationId}", "route_path": "/chat-with-llm"},
|
||||
{"permission_key": "rag:conversation:delete", "display_name": "删除 RAG 会话", "module": "rag", "resource": "conversation", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/rag/chat/conversations/{ConversationId}", "route_path": "/chat-with-llm"},
|
||||
{"permission_key": "rag:message:feedback", "display_name": "反馈 RAG 消息", "module": "rag", "resource": "message", "action": "feedback", "api_method": "POST", "api_path": "/api/v3/rag/chat/messages/{MessageId}/feedback", "route_path": "/chat-with-llm"},
|
||||
{"permission_key": "rag:dataset:read", "display_name": "查看 RAG 知识库", "module": "rag", "resource": "dataset", "action": "read", "api_method": "GET", "api_path": "/api/v3/rag/datasets/my", "route_path": "/chat-with-llm"},
|
||||
]
|
||||
|
||||
async def ListRoles(self, CurrentUserId: int, Page: int, PageSize: int, RoleKey: str | None, RoleName: str | None, IncludeSystem: bool) -> RoleListVO:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.domian.Dto.ragChatDto import (
|
||||
RagConversationRenameDTO,
|
||||
RagMessageFeedbackDTO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.ragChatVo import (
|
||||
RagAppParametersVO,
|
||||
RagChatAppListVO,
|
||||
RagChatAppVO,
|
||||
RagConversationPageVO,
|
||||
RagConversationRenameVO,
|
||||
RagMessagePageVO,
|
||||
RagOperationResultVO,
|
||||
)
|
||||
|
||||
|
||||
class IRagChatService(ABC):
|
||||
@abstractmethod
|
||||
async def GetApps(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagChatAppListVO: ...
|
||||
|
||||
@abstractmethod
|
||||
async def GetDefaultApp(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagChatAppVO | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def SendMessage(
|
||||
self,
|
||||
CurrentUserId: int,
|
||||
UserName: str,
|
||||
UserArea: str | None,
|
||||
UserRole: str | None,
|
||||
Query: str,
|
||||
ConversationId: str | None,
|
||||
AppId: int | None,
|
||||
) -> AsyncGenerator[bytes, None]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def GetConversations(self, CurrentUserId: int, AppId: int | None, Page: int, PageSize: int) -> RagConversationPageVO: ...
|
||||
|
||||
@abstractmethod
|
||||
async def GetConversationMessages(self, CurrentUserId: int, ConversationId: str, Page: int, PageSize: int) -> RagMessagePageVO: ...
|
||||
|
||||
@abstractmethod
|
||||
async def RenameConversation(self, CurrentUserId: int, ConversationId: str, Body: RagConversationRenameDTO) -> RagConversationRenameVO: ...
|
||||
|
||||
@abstractmethod
|
||||
async def DeleteConversation(self, CurrentUserId: int, ConversationId: str) -> RagOperationResultVO: ...
|
||||
|
||||
@abstractmethod
|
||||
async def UpdateFeedback(self, CurrentUserId: int, MessageId: str, Body: RagMessageFeedbackDTO) -> RagOperationResultVO: ...
|
||||
|
||||
@abstractmethod
|
||||
async def GetAppParameters(
|
||||
self,
|
||||
CurrentUserId: int,
|
||||
UserArea: str | None,
|
||||
UserRole: str | None,
|
||||
AppId: int | None,
|
||||
) -> RagAppParametersVO: ...
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.ragDatasetVo import RagDatasetPageVO
|
||||
|
||||
|
||||
class IRagDatasetService(ABC):
|
||||
@abstractmethod
|
||||
async def GetMyDatasets(self, CurrentUserId: int, UserArea: str | None, UserRole: str | None) -> RagDatasetPageVO: ...
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"pydantic>=2.10.0",
|
||||
"pydantic-settings>=2.7.0",
|
||||
"httpx>=0.28.0",
|
||||
"chromadb>=0.5.23",
|
||||
"celery>=5.4.0",
|
||||
"redis>=5.2.0",
|
||||
"tomli>=2.2.0",
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge historical split document version chains under the new root-group rule.
|
||||
|
||||
Default mode is dry-run. Use --apply to write changes.
|
||||
|
||||
Merge key:
|
||||
- region
|
||||
- root business group (一级分组)
|
||||
- normalized_name
|
||||
|
||||
Within one merge group:
|
||||
- sort by created_at ASC, then document_id ASC
|
||||
- keep the earliest document as root version
|
||||
- reuse the earliest document's version_group_key as the canonical chain key
|
||||
- renumber version_no from 1..N
|
||||
- link previous_version_id sequentially
|
||||
- mark only the newest document as is_latest_version = true
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP_TOML = ROOT / "app.toml"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CandidateRow:
|
||||
document_id: int
|
||||
region: str
|
||||
normalized_name: str
|
||||
root_group_id: int | None
|
||||
root_group_name: str | None
|
||||
type_id: int | None
|
||||
type_name: str | None
|
||||
group_id: int | None
|
||||
group_name: str | None
|
||||
version_group_key: str
|
||||
version_no: int
|
||||
is_latest_version: bool
|
||||
file_name: str | None
|
||||
updated_at: Any
|
||||
created_at: Any
|
||||
|
||||
|
||||
def load_target_dsn() -> str:
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError: # pragma: no cover
|
||||
import tomli as tomllib
|
||||
|
||||
with APP_TOML.open("rb") as fh:
|
||||
config = tomllib.load(fh)
|
||||
db = config["DB"]
|
||||
return (
|
||||
f"postgresql://{db['USER']}:{db['PASSWORD']}"
|
||||
f"@{db['HOST']}:{db['PORT']}/{db['NAME']}"
|
||||
)
|
||||
|
||||
|
||||
async def fetch_candidates(
|
||||
conn: asyncpg.Connection,
|
||||
*,
|
||||
region: str | None,
|
||||
normalized_name: str | None,
|
||||
) -> list[CandidateRow]:
|
||||
filters = ["d.deleted_at IS NULL"]
|
||||
params: list[Any] = []
|
||||
|
||||
if region:
|
||||
params.append(region)
|
||||
filters.append(f"d.region = ${len(params)}")
|
||||
if normalized_name:
|
||||
params.append(normalized_name.strip().lower())
|
||||
filters.append(f"d.normalized_name = ${len(params)}")
|
||||
|
||||
where_clause = " AND ".join(filters)
|
||||
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
WITH doc_scope AS (
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
d.region,
|
||||
d.normalized_name,
|
||||
d.version_group_key,
|
||||
d.version_no,
|
||||
d.is_latest_version,
|
||||
d.type_id,
|
||||
dt.name AS type_name,
|
||||
d.group_id,
|
||||
child.name AS group_name,
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN child.id IS NULL THEN NULL
|
||||
WHEN COALESCE(child.pid, 0) = 0 THEN child.id
|
||||
ELSE child.pid
|
||||
END,
|
||||
inferred.root_group_id
|
||||
) AS root_group_id,
|
||||
COALESCE(root.name, inferred.root_group_name) AS root_group_name,
|
||||
d.created_at,
|
||||
d.updated_at,
|
||||
f.file_name
|
||||
FROM leaudit_documents d
|
||||
LEFT JOIN leaudit_document_types dt
|
||||
ON dt.id = d.type_id
|
||||
LEFT JOIN leaudit_evaluation_point_groups child
|
||||
ON child.id = d.group_id
|
||||
LEFT JOIN leaudit_evaluation_point_groups root
|
||||
ON root.id = CASE
|
||||
WHEN child.id IS NULL THEN NULL
|
||||
WHEN COALESCE(child.pid, 0) = 0 THEN child.id
|
||||
ELSE child.pid
|
||||
END
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
child2.document_type_id,
|
||||
CASE WHEN COUNT(DISTINCT child2.pid) = 1 THEN MIN(child2.pid) END AS root_group_id,
|
||||
CASE WHEN COUNT(DISTINCT child2.pid) = 1 THEN MIN(parent.name) END AS root_group_name
|
||||
FROM leaudit_evaluation_point_groups child2
|
||||
JOIN leaudit_evaluation_point_groups parent
|
||||
ON parent.id = child2.pid
|
||||
WHERE COALESCE(child2.pid, 0) <> 0
|
||||
AND child2.deleted_at IS NULL
|
||||
AND child2.is_enabled = true
|
||||
AND child2.document_type_id IS NOT NULL
|
||||
GROUP BY child2.document_type_id
|
||||
) inferred
|
||||
ON inferred.document_type_id = d.type_id
|
||||
LEFT JOIN leaudit_document_files f
|
||||
ON f.document_id = d.id
|
||||
AND f.is_active = true
|
||||
AND f.file_role = 'primary'
|
||||
WHERE {where_clause}
|
||||
),
|
||||
conflict_keys AS (
|
||||
SELECT
|
||||
region,
|
||||
normalized_name,
|
||||
root_group_id
|
||||
FROM doc_scope
|
||||
WHERE normalized_name IS NOT NULL
|
||||
AND normalized_name <> ''
|
||||
AND root_group_id IS NOT NULL
|
||||
GROUP BY region, normalized_name, root_group_id
|
||||
HAVING COUNT(DISTINCT version_group_key) > 1
|
||||
)
|
||||
SELECT ds.*
|
||||
FROM doc_scope ds
|
||||
JOIN conflict_keys ck
|
||||
ON ck.region = ds.region
|
||||
AND ck.normalized_name = ds.normalized_name
|
||||
AND ck.root_group_id = ds.root_group_id
|
||||
ORDER BY ds.region, ds.root_group_name, ds.normalized_name, ds.created_at ASC, ds.document_id ASC
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
|
||||
return [CandidateRow(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
def group_candidates(rows: list[CandidateRow]) -> list[list[CandidateRow]]:
|
||||
grouped: dict[tuple[str, str, int], list[CandidateRow]] = defaultdict(list)
|
||||
for row in rows:
|
||||
if row.root_group_id is None:
|
||||
continue
|
||||
grouped[(row.region, row.normalized_name, row.root_group_id)].append(row)
|
||||
|
||||
result: list[list[CandidateRow]] = []
|
||||
for group_rows in grouped.values():
|
||||
group_rows.sort(key=lambda item: (item.created_at, item.document_id))
|
||||
result.append(group_rows)
|
||||
result.sort(key=lambda items: (items[0].region, items[0].root_group_name or "", items[0].normalized_name))
|
||||
return result
|
||||
|
||||
|
||||
def print_plan(groups: list[list[CandidateRow]], limit: int) -> None:
|
||||
print(f"计划处理候选组: {len(groups)}")
|
||||
if not groups:
|
||||
return
|
||||
|
||||
for index, rows in enumerate(groups[:limit], start=1):
|
||||
first = rows[0]
|
||||
canonical_key = first.version_group_key
|
||||
root_id = first.document_id
|
||||
latest_id = rows[-1].document_id
|
||||
print()
|
||||
print(
|
||||
f"[{index}] 地区={first.region} | 一级分组={first.root_group_name or first.root_group_id} | "
|
||||
f"名称={first.normalized_name} | 目标链={canonical_key} | 根文档={root_id} | 最新文档={latest_id}"
|
||||
)
|
||||
for version_no, row in enumerate(rows, start=1):
|
||||
previous_id = rows[version_no - 2].document_id if version_no > 1 else None
|
||||
latest_mark = "latest" if row.document_id == latest_id else "history"
|
||||
print(
|
||||
f" - doc={row.document_id} old_chain={row.version_group_key} old_v={row.version_no} "
|
||||
f"-> new_v={version_no} prev={previous_id} {latest_mark} type={row.type_name or row.type_id}"
|
||||
)
|
||||
|
||||
if len(groups) > limit:
|
||||
print()
|
||||
print(f"... 还剩 {len(groups) - limit} 组未显示,可通过 --limit 查看更多")
|
||||
|
||||
|
||||
async def apply_groups(conn: asyncpg.Connection, groups: list[list[CandidateRow]]) -> None:
|
||||
for rows in groups:
|
||||
root_row = rows[0]
|
||||
canonical_key = root_row.version_group_key
|
||||
root_document_id = root_row.document_id
|
||||
latest_document_id = rows[-1].document_id
|
||||
|
||||
for version_no, row in enumerate(rows, start=1):
|
||||
previous_id = rows[version_no - 2].document_id if version_no > 1 else None
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE leaudit_documents
|
||||
SET version_group_key = $1,
|
||||
version_no = $2,
|
||||
previous_version_id = $3,
|
||||
root_version_id = $4,
|
||||
is_latest_version = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $6
|
||||
""",
|
||||
canonical_key,
|
||||
version_no,
|
||||
previous_id,
|
||||
root_document_id,
|
||||
row.document_id == latest_document_id,
|
||||
row.document_id,
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="合并历史上被拆开的文档版本链")
|
||||
parser.add_argument("--region", help="仅处理指定地区")
|
||||
parser.add_argument("--name", help="仅处理指定 normalized_name")
|
||||
parser.add_argument("--limit", type=int, default=50, help="最多展示多少组计划")
|
||||
parser.add_argument("--apply", action="store_true", help="真正写库;默认仅预览")
|
||||
args = parser.parse_args()
|
||||
|
||||
conn = await asyncpg.connect(load_target_dsn())
|
||||
try:
|
||||
rows = await fetch_candidates(
|
||||
conn,
|
||||
region=args.region,
|
||||
normalized_name=args.name,
|
||||
)
|
||||
groups = group_candidates(rows)
|
||||
print_plan(groups, max(1, args.limit))
|
||||
|
||||
if not args.apply:
|
||||
print()
|
||||
print("dry-run complete; rerun with --apply to write data")
|
||||
return
|
||||
|
||||
async with conn.transaction():
|
||||
await apply_groups(conn, groups)
|
||||
|
||||
print()
|
||||
print(f"apply complete; merged groups: {len(groups)}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Preview split document version chains that can be merged under the new rule.
|
||||
|
||||
The new rule groups versions by:
|
||||
- region
|
||||
- root business group (一级分组)
|
||||
- normalized_name
|
||||
|
||||
This script is read-only. It prints candidate groups whose records currently
|
||||
span more than one version_group_key, which means historical uploads were
|
||||
split into multiple chains under the old same-type-only rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP_TOML = ROOT / "app.toml"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CandidateRow:
|
||||
document_id: int
|
||||
region: str
|
||||
normalized_name: str
|
||||
root_group_id: int | None
|
||||
root_group_name: str | None
|
||||
type_id: int | None
|
||||
type_name: str | None
|
||||
group_id: int | None
|
||||
group_name: str | None
|
||||
version_group_key: str
|
||||
version_no: int
|
||||
is_latest_version: bool
|
||||
file_name: str | None
|
||||
updated_at: Any
|
||||
created_at: Any
|
||||
|
||||
|
||||
def load_target_dsn() -> str:
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError: # pragma: no cover
|
||||
import tomli as tomllib
|
||||
|
||||
with APP_TOML.open("rb") as fh:
|
||||
config = tomllib.load(fh)
|
||||
db = config["DB"]
|
||||
return (
|
||||
f"postgresql://{db['USER']}:{db['PASSWORD']}"
|
||||
f"@{db['HOST']}:{db['PORT']}/{db['NAME']}"
|
||||
)
|
||||
|
||||
|
||||
async def fetch_candidates(
|
||||
conn: asyncpg.Connection,
|
||||
*,
|
||||
region: str | None,
|
||||
normalized_name: str | None,
|
||||
) -> list[CandidateRow]:
|
||||
filters = ["d.deleted_at IS NULL"]
|
||||
params: list[Any] = []
|
||||
|
||||
if region:
|
||||
params.append(region)
|
||||
filters.append(f"d.region = ${len(params)}")
|
||||
if normalized_name:
|
||||
params.append(normalized_name.strip().lower())
|
||||
filters.append(f"d.normalized_name = ${len(params)}")
|
||||
|
||||
where_clause = " AND ".join(filters)
|
||||
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
WITH doc_scope AS (
|
||||
SELECT
|
||||
d.id AS document_id,
|
||||
d.region,
|
||||
d.normalized_name,
|
||||
d.version_group_key,
|
||||
d.version_no,
|
||||
d.is_latest_version,
|
||||
d.type_id,
|
||||
dt.name AS type_name,
|
||||
d.group_id,
|
||||
child.name AS group_name,
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN child.id IS NULL THEN NULL
|
||||
WHEN COALESCE(child.pid, 0) = 0 THEN child.id
|
||||
ELSE child.pid
|
||||
END,
|
||||
inferred.root_group_id
|
||||
) AS root_group_id,
|
||||
COALESCE(
|
||||
root.name,
|
||||
inferred.root_group_name
|
||||
) AS root_group_name,
|
||||
d.created_at,
|
||||
d.updated_at,
|
||||
f.file_name
|
||||
FROM leaudit_documents d
|
||||
LEFT JOIN leaudit_document_types dt
|
||||
ON dt.id = d.type_id
|
||||
LEFT JOIN leaudit_evaluation_point_groups child
|
||||
ON child.id = d.group_id
|
||||
LEFT JOIN leaudit_evaluation_point_groups root
|
||||
ON root.id = CASE
|
||||
WHEN child.id IS NULL THEN NULL
|
||||
WHEN COALESCE(child.pid, 0) = 0 THEN child.id
|
||||
ELSE child.pid
|
||||
END
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
child2.document_type_id,
|
||||
CASE WHEN COUNT(DISTINCT child2.pid) = 1 THEN MIN(child2.pid) END AS root_group_id,
|
||||
CASE WHEN COUNT(DISTINCT child2.pid) = 1 THEN MIN(parent.name) END AS root_group_name
|
||||
FROM leaudit_evaluation_point_groups child2
|
||||
JOIN leaudit_evaluation_point_groups parent
|
||||
ON parent.id = child2.pid
|
||||
WHERE COALESCE(child2.pid, 0) <> 0
|
||||
AND child2.deleted_at IS NULL
|
||||
AND child2.is_enabled = true
|
||||
AND child2.document_type_id IS NOT NULL
|
||||
GROUP BY child2.document_type_id
|
||||
) inferred
|
||||
ON inferred.document_type_id = d.type_id
|
||||
LEFT JOIN leaudit_document_files f
|
||||
ON f.document_id = d.id
|
||||
AND f.is_active = true
|
||||
AND f.file_role = 'primary'
|
||||
WHERE {where_clause}
|
||||
),
|
||||
conflict_keys AS (
|
||||
SELECT
|
||||
region,
|
||||
normalized_name,
|
||||
root_group_id
|
||||
FROM doc_scope
|
||||
WHERE normalized_name IS NOT NULL
|
||||
AND normalized_name <> ''
|
||||
AND root_group_id IS NOT NULL
|
||||
GROUP BY region, normalized_name, root_group_id
|
||||
HAVING COUNT(DISTINCT version_group_key) > 1
|
||||
)
|
||||
SELECT ds.*
|
||||
FROM doc_scope ds
|
||||
JOIN conflict_keys ck
|
||||
ON ck.region = ds.region
|
||||
AND ck.normalized_name = ds.normalized_name
|
||||
AND ck.root_group_id = ds.root_group_id
|
||||
ORDER BY ds.region, ds.root_group_name, ds.normalized_name, ds.created_at ASC, ds.document_id ASC
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
|
||||
return [CandidateRow(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
def print_preview(rows: list[CandidateRow], limit: int) -> None:
|
||||
grouped: dict[tuple[str, str, int | None], list[CandidateRow]] = defaultdict(list)
|
||||
for row in rows:
|
||||
grouped[(row.region, row.normalized_name, row.root_group_id)].append(row)
|
||||
|
||||
items = sorted(
|
||||
grouped.items(),
|
||||
key=lambda item: (
|
||||
item[0][0],
|
||||
item[1][0].root_group_name or "",
|
||||
item[0][1],
|
||||
),
|
||||
)
|
||||
|
||||
print(f"候选合并组: {len(items)}")
|
||||
if not items:
|
||||
return
|
||||
|
||||
shown = 0
|
||||
for (_, _, _), group_rows in items:
|
||||
if shown >= limit:
|
||||
break
|
||||
shown += 1
|
||||
|
||||
sample = group_rows[0]
|
||||
distinct_chain_keys = sorted({row.version_group_key for row in group_rows})
|
||||
print()
|
||||
print(
|
||||
f"[{shown}] 地区={sample.region} | 一级分组={sample.root_group_name or sample.root_group_id} | "
|
||||
f"归一化名称={sample.normalized_name} | 当前链数={len(distinct_chain_keys)} | 文档数={len(group_rows)}"
|
||||
)
|
||||
|
||||
for idx, row in enumerate(group_rows, start=1):
|
||||
latest_mark = "latest" if row.is_latest_version else "history"
|
||||
display_name = row.file_name or row.normalized_name
|
||||
print(
|
||||
f" - #{idx} doc={row.document_id} chain={row.version_group_key} v{row.version_no} {latest_mark} "
|
||||
f"type={row.type_name or row.type_id} child={row.group_name or row.group_id} "
|
||||
f"time={row.updated_at} file={display_name}"
|
||||
)
|
||||
|
||||
if len(items) > limit:
|
||||
print()
|
||||
print(f"... 还剩 {len(items) - limit} 组未显示,可通过 --limit 查看更多")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="预览需要合并的历史文档版本链")
|
||||
parser.add_argument("--region", help="仅查看指定地区,如 cz / mz / default")
|
||||
parser.add_argument("--name", help="仅查看指定 normalized_name")
|
||||
parser.add_argument("--limit", type=int, default=50, help="最多展示多少组候选,默认 50")
|
||||
args = parser.parse_args()
|
||||
|
||||
conn = await asyncpg.connect(load_target_dsn())
|
||||
try:
|
||||
rows = await fetch_candidates(
|
||||
conn,
|
||||
region=args.region,
|
||||
normalized_name=args.name,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
print_preview(rows, max(1, args.limit))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,187 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rag_dataset (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
area VARCHAR(50) NOT NULL DEFAULT '',
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
collection_name VARCHAR(100) NOT NULL UNIQUE,
|
||||
embedding_model VARCHAR(100) NOT NULL DEFAULT 'text-embedding-v4',
|
||||
embedding_dim INTEGER NOT NULL DEFAULT 1024,
|
||||
chunk_max_size INTEGER NOT NULL DEFAULT 800,
|
||||
chunk_min_size INTEGER NOT NULL DEFAULT 20,
|
||||
document_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_chunks INTEGER NOT NULL DEFAULT 0,
|
||||
retrieval_model JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
status SMALLINT NOT NULL DEFAULT 1,
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by BIGINT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rag_document (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL REFERENCES rag_dataset(id),
|
||||
filename VARCHAR(500) NOT NULL,
|
||||
original_name VARCHAR(500) NOT NULL,
|
||||
minio_path VARCHAR(1000) NOT NULL,
|
||||
file_type VARCHAR(20) NOT NULL,
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
indexing_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
indexing_error TEXT,
|
||||
indexing_started_at TIMESTAMPTZ,
|
||||
indexing_completed_at TIMESTAMPTZ,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
hit_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rag_chat_app (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
area VARCHAR(50) NOT NULL DEFAULT '',
|
||||
dataset_id BIGINT REFERENCES rag_dataset(id),
|
||||
system_prompt TEXT NOT NULL DEFAULT '',
|
||||
llm_model VARCHAR(100) NOT NULL DEFAULT '',
|
||||
temperature REAL NOT NULL DEFAULT 0.3,
|
||||
max_tokens INTEGER NOT NULL DEFAULT 2048,
|
||||
opening_statement TEXT NOT NULL DEFAULT '',
|
||||
suggested_questions TEXT NOT NULL DEFAULT '[]',
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
status SMALLINT NOT NULL DEFAULT 1,
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by BIGINT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rag_conversation (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
conversation_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
user_id BIGINT NOT NULL,
|
||||
app_id BIGINT REFERENCES rag_chat_app(id),
|
||||
name VARCHAR(500) NOT NULL DEFAULT '新对话',
|
||||
introduction TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rag_message (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
message_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
conversation_id VARCHAR(100) NOT NULL REFERENCES rag_conversation(conversation_id),
|
||||
role VARCHAR(20) NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
sources JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
feedback VARCHAR(20),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE rag_dataset
|
||||
ADD COLUMN IF NOT EXISTS retrieval_model JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS embedding_model VARCHAR(100) NOT NULL DEFAULT 'text-embedding-v4',
|
||||
ADD COLUMN IF NOT EXISTS embedding_dim INTEGER NOT NULL DEFAULT 1024,
|
||||
ADD COLUMN IF NOT EXISTS chunk_max_size INTEGER NOT NULL DEFAULT 800,
|
||||
ADD COLUMN IF NOT EXISTS chunk_min_size INTEGER NOT NULL DEFAULT 20,
|
||||
ADD COLUMN IF NOT EXISTS document_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS total_chunks INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS status SMALLINT NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS updated_by BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE rag_document
|
||||
ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
ADD COLUMN IF NOT EXISTS hit_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS indexing_started_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS indexing_completed_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE rag_chat_app
|
||||
ADD COLUMN IF NOT EXISTS dataset_id BIGINT REFERENCES rag_dataset(id),
|
||||
ADD COLUMN IF NOT EXISTS system_prompt TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS llm_model VARCHAR(100) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS temperature REAL NOT NULL DEFAULT 0.3,
|
||||
ADD COLUMN IF NOT EXISTS max_tokens INTEGER NOT NULL DEFAULT 2048,
|
||||
ADD COLUMN IF NOT EXISTS opening_statement TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS suggested_questions TEXT NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN IF NOT EXISTS is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS status SMALLINT NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS updated_by BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE rag_conversation
|
||||
ADD COLUMN IF NOT EXISTS app_id BIGINT REFERENCES rag_chat_app(id),
|
||||
ADD COLUMN IF NOT EXISTS introduction TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE rag_message
|
||||
ADD COLUMN IF NOT EXISTS sources JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS feedback VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
|
||||
UPDATE rag_dataset SET retrieval_model = '{}'::jsonb WHERE retrieval_model IS NULL;
|
||||
UPDATE rag_document SET enabled = TRUE WHERE enabled IS NULL;
|
||||
UPDATE rag_document SET hit_count = 0 WHERE hit_count IS NULL;
|
||||
UPDATE rag_chat_app SET suggested_questions = '[]' WHERE suggested_questions IS NULL;
|
||||
UPDATE rag_message SET sources = '[]'::jsonb WHERE sources IS NULL;
|
||||
UPDATE rag_message SET metadata = '{}'::jsonb WHERE metadata IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_rag_dataset_collection_name ON rag_dataset(collection_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_dataset_area ON rag_dataset(area) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_dataset_status ON rag_dataset(status) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_document_dataset ON rag_document(dataset_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_document_status ON rag_document(indexing_status) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_document_original_name ON rag_document(dataset_id, original_name) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_chat_app_area ON rag_chat_app(area) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_chat_app_status ON rag_chat_app(status) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_conversation_user ON rag_conversation(user_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_conversation_app ON rag_conversation(app_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_message_conversation ON rag_message(conversation_id, created_at);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_rag_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
t TEXT;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['rag_dataset', 'rag_document', 'rag_chat_app', 'rag_conversation']
|
||||
LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_updated_at ON %I', t, t);
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER trg_%s_updated_at
|
||||
BEFORE UPDATE ON %I
|
||||
FOR EACH ROW EXECUTE FUNCTION update_rag_updated_at()',
|
||||
t, t
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
+48
-13
@@ -48,8 +48,8 @@ VALUES
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
UPDATE role_route
|
||||
SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE deleted_at IS NULL
|
||||
SET status = 0, updated_at = NOW()
|
||||
WHERE status <> 0
|
||||
AND route_id IN (
|
||||
SELECT id FROM sys_routes WHERE route_path = '/rules/sets' AND deleted_at IS NULL
|
||||
);
|
||||
@@ -96,15 +96,15 @@ VALUES
|
||||
('evaluation_point:create:write', 'evaluation_point', 'create', 'write', '创建评查点', '创建评查点', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 54, NULL, '/api/v3/evaluation-points', 'POST', NULL),
|
||||
('evaluation_point:update:write', 'evaluation_point', 'update', 'write', '更新评查点', '更新评查点', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 55, NULL, '/api/v3/evaluation-points/{id}', 'PUT', NULL),
|
||||
('evaluation_point:delete:delete', 'evaluation_point', 'delete', 'delete', '删除评查点', '删除评查点', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 56, NULL, '/api/v3/evaluation-points/{id}', 'DELETE', NULL),
|
||||
('cross_review:task:create', 'cross_review', 'task', 'create', '创建交叉评查任务', '创建交叉评查任务', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 57, NULL, '/api/v3/cross-review/tasks', 'POST', ARRAY['/cross-checking/upload']),
|
||||
('cross_review:task:read', 'cross_review', 'task', 'read', '查看交叉评查任务', '查看交叉评查任务', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 58, NULL, '/api/v3/cross-review/tasks/query', 'POST', ARRAY['/cross-checking']),
|
||||
('cross_review:progress:view', 'cross_review', 'progress', 'view', '查看交叉评查任务进度', '查看交叉评查任务进度', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 59, NULL, '/api/v3/cross-review/tasks/{task_id}/progress', 'GET', ARRAY['/cross-checking']),
|
||||
('cross_review:document:read', 'cross_review', 'document', 'read', '查看交叉评查任务文档', '查看交叉评查任务文档', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 60, NULL, '/api/v3/cross-review/tasks/{task_id}/documents', 'GET', ARRAY['/cross-checking','/cross-checking/result']),
|
||||
('cross_review:document:complete', 'cross_review', 'document', 'complete', '确认交叉评查文档完成', '确认交叉评查文档完成', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 61, NULL, '/api/v3/cross-review/tasks/{task_id}/can-confirm', 'GET', ARRAY['/cross-checking/result']),
|
||||
('cross_review:proposal:create', 'cross_review', 'proposal', 'create', '创建交叉评查提案', '创建交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 62, NULL, '/api/v3/cross-review/proposals', 'POST', ARRAY['/cross-checking/result']),
|
||||
('cross_review:proposal:read', 'cross_review', 'proposal', 'read', '查看交叉评查提案', '查看交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 63, NULL, '/api/v3/cross-review/documents/{document_id}/proposals', 'GET', ARRAY['/cross-checking/result']),
|
||||
('cross_review:proposal:delete', 'cross_review', 'proposal', 'delete', '撤销交叉评查提案', '撤销交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 64, NULL, '/api/v3/cross-review/proposals/{proposal_id}', 'DELETE', ARRAY['/cross-checking/result']),
|
||||
('cross_review:proposal:vote', 'cross_review', 'proposal', 'vote', '交叉评查提案投票', '交叉评查提案投票', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 65, NULL, '/api/v3/cross-review/proposals/{proposal_id}/votes', 'POST', ARRAY['/cross-checking/result']),
|
||||
('cross_review:task:create', 'cross_review', 'task', 'create', '创建交叉评查任务', '创建交叉评查任务', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 57, NULL, '/api/v3/cross-review/tasks', 'POST', NULL),
|
||||
('cross_review:task:read', 'cross_review', 'task', 'read', '查看交叉评查任务', '查看交叉评查任务', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 58, NULL, '/api/v3/cross-review/tasks/query', 'POST', NULL),
|
||||
('cross_review:progress:view', 'cross_review', 'progress', 'view', '查看交叉评查任务进度', '查看交叉评查任务进度', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 59, NULL, '/api/v3/cross-review/tasks/{task_id}/progress', 'GET', NULL),
|
||||
('cross_review:document:read', 'cross_review', 'document', 'read', '查看交叉评查任务文档', '查看交叉评查任务文档', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 60, NULL, '/api/v3/cross-review/tasks/{task_id}/documents', 'GET', NULL),
|
||||
('cross_review:document:complete', 'cross_review', 'document', 'complete', '确认交叉评查文档完成', '确认交叉评查文档完成', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 61, NULL, '/api/v3/cross-review/tasks/{task_id}/can-confirm', 'GET', NULL),
|
||||
('cross_review:proposal:create', 'cross_review', 'proposal', 'create', '创建交叉评查提案', '创建交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 62, NULL, '/api/v3/cross-review/proposals', 'POST', NULL),
|
||||
('cross_review:proposal:read', 'cross_review', 'proposal', 'read', '查看交叉评查提案', '查看交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 63, NULL, '/api/v3/cross-review/documents/{document_id}/proposals', 'GET', NULL),
|
||||
('cross_review:proposal:delete', 'cross_review', 'proposal', 'delete', '撤销交叉评查提案', '撤销交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 64, NULL, '/api/v3/cross-review/proposals/{proposal_id}', 'DELETE', NULL),
|
||||
('cross_review:proposal:vote', 'cross_review', 'proposal', 'vote', '交叉评查提案投票', '交叉评查提案投票', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 65, NULL, '/api/v3/cross-review/proposals/{proposal_id}/votes', 'POST', NULL),
|
||||
|
||||
('users:list:read', 'users', 'list', 'read', '查看用户列表', '用户列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 70, NULL, '/api/users/list', 'GET', NULL),
|
||||
('users:create:write', 'users', 'create', 'write', '创建用户', '创建用户', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 71, NULL, '/api/users', 'POST', NULL),
|
||||
@@ -116,7 +116,14 @@ VALUES
|
||||
('rbac:roles:update', 'rbac', 'roles', 'update', '维护角色信息', '维护角色', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 81, NULL, '/api/rbac/roles/{role_id}', 'PUT', NULL),
|
||||
('rbac:permissions:read', 'rbac', 'permissions', 'read', '查看权限点列表', '权限点列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 82, NULL, '/api/rbac/permissions', 'GET', NULL),
|
||||
('rbac:role_permissions:write', 'rbac', 'role_permissions', 'write', '分配角色权限', '分配角色权限', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 83, NULL, '/api/rbac/roles/{role_id}/permissions', 'POST', NULL),
|
||||
('rbac:role_routes:write', 'rbac', 'role_routes', 'write', '分配角色菜单', '分配角色菜单', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 84, NULL, '/api/rbac/roles/{role_id}/routes', 'PUT', NULL)
|
||||
('rbac:role_routes:write', 'rbac', 'role_routes', 'write', '分配角色菜单', '分配角色菜单', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 84, NULL, '/api/rbac/roles/{role_id}/routes', 'PUT', NULL),
|
||||
('rag:app:read', 'rag', 'app', 'read', '查看 RAG 应用', '查看 RAG 应用', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 85, NULL, '/api/v3/rag/apps', 'GET', NULL),
|
||||
('rag:chat:use', 'rag', 'chat', 'use', '使用 RAG 对话', '使用 RAG 对话', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 86, NULL, '/api/v3/rag/chat/messages', 'POST', NULL),
|
||||
('rag:conversation:read', 'rag', 'conversation', 'read', '查看 RAG 会话', '查看 RAG 会话', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 87, NULL, '/api/v3/rag/chat/conversations', 'GET', NULL),
|
||||
('rag:conversation:update', 'rag', 'conversation', 'update', '重命名 RAG 会话', '重命名 RAG 会话', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 88, NULL, '/api/v3/rag/chat/conversations/{ConversationId}', 'PATCH', NULL),
|
||||
('rag:conversation:delete', 'rag', 'conversation', 'delete', '删除 RAG 会话', '删除 RAG 会话', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 89, NULL, '/api/v3/rag/chat/conversations/{ConversationId}', 'DELETE', NULL),
|
||||
('rag:message:feedback', 'rag', 'message', 'feedback', '反馈 RAG 消息', '反馈 RAG 消息', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 90, NULL, '/api/v3/rag/chat/messages/{MessageId}/feedback', 'POST', NULL),
|
||||
('rag:dataset:read', 'rag', 'dataset', 'read', '查看 RAG 知识库', '查看 RAG 知识库', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 91, NULL, '/api/v3/rag/datasets/my', 'GET', NULL)
|
||||
ON CONFLICT (permission_key) DO UPDATE SET
|
||||
module = EXCLUDED.module,
|
||||
resource = EXCLUDED.resource,
|
||||
@@ -258,6 +265,13 @@ seed(role_key, permission_key, grant_type, data_scope) AS (
|
||||
('super_admin', 'users:roles_assign:write', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rbac:roles:read', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rbac:roles:update', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rag:app:read', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rag:chat:use', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rag:conversation:read', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rag:conversation:update', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rag:conversation:delete', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rag:message:feedback', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rag:dataset:read', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rbac:permissions:read', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rbac:role_permissions:write', 'GRANT', 'ALL'),
|
||||
('super_admin', 'rbac:role_routes:write', 'GRANT', 'ALL'),
|
||||
@@ -304,6 +318,13 @@ seed(role_key, permission_key, grant_type, data_scope) AS (
|
||||
('provincial_admin', 'users:roles_assign:write', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rbac:roles:read', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rbac:roles:update', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rag:app:read', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rag:chat:use', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rag:conversation:read', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rag:conversation:update', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rag:conversation:delete', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rag:message:feedback', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rag:dataset:read', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rbac:permissions:read', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rbac:role_permissions:write', 'GRANT', 'ALL'),
|
||||
('provincial_admin', 'rbac:role_routes:write', 'GRANT', 'ALL'),
|
||||
@@ -339,6 +360,13 @@ seed(role_key, permission_key, grant_type, data_scope) AS (
|
||||
('admin', 'evaluation_point:create:write', 'GRANT', 'DEPT'),
|
||||
('admin', 'evaluation_point:update:write', 'GRANT', 'DEPT'),
|
||||
('admin', 'evaluation_point:delete:delete', 'GRANT', 'DEPT'),
|
||||
('admin', 'rag:app:read', 'GRANT', 'DEPT'),
|
||||
('admin', 'rag:chat:use', 'GRANT', 'DEPT'),
|
||||
('admin', 'rag:conversation:read', 'GRANT', 'DEPT'),
|
||||
('admin', 'rag:conversation:update', 'GRANT', 'DEPT'),
|
||||
('admin', 'rag:conversation:delete', 'GRANT', 'DEPT'),
|
||||
('admin', 'rag:message:feedback', 'GRANT', 'DEPT'),
|
||||
('admin', 'rag:dataset:read', 'GRANT', 'DEPT'),
|
||||
('admin', 'users:list:read', 'GRANT', 'DEPT'),
|
||||
('admin', 'users:update:write', 'GRANT', 'DEPT'),
|
||||
|
||||
@@ -356,7 +384,14 @@ seed(role_key, permission_key, grant_type, data_scope) AS (
|
||||
('common', 'rules:list:read', 'GRANT', 'DEPT'),
|
||||
('common', 'rules:version_list:read', 'GRANT', 'DEPT'),
|
||||
('common', 'rules:content:read', 'GRANT', 'DEPT'),
|
||||
('common', 'rules:binding_list:read', 'GRANT', 'DEPT')
|
||||
('common', 'rules:binding_list:read', 'GRANT', 'DEPT'),
|
||||
('common', 'rag:app:read', 'GRANT', 'SELF'),
|
||||
('common', 'rag:chat:use', 'GRANT', 'SELF'),
|
||||
('common', 'rag:conversation:read', 'GRANT', 'SELF'),
|
||||
('common', 'rag:conversation:update', 'GRANT', 'SELF'),
|
||||
('common', 'rag:conversation:delete', 'GRANT', 'SELF'),
|
||||
('common', 'rag:message:feedback', 'GRANT', 'SELF'),
|
||||
('common', 'rag:dataset:read', 'GRANT', 'SELF')
|
||||
)
|
||||
INSERT INTO role_permissions (role_id, permission_id, grant_type, data_scope, created_at, updated_at)
|
||||
SELECT rm.id, pm.id, s.grant_type, s.data_scope, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
|
||||
Reference in New Issue
Block a user