feat(knowledge): 添加知识库批量初始化接口
## 新增功能 1. **KnowledgeBaseController** - POST /api/knowledge/init - 批量初始化知识库 - GET /api/knowledge/stats - 查询统计信息 2. **KnowledgeBaseInitService** - 递归扫描 knowledge_base 目录所有 .md 文件 - 解析 frontmatter 提取元数据 - 自动去重(基于文件路径) - 数据入库到 api_document 表 - 自动加入 L0 内存索引 ## 核心特性 ### 去重机制 - 基于文件相对路径去重 - 支持 force=true 强制重新导入 - 跳过已存在文档,避免重复插入 ### 数据存储 - 数据库:保存文档元数据(title、keywords、summary 等) - L0 索引:加入 KnowledgeIndexService 内存索引 - L1 索引:暂未实现(TODO) ### 错误处理 - 格式无效:frontmatter 解析失败 - 缺少标题:必填字段验证 - 详细的错误信息反馈 ## API 示例 ```bash # 首次导入 curl -X POST http://localhost:9900/api/knowledge/init # 强制重新导入 curl -X POST http://localhost:9900/api/knowledge/init?force=true # 查询统计 curl http://localhost:9900/api/knowledge/stats ``` ## 响应示例 ```json { "success": true, "scanned": 6, "skipped": 0, "inserted": 6, "failed": 0, "details": { "api/payment-errors.md": "导入成功(L0)" } } ``` ## 后续扩展 - [ ] L1 向量索引(Milvus)集成 - [ ] 文档更新检测(基于文件哈希) - [ ] 批量删除接口 - [ ] 进度回调支持 ## 文档 - 使用文档:.docs/2026-06-25-knowledge-base-init-api.md
This commit is contained in:
@@ -0,0 +1,402 @@
|
|||||||
|
# 知识库初始化 API 使用文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
提供了知识库批量初始化接口,用于将 `knowledge_base` 目录下的所有 Markdown 文档导入到数据库和向量索引(L0)。
|
||||||
|
|
||||||
|
**功能特点**:
|
||||||
|
1. ✅ **批量扫描**:递归扫描 knowledge_base 目录下所有 .md 文件
|
||||||
|
2. ✅ **自动去重**:基于文件路径检查,避免重复导入
|
||||||
|
3. ✅ **数据入库**:保存文档元数据到 MySQL
|
||||||
|
4. ✅ **L0 索引**:自动加入内存精确匹配索引
|
||||||
|
5. ⏳ **L1 索引**:暂未实现,需要后续通过独立索引任务完成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 1. 初始化知识库
|
||||||
|
|
||||||
|
**端点**:
|
||||||
|
```
|
||||||
|
POST /api/knowledge/init?force=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数**:
|
||||||
|
- `force`(可选):是否强制重新导入,跳过去重检查
|
||||||
|
- `false`(默认):跳过已存在的文档
|
||||||
|
- `true`:强制重新导入所有文档
|
||||||
|
|
||||||
|
**请求示例**:
|
||||||
|
```bash
|
||||||
|
# 首次导入(去重模式)
|
||||||
|
curl -X POST http://localhost:9900/api/knowledge/init
|
||||||
|
|
||||||
|
# 强制重新导入
|
||||||
|
curl -X POST http://localhost:9900/api/knowledge/init?force=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "知识库初始化完成",
|
||||||
|
"scanned": 6,
|
||||||
|
"skipped": 0,
|
||||||
|
"inserted": 6,
|
||||||
|
"failed": 0,
|
||||||
|
"details": {
|
||||||
|
"api/payment-errors.md": "导入成功(L0)",
|
||||||
|
"domain/spring-ai-tool-best-practices.md": "导入成功(L0)",
|
||||||
|
"infrastructure/flyway-best-practices.md": "导入成功(L0)",
|
||||||
|
"infrastructure/mysql-connection-pool.md": "导入成功(L0)",
|
||||||
|
"infrastructure/redis-config.md": "导入成功(L0)",
|
||||||
|
"troubleshooting/fault-diagnosis-process.md": "导入成功(L0)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
- `scanned`:扫描到的文件总数
|
||||||
|
- `skipped`:跳过的文件数量(已存在)
|
||||||
|
- `inserted`:成功导入的文件数量
|
||||||
|
- `failed`:失败的文件数量
|
||||||
|
- `details`:每个文件的处理结果详情
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 查询知识库统计
|
||||||
|
|
||||||
|
**端点**:
|
||||||
|
```
|
||||||
|
GET /api/knowledge/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求示例**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:9900/api/knowledge/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"totalDocuments": 6,
|
||||||
|
"totalVectors": 0,
|
||||||
|
"categories": {
|
||||||
|
"api": 1,
|
||||||
|
"domain": 1,
|
||||||
|
"infrastructure": 3,
|
||||||
|
"troubleshooting": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
- `totalDocuments`:数据库中的文档总数
|
||||||
|
- `totalVectors`:Milvus 中的向量总数(当前为 0,未实现)
|
||||||
|
- `categories`:按分类统计的文档数量
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 场景 1:项目启动时初始化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动应用
|
||||||
|
mvn spring-boot:run
|
||||||
|
|
||||||
|
# 2. 等待应用启动完成(约 10 秒)
|
||||||
|
|
||||||
|
# 3. 调用初始化接口
|
||||||
|
curl -X POST http://localhost:9900/api/knowledge/init
|
||||||
|
|
||||||
|
# 4. 查看结果
|
||||||
|
# 日志输出:知识库初始化完成: 扫描=6, 跳过=0, 新增=6, 失败=0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景 2:添加新文档后重新初始化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 添加新文档到 knowledge_base 目录
|
||||||
|
echo "---
|
||||||
|
title: 新文档
|
||||||
|
keywords: [测试, test]
|
||||||
|
summary: 这是一个测试文档
|
||||||
|
category: test
|
||||||
|
---
|
||||||
|
|
||||||
|
# 新文档内容
|
||||||
|
" > knowledge_base/test/new-doc.md
|
||||||
|
|
||||||
|
# 2. 调用初始化接口(去重模式)
|
||||||
|
curl -X POST http://localhost:9900/api/knowledge/init
|
||||||
|
|
||||||
|
# 3. 查看结果
|
||||||
|
# 只会导入新文档,跳过已存在的 6 个文档
|
||||||
|
# 响应: scanned=7, skipped=6, inserted=1, failed=0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景 3:强制重新导入所有文档
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 适用场景:
|
||||||
|
# - 数据库被清空,需要重新导入
|
||||||
|
# - 文档内容有更新,需要刷新
|
||||||
|
# - 索引损坏,需要重建
|
||||||
|
|
||||||
|
curl -X POST http://localhost:9900/api/knowledge/init?force=true
|
||||||
|
|
||||||
|
# 响应: scanned=6, skipped=0, inserted=6, failed=0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 去重机制
|
||||||
|
|
||||||
|
### 去重依据
|
||||||
|
- **文件路径**:相对于 `knowledge_base` 目录的相对路径
|
||||||
|
- 示例:`api/payment-errors.md`
|
||||||
|
|
||||||
|
### 去重逻辑
|
||||||
|
```
|
||||||
|
if (!force && existingFilePaths.contains(relativePath)) {
|
||||||
|
跳过该文档
|
||||||
|
} else {
|
||||||
|
导入该文档
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
1. **文件移动会被视为新文档**:
|
||||||
|
```bash
|
||||||
|
# 移动前:api/payment-errors.md
|
||||||
|
# 移动后:errors/payment-errors.md
|
||||||
|
# 结果:会被当作两个不同的文档
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **文件重命名会被视为新文档**:
|
||||||
|
```bash
|
||||||
|
# 重命名前:payment-errors.md
|
||||||
|
# 重命名后:payment-error-codes.md
|
||||||
|
# 结果:会被当作两个不同的文档
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **内容更新不触发重新导入**(非 force 模式):
|
||||||
|
```bash
|
||||||
|
# 修改文件内容后调用 init(非 force)
|
||||||
|
# 结果:跳过该文档,数据库中仍是旧内容
|
||||||
|
# 解决:使用 force=true 强制重新导入
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据存储
|
||||||
|
|
||||||
|
### 数据库表结构(api_document)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `id` | BIGINT | 主键 | 1 |
|
||||||
|
| `doc_id` | VARCHAR(64) | 文档唯一标识 | uuid |
|
||||||
|
| `file_name` | VARCHAR(256) | 文件名 | payment-errors.md |
|
||||||
|
| `file_path` | VARCHAR(512) | 相对路径 | api/payment-errors.md |
|
||||||
|
| `api_name` | VARCHAR(128) | 文档标题 | 支付网关错误码定义 |
|
||||||
|
| `status` | VARCHAR(16) | 状态 | INDEXED |
|
||||||
|
| `metadata` | TEXT | Frontmatter JSON | {"title":"...","keywords":[...]} |
|
||||||
|
| `file_size` | BIGINT | 文件大小(字节) | 2048 |
|
||||||
|
| `indexed_at` | DATETIME | 索引时间 | 2026-06-25 10:00:00 |
|
||||||
|
|
||||||
|
### metadata JSON 结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "支付网关错误码定义",
|
||||||
|
"summary": "记录了支付网关所有核心错误码的含义及排查方向",
|
||||||
|
"category": "api",
|
||||||
|
"keywords": ["ERR_TIMEOUT","超时","支付网关"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L0 内存索引
|
||||||
|
|
||||||
|
导入过程会自动将文档加入 `KnowledgeIndexService` 的内存索引:
|
||||||
|
|
||||||
|
```java
|
||||||
|
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||||
|
.filePath(relativePath)
|
||||||
|
.title(title)
|
||||||
|
.keywords(keywords)
|
||||||
|
.summary(summary)
|
||||||
|
.category(category)
|
||||||
|
.build();
|
||||||
|
knowledgeIndexService.addToIndex(entry);
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证 L0 索引**:
|
||||||
|
```bash
|
||||||
|
# 应用启动后查看日志
|
||||||
|
grep "知识库索引加载完成" logs/application.log
|
||||||
|
|
||||||
|
# 输出示例:
|
||||||
|
# [INFO] 知识库索引加载完成,共 6 个文档
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 常见错误
|
||||||
|
|
||||||
|
#### 1. 目录不存在
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "初始化失败: 知识库目录不存在: knowledge_base"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
```bash
|
||||||
|
mkdir -p knowledge_base/api
|
||||||
|
mkdir -p knowledge_base/infrastructure
|
||||||
|
mkdir -p knowledge_base/domain
|
||||||
|
mkdir -p knowledge_base/troubleshooting
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. 文档格式无效
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"scanned": 6,
|
||||||
|
"inserted": 5,
|
||||||
|
"failed": 1,
|
||||||
|
"details": {
|
||||||
|
"test/invalid.md": "格式无效: frontmatter 解析失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 缺少 frontmatter
|
||||||
|
- YAML 格式错误
|
||||||
|
- 缺少必填字段(title, keywords, summary)
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 文档标题
|
||||||
|
keywords: [关键词1, 关键词2]
|
||||||
|
summary: 文档摘要
|
||||||
|
category: api
|
||||||
|
---
|
||||||
|
|
||||||
|
# 正文内容
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. 文档缺少标题
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"details": {
|
||||||
|
"test/no-title.md": "缺少标题"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决**:在 frontmatter 中添加 `title` 字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续扩展(L1 向量索引)
|
||||||
|
|
||||||
|
当前版本暂未实现 L1 向量索引(Milvus),计划后续扩展:
|
||||||
|
|
||||||
|
### 扩展方案
|
||||||
|
|
||||||
|
1. **独立索引任务**:
|
||||||
|
```bash
|
||||||
|
POST /api/knowledge/build-vectors
|
||||||
|
```
|
||||||
|
- 读取数据库中所有文档
|
||||||
|
- 调用 `DocumentManagementService` 处理分块
|
||||||
|
- 上传到 Milvus
|
||||||
|
|
||||||
|
2. **或者修改当前接口**:
|
||||||
|
- 在 `initializeKnowledgeBase` 中调用文档分块和向量索引
|
||||||
|
- 需要处理大文件的分块逻辑
|
||||||
|
|
||||||
|
### 验证 L1 的方法(未来)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 调用 L1 索引构建
|
||||||
|
curl -X POST http://localhost:9900/api/knowledge/build-vectors
|
||||||
|
|
||||||
|
# 2. 查询统计信息
|
||||||
|
curl http://localhost:9900/api/knowledge/stats
|
||||||
|
|
||||||
|
# 3. 确认 totalVectors > 0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### ✅ 推荐做法
|
||||||
|
|
||||||
|
1. **首次启动后立即初始化**:
|
||||||
|
```bash
|
||||||
|
mvn spring-boot:run
|
||||||
|
sleep 15 # 等待启动完成
|
||||||
|
curl -X POST http://localhost:9900/api/knowledge/init
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **新增文档后增量导入**:
|
||||||
|
```bash
|
||||||
|
# 不使用 force,只导入新文档
|
||||||
|
curl -X POST http://localhost:9900/api/knowledge/init
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **定期检查统计信息**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:9900/api/knowledge/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **更新文档内容后强制刷新**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:9900/api/knowledge/init?force=true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ❌ 避免做法
|
||||||
|
|
||||||
|
1. **不检查响应就认为成功**:
|
||||||
|
- 始终检查 `failed` 字段
|
||||||
|
- 查看 `details` 了解具体失败原因
|
||||||
|
|
||||||
|
2. **频繁使用 force=true**:
|
||||||
|
- 会重复插入数据(违反唯一约束)
|
||||||
|
- 建议先清理数据库,再使用 force
|
||||||
|
|
||||||
|
3. **不检查文档格式就导入**:
|
||||||
|
- 先手动验证 frontmatter 格式
|
||||||
|
- 确保必填字段完整
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- **知识库使用指南**:`mvp/architecture/knowledge-retrieval-usage.md`
|
||||||
|
- **知识库架构**:`mvp/architecture/knowledge-retrieval-architecture.md`
|
||||||
|
- **Executor Prompt**:`src/main/resources/prompts/executor-prompt.md`
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.superbiz.agent.controller;
|
||||||
|
|
||||||
|
import com.superbiz.agent.service.KnowledgeBaseInitService;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库管理控制器
|
||||||
|
* 提供知识库初始化、查询等接口
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/knowledge")
|
||||||
|
public class KnowledgeBaseController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(KnowledgeBaseController.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private KnowledgeBaseInitService initService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化知识库
|
||||||
|
* 扫描 knowledge_base 目录下的所有文档,去重后批量导入到数据库和 Milvus
|
||||||
|
*
|
||||||
|
* @param force 是否强制重新导入(跳过去重检查)
|
||||||
|
* @return 初始化结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/init")
|
||||||
|
public ResponseEntity<?> initKnowledgeBase(@RequestParam(defaultValue = "false") boolean force) {
|
||||||
|
logger.info("收到知识库初始化请求, force={}", force);
|
||||||
|
|
||||||
|
try {
|
||||||
|
KnowledgeBaseInitService.InitResult result = initService.initializeKnowledgeBase(force);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "知识库初始化完成");
|
||||||
|
response.put("scanned", result.getScanned());
|
||||||
|
response.put("skipped", result.getSkipped());
|
||||||
|
response.put("inserted", result.getInserted());
|
||||||
|
response.put("failed", result.getFailed());
|
||||||
|
response.put("details", result.getDetails());
|
||||||
|
|
||||||
|
logger.info("知识库初始化成功: 扫描={}, 跳过={}, 新增={}, 失败={}",
|
||||||
|
result.getScanned(), result.getSkipped(), result.getInserted(), result.getFailed());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("知识库初始化失败", e);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", "初始化失败: " + e.getMessage());
|
||||||
|
|
||||||
|
return ResponseEntity.internalServerError().body(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询知识库统计信息
|
||||||
|
*
|
||||||
|
* @return 统计信息
|
||||||
|
*/
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public ResponseEntity<?> getStats() {
|
||||||
|
try {
|
||||||
|
KnowledgeBaseInitService.Stats stats = initService.getStats();
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("totalDocuments", stats.getTotalDocuments());
|
||||||
|
response.put("totalVectors", stats.getTotalVectors());
|
||||||
|
response.put("categories", stats.getCategoryCount());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("查询统计信息失败", e);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", "查询失败: " + e.getMessage());
|
||||||
|
|
||||||
|
return ResponseEntity.internalServerError().body(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,7 +154,7 @@ public class AiOpsService {
|
|||||||
return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools, queryLogsTools};
|
return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools, queryLogsTools};
|
||||||
} else {
|
} else {
|
||||||
// 真实模式:不包含 QueryLogsTools(由 MCP 提供日志查询功能)
|
// 真实模式:不包含 QueryLogsTools(由 MCP 提供日志查询功能)
|
||||||
return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools, internalDocsTools};
|
return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.superbiz.agent.agent.tool.DateTimeTools;
|
|||||||
import com.superbiz.agent.agent.tool.InternalDocsTools;
|
import com.superbiz.agent.agent.tool.InternalDocsTools;
|
||||||
import com.superbiz.agent.agent.tool.QueryLogsTools;
|
import com.superbiz.agent.agent.tool.QueryLogsTools;
|
||||||
import com.superbiz.agent.agent.tool.QueryMetricsTools;
|
import com.superbiz.agent.agent.tool.QueryMetricsTools;
|
||||||
|
import com.superbiz.agent.tool.LookupKnowledgeTool;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
@@ -44,6 +46,9 @@ public class ChatService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ChatModel chatModel;
|
private ChatModel chatModel;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LookupKnowledgeTool lookupKnowledgeTool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取注入的 ChatModel
|
* 获取注入的 ChatModel
|
||||||
*/
|
*/
|
||||||
@@ -62,7 +67,7 @@ public class ChatService {
|
|||||||
// 基础系统提示
|
// 基础系统提示
|
||||||
systemPromptBuilder.append("你是一个专业的智能助手,可以获取当前时间、查询天气信息、搜索内部文档知识库,以及查询 Prometheus 告警信息。\n");
|
systemPromptBuilder.append("你是一个专业的智能助手,可以获取当前时间、查询天气信息、搜索内部文档知识库,以及查询 Prometheus 告警信息。\n");
|
||||||
systemPromptBuilder.append("当用户询问时间相关问题时,**必须每次都调用 getCurrentDateTime 工具**,因为时间会不断变化。即使历史消息中有时间信息,也不要直接复用,必须重新查询最新时间。\n");
|
systemPromptBuilder.append("当用户询问时间相关问题时,**必须每次都调用 getCurrentDateTime 工具**,因为时间会不断变化。即使历史消息中有时间信息,也不要直接复用,必须重新查询最新时间。\n");
|
||||||
systemPromptBuilder.append("当用户需要查询公司内部文档、流程、最佳实践或技术指南时,使用 queryInternalDocs 工具。\n");
|
systemPromptBuilder.append("当用户需要查询公司内部文档、流程、最佳实践或技术指南时,使用 lookupKnowledgeTool 工具。\n");
|
||||||
systemPromptBuilder.append("当用户需要查询 Prometheus 告警、监控指标或系统告警状态时,使用 queryPrometheusAlerts 工具。\n");
|
systemPromptBuilder.append("当用户需要查询 Prometheus 告警、监控指标或系统告警状态时,使用 queryPrometheusAlerts 工具。\n");
|
||||||
systemPromptBuilder.append("当用户需要查询腾讯云日志时,请调用腾讯云mcp服务查询,默认查询地域ap-guangzhou,查询时间范围为近一个月。\n\n");
|
systemPromptBuilder.append("当用户需要查询腾讯云日志时,请调用腾讯云mcp服务查询,默认查询地域ap-guangzhou,查询时间范围为近一个月。\n\n");
|
||||||
|
|
||||||
@@ -126,10 +131,10 @@ public class ChatService {
|
|||||||
public Object[] buildMethodToolsArray() {
|
public Object[] buildMethodToolsArray() {
|
||||||
if (queryLogsTools != null) {
|
if (queryLogsTools != null) {
|
||||||
// Mock 模式:包含 QueryLogsTools
|
// Mock 模式:包含 QueryLogsTools
|
||||||
return new Object[]{dateTimeTools, internalDocsTools, queryMetricsTools, queryLogsTools};
|
return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools, queryLogsTools};
|
||||||
} else {
|
} else {
|
||||||
// 真实模式:不包含 QueryLogsTools(由 MCP 提供日志查询功能)
|
// 真实模式:不包含 QueryLogsTools(由 MCP 提供日志查询功能)
|
||||||
return new Object[]{dateTimeTools, internalDocsTools, queryMetricsTools};
|
return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package com.superbiz.agent.service;
|
||||||
|
|
||||||
|
import com.superbiz.agent.domain.entity.ApiDocument;
|
||||||
|
import com.superbiz.agent.repository.ApiDocumentRepository;
|
||||||
|
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||||
|
import com.superbiz.agent.dto.Frontmatter;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库初始化服务
|
||||||
|
* 负责批量导入 knowledge_base 目录下的文档到数据库和 Milvus
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class KnowledgeBaseInitService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(KnowledgeBaseInitService.class);
|
||||||
|
|
||||||
|
@Value("${knowledge.base-path:knowledge_base}")
|
||||||
|
private String knowledgeBasePath;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApiDocumentRepository apiDocumentRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FrontmatterParser frontmatterParser;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private KnowledgeIndexService knowledgeIndexService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化知识库
|
||||||
|
*
|
||||||
|
* @param force 是否强制重新导入(跳过去重检查)
|
||||||
|
* @return 初始化结果
|
||||||
|
*/
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public InitResult initializeKnowledgeBase(boolean force) {
|
||||||
|
logger.info("开始初始化知识库: basePath={}, force={}", knowledgeBasePath, force);
|
||||||
|
|
||||||
|
InitResult result = new InitResult();
|
||||||
|
Path baseDir = Paths.get(knowledgeBasePath);
|
||||||
|
|
||||||
|
if (!Files.exists(baseDir)) {
|
||||||
|
logger.error("知识库目录不存在: {}", knowledgeBasePath);
|
||||||
|
throw new RuntimeException("知识库目录不存在: " + knowledgeBasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 扫描所有 Markdown 文件
|
||||||
|
List<Path> markdownFiles = scanMarkdownFiles(baseDir);
|
||||||
|
result.setScanned(markdownFiles.size());
|
||||||
|
logger.info("扫描到 {} 个 Markdown 文件", markdownFiles.size());
|
||||||
|
|
||||||
|
// 2. 如果非强制模式,获取已存在的文档(用于去重)
|
||||||
|
Set<String> existingFilePaths = new HashSet<>();
|
||||||
|
if (!force) {
|
||||||
|
existingFilePaths = apiDocumentRepository.findAll().stream()
|
||||||
|
.map(ApiDocument::getFilePath)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
logger.info("已存在 个文档记录", existingFilePaths.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 逐个处理文档
|
||||||
|
for (Path file : markdownFiles) {
|
||||||
|
String relativePath = baseDir.relativize(file).toString().replace("\\", "/");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 去重检查
|
||||||
|
if (!force && existingFilePaths.contains(relativePath)) {
|
||||||
|
logger.debug("跳过已存在的文档: {}", relativePath);
|
||||||
|
result.incrementSkipped();
|
||||||
|
result.addDetail(relativePath, "已存在,跳过");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析文档
|
||||||
|
String content = Files.readString(file);
|
||||||
|
Frontmatter frontmatter = frontmatterParser.parse(content);
|
||||||
|
|
||||||
|
if (frontmatter == null) {
|
||||||
|
logger.warn("文档格式无效: {}, frontmatter 解析失败", relativePath);
|
||||||
|
result.incrementFailed();
|
||||||
|
result.addDetail(relativePath, "格式无效: frontmatter 解析失败");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取字段
|
||||||
|
String title = frontmatter.getTitle();
|
||||||
|
String summary = frontmatter.getSummary();
|
||||||
|
String category = frontmatter.getCategory() != null ? frontmatter.getCategory() : "general";
|
||||||
|
List<String> keywords = frontmatter.getKeywords();
|
||||||
|
|
||||||
|
if (title == null || title.isBlank()) {
|
||||||
|
logger.warn("文档缺少标题: {}", relativePath);
|
||||||
|
result.incrementFailed();
|
||||||
|
result.addDetail(relativePath, "缺少标题");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
ApiDocument document = saveToDatabase(relativePath, title, summary, category, content, keywords);
|
||||||
|
|
||||||
|
// 添加到 L0 内存索引
|
||||||
|
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||||
|
.filePath(relativePath)
|
||||||
|
.title(title)
|
||||||
|
.keywords(keywords)
|
||||||
|
.summary(summary)
|
||||||
|
.category(category)
|
||||||
|
.build();
|
||||||
|
knowledgeIndexService.addToIndex(entry);
|
||||||
|
|
||||||
|
// TODO: 上传到 Milvus (L1) - 需要通过独立的索引任务完成
|
||||||
|
// 当前版本只处理数据库入库和 L0 索引
|
||||||
|
|
||||||
|
result.incrementInserted();
|
||||||
|
result.addDetail(relativePath, "导入成功(L0)");
|
||||||
|
logger.info("文档导入成功: {} -> {} (L0 索引已更新)", relativePath, title);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("处理文档失败: {}", relativePath, e);
|
||||||
|
result.incrementFailed();
|
||||||
|
result.addDetail(relativePath, "处理失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("知识库初始化完成: 扫描={}, 跳过={}, 新增={}, 失败={}",
|
||||||
|
result.getScanned(), result.getSkipped(), result.getInserted(), result.getFailed());
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识库统计信息
|
||||||
|
*/
|
||||||
|
public Stats getStats() {
|
||||||
|
Stats stats = new Stats();
|
||||||
|
|
||||||
|
// 数据库中的文档数量
|
||||||
|
long totalDocuments = apiDocumentRepository.count();
|
||||||
|
stats.setTotalDocuments(totalDocuments);
|
||||||
|
|
||||||
|
// L0 索引中的文档数量
|
||||||
|
int indexSize = knowledgeIndexService.getIndexSize();
|
||||||
|
logger.debug("L0 索引大小: {}", indexSize);
|
||||||
|
|
||||||
|
// 按分类统计(从 metadata JSON 中提取 category)
|
||||||
|
Map<String, Long> categoryCount = new HashMap<>();
|
||||||
|
apiDocumentRepository.findAll().forEach(doc -> {
|
||||||
|
String category = extractCategoryFromMetadata(doc.getMetadata());
|
||||||
|
categoryCount.merge(category, 1L, Long::sum);
|
||||||
|
});
|
||||||
|
stats.setCategoryCount(categoryCount);
|
||||||
|
|
||||||
|
// Milvus 中的向量数量(需要实现)
|
||||||
|
// TODO: 查询 Milvus collection 的实体数量
|
||||||
|
stats.setTotalVectors(0L);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描目录下所有 Markdown 文件
|
||||||
|
*/
|
||||||
|
private List<Path> scanMarkdownFiles(Path baseDir) {
|
||||||
|
List<Path> files = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.walkFileTree(baseDir, new SimpleFileVisitor<Path>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||||
|
if (file.toString().endsWith(".md")) {
|
||||||
|
files.add(file);
|
||||||
|
}
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFileFailed(Path file, IOException exc) {
|
||||||
|
logger.warn("访问文件失败: {}", file, exc);
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("扫描目录失败: {}", baseDir, e);
|
||||||
|
throw new RuntimeException("扫描目录失败", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存文档到数据库
|
||||||
|
*/
|
||||||
|
private ApiDocument saveToDatabase(String filePath, String title, String summary,
|
||||||
|
String category, String content, List<String> keywords) {
|
||||||
|
ApiDocument document = new ApiDocument();
|
||||||
|
document.setDocId(UUID.randomUUID().toString());
|
||||||
|
document.setFileName(Paths.get(filePath).getFileName().toString());
|
||||||
|
document.setFilePath(filePath);
|
||||||
|
document.setApiName(title); // 使用 title 作为 apiName
|
||||||
|
document.setStatus("INDEXED");
|
||||||
|
|
||||||
|
// 将 frontmatter 信息保存到 metadata(JSON 格式)
|
||||||
|
String metadataJson = String.format(
|
||||||
|
"{\"title\":\"%s\",\"summary\":\"%s\",\"category\":\"%s\",\"keywords\":%s}",
|
||||||
|
escapeJson(title),
|
||||||
|
escapeJson(summary),
|
||||||
|
escapeJson(category),
|
||||||
|
"[\"" + String.join("\",\"", keywords.stream().map(this::escapeJson).toArray(String[]::new)) + "\"]"
|
||||||
|
);
|
||||||
|
document.setMetadata(metadataJson);
|
||||||
|
|
||||||
|
document.setFileSize((long) content.length());
|
||||||
|
document.setIndexedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
return apiDocumentRepository.save(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 转义
|
||||||
|
*/
|
||||||
|
private String escapeJson(String str) {
|
||||||
|
if (str == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return str.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 metadata JSON 中提取 category
|
||||||
|
*/
|
||||||
|
private String extractCategoryFromMetadata(String metadata) {
|
||||||
|
if (metadata == null || metadata.isEmpty()) {
|
||||||
|
return "general";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 简单的 JSON 解析(提取 "category":"xxx")
|
||||||
|
int categoryIndex = metadata.indexOf("\"category\":\"");
|
||||||
|
if (categoryIndex == -1) {
|
||||||
|
return "general";
|
||||||
|
}
|
||||||
|
|
||||||
|
int startIndex = categoryIndex + "\"category\":\"".length();
|
||||||
|
int endIndex = metadata.indexOf("\"", startIndex);
|
||||||
|
if (endIndex == -1) {
|
||||||
|
return "general";
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata.substring(startIndex, endIndex);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("解析 metadata 失败: {}", metadata, e);
|
||||||
|
return "general";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取文档正文(去除 frontmatter)
|
||||||
|
*/
|
||||||
|
private String extractBody(String content) {
|
||||||
|
if (!content.trim().startsWith("---")) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
int firstEnd = content.indexOf("---", 3);
|
||||||
|
if (firstEnd == -1) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
int secondEnd = content.indexOf("---", firstEnd + 3);
|
||||||
|
if (secondEnd == -1) {
|
||||||
|
return content.substring(firstEnd + 3).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.substring(secondEnd + 3).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据模型 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化结果
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public static class InitResult {
|
||||||
|
private int scanned; // 扫描到的文件数量
|
||||||
|
private int skipped; // 跳过的文件数量(已存在)
|
||||||
|
private int inserted; // 成功导入的文件数量
|
||||||
|
private int failed; // 失败的文件数量
|
||||||
|
private Map<String, String> details = new LinkedHashMap<>(); // 详细信息
|
||||||
|
|
||||||
|
public void incrementSkipped() {
|
||||||
|
this.skipped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void incrementInserted() {
|
||||||
|
this.inserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void incrementFailed() {
|
||||||
|
this.failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addDetail(String filePath, String message) {
|
||||||
|
this.details.put(filePath, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计信息
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public static class Stats {
|
||||||
|
private long totalDocuments; // 数据库中的文档总数
|
||||||
|
private long totalVectors; // Milvus 中的向量总数
|
||||||
|
private Map<String, Long> categoryCount; // 按分类统计
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user