Compare commits
10 Commits
dfc9847e4d
...
cc745cad47
| Author | SHA1 | Date | |
|---|---|---|---|
| cc745cad47 | |||
| e312c5da8a | |||
| 58a6786f17 | |||
| 705034d41b | |||
| 780abf11f1 | |||
| d8079d6277 | |||
| e51903efbe | |||
| cfa5d1f4e1 | |||
| e37e8dfca6 | |||
| bd8bfeb607 |
71
AGENT.md
71
AGENT.md
@@ -11,8 +11,8 @@
|
|||||||
- 文件上传与附件管理
|
- 文件上传与附件管理
|
||||||
- 前后端统一的管理控制台
|
- 前后端统一的管理控制台
|
||||||
|
|
||||||
当前阶段已经完成平台骨架、公共接口规范、知识库/知识文档管理、文档上传与解析入口。
|
当前阶段已经完成平台骨架、公共接口规范、知识库/知识文档管理、文档上传、文档解析、解析快照与手动切片入口。
|
||||||
后续重点从"元数据可管"推进到"RAG 可检索"和"Agent 可运行"。
|
后续重点从"文档可切片"推进到"向量可检索"、"模型可路由"和"Agent 可运行"。
|
||||||
|
|
||||||
## 2. 总体设计思路
|
## 2. 总体设计思路
|
||||||
|
|
||||||
@@ -51,16 +51,17 @@
|
|||||||
|
|
||||||
- `rag_store`:知识库主表(已完成 CRUD、编码唯一性校验)
|
- `rag_store`:知识库主表(已完成 CRUD、编码唯一性校验)
|
||||||
- `rag_document`:知识库文档表(已完成 CRUD、条件查询、批量上传、启停用)
|
- `rag_document`:知识库文档表(已完成 CRUD、条件查询、批量上传、启停用)
|
||||||
- `rag_chunk`:知识切片表结构、实体、Mapper、Service(已完成结构,待生成逻辑)
|
- `rag_chunk`:知识切片表结构、实体、Mapper、Service、定长/分隔符切片器与手动切片入口(已完成基础闭环)
|
||||||
- `rag_chunk_embedding`:切片向量表结构、实体、Mapper、Service(已完成结构,待向量化逻辑)
|
- `rag_chunk_embedding`:切片向量表结构、实体、Mapper、Service(已完成结构,待模型网关接入后生成向量)
|
||||||
- RAG 解析状态枚举 `RagParseStatusEnum`(已完成)
|
- RAG 解析状态枚举 `RagParseStatusEnum`(已完成)
|
||||||
- RAG 索引状态枚举 `RagIndexStatusEnum`(已完成)
|
- RAG 索引状态枚举 `RagIndexStatusEnum`(已完成)
|
||||||
- RAG 切片策略枚举 `RagChunkStrategyEnum`(已完成)
|
- RAG 切片策略枚举 `RagChunkStrategyEnum`(已完成)
|
||||||
- 文档解析接口 `/api/rag/documents/parse`(已完成状态流转和文本抽取,尚未落切片)
|
- 文档解析接口 `/api/rag/documents/parse`(已完成状态流转、文本抽取和解析快照保存)
|
||||||
|
- 文档切片接口 `/api/rag/documents/chunk`(已完成按解析快照生成并替换 `rag_chunk`)
|
||||||
|
|
||||||
后续计划继续扩展:
|
后续计划继续扩展:
|
||||||
|
|
||||||
- 将解析结果按切片策略写入 `rag_chunk`
|
- 接入模型服务商配置与模型路由,统一管理 Ollama、硅基流动、百炼等模型来源
|
||||||
- 调用 Embedding 模型并写入 `rag_chunk_embedding`
|
- 调用 Embedding 模型并写入 `rag_chunk_embedding`
|
||||||
- 检索召回与重排序
|
- 检索召回与重排序
|
||||||
- 索引任务、失败重试和任务日志
|
- 索引任务、失败重试和任务日志
|
||||||
@@ -97,11 +98,12 @@
|
|||||||
已具备的页面与布局:
|
已具备的页面与布局:
|
||||||
|
|
||||||
- 左侧管理菜单与品牌区(232px 侧边栏)
|
- 左侧管理菜单与品牌区(232px 侧边栏)
|
||||||
- 工作台(占位)
|
- RAG 工作台(文档解析与切片概览)
|
||||||
- 系统枚举管理页(完整 CRUD + 批量新增)
|
- 系统枚举管理页(完整 CRUD + 批量新增)
|
||||||
- 附件管理入口(占位)
|
- 附件管理入口(占位)
|
||||||
- 知识库管理页(完整 CRUD + 概览卡片 + 双栏详情 + 批量上传入口)
|
- 知识库管理页(完整 CRUD + 概览卡片 + 双栏详情 + 批量上传入口)
|
||||||
- 知识文档页(条件查询 + 批量上传 + 批量解析入口 + 编辑/启停用/删除)
|
- 知识文档页(条件查询 + 批量上传 + 解析重试 + 批量切片 + 编辑/启停用/删除)
|
||||||
|
- 切片任务页(解析成功/失败文档概览与切片入口)
|
||||||
|
|
||||||
前端技术要点:
|
前端技术要点:
|
||||||
|
|
||||||
@@ -121,7 +123,8 @@
|
|||||||
后续控制台至少继续覆盖:
|
后续控制台至少继续覆盖:
|
||||||
|
|
||||||
- 附件管理页面前端联调
|
- 附件管理页面前端联调
|
||||||
- RAG 检索配置、索引任务和最近任务页面联调
|
- RAG 检索配置、向量索引任务和最近任务页面联调
|
||||||
|
- 模型服务商、模型配置、路由规则和调用日志管理
|
||||||
- Agent 调试页
|
- Agent 调试页
|
||||||
- 执行日志查看
|
- 执行日志查看
|
||||||
|
|
||||||
@@ -147,6 +150,35 @@
|
|||||||
6. OpenAPI 注解覆盖
|
6. OpenAPI 注解覆盖
|
||||||
所有 Controller、DTO 使用 `@Tag`、`@Operation`、`@Schema` 注解。
|
所有 Controller、DTO 使用 `@Tag`、`@Operation`、`@Schema` 注解。
|
||||||
|
|
||||||
|
## 4.1 代码注释约定
|
||||||
|
|
||||||
|
为方便后续多人协作和 Agent 接力阅读,新增以下约定:
|
||||||
|
|
||||||
|
1. 新增或修改核心业务代码时,需要补充中文注释
|
||||||
|
注释优先覆盖类职责、关键方法、关键分支和重要参数含义,避免只写重复代码字面的无效注释。
|
||||||
|
|
||||||
|
2. 每次提交代码时,同步检查对应改动是否已经补齐中文注释
|
||||||
|
尤其是新引入的工厂、策略、服务编排、状态流转和复杂转换逻辑,默认需要有中文说明。
|
||||||
|
|
||||||
|
3. 注释以“帮助后来者快速理解设计意图”为目标
|
||||||
|
不追求注释数量,重点说明为什么这样做、边界是什么、哪些地方后续还会扩展。
|
||||||
|
|
||||||
|
## 4.2 结构化枚举约定
|
||||||
|
|
||||||
|
为保证前后端协议、代码定义和数据库配置一致,新增以下长期规则:
|
||||||
|
|
||||||
|
1. 长期固定的结构化文本字段,统一采用枚举值传输
|
||||||
|
不再以字符串名称作为接口协议值,前后端统一传整型枚举值。
|
||||||
|
|
||||||
|
2. 这类枚举必须先定义为 Java 枚举类
|
||||||
|
Java 枚举类作为单一事实来源,再派生前端常量和 `sys_enum` 配置。
|
||||||
|
|
||||||
|
3. 每次新增或修改结构化枚举时,必须同步纳入 `sys_enum` 初始化测试
|
||||||
|
通过统一测试入口按 `catalog + type` 先删后全量重建,避免数据库枚举配置漂移。
|
||||||
|
|
||||||
|
4. `catalog + type` 在枚举组层面必须唯一
|
||||||
|
一旦重复,会破坏枚举组重建语义,因此视为非法设计。
|
||||||
|
|
||||||
## 5. 数据与存储设计
|
## 5. 数据与存储设计
|
||||||
|
|
||||||
### 5.1 关系型数据库
|
### 5.1 关系型数据库
|
||||||
@@ -184,27 +216,28 @@
|
|||||||
2. ~~收紧基础模块~~ `sys_enum`、`sys_attachment`(已完成)
|
2. ~~收紧基础模块~~ `sys_enum`、`sys_attachment`(已完成)
|
||||||
3. ~~补全 RAG 基础元数据管理~~ `rag_store`、`rag_document`(已完成)
|
3. ~~补全 RAG 基础元数据管理~~ `rag_store`、`rag_document`(已完成)
|
||||||
4. ~~补全 RAG 文档上传与解析入口~~ 批量上传、Tika 文本抽取、解析状态流转(已完成)
|
4. ~~补全 RAG 文档上传与解析入口~~ 批量上传、Tika 文本抽取、解析状态流转(已完成)
|
||||||
5. 接入切片生成与切片持久化
|
5. ~~接入切片生成与切片持久化~~(已完成定长/分隔符切片与手动切片入口)
|
||||||
6. 接入 Spring AI Embedding / Chat 模型
|
6. 建设模型服务商配置与模型路由层
|
||||||
7. 建立 Agent 运行时骨架
|
7. 接入 Embedding / Chat 模型并完成向量写入
|
||||||
8. ~~补前端控制台基础骨架~~(已完成,部分高级页面待联调)
|
8. 建立 Agent 运行时骨架
|
||||||
|
9. ~~补前端控制台基础骨架~~(已完成,部分高级页面待联调)
|
||||||
|
|
||||||
剩余重点:
|
剩余重点:
|
||||||
|
|
||||||
- 完成 RAG 解析结果到 `rag_chunk` 的落库闭环
|
- 完成模型服务商配置、模型配置、路由规则和调用日志基础能力
|
||||||
- 接入 Embedding,生成并保存 `rag_chunk_embedding`
|
- 接入 Embedding,生成并保存 `rag_chunk_embedding`
|
||||||
- 补齐索引任务、重试、重建索引和最近任务接口
|
- 补齐索引任务、重试、重建索引和最近任务接口
|
||||||
- 接入 Spring AI 并实现最小模型调用链路
|
- 接入 OpenAI-compatible / Spring AI 适配层并实现最小模型调用链路
|
||||||
|
|
||||||
## 7. 下一步建议
|
## 7. 下一步建议
|
||||||
|
|
||||||
结合当前代码状态,接下来建议重点做:
|
结合当前代码状态,接下来建议重点做:
|
||||||
|
|
||||||
- 实现解析结果切片:根据 `RagChunkStrategyEnum` 生成 `rag_chunk`
|
- 实现模型服务商和模型配置表:支持 Ollama、硅基流动、百炼等 OpenAI-compatible 来源
|
||||||
- 实现索引入口:对切片调用 Embedding 模型并写入 `rag_chunk_embedding`
|
- 实现 Embedding 网关:对已落库切片调用 Embedding 模型并写入 `rag_chunk_embedding`
|
||||||
- 把 `indexStatus` 从手工字段推进为真实状态流转
|
- 把 `indexStatus` 从手工字段推进为真实状态流转
|
||||||
- 补齐重建索引、失败重试、最近任务接口和前端展示
|
- 补齐重建索引、失败重试、最近任务接口和前端展示
|
||||||
- 接入 Spring AI,实现最小 Chat / Embedding 调用链路
|
- 接入模型路由,实现本地小模型与云端大模型的成本优先调用链路
|
||||||
|
|
||||||
## 8. 文档用途说明
|
## 8. 文档用途说明
|
||||||
|
|
||||||
@@ -218,5 +251,7 @@
|
|||||||
|
|
||||||
- `agent-runtime.md`
|
- `agent-runtime.md`
|
||||||
- `rag-design.md`
|
- `rag-design.md`
|
||||||
|
- `MODEL_PROVIDER_REQUIREMENTS.md`
|
||||||
|
- `MODEL_PROVIDER_DESIGN.md`
|
||||||
- `api-style.md`
|
- `api-style.md`
|
||||||
- `frontend-console.md`
|
- `frontend-console.md`
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -3,8 +3,8 @@
|
|||||||
Common Agent 是一个规划中的通用 Agent 平台,技术路线基于 Java、Spring Boot 和 Spring AI。
|
Common Agent 是一个规划中的通用 Agent 平台,技术路线基于 Java、Spring Boot 和 Spring AI。
|
||||||
项目目标是建设一套完整的前后端系统,支持 Agent 编排、工具调用、会话管理、RAG 知识库和平台管理能力。
|
项目目标是建设一套完整的前后端系统,支持 Agent 编排、工具调用、会话管理、RAG 知识库和平台管理能力。
|
||||||
|
|
||||||
当前项目已经完成基础工程、公共模块、RAG 元数据管理、文档上传、文档解析入口、前端知识库与知识文档管理页面。
|
当前项目已经完成基础工程、公共模块、RAG 元数据管理、文档上传、文档解析入口、解析快照、手动切片入口、前端知识库与知识文档管理页面。
|
||||||
Agent 运行时、RAG 切片入库、向量化、检索问答和更多平台管理能力会在后续阶段逐步实现。
|
Agent 运行时、RAG 向量化、检索问答、模型服务商配置与更多平台管理能力会在后续阶段逐步实现。
|
||||||
|
|
||||||
## 项目愿景
|
## 项目愿景
|
||||||
|
|
||||||
@@ -146,11 +146,12 @@ npm run build
|
|||||||
|
|
||||||
| 页面 | 状态 |
|
| 页面 | 状态 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 工作台 | 占位 |
|
| RAG 工作台 | 文档解析与切片概览 |
|
||||||
| 系统枚举 | 完整 CRUD + 批量新增 |
|
| 系统枚举 | 完整 CRUD + 批量新增 |
|
||||||
| 附件管理 | 占位 |
|
| 附件管理 | 占位 |
|
||||||
| 知识库 | 完整 CRUD + 双栏详情 |
|
| 知识库 | 完整 CRUD + 双栏详情 |
|
||||||
| 知识文档 | 条件查询 + 批量上传 + 批量解析入口 + 编辑/启停用/删除 |
|
| 知识文档 | 条件查询 + 批量上传 + 解析重试 + 批量切片 + 编辑/启停用/删除 |
|
||||||
|
| 切片任务 | 解析成功/失败文档概览 + 切片入口 |
|
||||||
|
|
||||||
当前 UI 规范:
|
当前 UI 规范:
|
||||||
|
|
||||||
@@ -170,19 +171,21 @@ npm run build
|
|||||||
|
|
||||||
## RAG 当前能力边界
|
## RAG 当前能力边界
|
||||||
|
|
||||||
当前 RAG 已经从元数据管理推进到"上传 + 解析入口"阶段:
|
当前 RAG 已经从元数据管理推进到"上传 + 解析 + 手动切片"阶段:
|
||||||
|
|
||||||
- 知识库:支持列表、条件查询、详情、总览、单库文档概览、新增、编辑、删除。
|
- 知识库:支持列表、条件查询、详情、总览、单库文档概览、新增、编辑、删除。
|
||||||
- 知识文档:支持列表、条件查询、详情、新增/编辑、删除、批量上传。
|
- 知识文档:支持列表、条件查询、详情、新增/编辑、删除、批量上传。
|
||||||
- 文档解析:基于 Apache Tika 支持 TXT/Markdown/LOG、PDF、Word、Excel 文本抽取,解析时更新 `parseStatus`。
|
- 文档解析:基于 Apache Tika 支持 TXT/Markdown/LOG、PDF、Word、Excel 文本抽取,解析时更新 `parseStatus` 并保存解析快照。
|
||||||
- 切片与向量表:`rag_chunk`、`rag_chunk_embedding` 实体、Mapper、Service 已有结构,但尚未实现切片生成、向量写入和检索召回。
|
- 文档切片:支持按解析快照进行手动异步切片,已落地定长切片和分隔符切片,写入 `rag_chunk`。
|
||||||
- 前端:知识库页和知识文档页已经接入当前接口,检索配置、最近任务、重建索引仍是后续能力。
|
- 向量表:`rag_chunk_embedding` 实体、Mapper、Service 已有结构,向量写入、检索召回和重排序仍待接入。
|
||||||
|
- 模型配置:已补充模型服务商配置与路由需求/设计文档,后续用于统一接入 Ollama、硅基流动、百炼等来源。
|
||||||
|
- 前端:知识库页、知识文档页、RAG 工作台和切片任务页已经接入当前接口,检索配置、最近任务、重建索引仍是后续能力。
|
||||||
|
|
||||||
## 规划模块
|
## 规划模块
|
||||||
|
|
||||||
- `agent-core`:Agent 执行模型、工具注册、记忆和编排能力。
|
- `agent-core`:Agent 执行模型、工具注册、记忆和编排能力。
|
||||||
- `rag-core`:文档导入、解析、切片、Embedding、检索和引用元数据。
|
- `rag-core`:文档导入、解析、切片、Embedding、检索和引用元数据。
|
||||||
- `model-provider`:基于 Spring AI 的聊天模型、Embedding、重排序和模型配置层。
|
- `model-provider`:模型服务商配置、模型路由、调用日志,以及基于 OpenAI-compatible / Spring AI 的聊天模型、Embedding、重排序适配层。
|
||||||
- `platform-api`:面向前端和外部系统的 REST API。
|
- `platform-api`:面向前端和外部系统的 REST API。
|
||||||
- `platform-admin`:平台管理前端。
|
- `platform-admin`:平台管理前端。
|
||||||
- `common-infra`:持久化、审计日志、安全、租户隔离和可观测性。
|
- `common-infra`:持久化、审计日志、安全、租户隔离和可观测性。
|
||||||
@@ -191,6 +194,8 @@ npm run build
|
|||||||
|
|
||||||
- [架构说明](docs/ARCHITECTURE.md)
|
- [架构说明](docs/ARCHITECTURE.md)
|
||||||
- [开发路线图](docs/ROADMAP.md)
|
- [开发路线图](docs/ROADMAP.md)
|
||||||
|
- [模型服务商配置与路由需求](docs/MODEL_PROVIDER_REQUIREMENTS.md)
|
||||||
|
- [模型服务商配置与路由设计](docs/MODEL_PROVIDER_DESIGN.md)
|
||||||
- [平台设计草案](AGENT.md)
|
- [平台设计草案](AGENT.md)
|
||||||
|
|
||||||
## 参考资料
|
## 参考资料
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
|
|
||||||
- 维护 RAG 知识库主数据(CRUD + 编码唯一性校验)。
|
- 维护 RAG 知识库主数据(CRUD + 编码唯一性校验)。
|
||||||
- 维护知识库文档与附件的关联关系。
|
- 维护知识库文档与附件的关联关系。
|
||||||
- 支持知识文档批量上传、解析入口和解析状态流转。
|
- 支持知识文档批量上传、解析入口、解析快照、手动切片入口和状态流转。
|
||||||
- 定义切片、向量、解析状态、索引状态和 RAG 相关来源常量。
|
- 定义切片、向量、解析状态、索引状态和 RAG 相关来源常量。
|
||||||
|
|
||||||
关键类:
|
关键类:
|
||||||
@@ -102,6 +102,10 @@
|
|||||||
| RagStoreServiceImpl | `rag/service/impl/RagStoreServiceImpl.java` |
|
| RagStoreServiceImpl | `rag/service/impl/RagStoreServiceImpl.java` |
|
||||||
| RagDocumentServiceImpl | `rag/service/impl/RagDocumentServiceImpl.java` |
|
| RagDocumentServiceImpl | `rag/service/impl/RagDocumentServiceImpl.java` |
|
||||||
| RagDocumentParseServiceImpl | `rag/service/impl/RagDocumentParseServiceImpl.java` |
|
| RagDocumentParseServiceImpl | `rag/service/impl/RagDocumentParseServiceImpl.java` |
|
||||||
|
| RagDocumentChunkServiceImpl | `rag/service/impl/RagDocumentChunkServiceImpl.java` |
|
||||||
|
| ChunkerFactory | `rag/parse/ChunkerFactory.java` |
|
||||||
|
| FixedLengthChunker | `rag/parse/impl/FixedLengthChunker.java` |
|
||||||
|
| DelimiterChunker | `rag/parse/impl/DelimiterChunker.java` |
|
||||||
| RagParseStatusEnum | `rag/enums/RagParseStatusEnum.java` |
|
| RagParseStatusEnum | `rag/enums/RagParseStatusEnum.java` |
|
||||||
| RagIndexStatusEnum | `rag/enums/RagIndexStatusEnum.java` |
|
| RagIndexStatusEnum | `rag/enums/RagIndexStatusEnum.java` |
|
||||||
| RagChunkStrategyEnum | `rag/enums/RagChunkStrategyEnum.java` |
|
| RagChunkStrategyEnum | `rag/enums/RagChunkStrategyEnum.java` |
|
||||||
@@ -125,12 +129,14 @@
|
|||||||
| POST | `/api/rag/documents/delete` | 删除知识文档 |
|
| POST | `/api/rag/documents/delete` | 删除知识文档 |
|
||||||
| POST | `/api/rag/documents/batchUpload` | 批量上传文档并创建 `rag_document` |
|
| POST | `/api/rag/documents/batchUpload` | 批量上传文档并创建 `rag_document` |
|
||||||
| POST | `/api/rag/documents/parse` | 批量解析知识文档 |
|
| POST | `/api/rag/documents/parse` | 批量解析知识文档 |
|
||||||
|
| POST | `/api/rag/documents/chunk` | 按策略异步生成文档切片 |
|
||||||
|
|
||||||
当前边界:
|
当前边界:
|
||||||
|
|
||||||
- 知识库 CRUD、文档 CRUD、批量上传、Tika 文本解析和状态流转已完成。
|
- 知识库 CRUD、文档 CRUD、批量上传、Tika 文本解析、解析快照和状态流转已完成。
|
||||||
- 解析结果当前只返回给调用方并更新文档状态,尚未写入 `rag_chunk`。
|
- `rag_chunk` 已支持基于解析快照的手动异步切片,当前已落地定长切片和分隔符切片。
|
||||||
- `rag_chunk`、`rag_chunk_embedding` 的结构层已就绪,尚未实现切片生成、向量化、索引任务和检索问答。
|
- `rag_chunk_embedding` 的结构层已就绪,尚未实现模型调用、向量化、索引任务和检索问答。
|
||||||
|
- 模型服务商配置与路由已有需求/设计文档,后续会作为 Embedding、Chat 和 Rerank 的统一接入层。
|
||||||
|
|
||||||
## 4. 数据模型关系
|
## 4. 数据模型关系
|
||||||
|
|
||||||
@@ -162,17 +168,31 @@
|
|||||||
- **后端测试**:围绕结构约束的单元测试(Mapper/Service/Controller 继承体系、实体字段注解、方法签名验证)。
|
- **后端测试**:围绕结构约束的单元测试(Mapper/Service/Controller 继承体系、实体字段注解、方法签名验证)。
|
||||||
- **前端测试**:Vitest + @vue/test-utils,覆盖路由定义、布局组件、页面渲染、API 调用和 Long 类型解析。
|
- **前端测试**:Vitest + @vue/test-utils,覆盖路由定义、布局组件、页面渲染、API 调用和 Long 类型解析。
|
||||||
|
|
||||||
|
## 6.1 注释规范
|
||||||
|
|
||||||
|
- 新增或修改核心业务代码时,需补充中文注释,优先说明类职责、方法目的、关键判断和扩展边界。
|
||||||
|
- 每次提交代码时,需要同步检查本次改动是否已经补齐对应中文注释,避免后续阅读只能靠反推代码语义。
|
||||||
|
- 注释应聚焦设计意图和边界,不建议堆砌“变量赋值”“循环遍历”这类低价值说明。
|
||||||
|
|
||||||
|
## 6.2 结构化枚举规范
|
||||||
|
|
||||||
|
- 长期固定的结构化文本字段,统一采用整型枚举值作为前后端传输协议,不再直接传递字符串名称。
|
||||||
|
- 后端 Java 枚举类是这类结构化枚举的单一事实来源,前端常量和 `sys_enum` 数据都基于它同步。
|
||||||
|
- 新增或修改结构化枚举时,需要通过统一的枚举初始化测试按 `catalog + type` 先删后全量重建写入 `sys_enum`。
|
||||||
|
- 不同枚举组之间的 `catalog + type` 必须唯一,否则会破坏枚举组重建的确定性。
|
||||||
|
|
||||||
## 7. 当前不足
|
## 7. 当前不足
|
||||||
|
|
||||||
- RAG 尚未进入"可检索链路",当前完成上传与解析,但未完成切片、向量化和召回。
|
- RAG 尚未进入"可检索链路",当前完成上传、解析和手动切片,但未完成向量化和召回。
|
||||||
|
- 模型服务商配置、模型路由和调用日志尚未落地代码。
|
||||||
- Agent 运行时相关模型与服务尚未开始建设。
|
- Agent 运行时相关模型与服务尚未开始建设。
|
||||||
- 前端部分页面(工作台、附件管理、检索配置、最近任务)为占位或后续能力提示。
|
- 前端部分页面(附件管理、检索配置、最近任务)为占位或后续能力提示。
|
||||||
- 缺少鉴权、租户、操作日志。
|
- 缺少鉴权、租户、操作日志。
|
||||||
|
|
||||||
## 8. 建议演进方向
|
## 8. 建议演进方向
|
||||||
|
|
||||||
1. 补 RAG 最小检索闭环:解析文本 → 生成切片 → 生成向量 → 检索召回。
|
1. 补 RAG 最小检索闭环:解析文本 → 生成切片 → 生成向量 → 检索召回。
|
||||||
2. 接入 Spring AI,实现最小模型调用链路。
|
2. 建设模型服务商配置与路由层,统一接入 Ollama、硅基流动、百炼等 OpenAI-compatible 来源。
|
||||||
3. 建设 Agent 域模型:Agent、Session、Message、Tool、Task。
|
3. 建设 Agent 域模型:Agent、Session、Message、Tool、Task。
|
||||||
4. 补齐索引任务、重试、重建索引和前端任务视图。
|
4. 补齐索引任务、重试、重建索引和前端任务视图。
|
||||||
5. 衔接模型供应商、工作流编排和前端管理台。
|
5. 衔接模型供应商、Spring AI 适配层、工作流编排和前端管理台。
|
||||||
|
|||||||
898
docs/MODEL_PROVIDER_DESIGN.md
Normal file
898
docs/MODEL_PROVIDER_DESIGN.md
Normal file
@@ -0,0 +1,898 @@
|
|||||||
|
# 模型服务商配置与路由设计文档
|
||||||
|
|
||||||
|
## 1. 文档信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 所属项目 | Common Agent |
|
||||||
|
| 文档类型 | 设计文档 |
|
||||||
|
| 编写日期 | 2026-05-25 |
|
||||||
|
| 对应需求 | `docs/MODEL_PROVIDER_REQUIREMENTS.md` |
|
||||||
|
| 目标阶段 | RAG 向量导入与模型网关基础能力 |
|
||||||
|
|
||||||
|
## 2. 设计目标
|
||||||
|
|
||||||
|
本设计用于在 Common Agent 中新增模型服务商配置与模型路由能力,使系统可以统一接入 Ollama、硅基流动、百炼、OpenAI 等模型服务,并为 RAG 和后续 Agent 运行时提供统一模型调用入口。
|
||||||
|
|
||||||
|
核心设计目标:
|
||||||
|
|
||||||
|
1. 业务模块只依赖平台内部模型网关,不直接依赖具体服务商。
|
||||||
|
2. 首期优先支持 OpenAI-compatible 协议,减少多服务商适配成本。
|
||||||
|
3. 支持本地模型和云端模型混用,实现成本控制。
|
||||||
|
4. 支持 RAG Embedding 模型配置化,并保证同一知识库向量模型一致。
|
||||||
|
5. 支持调用日志,为排障、统计和后续成本分析打基础。
|
||||||
|
|
||||||
|
## 3. 总体架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Admin["管理控制台"] --> ProviderApi["模型配置 API"]
|
||||||
|
Rag["RAG 模块"] --> ModelGateway["ModelGateway"]
|
||||||
|
Agent["Agent 模块"] --> ModelGateway
|
||||||
|
ModelGateway --> Router["ModelRouteService"]
|
||||||
|
Router --> Config["模型配置与路由表"]
|
||||||
|
ModelGateway --> ClientFactory["ModelClientFactory"]
|
||||||
|
ClientFactory --> Ollama["Ollama OpenAI-compatible"]
|
||||||
|
ClientFactory --> SiliconFlow["SiliconFlow OpenAI-compatible"]
|
||||||
|
ClientFactory --> DashScope["DashScope OpenAI-compatible"]
|
||||||
|
ClientFactory --> Other["其他 OpenAI-compatible 服务"]
|
||||||
|
ModelGateway --> Log["ModelCallLogService"]
|
||||||
|
Rag --> RagChunk["rag_chunk"]
|
||||||
|
Rag --> RagEmbedding["rag_chunk_embedding"]
|
||||||
|
```
|
||||||
|
|
||||||
|
设计上将模型平台能力拆成四层:
|
||||||
|
|
||||||
|
| 层级 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| 配置层 | 管理服务商、模型、路由规则和知识库模型绑定 |
|
||||||
|
| 路由层 | 根据任务类型、范围和策略选择具体模型 |
|
||||||
|
| 调用层 | 通过统一客户端调用 Chat、Embedding、Rerank 等能力 |
|
||||||
|
| 观测层 | 记录调用日志、耗时、token、费用估算和错误信息 |
|
||||||
|
|
||||||
|
## 4. 包结构设计
|
||||||
|
|
||||||
|
建议新增后端包:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/java/com/bruce/modelprovider
|
||||||
|
├── controller
|
||||||
|
├── dto
|
||||||
|
│ ├── request
|
||||||
|
│ └── response
|
||||||
|
├── entity
|
||||||
|
├── enums
|
||||||
|
├── mapper
|
||||||
|
├── service
|
||||||
|
│ └── impl
|
||||||
|
├── gateway
|
||||||
|
├── client
|
||||||
|
└── config
|
||||||
|
```
|
||||||
|
|
||||||
|
各包职责:
|
||||||
|
|
||||||
|
| 包 | 职责 |
|
||||||
|
|----|------|
|
||||||
|
| `controller` | 对外暴露服务商、模型、路由规则、日志查询接口 |
|
||||||
|
| `dto` | 请求和响应对象,不直接暴露实体 |
|
||||||
|
| `entity` | 数据库实体,继承 `BaseEntity` |
|
||||||
|
| `enums` | 服务商类型、模型类型、任务类型、路由策略、调用状态 |
|
||||||
|
| `mapper` | MyBatis-Plus `BaseMapper` |
|
||||||
|
| `service` | 配置管理、路由选择、调用日志 |
|
||||||
|
| `gateway` | 面向业务模块的模型调用入口 |
|
||||||
|
| `client` | 具体协议客户端,例如 OpenAI-compatible 客户端 |
|
||||||
|
| `config` | 模型平台配置,例如默认超时、批量大小、密钥加密开关 |
|
||||||
|
|
||||||
|
## 5. 核心数据模型
|
||||||
|
|
||||||
|
### 5.1 `model_provider` 模型服务商表
|
||||||
|
|
||||||
|
用于保存服务商基础配置。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE model_provider (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
provider_code VARCHAR(64) NOT NULL,
|
||||||
|
provider_name VARCHAR(100) NOT NULL,
|
||||||
|
provider_type VARCHAR(50) NOT NULL,
|
||||||
|
protocol_type VARCHAR(50) NOT NULL DEFAULT 'OPENAI_COMPATIBLE',
|
||||||
|
base_url VARCHAR(500) NOT NULL,
|
||||||
|
auth_type VARCHAR(50) NOT NULL DEFAULT 'BEARER_TOKEN',
|
||||||
|
secret_ref VARCHAR(200),
|
||||||
|
api_key_cipher TEXT,
|
||||||
|
timeout_ms INTEGER NOT NULL DEFAULT 60000,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
health_status VARCHAR(50) NOT NULL DEFAULT 'UNKNOWN',
|
||||||
|
last_health_check_time TIMESTAMP,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
create_time TIMESTAMP,
|
||||||
|
update_time TIMESTAMP,
|
||||||
|
remark VARCHAR(500) DEFAULT '',
|
||||||
|
create_by VARCHAR(64),
|
||||||
|
update_by VARCHAR(64),
|
||||||
|
CONSTRAINT uk_model_provider_code UNIQUE (provider_code)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `provider_code` | 服务商编码,例如 `ollama-main`、`siliconflow` |
|
||||||
|
| `provider_type` | 服务商类型,例如 `OLLAMA`、`SILICONFLOW`、`DASHSCOPE`、`OPENAI`、`CUSTOM` |
|
||||||
|
| `protocol_type` | 协议类型,首期使用 `OPENAI_COMPATIBLE` |
|
||||||
|
| `base_url` | API 基础地址,例如 `https://api.siliconflow.cn/v1` |
|
||||||
|
| `secret_ref` | 密钥引用,例如环境变量名或配置中心键 |
|
||||||
|
| `api_key_cipher` | 可选的加密密钥内容,前端不返回 |
|
||||||
|
| `health_status` | 健康检查状态 |
|
||||||
|
|
||||||
|
### 5.2 `model_config` 模型配置表
|
||||||
|
|
||||||
|
用于保存服务商下的具体模型。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE model_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
provider_id BIGINT NOT NULL,
|
||||||
|
model_code VARCHAR(100) NOT NULL,
|
||||||
|
model_name VARCHAR(200) NOT NULL,
|
||||||
|
upstream_model VARCHAR(200) NOT NULL,
|
||||||
|
model_type VARCHAR(50) NOT NULL,
|
||||||
|
context_window INTEGER,
|
||||||
|
max_output_tokens INTEGER,
|
||||||
|
embedding_dimension INTEGER,
|
||||||
|
input_price_per_1k NUMERIC(12, 8),
|
||||||
|
output_price_per_1k NUMERIC(12, 8),
|
||||||
|
local_model BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
default_model BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
capabilities_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
options_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
create_time TIMESTAMP,
|
||||||
|
update_time TIMESTAMP,
|
||||||
|
remark VARCHAR(500) DEFAULT '',
|
||||||
|
create_by VARCHAR(64),
|
||||||
|
update_by VARCHAR(64),
|
||||||
|
CONSTRAINT uk_model_config_provider_code UNIQUE (provider_id, model_code),
|
||||||
|
CONSTRAINT fk_model_config_provider_id FOREIGN KEY (provider_id) REFERENCES model_provider (id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `model_code` | 平台内部模型编码 |
|
||||||
|
| `model_name` | 展示名称 |
|
||||||
|
| `upstream_model` | 上游真实模型名,例如 `Qwen/Qwen3-Embedding-0.6B`、`qwen2.5:7b` |
|
||||||
|
| `model_type` | `CHAT`、`EMBEDDING`、`RERANK`、`MULTIMODAL` |
|
||||||
|
| `embedding_dimension` | Embedding 输出维度,RAG 首期使用 1024 |
|
||||||
|
| `capabilities_json` | 能力标签,例如是否支持工具调用、视觉、JSON 输出 |
|
||||||
|
| `options_json` | 模型调用默认参数,例如 `temperature`、`topP`、`dimensions` |
|
||||||
|
|
||||||
|
### 5.3 `model_route_rule` 模型路由规则表
|
||||||
|
|
||||||
|
用于根据任务类型和范围选择模型。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE model_route_rule (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
route_code VARCHAR(100) NOT NULL,
|
||||||
|
route_name VARCHAR(100) NOT NULL,
|
||||||
|
task_type VARCHAR(50) NOT NULL,
|
||||||
|
match_scope VARCHAR(50) NOT NULL DEFAULT 'GLOBAL',
|
||||||
|
scope_id BIGINT,
|
||||||
|
primary_model_id BIGINT NOT NULL,
|
||||||
|
fallback_model_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
route_strategy VARCHAR(50) NOT NULL DEFAULT 'MANUAL',
|
||||||
|
max_latency_ms INTEGER,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
create_time TIMESTAMP,
|
||||||
|
update_time TIMESTAMP,
|
||||||
|
remark VARCHAR(500) DEFAULT '',
|
||||||
|
create_by VARCHAR(64),
|
||||||
|
update_by VARCHAR(64),
|
||||||
|
CONSTRAINT uk_model_route_rule_code UNIQUE (route_code),
|
||||||
|
CONSTRAINT fk_model_route_primary_model_id FOREIGN KEY (primary_model_id) REFERENCES model_config (id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `task_type` | 任务类型,例如 `RAG_EMBEDDING`、`CHAT_SIMPLE`、`AGENT_PLANNING` |
|
||||||
|
| `match_scope` | 匹配范围,例如 `GLOBAL`、`RAG_STORE`、`AGENT` |
|
||||||
|
| `scope_id` | 范围 ID,例如知识库 ID 或 Agent ID |
|
||||||
|
| `primary_model_id` | 主模型 |
|
||||||
|
| `fallback_model_ids_json` | 备用模型 ID 列表 |
|
||||||
|
| `route_strategy` | `LOCAL_FIRST`、`COST_FIRST`、`QUALITY_FIRST`、`MANUAL` |
|
||||||
|
|
||||||
|
### 5.4 `rag_store_model_config` 知识库模型绑定表
|
||||||
|
|
||||||
|
用于固定知识库的 Embedding 模型和维度,避免同一知识库混用向量空间。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE rag_store_model_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
store_id BIGINT NOT NULL,
|
||||||
|
embedding_model_id BIGINT NOT NULL,
|
||||||
|
embedding_dimension INTEGER NOT NULL DEFAULT 1024,
|
||||||
|
chunk_strategy INTEGER,
|
||||||
|
chunk_size INTEGER,
|
||||||
|
chunk_overlap INTEGER,
|
||||||
|
delimiter VARCHAR(50),
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
index_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
create_time TIMESTAMP,
|
||||||
|
update_time TIMESTAMP,
|
||||||
|
remark VARCHAR(500) DEFAULT '',
|
||||||
|
create_by VARCHAR(64),
|
||||||
|
update_by VARCHAR(64),
|
||||||
|
CONSTRAINT uk_rag_store_model_config_store_active UNIQUE (store_id, active),
|
||||||
|
CONSTRAINT fk_rag_store_model_config_store_id FOREIGN KEY (store_id) REFERENCES rag_store (id),
|
||||||
|
CONSTRAINT fk_rag_store_model_config_embedding_model_id FOREIGN KEY (embedding_model_id) REFERENCES model_config (id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
设计约束:
|
||||||
|
|
||||||
|
- 首期每个知识库只有一个生效配置。
|
||||||
|
- 更换 Embedding 模型或维度时,`index_version` 增加,并触发重建索引。
|
||||||
|
- 检索时只使用当前生效配置对应的向量。
|
||||||
|
|
||||||
|
### 5.5 `model_call_log` 模型调用日志表
|
||||||
|
|
||||||
|
用于记录模型调用行为。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE model_call_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
request_id VARCHAR(64) NOT NULL,
|
||||||
|
provider_id BIGINT,
|
||||||
|
model_id BIGINT,
|
||||||
|
task_type VARCHAR(50) NOT NULL,
|
||||||
|
biz_type VARCHAR(50),
|
||||||
|
biz_id VARCHAR(100),
|
||||||
|
call_type VARCHAR(50) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL,
|
||||||
|
prompt_tokens INTEGER,
|
||||||
|
completion_tokens INTEGER,
|
||||||
|
total_tokens INTEGER,
|
||||||
|
estimated_cost NUMERIC(14, 8),
|
||||||
|
duration_ms INTEGER,
|
||||||
|
request_hash VARCHAR(64),
|
||||||
|
error_code VARCHAR(100),
|
||||||
|
error_message VARCHAR(1000),
|
||||||
|
create_time TIMESTAMP,
|
||||||
|
remark VARCHAR(500) DEFAULT '',
|
||||||
|
CONSTRAINT uk_model_call_log_request_id UNIQUE (request_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `request_id` | 单次模型调用请求 ID |
|
||||||
|
| `task_type` | 任务类型 |
|
||||||
|
| `biz_type` | 业务类型,例如 `RAG_DOCUMENT_INDEX` |
|
||||||
|
| `biz_id` | 业务 ID,例如文档 ID |
|
||||||
|
| `call_type` | `CHAT`、`EMBEDDING`、`RERANK` |
|
||||||
|
| `status` | `SUCCESS`、`FAILED`、`TIMEOUT`、`FALLBACK_SUCCESS` |
|
||||||
|
| `request_hash` | 请求内容哈希,用于排障和幂等分析,不保存完整敏感内容 |
|
||||||
|
|
||||||
|
## 6. 枚举设计
|
||||||
|
|
||||||
|
新增枚举应实现现有 `PersistableSysEnumDefinition`,并同步到 `sys_enum`。
|
||||||
|
|
||||||
|
| 枚举 | 值 |
|
||||||
|
|------|----|
|
||||||
|
| `ModelProviderTypeEnum` | `OLLAMA`、`SILICONFLOW`、`DASHSCOPE`、`OPENAI`、`CUSTOM` |
|
||||||
|
| `ModelProtocolTypeEnum` | `OPENAI_COMPATIBLE` |
|
||||||
|
| `ModelTypeEnum` | `CHAT`、`EMBEDDING`、`RERANK`、`MULTIMODAL` |
|
||||||
|
| `ModelTaskTypeEnum` | `RAG_EMBEDDING`、`RAG_QUERY_EMBEDDING`、`RAG_ANSWER`、`CHAT_SIMPLE`、`CHAT_COMPLEX`、`AGENT_PLANNING`、`RERANK` |
|
||||||
|
| `ModelRouteStrategyEnum` | `LOCAL_FIRST`、`COST_FIRST`、`QUALITY_FIRST`、`MANUAL` |
|
||||||
|
| `ModelCallStatusEnum` | `SUCCESS`、`FAILED`、`TIMEOUT`、`FALLBACK_SUCCESS` |
|
||||||
|
| `ModelHealthStatusEnum` | `UNKNOWN`、`HEALTHY`、`UNHEALTHY` |
|
||||||
|
|
||||||
|
## 7. 服务接口设计
|
||||||
|
|
||||||
|
### 7.1 配置管理服务
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface IModelProviderService extends IService<ModelProvider> {
|
||||||
|
List<ModelProviderResponse> query(ModelProviderQueryRequest request);
|
||||||
|
ModelProviderResponse getResponseById(Long id);
|
||||||
|
boolean saveOrUpdate(ModelProviderSaveRequest request);
|
||||||
|
boolean checkHealth(Long id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface IModelConfigService extends IService<ModelConfig> {
|
||||||
|
List<ModelConfigResponse> query(ModelConfigQueryRequest request);
|
||||||
|
ModelConfigResponse getResponseById(Long id);
|
||||||
|
boolean saveOrUpdate(ModelConfigSaveRequest request);
|
||||||
|
ModelConfig getEnabledModel(Long modelId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 路由服务
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface IModelRouteService {
|
||||||
|
ModelRouteDecision route(ModelRouteContext context);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ModelRouteContext` 应包含:
|
||||||
|
|
||||||
|
- `taskType`
|
||||||
|
- `matchScope`
|
||||||
|
- `scopeId`
|
||||||
|
- `requiredModelType`
|
||||||
|
- `preferredLocal`
|
||||||
|
- `requiredEmbeddingDimension`
|
||||||
|
- `bizType`
|
||||||
|
- `bizId`
|
||||||
|
|
||||||
|
`ModelRouteDecision` 应包含:
|
||||||
|
|
||||||
|
- 主模型。
|
||||||
|
- 备用模型列表。
|
||||||
|
- 路由策略。
|
||||||
|
- 决策原因。
|
||||||
|
|
||||||
|
### 7.3 模型网关
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface EmbeddingModelGateway {
|
||||||
|
EmbeddingResult embed(EmbeddingRequest request);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface ChatModelGateway {
|
||||||
|
ChatResult chat(ChatRequest request);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`EmbeddingRequest` 应包含:
|
||||||
|
|
||||||
|
- 文本列表。
|
||||||
|
- 任务类型。
|
||||||
|
- 匹配范围。
|
||||||
|
- 范围 ID。
|
||||||
|
- 业务类型。
|
||||||
|
- 业务 ID。
|
||||||
|
- 期望维度。
|
||||||
|
|
||||||
|
`EmbeddingResult` 应包含:
|
||||||
|
|
||||||
|
- 模型 ID。
|
||||||
|
- 模型名称。
|
||||||
|
- 维度。
|
||||||
|
- 向量列表。
|
||||||
|
- 调用日志 ID。
|
||||||
|
|
||||||
|
## 8. 客户端设计
|
||||||
|
|
||||||
|
首期优先实现 `OpenAiCompatibleModelClient`,统一调用以下接口:
|
||||||
|
|
||||||
|
- `POST /v1/embeddings`
|
||||||
|
- `POST /v1/chat/completions`
|
||||||
|
|
||||||
|
OpenAI-compatible 客户端输入来自数据库配置:
|
||||||
|
|
||||||
|
| 配置来源 | 字段 |
|
||||||
|
|----------|------|
|
||||||
|
| `model_provider.base_url` | 服务基础地址 |
|
||||||
|
| `model_provider.secret_ref` / `api_key_cipher` | 鉴权信息 |
|
||||||
|
| `model_config.upstream_model` | 上游模型名 |
|
||||||
|
| `model_config.options_json` | 调用参数 |
|
||||||
|
| `model_config.embedding_dimension` | Embedding 维度 |
|
||||||
|
|
||||||
|
### 8.1 Spring AI 使用方式
|
||||||
|
|
||||||
|
项目可以在两个阶段使用 Spring AI:
|
||||||
|
|
||||||
|
第一阶段:使用项目自定义 `ModelGateway` 和 OpenAI-compatible HTTP 客户端,优先解决多服务商动态配置问题。
|
||||||
|
|
||||||
|
第二阶段:在稳定后引入 Spring AI 的 `EmbeddingModel`、`ChatModel` 抽象或适配器,将动态客户端包装为平台内部统一接口。
|
||||||
|
|
||||||
|
这样设计的原因是 Spring Boot 自动配置更适合单默认服务商,而本项目需要从数据库动态选择多个 provider。业务层保持 `ModelGateway` 抽象,后续替换底层实现不会影响 RAG 和 Agent。
|
||||||
|
|
||||||
|
### 8.2 Ollama 适配
|
||||||
|
|
||||||
|
Ollama 使用 OpenAI-compatible 地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://<ollama-host>:11434/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
配置示例:
|
||||||
|
|
||||||
|
| 字段 | 示例 |
|
||||||
|
|------|------|
|
||||||
|
| `provider_code` | `ollama-main` |
|
||||||
|
| `provider_type` | `OLLAMA` |
|
||||||
|
| `protocol_type` | `OPENAI_COMPATIBLE` |
|
||||||
|
| `base_url` | `http://10.0.0.10:11434/v1` |
|
||||||
|
| `auth_type` | `NONE` 或 `BEARER_TOKEN` |
|
||||||
|
| `model_config.upstream_model` | `qwen2.5:7b` |
|
||||||
|
|
||||||
|
部署建议:
|
||||||
|
|
||||||
|
- 开发环境可以通过内网访问。
|
||||||
|
- 生产环境不要直接开放 11434 到公网。
|
||||||
|
- 推荐使用 VPN、Tailscale、Cloudflare Tunnel、Nginx 鉴权反向代理或安全网关。
|
||||||
|
- 如果必须公网访问,需要 HTTPS、鉴权、IP 白名单和访问日志。
|
||||||
|
|
||||||
|
### 8.3 硅基流动适配
|
||||||
|
|
||||||
|
配置示例:
|
||||||
|
|
||||||
|
| 字段 | 示例 |
|
||||||
|
|------|------|
|
||||||
|
| `provider_code` | `siliconflow` |
|
||||||
|
| `provider_type` | `SILICONFLOW` |
|
||||||
|
| `protocol_type` | `OPENAI_COMPATIBLE` |
|
||||||
|
| `base_url` | `https://api.siliconflow.cn/v1` |
|
||||||
|
| `secret_ref` | `SILICONFLOW_API_KEY` |
|
||||||
|
| `model_config.upstream_model` | `Qwen/Qwen3-Embedding-0.6B` |
|
||||||
|
| `embedding_dimension` | `1024` |
|
||||||
|
|
||||||
|
RAG 首期推荐使用 1024 维 Embedding,匹配当前 `rag_chunk_embedding.embedding VECTOR(1024)`。
|
||||||
|
|
||||||
|
### 8.4 百炼适配
|
||||||
|
|
||||||
|
百炼 OpenAI-compatible 配置示例:
|
||||||
|
|
||||||
|
| 字段 | 示例 |
|
||||||
|
|------|------|
|
||||||
|
| `provider_code` | `dashscope` |
|
||||||
|
| `provider_type` | `DASHSCOPE` |
|
||||||
|
| `protocol_type` | `OPENAI_COMPATIBLE` |
|
||||||
|
| `base_url` | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
|
||||||
|
| `secret_ref` | `DASHSCOPE_API_KEY` |
|
||||||
|
| `model_config.upstream_model` | `text-embedding-v4` |
|
||||||
|
| `embedding_dimension` | `1024` |
|
||||||
|
|
||||||
|
## 9. 路由策略设计
|
||||||
|
|
||||||
|
### 9.1 路由优先级
|
||||||
|
|
||||||
|
模型路由按以下顺序匹配:
|
||||||
|
|
||||||
|
1. 业务范围精确规则,例如某个知识库或某个 Agent。
|
||||||
|
2. 任务类型规则,例如 `RAG_EMBEDDING`。
|
||||||
|
3. 全局默认规则。
|
||||||
|
4. 模型类型默认模型。
|
||||||
|
|
||||||
|
如果没有匹配到模型,应返回清晰错误,不隐式选择不确定模型。
|
||||||
|
|
||||||
|
### 9.2 策略说明
|
||||||
|
|
||||||
|
| 策略 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| `MANUAL` | 使用规则中指定的主模型 |
|
||||||
|
| `LOCAL_FIRST` | 优先选择本地模型,失败后使用备用云端模型 |
|
||||||
|
| `COST_FIRST` | 在可用模型中优先选择成本低的模型 |
|
||||||
|
| `QUALITY_FIRST` | 优先选择质量更高或优先级更高的模型 |
|
||||||
|
|
||||||
|
首期可以只实现 `MANUAL` 和 `LOCAL_FIRST`,其余策略先完成数据结构和枚举。
|
||||||
|
|
||||||
|
### 9.3 失败兜底
|
||||||
|
|
||||||
|
主模型调用失败时:
|
||||||
|
|
||||||
|
1. 记录主模型失败日志。
|
||||||
|
2. 判断是否存在备用模型。
|
||||||
|
3. 按备用模型顺序重试。
|
||||||
|
4. 若备用模型成功,返回结果并记录 `FALLBACK_SUCCESS`。
|
||||||
|
5. 若全部失败,返回最后一次错误,并将业务状态更新为失败。
|
||||||
|
|
||||||
|
RAG 向量导入首期应谨慎使用 fallback。Embedding 模型 fallback 只有在维度和语义模型族一致时才允许自动切换,否则应失败并提示重建或重新配置。
|
||||||
|
|
||||||
|
## 10. RAG 向量导入设计
|
||||||
|
|
||||||
|
### 10.1 当前 RAG 状态
|
||||||
|
|
||||||
|
当前项目已有:
|
||||||
|
|
||||||
|
- `rag_document`
|
||||||
|
- `rag_document_parse_result`
|
||||||
|
- `rag_chunk`
|
||||||
|
- `rag_chunk_embedding`
|
||||||
|
- `RagDocumentParseServiceImpl`
|
||||||
|
- `RagDocumentChunkServiceImpl`
|
||||||
|
- `FixedLengthChunker`
|
||||||
|
- `DelimiterChunker`
|
||||||
|
|
||||||
|
下一步需要把切片服务和 Embedding 网关串起来。
|
||||||
|
|
||||||
|
### 10.2 目标流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as 用户/后台
|
||||||
|
participant Doc as RagDocumentService
|
||||||
|
participant Parse as RagDocumentParseService
|
||||||
|
participant Chunk as RagDocumentChunkService
|
||||||
|
participant Gateway as EmbeddingModelGateway
|
||||||
|
participant Provider as 上游模型服务
|
||||||
|
participant DB as PostgreSQL
|
||||||
|
|
||||||
|
User->>Doc: 上传文档
|
||||||
|
Doc->>Parse: 自动解析
|
||||||
|
Parse->>DB: 保存解析快照
|
||||||
|
Parse->>DB: 更新 parseStatus=PARSED
|
||||||
|
Doc->>Chunk: 提交切片索引任务
|
||||||
|
Chunk->>DB: 更新 indexStatus=INDEXING
|
||||||
|
Chunk->>DB: 读取解析快照
|
||||||
|
Chunk->>DB: 删除旧切片和旧向量
|
||||||
|
Chunk->>DB: 写入 rag_chunk
|
||||||
|
Chunk->>Gateway: 批量生成 Embedding
|
||||||
|
Gateway->>Provider: POST /v1/embeddings
|
||||||
|
Provider-->>Gateway: 返回向量
|
||||||
|
Gateway->>DB: 记录 model_call_log
|
||||||
|
Chunk->>DB: 写入 rag_chunk_embedding
|
||||||
|
Chunk->>DB: 更新 indexStatus=INDEXED
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 状态流转
|
||||||
|
|
||||||
|
`rag_document.index_status` 流转:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PENDING -> INDEXING -> INDEXED
|
||||||
|
PENDING -> INDEXING -> FAILED
|
||||||
|
INDEXED -> INDEXING -> INDEXED
|
||||||
|
INDEXED -> INDEXING -> FAILED
|
||||||
|
```
|
||||||
|
|
||||||
|
失败时应写入 `rag_document.error_message`。
|
||||||
|
|
||||||
|
### 10.4 切片与向量写入规则
|
||||||
|
|
||||||
|
1. 生成新切片前,先删除当前文档旧向量,再删除旧切片。
|
||||||
|
2. 新切片保存成功后,再批量调用 Embedding。
|
||||||
|
3. Embedding 成功后写入 `rag_chunk_embedding`。
|
||||||
|
4. 每条 embedding 记录保存 `embedding_model`、`embedding_dimension`、`content_hash`。
|
||||||
|
5. `embedding` 字段以 pgvector 可接受的字符串格式保存,例如 `[0.1,0.2,0.3]`。
|
||||||
|
6. 向量数量必须等于切片数量,否则本次索引失败。
|
||||||
|
|
||||||
|
### 10.5 知识库模型绑定
|
||||||
|
|
||||||
|
RAG 索引选择模型的优先级:
|
||||||
|
|
||||||
|
1. `rag_store_model_config` 中该知识库绑定的 Embedding 模型。
|
||||||
|
2. `model_route_rule` 中 `match_scope=RAG_STORE` 的规则。
|
||||||
|
3. `model_route_rule` 中 `task_type=RAG_EMBEDDING` 的全局规则。
|
||||||
|
4. 全局默认 Embedding 模型。
|
||||||
|
|
||||||
|
如果以上都不存在,索引任务失败并提示需要配置 Embedding 模型。
|
||||||
|
|
||||||
|
## 11. API 设计
|
||||||
|
|
||||||
|
### 11.1 服务商接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/model/providers/query` | 查询服务商 |
|
||||||
|
| GET | `/api/model/providers/detail` | 服务商详情 |
|
||||||
|
| POST | `/api/model/providers/save` | 新增或修改服务商 |
|
||||||
|
| POST | `/api/model/providers/delete` | 删除服务商 |
|
||||||
|
| POST | `/api/model/providers/checkHealth` | 健康检查 |
|
||||||
|
|
||||||
|
服务商详情接口不返回 `apiKeyCipher` 明文。
|
||||||
|
|
||||||
|
### 11.2 模型接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/model/configs/query` | 查询模型 |
|
||||||
|
| GET | `/api/model/configs/detail` | 模型详情 |
|
||||||
|
| POST | `/api/model/configs/save` | 新增或修改模型 |
|
||||||
|
| POST | `/api/model/configs/delete` | 删除模型 |
|
||||||
|
|
||||||
|
### 11.3 路由规则接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/model/routes/query` | 查询路由规则 |
|
||||||
|
| GET | `/api/model/routes/detail` | 路由规则详情 |
|
||||||
|
| POST | `/api/model/routes/save` | 新增或修改路由规则 |
|
||||||
|
| POST | `/api/model/routes/delete` | 删除路由规则 |
|
||||||
|
|
||||||
|
### 11.4 调用日志接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/model/call-logs/query` | 查询调用日志 |
|
||||||
|
| GET | `/api/model/call-logs/detail` | 调用日志详情 |
|
||||||
|
|
||||||
|
### 11.5 RAG 模型配置接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/rag/store/modelConfig` | 查询知识库模型配置 |
|
||||||
|
| POST | `/api/rag/store/modelConfig/save` | 保存知识库模型配置 |
|
||||||
|
| POST | `/api/rag/store/rebuildIndex` | 重建知识库索引 |
|
||||||
|
|
||||||
|
## 12. DTO 设计要点
|
||||||
|
|
||||||
|
所有 API 保持现有项目规范:
|
||||||
|
|
||||||
|
- Controller 返回 `RequestResult<T>`。
|
||||||
|
- 请求对象使用业务语义明确的 `Request` 类,例如 `ModelProviderSaveRequest`。
|
||||||
|
- 响应对象使用业务语义明确的 `Response` 类,例如 `ModelProviderResponse`。
|
||||||
|
- 响应 DTO 提供 `fromEntity()` 静态转换。
|
||||||
|
- Long ID 输出给前端时使用 `ToStringSerializer`。
|
||||||
|
|
||||||
|
`ModelProviderResponse` 不包含完整密钥,只包含:
|
||||||
|
|
||||||
|
- `secretRef`
|
||||||
|
- `hasApiKey`
|
||||||
|
- `authType`
|
||||||
|
|
||||||
|
## 13. 密钥处理设计
|
||||||
|
|
||||||
|
### 13.1 推荐方式
|
||||||
|
|
||||||
|
首期推荐使用 `secret_ref`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
model_provider.secret_ref = SILICONFLOW_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时从环境变量读取:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SILICONFLOW_API_KEY=sk-***
|
||||||
|
```
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 数据库不保存密钥。
|
||||||
|
- 本地、测试、生产可以使用不同环境变量。
|
||||||
|
- 实现简单,风险较低。
|
||||||
|
|
||||||
|
### 13.2 可选加密方式
|
||||||
|
|
||||||
|
如需在数据库中保存密钥,应保存 `api_key_cipher`,并使用环境变量中的主密钥加解密:
|
||||||
|
|
||||||
|
```text
|
||||||
|
COMMON_AGENT_SECRET_KEY=<secret-from-environment>
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 主密钥不入库。
|
||||||
|
- 前端不返回密钥明文。
|
||||||
|
- 修改密钥时只允许写入,不允许读取原文。
|
||||||
|
|
||||||
|
## 14. 调用日志与费用估算
|
||||||
|
|
||||||
|
调用日志记录在 `model_call_log`。
|
||||||
|
|
||||||
|
费用估算规则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
estimatedCost = promptTokens / 1000 * inputPricePer1k
|
||||||
|
+ completionTokens / 1000 * outputPricePer1k
|
||||||
|
```
|
||||||
|
|
||||||
|
Embedding 模型通常只使用输入 token 费用。若上游不返回 token 信息,首期可以记录空值,后续通过本地 tokenizer 或文本长度估算。
|
||||||
|
|
||||||
|
日志脱敏规则:
|
||||||
|
|
||||||
|
- 不记录完整 API Key。
|
||||||
|
- 不默认记录完整 prompt。
|
||||||
|
- 可记录请求哈希。
|
||||||
|
- 错误信息截断到 1000 字以内。
|
||||||
|
|
||||||
|
## 15. 健康检查设计
|
||||||
|
|
||||||
|
健康检查目标是判断服务商是否可用。
|
||||||
|
|
||||||
|
首期检查方式:
|
||||||
|
|
||||||
|
- OpenAI-compatible 服务调用 `/v1/models`。
|
||||||
|
- Ollama 同样可通过 OpenAI-compatible `/v1/models` 检查。
|
||||||
|
- 如果服务商不支持 `/v1/models`,可配置跳过健康检查或使用轻量模型调用。
|
||||||
|
|
||||||
|
健康状态:
|
||||||
|
|
||||||
|
| 状态 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `UNKNOWN` | 尚未检查 |
|
||||||
|
| `HEALTHY` | 最近一次检查成功 |
|
||||||
|
| `UNHEALTHY` | 最近一次检查失败 |
|
||||||
|
|
||||||
|
## 16. 异常处理设计
|
||||||
|
|
||||||
|
常见异常:
|
||||||
|
|
||||||
|
| 场景 | 处理方式 |
|
||||||
|
|------|----------|
|
||||||
|
| 服务商停用 | 路由阶段直接失败 |
|
||||||
|
| 模型停用 | 路由阶段跳过或失败 |
|
||||||
|
| 缺少 API Key | 调用前失败,提示密钥未配置 |
|
||||||
|
| 上游超时 | 记录 `TIMEOUT`,尝试备用模型 |
|
||||||
|
| 上游返回错误 | 记录 `FAILED`,保留错误码和摘要 |
|
||||||
|
| Embedding 维度不匹配 | RAG 索引失败,不写入向量 |
|
||||||
|
| 向量数量不匹配 | RAG 索引失败,清理本次中间数据 |
|
||||||
|
|
||||||
|
## 17. 前端页面设计
|
||||||
|
|
||||||
|
前端延续当前后台风格,保持信息密度和可扫描性。
|
||||||
|
|
||||||
|
建议新增页面:
|
||||||
|
|
||||||
|
| 页面 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| 模型服务商 | 服务商列表、启停用、健康检查、编辑 |
|
||||||
|
| 模型配置 | 模型列表、类型筛选、价格和能力配置 |
|
||||||
|
| 路由规则 | 按任务类型配置主模型和备用模型 |
|
||||||
|
| 调用日志 | 查询调用状态、耗时、服务商、模型、错误 |
|
||||||
|
| 知识库模型配置 | 在知识库详情中配置 Embedding 模型和重建索引 |
|
||||||
|
|
||||||
|
首期后端优先,前端可以在 RAG 最小闭环完成后接入。
|
||||||
|
|
||||||
|
## 18. 测试设计
|
||||||
|
|
||||||
|
### 18.1 单元测试
|
||||||
|
|
||||||
|
需要覆盖:
|
||||||
|
|
||||||
|
- 实体字段结构。
|
||||||
|
- Mapper 和 Service 继承结构。
|
||||||
|
- DTO 转换。
|
||||||
|
- 服务商编码唯一校验。
|
||||||
|
- 模型编码唯一校验。
|
||||||
|
- 路由优先级。
|
||||||
|
- 密钥引用解析。
|
||||||
|
- Embedding 维度校验。
|
||||||
|
|
||||||
|
### 18.2 RAG 集成测试
|
||||||
|
|
||||||
|
需要覆盖:
|
||||||
|
|
||||||
|
- 文档解析快照转切片。
|
||||||
|
- 切片批量调用 Embedding 网关。
|
||||||
|
- 向量数量与切片数量一致。
|
||||||
|
- 向量写入 `rag_chunk_embedding`。
|
||||||
|
- 索引状态成功流转。
|
||||||
|
- Embedding 失败时索引状态为 `FAILED`。
|
||||||
|
|
||||||
|
### 18.3 客户端测试
|
||||||
|
|
||||||
|
OpenAI-compatible 客户端使用 Mock Web Server 或类似方式测试:
|
||||||
|
|
||||||
|
- `/v1/embeddings` 成功响应。
|
||||||
|
- `/v1/chat/completions` 成功响应。
|
||||||
|
- 401 鉴权失败。
|
||||||
|
- 429 限流。
|
||||||
|
- 5xx 上游错误。
|
||||||
|
- 超时。
|
||||||
|
|
||||||
|
## 19. 实施计划
|
||||||
|
|
||||||
|
### 19.1 第一阶段:配置数据结构
|
||||||
|
|
||||||
|
1. 新增 SQL 脚本。
|
||||||
|
2. 新增实体、Mapper、Service、Controller。
|
||||||
|
3. 新增枚举并同步 `sys_enum` 测试。
|
||||||
|
4. 新增结构稳定性测试。
|
||||||
|
|
||||||
|
### 19.2 第二阶段:Embedding 网关
|
||||||
|
|
||||||
|
1. 新增 `EmbeddingModelGateway`。
|
||||||
|
2. 新增 OpenAI-compatible Embedding 客户端。
|
||||||
|
3. 新增密钥解析器。
|
||||||
|
4. 新增调用日志服务。
|
||||||
|
5. 完成 Mock 单元测试。
|
||||||
|
|
||||||
|
### 19.3 第三阶段:RAG 向量导入
|
||||||
|
|
||||||
|
1. 扩展 `RagDocumentChunkServiceImpl`。
|
||||||
|
2. 切片后调用 Embedding 网关。
|
||||||
|
3. 写入 `rag_chunk_embedding`。
|
||||||
|
4. 更新 `indexStatus`。
|
||||||
|
5. 完成失败回滚和日志记录。
|
||||||
|
|
||||||
|
### 19.4 第四阶段:路由与前端
|
||||||
|
|
||||||
|
1. 新增路由规则服务。
|
||||||
|
2. RAG 按知识库配置或路由规则选择模型。
|
||||||
|
3. 接入前端模型配置页面。
|
||||||
|
4. 接入调用日志查询页面。
|
||||||
|
|
||||||
|
## 20. 配置示例
|
||||||
|
|
||||||
|
### 20.1 Ollama 服务商
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providerCode": "ollama-main",
|
||||||
|
"providerName": "远程 Ollama",
|
||||||
|
"providerType": "OLLAMA",
|
||||||
|
"protocolType": "OPENAI_COMPATIBLE",
|
||||||
|
"baseUrl": "http://10.0.0.10:11434/v1",
|
||||||
|
"authType": "NONE",
|
||||||
|
"timeoutMs": 120000,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 20.2 硅基流动 Embedding 模型
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providerId": "1001",
|
||||||
|
"modelCode": "siliconflow-qwen3-embedding-0_6b",
|
||||||
|
"modelName": "硅基流动 Qwen3 Embedding 0.6B",
|
||||||
|
"upstreamModel": "Qwen/Qwen3-Embedding-0.6B",
|
||||||
|
"modelType": "EMBEDDING",
|
||||||
|
"embeddingDimension": 1024,
|
||||||
|
"localModel": false,
|
||||||
|
"enabled": true,
|
||||||
|
"optionsJson": {
|
||||||
|
"dimensions": 1024,
|
||||||
|
"encoding_format": "float"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 20.3 RAG Embedding 路由规则
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"routeCode": "global-rag-embedding",
|
||||||
|
"routeName": "全局 RAG 向量模型",
|
||||||
|
"taskType": "RAG_EMBEDDING",
|
||||||
|
"matchScope": "GLOBAL",
|
||||||
|
"primaryModelId": "2001",
|
||||||
|
"fallbackModelIds": [],
|
||||||
|
"routeStrategy": "MANUAL",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 21. 风险与对策
|
||||||
|
|
||||||
|
| 风险 | 对策 |
|
||||||
|
|------|------|
|
||||||
|
| 动态多服务商与 Spring AI 自动配置存在差异 | 先用项目内部 `ModelGateway` 隔离,后续逐步适配 Spring AI |
|
||||||
|
| Ollama 远程服务暴露风险 | 文档和配置中明确生产环境必须通过安全通道访问 |
|
||||||
|
| Embedding 模型混用导致检索漂移 | 知识库绑定模型和维度,变更时重建索引 |
|
||||||
|
| 云端 API 费用不可控 | 路由策略、调用日志和费用估算逐步完善 |
|
||||||
|
| 上游接口返回格式差异 | 首期只承诺 OpenAI-compatible,特殊服务商后续单独适配 |
|
||||||
|
| 大批量向量化超时 | 支持批量大小配置、异步任务和失败重试 |
|
||||||
|
|
||||||
|
## 22. 后续扩展
|
||||||
|
|
||||||
|
后续可以扩展:
|
||||||
|
|
||||||
|
- Rerank 模型网关。
|
||||||
|
- Agent 级模型配置。
|
||||||
|
- 用户级额度和限流。
|
||||||
|
- 模型质量评测。
|
||||||
|
- 模型调用缓存。
|
||||||
|
- Prompt 模板与模型绑定。
|
||||||
|
- 多索引版本并存。
|
||||||
|
- 调用日志聚合报表。
|
||||||
|
|
||||||
|
## 23. 参考资料
|
||||||
|
|
||||||
|
- Ollama OpenAI-compatible API: https://docs.ollama.com/api/openai-compatibility
|
||||||
|
- Ollama Embeddings: https://docs.ollama.com/capabilities/embeddings
|
||||||
|
- Spring AI OpenAI Embeddings: https://docs.spring.io/spring-ai/reference/api/embeddings/openai-embeddings.html
|
||||||
|
- SiliconFlow Embedding 模型列表: https://www.siliconflow.com/models/embedding
|
||||||
448
docs/MODEL_PROVIDER_REQUIREMENTS.md
Normal file
448
docs/MODEL_PROVIDER_REQUIREMENTS.md
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
# 模型服务商配置与路由需求文档
|
||||||
|
|
||||||
|
## 1. 文档信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 所属项目 | Common Agent |
|
||||||
|
| 文档类型 | 需求文档 |
|
||||||
|
| 编写日期 | 2026-05-25 |
|
||||||
|
| 目标阶段 | RAG 最小检索闭环与模型平台化前置能力 |
|
||||||
|
| 相关模块 | `model-provider`、`rag-core`、`platform-admin` |
|
||||||
|
|
||||||
|
## 2. 背景
|
||||||
|
|
||||||
|
Common Agent 的长期目标是建设一套可复用的企业级 AI 应用基础平台,后续需要支持 RAG、Agent 编排、工具调用、会话管理和模型统一接入。
|
||||||
|
|
||||||
|
当前项目已经完成基础工程、附件上传、文档解析、RAG 知识库和知识文档管理。下一步需要将解析结果切片、生成向量并写入 `rag_chunk_embedding`,再继续建设检索召回和 Agent 运行时。
|
||||||
|
|
||||||
|
在模型接入层面,项目需要同时支持:
|
||||||
|
|
||||||
|
- 自建模型服务,例如云服务器上的 Ollama。
|
||||||
|
- 云端模型平台,例如硅基流动、阿里云百炼、OpenAI 兼容服务。
|
||||||
|
- 不同任务使用不同模型,例如简单任务走本地小模型,复杂推理走云端大模型。
|
||||||
|
- RAG 向量化使用稳定且可控的 embedding 模型,并保证同一知识库内向量空间一致。
|
||||||
|
|
||||||
|
因此,本阶段需要建设一个轻量但可扩展的模型服务商配置与路由能力,作为 RAG 向量导入和后续 Agent 调用的统一入口。
|
||||||
|
|
||||||
|
## 3. 建设目标
|
||||||
|
|
||||||
|
### 3.1 业务目标
|
||||||
|
|
||||||
|
1. 支持在后台配置多个模型服务商,包括本地 Ollama、硅基流动、百炼和其他 OpenAI-compatible 服务。
|
||||||
|
2. 支持在后台维护每个服务商下的具体模型,例如聊天模型、Embedding 模型、重排序模型。
|
||||||
|
3. 支持按任务类型选择模型,达到“小任务本地处理,大任务云端处理”的成本控制目标。
|
||||||
|
4. 支持 RAG 向量导入从配置中选择 Embedding 模型,不在代码中写死服务商和模型名。
|
||||||
|
5. 支持记录模型调用日志,为后续成本统计、质量评估、失败排查和限流策略提供数据基础。
|
||||||
|
|
||||||
|
### 3.2 技术目标
|
||||||
|
|
||||||
|
1. 抽象统一的 `ModelGateway`,业务模块不直接依赖具体服务商 SDK。
|
||||||
|
2. 优先兼容 OpenAI API 形态,降低接入 Ollama、硅基流动、百炼等平台的差异成本。
|
||||||
|
3. 将 API Key 与业务配置解耦,避免密钥明文散落在业务代码或普通配置中。
|
||||||
|
4. 为后续 Spring AI 接入、Agent 运行时和模型调用观测保留扩展点。
|
||||||
|
5. 保持与当前项目风格一致:Spring Boot、MyBatis-Plus、DTO、统一返回体、结构化枚举。
|
||||||
|
|
||||||
|
## 4. 范围说明
|
||||||
|
|
||||||
|
### 4.1 本阶段范围
|
||||||
|
|
||||||
|
本阶段覆盖以下能力:
|
||||||
|
|
||||||
|
- 模型服务商配置管理。
|
||||||
|
- 模型配置管理。
|
||||||
|
- 模型路由规则管理。
|
||||||
|
- 模型调用日志记录。
|
||||||
|
- Embedding 调用网关。
|
||||||
|
- Chat 调用网关的基础接口设计。
|
||||||
|
- RAG 切片向量导入接入模型网关。
|
||||||
|
- Ollama 远程服务接入设计。
|
||||||
|
- 硅基流动等 OpenAI-compatible 云服务接入设计。
|
||||||
|
|
||||||
|
### 4.2 本阶段不覆盖
|
||||||
|
|
||||||
|
本阶段不实现以下完整能力:
|
||||||
|
|
||||||
|
- 完整 Agent 运行时。
|
||||||
|
- 多租户权限体系。
|
||||||
|
- 复杂模型质量评测平台。
|
||||||
|
- 自动提示词优化。
|
||||||
|
- 按用户或部门计费结算。
|
||||||
|
- 高级模型编排工作流。
|
||||||
|
- 完整密钥管理系统。
|
||||||
|
|
||||||
|
这些能力可以在模型服务商配置与路由能力稳定后继续扩展。
|
||||||
|
|
||||||
|
## 5. 术语定义
|
||||||
|
|
||||||
|
| 术语 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 模型服务商 | 提供模型调用能力的上游服务,例如 Ollama、硅基流动、百炼、OpenAI |
|
||||||
|
| 模型配置 | 某个服务商下的具体模型配置,例如 `Qwen/Qwen3-Embedding-0.6B` |
|
||||||
|
| 模型路由 | 根据任务类型、成本、质量、可用性选择具体模型的规则 |
|
||||||
|
| OpenAI-compatible | 兼容 OpenAI API 风格的服务接口,通常包含 `/v1/chat/completions`、`/v1/embeddings` |
|
||||||
|
| 本地模型 | 由用户自行部署和维护的模型服务,例如云服务器上的 Ollama |
|
||||||
|
| 云端模型 | 第三方平台托管的模型服务,例如硅基流动或百炼 |
|
||||||
|
| Embedding | 将文本转换为向量,用于 RAG 检索召回 |
|
||||||
|
| Rerank | 对召回结果二次排序,提高检索结果相关性 |
|
||||||
|
|
||||||
|
## 6. 当前问题
|
||||||
|
|
||||||
|
### 6.1 模型配置不可管理
|
||||||
|
|
||||||
|
当前项目尚未接入统一模型配置层。如果在 RAG 向量化阶段直接写死模型服务商、模型名和 API 地址,后续切换服务商会产生重复改造。
|
||||||
|
|
||||||
|
### 6.2 成本控制缺少入口
|
||||||
|
|
||||||
|
不同任务对模型能力要求不同。简单分类、标题生成、短文本摘要可以使用本地小模型,复杂推理、长上下文问答和 Agent 规划适合使用云端大模型。当前缺少统一路由层,无法系统性节省调用成本。
|
||||||
|
|
||||||
|
### 6.3 RAG 向量模型需要稳定约束
|
||||||
|
|
||||||
|
同一知识库中的向量必须来自同一个语义空间。随意混用不同 Embedding 模型或不同维度,会导致检索结果不稳定。当前 `rag_chunk_embedding` 已有模型名和维度字段,但缺少知识库级别的模型绑定和重建索引约束。
|
||||||
|
|
||||||
|
### 6.4 调用可观测性不足
|
||||||
|
|
||||||
|
模型调用需要记录耗时、状态、错误、token、费用估算和业务来源。当前没有统一日志表,后续无法分析失败原因、成本趋势和模型效果。
|
||||||
|
|
||||||
|
### 6.5 密钥管理需要提前约束
|
||||||
|
|
||||||
|
模型服务商通常需要 API Key。若直接明文存储在数据库或配置文件中,会带来泄露风险。项目早期可以简化实现,但必须明确密钥引用和加密策略。
|
||||||
|
|
||||||
|
## 7. 用户角色
|
||||||
|
|
||||||
|
| 角色 | 诉求 |
|
||||||
|
|------|------|
|
||||||
|
| 平台管理员 | 配置模型服务商、模型、路由规则和密钥引用 |
|
||||||
|
| 知识库维护者 | 为知识库选择 Embedding 模型,触发索引重建 |
|
||||||
|
| Agent 配置者 | 为不同 Agent 选择默认模型或路由策略 |
|
||||||
|
| 系统开发者 | 通过统一网关调用模型,不关心具体服务商差异 |
|
||||||
|
| 运维人员 | 查看模型服务可用性、调用失败、耗时和成本趋势 |
|
||||||
|
|
||||||
|
## 8. 典型使用场景
|
||||||
|
|
||||||
|
### 8.1 RAG 使用硅基流动生成向量
|
||||||
|
|
||||||
|
管理员配置硅基流动服务商,添加 `Qwen/Qwen3-Embedding-0.6B` 模型,设置维度为 1024。知识库选择该模型作为 Embedding 模型。文档上传后,系统解析、切片、调用模型生成向量,并写入 `rag_chunk_embedding`。
|
||||||
|
|
||||||
|
### 8.2 简单任务使用远程 Ollama
|
||||||
|
|
||||||
|
用户在云服务器部署 Ollama,并开放安全访问地址。管理员配置 Ollama 服务商和本地小模型,例如 `qwen2.5:7b`。系统在标题生成、短摘要、分类等任务中优先调用 Ollama,减少云端模型费用。
|
||||||
|
|
||||||
|
### 8.3 复杂任务使用云端大模型
|
||||||
|
|
||||||
|
当任务类型为复杂 Agent 规划、长上下文问答或工具调用编排时,路由规则选择云端大模型。若主模型调用失败,可以按规则切换到备用模型。
|
||||||
|
|
||||||
|
### 8.4 Embedding 模型变更触发索引重建
|
||||||
|
|
||||||
|
知识库已经使用某个 Embedding 模型完成索引。管理员切换该知识库的 Embedding 模型时,系统标记该知识库需要重建索引,并阻止新旧模型向量混合检索。
|
||||||
|
|
||||||
|
### 8.5 查看模型调用日志
|
||||||
|
|
||||||
|
运维人员进入模型调用日志页面,查看某段时间内的调用次数、失败率、平均耗时和费用估算,定位某个服务商或模型的异常。
|
||||||
|
|
||||||
|
## 9. 功能需求
|
||||||
|
|
||||||
|
### 9.1 模型服务商管理
|
||||||
|
|
||||||
|
系统应支持新增、编辑、启用、停用和查询模型服务商。
|
||||||
|
|
||||||
|
服务商至少包含:
|
||||||
|
|
||||||
|
- 服务商编码。
|
||||||
|
- 服务商名称。
|
||||||
|
- 服务商类型。
|
||||||
|
- API 基础地址。
|
||||||
|
- 鉴权方式。
|
||||||
|
- 密钥引用。
|
||||||
|
- 超时时间。
|
||||||
|
- 默认优先级。
|
||||||
|
- 是否启用。
|
||||||
|
- 备注。
|
||||||
|
|
||||||
|
服务商编码应全局唯一。
|
||||||
|
|
||||||
|
### 9.2 模型配置管理
|
||||||
|
|
||||||
|
系统应支持在服务商下维护具体模型。
|
||||||
|
|
||||||
|
模型至少包含:
|
||||||
|
|
||||||
|
- 所属服务商。
|
||||||
|
- 模型编码。
|
||||||
|
- 上游模型名称。
|
||||||
|
- 模型类型。
|
||||||
|
- 上下文窗口。
|
||||||
|
- 输出 token 上限。
|
||||||
|
- Embedding 维度。
|
||||||
|
- 是否本地模型。
|
||||||
|
- 是否默认模型。
|
||||||
|
- 能力标签。
|
||||||
|
- 价格配置。
|
||||||
|
- 是否启用。
|
||||||
|
|
||||||
|
同一服务商下模型编码应唯一。
|
||||||
|
|
||||||
|
### 9.3 模型路由规则管理
|
||||||
|
|
||||||
|
系统应支持按任务类型配置模型路由。
|
||||||
|
|
||||||
|
路由规则至少包含:
|
||||||
|
|
||||||
|
- 任务类型。
|
||||||
|
- 匹配范围。
|
||||||
|
- 主模型。
|
||||||
|
- 备用模型列表。
|
||||||
|
- 路由策略。
|
||||||
|
- 最大允许耗时。
|
||||||
|
- 是否启用。
|
||||||
|
|
||||||
|
任务类型初始支持:
|
||||||
|
|
||||||
|
- RAG 文档向量化。
|
||||||
|
- RAG 查询向量化。
|
||||||
|
- RAG 问答生成。
|
||||||
|
- 简单文本处理。
|
||||||
|
- 复杂文本处理。
|
||||||
|
- Agent 规划。
|
||||||
|
- Rerank。
|
||||||
|
|
||||||
|
### 9.4 模型网关
|
||||||
|
|
||||||
|
系统应提供统一模型网关,供业务模块调用。
|
||||||
|
|
||||||
|
模型网关应支持:
|
||||||
|
|
||||||
|
- 根据任务类型选择模型。
|
||||||
|
- 调用 Embedding 模型生成向量。
|
||||||
|
- 调用 Chat 模型生成回复。
|
||||||
|
- 记录调用日志。
|
||||||
|
- 在主模型失败时按规则尝试备用模型。
|
||||||
|
- 返回统一错误信息。
|
||||||
|
|
||||||
|
业务模块不应直接调用具体服务商接口。
|
||||||
|
|
||||||
|
### 9.5 RAG 向量导入
|
||||||
|
|
||||||
|
系统应将 RAG 文档索引流程接入模型网关。
|
||||||
|
|
||||||
|
向量导入流程应支持:
|
||||||
|
|
||||||
|
1. 读取文档解析快照。
|
||||||
|
2. 按切片策略生成 `rag_chunk`。
|
||||||
|
3. 根据知识库或全局路由选择 Embedding 模型。
|
||||||
|
4. 调用模型生成向量。
|
||||||
|
5. 写入 `rag_chunk_embedding`。
|
||||||
|
6. 更新文档索引状态。
|
||||||
|
7. 记录模型调用日志。
|
||||||
|
|
||||||
|
同一知识库应固定 Embedding 模型和维度。模型变更时应触发重建索引流程。
|
||||||
|
|
||||||
|
### 9.6 Ollama 接入
|
||||||
|
|
||||||
|
系统应支持配置远程 Ollama 服务。
|
||||||
|
|
||||||
|
Ollama 接入要求:
|
||||||
|
|
||||||
|
- 支持 OpenAI-compatible 地址,例如 `http://host:11434/v1`。
|
||||||
|
- 支持 Chat 模型。
|
||||||
|
- 支持 Embedding 模型。
|
||||||
|
- 支持模型健康检查。
|
||||||
|
- 支持设置本地模型标签。
|
||||||
|
|
||||||
|
Ollama 远程访问不应直接裸露在公网。生产环境应通过 VPN、内网、反向代理鉴权、Tailscale、Cloudflare Tunnel 或安全网关访问。
|
||||||
|
|
||||||
|
### 9.7 云端 OpenAI-compatible 服务接入
|
||||||
|
|
||||||
|
系统应支持硅基流动、百炼、OpenAI 或其他兼容接口。
|
||||||
|
|
||||||
|
接入要求:
|
||||||
|
|
||||||
|
- 服务商可配置 `baseUrl`。
|
||||||
|
- 服务商可配置 `apiKey` 引用。
|
||||||
|
- 模型可配置上游模型名。
|
||||||
|
- Embedding 支持设置维度。
|
||||||
|
- Chat 支持设置温度、最大输出等参数。
|
||||||
|
|
||||||
|
### 9.8 调用日志
|
||||||
|
|
||||||
|
系统应记录模型调用日志。
|
||||||
|
|
||||||
|
日志至少包含:
|
||||||
|
|
||||||
|
- 请求 ID。
|
||||||
|
- 服务商。
|
||||||
|
- 模型。
|
||||||
|
- 任务类型。
|
||||||
|
- 业务来源。
|
||||||
|
- 调用状态。
|
||||||
|
- 耗时。
|
||||||
|
- token 数量。
|
||||||
|
- 费用估算。
|
||||||
|
- 错误码。
|
||||||
|
- 错误消息。
|
||||||
|
- 请求哈希。
|
||||||
|
- 创建时间。
|
||||||
|
|
||||||
|
日志中不得记录完整 API Key。
|
||||||
|
|
||||||
|
### 9.9 后台管理界面
|
||||||
|
|
||||||
|
后台管理界面应逐步提供:
|
||||||
|
|
||||||
|
- 服务商列表。
|
||||||
|
- 模型列表。
|
||||||
|
- 路由规则配置。
|
||||||
|
- 调用日志查询。
|
||||||
|
- 模型健康检查结果。
|
||||||
|
- 知识库 Embedding 配置入口。
|
||||||
|
|
||||||
|
首期可先完成后端接口和 SQL 结构,前端在 RAG 最小闭环后接入。
|
||||||
|
|
||||||
|
## 10. 非功能需求
|
||||||
|
|
||||||
|
### 10.1 可扩展性
|
||||||
|
|
||||||
|
模型服务商接入应通过统一接口扩展。新增 OpenAI-compatible 服务商时,原则上只需要新增配置,不需要修改业务调用代码。
|
||||||
|
|
||||||
|
### 10.2 可用性
|
||||||
|
|
||||||
|
模型调用应支持超时控制和失败兜底。云端模型失败时,可以根据路由规则切换备用模型。本地 Ollama 不可用时,不应阻塞整个系统启动。
|
||||||
|
|
||||||
|
### 10.3 性能
|
||||||
|
|
||||||
|
Embedding 批量生成应支持批处理,减少网络请求次数。批量大小应可配置,避免超过上游接口限制。
|
||||||
|
|
||||||
|
### 10.4 可观测性
|
||||||
|
|
||||||
|
每次模型调用应有请求 ID,并记录耗时、状态和错误原因。后续可接入监控告警。
|
||||||
|
|
||||||
|
### 10.5 可维护性
|
||||||
|
|
||||||
|
新增核心代码应补充中文注释,说明类职责、关键分支和扩展边界。新增枚举应同步纳入系统枚举初始化测试。
|
||||||
|
|
||||||
|
## 11. 数据安全需求
|
||||||
|
|
||||||
|
1. API Key 不得写死在代码中。
|
||||||
|
2. API Key 不应明文返回给前端。
|
||||||
|
3. 数据库可保存 `secretRef` 或加密后的 `apiKeyCipher`。
|
||||||
|
4. 若保存加密密钥,主加密密钥应来自环境变量或外部配置,不应存储在数据库。
|
||||||
|
5. 调用日志不得保存完整请求正文中的敏感信息。
|
||||||
|
6. 管理接口后续应接入权限控制。
|
||||||
|
7. Ollama 远程服务应限制访问来源,避免未授权公网调用。
|
||||||
|
|
||||||
|
## 12. 成本控制需求
|
||||||
|
|
||||||
|
系统应支持以下成本控制策略:
|
||||||
|
|
||||||
|
- 本地优先:简单任务优先走 Ollama。
|
||||||
|
- 成本优先:优先选择单价较低的云模型。
|
||||||
|
- 质量优先:复杂任务优先选择能力更强的模型。
|
||||||
|
- 手动指定:某个知识库或 Agent 固定使用指定模型。
|
||||||
|
- 失败兜底:主模型失败后切换备用模型。
|
||||||
|
|
||||||
|
调用日志应记录费用估算所需字段,为后续成本报表提供基础。
|
||||||
|
|
||||||
|
## 13. RAG 特殊约束
|
||||||
|
|
||||||
|
### 13.1 向量维度约束
|
||||||
|
|
||||||
|
当前 `rag_chunk_embedding.embedding` 使用 `VECTOR(1024)`。因此首期 Embedding 模型应统一输出 1024 维向量。
|
||||||
|
|
||||||
|
适配方向:
|
||||||
|
|
||||||
|
- 硅基流动:可使用 `Qwen/Qwen3-Embedding-0.6B`,配置 1024 维。
|
||||||
|
- Ollama:应选择输出维度与表结构匹配的 embedding 模型。
|
||||||
|
- 百炼:可使用支持 1024 维的文本向量模型。
|
||||||
|
|
||||||
|
### 13.2 模型一致性约束
|
||||||
|
|
||||||
|
同一知识库同一索引版本内,应使用同一个 Embedding 模型和维度。
|
||||||
|
|
||||||
|
当知识库 Embedding 模型或维度变化时:
|
||||||
|
|
||||||
|
- 已有索引应标记为需要重建。
|
||||||
|
- 新索引写入前应清理旧切片向量,或写入新的索引版本。
|
||||||
|
- 检索时应只使用当前生效模型生成的向量。
|
||||||
|
|
||||||
|
### 13.3 失败状态约束
|
||||||
|
|
||||||
|
向量生成失败时,文档 `indexStatus` 应更新为 `FAILED`,并记录失败原因。部分切片成功、部分失败时,首期按整个文档索引失败处理,避免半成品参与检索。
|
||||||
|
|
||||||
|
## 14. 验收标准
|
||||||
|
|
||||||
|
### 14.1 服务商配置验收
|
||||||
|
|
||||||
|
- 可以新增一个 Ollama 服务商。
|
||||||
|
- 可以新增一个硅基流动服务商。
|
||||||
|
- 可以为服务商配置至少一个 Chat 模型和一个 Embedding 模型。
|
||||||
|
- 服务商编码和模型编码唯一性校验生效。
|
||||||
|
|
||||||
|
### 14.2 路由验收
|
||||||
|
|
||||||
|
- 可以为 RAG 文档向量化配置默认 Embedding 模型。
|
||||||
|
- 可以为简单文本处理配置 Ollama 模型。
|
||||||
|
- 可以为复杂文本处理配置云端模型。
|
||||||
|
- 主模型不可用时,可以根据配置切换备用模型。
|
||||||
|
|
||||||
|
### 14.3 RAG 向量导入验收
|
||||||
|
|
||||||
|
- 文档解析完成后,可以生成切片。
|
||||||
|
- 切片可以调用配置的 Embedding 模型生成 1024 维向量。
|
||||||
|
- 向量可以写入 `rag_chunk_embedding`。
|
||||||
|
- 文档索引状态可以从 `PENDING` 流转到 `INDEXING`、`INDEXED` 或 `FAILED`。
|
||||||
|
- 调用日志可以记录 Embedding 调用信息。
|
||||||
|
|
||||||
|
### 14.4 安全验收
|
||||||
|
|
||||||
|
- 前端查询服务商详情时不返回明文 API Key。
|
||||||
|
- 调用日志中不包含完整 API Key。
|
||||||
|
- 未配置密钥时,云端服务调用失败信息清晰。
|
||||||
|
- Ollama 远程地址可配置,但文档中明确生产环境访问安全要求。
|
||||||
|
|
||||||
|
### 14.5 测试验收
|
||||||
|
|
||||||
|
- 后端新增实体、Mapper、Service、Controller 结构测试。
|
||||||
|
- 模型路由单元测试。
|
||||||
|
- Embedding 网关单元测试。
|
||||||
|
- RAG 向量导入服务单元测试。
|
||||||
|
- 配置缺失、模型停用、服务商停用、调用失败等异常测试。
|
||||||
|
|
||||||
|
## 15. 分阶段里程碑
|
||||||
|
|
||||||
|
### 15.1 阶段一:模型配置基础
|
||||||
|
|
||||||
|
- 新增模型服务商表。
|
||||||
|
- 新增模型配置表。
|
||||||
|
- 新增基础 CRUD 接口。
|
||||||
|
- 支持 OpenAI-compatible 服务商配置。
|
||||||
|
- 支持密钥引用字段。
|
||||||
|
|
||||||
|
### 15.2 阶段二:Embedding 网关与 RAG 接入
|
||||||
|
|
||||||
|
- 新增 Embedding 调用网关。
|
||||||
|
- 接入硅基流动或 Ollama Embedding。
|
||||||
|
- RAG 切片后调用 Embedding 并写入 `rag_chunk_embedding`。
|
||||||
|
- 更新文档索引状态。
|
||||||
|
- 记录调用日志。
|
||||||
|
|
||||||
|
### 15.3 阶段三:模型路由与失败兜底
|
||||||
|
|
||||||
|
- 新增模型路由规则。
|
||||||
|
- 支持按任务类型选择模型。
|
||||||
|
- 支持主备模型调用。
|
||||||
|
- 支持本地优先、成本优先、质量优先策略。
|
||||||
|
|
||||||
|
### 15.4 阶段四:后台管理与成本观测
|
||||||
|
|
||||||
|
- 前端接入服务商管理。
|
||||||
|
- 前端接入模型管理。
|
||||||
|
- 前端接入路由规则管理。
|
||||||
|
- 前端接入调用日志查询。
|
||||||
|
- 增加费用估算和调用趋势展示。
|
||||||
|
|
||||||
|
## 16. 参考资料
|
||||||
|
|
||||||
|
- Ollama OpenAI-compatible API: https://docs.ollama.com/api/openai-compatibility
|
||||||
|
- Ollama Embeddings: https://docs.ollama.com/capabilities/embeddings
|
||||||
|
- Spring AI OpenAI Embeddings: https://docs.spring.io/spring-ai/reference/api/embeddings/openai-embeddings.html
|
||||||
|
- SiliconFlow Embedding 模型列表: https://www.siliconflow.com/models/embedding
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Common Agent 开发路线图
|
# Common Agent 开发路线图
|
||||||
|
|
||||||
本文档基于 2026-05-24 当前分支代码整理,用来区分"已经完成""建议优先做""中期建设项"。
|
本文档基于 2026-05-25 当前分支代码整理,用来区分"已经完成""建议优先做""中期建设项"。
|
||||||
|
|
||||||
## 已完成
|
## 已完成
|
||||||
|
|
||||||
@@ -33,7 +33,10 @@
|
|||||||
- `rag_chunk`、`rag_chunk_embedding` 表结构与实体定义。
|
- `rag_chunk`、`rag_chunk_embedding` 表结构与实体定义。
|
||||||
- RAG 知识库完整 CRUD(含编码唯一性校验)。
|
- RAG 知识库完整 CRUD(含编码唯一性校验)。
|
||||||
- 知识文档完整 CRUD、条件查询、批量上传。
|
- 知识文档完整 CRUD、条件查询、批量上传。
|
||||||
- 文档解析入口:支持批量提交文档 ID,按切片策略参数发起解析,状态流转为 PARSING / PARSED / FAILED。
|
- 文档解析入口:支持批量提交文档 ID,状态流转为 PARSING / PARSED / FAILED。
|
||||||
|
- 解析快照:解析成功后保存 `rag_document_parse_result`,供后续切片复用。
|
||||||
|
- 切片基础能力:已完成 `Chunker`、`ChunkerFactory`、定长切片和分隔符切片。
|
||||||
|
- 文档切片入口:支持按切片策略异步生成并替换 `rag_chunk`。
|
||||||
- 知识库总览接口与单库文档概览接口。
|
- 知识库总览接口与单库文档概览接口。
|
||||||
- RAG 解析状态枚举 `RagParseStatusEnum`(UPLOADED / PARSING / PARSED / FAILED)。
|
- RAG 解析状态枚举 `RagParseStatusEnum`(UPLOADED / PARSING / PARSED / FAILED)。
|
||||||
- RAG 索引状态枚举 `RagIndexStatusEnum`(PENDING / INDEXING / INDEXED / FAILED)。
|
- RAG 索引状态枚举 `RagIndexStatusEnum`(PENDING / INDEXING / INDEXED / FAILED)。
|
||||||
@@ -46,11 +49,18 @@
|
|||||||
- `AdminLayout.vue` 管理后台布局(侧边栏菜单 + 主内容区)。
|
- `AdminLayout.vue` 管理后台布局(侧边栏菜单 + 主内容区)。
|
||||||
- 系统枚举管理页:完整 CRUD + 批量新增对话框 + 关键词搜索 + 响应式布局。
|
- 系统枚举管理页:完整 CRUD + 批量新增对话框 + 关键词搜索 + 响应式布局。
|
||||||
- 知识库管理页:完整 CRUD + 概览卡片 + 双栏详情 + 编辑对话框 + 批量上传入口。
|
- 知识库管理页:完整 CRUD + 概览卡片 + 双栏详情 + 编辑对话框 + 批量上传入口。
|
||||||
- 知识文档管理页:条件查询 + 批量上传 + 批量解析入口 + 编辑/启停用/删除。
|
- 知识文档管理页:条件查询 + 批量上传 + 解析重试 + 批量切片入口 + 编辑/启停用/删除。
|
||||||
|
- RAG 工作台与切片任务页:展示文档解析/切片概览并提供切片入口。
|
||||||
- RAG 文档批量上传组件:支持锁定知识库或选择知识库上传。
|
- RAG 文档批量上传组件:支持锁定知识库或选择知识库上传。
|
||||||
- API 层:Axios 封装 + Long 类型安全解析 + 统一错误拦截。
|
- API 层:Axios 封装 + Long 类型安全解析 + 统一错误拦截。
|
||||||
- 单元测试:Vitest + @vue/test-utils,覆盖路由、布局、页面和 API。
|
- 单元测试:Vitest + @vue/test-utils,覆盖路由、布局、页面和 API。
|
||||||
|
|
||||||
|
### 模型平台设计
|
||||||
|
|
||||||
|
- 已新增模型服务商配置与路由需求文档:`docs/MODEL_PROVIDER_REQUIREMENTS.md`。
|
||||||
|
- 已新增模型服务商配置与路由设计文档:`docs/MODEL_PROVIDER_DESIGN.md`。
|
||||||
|
- 已明确后续通过模型网关统一接入 Ollama、硅基流动、百炼等 OpenAI-compatible 来源。
|
||||||
|
|
||||||
### 质量保障
|
### 质量保障
|
||||||
|
|
||||||
- 后端结构稳定性单元测试。
|
- 后端结构稳定性单元测试。
|
||||||
@@ -58,22 +68,22 @@
|
|||||||
|
|
||||||
## 短期优先级
|
## 短期优先级
|
||||||
|
|
||||||
建议优先完成下面几项,把 RAG 上传解析链路升级为可检索链路:
|
建议优先完成下面几项,把 RAG 上传解析切片链路升级为可检索链路:
|
||||||
|
|
||||||
1. 切片生成服务:把解析服务拿到的 `DocumentParseResult.text` 改造成可持久化的 `rag_chunk` 记录。
|
1. 模型服务商配置:新增服务商、模型、路由规则和调用日志基础表。
|
||||||
2. 按 `RagChunkStrategyEnum` 先落地 `FIXED_LENGTH` 和 `DELIMITER`,再扩展段落/标题/表格/语义切片。
|
2. Embedding 网关:优先支持 OpenAI-compatible 接口,接入硅基流动或 Ollama Embedding。
|
||||||
3. 索引任务入口:把文档或知识库的 `indexStatus` 推进为真实状态流转。
|
3. 向量写入:对 `rag_chunk` 调用 Embedding 模型并保存 `rag_chunk_embedding`。
|
||||||
4. 接入 Spring AI Embedding 模型,生成并保存 `rag_chunk_embedding`。
|
4. 索引任务入口:把文档或知识库的 `indexStatus` 推进为真实状态流转。
|
||||||
5. 补齐重建索引、失败重试、最近任务接口。
|
5. 补齐重建索引、失败重试、最近任务接口。
|
||||||
6. 前端接入检索配置、最近任务和重建索引动作。
|
6. 前端接入模型配置、检索配置、最近任务和重建索引动作。
|
||||||
|
|
||||||
## RAG 最小闭环
|
## RAG 最小闭环
|
||||||
|
|
||||||
当前上传与解析入口已完成,下一步建设最小检索闭环:
|
当前上传与解析入口已完成,下一步建设最小检索闭环:
|
||||||
|
|
||||||
1. 批量上传文件,自动创建 `sys_attachment` 与 `rag_document`。
|
1. ~~批量上传文件,自动创建 `sys_attachment` 与 `rag_document`。~~
|
||||||
2. 调用解析入口,使用 Tika 抽取文本并更新 `parseStatus`。
|
2. ~~调用解析入口,使用 Tika 抽取文本并更新 `parseStatus`。~~
|
||||||
3. 根据切片策略生成 `rag_chunk`。
|
3. ~~根据切片策略生成 `rag_chunk`。~~
|
||||||
4. 调用 Embedding 模型生成向量并写入 `rag_chunk_embedding`。
|
4. 调用 Embedding 模型生成向量并写入 `rag_chunk_embedding`。
|
||||||
5. 提供检索接口,按 query 向量召回切片并返回引用元数据。
|
5. 提供检索接口,按 query 向量召回切片并返回引用元数据。
|
||||||
|
|
||||||
@@ -108,7 +118,8 @@ RAG 数据链路稳定后,再进入 Agent 主线:
|
|||||||
- 枚举查询接口规范:POST `/api/sys-enum/queryForManagement`。
|
- 枚举查询接口规范:POST `/api/sys-enum/queryForManagement`。
|
||||||
- RAG 文档状态字段:`parseStatus` + `indexStatus` + `enabled`。
|
- RAG 文档状态字段:`parseStatus` + `indexStatus` + `enabled`。
|
||||||
- RAG 文档批量上传接口:POST `/api/rag/documents/batchUpload`。
|
- RAG 文档批量上传接口:POST `/api/rag/documents/batchUpload`。
|
||||||
- RAG 文档解析接口:POST `/api/rag/documents/parse`,当前同步解析并返回解析元数据。
|
- RAG 文档解析接口:POST `/api/rag/documents/parse`,当前同步解析、保存解析快照并返回解析元数据。
|
||||||
|
- RAG 文档切片接口:POST `/api/rag/documents/chunk`,当前异步生成并替换 `rag_chunk`。
|
||||||
- 大整数 ID 通过 `@JsonSerialize(ToStringSerializer.class)` 输出为字符串。
|
- 大整数 ID 通过 `@JsonSerialize(ToStringSerializer.class)` 输出为字符串。
|
||||||
|
|
||||||
## 里程碑
|
## 里程碑
|
||||||
@@ -120,9 +131,9 @@ RAG 数据链路稳定后,再进入 Agent 主线:
|
|||||||
|
|
||||||
### 里程碑 2:RAG 可演示
|
### 里程碑 2:RAG 可演示
|
||||||
|
|
||||||
- 已完成知识库文档上传、建档、解析状态流转。
|
- 已完成知识库文档上传、建档、解析状态流转和手动切片持久化。
|
||||||
- 下一步完成切片持久化、向量化和检索接口。
|
- 下一步完成模型配置、向量化和检索接口。
|
||||||
- 前端知识库与知识文档页面已接入当前接口,索引任务和检索配置待接入。
|
- 前端知识库、知识文档、RAG 工作台和切片任务页面已接入当前接口,索引任务和检索配置待接入。
|
||||||
|
|
||||||
### 里程碑 3:Agent 最小运行时
|
### 里程碑 3:Agent 最小运行时
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,28 @@ export interface RagDocumentBatchUploadRequest {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RagChunkStrategy =
|
/**
|
||||||
| 'FIXED_LENGTH'
|
* RAG 切片策略枚举值。
|
||||||
| 'PARAGRAPH'
|
* <p>
|
||||||
| 'HEADING'
|
* 前后端统一传递枚举值,不再传递字符串名称。
|
||||||
| 'TABLE_ROW'
|
*/
|
||||||
| 'DELIMITER'
|
export const RAG_CHUNK_STRATEGY = {
|
||||||
| 'SEMANTIC';
|
FIXED_LENGTH: 1,
|
||||||
|
PARAGRAPH: 2,
|
||||||
|
HEADING: 3,
|
||||||
|
TABLE_ROW: 4,
|
||||||
|
DELIMITER: 5,
|
||||||
|
SEMANTIC: 6,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RagChunkStrategy = (typeof RAG_CHUNK_STRATEGY)[keyof typeof RAG_CHUNK_STRATEGY];
|
||||||
|
|
||||||
export interface RagDocumentParseRequest {
|
export interface RagDocumentParseRequest {
|
||||||
documentIds: string[];
|
documentIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagDocumentChunkRequest {
|
||||||
|
documentIds: string[];
|
||||||
chunkStrategy: RagChunkStrategy;
|
chunkStrategy: RagChunkStrategy;
|
||||||
chunkSize?: number;
|
chunkSize?: number;
|
||||||
chunkOverlap?: number;
|
chunkOverlap?: number;
|
||||||
@@ -115,3 +127,11 @@ export function batchUploadRagDocuments(data: RagDocumentBatchUploadRequest) {
|
|||||||
export function parseRagDocuments(data: RagDocumentParseRequest) {
|
export function parseRagDocuments(data: RagDocumentParseRequest) {
|
||||||
return post<RagDocumentParseResponse[], RagDocumentParseRequest>('/rag/documents/parse', data);
|
return post<RagDocumentParseResponse[], RagDocumentParseRequest>('/rag/documents/parse', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function retryParseRagDocuments(data: RagDocumentParseRequest) {
|
||||||
|
return post<RagDocumentParseResponse[], RagDocumentParseRequest>('/rag/documents/retryParse', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chunkRagDocuments(data: RagDocumentChunkRequest) {
|
||||||
|
return post<boolean, RagDocumentChunkRequest>('/rag/documents/chunk', data);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { computed, ref, watch } from 'vue';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
batchUploadRagDocuments,
|
batchUploadRagDocuments,
|
||||||
|
type RagDocument,
|
||||||
SOURCE_TYPE_RAG,
|
SOURCE_TYPE_RAG,
|
||||||
} from '@/api/ragDocuments';
|
} from '@/api/ragDocuments';
|
||||||
import type { RagStore } from '@/api/ragStores';
|
import type { RagStore } from '@/api/ragStores';
|
||||||
@@ -17,7 +18,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'update:modelValue', value: boolean): void;
|
(event: 'update:modelValue', value: boolean): void;
|
||||||
(event: 'uploaded'): void;
|
(event: 'uploaded', documentIds: string[]): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
@@ -83,16 +84,19 @@ async function submitUpload() {
|
|||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await batchUploadRagDocuments({
|
const response = await batchUploadRagDocuments({
|
||||||
storeId: uploadStoreId.value,
|
storeId: uploadStoreId.value,
|
||||||
sourceType: SOURCE_TYPE_RAG,
|
sourceType: SOURCE_TYPE_RAG,
|
||||||
files: uploadFiles.value,
|
files: uploadFiles.value,
|
||||||
documentSummary: uploadSummary.value || undefined,
|
documentSummary: uploadSummary.value || undefined,
|
||||||
remark: uploadRemark.value || undefined,
|
remark: uploadRemark.value || undefined,
|
||||||
});
|
});
|
||||||
|
const ids = (response.data ?? [])
|
||||||
|
.map((doc: RagDocument) => doc.id ?? '')
|
||||||
|
.filter(Boolean);
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
ElMessage.success('文档已上传');
|
ElMessage.success('文档已上传');
|
||||||
emit('uploaded');
|
emit('uploaded', ids);
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
59
frontend/src/components/rag/chunk/RagChunkTaskBoard.vue
Normal file
59
frontend/src/components/rag/chunk/RagChunkTaskBoard.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
total: number;
|
||||||
|
parsed: number;
|
||||||
|
failed: number;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="task-board">
|
||||||
|
<div class="task-card">
|
||||||
|
<strong>{{ total }}</strong>
|
||||||
|
<span>文档总数</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-card">
|
||||||
|
<strong>{{ parsed }}</strong>
|
||||||
|
<span>可切片文档</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-card">
|
||||||
|
<strong>{{ failed }}</strong>
|
||||||
|
<span>解析失败文档</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #d8dee9;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card strong {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #2f3a4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card span {
|
||||||
|
color: #738099;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.task-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
38
frontend/src/components/rag/document/RagFlowOverview.vue
Normal file
38
frontend/src/components/rag/document/RagFlowOverview.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import type { RagDocument } from '@/api/ragDocuments';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
documents: RagDocument[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const total = computed(() => props.documents.length);
|
||||||
|
const parsing = computed(() => props.documents.filter((row) => row.parseStatus === 'PARSING').length);
|
||||||
|
const parsed = computed(() => props.documents.filter((row) => row.parseStatus === 'PARSED').length);
|
||||||
|
const failed = computed(() => props.documents.filter((row) => row.parseStatus === 'FAILED').length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flow-overview">
|
||||||
|
<el-tag type="info">总文档 {{ total }}</el-tag>
|
||||||
|
<el-tag type="warning">解析中 {{ parsing }}</el-tag>
|
||||||
|
<el-tag type="success">可切片 {{ parsed }}</el-tag>
|
||||||
|
<el-tag type="danger">解析失败 {{ failed }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flow-overview {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 14px 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.flow-overview {
|
||||||
|
padding: 14px 16px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,18 +2,18 @@
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Collection,
|
Collection,
|
||||||
DataBoard,
|
|
||||||
Document,
|
Document,
|
||||||
Files,
|
|
||||||
Grid,
|
Grid,
|
||||||
|
Histogram,
|
||||||
|
List,
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ path: '/dashboard', label: '工作台', icon: DataBoard },
|
|
||||||
{ path: '/system/enums', label: '系统枚举', icon: Grid },
|
{ path: '/system/enums', label: '系统枚举', icon: Grid },
|
||||||
{ path: '/system/attachments', label: '附件管理', icon: Files },
|
|
||||||
{ path: '/rag/stores', label: '知识库', icon: Collection },
|
{ path: '/rag/stores', label: '知识库', icon: Collection },
|
||||||
|
{ path: '/rag/workbench', label: 'RAG工作台', icon: Histogram },
|
||||||
{ path: '/rag/documents', label: '知识文档', icon: Document },
|
{ path: '/rag/documents', label: '知识文档', icon: Document },
|
||||||
|
{ path: '/rag/tasks/chunk', label: '切片任务', icon: List },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="not-found">
|
<main class="not-found">
|
||||||
<h1>404</h1>
|
<h1>404</h1>
|
||||||
<RouterLink to="/dashboard">返回工作台</RouterLink>
|
<RouterLink to="/rag/workbench">返回RAG工作台</RouterLink>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="page-panel">
|
|
||||||
<div class="page-panel__header">
|
|
||||||
<h2>工作台</h2>
|
|
||||||
<span>Console</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -1,638 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Operation, Search, UploadFilled } from '@element-plus/icons-vue';
|
import RagDocumentsPage from '@/pages/rag/documents/RagDocumentsPage.vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
|
|
||||||
import {
|
|
||||||
deleteRagDocument,
|
|
||||||
getRagDocumentById,
|
|
||||||
parseRagDocuments,
|
|
||||||
queryRagDocuments,
|
|
||||||
saveRagDocument,
|
|
||||||
type RagChunkStrategy,
|
|
||||||
type RagDocument,
|
|
||||||
} from '@/api/ragDocuments';
|
|
||||||
import { queryRagStores, type RagStore } from '@/api/ragStores';
|
|
||||||
import RagDocumentBatchUploadDialog from '@/components/rag/RagDocumentBatchUploadDialog.vue';
|
|
||||||
|
|
||||||
const loading = ref(false);
|
|
||||||
const submitting = ref(false);
|
|
||||||
const parseSubmitting = ref(false);
|
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
const storeOptions = ref<RagStore[]>([]);
|
|
||||||
const docRows = ref<RagDocument[]>([]);
|
|
||||||
|
|
||||||
const queryForm = reactive({
|
|
||||||
storeId: '',
|
|
||||||
parseStatus: '',
|
|
||||||
indexStatus: '',
|
|
||||||
enabled: '' as string,
|
|
||||||
keyword: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const editDialogVisible = ref(false);
|
|
||||||
const uploadDialogVisible = ref(false);
|
|
||||||
const parseDialogVisible = ref(false);
|
|
||||||
const selectedDocuments = ref<RagDocument[]>([]);
|
|
||||||
|
|
||||||
const editForm = reactive({
|
|
||||||
id: '',
|
|
||||||
storeId: '',
|
|
||||||
attachmentId: '',
|
|
||||||
documentTitle: '',
|
|
||||||
documentSummary: '',
|
|
||||||
enabled: true,
|
|
||||||
remark: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const parseForm = reactive({
|
|
||||||
documentIds: [] as string[],
|
|
||||||
chunkStrategy: 'FIXED_LENGTH' as RagChunkStrategy,
|
|
||||||
chunkSize: 800,
|
|
||||||
chunkOverlap: 120,
|
|
||||||
delimiter: '。',
|
|
||||||
});
|
|
||||||
|
|
||||||
const chunkStrategyOptions: Array<{ label: string; value: RagChunkStrategy; description: string }> = [
|
|
||||||
{ label: '固定长度切片', value: 'FIXED_LENGTH', description: '按指定长度和重叠长度切分通用文本' },
|
|
||||||
{ label: '按段落切片', value: 'PARAGRAPH', description: '按空行、自然段落边界切分' },
|
|
||||||
{ label: '按标题层级切片', value: 'HEADING', description: '按标题和章节层级组织内容' },
|
|
||||||
{ label: '按表格行切片', value: 'TABLE_ROW', description: '适合 Excel 表格和结构化明细数据' },
|
|
||||||
{ label: '按分隔符切片', value: 'DELIMITER', description: '按句号、换行符或自定义分隔符切分' },
|
|
||||||
{ label: '语义切片', value: 'SEMANTIC', description: '后续结合语义边界或模型能力切分' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const filteredRows = computed(() => {
|
|
||||||
const kw = queryForm.keyword.trim().toLowerCase();
|
|
||||||
return docRows.value.filter(
|
|
||||||
(row) => {
|
|
||||||
const matchStore = !queryForm.storeId || row.storeId === queryForm.storeId;
|
|
||||||
const matchParseStatus = !queryForm.parseStatus || row.parseStatus === queryForm.parseStatus;
|
|
||||||
const matchIndexStatus = !queryForm.indexStatus || row.indexStatus === queryForm.indexStatus;
|
|
||||||
const matchEnabled = !queryForm.enabled || String(row.enabled ?? false) === queryForm.enabled;
|
|
||||||
const matchKeyword =
|
|
||||||
!kw ||
|
|
||||||
(row.documentTitle && row.documentTitle.toLowerCase().includes(kw)) ||
|
|
||||||
(row.documentSummary && row.documentSummary.toLowerCase().includes(kw)) ||
|
|
||||||
(row.remark && row.remark.toLowerCase().includes(kw));
|
|
||||||
return matchStore && matchParseStatus && matchIndexStatus && matchEnabled && matchKeyword;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadStores() {
|
|
||||||
try {
|
|
||||||
const response = await queryRagStores();
|
|
||||||
storeOptions.value = response.data ?? [];
|
|
||||||
} catch {
|
|
||||||
storeOptions.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDocs() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const response = await queryRagDocuments({
|
|
||||||
...(queryForm.storeId ? { storeId: queryForm.storeId } : {}),
|
|
||||||
...(queryForm.parseStatus ? { parseStatus: queryForm.parseStatus } : {}),
|
|
||||||
...(queryForm.indexStatus ? { indexStatus: queryForm.indexStatus } : {}),
|
|
||||||
...(queryForm.enabled ? { enabled: queryForm.enabled === 'true' } : {}),
|
|
||||||
});
|
|
||||||
docRows.value = response.data ?? [];
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
loadDocs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReset() {
|
|
||||||
queryForm.storeId = '';
|
|
||||||
queryForm.parseStatus = '';
|
|
||||||
queryForm.indexStatus = '';
|
|
||||||
queryForm.enabled = '';
|
|
||||||
queryForm.keyword = '';
|
|
||||||
loadDocs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openUploadDialog() {
|
|
||||||
if (storeOptions.value.length === 0) {
|
|
||||||
ElMessage.warning('请先创建知识库');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uploadDialogVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectionChange(rows: RagDocument[]) {
|
|
||||||
selectedDocuments.value = rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openParseDialog(rows: RagDocument[]) {
|
|
||||||
const ids = rows.map((row) => String(row.id ?? '')).filter(Boolean);
|
|
||||||
if (ids.length === 0) {
|
|
||||||
ElMessage.warning('请选择需要解析的文档');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
parseForm.documentIds = ids;
|
|
||||||
parseForm.chunkStrategy = 'FIXED_LENGTH';
|
|
||||||
parseForm.chunkSize = 800;
|
|
||||||
parseForm.chunkOverlap = 120;
|
|
||||||
parseForm.delimiter = '。';
|
|
||||||
parseDialogVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openBatchParseDialog() {
|
|
||||||
openParseDialog(selectedDocuments.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEditDialog(row: RagDocument) {
|
|
||||||
const detail = row.id ? (await getRagDocumentById(String(row.id))).data : row;
|
|
||||||
|
|
||||||
editForm.id = String(detail.id ?? '');
|
|
||||||
editForm.storeId = detail.storeId;
|
|
||||||
editForm.attachmentId = detail.attachmentId ?? '';
|
|
||||||
editForm.documentTitle = detail.documentTitle ?? '';
|
|
||||||
editForm.documentSummary = detail.documentSummary ?? '';
|
|
||||||
editForm.enabled = detail.enabled ?? true;
|
|
||||||
editForm.remark = detail.remark ?? '';
|
|
||||||
editDialogVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitEdit() {
|
|
||||||
if (!editForm.id || !editForm.storeId || !editForm.documentTitle) {
|
|
||||||
ElMessage.warning('请填写文档标题');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting.value = true;
|
|
||||||
try {
|
|
||||||
await saveRagDocument({
|
|
||||||
id: editForm.id,
|
|
||||||
storeId: editForm.storeId,
|
|
||||||
attachmentId: editForm.attachmentId || undefined,
|
|
||||||
documentTitle: editForm.documentTitle,
|
|
||||||
documentSummary: editForm.documentSummary || undefined,
|
|
||||||
enabled: editForm.enabled,
|
|
||||||
remark: editForm.remark || undefined,
|
|
||||||
});
|
|
||||||
editDialogVisible.value = false;
|
|
||||||
ElMessage.success('文档信息已更新');
|
|
||||||
await loadDocs();
|
|
||||||
} finally {
|
|
||||||
submitting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeDoc(row: RagDocument) {
|
|
||||||
if (!row.id) return;
|
|
||||||
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
`确认删除文档「${row.documentTitle || '未命名'}」?`,
|
|
||||||
'删除确认',
|
|
||||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
|
||||||
);
|
|
||||||
|
|
||||||
await deleteRagDocument(String(row.id));
|
|
||||||
ElMessage.success('文档已删除');
|
|
||||||
await loadDocs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleEnabled(row: RagDocument) {
|
|
||||||
if (!row.id) return;
|
|
||||||
const newEnabled = !row.enabled;
|
|
||||||
saveRagDocument({
|
|
||||||
id: String(row.id),
|
|
||||||
storeId: row.storeId,
|
|
||||||
documentTitle: row.documentTitle ?? '',
|
|
||||||
enabled: newEnabled,
|
|
||||||
}).then(() => {
|
|
||||||
row.enabled = newEnabled;
|
|
||||||
ElMessage.success(`已${newEnabled ? '启用' : '停用'}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitParse() {
|
|
||||||
if (parseForm.documentIds.length === 0) {
|
|
||||||
ElMessage.warning('请选择需要解析的文档');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
parseSubmitting.value = true;
|
|
||||||
try {
|
|
||||||
await parseRagDocuments({
|
|
||||||
documentIds: parseForm.documentIds,
|
|
||||||
chunkStrategy: parseForm.chunkStrategy,
|
|
||||||
chunkSize: parseForm.chunkSize,
|
|
||||||
chunkOverlap: parseForm.chunkOverlap,
|
|
||||||
delimiter: parseForm.delimiter,
|
|
||||||
});
|
|
||||||
parseDialogVisible.value = false;
|
|
||||||
ElMessage.success('解析任务已提交');
|
|
||||||
await loadDocs();
|
|
||||||
} finally {
|
|
||||||
parseSubmitting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoreName(storeId: string) {
|
|
||||||
const store = storeOptions.value.find((s) => String(s.id) === storeId);
|
|
||||||
return store ? store.storeName : '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusLabel(status?: string | null) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
UPLOADED: '已上传',
|
|
||||||
PARSING: '解析中',
|
|
||||||
PARSED: '已解析',
|
|
||||||
FAILED: '解析失败',
|
|
||||||
PENDING: '待索引',
|
|
||||||
INDEXING: '索引中',
|
|
||||||
INDEXED: '已索引',
|
|
||||||
};
|
|
||||||
return status ? (map[status] ?? status) : '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getParseStatusType(status?: string | null) {
|
|
||||||
const success = ['PARSED'];
|
|
||||||
const warning = ['UPLOADED', 'PARSING'];
|
|
||||||
const danger = ['FAILED'];
|
|
||||||
if (!status) return 'info';
|
|
||||||
if (success.includes(status)) return 'success';
|
|
||||||
if (warning.includes(status)) return 'warning';
|
|
||||||
if (danger.includes(status)) return 'danger';
|
|
||||||
return 'info';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIndexStatusType(status?: string | null) {
|
|
||||||
const success = ['INDEXED'];
|
|
||||||
const warning = ['PENDING', 'INDEXING'];
|
|
||||||
const danger = ['FAILED'];
|
|
||||||
if (!status) return 'info';
|
|
||||||
if (success.includes(status)) return 'success';
|
|
||||||
if (warning.includes(status)) return 'warning';
|
|
||||||
if (danger.includes(status)) return 'danger';
|
|
||||||
return 'info';
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const routeStoreId = route.query.storeId;
|
|
||||||
if (typeof routeStoreId === 'string') {
|
|
||||||
queryForm.storeId = routeStoreId;
|
|
||||||
}
|
|
||||||
loadStores();
|
|
||||||
loadDocs();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-panel rag-doc-page">
|
<RagDocumentsPage />
|
||||||
<div class="page-panel__header">
|
|
||||||
<h2>知识文档</h2>
|
|
||||||
<span>Documents</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="document-query-bar" data-test="document-query-bar">
|
|
||||||
<el-form class="document-query-form" data-test="document-query-form" inline>
|
|
||||||
<el-form-item label="知识库">
|
|
||||||
<el-select
|
|
||||||
v-model="queryForm.storeId"
|
|
||||||
data-test="doc-store-filter"
|
|
||||||
placeholder="请选择"
|
|
||||||
clearable
|
|
||||||
class="query-control query-control--select"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="store in storeOptions"
|
|
||||||
:key="String(store.id)"
|
|
||||||
:label="store.storeName"
|
|
||||||
:value="String(store.id)"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="解析状态">
|
|
||||||
<el-select
|
|
||||||
v-model="queryForm.parseStatus"
|
|
||||||
data-test="doc-parse-filter"
|
|
||||||
placeholder="请选择"
|
|
||||||
clearable
|
|
||||||
class="query-control query-control--select"
|
|
||||||
>
|
|
||||||
<el-option label="已上传" value="UPLOADED" />
|
|
||||||
<el-option label="解析中" value="PARSING" />
|
|
||||||
<el-option label="已解析" value="PARSED" />
|
|
||||||
<el-option label="解析失败" value="FAILED" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="索引状态">
|
|
||||||
<el-select
|
|
||||||
v-model="queryForm.indexStatus"
|
|
||||||
data-test="doc-index-filter"
|
|
||||||
placeholder="请选择"
|
|
||||||
clearable
|
|
||||||
class="query-control query-control--select"
|
|
||||||
>
|
|
||||||
<el-option label="待索引" value="PENDING" />
|
|
||||||
<el-option label="索引中" value="INDEXING" />
|
|
||||||
<el-option label="已索引" value="INDEXED" />
|
|
||||||
<el-option label="索引失败" value="FAILED" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="启用状态">
|
|
||||||
<el-select
|
|
||||||
v-model="queryForm.enabled"
|
|
||||||
data-test="doc-enabled-filter"
|
|
||||||
placeholder="请选择"
|
|
||||||
clearable
|
|
||||||
class="query-control query-control--select"
|
|
||||||
>
|
|
||||||
<el-option label="启用" value="true" />
|
|
||||||
<el-option label="停用" value="false" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="关键词">
|
|
||||||
<el-input
|
|
||||||
v-model="queryForm.keyword"
|
|
||||||
data-test="doc-keyword-input"
|
|
||||||
placeholder="搜索标题/摘要/备注"
|
|
||||||
clearable
|
|
||||||
class="query-control query-control--keyword"
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item class="document-query-form__actions">
|
|
||||||
<el-button data-test="doc-search" type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
|
||||||
<el-button @click="handleReset">重置</el-button>
|
|
||||||
<el-button
|
|
||||||
data-test="open-batch-parse"
|
|
||||||
:icon="Operation"
|
|
||||||
:disabled="selectedDocuments.length === 0"
|
|
||||||
@click="openBatchParseDialog"
|
|
||||||
>
|
|
||||||
批量解析
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
data-test="open-doc-upload"
|
|
||||||
type="primary"
|
|
||||||
:icon="UploadFilled"
|
|
||||||
@click="openUploadDialog"
|
|
||||||
>
|
|
||||||
批量上传
|
|
||||||
</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table
|
|
||||||
v-loading="loading"
|
|
||||||
:data="filteredRows"
|
|
||||||
border
|
|
||||||
stripe
|
|
||||||
style="width: 100%"
|
|
||||||
@selection-change="handleSelectionChange"
|
|
||||||
>
|
|
||||||
<el-table-column type="selection" width="48" align="center" />
|
|
||||||
<el-table-column type="index" label="编号" width="70" align="center" />
|
|
||||||
<el-table-column prop="documentTitle" label="文档标题" min-width="180" show-overflow-tooltip />
|
|
||||||
<el-table-column label="所属知识库" width="150">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ getStoreName(row.storeId) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="解析状态" width="110">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="getParseStatusType(row.parseStatus)" size="small">
|
|
||||||
{{ getStatusLabel(row.parseStatus) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="索引状态" width="110">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="getIndexStatusType(row.indexStatus)" size="small">
|
|
||||||
{{ getStatusLabel(row.indexStatus) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="启用" width="80" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-switch
|
|
||||||
:model-value="row.enabled ?? false"
|
|
||||||
size="small"
|
|
||||||
@change="toggleEnabled(row)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="documentSummary" label="摘要" min-width="160" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
|
||||||
<el-table-column label="操作" width="210" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button :data-test="`doc-parse-${row.id}`" link type="primary" @click="openParseDialog([row])">解析</el-button>
|
|
||||||
<el-button :data-test="`doc-edit-${row.id}`" link type="primary" @click="openEditDialog(row)">编辑</el-button>
|
|
||||||
<el-button link type="danger" @click="removeDoc(row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-empty v-if="!loading && filteredRows.length === 0" description="暂无知识文档" />
|
|
||||||
|
|
||||||
<RagDocumentBatchUploadDialog
|
|
||||||
v-model="uploadDialogVisible"
|
|
||||||
:stores="storeOptions"
|
|
||||||
:locked-store-id="queryForm.storeId || null"
|
|
||||||
@uploaded="loadDocs"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-dialog
|
|
||||||
v-model="parseDialogVisible"
|
|
||||||
data-test="document-parse-dialog"
|
|
||||||
title="解析配置"
|
|
||||||
width="620px"
|
|
||||||
>
|
|
||||||
<el-form :model="parseForm" label-width="112px">
|
|
||||||
<el-form-item label="文档数量">
|
|
||||||
<el-tag>{{ parseForm.documentIds.length }} 个文档</el-tag>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="切片方式">
|
|
||||||
<el-radio-group v-model="parseForm.chunkStrategy" class="chunk-strategy-group">
|
|
||||||
<el-radio
|
|
||||||
v-for="strategy in chunkStrategyOptions"
|
|
||||||
:key="strategy.value"
|
|
||||||
:value="strategy.value"
|
|
||||||
class="chunk-strategy-option"
|
|
||||||
>
|
|
||||||
<span class="chunk-strategy-option__label">{{ strategy.label }}</span>
|
|
||||||
<small>{{ strategy.description }}</small>
|
|
||||||
</el-radio>
|
|
||||||
</el-radio-group>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="parseForm.chunkStrategy === 'FIXED_LENGTH'" label="切片长度">
|
|
||||||
<el-input-number v-model="parseForm.chunkSize" :min="100" :max="4000" :step="100" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="parseForm.chunkStrategy === 'FIXED_LENGTH'" label="重叠长度">
|
|
||||||
<el-input-number v-model="parseForm.chunkOverlap" :min="0" :max="1000" :step="20" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="parseForm.chunkStrategy === 'DELIMITER'" label="分隔符">
|
|
||||||
<el-input v-model="parseForm.delimiter" maxlength="20" placeholder="如 。、换行符或自定义符号" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="parseDialogVisible = false">取消</el-button>
|
|
||||||
<el-button
|
|
||||||
data-test="document-parse-submit"
|
|
||||||
type="primary"
|
|
||||||
:loading="parseSubmitting"
|
|
||||||
@click="submitParse"
|
|
||||||
>
|
|
||||||
开始解析
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<!-- 编辑对话框 -->
|
|
||||||
<el-dialog v-model="editDialogVisible" title="编辑文档" width="560px">
|
|
||||||
<el-form :model="editForm" label-width="96px">
|
|
||||||
<el-form-item label="文档标题" required>
|
|
||||||
<el-input v-model="editForm.documentTitle" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="文档摘要">
|
|
||||||
<el-input v-model="editForm.documentSummary" type="textarea" :rows="3" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="启用">
|
|
||||||
<el-switch v-model="editForm.enabled" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="备注">
|
|
||||||
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="submitting" @click="submitEdit">保存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.rag-doc-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-query-bar {
|
|
||||||
padding: 18px 28px 17px;
|
|
||||||
border-bottom: 1px solid #e8edf5;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-query-form {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-query-form :deep(.el-form-item) {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-query-form :deep(.el-form-item__label) {
|
|
||||||
height: 38px;
|
|
||||||
padding-right: 8px;
|
|
||||||
color: #606266;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 38px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-query-form :deep(.el-input__wrapper),
|
|
||||||
.document-query-form :deep(.el-select__wrapper) {
|
|
||||||
min-height: 38px;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 0 0 1px #d8dee9 inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-query-form :deep(.el-input__wrapper:hover),
|
|
||||||
.document-query-form :deep(.el-select__wrapper:hover) {
|
|
||||||
box-shadow: 0 0 0 1px #b9c6d8 inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.query-control--select {
|
|
||||||
width: 168px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.query-control--keyword {
|
|
||||||
width: 225px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-query-form__actions {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-query-form__actions :deep(.el-form-item__content) {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chunk-strategy-group {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chunk-strategy-option {
|
|
||||||
align-items: flex-start;
|
|
||||||
height: auto;
|
|
||||||
margin-right: 0;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid #d8dee9;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chunk-strategy-option :deep(.el-radio__label) {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chunk-strategy-option__label {
|
|
||||||
color: #303133;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chunk-strategy-option small {
|
|
||||||
color: #7a8599;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.document-query-bar {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-query-form {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.query-control--select,
|
|
||||||
.query-control--keyword {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-query-form__actions {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-query-form__actions :deep(.el-form-item__content) {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chunk-strategy-group {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import ElementPlus from 'element-plus';
|
import ElementPlus from 'element-plus';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import RagDocumentsPage from '../RagDocumentsPage.vue';
|
import RagDocumentsPage from '../RagDocumentsPage.vue';
|
||||||
import { getRagDocumentById, parseRagDocuments, queryRagDocuments } from '@/api/ragDocuments';
|
import { chunkRagDocuments, getRagDocumentById, queryRagDocuments, retryParseRagDocuments } from '@/api/ragDocuments';
|
||||||
import { queryRagStores } from '@/api/ragStores';
|
import { queryRagStores } from '@/api/ragStores';
|
||||||
|
|
||||||
const routeQuery = vi.hoisted(() => ({ storeId: undefined as string | undefined }));
|
const routeQuery = vi.hoisted(() => ({ storeId: undefined as string | undefined }));
|
||||||
@@ -29,6 +29,14 @@ vi.mock('@/api/ragStores', () => ({
|
|||||||
|
|
||||||
vi.mock('@/api/ragDocuments', () => ({
|
vi.mock('@/api/ragDocuments', () => ({
|
||||||
SOURCE_TYPE_RAG: 'RAG',
|
SOURCE_TYPE_RAG: 'RAG',
|
||||||
|
RAG_CHUNK_STRATEGY: {
|
||||||
|
FIXED_LENGTH: 1,
|
||||||
|
PARAGRAPH: 2,
|
||||||
|
HEADING: 3,
|
||||||
|
TABLE_ROW: 4,
|
||||||
|
DELIMITER: 5,
|
||||||
|
SEMANTIC: 6,
|
||||||
|
},
|
||||||
queryRagDocuments: vi.fn((query?: { storeId?: string }) => {
|
queryRagDocuments: vi.fn((query?: { storeId?: string }) => {
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
@@ -77,12 +85,149 @@ vi.mock('@/api/ragDocuments', () => ({
|
|||||||
saveRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
saveRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||||
deleteRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
deleteRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||||
batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
|
batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
|
||||||
parseRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
|
chunkRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||||
|
retryParseRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('RagDocumentsPage', () => {
|
describe('RagDocumentsPage', () => {
|
||||||
it('loads documents from query api', async () => {
|
beforeEach(() => {
|
||||||
routeQuery.storeId = undefined;
|
routeQuery.storeId = undefined;
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
vi.mocked(queryRagDocuments).mockReset();
|
||||||
|
vi.mocked(queryRagDocuments).mockImplementation((query?: { storeId?: string }) => {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: '11',
|
||||||
|
storeId: '1',
|
||||||
|
attachmentId: '101',
|
||||||
|
documentTitle: '产品制度总则',
|
||||||
|
documentSummary: '制度摘要',
|
||||||
|
parseStatus: 'UPLOADED',
|
||||||
|
indexStatus: 'PENDING',
|
||||||
|
enabled: true,
|
||||||
|
remark: '制度文档',
|
||||||
|
createTime: '2026-05-21 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '22',
|
||||||
|
storeId: '2',
|
||||||
|
attachmentId: '202',
|
||||||
|
documentTitle: 'FAQ 手册',
|
||||||
|
documentSummary: 'FAQ 摘要',
|
||||||
|
parseStatus: 'PARSED',
|
||||||
|
indexStatus: 'INDEXED',
|
||||||
|
enabled: false,
|
||||||
|
remark: '常见问题',
|
||||||
|
createTime: '2026-05-21 11:00:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const data = query?.storeId ? rows.filter((row) => row.storeId === query.storeId) : rows;
|
||||||
|
return Promise.resolve({ resultcode: '0', message: null, data });
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(retryParseRagDocuments).mockReset();
|
||||||
|
vi.mocked(retryParseRagDocuments).mockResolvedValue({ resultcode: '0', message: null, data: [] });
|
||||||
|
|
||||||
|
vi.mocked(chunkRagDocuments).mockReset();
|
||||||
|
vi.mocked(chunkRagDocuments).mockResolvedValue({ resultcode: '0', message: null, data: true });
|
||||||
|
|
||||||
|
vi.mocked(getRagDocumentById).mockReset();
|
||||||
|
vi.mocked(getRagDocumentById).mockImplementation((id: string) =>
|
||||||
|
Promise.resolve({
|
||||||
|
resultcode: '0',
|
||||||
|
message: null,
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
storeId: '2',
|
||||||
|
attachmentId: '202',
|
||||||
|
documentTitle: 'FAQ 手册',
|
||||||
|
documentSummary: 'FAQ 摘要',
|
||||||
|
enabled: false,
|
||||||
|
remark: '常见问题',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps polling document status after retry parse is submitted', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.mocked(queryRagDocuments)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
resultcode: '0',
|
||||||
|
message: null,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '33',
|
||||||
|
storeId: '1',
|
||||||
|
attachmentId: '303',
|
||||||
|
documentTitle: '失败文档',
|
||||||
|
documentSummary: '失败摘要',
|
||||||
|
parseStatus: 'FAILED',
|
||||||
|
indexStatus: 'PENDING',
|
||||||
|
enabled: true,
|
||||||
|
remark: '失败',
|
||||||
|
createTime: '2026-05-21 11:00:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
resultcode: '0',
|
||||||
|
message: null,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '33',
|
||||||
|
storeId: '1',
|
||||||
|
attachmentId: '303',
|
||||||
|
documentTitle: '失败文档',
|
||||||
|
documentSummary: '失败摘要',
|
||||||
|
parseStatus: 'PARSING',
|
||||||
|
indexStatus: 'PENDING',
|
||||||
|
enabled: true,
|
||||||
|
remark: '失败',
|
||||||
|
createTime: '2026-05-21 11:00:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValue({
|
||||||
|
resultcode: '0',
|
||||||
|
message: null,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '33',
|
||||||
|
storeId: '1',
|
||||||
|
attachmentId: '303',
|
||||||
|
documentTitle: '失败文档',
|
||||||
|
documentSummary: '失败摘要',
|
||||||
|
parseStatus: 'PARSED',
|
||||||
|
indexStatus: 'PENDING',
|
||||||
|
enabled: true,
|
||||||
|
remark: '失败',
|
||||||
|
createTime: '2026-05-21 11:00:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.get('[data-test="doc-retry-parse-33"]').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(retryParseRagDocuments).toHaveBeenCalledWith({ documentIds: ['33'] });
|
||||||
|
expect(vi.mocked(queryRagDocuments)).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads documents from query api', async () => {
|
||||||
const wrapper = mount(RagDocumentsPage, {
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [ElementPlus],
|
plugins: [ElementPlus],
|
||||||
@@ -98,7 +243,6 @@ describe('RagDocumentsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders document filters as a form-style query bar', async () => {
|
it('renders document filters as a form-style query bar', async () => {
|
||||||
routeQuery.storeId = undefined;
|
|
||||||
const wrapper = mount(RagDocumentsPage, {
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [ElementPlus],
|
plugins: [ElementPlus],
|
||||||
@@ -130,7 +274,6 @@ describe('RagDocumentsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads backend detail when editing a row', async () => {
|
it('loads backend detail when editing a row', async () => {
|
||||||
routeQuery.storeId = undefined;
|
|
||||||
const wrapper = mount(RagDocumentsPage, {
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [ElementPlus],
|
plugins: [ElementPlus],
|
||||||
@@ -144,8 +287,7 @@ describe('RagDocumentsPage', () => {
|
|||||||
expect(getRagDocumentById).toHaveBeenCalledWith('22');
|
expect(getRagDocumentById).toHaveBeenCalledWith('22');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens parse dialog with chunk strategy options from row action', async () => {
|
it('opens chunk dialog with chunk strategy options from row action', async () => {
|
||||||
routeQuery.storeId = undefined;
|
|
||||||
const wrapper = mount(RagDocumentsPage, {
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [ElementPlus],
|
plugins: [ElementPlus],
|
||||||
@@ -153,17 +295,16 @@ describe('RagDocumentsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
await wrapper.get('[data-test="doc-parse-11"]').trigger('click');
|
await wrapper.get('[data-test="doc-chunk-22"]').trigger('click');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(wrapper.find('[data-test="document-parse-dialog"]').exists()).toBe(true);
|
expect(wrapper.find('[data-test="document-chunk-dialog"]').exists()).toBe(true);
|
||||||
expect(wrapper.text()).toContain('固定长度切片');
|
expect(wrapper.text()).toContain('固定长度切片');
|
||||||
expect(wrapper.text()).toContain('按分隔符切片');
|
expect(wrapper.text()).toContain('按分隔符切片');
|
||||||
expect(wrapper.text()).toContain('语义切片');
|
expect(wrapper.text()).toContain('语义切片');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submits parse request with selected chunk strategy', async () => {
|
it('submits chunk request with selected chunk strategy', async () => {
|
||||||
routeQuery.storeId = undefined;
|
|
||||||
const wrapper = mount(RagDocumentsPage, {
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [ElementPlus],
|
plugins: [ElementPlus],
|
||||||
@@ -171,20 +312,52 @@ describe('RagDocumentsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
await wrapper.get('[data-test="doc-parse-11"]').trigger('click');
|
await wrapper.get('[data-test="doc-chunk-22"]').trigger('click');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
await wrapper.get('[data-test="document-parse-submit"]').trigger('click');
|
await wrapper.get('[data-test="document-chunk-submit"]').trigger('click');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(parseRagDocuments).toHaveBeenCalledWith({
|
expect(chunkRagDocuments).toHaveBeenCalledWith({
|
||||||
documentIds: ['11'],
|
documentIds: ['22'],
|
||||||
chunkStrategy: 'FIXED_LENGTH',
|
chunkStrategy: 1,
|
||||||
chunkSize: 800,
|
chunkSize: 800,
|
||||||
chunkOverlap: 120,
|
chunkOverlap: 120,
|
||||||
delimiter: '。',
|
delimiter: '。',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries parse for failed document', async () => {
|
||||||
|
vi.mocked(queryRagDocuments).mockResolvedValueOnce({
|
||||||
|
resultcode: '0',
|
||||||
|
message: null,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '33',
|
||||||
|
storeId: '1',
|
||||||
|
attachmentId: '303',
|
||||||
|
documentTitle: '失败文档',
|
||||||
|
documentSummary: '失败摘要',
|
||||||
|
parseStatus: 'FAILED',
|
||||||
|
indexStatus: 'PENDING',
|
||||||
|
enabled: true,
|
||||||
|
remark: '失败',
|
||||||
|
createTime: '2026-05-21 11:00:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.get('[data-test="doc-retry-parse-33"]').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(retryParseRagDocuments).toHaveBeenCalledWith({ documentIds: ['33'] });
|
||||||
|
});
|
||||||
|
|
||||||
it('renders reusable upload dialog with drag upload area', async () => {
|
it('renders reusable upload dialog with drag upload area', async () => {
|
||||||
routeQuery.storeId = '1';
|
routeQuery.storeId = '1';
|
||||||
const wrapper = mount(RagDocumentsPage, {
|
const wrapper = mount(RagDocumentsPage, {
|
||||||
|
|||||||
749
frontend/src/pages/rag/documents/RagDocumentsPage.vue
Normal file
749
frontend/src/pages/rag/documents/RagDocumentsPage.vue
Normal file
@@ -0,0 +1,749 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Operation, Search, UploadFilled } from '@element-plus/icons-vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
chunkRagDocuments,
|
||||||
|
deleteRagDocument,
|
||||||
|
getRagDocumentById,
|
||||||
|
queryRagDocuments,
|
||||||
|
retryParseRagDocuments,
|
||||||
|
saveRagDocument,
|
||||||
|
RAG_CHUNK_STRATEGY,
|
||||||
|
type RagChunkStrategy,
|
||||||
|
type RagDocument,
|
||||||
|
} from '@/api/ragDocuments';
|
||||||
|
import { queryRagStores, type RagStore } from '@/api/ragStores';
|
||||||
|
import RagChunkTaskBoard from '@/components/rag/chunk/RagChunkTaskBoard.vue';
|
||||||
|
import RagDocumentBatchUploadDialog from '@/components/rag/RagDocumentBatchUploadDialog.vue';
|
||||||
|
import RagFlowOverview from '@/components/rag/document/RagFlowOverview.vue';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const parseSubmitting = ref(false);
|
||||||
|
const retryParsing = ref(false);
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const storeOptions = ref<RagStore[]>([]);
|
||||||
|
const docRows = ref<RagDocument[]>([]);
|
||||||
|
|
||||||
|
const queryForm = reactive({
|
||||||
|
storeId: '',
|
||||||
|
parseStatus: '',
|
||||||
|
indexStatus: '',
|
||||||
|
enabled: '' as string,
|
||||||
|
keyword: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const editDialogVisible = ref(false);
|
||||||
|
const uploadDialogVisible = ref(false);
|
||||||
|
const chunkDialogVisible = ref(false);
|
||||||
|
const selectedDocuments = ref<RagDocument[]>([]);
|
||||||
|
|
||||||
|
const editForm = reactive({
|
||||||
|
id: '',
|
||||||
|
storeId: '',
|
||||||
|
attachmentId: '',
|
||||||
|
documentTitle: '',
|
||||||
|
documentSummary: '',
|
||||||
|
enabled: true,
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunkForm = reactive({
|
||||||
|
documentIds: [] as string[],
|
||||||
|
chunkStrategy: RAG_CHUNK_STRATEGY.FIXED_LENGTH as RagChunkStrategy,
|
||||||
|
chunkSize: 800,
|
||||||
|
chunkOverlap: 120,
|
||||||
|
delimiter: '。',
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunkStrategyOptions: Array<{ label: string; value: RagChunkStrategy; description: string }> = [
|
||||||
|
{ label: '固定长度切片', value: RAG_CHUNK_STRATEGY.FIXED_LENGTH, description: '按指定长度和重叠长度切分通用文本' },
|
||||||
|
{ label: '按段落切片', value: RAG_CHUNK_STRATEGY.PARAGRAPH, description: '按空行、自然段落边界切分' },
|
||||||
|
{ label: '按标题层级切片', value: RAG_CHUNK_STRATEGY.HEADING, description: '按标题和章节层级组织内容' },
|
||||||
|
{ label: '按表格行切片', value: RAG_CHUNK_STRATEGY.TABLE_ROW, description: '适合 Excel 表格和结构化明细数据' },
|
||||||
|
{ label: '按分隔符切片', value: RAG_CHUNK_STRATEGY.DELIMITER, description: '按句号、换行符或自定义分隔符切分' },
|
||||||
|
{ label: '语义切片', value: RAG_CHUNK_STRATEGY.SEMANTIC, description: '后续结合语义边界或模型能力切分' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const kw = queryForm.keyword.trim().toLowerCase();
|
||||||
|
return docRows.value.filter(
|
||||||
|
(row) => {
|
||||||
|
const matchStore = !queryForm.storeId || row.storeId === queryForm.storeId;
|
||||||
|
const matchParseStatus = !queryForm.parseStatus || row.parseStatus === queryForm.parseStatus;
|
||||||
|
const matchIndexStatus = !queryForm.indexStatus || row.indexStatus === queryForm.indexStatus;
|
||||||
|
const matchEnabled = !queryForm.enabled || String(row.enabled ?? false) === queryForm.enabled;
|
||||||
|
const matchKeyword =
|
||||||
|
!kw ||
|
||||||
|
(row.documentTitle && row.documentTitle.toLowerCase().includes(kw)) ||
|
||||||
|
(row.documentSummary && row.documentSummary.toLowerCase().includes(kw)) ||
|
||||||
|
(row.remark && row.remark.toLowerCase().includes(kw));
|
||||||
|
return matchStore && matchParseStatus && matchIndexStatus && matchEnabled && matchKeyword;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsedCount = computed(() => docRows.value.filter((row) => row.parseStatus === 'PARSED').length);
|
||||||
|
const failedCount = computed(() => docRows.value.filter((row) => row.parseStatus === 'FAILED').length);
|
||||||
|
const selectedParsedCount = computed(() => selectedDocuments.value.filter((row) => row.parseStatus === 'PARSED').length);
|
||||||
|
const selectedFailedCount = computed(() => selectedDocuments.value.filter((row) => row.parseStatus === 'FAILED').length);
|
||||||
|
|
||||||
|
async function loadStores() {
|
||||||
|
try {
|
||||||
|
const response = await queryRagStores();
|
||||||
|
storeOptions.value = response.data ?? [];
|
||||||
|
} catch {
|
||||||
|
storeOptions.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDocs() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await queryRagDocuments({
|
||||||
|
...(queryForm.storeId ? { storeId: queryForm.storeId } : {}),
|
||||||
|
...(queryForm.parseStatus ? { parseStatus: queryForm.parseStatus } : {}),
|
||||||
|
...(queryForm.indexStatus ? { indexStatus: queryForm.indexStatus } : {}),
|
||||||
|
...(queryForm.enabled ? { enabled: queryForm.enabled === 'true' } : {}),
|
||||||
|
});
|
||||||
|
docRows.value = response.data ?? [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshParseProgress(documentIds: string[]) {
|
||||||
|
if (documentIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rounds = 0;
|
||||||
|
while (rounds < 6) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
await loadDocs();
|
||||||
|
const targetDocs = docRows.value.filter((row) => documentIds.includes(String(row.id ?? '')));
|
||||||
|
const allDone = targetDocs.every((row) => row.parseStatus === 'PARSED' || row.parseStatus === 'FAILED');
|
||||||
|
if (allDone) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rounds += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRowsParsing(documentIds: string[]) {
|
||||||
|
if (documentIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idSet = new Set(documentIds);
|
||||||
|
docRows.value = docRows.value.map((row) => {
|
||||||
|
if (!idSet.has(String(row.id ?? ''))) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
parseStatus: 'PARSING',
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploaded(documentIds: string[]) {
|
||||||
|
await loadDocs();
|
||||||
|
await refreshParseProgress(documentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
loadDocs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
queryForm.storeId = '';
|
||||||
|
queryForm.parseStatus = '';
|
||||||
|
queryForm.indexStatus = '';
|
||||||
|
queryForm.enabled = '';
|
||||||
|
queryForm.keyword = '';
|
||||||
|
loadDocs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUploadDialog() {
|
||||||
|
if (storeOptions.value.length === 0) {
|
||||||
|
ElMessage.warning('请先创建知识库');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(rows: RagDocument[]) {
|
||||||
|
selectedDocuments.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChunkDialog(rows: RagDocument[]) {
|
||||||
|
const parsedRows = rows.filter((row) => row.parseStatus === 'PARSED');
|
||||||
|
const ids = parsedRows.map((row) => String(row.id ?? '')).filter(Boolean);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
ElMessage.warning('请选择解析完成(PARSED)的文档进行切片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsedRows.length < rows.length) {
|
||||||
|
ElMessage.warning(`已自动跳过 ${rows.length - parsedRows.length} 个未解析完成文档`);
|
||||||
|
}
|
||||||
|
chunkForm.documentIds = ids;
|
||||||
|
chunkForm.chunkStrategy = RAG_CHUNK_STRATEGY.FIXED_LENGTH;
|
||||||
|
chunkForm.chunkSize = 800;
|
||||||
|
chunkForm.chunkOverlap = 120;
|
||||||
|
chunkForm.delimiter = '。';
|
||||||
|
chunkDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchChunkDialog() {
|
||||||
|
openChunkDialog(selectedDocuments.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditDialog(row: RagDocument) {
|
||||||
|
const detail = row.id ? (await getRagDocumentById(String(row.id))).data : row;
|
||||||
|
|
||||||
|
editForm.id = String(detail.id ?? '');
|
||||||
|
editForm.storeId = detail.storeId;
|
||||||
|
editForm.attachmentId = detail.attachmentId ?? '';
|
||||||
|
editForm.documentTitle = detail.documentTitle ?? '';
|
||||||
|
editForm.documentSummary = detail.documentSummary ?? '';
|
||||||
|
editForm.enabled = detail.enabled ?? true;
|
||||||
|
editForm.remark = detail.remark ?? '';
|
||||||
|
editDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit() {
|
||||||
|
if (!editForm.id || !editForm.storeId || !editForm.documentTitle) {
|
||||||
|
ElMessage.warning('请填写文档标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await saveRagDocument({
|
||||||
|
id: editForm.id,
|
||||||
|
storeId: editForm.storeId,
|
||||||
|
attachmentId: editForm.attachmentId || undefined,
|
||||||
|
documentTitle: editForm.documentTitle,
|
||||||
|
documentSummary: editForm.documentSummary || undefined,
|
||||||
|
enabled: editForm.enabled,
|
||||||
|
remark: editForm.remark || undefined,
|
||||||
|
});
|
||||||
|
editDialogVisible.value = false;
|
||||||
|
ElMessage.success('文档信息已更新');
|
||||||
|
await loadDocs();
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDoc(row: RagDocument) {
|
||||||
|
if (!row.id) return;
|
||||||
|
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认删除文档「${row.documentTitle || '未命名'}」?`,
|
||||||
|
'删除确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await deleteRagDocument(String(row.id));
|
||||||
|
ElMessage.success('文档已删除');
|
||||||
|
await loadDocs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEnabled(row: RagDocument) {
|
||||||
|
if (!row.id) return;
|
||||||
|
const newEnabled = !row.enabled;
|
||||||
|
saveRagDocument({
|
||||||
|
id: String(row.id),
|
||||||
|
storeId: row.storeId,
|
||||||
|
documentTitle: row.documentTitle ?? '',
|
||||||
|
enabled: newEnabled,
|
||||||
|
}).then(() => {
|
||||||
|
row.enabled = newEnabled;
|
||||||
|
ElMessage.success(`已${newEnabled ? '启用' : '停用'}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitChunk() {
|
||||||
|
if (chunkForm.documentIds.length === 0) {
|
||||||
|
ElMessage.warning('请选择需要切片的文档');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parseSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await chunkRagDocuments({
|
||||||
|
documentIds: chunkForm.documentIds,
|
||||||
|
chunkStrategy: chunkForm.chunkStrategy,
|
||||||
|
chunkSize: chunkForm.chunkSize,
|
||||||
|
chunkOverlap: chunkForm.chunkOverlap,
|
||||||
|
delimiter: chunkForm.delimiter,
|
||||||
|
});
|
||||||
|
chunkDialogVisible.value = false;
|
||||||
|
ElMessage.success('切片任务已提交');
|
||||||
|
await loadDocs();
|
||||||
|
} finally {
|
||||||
|
parseSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryParseRows(rows: RagDocument[]) {
|
||||||
|
const ids = rows
|
||||||
|
.filter((row) => row.parseStatus === 'FAILED')
|
||||||
|
.map((row) => String(row.id ?? ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
ElMessage.warning('请选择解析失败(FAILED)的文档重试解析');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
retryParsing.value = true;
|
||||||
|
try {
|
||||||
|
await retryParseRagDocuments({ documentIds: ids });
|
||||||
|
markRowsParsing(ids);
|
||||||
|
ElMessage.success('已提交解析重试任务');
|
||||||
|
await loadDocs();
|
||||||
|
await refreshParseProgress(ids);
|
||||||
|
} finally {
|
||||||
|
retryParsing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoreName(storeId: string) {
|
||||||
|
const store = storeOptions.value.find((s) => String(s.id) === storeId);
|
||||||
|
return store ? store.storeName : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status?: string | null) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
UPLOADED: '已上传',
|
||||||
|
PARSING: '解析中',
|
||||||
|
PARSED: '已解析',
|
||||||
|
FAILED: '解析失败',
|
||||||
|
PENDING: '待索引',
|
||||||
|
INDEXING: '索引中',
|
||||||
|
INDEXED: '已索引',
|
||||||
|
};
|
||||||
|
return status ? (map[status] ?? status) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParseStatusType(status?: string | null) {
|
||||||
|
const success = ['PARSED'];
|
||||||
|
const warning = ['UPLOADED', 'PARSING'];
|
||||||
|
const danger = ['FAILED'];
|
||||||
|
if (!status) return 'info';
|
||||||
|
if (success.includes(status)) return 'success';
|
||||||
|
if (warning.includes(status)) return 'warning';
|
||||||
|
if (danger.includes(status)) return 'danger';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndexStatusType(status?: string | null) {
|
||||||
|
const success = ['INDEXED'];
|
||||||
|
const warning = ['PENDING', 'INDEXING'];
|
||||||
|
const danger = ['FAILED'];
|
||||||
|
if (!status) return 'info';
|
||||||
|
if (success.includes(status)) return 'success';
|
||||||
|
if (warning.includes(status)) return 'warning';
|
||||||
|
if (danger.includes(status)) return 'danger';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const routeStoreId = route.query.storeId;
|
||||||
|
if (typeof routeStoreId === 'string') {
|
||||||
|
queryForm.storeId = routeStoreId;
|
||||||
|
}
|
||||||
|
loadStores();
|
||||||
|
loadDocs();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-panel rag-doc-page">
|
||||||
|
<div class="page-panel__header">
|
||||||
|
<h2>知识文档</h2>
|
||||||
|
<span>上传自动解析,切片手动触发</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RagFlowOverview :documents="docRows" />
|
||||||
|
<div class="task-board-wrap">
|
||||||
|
<RagChunkTaskBoard :total="docRows.length" :parsed="parsedCount" :failed="failedCount" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="document-query-bar" data-test="document-query-bar">
|
||||||
|
<el-form class="document-query-form" data-test="document-query-form" inline>
|
||||||
|
<el-form-item label="知识库">
|
||||||
|
<el-select
|
||||||
|
v-model="queryForm.storeId"
|
||||||
|
data-test="doc-store-filter"
|
||||||
|
placeholder="请选择"
|
||||||
|
clearable
|
||||||
|
class="query-control query-control--select"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="store in storeOptions"
|
||||||
|
:key="String(store.id)"
|
||||||
|
:label="store.storeName"
|
||||||
|
:value="String(store.id)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="解析状态">
|
||||||
|
<el-select
|
||||||
|
v-model="queryForm.parseStatus"
|
||||||
|
data-test="doc-parse-filter"
|
||||||
|
placeholder="请选择"
|
||||||
|
clearable
|
||||||
|
class="query-control query-control--select"
|
||||||
|
>
|
||||||
|
<el-option label="已上传" value="UPLOADED" />
|
||||||
|
<el-option label="解析中" value="PARSING" />
|
||||||
|
<el-option label="已解析" value="PARSED" />
|
||||||
|
<el-option label="解析失败" value="FAILED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="索引状态">
|
||||||
|
<el-select
|
||||||
|
v-model="queryForm.indexStatus"
|
||||||
|
data-test="doc-index-filter"
|
||||||
|
placeholder="请选择"
|
||||||
|
clearable
|
||||||
|
class="query-control query-control--select"
|
||||||
|
>
|
||||||
|
<el-option label="待索引" value="PENDING" />
|
||||||
|
<el-option label="索引中" value="INDEXING" />
|
||||||
|
<el-option label="已索引" value="INDEXED" />
|
||||||
|
<el-option label="索引失败" value="FAILED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用状态">
|
||||||
|
<el-select
|
||||||
|
v-model="queryForm.enabled"
|
||||||
|
data-test="doc-enabled-filter"
|
||||||
|
placeholder="请选择"
|
||||||
|
clearable
|
||||||
|
class="query-control query-control--select"
|
||||||
|
>
|
||||||
|
<el-option label="启用" value="true" />
|
||||||
|
<el-option label="停用" value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="queryForm.keyword"
|
||||||
|
data-test="doc-keyword-input"
|
||||||
|
placeholder="搜索标题/摘要/备注"
|
||||||
|
clearable
|
||||||
|
class="query-control query-control--keyword"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="document-query-form__actions">
|
||||||
|
<el-button data-test="doc-search" type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
<el-button
|
||||||
|
data-test="open-batch-chunk"
|
||||||
|
:icon="Operation"
|
||||||
|
:disabled="selectedParsedCount === 0"
|
||||||
|
@click="openBatchChunkDialog"
|
||||||
|
>
|
||||||
|
批量切片({{ selectedParsedCount }})
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
data-test="retry-batch-parse"
|
||||||
|
:loading="retryParsing"
|
||||||
|
:disabled="selectedFailedCount === 0"
|
||||||
|
@click="retryParseRows(selectedDocuments)"
|
||||||
|
>
|
||||||
|
重试解析({{ selectedFailedCount }})
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
data-test="open-doc-upload"
|
||||||
|
type="primary"
|
||||||
|
:icon="UploadFilled"
|
||||||
|
@click="openUploadDialog"
|
||||||
|
>
|
||||||
|
批量上传
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="filteredRows"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
style="width: 100%"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="48" align="center" />
|
||||||
|
<el-table-column type="index" label="编号" width="70" align="center" />
|
||||||
|
<el-table-column prop="documentTitle" label="文档标题" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="所属知识库" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getStoreName(row.storeId) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="解析状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getParseStatusType(row.parseStatus)" size="small">
|
||||||
|
{{ getStatusLabel(row.parseStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="索引状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getIndexStatusType(row.indexStatus)" size="small">
|
||||||
|
{{ getStatusLabel(row.indexStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="启用" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
:model-value="row.enabled ?? false"
|
||||||
|
size="small"
|
||||||
|
@change="toggleEnabled(row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="documentSummary" label="摘要" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
:data-test="`doc-chunk-${row.id}`"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:disabled="row.parseStatus !== 'PARSED'"
|
||||||
|
@click="openChunkDialog([row])"
|
||||||
|
>
|
||||||
|
切片
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:data-test="`doc-retry-parse-${row.id}`"
|
||||||
|
link
|
||||||
|
type="warning"
|
||||||
|
:disabled="row.parseStatus !== 'FAILED'"
|
||||||
|
@click="retryParseRows([row])"
|
||||||
|
>
|
||||||
|
重试解析
|
||||||
|
</el-button>
|
||||||
|
<el-button :data-test="`doc-edit-${row.id}`" link type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="removeDoc(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-empty v-if="!loading && filteredRows.length === 0" description="暂无知识文档" />
|
||||||
|
|
||||||
|
<RagDocumentBatchUploadDialog
|
||||||
|
v-model="uploadDialogVisible"
|
||||||
|
:stores="storeOptions"
|
||||||
|
:locked-store-id="queryForm.storeId || null"
|
||||||
|
@uploaded="handleUploaded"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="chunkDialogVisible"
|
||||||
|
data-test="document-chunk-dialog"
|
||||||
|
title="切片配置"
|
||||||
|
width="620px"
|
||||||
|
>
|
||||||
|
<el-form :model="chunkForm" label-width="112px">
|
||||||
|
<el-form-item label="文档数量">
|
||||||
|
<el-tag>{{ chunkForm.documentIds.length }} 个文档</el-tag>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="切片方式">
|
||||||
|
<el-radio-group v-model="chunkForm.chunkStrategy" class="chunk-strategy-group">
|
||||||
|
<el-radio
|
||||||
|
v-for="strategy in chunkStrategyOptions"
|
||||||
|
:key="strategy.value"
|
||||||
|
:value="strategy.value"
|
||||||
|
class="chunk-strategy-option"
|
||||||
|
>
|
||||||
|
<span class="chunk-strategy-option__label">{{ strategy.label }}</span>
|
||||||
|
<small>{{ strategy.description }}</small>
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="chunkForm.chunkStrategy === RAG_CHUNK_STRATEGY.FIXED_LENGTH" label="切片长度">
|
||||||
|
<el-input-number v-model="chunkForm.chunkSize" :min="100" :max="4000" :step="100" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="chunkForm.chunkStrategy === RAG_CHUNK_STRATEGY.FIXED_LENGTH" label="重叠长度">
|
||||||
|
<el-input-number v-model="chunkForm.chunkOverlap" :min="0" :max="1000" :step="20" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="chunkForm.chunkStrategy === RAG_CHUNK_STRATEGY.DELIMITER" label="分隔符">
|
||||||
|
<el-input v-model="chunkForm.delimiter" maxlength="20" placeholder="如 。、换行符或自定义符号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="chunkDialogVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
data-test="document-chunk-submit"
|
||||||
|
type="primary"
|
||||||
|
:loading="parseSubmitting"
|
||||||
|
@click="submitChunk"
|
||||||
|
>
|
||||||
|
开始切片
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="editDialogVisible" title="编辑文档" width="560px">
|
||||||
|
<el-form :model="editForm" label-width="96px">
|
||||||
|
<el-form-item label="文档标题" required>
|
||||||
|
<el-input v-model="editForm.documentTitle" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="文档摘要">
|
||||||
|
<el-input v-model="editForm.documentSummary" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="editForm.enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitEdit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rag-doc-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-wrap {
|
||||||
|
padding: 10px 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-bar {
|
||||||
|
padding: 18px 28px 17px;
|
||||||
|
border-bottom: 1px solid #e8edf5;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form :deep(.el-form-item) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form :deep(.el-form-item__label) {
|
||||||
|
height: 38px;
|
||||||
|
padding-right: 8px;
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form :deep(.el-input__wrapper),
|
||||||
|
.document-query-form :deep(.el-select__wrapper) {
|
||||||
|
min-height: 38px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 0 1px #d8dee9 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form :deep(.el-input__wrapper:hover),
|
||||||
|
.document-query-form :deep(.el-select__wrapper:hover) {
|
||||||
|
box-shadow: 0 0 0 1px #b9c6d8 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-control--select {
|
||||||
|
width: 168px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-control--keyword {
|
||||||
|
width: 225px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form__actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form__actions :deep(.el-form-item__content) {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-option {
|
||||||
|
align-items: flex-start;
|
||||||
|
height: auto;
|
||||||
|
margin-right: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d8dee9;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-option :deep(.el-radio__label) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-option__label {
|
||||||
|
color: #303133;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-option small {
|
||||||
|
color: #7a8599;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.task-board-wrap {
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-bar {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-control--select,
|
||||||
|
.query-control--keyword {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form__actions {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-query-form__actions :deep(.el-form-item__content) {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-strategy-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
96
frontend/src/pages/rag/tasks/RagChunkTasksPage.vue
Normal file
96
frontend/src/pages/rag/tasks/RagChunkTasksPage.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { queryRagDocuments } from '@/api/ragDocuments';
|
||||||
|
import RagChunkTaskBoard from '@/components/rag/chunk/RagChunkTaskBoard.vue';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const docs = ref<any[]>([]);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const failedDocs = computed(() => docs.value.filter((row) => row.parseStatus === 'FAILED'));
|
||||||
|
const parsedDocs = computed(() => docs.value.filter((row) => row.parseStatus === 'PARSED'));
|
||||||
|
|
||||||
|
async function loadDocs() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await queryRagDocuments();
|
||||||
|
docs.value = response.data ?? [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadDocs);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-panel chunk-task-page">
|
||||||
|
<div class="page-panel__header">
|
||||||
|
<h2>切片任务</h2>
|
||||||
|
<span>聚焦可切片文档与异常解析文档</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-board-wrap">
|
||||||
|
<RagChunkTaskBoard :total="docs.length" :parsed="parsedDocs.length" :failed="failedDocs.length" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-actions">
|
||||||
|
<el-button type="primary" @click="router.push('/rag/documents')">去执行切片</el-button>
|
||||||
|
<el-button :loading="loading" @click="loadDocs">刷新任务视图</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-list">
|
||||||
|
<h3>解析失败文档</h3>
|
||||||
|
<el-empty v-if="!loading && failedDocs.length === 0" description="当前没有失败文档" />
|
||||||
|
<el-table v-else :data="failedDocs" border>
|
||||||
|
<el-table-column prop="documentTitle" label="文档标题" min-width="220" />
|
||||||
|
<el-table-column prop="parseStatus" label="解析状态" width="120" />
|
||||||
|
<el-table-column prop="errorMessage" label="失败原因" min-width="280" />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chunk-task-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-wrap {
|
||||||
|
padding: 10px 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
padding: 0 28px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.task-board-wrap {
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
padding: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
76
frontend/src/pages/rag/workbench/RagWorkbenchPage.vue
Normal file
76
frontend/src/pages/rag/workbench/RagWorkbenchPage.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { queryRagDocuments } from '@/api/ragDocuments';
|
||||||
|
import RagChunkTaskBoard from '@/components/rag/chunk/RagChunkTaskBoard.vue';
|
||||||
|
import RagFlowOverview from '@/components/rag/document/RagFlowOverview.vue';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const docs = ref<any[]>([]);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const parsedCount = () => docs.value.filter((row) => row.parseStatus === 'PARSED').length;
|
||||||
|
const failedCount = () => docs.value.filter((row) => row.parseStatus === 'FAILED').length;
|
||||||
|
|
||||||
|
async function loadDocs() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await queryRagDocuments();
|
||||||
|
docs.value = response.data ?? [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadDocs);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-panel rag-workbench">
|
||||||
|
<div class="page-panel__header">
|
||||||
|
<h2>RAG 工作台</h2>
|
||||||
|
<span>上传自动解析,切片手动触发</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RagFlowOverview :documents="docs" />
|
||||||
|
|
||||||
|
<div class="workbench-board">
|
||||||
|
<RagChunkTaskBoard :total="docs.length" :parsed="parsedCount()" :failed="failedCount()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="workbench-actions">
|
||||||
|
<el-button type="primary" @click="router.push('/rag/documents')">进入文档管理</el-button>
|
||||||
|
<el-button @click="router.push('/rag/tasks/chunk')">查看切片任务</el-button>
|
||||||
|
<el-button :loading="loading" @click="loadDocs">刷新概览</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rag-workbench {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-board {
|
||||||
|
padding: 10px 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px 28px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.workbench-board {
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-actions {
|
||||||
|
padding: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="page-panel">
|
|
||||||
<div class="page-panel__header">
|
|
||||||
<h2>附件管理</h2>
|
|
||||||
<span>Files</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -7,11 +7,11 @@ describe('router', () => {
|
|||||||
const paths = routes.map((route) => route.path);
|
const paths = routes.map((route) => route.path);
|
||||||
|
|
||||||
expect(paths).toContain('/');
|
expect(paths).toContain('/');
|
||||||
expect(paths).toContain('/dashboard');
|
|
||||||
expect(paths).toContain('/system/enums');
|
|
||||||
expect(paths).toContain('/system/attachments');
|
|
||||||
expect(paths).toContain('/rag/stores');
|
expect(paths).toContain('/rag/stores');
|
||||||
|
expect(paths).toContain('/rag/workbench');
|
||||||
expect(paths).toContain('/rag/documents');
|
expect(paths).toContain('/rag/documents');
|
||||||
|
expect(paths).toContain('/rag/tasks/chunk');
|
||||||
|
expect(paths).toContain('/system/enums');
|
||||||
expect(paths).toContain('/:pathMatch(.*)*');
|
expect(paths).toContain('/:pathMatch(.*)*');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
import type { RouteRecordRaw } from 'vue-router';
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
|
||||||
import DashboardPage from '@/pages/dashboard/DashboardPage.vue';
|
|
||||||
import NotFoundPage from '@/pages/common/NotFoundPage.vue';
|
import NotFoundPage from '@/pages/common/NotFoundPage.vue';
|
||||||
import RagDocumentsPage from '@/pages/rag/RagDocumentsPage.vue';
|
import RagDocumentsPage from '@/pages/rag/RagDocumentsPage.vue';
|
||||||
import RagStoresPage from '@/pages/rag/RagStoresPage.vue';
|
import RagStoresPage from '@/pages/rag/RagStoresPage.vue';
|
||||||
import SystemAttachmentsPage from '@/pages/system/SystemAttachmentsPage.vue';
|
import RagChunkTasksPage from '@/pages/rag/tasks/RagChunkTasksPage.vue';
|
||||||
|
import RagWorkbenchPage from '@/pages/rag/workbench/RagWorkbenchPage.vue';
|
||||||
import SystemEnumsPage from '@/pages/system/SystemEnumsPage.vue';
|
import SystemEnumsPage from '@/pages/system/SystemEnumsPage.vue';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
|
||||||
export const routes: RouteRecordRaw[] = [
|
export const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/dashboard',
|
redirect: '/rag/workbench',
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/dashboard',
|
|
||||||
name: 'dashboard',
|
|
||||||
component: DashboardPage,
|
|
||||||
meta: { title: '工作台' },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/system/enums',
|
path: '/system/enums',
|
||||||
@@ -26,24 +20,30 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
component: SystemEnumsPage,
|
component: SystemEnumsPage,
|
||||||
meta: { title: '系统枚举' },
|
meta: { title: '系统枚举' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/system/attachments',
|
|
||||||
name: 'system-attachments',
|
|
||||||
component: SystemAttachmentsPage,
|
|
||||||
meta: { title: '附件管理' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/rag/stores',
|
path: '/rag/stores',
|
||||||
name: 'rag-stores',
|
name: 'rag-stores',
|
||||||
component: RagStoresPage,
|
component: RagStoresPage,
|
||||||
meta: { title: '知识库' },
|
meta: { title: '知识库' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/rag/workbench',
|
||||||
|
name: 'rag-workbench',
|
||||||
|
component: RagWorkbenchPage,
|
||||||
|
meta: { title: 'RAG工作台' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/rag/documents',
|
path: '/rag/documents',
|
||||||
name: 'rag-documents',
|
name: 'rag-documents',
|
||||||
component: RagDocumentsPage,
|
component: RagDocumentsPage,
|
||||||
meta: { title: '知识文档' },
|
meta: { title: '知识文档' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/rag/tasks/chunk',
|
||||||
|
name: 'rag-chunk-tasks',
|
||||||
|
component: RagChunkTasksPage,
|
||||||
|
meta: { title: '切片任务' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'not-found',
|
name: 'not-found',
|
||||||
@@ -55,42 +55,42 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
const routerRoutes: RouteRecordRaw[] = [
|
const routerRoutes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/dashboard',
|
redirect: '/rag/workbench',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: AdminLayout,
|
component: AdminLayout,
|
||||||
children: [
|
children: [
|
||||||
{
|
|
||||||
path: 'dashboard',
|
|
||||||
name: 'dashboard',
|
|
||||||
component: DashboardPage,
|
|
||||||
meta: { title: '工作台' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'system/enums',
|
path: 'system/enums',
|
||||||
name: 'system-enums',
|
name: 'system-enums',
|
||||||
component: SystemEnumsPage,
|
component: SystemEnumsPage,
|
||||||
meta: { title: '系统枚举' },
|
meta: { title: '系统枚举' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'system/attachments',
|
|
||||||
name: 'system-attachments',
|
|
||||||
component: SystemAttachmentsPage,
|
|
||||||
meta: { title: '附件管理' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'rag/stores',
|
path: 'rag/stores',
|
||||||
name: 'rag-stores',
|
name: 'rag-stores',
|
||||||
component: RagStoresPage,
|
component: RagStoresPage,
|
||||||
meta: { title: '知识库' },
|
meta: { title: '知识库' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'rag/workbench',
|
||||||
|
name: 'rag-workbench',
|
||||||
|
component: RagWorkbenchPage,
|
||||||
|
meta: { title: 'RAG工作台' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'rag/documents',
|
path: 'rag/documents',
|
||||||
name: 'rag-documents',
|
name: 'rag-documents',
|
||||||
component: RagDocumentsPage,
|
component: RagDocumentsPage,
|
||||||
meta: { title: '知识文档' },
|
meta: { title: '知识文档' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'rag/tasks/chunk',
|
||||||
|
name: 'rag-chunk-tasks',
|
||||||
|
component: RagChunkTasksPage,
|
||||||
|
meta: { title: '切片任务' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -193,10 +193,29 @@
|
|||||||
|
|
||||||
请求体:
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"documentIds": [1, 2]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前行为:
|
||||||
|
|
||||||
|
- 根据附件后缀或 content type 选择 Tika 解析器
|
||||||
|
- 解析结果写入 `rag_document_parse_result`
|
||||||
|
- 解析成功后更新 `parseStatus=PARSED`
|
||||||
|
- 解析失败后更新 `parseStatus=FAILED` 和 `errorMessage`
|
||||||
|
|
||||||
|
### 3.8 批量切片知识文档
|
||||||
|
|
||||||
|
- `POST /api/rag/documents/chunk`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"documentIds": [1, 2],
|
"documentIds": [1, 2],
|
||||||
"chunkStrategy": "FIXED_LENGTH",
|
"chunkStrategy": 1,
|
||||||
"chunkSize": 800,
|
"chunkSize": 800,
|
||||||
"chunkOverlap": 120,
|
"chunkOverlap": 120,
|
||||||
"delimiter": "。"
|
"delimiter": "。"
|
||||||
@@ -205,15 +224,14 @@
|
|||||||
|
|
||||||
当前行为:
|
当前行为:
|
||||||
|
|
||||||
- 校验 `chunkStrategy` 是否属于 `RagChunkStrategyEnum`
|
- `chunkStrategy` 使用 `RagChunkStrategyEnum` 的整型枚举值,例如 `1` 表示固定长度切片,`5` 表示按分隔符切片。
|
||||||
- 根据附件后缀或 content type 选择 Tika 解析器
|
- 只处理已经存在解析快照的文档。
|
||||||
- 解析成功后更新 `parseStatus=PARSED`
|
- 按策略生成 `rag_chunk`,写入前会替换该文档已有切片。
|
||||||
- 解析失败后更新 `parseStatus=FAILED` 和 `errorMessage`
|
- 当前尚未调用 Embedding 模型写入 `rag_chunk_embedding`。
|
||||||
- 当前暂不写入 `rag_chunk`
|
|
||||||
|
|
||||||
## 4. 下一批建议补充的接口
|
## 4. 下一批建议补充的接口
|
||||||
|
|
||||||
当前已有接口能支撑知识库、文档、上传和解析入口。下一批建议聚焦切片、索引和任务化。
|
当前已有接口能支撑知识库、文档、上传、解析和手动切片入口。下一批建议聚焦向量索引、模型配置和任务化。
|
||||||
|
|
||||||
### 4.1 知识库列表查询增强版
|
### 4.1 知识库列表查询增强版
|
||||||
|
|
||||||
@@ -386,8 +404,9 @@
|
|||||||
|
|
||||||
当前知识库页和知识文档页已经接入基础接口。下一步联调顺序建议:
|
当前知识库页和知识文档页已经接入基础接口。下一步联调顺序建议:
|
||||||
|
|
||||||
1. 完成切片入库:
|
1. 完成向量入库:
|
||||||
- `POST /api/rag/documents/parse` 解析后写入 `rag_chunk`
|
- `POST /api/rag/documents/chunk` 生成 `rag_chunk`
|
||||||
|
- 通过模型网关调用 Embedding 并写入 `rag_chunk_embedding`
|
||||||
|
|
||||||
2. 完成索引入口:
|
2. 完成索引入口:
|
||||||
- `POST /api/rag/documents/index`
|
- `POST /api/rag/documents/index`
|
||||||
@@ -408,10 +427,12 @@
|
|||||||
- 批量上传:`/api/rag/documents/batchUpload`
|
- 批量上传:`/api/rag/documents/batchUpload`
|
||||||
- 知识文档列表:`/api/rag/documents/query`
|
- 知识文档列表:`/api/rag/documents/query`
|
||||||
- 批量解析:`/api/rag/documents/parse`
|
- 批量解析:`/api/rag/documents/parse`
|
||||||
|
- 批量切片:`/api/rag/documents/chunk`
|
||||||
|
|
||||||
仍待后端补齐后再联调:
|
仍待后端补齐后再联调:
|
||||||
|
|
||||||
- 重建索引
|
- 重建索引
|
||||||
- 最近任务
|
- 最近任务
|
||||||
|
- 模型服务商与 Embedding 模型配置
|
||||||
- 检索配置
|
- 检索配置
|
||||||
- 检索测试/召回预览
|
- 检索测试/召回预览
|
||||||
|
|||||||
49
script/sql/rag_document_parse_result.sql
Normal file
49
script/sql/rag_document_parse_result.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
DROP TABLE IF EXISTS rag_document_parse_result;
|
||||||
|
|
||||||
|
CREATE TABLE rag_document_parse_result (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
store_id BIGINT NOT NULL,
|
||||||
|
document_id BIGINT NOT NULL,
|
||||||
|
parsed_text TEXT NOT NULL,
|
||||||
|
text_length INTEGER,
|
||||||
|
page_count INTEGER,
|
||||||
|
sheet_count INTEGER,
|
||||||
|
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
content_hash VARCHAR(64),
|
||||||
|
parse_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
create_time TIMESTAMP,
|
||||||
|
update_time TIMESTAMP,
|
||||||
|
remark VARCHAR(500) DEFAULT '',
|
||||||
|
create_by VARCHAR(64),
|
||||||
|
update_by VARCHAR(64),
|
||||||
|
CONSTRAINT uk_rag_parse_result_document UNIQUE (document_id),
|
||||||
|
CONSTRAINT fk_rag_parse_result_store_id FOREIGN KEY (store_id) REFERENCES rag_store (id),
|
||||||
|
CONSTRAINT fk_rag_parse_result_document_id FOREIGN KEY (document_id) REFERENCES rag_document (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_rag_parse_result_store_id ON rag_document_parse_result (store_id);
|
||||||
|
CREATE INDEX idx_rag_parse_result_document_id ON rag_document_parse_result (document_id);
|
||||||
|
CREATE INDEX idx_rag_parse_result_content_hash ON rag_document_parse_result (content_hash);
|
||||||
|
CREATE INDEX idx_rag_parse_result_enabled ON rag_document_parse_result (enabled);
|
||||||
|
CREATE INDEX idx_rag_parse_result_metadata_json ON rag_document_parse_result USING GIN (metadata_json);
|
||||||
|
|
||||||
|
COMMENT ON TABLE rag_document_parse_result IS 'RAG文档解析结果快照表';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.id IS 'ID';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.store_id IS '知识库ID';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.document_id IS '文档ID';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.parsed_text IS '解析文本';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.text_length IS '文本长度';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.page_count IS '页数';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.sheet_count IS '工作表数量';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.metadata_json IS '解析元数据JSON';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.content_hash IS '解析文本哈希';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.parse_version IS '解析版本';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.enabled IS '是否启用';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.version IS '版本';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.create_time IS '创建时间';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.update_time IS '更新时间';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.remark IS '备注';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.create_by IS '创建者';
|
||||||
|
COMMENT ON COLUMN rag_document_parse_result.update_by IS '更新者';
|
||||||
@@ -2,8 +2,10 @@ package com.bruce;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableAsync
|
||||||
public class CommonAgentApplication {
|
public class CommonAgentApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import lombok.Getter;
|
|||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum CommonStatusEnum {
|
public enum CommonStatusEnum implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
DISABLED(0, "禁用"),
|
DISABLED(0, "禁用"),
|
||||||
ENABLED(1, "启用"),
|
ENABLED(1, "启用"),
|
||||||
@@ -14,7 +14,28 @@ public enum CommonStatusEnum {
|
|||||||
COMPLETED(4, "已完成"),
|
COMPLETED(4, "已完成"),
|
||||||
FAILED(5, "失败");
|
FAILED(5, "失败");
|
||||||
|
|
||||||
|
private static final String CATALOG = "common";
|
||||||
|
|
||||||
|
private static final String TYPE = "common_status";
|
||||||
|
|
||||||
|
private static final String REMARK = "通用状态";
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return CATALOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return REMARK;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,33 @@ import lombok.Getter;
|
|||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum EnableStatusEnum {
|
public enum EnableStatusEnum implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
DISABLED(0, "禁用"),
|
DISABLED(0, "禁用"),
|
||||||
ENABLED(1, "启用");
|
ENABLED(1, "启用");
|
||||||
|
|
||||||
|
private static final String CATALOG = "common";
|
||||||
|
|
||||||
|
private static final String TYPE = "enable_status";
|
||||||
|
|
||||||
|
private static final String REMARK = "通用启用状态";
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return CATALOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return REMARK;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.bruce.common.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可同步到 sys_enum 的枚举定义契约。
|
||||||
|
* <p>
|
||||||
|
* 长期固定的结构化文本字段统一通过该契约描述,
|
||||||
|
* 便于前后端传值、数据库初始化和后续协作保持同一事实来源。
|
||||||
|
*/
|
||||||
|
public interface PersistableSysEnumDefinition {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 枚举所属模块目录,例如 common、rag。
|
||||||
|
*/
|
||||||
|
String getCatalog();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 枚举所属类型,同一 catalog/type 视为同一个枚举组。
|
||||||
|
*/
|
||||||
|
String getType();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 落库到 sys_enum.name 的展示名称。
|
||||||
|
*/
|
||||||
|
default String getName() {
|
||||||
|
return getLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 枚举整型值,前后端协议统一传该值。
|
||||||
|
*/
|
||||||
|
Integer getValue();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可选的字符串值,当前大多数业务枚举不使用。
|
||||||
|
*/
|
||||||
|
default String getStrvalue() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序值,默认与枚举值保持一致。
|
||||||
|
*/
|
||||||
|
default Integer getSort() {
|
||||||
|
return getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sys_enum.remark 中的说明文本。
|
||||||
|
*/
|
||||||
|
String getRemark();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 枚举显示文案,通常与 name 保持一致。
|
||||||
|
*/
|
||||||
|
String getLabel();
|
||||||
|
}
|
||||||
@@ -25,4 +25,6 @@ public interface ISysEnumService extends IService<SysEnum> {
|
|||||||
boolean saveOrUpdate(SysEnumSaveRequest request);
|
boolean saveOrUpdate(SysEnumSaveRequest request);
|
||||||
|
|
||||||
boolean batchSave(SysEnumBatchSaveRequest request);
|
boolean batchSave(SysEnumBatchSaveRequest request);
|
||||||
|
|
||||||
|
boolean replaceByCatalogAndType(String catalog, String type, List<SysEnum> items);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.bruce.common.dto.response.SysEnumResponse;
|
|||||||
import com.bruce.common.mapper.SysEnumMapper;
|
import com.bruce.common.mapper.SysEnumMapper;
|
||||||
import com.bruce.common.service.ISysEnumService;
|
import com.bruce.common.service.ISysEnumService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
@@ -137,6 +138,24 @@ public class SysEnumServiceImpl extends ServiceImpl<SysEnumMapper, SysEnum> impl
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public boolean replaceByCatalogAndType(String catalog, String type, List<SysEnum> items) {
|
||||||
|
log.info("SysEnumServiceImpl.replaceByCatalogAndType start, catalog={}, type={}, itemCount={}",
|
||||||
|
catalog, type, items == null ? 0 : items.size());
|
||||||
|
validateReplaceByCatalogAndTypeRequest(catalog, type, items);
|
||||||
|
|
||||||
|
boolean removed = lambdaUpdate()
|
||||||
|
.eq(SysEnum::getCatalog, catalog)
|
||||||
|
.eq(SysEnum::getType, type)
|
||||||
|
.remove();
|
||||||
|
boolean saved = saveBatch(items);
|
||||||
|
boolean result = removed || saved;
|
||||||
|
log.info("SysEnumServiceImpl.replaceByCatalogAndType success, catalog={}, type={}, removed={}, saved={}, result={}",
|
||||||
|
catalog, type, removed, saved, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public void validateBatchSaveRequest(SysEnumBatchSaveRequest request, List<SysEnum> existingEnums) {
|
public void validateBatchSaveRequest(SysEnumBatchSaveRequest request, List<SysEnum> existingEnums) {
|
||||||
log.info("SysEnumServiceImpl.validateBatchSaveRequest start");
|
log.info("SysEnumServiceImpl.validateBatchSaveRequest start");
|
||||||
if (request == null) {
|
if (request == null) {
|
||||||
@@ -183,6 +202,44 @@ public class SysEnumServiceImpl extends ServiceImpl<SysEnumMapper, SysEnum> impl
|
|||||||
request.getCatalog(), request.getType(), requestValues.size(), existingValues.size());
|
request.getCatalog(), request.getType(), requestValues.size(), existingValues.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateReplaceByCatalogAndTypeRequest(String catalog, String type, List<SysEnum> items) {
|
||||||
|
if (!StringUtils.hasText(catalog)) {
|
||||||
|
throw new IllegalArgumentException("模块目录不能为空");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(type)) {
|
||||||
|
throw new IllegalArgumentException("枚举类型不能为空");
|
||||||
|
}
|
||||||
|
if (items == null || items.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("枚举项不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Integer> values = new HashSet<>();
|
||||||
|
Set<Integer> sorts = new HashSet<>();
|
||||||
|
for (SysEnum item : items) {
|
||||||
|
if (item == null) {
|
||||||
|
throw new IllegalArgumentException("枚举项不能为空");
|
||||||
|
}
|
||||||
|
if (!catalog.equals(item.getCatalog()) || !type.equals(item.getType())) {
|
||||||
|
throw new IllegalArgumentException("替换的枚举项 catalog/type 必须与目标分组一致");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(item.getName())) {
|
||||||
|
throw new IllegalArgumentException("枚举名称不能为空");
|
||||||
|
}
|
||||||
|
if (item.getValue() == null) {
|
||||||
|
throw new IllegalArgumentException("枚举整型值不能为空");
|
||||||
|
}
|
||||||
|
if (!values.add(item.getValue())) {
|
||||||
|
throw new IllegalArgumentException("枚举值重复: " + item.getValue());
|
||||||
|
}
|
||||||
|
if (item.getSort() == null) {
|
||||||
|
throw new IllegalArgumentException("枚举排序不能为空");
|
||||||
|
}
|
||||||
|
if (!sorts.add(item.getSort())) {
|
||||||
|
throw new IllegalArgumentException("枚举排序重复: " + item.getSort());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private List<SysEnumResponse> toResponses(List<SysEnum> enums) {
|
private List<SysEnumResponse> toResponses(List<SysEnum> enums) {
|
||||||
return enums.stream()
|
return enums.stream()
|
||||||
.map(SysEnumResponse::fromEntity)
|
.map(SysEnumResponse::fromEntity)
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package com.bruce.rag.controller;
|
|||||||
|
|
||||||
import com.bruce.common.domain.model.RequestResult;
|
import com.bruce.common.domain.model.RequestResult;
|
||||||
import com.bruce.rag.dto.request.RagDocumentBatchUploadRequest;
|
import com.bruce.rag.dto.request.RagDocumentBatchUploadRequest;
|
||||||
|
import com.bruce.rag.dto.request.RagDocumentChunkRequest;
|
||||||
import com.bruce.rag.dto.request.RagDocumentParseRequest;
|
import com.bruce.rag.dto.request.RagDocumentParseRequest;
|
||||||
import com.bruce.rag.dto.request.RagDocumentQueryRequest;
|
import com.bruce.rag.dto.request.RagDocumentQueryRequest;
|
||||||
import com.bruce.rag.dto.request.RagDocumentSaveRequest;
|
import com.bruce.rag.dto.request.RagDocumentSaveRequest;
|
||||||
import com.bruce.rag.dto.response.RagDocumentParseResponse;
|
import com.bruce.rag.dto.response.RagDocumentParseResponse;
|
||||||
import com.bruce.rag.dto.response.RagDocumentResponse;
|
import com.bruce.rag.dto.response.RagDocumentResponse;
|
||||||
import com.bruce.rag.service.IRagDocumentParseService;
|
import com.bruce.rag.service.IRagDocumentParseService;
|
||||||
|
import com.bruce.rag.service.IRagDocumentChunkService;
|
||||||
import com.bruce.rag.service.IRagDocumentService;
|
import com.bruce.rag.service.IRagDocumentService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -35,6 +37,9 @@ public class RagDocumentController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private IRagDocumentParseService ragDocumentParseService;
|
private IRagDocumentParseService ragDocumentParseService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IRagDocumentChunkService ragDocumentChunkService;
|
||||||
|
|
||||||
@Operation(summary = "查询全部知识库文档")
|
@Operation(summary = "查询全部知识库文档")
|
||||||
@PostMapping("/list")
|
@PostMapping("/list")
|
||||||
public RequestResult<List<RagDocumentResponse>> list() {
|
public RequestResult<List<RagDocumentResponse>> list() {
|
||||||
@@ -100,4 +105,22 @@ public class RagDocumentController {
|
|||||||
log.info("RagDocumentController.parse success, count={}", responses.size());
|
log.info("RagDocumentController.parse success, count={}", responses.size());
|
||||||
return RequestResult.success(responses);
|
return RequestResult.success(responses);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "重试解析知识库文档")
|
||||||
|
@PostMapping("/retryParse")
|
||||||
|
public RequestResult<List<RagDocumentParseResponse>> retryParse(@RequestBody RagDocumentParseRequest request) {
|
||||||
|
log.info("RagDocumentController.retryParse start, request={}", request);
|
||||||
|
List<RagDocumentParseResponse> responses = ragDocumentParseService.parse(request);
|
||||||
|
log.info("RagDocumentController.retryParse success, count={}", responses.size());
|
||||||
|
return RequestResult.success(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "按策略异步切片")
|
||||||
|
@PostMapping("/chunk")
|
||||||
|
public RequestResult<Boolean> chunk(@RequestBody RagDocumentChunkRequest request) {
|
||||||
|
log.info("RagDocumentController.chunk start, request={}", request);
|
||||||
|
ragDocumentChunkService.submitChunkTask(request);
|
||||||
|
log.info("RagDocumentController.chunk submitted");
|
||||||
|
return RequestResult.success(Boolean.TRUE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.bruce.rag.dto.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "RAG知识库文档切片请求")
|
||||||
|
public class RagDocumentChunkRequest {
|
||||||
|
|
||||||
|
@Schema(description = "文档ID列表")
|
||||||
|
private List<Long> documentIds;
|
||||||
|
|
||||||
|
@Schema(description = "切片方式枚举值")
|
||||||
|
private Integer chunkStrategy;
|
||||||
|
|
||||||
|
@Schema(description = "切片长度")
|
||||||
|
private Integer chunkSize;
|
||||||
|
|
||||||
|
@Schema(description = "重叠长度")
|
||||||
|
private Integer chunkOverlap;
|
||||||
|
|
||||||
|
@Schema(description = "分隔符")
|
||||||
|
private String delimiter;
|
||||||
|
}
|
||||||
@@ -11,16 +11,4 @@ public class RagDocumentParseRequest {
|
|||||||
|
|
||||||
@Schema(description = "文档ID列表")
|
@Schema(description = "文档ID列表")
|
||||||
private List<Long> documentIds;
|
private List<Long> documentIds;
|
||||||
|
|
||||||
@Schema(description = "切片方式")
|
|
||||||
private String chunkStrategy;
|
|
||||||
|
|
||||||
@Schema(description = "切片长度")
|
|
||||||
private Integer chunkSize;
|
|
||||||
|
|
||||||
@Schema(description = "重叠长度")
|
|
||||||
private Integer chunkOverlap;
|
|
||||||
|
|
||||||
@Schema(description = "分隔符")
|
|
||||||
private String delimiter;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.bruce.rag.entity;
|
|||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import com.bruce.common.domain.model.BaseEntity;
|
import com.bruce.common.domain.model.BaseEntity;
|
||||||
|
import com.bruce.rag.typehandler.PgJsonbStringTypeHandler;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
@@ -11,7 +12,7 @@ import lombok.NoArgsConstructor;
|
|||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
@TableName("rag_chunk")
|
@TableName(value = "rag_chunk", autoResultMap = true)
|
||||||
@Schema(description = "RAG知识切片")
|
@Schema(description = "RAG知识切片")
|
||||||
public class RagChunk extends BaseEntity {
|
public class RagChunk extends BaseEntity {
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ public class RagChunk extends BaseEntity {
|
|||||||
private String vectorId;
|
private String vectorId;
|
||||||
|
|
||||||
@Schema(description = "切片级扩展元数据JSON")
|
@Schema(description = "切片级扩展元数据JSON")
|
||||||
@TableField("metadata_json")
|
@TableField(value = "metadata_json", typeHandler = PgJsonbStringTypeHandler.class)
|
||||||
private String metadataJson;
|
private String metadataJson;
|
||||||
|
|
||||||
@Schema(description = "是否启用")
|
@Schema(description = "是否启用")
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.bruce.rag.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.bruce.common.domain.model.BaseEntity;
|
||||||
|
import com.bruce.rag.typehandler.PgJsonbStringTypeHandler;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName(value = "rag_document_parse_result", autoResultMap = true)
|
||||||
|
@Schema(description = "RAG文档解析结果快照")
|
||||||
|
public class RagDocumentParseResult extends BaseEntity {
|
||||||
|
|
||||||
|
@Schema(description = "知识库ID")
|
||||||
|
@TableField("store_id")
|
||||||
|
private Long storeId;
|
||||||
|
|
||||||
|
@Schema(description = "文档ID")
|
||||||
|
@TableField("document_id")
|
||||||
|
private Long documentId;
|
||||||
|
|
||||||
|
@Schema(description = "解析文本")
|
||||||
|
@TableField("parsed_text")
|
||||||
|
private String parsedText;
|
||||||
|
|
||||||
|
@Schema(description = "文本长度")
|
||||||
|
@TableField("text_length")
|
||||||
|
private Integer textLength;
|
||||||
|
|
||||||
|
@Schema(description = "页数")
|
||||||
|
@TableField("page_count")
|
||||||
|
private Integer pageCount;
|
||||||
|
|
||||||
|
@Schema(description = "工作表数量")
|
||||||
|
@TableField("sheet_count")
|
||||||
|
private Integer sheetCount;
|
||||||
|
|
||||||
|
@Schema(description = "解析元数据JSON")
|
||||||
|
@TableField(value = "metadata_json", typeHandler = PgJsonbStringTypeHandler.class)
|
||||||
|
private String metadataJson;
|
||||||
|
|
||||||
|
@Schema(description = "解析结果哈希")
|
||||||
|
@TableField("content_hash")
|
||||||
|
private String contentHash;
|
||||||
|
|
||||||
|
@Schema(description = "解析版本")
|
||||||
|
@TableField("parse_version")
|
||||||
|
private Integer parseVersion;
|
||||||
|
|
||||||
|
@Schema(description = "是否启用")
|
||||||
|
@TableField("enabled")
|
||||||
|
private Boolean enabled;
|
||||||
|
|
||||||
|
@Schema(description = "备注")
|
||||||
|
@TableField("remark")
|
||||||
|
private String remark;
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.bruce.rag.enums;
|
package com.bruce.rag.enums;
|
||||||
|
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum RagChunkStrategyEnum {
|
public enum RagChunkStrategyEnum implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
FIXED_LENGTH(1, "固定长度切片"),
|
FIXED_LENGTH(1, "固定长度切片"),
|
||||||
PARAGRAPH(2, "按段落切片"),
|
PARAGRAPH(2, "按段落切片"),
|
||||||
@@ -14,7 +17,35 @@ public enum RagChunkStrategyEnum {
|
|||||||
DELIMITER(5, "按分隔符切片"),
|
DELIMITER(5, "按分隔符切片"),
|
||||||
SEMANTIC(6, "语义切片");
|
SEMANTIC(6, "语义切片");
|
||||||
|
|
||||||
|
private static final String CATALOG = "rag";
|
||||||
|
|
||||||
|
private static final String TYPE = "chunk_strategy";
|
||||||
|
|
||||||
|
private static final String REMARK = "RAG文档切片方式";
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
|
public static RagChunkStrategyEnum fromValue(Integer value) {
|
||||||
|
return Arrays.stream(values())
|
||||||
|
.filter(item -> item.getValue().equals(value))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("不支持的切片方式: " + value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return CATALOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return REMARK;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
package com.bruce.rag.enums;
|
package com.bruce.rag.enums;
|
||||||
|
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum RagIndexStatusEnum {
|
public enum RagIndexStatusEnum implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
PENDING(1, "待索引"),
|
PENDING(1, "待索引"),
|
||||||
INDEXING(2, "索引中"),
|
INDEXING(2, "索引中"),
|
||||||
INDEXED(3, "已索引"),
|
INDEXED(3, "已索引"),
|
||||||
FAILED(4, "索引失败");
|
FAILED(4, "索引失败");
|
||||||
|
|
||||||
|
private static final String CATALOG = "rag";
|
||||||
|
|
||||||
|
private static final String TYPE = "index_status";
|
||||||
|
|
||||||
|
private static final String REMARK = "RAG文档索引状态";
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return CATALOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return REMARK;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
package com.bruce.rag.enums;
|
package com.bruce.rag.enums;
|
||||||
|
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum RagParseStatusEnum {
|
public enum RagParseStatusEnum implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
UPLOADED(1, "已上传"),
|
UPLOADED(1, "已上传"),
|
||||||
PARSING(2, "解析中"),
|
PARSING(2, "解析中"),
|
||||||
PARSED(3, "已解析"),
|
PARSED(3, "已解析"),
|
||||||
FAILED(4, "解析失败");
|
FAILED(4, "解析失败");
|
||||||
|
|
||||||
|
private static final String CATALOG = "rag";
|
||||||
|
|
||||||
|
private static final String TYPE = "parse_status";
|
||||||
|
|
||||||
|
private static final String REMARK = "RAG文档解析状态";
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return CATALOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return REMARK;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.bruce.rag.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.bruce.rag.entity.RagDocumentParseResult;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface RagDocumentParseResultMapper extends BaseMapper<RagDocumentParseResult> {
|
||||||
|
}
|
||||||
26
src/main/java/com/bruce/rag/parse/Chunker.java
Normal file
26
src/main/java/com/bruce/rag/parse/Chunker.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package com.bruce.rag.parse;
|
||||||
|
|
||||||
|
import com.bruce.rag.entity.RagChunk;
|
||||||
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切片策略统一接口。
|
||||||
|
* <p>
|
||||||
|
* 这里的职责只有两个:
|
||||||
|
* 1. 告诉工厂自己支持哪一种切片策略
|
||||||
|
* 2. 根据切片命令生成切片结果
|
||||||
|
*/
|
||||||
|
public interface Chunker {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前实现是否支持指定的切片策略。
|
||||||
|
*/
|
||||||
|
boolean supports(RagChunkStrategyEnum strategy);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行切片,返回内存中的切片对象列表。
|
||||||
|
*/
|
||||||
|
List<RagChunk> chunk(RagChunkCommand command);
|
||||||
|
}
|
||||||
32
src/main/java/com/bruce/rag/parse/ChunkerFactory.java
Normal file
32
src/main/java/com/bruce/rag/parse/ChunkerFactory.java
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package com.bruce.rag.parse;
|
||||||
|
|
||||||
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
/**
|
||||||
|
* 切片策略工厂。
|
||||||
|
* <p>
|
||||||
|
* Spring 会把所有实现了 {@link Chunker} 的 Bean 注入进来,
|
||||||
|
* 工厂再根据切片策略挑出对应实现,避免业务层自己写 if-else 或 switch。
|
||||||
|
*/
|
||||||
|
public class ChunkerFactory {
|
||||||
|
|
||||||
|
private final List<Chunker> chunkers;
|
||||||
|
|
||||||
|
public ChunkerFactory(List<Chunker> chunkers) {
|
||||||
|
this.chunkers = chunkers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据切片策略解析出具体的切片器实现。
|
||||||
|
*/
|
||||||
|
public Chunker resolve(RagChunkStrategyEnum strategy) {
|
||||||
|
return chunkers.stream()
|
||||||
|
.filter(chunker -> chunker.supports(strategy))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("不支持的切片方式: " + strategy));
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/main/java/com/bruce/rag/parse/RagChunkCommand.java
Normal file
45
src/main/java/com/bruce/rag/parse/RagChunkCommand.java
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package com.bruce.rag.parse;
|
||||||
|
|
||||||
|
import com.bruce.common.document.parse.DocumentParseResult;
|
||||||
|
import com.bruce.rag.entity.RagDocument;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
/**
|
||||||
|
* 一次切片请求的上下文参数。
|
||||||
|
* <p>
|
||||||
|
* 这里把文档信息、解析结果和切片配置收拢到一个对象里,
|
||||||
|
* 这样切片器接口不会因为参数越来越多而变得难维护。
|
||||||
|
*/
|
||||||
|
public class RagChunkCommand {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前要切片的文档实体。
|
||||||
|
*/
|
||||||
|
private RagDocument document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档解析后的文本结果。
|
||||||
|
*/
|
||||||
|
private DocumentParseResult parseResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切片策略枚举值,通常来自前端请求。
|
||||||
|
*/
|
||||||
|
private Integer chunkStrategy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个切片的目标长度,主要给定长切片使用。
|
||||||
|
*/
|
||||||
|
private Integer chunkSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相邻切片之间的重叠长度,主要给定长切片使用。
|
||||||
|
*/
|
||||||
|
private Integer chunkOverlap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义分隔符,主要给分隔符切片使用。
|
||||||
|
*/
|
||||||
|
private String delimiter;
|
||||||
|
}
|
||||||
68
src/main/java/com/bruce/rag/parse/impl/DelimiterChunker.java
Normal file
68
src/main/java/com/bruce/rag/parse/impl/DelimiterChunker.java
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package com.bruce.rag.parse.impl;
|
||||||
|
|
||||||
|
import com.bruce.common.document.parse.DocumentParseResult;
|
||||||
|
import com.bruce.rag.entity.RagChunk;
|
||||||
|
import com.bruce.rag.entity.RagDocument;
|
||||||
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
import com.bruce.rag.parse.Chunker;
|
||||||
|
import com.bruce.rag.parse.RagChunkCommand;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
/**
|
||||||
|
* 分隔符切片实现。
|
||||||
|
* <p>
|
||||||
|
* 先按外部传入的 delimiter 拆分文本,再过滤空片段,生成顺序切片。
|
||||||
|
*/
|
||||||
|
public class DelimiterChunker implements Chunker {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(RagChunkStrategyEnum strategy) {
|
||||||
|
return RagChunkStrategyEnum.DELIMITER == strategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<RagChunk> chunk(RagChunkCommand command) {
|
||||||
|
String text = extractText(command);
|
||||||
|
String delimiter = command == null ? null : command.getDelimiter();
|
||||||
|
if (!StringUtils.hasText(text) || !StringUtils.hasText(delimiter)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 Pattern.quote 处理正则特殊字符,确保分隔符按字面值切分。
|
||||||
|
String[] parts = text.split(Pattern.quote(delimiter));
|
||||||
|
List<RagChunk> chunks = new ArrayList<>();
|
||||||
|
for (String part : parts) {
|
||||||
|
if (!StringUtils.hasText(part)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunks.add(buildChunk(command.getDocument(), chunks.size(), part.trim()));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractText(RagChunkCommand command) {
|
||||||
|
DocumentParseResult parseResult = command == null ? null : command.getParseResult();
|
||||||
|
return parseResult == null ? null : parseResult.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分隔符切片同样只负责生成基础切片结构,不处理持久化和向量化。
|
||||||
|
*/
|
||||||
|
private RagChunk buildChunk(RagDocument document, int index, String content) {
|
||||||
|
RagChunk chunk = new RagChunk();
|
||||||
|
if (document != null) {
|
||||||
|
chunk.setStoreId(document.getStoreId());
|
||||||
|
chunk.setDocumentId(document.getId());
|
||||||
|
}
|
||||||
|
chunk.setChunkIndex(index);
|
||||||
|
chunk.setChunkContent(content);
|
||||||
|
chunk.setEnabled(Boolean.TRUE);
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.bruce.rag.parse.impl;
|
||||||
|
|
||||||
|
import com.bruce.common.document.parse.DocumentParseResult;
|
||||||
|
import com.bruce.rag.entity.RagChunk;
|
||||||
|
import com.bruce.rag.entity.RagDocument;
|
||||||
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
import com.bruce.rag.parse.Chunker;
|
||||||
|
import com.bruce.rag.parse.RagChunkCommand;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
/**
|
||||||
|
* 定长切片实现。
|
||||||
|
* <p>
|
||||||
|
* 按 chunkSize 顺序截取文本,并结合 chunkOverlap 控制相邻切片的重叠部分。
|
||||||
|
*/
|
||||||
|
public class FixedLengthChunker implements Chunker {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(RagChunkStrategyEnum strategy) {
|
||||||
|
return RagChunkStrategyEnum.FIXED_LENGTH == strategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<RagChunk> chunk(RagChunkCommand command) {
|
||||||
|
String text = extractText(command);
|
||||||
|
if (!StringUtils.hasText(text)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
int chunkSize = resolveChunkSize(command, text.length());
|
||||||
|
int overlap = resolveChunkOverlap(command, chunkSize);
|
||||||
|
// 实际步长等于切片长度减去重叠长度,最小保证为 1,避免死循环。
|
||||||
|
int step = Math.max(1, chunkSize - overlap);
|
||||||
|
List<RagChunk> chunks = new ArrayList<>();
|
||||||
|
for (int start = 0; start < text.length(); start += step) {
|
||||||
|
int end = Math.min(text.length(), start + chunkSize);
|
||||||
|
chunks.add(buildChunk(command.getDocument(), chunks.size(), text.substring(start, end)));
|
||||||
|
if (end >= text.length()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractText(RagChunkCommand command) {
|
||||||
|
DocumentParseResult parseResult = command == null ? null : command.getParseResult();
|
||||||
|
return parseResult == null ? null : parseResult.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当未传 chunkSize 或传入非法值时,退化为整段文本一个切片。
|
||||||
|
*/
|
||||||
|
private int resolveChunkSize(RagChunkCommand command, int textLength) {
|
||||||
|
Integer chunkSize = command == null ? null : command.getChunkSize();
|
||||||
|
if (chunkSize == null || chunkSize <= 0) {
|
||||||
|
return textLength;
|
||||||
|
}
|
||||||
|
return chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* overlap 不能为负,也不能大于等于 chunkSize,否则步长会变成 0 或负数。
|
||||||
|
*/
|
||||||
|
private int resolveChunkOverlap(RagChunkCommand command, int chunkSize) {
|
||||||
|
Integer overlap = command == null ? null : command.getChunkOverlap();
|
||||||
|
if (overlap == null || overlap < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.min(overlap, Math.max(0, chunkSize - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这里只构造最基础的切片对象,后续落库时再补充摘要、向量等扩展字段。
|
||||||
|
*/
|
||||||
|
private RagChunk buildChunk(RagDocument document, int index, String content) {
|
||||||
|
RagChunk chunk = new RagChunk();
|
||||||
|
if (document != null) {
|
||||||
|
chunk.setStoreId(document.getStoreId());
|
||||||
|
chunk.setDocumentId(document.getId());
|
||||||
|
}
|
||||||
|
chunk.setChunkIndex(index);
|
||||||
|
chunk.setChunkContent(content);
|
||||||
|
chunk.setEnabled(Boolean.TRUE);
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.bruce.rag.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface IRagDocumentAutoParseService {
|
||||||
|
|
||||||
|
void parseUploadedDocuments(List<Long> documentIds);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.bruce.rag.service;
|
||||||
|
|
||||||
|
import com.bruce.rag.dto.request.RagDocumentChunkRequest;
|
||||||
|
|
||||||
|
public interface IRagDocumentChunkService {
|
||||||
|
|
||||||
|
void submitChunkTask(RagDocumentChunkRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.bruce.rag.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import com.bruce.common.document.parse.DocumentParseResult;
|
||||||
|
import com.bruce.rag.entity.RagDocumentParseResult;
|
||||||
|
|
||||||
|
public interface IRagDocumentParseResultService extends IService<RagDocumentParseResult> {
|
||||||
|
|
||||||
|
RagDocumentParseResult getByDocumentId(Long documentId);
|
||||||
|
|
||||||
|
void saveSnapshot(Long storeId, Long documentId, DocumentParseResult parseResult);
|
||||||
|
|
||||||
|
DocumentParseResult toParseResult(RagDocumentParseResult snapshot);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.bruce.rag.service;
|
package com.bruce.rag.service;
|
||||||
|
|
||||||
|
import com.bruce.common.document.parse.DocumentParseResult;
|
||||||
import com.bruce.rag.dto.response.RagDocumentParseResponse;
|
import com.bruce.rag.dto.response.RagDocumentParseResponse;
|
||||||
import com.bruce.rag.dto.request.RagDocumentParseRequest;
|
import com.bruce.rag.dto.request.RagDocumentParseRequest;
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ import java.util.List;
|
|||||||
|
|
||||||
public interface IRagDocumentParseService {
|
public interface IRagDocumentParseService {
|
||||||
|
|
||||||
|
DocumentParseResult parseDocumentResult(Long documentId);
|
||||||
|
|
||||||
RagDocumentParseResponse parse(Long documentId);
|
RagDocumentParseResponse parse(Long documentId);
|
||||||
|
|
||||||
List<RagDocumentParseResponse> parse(RagDocumentParseRequest request);
|
List<RagDocumentParseResponse> parse(RagDocumentParseRequest request);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.bruce.rag.service.impl;
|
||||||
|
|
||||||
|
import com.bruce.rag.service.IRagDocumentAutoParseService;
|
||||||
|
import com.bruce.rag.service.IRagDocumentParseService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RagDocumentAutoParseServiceImpl implements IRagDocumentAutoParseService {
|
||||||
|
|
||||||
|
private final IRagDocumentParseService ragDocumentParseService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Async
|
||||||
|
public void parseUploadedDocuments(List<Long> documentIds) {
|
||||||
|
if (documentIds == null || documentIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (Long documentId : documentIds) {
|
||||||
|
if (documentId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ragDocumentParseService.parse(documentId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("RagDocumentAutoParseServiceImpl.parseUploadedDocuments failed, documentId={}, message={}",
|
||||||
|
documentId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.bruce.rag.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.bruce.common.document.parse.DocumentParseResult;
|
||||||
|
import com.bruce.rag.dto.request.RagDocumentChunkRequest;
|
||||||
|
import com.bruce.rag.entity.RagChunk;
|
||||||
|
import com.bruce.rag.entity.RagDocument;
|
||||||
|
import com.bruce.rag.entity.RagDocumentParseResult;
|
||||||
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
import com.bruce.rag.parse.Chunker;
|
||||||
|
import com.bruce.rag.parse.ChunkerFactory;
|
||||||
|
import com.bruce.rag.parse.RagChunkCommand;
|
||||||
|
import com.bruce.rag.service.IRagChunkService;
|
||||||
|
import com.bruce.rag.service.IRagDocumentChunkService;
|
||||||
|
import com.bruce.rag.service.IRagDocumentParseResultService;
|
||||||
|
import com.bruce.rag.service.IRagDocumentService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RagDocumentChunkServiceImpl implements IRagDocumentChunkService {
|
||||||
|
|
||||||
|
private final IRagDocumentService ragDocumentService;
|
||||||
|
|
||||||
|
private final IRagDocumentParseResultService ragDocumentParseResultService;
|
||||||
|
|
||||||
|
private final ChunkerFactory chunkerFactory;
|
||||||
|
|
||||||
|
private final IRagChunkService ragChunkService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Async
|
||||||
|
public void submitChunkTask(RagDocumentChunkRequest request) {
|
||||||
|
validateRequest(request);
|
||||||
|
RagChunkStrategyEnum strategy = RagChunkStrategyEnum.fromValue(request.getChunkStrategy());
|
||||||
|
Chunker chunker = chunkerFactory.resolve(strategy);
|
||||||
|
for (Long documentId : request.getDocumentIds()) {
|
||||||
|
try {
|
||||||
|
RagDocument document = ragDocumentService.getById(documentId);
|
||||||
|
if (document == null) {
|
||||||
|
log.warn("RagDocumentChunkServiceImpl.chunkAsync document not found, documentId={}", documentId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
RagDocumentParseResult snapshot = ragDocumentParseResultService.getByDocumentId(documentId);
|
||||||
|
if (snapshot == null) {
|
||||||
|
throw new IllegalStateException("文档尚未生成解析快照,documentId=" + documentId);
|
||||||
|
}
|
||||||
|
DocumentParseResult parseResult = ragDocumentParseResultService.toParseResult(snapshot);
|
||||||
|
RagChunkCommand command = new RagChunkCommand();
|
||||||
|
command.setDocument(document);
|
||||||
|
command.setParseResult(parseResult);
|
||||||
|
command.setChunkStrategy(request.getChunkStrategy());
|
||||||
|
command.setChunkSize(request.getChunkSize());
|
||||||
|
command.setChunkOverlap(request.getChunkOverlap());
|
||||||
|
command.setDelimiter(request.getDelimiter());
|
||||||
|
List<RagChunk> chunks = chunker.chunk(command);
|
||||||
|
|
||||||
|
ragChunkService.remove(Wrappers.<RagChunk>lambdaQuery()
|
||||||
|
.eq(RagChunk::getDocumentId, documentId));
|
||||||
|
if (!chunks.isEmpty()) {
|
||||||
|
ragChunkService.saveBatch(chunks);
|
||||||
|
}
|
||||||
|
log.info("RagDocumentChunkServiceImpl.chunkAsync success, documentId={}, chunkCount={}",
|
||||||
|
documentId, chunks.size());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("RagDocumentChunkServiceImpl.chunkAsync failed, documentId={}, message={}",
|
||||||
|
documentId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRequest(RagDocumentChunkRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
throw new IllegalArgumentException("切片请求不能为空");
|
||||||
|
}
|
||||||
|
if (request.getDocumentIds() == null || request.getDocumentIds().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("文档ID列表不能为空");
|
||||||
|
}
|
||||||
|
RagChunkStrategyEnum.fromValue(request.getChunkStrategy());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.bruce.rag.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.bruce.common.document.parse.DocumentParseResult;
|
||||||
|
import com.bruce.rag.entity.RagDocumentParseResult;
|
||||||
|
import com.bruce.rag.mapper.RagDocumentParseResultMapper;
|
||||||
|
import com.bruce.rag.service.IRagDocumentParseResultService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.DigestUtils;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import tools.jackson.core.type.TypeReference;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RagDocumentParseResultServiceImpl extends ServiceImpl<RagDocumentParseResultMapper, RagDocumentParseResult>
|
||||||
|
implements IRagDocumentParseResultService {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RagDocumentParseResult getByDocumentId(Long documentId) {
|
||||||
|
if (documentId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getOne(Wrappers.<RagDocumentParseResult>lambdaQuery()
|
||||||
|
.eq(RagDocumentParseResult::getDocumentId, documentId)
|
||||||
|
.last("limit 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveSnapshot(Long storeId, Long documentId, DocumentParseResult parseResult) {
|
||||||
|
if (storeId == null || documentId == null || parseResult == null) {
|
||||||
|
throw new IllegalArgumentException("保存解析快照参数不完整");
|
||||||
|
}
|
||||||
|
RagDocumentParseResult existing = getByDocumentId(documentId);
|
||||||
|
RagDocumentParseResult snapshot = existing == null ? new RagDocumentParseResult() : existing;
|
||||||
|
snapshot.setStoreId(storeId);
|
||||||
|
snapshot.setDocumentId(documentId);
|
||||||
|
snapshot.setParsedText(parseResult.getText());
|
||||||
|
snapshot.setTextLength(parseResult.getTextLength());
|
||||||
|
snapshot.setPageCount(parseResult.getPageCount());
|
||||||
|
snapshot.setSheetCount(parseResult.getSheetCount());
|
||||||
|
snapshot.setMetadataJson(toJson(parseResult.getMetadata()));
|
||||||
|
snapshot.setContentHash(buildHash(parseResult.getText()));
|
||||||
|
snapshot.setParseVersion(resolveNextVersion(existing));
|
||||||
|
snapshot.setEnabled(Boolean.TRUE);
|
||||||
|
if (snapshot.getId() == null) {
|
||||||
|
save(snapshot);
|
||||||
|
} else {
|
||||||
|
updateById(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DocumentParseResult toParseResult(RagDocumentParseResult snapshot) {
|
||||||
|
if (snapshot == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
DocumentParseResult result = new DocumentParseResult();
|
||||||
|
result.setText(snapshot.getParsedText());
|
||||||
|
result.setTextLength(snapshot.getTextLength());
|
||||||
|
result.setPageCount(snapshot.getPageCount());
|
||||||
|
result.setSheetCount(snapshot.getSheetCount());
|
||||||
|
result.setMetadata(fromJson(snapshot.getMetadataJson()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer resolveNextVersion(RagDocumentParseResult existing) {
|
||||||
|
if (existing == null || existing.getParseVersion() == null || existing.getParseVersion() < 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return existing.getParseVersion() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildHash(String text) {
|
||||||
|
if (!StringUtils.hasText(text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return DigestUtils.md5DigestAsHex(text.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJson(Map<String, Object> metadata) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> payload = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||||
|
return objectMapper.writeValueAsString(payload);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("解析元数据序列化失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> fromJson(String metadataJson) {
|
||||||
|
if (!StringUtils.hasText(metadataJson)) {
|
||||||
|
return new LinkedHashMap<>();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(metadataJson, new TypeReference<>() {
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("解析元数据反序列化失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,11 @@ import com.bruce.common.service.ISysAttachmentService;
|
|||||||
import com.bruce.rag.dto.request.RagDocumentParseRequest;
|
import com.bruce.rag.dto.request.RagDocumentParseRequest;
|
||||||
import com.bruce.rag.dto.response.RagDocumentParseResponse;
|
import com.bruce.rag.dto.response.RagDocumentParseResponse;
|
||||||
import com.bruce.rag.entity.RagDocument;
|
import com.bruce.rag.entity.RagDocument;
|
||||||
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
import com.bruce.rag.entity.RagDocumentParseResult;
|
||||||
import com.bruce.rag.enums.RagParseStatusEnum;
|
import com.bruce.rag.enums.RagParseStatusEnum;
|
||||||
|
import com.bruce.rag.mapper.RagDocumentMapper;
|
||||||
import com.bruce.rag.service.IRagDocumentParseService;
|
import com.bruce.rag.service.IRagDocumentParseService;
|
||||||
import com.bruce.rag.service.IRagDocumentService;
|
import com.bruce.rag.service.IRagDocumentParseResultService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -22,17 +23,14 @@ import org.springframework.util.StringUtils;
|
|||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RagDocumentParseServiceImpl implements IRagDocumentParseService {
|
public class RagDocumentParseServiceImpl implements IRagDocumentParseService {
|
||||||
|
|
||||||
private final IRagDocumentService ragDocumentService;
|
private final RagDocumentMapper ragDocumentMapper;
|
||||||
|
|
||||||
private final ISysAttachmentService sysAttachmentService;
|
private final ISysAttachmentService sysAttachmentService;
|
||||||
|
|
||||||
@@ -40,6 +38,8 @@ public class RagDocumentParseServiceImpl implements IRagDocumentParseService {
|
|||||||
|
|
||||||
private final DocumentParserFactory documentParserFactory;
|
private final DocumentParserFactory documentParserFactory;
|
||||||
|
|
||||||
|
private final IRagDocumentParseResultService ragDocumentParseResultService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<RagDocumentParseResponse> parse(RagDocumentParseRequest request) {
|
public List<RagDocumentParseResponse> parse(RagDocumentParseRequest request) {
|
||||||
log.info("RagDocumentParseServiceImpl.parse batch start, request={}", request);
|
log.info("RagDocumentParseServiceImpl.parse batch start, request={}", request);
|
||||||
@@ -51,14 +51,31 @@ public class RagDocumentParseServiceImpl implements IRagDocumentParseService {
|
|||||||
return responses;
|
return responses;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DocumentParseResult parseDocumentResult(Long documentId) {
|
||||||
|
RagDocumentParseResult snapshot = ragDocumentParseResultService.getByDocumentId(documentId);
|
||||||
|
if (snapshot != null) {
|
||||||
|
return ragDocumentParseResultService.toParseResult(snapshot);
|
||||||
|
}
|
||||||
|
return doParse(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RagDocumentParseResponse parse(Long documentId) {
|
public RagDocumentParseResponse parse(Long documentId) {
|
||||||
|
DocumentParseResult result = doParse(documentId);
|
||||||
|
RagDocumentParseResponse response = toResponse(documentId, result);
|
||||||
|
log.info("RagDocumentParseServiceImpl.parse success, documentId={}, textLength={}",
|
||||||
|
documentId, response.getTextLength());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DocumentParseResult doParse(Long documentId) {
|
||||||
log.info("RagDocumentParseServiceImpl.parse start, documentId={}", documentId);
|
log.info("RagDocumentParseServiceImpl.parse start, documentId={}", documentId);
|
||||||
if (documentId == null) {
|
if (documentId == null) {
|
||||||
throw new IllegalArgumentException("文档ID不能为空");
|
throw new IllegalArgumentException("文档ID不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
RagDocument document = ragDocumentService.getById(documentId);
|
RagDocument document = ragDocumentMapper.selectById(documentId);
|
||||||
if (document == null) {
|
if (document == null) {
|
||||||
throw new IllegalArgumentException("文档不存在,ID: " + documentId);
|
throw new IllegalArgumentException("文档不存在,ID: " + documentId);
|
||||||
}
|
}
|
||||||
@@ -76,11 +93,9 @@ public class RagDocumentParseServiceImpl implements IRagDocumentParseService {
|
|||||||
DocumentParseContext context = buildParseContext(document, attachment);
|
DocumentParseContext context = buildParseContext(document, attachment);
|
||||||
DocumentParser parser = documentParserFactory.resolve(context);
|
DocumentParser parser = documentParserFactory.resolve(context);
|
||||||
DocumentParseResult result = parser.parse(context);
|
DocumentParseResult result = parser.parse(context);
|
||||||
|
ragDocumentParseResultService.saveSnapshot(document.getStoreId(), documentId, result);
|
||||||
updateParseStatus(documentId, RagParseStatusEnum.PARSED, null);
|
updateParseStatus(documentId, RagParseStatusEnum.PARSED, null);
|
||||||
RagDocumentParseResponse response = toResponse(documentId, result);
|
return result;
|
||||||
log.info("RagDocumentParseServiceImpl.parse success, documentId={}, textLength={}",
|
|
||||||
documentId, response.getTextLength());
|
|
||||||
return response;
|
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
updateParseStatus(documentId, RagParseStatusEnum.FAILED, e.getMessage());
|
updateParseStatus(documentId, RagParseStatusEnum.FAILED, e.getMessage());
|
||||||
log.warn("RagDocumentParseServiceImpl.parse failed, documentId={}, message={}", documentId, e.getMessage());
|
log.warn("RagDocumentParseServiceImpl.parse failed, documentId={}, message={}", documentId, e.getMessage());
|
||||||
@@ -95,12 +110,6 @@ public class RagDocumentParseServiceImpl implements IRagDocumentParseService {
|
|||||||
if (request.getDocumentIds() == null || request.getDocumentIds().isEmpty()) {
|
if (request.getDocumentIds() == null || request.getDocumentIds().isEmpty()) {
|
||||||
throw new IllegalArgumentException("文档ID列表不能为空");
|
throw new IllegalArgumentException("文档ID列表不能为空");
|
||||||
}
|
}
|
||||||
Set<String> strategies = Arrays.stream(RagChunkStrategyEnum.values())
|
|
||||||
.map(Enum::name)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
if (request.getChunkStrategy() == null || !strategies.contains(request.getChunkStrategy())) {
|
|
||||||
throw new IllegalArgumentException("不支持的切片方式: " + request.getChunkStrategy());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private DocumentParseContext buildParseContext(RagDocument document, SysAttachment attachment) {
|
private DocumentParseContext buildParseContext(RagDocument document, SysAttachment attachment) {
|
||||||
@@ -131,11 +140,19 @@ public class RagDocumentParseServiceImpl implements IRagDocumentParseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void updateParseStatus(Long documentId, RagParseStatusEnum status, String errorMessage) {
|
private void updateParseStatus(Long documentId, RagParseStatusEnum status, String errorMessage) {
|
||||||
|
RagDocument current = ragDocumentMapper.selectById(documentId);
|
||||||
|
if (current == null) {
|
||||||
|
throw new IllegalArgumentException("文档不存在,ID: " + documentId);
|
||||||
|
}
|
||||||
RagDocument update = new RagDocument();
|
RagDocument update = new RagDocument();
|
||||||
update.setId(documentId);
|
update.setId(documentId);
|
||||||
|
update.setVersion(current.getVersion());
|
||||||
update.setParseStatus(status.name());
|
update.setParseStatus(status.name());
|
||||||
update.setErrorMessage(StringUtils.hasText(errorMessage) ? errorMessage : null);
|
update.setErrorMessage(StringUtils.hasText(errorMessage) ? errorMessage : null);
|
||||||
ragDocumentService.updateById(update);
|
boolean updated = ragDocumentMapper.updateById(update) > 0;
|
||||||
|
if (!updated) {
|
||||||
|
throw new IllegalStateException("更新解析状态失败,文档ID: " + documentId + ", 状态: " + status.name());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private RagDocumentParseResponse toResponse(Long documentId, DocumentParseResult result) {
|
private RagDocumentParseResponse toResponse(Long documentId, DocumentParseResult result) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.bruce.rag.entity.RagDocument;
|
|||||||
import com.bruce.rag.enums.RagIndexStatusEnum;
|
import com.bruce.rag.enums.RagIndexStatusEnum;
|
||||||
import com.bruce.rag.enums.RagParseStatusEnum;
|
import com.bruce.rag.enums.RagParseStatusEnum;
|
||||||
import com.bruce.rag.mapper.RagDocumentMapper;
|
import com.bruce.rag.mapper.RagDocumentMapper;
|
||||||
|
import com.bruce.rag.service.IRagDocumentAutoParseService;
|
||||||
import com.bruce.rag.service.IRagDocumentService;
|
import com.bruce.rag.service.IRagDocumentService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -29,6 +30,9 @@ public class RagDocumentServiceImpl extends ServiceImpl<RagDocumentMapper, RagDo
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ISysAttachmentService sysAttachmentService;
|
private ISysAttachmentService sysAttachmentService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IRagDocumentAutoParseService ragDocumentAutoParseService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<RagDocumentResponse> listResponses() {
|
public List<RagDocumentResponse> listResponses() {
|
||||||
log.info("RagDocumentServiceImpl.listResponses start");
|
log.info("RagDocumentServiceImpl.listResponses start");
|
||||||
@@ -166,6 +170,14 @@ public class RagDocumentServiceImpl extends ServiceImpl<RagDocumentMapper, RagDo
|
|||||||
results.add(RagDocumentResponse.fromEntity(document));
|
results.add(RagDocumentResponse.fromEntity(document));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!results.isEmpty()) {
|
||||||
|
List<Long> documentIds = results.stream()
|
||||||
|
.map(RagDocumentResponse::getId)
|
||||||
|
.filter(id -> id != null)
|
||||||
|
.toList();
|
||||||
|
ragDocumentAutoParseService.parseUploadedDocuments(documentIds);
|
||||||
|
}
|
||||||
|
|
||||||
log.info("RagDocumentServiceImpl.batchUpload success, storeId={}, uploaded={}",
|
log.info("RagDocumentServiceImpl.batchUpload success, storeId={}, uploaded={}",
|
||||||
request.getStoreId(), results.size());
|
request.getStoreId(), results.size());
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.bruce.rag.typehandler;
|
||||||
|
|
||||||
|
import org.apache.ibatis.type.BaseTypeHandler;
|
||||||
|
import org.apache.ibatis.type.JdbcType;
|
||||||
|
|
||||||
|
import java.sql.CallableStatement;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
|
||||||
|
public class PgJsonbStringTypeHandler extends BaseTypeHandler<String> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
|
||||||
|
ps.setObject(i, parameter, Types.OTHER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||||
|
return rs.getString(columnName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||||
|
return rs.getString(columnIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||||
|
return cs.getString(columnIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ package com.bruce.common.enumconfig;
|
|||||||
|
|
||||||
import com.bruce.common.enums.CommonStatusEnum;
|
import com.bruce.common.enums.CommonStatusEnum;
|
||||||
import com.bruce.common.enums.EnableStatusEnum;
|
import com.bruce.common.enums.EnableStatusEnum;
|
||||||
import com.bruce.rag.enums.RagIndexStatusEnum;
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
import com.bruce.rag.enums.RagIndexStatusEnum;
|
||||||
import com.bruce.rag.enums.RagParseStatusEnum;
|
import com.bruce.rag.enums.RagParseStatusEnum;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
class EnumDefinitionTests {
|
class EnumDefinitionTests {
|
||||||
|
|
||||||
@@ -46,4 +48,33 @@ class EnumDefinitionTests {
|
|||||||
assertEquals("按分隔符切片", RagChunkStrategyEnum.DELIMITER.getLabel());
|
assertEquals("按分隔符切片", RagChunkStrategyEnum.DELIMITER.getLabel());
|
||||||
assertEquals("语义切片", RagChunkStrategyEnum.SEMANTIC.getLabel());
|
assertEquals("语义切片", RagChunkStrategyEnum.SEMANTIC.getLabel());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enumsShouldExposeStableSysEnumMetadata() {
|
||||||
|
PersistableSysEnumDefinition chunkStrategy = RagChunkStrategyEnum.DELIMITER;
|
||||||
|
PersistableSysEnumDefinition parseStatus = RagParseStatusEnum.PARSED;
|
||||||
|
PersistableSysEnumDefinition enableStatus = EnableStatusEnum.ENABLED;
|
||||||
|
|
||||||
|
assertEquals("rag", chunkStrategy.getCatalog());
|
||||||
|
assertEquals("chunk_strategy", chunkStrategy.getType());
|
||||||
|
assertEquals("按分隔符切片", chunkStrategy.getName());
|
||||||
|
assertEquals(5, chunkStrategy.getValue());
|
||||||
|
assertEquals(5, chunkStrategy.getSort());
|
||||||
|
assertEquals("RAG文档切片方式", chunkStrategy.getRemark());
|
||||||
|
|
||||||
|
assertEquals("rag", parseStatus.getCatalog());
|
||||||
|
assertEquals("parse_status", parseStatus.getType());
|
||||||
|
assertEquals("已解析", parseStatus.getName());
|
||||||
|
|
||||||
|
assertEquals("common", enableStatus.getCatalog());
|
||||||
|
assertEquals("enable_status", enableStatus.getType());
|
||||||
|
assertEquals("启用", enableStatus.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ragChunkStrategyShouldResolveByIntegerValue() {
|
||||||
|
assertEquals(RagChunkStrategyEnum.FIXED_LENGTH, RagChunkStrategyEnum.fromValue(1));
|
||||||
|
assertEquals(RagChunkStrategyEnum.DELIMITER, RagChunkStrategyEnum.fromValue(5));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> RagChunkStrategyEnum.fromValue(999));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,4 +104,12 @@ class SysEnumComponentStructureTests {
|
|||||||
|
|
||||||
assertNotNull(initMethod);
|
assertNotNull(initMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sysEnumServiceShouldExposeReplaceByCatalogAndType() throws NoSuchMethodException {
|
||||||
|
Method replaceMethod = ISysEnumService.class.getMethod("replaceByCatalogAndType", String.class, String.class, List.class);
|
||||||
|
|
||||||
|
assertNotNull(replaceMethod);
|
||||||
|
assertEquals(boolean.class, replaceMethod.getReturnType());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.bruce.common.enumconfig;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.bruce.common.domain.entity.SysEnum;
|
import com.bruce.common.domain.entity.SysEnum;
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
import com.bruce.common.enums.CommonStatusEnum;
|
import com.bruce.common.enums.CommonStatusEnum;
|
||||||
import com.bruce.common.enums.EnableStatusEnum;
|
import com.bruce.common.enums.EnableStatusEnum;
|
||||||
import com.bruce.common.service.ISysEnumService;
|
import com.bruce.common.service.ISysEnumService;
|
||||||
@@ -13,6 +14,8 @@ import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@EnabledIfSystemProperty(named = "runEnumInit", matches = "true")
|
@EnabledIfSystemProperty(named = "runEnumInit", matches = "true")
|
||||||
class SysEnumDataInitTests {
|
class SysEnumDataInitTests {
|
||||||
@@ -22,49 +25,22 @@ class SysEnumDataInitTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void initDefaultEnums() {
|
public void initDefaultEnums() {
|
||||||
saveOrUpdate("common", "enable_status", EnableStatusEnum.DISABLED.getLabel(), EnableStatusEnum.DISABLED.getValue(), 0, "通用启用状态");
|
List<SysEnumDefinitionSyncSupport.EnumGroup> groups = List.of(
|
||||||
saveOrUpdate("common", "enable_status", EnableStatusEnum.ENABLED.getLabel(), EnableStatusEnum.ENABLED.getValue(), 1, "通用启用状态");
|
buildGroup(EnableStatusEnum.values()),
|
||||||
|
buildGroup(CommonStatusEnum.values()),
|
||||||
|
buildGroup(RagParseStatusEnum.values()),
|
||||||
|
buildGroup(RagIndexStatusEnum.values()),
|
||||||
|
buildGroup(RagChunkStrategyEnum.values())
|
||||||
|
);
|
||||||
|
SysEnumDefinitionSyncSupport.validateUniqueGroupKeys(groups);
|
||||||
|
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.DISABLED.getLabel(), CommonStatusEnum.DISABLED.getValue(), 0, "通用状态");
|
for (SysEnumDefinitionSyncSupport.EnumGroup group : groups) {
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.ENABLED.getLabel(), CommonStatusEnum.ENABLED.getValue(), 1, "通用状态");
|
List<SysEnum> rows = SysEnumDefinitionSyncSupport.toEntities(group);
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.DRAFT.getLabel(), CommonStatusEnum.DRAFT.getValue(), 2, "通用状态");
|
sysEnumService.replaceByCatalogAndType(group.catalog(), group.type(), rows);
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.PROCESSING.getLabel(), CommonStatusEnum.PROCESSING.getValue(), 3, "通用状态");
|
}
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.COMPLETED.getLabel(), CommonStatusEnum.COMPLETED.getValue(), 4, "通用状态");
|
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.FAILED.getLabel(), CommonStatusEnum.FAILED.getValue(), 5, "通用状态");
|
|
||||||
|
|
||||||
saveOrUpdate("rag", "parse_status", RagParseStatusEnum.UPLOADED.getLabel(), RagParseStatusEnum.UPLOADED.getValue(), 1, "RAG文档解析状态");
|
|
||||||
saveOrUpdate("rag", "parse_status", RagParseStatusEnum.PARSING.getLabel(), RagParseStatusEnum.PARSING.getValue(), 2, "RAG文档解析状态");
|
|
||||||
saveOrUpdate("rag", "parse_status", RagParseStatusEnum.PARSED.getLabel(), RagParseStatusEnum.PARSED.getValue(), 3, "RAG文档解析状态");
|
|
||||||
saveOrUpdate("rag", "parse_status", RagParseStatusEnum.FAILED.getLabel(), RagParseStatusEnum.FAILED.getValue(), 4, "RAG文档解析状态");
|
|
||||||
|
|
||||||
saveOrUpdate("rag", "index_status", RagIndexStatusEnum.PENDING.getLabel(), RagIndexStatusEnum.PENDING.getValue(), 1, "RAG文档索引状态");
|
|
||||||
saveOrUpdate("rag", "index_status", RagIndexStatusEnum.INDEXING.getLabel(), RagIndexStatusEnum.INDEXING.getValue(), 2, "RAG文档索引状态");
|
|
||||||
saveOrUpdate("rag", "index_status", RagIndexStatusEnum.INDEXED.getLabel(), RagIndexStatusEnum.INDEXED.getValue(), 3, "RAG文档索引状态");
|
|
||||||
saveOrUpdate("rag", "index_status", RagIndexStatusEnum.FAILED.getLabel(), RagIndexStatusEnum.FAILED.getValue(), 4, "RAG文档索引状态");
|
|
||||||
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.FIXED_LENGTH.getLabel(), RagChunkStrategyEnum.FIXED_LENGTH.getValue(), 1, "RAG文档切片方式");
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.PARAGRAPH.getLabel(), RagChunkStrategyEnum.PARAGRAPH.getValue(), 2, "RAG文档切片方式");
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.HEADING.getLabel(), RagChunkStrategyEnum.HEADING.getValue(), 3, "RAG文档切片方式");
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.TABLE_ROW.getLabel(), RagChunkStrategyEnum.TABLE_ROW.getValue(), 4, "RAG文档切片方式");
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.DELIMITER.getLabel(), RagChunkStrategyEnum.DELIMITER.getValue(), 5, "RAG文档切片方式");
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.SEMANTIC.getLabel(), RagChunkStrategyEnum.SEMANTIC.getValue(), 6, "RAG文档切片方式");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveOrUpdate(String catalog, String type, String name, Integer value, Integer sort, String remark) {
|
private SysEnumDefinitionSyncSupport.EnumGroup buildGroup(PersistableSysEnumDefinition[] definitions) {
|
||||||
SysEnum sysEnum = sysEnumService.getOne(new LambdaQueryWrapper<SysEnum>()
|
return SysEnumDefinitionSyncSupport.groupOf(List.of(definitions));
|
||||||
.eq(SysEnum::getCatalog, catalog)
|
|
||||||
.eq(SysEnum::getType, type)
|
|
||||||
.eq(SysEnum::getName, name));
|
|
||||||
if (sysEnum == null) {
|
|
||||||
sysEnum = new SysEnum();
|
|
||||||
}
|
|
||||||
sysEnum.setCatalog(catalog);
|
|
||||||
sysEnum.setType(type);
|
|
||||||
sysEnum.setName(name);
|
|
||||||
sysEnum.setValue(value);
|
|
||||||
sysEnum.setStrvalue(null);
|
|
||||||
sysEnum.setSort(sort);
|
|
||||||
sysEnum.setRemark(remark);
|
|
||||||
sysEnumService.saveOrUpdate(sysEnum);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.bruce.common.enumconfig;
|
||||||
|
|
||||||
|
import com.bruce.common.domain.entity.SysEnum;
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sys_enum 初始化测试的辅助工具。
|
||||||
|
* <p>
|
||||||
|
* 该类只服务于测试入口,用于把代码里的枚举定义组装成可落库的数据结构,
|
||||||
|
* 并在真正写库前完成组级唯一性校验。
|
||||||
|
*/
|
||||||
|
final class SysEnumDefinitionSyncSupport {
|
||||||
|
|
||||||
|
private SysEnumDefinitionSyncSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static EnumGroup groupOf(List<? extends PersistableSysEnumDefinition> definitions) {
|
||||||
|
if (definitions == null || definitions.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("枚举定义不能为空");
|
||||||
|
}
|
||||||
|
PersistableSysEnumDefinition first = definitions.getFirst();
|
||||||
|
validateGroupMembers(first, definitions);
|
||||||
|
validateUniqueValuesAndSorts(first, definitions);
|
||||||
|
return new EnumGroup(first.getCatalog(), first.getType(), List.copyOf(definitions));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void validateUniqueGroupKeys(List<EnumGroup> groups) {
|
||||||
|
Set<String> keys = new HashSet<>();
|
||||||
|
for (EnumGroup group : groups) {
|
||||||
|
String key = group.catalog() + "/" + group.type();
|
||||||
|
if (!keys.add(key)) {
|
||||||
|
throw new IllegalArgumentException("存在重复的枚举分组: " + key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<SysEnum> toEntities(EnumGroup group) {
|
||||||
|
return group.definitions().stream()
|
||||||
|
.map(item -> {
|
||||||
|
SysEnum sysEnum = new SysEnum();
|
||||||
|
sysEnum.setCatalog(group.catalog());
|
||||||
|
sysEnum.setType(group.type());
|
||||||
|
sysEnum.setName(item.getName());
|
||||||
|
sysEnum.setValue(item.getValue());
|
||||||
|
sysEnum.setStrvalue(item.getStrvalue());
|
||||||
|
sysEnum.setSort(item.getSort());
|
||||||
|
sysEnum.setRemark(item.getRemark());
|
||||||
|
return sysEnum;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateGroupMembers(
|
||||||
|
PersistableSysEnumDefinition first,
|
||||||
|
List<? extends PersistableSysEnumDefinition> definitions
|
||||||
|
) {
|
||||||
|
for (PersistableSysEnumDefinition item : definitions) {
|
||||||
|
if (!first.getCatalog().equals(item.getCatalog()) || !first.getType().equals(item.getType())) {
|
||||||
|
throw new IllegalArgumentException("同一枚举组中的 catalog/type 必须一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateUniqueValuesAndSorts(
|
||||||
|
PersistableSysEnumDefinition first,
|
||||||
|
List<? extends PersistableSysEnumDefinition> definitions
|
||||||
|
) {
|
||||||
|
Set<Integer> values = new HashSet<>();
|
||||||
|
Set<Integer> sorts = new HashSet<>();
|
||||||
|
for (PersistableSysEnumDefinition item : definitions) {
|
||||||
|
if (!values.add(item.getValue())) {
|
||||||
|
throw new IllegalArgumentException("枚举值重复: " + first.getCatalog() + "/" + first.getType() + "/" + item.getValue());
|
||||||
|
}
|
||||||
|
if (!sorts.add(item.getSort())) {
|
||||||
|
throw new IllegalArgumentException("枚举排序重复: " + first.getCatalog() + "/" + first.getType() + "/" + item.getSort());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record EnumGroup(
|
||||||
|
String catalog,
|
||||||
|
String type,
|
||||||
|
List<? extends PersistableSysEnumDefinition> definitions
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.bruce.common.enumconfig;
|
||||||
|
|
||||||
|
import com.bruce.common.domain.entity.SysEnum;
|
||||||
|
import com.bruce.common.enums.EnableStatusEnum;
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
class SysEnumDefinitionSyncSupportTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectDuplicateCatalogAndTypeAcrossGroups() {
|
||||||
|
SysEnumDefinitionSyncSupport.EnumGroup left = SysEnumDefinitionSyncSupport.groupOf(
|
||||||
|
List.of(EnableStatusEnum.DISABLED, EnableStatusEnum.ENABLED)
|
||||||
|
);
|
||||||
|
SysEnumDefinitionSyncSupport.EnumGroup right = SysEnumDefinitionSyncSupport.groupOf(
|
||||||
|
List.of(
|
||||||
|
new FakeEnumDefinition("common", "enable_status", "草稿", 2, 2, "通用启用状态")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> SysEnumDefinitionSyncSupport.validateUniqueGroupKeys(List.of(left, right))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldBuildSysEnumRowsFromDefinitions() {
|
||||||
|
SysEnumDefinitionSyncSupport.EnumGroup group = SysEnumDefinitionSyncSupport.groupOf(
|
||||||
|
List.of(RagChunkStrategyEnum.FIXED_LENGTH, RagChunkStrategyEnum.DELIMITER)
|
||||||
|
);
|
||||||
|
|
||||||
|
List<SysEnum> rows = SysEnumDefinitionSyncSupport.toEntities(group);
|
||||||
|
|
||||||
|
assertEquals(2, rows.size());
|
||||||
|
assertEquals("rag", rows.get(0).getCatalog());
|
||||||
|
assertEquals("chunk_strategy", rows.get(0).getType());
|
||||||
|
assertEquals("固定长度切片", rows.get(0).getName());
|
||||||
|
assertEquals(1, rows.get(0).getValue());
|
||||||
|
assertEquals(5, rows.get(1).getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record FakeEnumDefinition(
|
||||||
|
String catalog,
|
||||||
|
String type,
|
||||||
|
String name,
|
||||||
|
Integer value,
|
||||||
|
Integer sort,
|
||||||
|
String remark
|
||||||
|
) implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getLabel() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getStrvalue() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getSort() {
|
||||||
|
return sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return remark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.bruce.rag;
|
|||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import com.bruce.common.domain.model.RequestResult;
|
import com.bruce.common.domain.model.RequestResult;
|
||||||
import com.bruce.rag.constant.RagSystemConstants;
|
import com.bruce.rag.constant.RagSystemConstants;
|
||||||
import com.bruce.rag.controller.RagDocumentController;
|
import com.bruce.rag.controller.RagDocumentController;
|
||||||
@@ -19,20 +21,25 @@ import com.bruce.rag.dto.response.RagStoreResponse;
|
|||||||
import com.bruce.rag.entity.RagChunk;
|
import com.bruce.rag.entity.RagChunk;
|
||||||
import com.bruce.rag.entity.RagChunkEmbedding;
|
import com.bruce.rag.entity.RagChunkEmbedding;
|
||||||
import com.bruce.rag.entity.RagDocument;
|
import com.bruce.rag.entity.RagDocument;
|
||||||
|
import com.bruce.rag.entity.RagDocumentParseResult;
|
||||||
import com.bruce.rag.entity.RagStore;
|
import com.bruce.rag.entity.RagStore;
|
||||||
import com.bruce.rag.mapper.RagChunkEmbeddingMapper;
|
import com.bruce.rag.mapper.RagChunkEmbeddingMapper;
|
||||||
import com.bruce.rag.mapper.RagChunkMapper;
|
import com.bruce.rag.mapper.RagChunkMapper;
|
||||||
import com.bruce.rag.mapper.RagDocumentMapper;
|
import com.bruce.rag.mapper.RagDocumentMapper;
|
||||||
|
import com.bruce.rag.mapper.RagDocumentParseResultMapper;
|
||||||
import com.bruce.rag.mapper.RagStoreMapper;
|
import com.bruce.rag.mapper.RagStoreMapper;
|
||||||
import com.bruce.rag.service.IRagChunkEmbeddingService;
|
import com.bruce.rag.service.IRagChunkEmbeddingService;
|
||||||
import com.bruce.rag.service.IRagChunkService;
|
import com.bruce.rag.service.IRagChunkService;
|
||||||
import com.bruce.rag.service.IRagDocumentParseService;
|
import com.bruce.rag.service.IRagDocumentParseService;
|
||||||
|
import com.bruce.rag.service.IRagDocumentParseResultService;
|
||||||
import com.bruce.rag.service.IRagDocumentService;
|
import com.bruce.rag.service.IRagDocumentService;
|
||||||
import com.bruce.rag.service.IRagStoreService;
|
import com.bruce.rag.service.IRagStoreService;
|
||||||
import com.bruce.rag.service.impl.RagChunkEmbeddingServiceImpl;
|
import com.bruce.rag.service.impl.RagChunkEmbeddingServiceImpl;
|
||||||
import com.bruce.rag.service.impl.RagChunkServiceImpl;
|
import com.bruce.rag.service.impl.RagChunkServiceImpl;
|
||||||
|
import com.bruce.rag.service.impl.RagDocumentParseResultServiceImpl;
|
||||||
import com.bruce.rag.service.impl.RagDocumentServiceImpl;
|
import com.bruce.rag.service.impl.RagDocumentServiceImpl;
|
||||||
import com.bruce.rag.service.impl.RagStoreServiceImpl;
|
import com.bruce.rag.service.impl.RagStoreServiceImpl;
|
||||||
|
import com.bruce.rag.typehandler.PgJsonbStringTypeHandler;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
|
||||||
@@ -50,14 +57,17 @@ class RagComponentStructureTests {
|
|||||||
void ragComponentsShouldReuseMybatisPlusBaseTypes() {
|
void ragComponentsShouldReuseMybatisPlusBaseTypes() {
|
||||||
assertTrue(BaseMapper.class.isAssignableFrom(RagStoreMapper.class));
|
assertTrue(BaseMapper.class.isAssignableFrom(RagStoreMapper.class));
|
||||||
assertTrue(BaseMapper.class.isAssignableFrom(RagDocumentMapper.class));
|
assertTrue(BaseMapper.class.isAssignableFrom(RagDocumentMapper.class));
|
||||||
|
assertTrue(BaseMapper.class.isAssignableFrom(RagDocumentParseResultMapper.class));
|
||||||
assertTrue(BaseMapper.class.isAssignableFrom(RagChunkMapper.class));
|
assertTrue(BaseMapper.class.isAssignableFrom(RagChunkMapper.class));
|
||||||
assertTrue(BaseMapper.class.isAssignableFrom(RagChunkEmbeddingMapper.class));
|
assertTrue(BaseMapper.class.isAssignableFrom(RagChunkEmbeddingMapper.class));
|
||||||
assertTrue(IService.class.isAssignableFrom(IRagStoreService.class));
|
assertTrue(IService.class.isAssignableFrom(IRagStoreService.class));
|
||||||
assertTrue(IService.class.isAssignableFrom(IRagDocumentService.class));
|
assertTrue(IService.class.isAssignableFrom(IRagDocumentService.class));
|
||||||
|
assertTrue(IService.class.isAssignableFrom(IRagDocumentParseResultService.class));
|
||||||
assertTrue(IService.class.isAssignableFrom(IRagChunkService.class));
|
assertTrue(IService.class.isAssignableFrom(IRagChunkService.class));
|
||||||
assertTrue(IService.class.isAssignableFrom(IRagChunkEmbeddingService.class));
|
assertTrue(IService.class.isAssignableFrom(IRagChunkEmbeddingService.class));
|
||||||
assertTrue(ServiceImpl.class.isAssignableFrom(RagStoreServiceImpl.class));
|
assertTrue(ServiceImpl.class.isAssignableFrom(RagStoreServiceImpl.class));
|
||||||
assertTrue(ServiceImpl.class.isAssignableFrom(RagDocumentServiceImpl.class));
|
assertTrue(ServiceImpl.class.isAssignableFrom(RagDocumentServiceImpl.class));
|
||||||
|
assertTrue(ServiceImpl.class.isAssignableFrom(RagDocumentParseResultServiceImpl.class));
|
||||||
assertTrue(ServiceImpl.class.isAssignableFrom(RagChunkServiceImpl.class));
|
assertTrue(ServiceImpl.class.isAssignableFrom(RagChunkServiceImpl.class));
|
||||||
assertTrue(ServiceImpl.class.isAssignableFrom(RagChunkEmbeddingServiceImpl.class));
|
assertTrue(ServiceImpl.class.isAssignableFrom(RagChunkEmbeddingServiceImpl.class));
|
||||||
}
|
}
|
||||||
@@ -173,4 +183,35 @@ class RagComponentStructureTests {
|
|||||||
assertEquals(Boolean.class, RagChunkEmbedding.class.getDeclaredField("enabled").getType());
|
assertEquals(Boolean.class, RagChunkEmbedding.class.getDeclaredField("enabled").getType());
|
||||||
assertEquals(String.class, RagChunkEmbedding.class.getDeclaredField("remark").getType());
|
assertEquals(String.class, RagChunkEmbedding.class.getDeclaredField("remark").getType());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ragParseResultStructureShouldSupportSnapshotMetadata() throws NoSuchFieldException {
|
||||||
|
assertEquals(Long.class, RagDocumentParseResult.class.getDeclaredField("storeId").getType());
|
||||||
|
assertEquals(Long.class, RagDocumentParseResult.class.getDeclaredField("documentId").getType());
|
||||||
|
assertEquals(String.class, RagDocumentParseResult.class.getDeclaredField("parsedText").getType());
|
||||||
|
assertEquals(Integer.class, RagDocumentParseResult.class.getDeclaredField("textLength").getType());
|
||||||
|
assertEquals(Integer.class, RagDocumentParseResult.class.getDeclaredField("pageCount").getType());
|
||||||
|
assertEquals(Integer.class, RagDocumentParseResult.class.getDeclaredField("sheetCount").getType());
|
||||||
|
assertEquals(String.class, RagDocumentParseResult.class.getDeclaredField("metadataJson").getType());
|
||||||
|
assertEquals(String.class, RagDocumentParseResult.class.getDeclaredField("contentHash").getType());
|
||||||
|
assertEquals(Integer.class, RagDocumentParseResult.class.getDeclaredField("parseVersion").getType());
|
||||||
|
assertEquals(Boolean.class, RagDocumentParseResult.class.getDeclaredField("enabled").getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ragMetadataJsonFieldsShouldUseJsonbTypeHandler() throws NoSuchFieldException {
|
||||||
|
TableName chunkTable = RagChunk.class.getAnnotation(TableName.class);
|
||||||
|
TableName parseResultTable = RagDocumentParseResult.class.getAnnotation(TableName.class);
|
||||||
|
TableField chunkMetadataField = RagChunk.class.getDeclaredField("metadataJson").getAnnotation(TableField.class);
|
||||||
|
TableField parseResultMetadataField = RagDocumentParseResult.class.getDeclaredField("metadataJson").getAnnotation(TableField.class);
|
||||||
|
|
||||||
|
assertNotNull(chunkTable);
|
||||||
|
assertNotNull(parseResultTable);
|
||||||
|
assertTrue(chunkTable.autoResultMap());
|
||||||
|
assertTrue(parseResultTable.autoResultMap());
|
||||||
|
assertNotNull(chunkMetadataField);
|
||||||
|
assertNotNull(parseResultMetadataField);
|
||||||
|
assertEquals(PgJsonbStringTypeHandler.class, chunkMetadataField.typeHandler());
|
||||||
|
assertEquals(PgJsonbStringTypeHandler.class, parseResultMetadataField.typeHandler());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import com.bruce.common.service.ISysAttachmentService;
|
|||||||
import com.bruce.rag.dto.request.RagDocumentParseRequest;
|
import com.bruce.rag.dto.request.RagDocumentParseRequest;
|
||||||
import com.bruce.rag.dto.response.RagDocumentParseResponse;
|
import com.bruce.rag.dto.response.RagDocumentParseResponse;
|
||||||
import com.bruce.rag.entity.RagDocument;
|
import com.bruce.rag.entity.RagDocument;
|
||||||
|
import com.bruce.rag.mapper.RagDocumentMapper;
|
||||||
|
import com.bruce.rag.service.IRagDocumentParseResultService;
|
||||||
import com.bruce.rag.enums.RagParseStatusEnum;
|
import com.bruce.rag.enums.RagParseStatusEnum;
|
||||||
import com.bruce.rag.service.IRagDocumentService;
|
|
||||||
import com.bruce.rag.service.impl.RagDocumentParseServiceImpl;
|
import com.bruce.rag.service.impl.RagDocumentParseServiceImpl;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
@@ -26,11 +27,13 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class RagDocumentParseServiceImplTests {
|
class RagDocumentParseServiceImplTests {
|
||||||
@@ -39,7 +42,7 @@ class RagDocumentParseServiceImplTests {
|
|||||||
private Path tempDir;
|
private Path tempDir;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private IRagDocumentService ragDocumentService;
|
private RagDocumentMapper ragDocumentMapper;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private ISysAttachmentService sysAttachmentService;
|
private ISysAttachmentService sysAttachmentService;
|
||||||
@@ -67,15 +70,16 @@ class RagDocumentParseServiceImplTests {
|
|||||||
attachmentProperties.setBasePath(tempDir.toString());
|
attachmentProperties.setBasePath(tempDir.toString());
|
||||||
DocumentParser parser = new FixedDocumentParser("people profiles");
|
DocumentParser parser = new FixedDocumentParser("people profiles");
|
||||||
RagDocumentParseServiceImpl service = new RagDocumentParseServiceImpl(
|
RagDocumentParseServiceImpl service = new RagDocumentParseServiceImpl(
|
||||||
ragDocumentService,
|
ragDocumentMapper,
|
||||||
sysAttachmentService,
|
sysAttachmentService,
|
||||||
attachmentProperties,
|
attachmentProperties,
|
||||||
new DocumentParserFactory(List.of(parser))
|
new DocumentParserFactory(List.of(parser)),
|
||||||
|
mock(IRagDocumentParseResultService.class)
|
||||||
);
|
);
|
||||||
|
|
||||||
when(ragDocumentService.getById(1001L)).thenReturn(document);
|
when(ragDocumentMapper.selectById(1001L)).thenReturn(document);
|
||||||
when(sysAttachmentService.getById(3003L)).thenReturn(attachment);
|
when(sysAttachmentService.getById(3003L)).thenReturn(attachment);
|
||||||
when(ragDocumentService.updateById(any(RagDocument.class))).thenReturn(true);
|
when(ragDocumentMapper.updateById(any(RagDocument.class))).thenReturn(1);
|
||||||
|
|
||||||
RagDocumentParseResponse response = service.parse(1001L);
|
RagDocumentParseResponse response = service.parse(1001L);
|
||||||
|
|
||||||
@@ -85,7 +89,7 @@ class RagDocumentParseServiceImplTests {
|
|||||||
assertEquals("fixed", response.getMetadata().get("parser"));
|
assertEquals("fixed", response.getMetadata().get("parser"));
|
||||||
|
|
||||||
ArgumentCaptor<RagDocument> captor = ArgumentCaptor.forClass(RagDocument.class);
|
ArgumentCaptor<RagDocument> captor = ArgumentCaptor.forClass(RagDocument.class);
|
||||||
verify(ragDocumentService, times(2)).updateById(captor.capture());
|
verify(ragDocumentMapper, times(2)).updateById(captor.capture());
|
||||||
List<RagDocument> updates = captor.getAllValues();
|
List<RagDocument> updates = captor.getAllValues();
|
||||||
assertEquals(RagParseStatusEnum.PARSING.name(), updates.get(0).getParseStatus());
|
assertEquals(RagParseStatusEnum.PARSING.name(), updates.get(0).getParseStatus());
|
||||||
assertEquals(RagParseStatusEnum.PARSED.name(), updates.get(1).getParseStatus());
|
assertEquals(RagParseStatusEnum.PARSED.name(), updates.get(1).getParseStatus());
|
||||||
@@ -93,7 +97,7 @@ class RagDocumentParseServiceImplTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parseShouldSupportBatchRequestAndChunkStrategyStructure() throws Exception {
|
void parseShouldSupportBatchRequest() throws Exception {
|
||||||
Path file = tempDir.resolve("rag").resolve("batch.txt");
|
Path file = tempDir.resolve("rag").resolve("batch.txt");
|
||||||
Files.createDirectories(file.getParent());
|
Files.createDirectories(file.getParent());
|
||||||
Files.writeString(file, "batch profiles");
|
Files.writeString(file, "batch profiles");
|
||||||
@@ -114,19 +118,18 @@ class RagDocumentParseServiceImplTests {
|
|||||||
AttachmentProperties attachmentProperties = new AttachmentProperties();
|
AttachmentProperties attachmentProperties = new AttachmentProperties();
|
||||||
attachmentProperties.setBasePath(tempDir.toString());
|
attachmentProperties.setBasePath(tempDir.toString());
|
||||||
RagDocumentParseServiceImpl service = new RagDocumentParseServiceImpl(
|
RagDocumentParseServiceImpl service = new RagDocumentParseServiceImpl(
|
||||||
ragDocumentService,
|
ragDocumentMapper,
|
||||||
sysAttachmentService,
|
sysAttachmentService,
|
||||||
attachmentProperties,
|
attachmentProperties,
|
||||||
new DocumentParserFactory(List.of(new FixedDocumentParser("batch profiles")))
|
new DocumentParserFactory(List.of(new FixedDocumentParser("batch profiles"))),
|
||||||
|
mock(IRagDocumentParseResultService.class)
|
||||||
);
|
);
|
||||||
RagDocumentParseRequest request = new RagDocumentParseRequest();
|
RagDocumentParseRequest request = new RagDocumentParseRequest();
|
||||||
request.setDocumentIds(List.of(1002L));
|
request.setDocumentIds(List.of(1002L));
|
||||||
request.setChunkStrategy("DELIMITER");
|
|
||||||
request.setDelimiter("。");
|
|
||||||
|
|
||||||
when(ragDocumentService.getById(1002L)).thenReturn(document);
|
when(ragDocumentMapper.selectById(1002L)).thenReturn(document);
|
||||||
when(sysAttachmentService.getById(3004L)).thenReturn(attachment);
|
when(sysAttachmentService.getById(3004L)).thenReturn(attachment);
|
||||||
when(ragDocumentService.updateById(any(RagDocument.class))).thenReturn(true);
|
when(ragDocumentMapper.updateById(any(RagDocument.class))).thenReturn(1);
|
||||||
|
|
||||||
List<RagDocumentParseResponse> responses = service.parse(request);
|
List<RagDocumentParseResponse> responses = service.parse(request);
|
||||||
|
|
||||||
@@ -135,6 +138,23 @@ class RagDocumentParseServiceImplTests {
|
|||||||
assertEquals(RagParseStatusEnum.PARSED.name(), responses.getFirst().getParseStatus());
|
assertEquals(RagParseStatusEnum.PARSED.name(), responses.getFirst().getParseStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseShouldRejectEmptyDocumentIds() {
|
||||||
|
AttachmentProperties attachmentProperties = new AttachmentProperties();
|
||||||
|
attachmentProperties.setBasePath(tempDir.toString());
|
||||||
|
RagDocumentParseServiceImpl service = new RagDocumentParseServiceImpl(
|
||||||
|
ragDocumentMapper,
|
||||||
|
sysAttachmentService,
|
||||||
|
attachmentProperties,
|
||||||
|
new DocumentParserFactory(List.of(new FixedDocumentParser("batch profiles"))),
|
||||||
|
mock(IRagDocumentParseResultService.class)
|
||||||
|
);
|
||||||
|
RagDocumentParseRequest request = new RagDocumentParseRequest();
|
||||||
|
request.setDocumentIds(List.of());
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> service.parse(request));
|
||||||
|
}
|
||||||
|
|
||||||
private static class FixedDocumentParser implements DocumentParser {
|
private static class FixedDocumentParser implements DocumentParser {
|
||||||
|
|
||||||
private final String text;
|
private final String text;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.bruce.rag.dto.request.RagDocumentSaveRequest;
|
|||||||
import com.bruce.rag.entity.RagDocument;
|
import com.bruce.rag.entity.RagDocument;
|
||||||
import com.bruce.rag.enums.RagIndexStatusEnum;
|
import com.bruce.rag.enums.RagIndexStatusEnum;
|
||||||
import com.bruce.rag.enums.RagParseStatusEnum;
|
import com.bruce.rag.enums.RagParseStatusEnum;
|
||||||
|
import com.bruce.rag.service.IRagDocumentAutoParseService;
|
||||||
import com.bruce.rag.service.impl.RagDocumentServiceImpl;
|
import com.bruce.rag.service.impl.RagDocumentServiceImpl;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -38,6 +39,9 @@ class RagDocumentServiceImplTests {
|
|||||||
@Mock
|
@Mock
|
||||||
private ISysAttachmentService sysAttachmentService;
|
private ISysAttachmentService sysAttachmentService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IRagDocumentAutoParseService ragDocumentAutoParseService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void batchUploadShouldUseRagSourceTypeAndStoreIdAsSourceId() {
|
void batchUploadShouldUseRagSourceTypeAndStoreIdAsSourceId() {
|
||||||
MockMultipartFile file = new MockMultipartFile(
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
|||||||
50
src/test/java/com/bruce/rag/parse/ChunkerFactoryTests.java
Normal file
50
src/test/java/com/bruce/rag/parse/ChunkerFactoryTests.java
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package com.bruce.rag.parse;
|
||||||
|
|
||||||
|
import com.bruce.rag.entity.RagChunk;
|
||||||
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
class ChunkerFactoryTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveShouldReturnMatchingChunker() {
|
||||||
|
Chunker supported = new StubChunker(RagChunkStrategyEnum.FIXED_LENGTH);
|
||||||
|
Chunker unsupported = new StubChunker(RagChunkStrategyEnum.DELIMITER);
|
||||||
|
ChunkerFactory factory = new ChunkerFactory(List.of(supported, unsupported));
|
||||||
|
|
||||||
|
Chunker resolved = factory.resolve(RagChunkStrategyEnum.FIXED_LENGTH);
|
||||||
|
|
||||||
|
assertSame(supported, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveShouldRejectUnsupportedStrategy() {
|
||||||
|
ChunkerFactory factory = new ChunkerFactory(List.of(new StubChunker(RagChunkStrategyEnum.FIXED_LENGTH)));
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> factory.resolve(RagChunkStrategyEnum.SEMANTIC));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class StubChunker implements Chunker {
|
||||||
|
|
||||||
|
private final RagChunkStrategyEnum strategy;
|
||||||
|
|
||||||
|
private StubChunker(RagChunkStrategyEnum strategy) {
|
||||||
|
this.strategy = strategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(RagChunkStrategyEnum strategy) {
|
||||||
|
return this.strategy == strategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<RagChunk> chunk(RagChunkCommand command) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/test/java/com/bruce/rag/parse/DelimiterChunkerTests.java
Normal file
64
src/test/java/com/bruce/rag/parse/DelimiterChunkerTests.java
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package com.bruce.rag.parse;
|
||||||
|
|
||||||
|
import com.bruce.common.document.parse.DocumentParseResult;
|
||||||
|
import com.bruce.rag.entity.RagChunk;
|
||||||
|
import com.bruce.rag.entity.RagDocument;
|
||||||
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
import com.bruce.rag.parse.impl.DelimiterChunker;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class DelimiterChunkerTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void chunkShouldSplitByDelimiterAndIgnoreBlankSegments() {
|
||||||
|
DelimiterChunker chunker = new DelimiterChunker();
|
||||||
|
|
||||||
|
RagChunkCommand command = new RagChunkCommand();
|
||||||
|
command.setDocument(buildDocument());
|
||||||
|
command.setParseResult(buildParseResult("第一段。第二段。。第三段"));
|
||||||
|
command.setChunkStrategy(RagChunkStrategyEnum.DELIMITER.getValue());
|
||||||
|
command.setDelimiter("。");
|
||||||
|
|
||||||
|
List<RagChunk> chunks = chunker.chunk(command);
|
||||||
|
|
||||||
|
assertEquals(3, chunks.size());
|
||||||
|
assertEquals("第一段", chunks.get(0).getChunkContent());
|
||||||
|
assertEquals("第二段", chunks.get(1).getChunkContent());
|
||||||
|
assertEquals("第三段", chunks.get(2).getChunkContent());
|
||||||
|
assertEquals(0, chunks.get(0).getChunkIndex());
|
||||||
|
assertEquals(1, chunks.get(1).getChunkIndex());
|
||||||
|
assertEquals(2, chunks.get(2).getChunkIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void chunkShouldReturnEmptyListForBlankText() {
|
||||||
|
DelimiterChunker chunker = new DelimiterChunker();
|
||||||
|
|
||||||
|
RagChunkCommand command = new RagChunkCommand();
|
||||||
|
command.setDocument(buildDocument());
|
||||||
|
command.setParseResult(buildParseResult(" "));
|
||||||
|
command.setChunkStrategy(RagChunkStrategyEnum.DELIMITER.getValue());
|
||||||
|
command.setDelimiter("。");
|
||||||
|
|
||||||
|
assertTrue(chunker.chunk(command).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RagDocument buildDocument() {
|
||||||
|
RagDocument document = new RagDocument();
|
||||||
|
document.setId(66L);
|
||||||
|
document.setStoreId(55L);
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentParseResult buildParseResult(String text) {
|
||||||
|
DocumentParseResult result = new DocumentParseResult();
|
||||||
|
result.setText(text);
|
||||||
|
result.setTextLength(text.length());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.bruce.rag.parse;
|
||||||
|
|
||||||
|
import com.bruce.common.document.parse.DocumentParseResult;
|
||||||
|
import com.bruce.rag.entity.RagChunk;
|
||||||
|
import com.bruce.rag.entity.RagDocument;
|
||||||
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
import com.bruce.rag.parse.impl.FixedLengthChunker;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class FixedLengthChunkerTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void chunkShouldSplitTextByChunkSizeAndOverlap() {
|
||||||
|
FixedLengthChunker chunker = new FixedLengthChunker();
|
||||||
|
|
||||||
|
RagChunkCommand command = new RagChunkCommand();
|
||||||
|
command.setDocument(buildDocument());
|
||||||
|
command.setParseResult(buildParseResult("abcdefghij"));
|
||||||
|
command.setChunkStrategy(RagChunkStrategyEnum.FIXED_LENGTH.getValue());
|
||||||
|
command.setChunkSize(4);
|
||||||
|
command.setChunkOverlap(1);
|
||||||
|
|
||||||
|
List<RagChunk> chunks = chunker.chunk(command);
|
||||||
|
|
||||||
|
assertEquals(3, chunks.size());
|
||||||
|
assertEquals("abcd", chunks.get(0).getChunkContent());
|
||||||
|
assertEquals("defg", chunks.get(1).getChunkContent());
|
||||||
|
assertEquals("ghij", chunks.get(2).getChunkContent());
|
||||||
|
assertEquals(0, chunks.get(0).getChunkIndex());
|
||||||
|
assertEquals(1, chunks.get(1).getChunkIndex());
|
||||||
|
assertEquals(2, chunks.get(2).getChunkIndex());
|
||||||
|
assertEquals(99L, chunks.get(0).getDocumentId());
|
||||||
|
assertEquals(88L, chunks.get(0).getStoreId());
|
||||||
|
assertTrue(Boolean.TRUE.equals(chunks.get(0).getEnabled()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void chunkShouldReturnEmptyListForBlankText() {
|
||||||
|
FixedLengthChunker chunker = new FixedLengthChunker();
|
||||||
|
|
||||||
|
RagChunkCommand command = new RagChunkCommand();
|
||||||
|
command.setDocument(buildDocument());
|
||||||
|
command.setParseResult(buildParseResult(" "));
|
||||||
|
command.setChunkStrategy(RagChunkStrategyEnum.FIXED_LENGTH.getValue());
|
||||||
|
command.setChunkSize(4);
|
||||||
|
command.setChunkOverlap(1);
|
||||||
|
|
||||||
|
assertTrue(chunker.chunk(command).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RagDocument buildDocument() {
|
||||||
|
RagDocument document = new RagDocument();
|
||||||
|
document.setId(99L);
|
||||||
|
document.setStoreId(88L);
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentParseResult buildParseResult(String text) {
|
||||||
|
DocumentParseResult result = new DocumentParseResult();
|
||||||
|
result.setText(text);
|
||||||
|
result.setTextLength(text.length());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user