diff --git a/.docs/2026-06-25-knowledge-base-init-api.md b/.docs/2026-06-25-knowledge-base-init-api.md new file mode 100644 index 0000000..8cad35e --- /dev/null +++ b/.docs/2026-06-25-knowledge-base-init-api.md @@ -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` diff --git a/src/main/java/com/superbiz/agent/controller/KnowledgeBaseController.java b/src/main/java/com/superbiz/agent/controller/KnowledgeBaseController.java new file mode 100644 index 0000000..23a61e2 --- /dev/null +++ b/src/main/java/com/superbiz/agent/controller/KnowledgeBaseController.java @@ -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 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 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 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 response = new HashMap<>(); + response.put("success", false); + response.put("message", "查询失败: " + e.getMessage()); + + return ResponseEntity.internalServerError().body(response); + } + } +} diff --git a/src/main/java/com/superbiz/agent/service/AiOpsService.java b/src/main/java/com/superbiz/agent/service/AiOpsService.java index 0d0e630..8f4934a 100644 --- a/src/main/java/com/superbiz/agent/service/AiOpsService.java +++ b/src/main/java/com/superbiz/agent/service/AiOpsService.java @@ -154,7 +154,7 @@ public class AiOpsService { return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools, queryLogsTools}; } else { // 真实模式:不包含 QueryLogsTools(由 MCP 提供日志查询功能) - return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools, internalDocsTools}; + return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools}; } } } diff --git a/src/main/java/com/superbiz/agent/service/ChatService.java b/src/main/java/com/superbiz/agent/service/ChatService.java index 16a68fa..12111f9 100644 --- a/src/main/java/com/superbiz/agent/service/ChatService.java +++ b/src/main/java/com/superbiz/agent/service/ChatService.java @@ -6,6 +6,8 @@ import com.superbiz.agent.agent.tool.DateTimeTools; import com.superbiz.agent.agent.tool.InternalDocsTools; import com.superbiz.agent.agent.tool.QueryLogsTools; import com.superbiz.agent.agent.tool.QueryMetricsTools; +import com.superbiz.agent.tool.LookupKnowledgeTool; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.model.ChatModel; @@ -44,6 +46,9 @@ public class ChatService { @Autowired private ChatModel chatModel; + @Autowired + private LookupKnowledgeTool lookupKnowledgeTool; + /** * 获取注入的 ChatModel */ @@ -62,7 +67,7 @@ public class ChatService { // 基础系统提示 systemPromptBuilder.append("你是一个专业的智能助手,可以获取当前时间、查询天气信息、搜索内部文档知识库,以及查询 Prometheus 告警信息。\n"); systemPromptBuilder.append("当用户询问时间相关问题时,**必须每次都调用 getCurrentDateTime 工具**,因为时间会不断变化。即使历史消息中有时间信息,也不要直接复用,必须重新查询最新时间。\n"); - systemPromptBuilder.append("当用户需要查询公司内部文档、流程、最佳实践或技术指南时,使用 queryInternalDocs 工具。\n"); + systemPromptBuilder.append("当用户需要查询公司内部文档、流程、最佳实践或技术指南时,使用 lookupKnowledgeTool 工具。\n"); systemPromptBuilder.append("当用户需要查询 Prometheus 告警、监控指标或系统告警状态时,使用 queryPrometheusAlerts 工具。\n"); systemPromptBuilder.append("当用户需要查询腾讯云日志时,请调用腾讯云mcp服务查询,默认查询地域ap-guangzhou,查询时间范围为近一个月。\n\n"); @@ -126,10 +131,10 @@ public class ChatService { public Object[] buildMethodToolsArray() { if (queryLogsTools != null) { // Mock 模式:包含 QueryLogsTools - return new Object[]{dateTimeTools, internalDocsTools, queryMetricsTools, queryLogsTools}; + return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools, queryLogsTools}; } else { // 真实模式:不包含 QueryLogsTools(由 MCP 提供日志查询功能) - return new Object[]{dateTimeTools, internalDocsTools, queryMetricsTools}; + return new Object[]{dateTimeTools, lookupKnowledgeTool, queryMetricsTools}; } } diff --git a/src/main/java/com/superbiz/agent/service/KnowledgeBaseInitService.java b/src/main/java/com/superbiz/agent/service/KnowledgeBaseInitService.java new file mode 100644 index 0000000..863d43b --- /dev/null +++ b/src/main/java/com/superbiz/agent/service/KnowledgeBaseInitService.java @@ -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 markdownFiles = scanMarkdownFiles(baseDir); + result.setScanned(markdownFiles.size()); + logger.info("扫描到 {} 个 Markdown 文件", markdownFiles.size()); + + // 2. 如果非强制模式,获取已存在的文档(用于去重) + Set 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 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 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 scanMarkdownFiles(Path baseDir) { + List files = new ArrayList<>(); + + try { + Files.walkFileTree(baseDir, new SimpleFileVisitor() { + @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 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 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 categoryCount; // 按分类统计 + } +}