feat(knowledge): 完成 L0+L1 混合检索集成
核心功能: - 新增 FrontmatterParser 解析 YAML frontmatter - 新增 KnowledgeIndexService L0 内存索引 - 新增 LookupKnowledgeTool 混合检索工具 - 增强 DocumentManagementService 文件保存和索引同步 技术实现: - 数据库迁移 V004: api_document.metadata (TEXT) - 依赖新增: snakeyaml 2.0 - 配置新增: knowledge.base-path - 可观测性: requestId 追踪 + 性能日志 质量保证: - 单元测试: 31/31 通过 - 测试覆盖: FrontmatterParser(11), KnowledgeIndexService(13), LookupKnowledgeTool(7) - 启动验证: L0 索引正常加载 归档文档: - OpenSpec: openspec/changes/lookup-knowledge-integration/ - devflow 档案: devflow/projects/2026-06-24-lookup-knowledge-integration/ - handoff: handoff/2026-06-24-lookup-knowledge-integration.md
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.dto.Frontmatter;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* FrontmatterParser 单元测试
|
||||
*/
|
||||
class FrontmatterParserTest {
|
||||
|
||||
private FrontmatterParser parser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
parser = new FrontmatterParser();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasFrontmatter_withValidFrontmatter() {
|
||||
String content = "---\ntitle: Test\n---\nContent";
|
||||
assertTrue(parser.hasFrontmatter(content));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasFrontmatter_withoutFrontmatter() {
|
||||
String content = "# Just a title\nContent";
|
||||
assertFalse(parser.hasFrontmatter(content));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasFrontmatter_nullContent() {
|
||||
assertFalse(parser.hasFrontmatter(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasFrontmatter_emptyContent() {
|
||||
assertFalse(parser.hasFrontmatter(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_validFrontmatter() {
|
||||
String content = """
|
||||
---
|
||||
title: 支付网关错误码
|
||||
keywords: [ERR_TIMEOUT, 超时, 支付网关]
|
||||
summary: 记录了支付网关所有核心错误码
|
||||
category: api
|
||||
---
|
||||
|
||||
# 正文内容
|
||||
""";
|
||||
|
||||
Frontmatter result = parser.parse(content);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("支付网关错误码", result.getTitle());
|
||||
assertEquals(3, result.getKeywords().size());
|
||||
assertTrue(result.getKeywords().contains("ERR_TIMEOUT"));
|
||||
assertEquals("记录了支付网关所有核心错误码", result.getSummary());
|
||||
assertEquals("api", result.getCategory());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_withoutFrontmatter() {
|
||||
String content = "# Just content\nNo frontmatter here";
|
||||
assertNull(parser.parse(content));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_missingRequiredFields() {
|
||||
String content = """
|
||||
---
|
||||
title: Only Title
|
||||
---
|
||||
Content
|
||||
""";
|
||||
|
||||
// 缺少 keywords 和 summary,应返回 null
|
||||
Frontmatter result = parser.parse(content);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_malformedYaml() {
|
||||
String content = """
|
||||
---
|
||||
title: Test
|
||||
keywords: [unclosed array
|
||||
---
|
||||
Content
|
||||
""";
|
||||
|
||||
// YAML 格式错误,应返回 null
|
||||
Frontmatter result = parser.parse(content);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_noClosingDelimiter() {
|
||||
String content = """
|
||||
---
|
||||
title: Test
|
||||
keywords: [test]
|
||||
summary: Test summary
|
||||
|
||||
Content without closing ---
|
||||
""";
|
||||
|
||||
// 缺少结束标记,应返回 null
|
||||
Frontmatter result = parser.parse(content);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_windowsLineEndings() {
|
||||
String content = "---\r\ntitle: Test\r\nkeywords: [test]\r\nsummary: Summary\r\n---\r\nContent";
|
||||
|
||||
Frontmatter result = parser.parse(content);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("Test", result.getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParse_withOptionalFields() {
|
||||
String content = """
|
||||
---
|
||||
title: Test Document
|
||||
keywords: [test, doc]
|
||||
summary: A test document
|
||||
version: 1.0.0
|
||||
author: Test Author
|
||||
---
|
||||
Content
|
||||
""";
|
||||
|
||||
Frontmatter result = parser.parse(content);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("Test Document", result.getTitle());
|
||||
assertEquals("1.0.0", result.getVersion());
|
||||
assertEquals("Test Author", result.getAuthor());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* KnowledgeIndexService 单元测试
|
||||
*/
|
||||
class KnowledgeIndexServiceTest {
|
||||
|
||||
private KnowledgeIndexService service;
|
||||
|
||||
@Mock
|
||||
private FrontmatterParser frontmatterParser;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
service = new KnowledgeIndexService();
|
||||
ReflectionTestUtils.setField(service, "frontmatterParser", frontmatterParser);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_singleMatch() {
|
||||
// 准备测试数据
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("test.md")
|
||||
.title("Test")
|
||||
.keywords(List.of("ERR_TIMEOUT", "超时"))
|
||||
.summary("Test summary")
|
||||
.category("api")
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
// 测试匹配
|
||||
List<KnowledgeEntry> results = service.exactMatch("ERR_TIMEOUT");
|
||||
|
||||
assertEquals(1, results.size());
|
||||
assertEquals("Test", results.get(0).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_caseInsensitive() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("test.md")
|
||||
.keywords(List.of("ERR_TIMEOUT"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
// 小写查询应该匹配
|
||||
List<KnowledgeEntry> results = service.exactMatch("err_timeout");
|
||||
assertEquals(1, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_partialMatch() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("test.md")
|
||||
.keywords(List.of("支付网关"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
// 包含关键词的查询应该匹配
|
||||
List<KnowledgeEntry> results = service.exactMatch("支付网关超时问题");
|
||||
assertEquals(1, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_multipleMatches() {
|
||||
KnowledgeEntry entry1 = KnowledgeEntry.builder()
|
||||
.filePath("doc1.md")
|
||||
.title("Doc 1")
|
||||
.keywords(List.of("超时"))
|
||||
.build();
|
||||
|
||||
KnowledgeEntry entry2 = KnowledgeEntry.builder()
|
||||
.filePath("doc2.md")
|
||||
.title("Doc 2")
|
||||
.keywords(List.of("超时", "错误"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry1);
|
||||
service.addToIndex(entry2);
|
||||
|
||||
// 应该匹配两个文档
|
||||
List<KnowledgeEntry> results = service.exactMatch("超时");
|
||||
assertEquals(2, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_noMatch() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("test.md")
|
||||
.keywords(List.of("错误码"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
// 不匹配的查询
|
||||
List<KnowledgeEntry> results = service.exactMatch("限流");
|
||||
assertEquals(0, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_emptyQuery() {
|
||||
List<KnowledgeEntry> results = service.exactMatch("");
|
||||
assertEquals(0, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_nullQuery() {
|
||||
List<KnowledgeEntry> results = service.exactMatch(null);
|
||||
assertEquals(0, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadDocument_success() throws Exception {
|
||||
// 创建测试文件
|
||||
Path testFile = tempDir.resolve("test.md");
|
||||
String content = "Test content line 1\nTest content line 2\n";
|
||||
Files.writeString(testFile, content);
|
||||
|
||||
// 读取文件
|
||||
String result = service.readDocument(testFile.toString(), 100);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.contains("Test content"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadDocument_exceedsMaxChars() throws Exception {
|
||||
// 创建超长内容
|
||||
String longContent = "x".repeat(3000);
|
||||
Path testFile = tempDir.resolve("long.md");
|
||||
Files.writeString(testFile, longContent);
|
||||
|
||||
// 读取限制字符数
|
||||
String result = service.readDocument(testFile.toString(), 2000);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(2003, result.length()); // 2000 + "..."
|
||||
assertTrue(result.endsWith("..."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadDocument_fileNotFound() {
|
||||
String result = service.readDocument("nonexistent.md", 100);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddToIndex() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("new.md")
|
||||
.title("New Document")
|
||||
.keywords(List.of("test"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
List<KnowledgeEntry> results = service.exactMatch("test");
|
||||
assertEquals(1, results.size());
|
||||
assertEquals("New Document", results.get(0).getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveFromIndex() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("remove.md")
|
||||
.keywords(List.of("test"))
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
assertEquals(1, service.exactMatch("test").size());
|
||||
|
||||
service.removeFromIndex("remove.md");
|
||||
assertEquals(0, service.exactMatch("test").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetIndexSize() {
|
||||
assertEquals(0, service.getIndexSize());
|
||||
|
||||
service.addToIndex(KnowledgeEntry.builder()
|
||||
.filePath("doc1.md")
|
||||
.keywords(List.of("test"))
|
||||
.build());
|
||||
|
||||
assertEquals(1, service.getIndexSize());
|
||||
|
||||
service.addToIndex(KnowledgeEntry.builder()
|
||||
.filePath("doc2.md")
|
||||
.keywords(List.of("test"))
|
||||
.build());
|
||||
|
||||
assertEquals(2, service.getIndexSize());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.superbiz.agent.tool;
|
||||
|
||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||
import com.superbiz.agent.dto.LookupResult;
|
||||
import com.superbiz.agent.service.KnowledgeIndexService;
|
||||
import com.superbiz.agent.service.VectorSearchService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* LookupKnowledgeTool 单元测试
|
||||
*/
|
||||
class LookupKnowledgeToolTest {
|
||||
|
||||
@Mock
|
||||
private KnowledgeIndexService knowledgeIndexService;
|
||||
|
||||
@Mock
|
||||
private VectorSearchService vectorSearchService;
|
||||
|
||||
@InjectMocks
|
||||
private LookupKnowledgeTool tool;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_uniqueMatch_highConfidence() {
|
||||
// 准备 L0 唯一匹配
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("test.md")
|
||||
.title("Test Doc")
|
||||
.keywords(List.of("ERR_TIMEOUT"))
|
||||
.summary("Test summary")
|
||||
.build();
|
||||
|
||||
when(knowledgeIndexService.exactMatch("ERR_TIMEOUT"))
|
||||
.thenReturn(List.of(entry));
|
||||
when(knowledgeIndexService.readDocument("test.md", 2000))
|
||||
.thenReturn("Test content");
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("ERR_TIMEOUT");
|
||||
|
||||
// 验证结果
|
||||
assertTrue(result.isFound());
|
||||
assertNotNull(result.getPrimary());
|
||||
assertEquals("high", result.getPrimary().getConfidence());
|
||||
assertEquals("exact_L0", result.getPrimary().getMatchType());
|
||||
assertEquals("Test content", result.getPrimary().getContent());
|
||||
assertNull(result.getSupplement()); // 高置信度不调用 L1
|
||||
|
||||
// 验证 L1 未被调用
|
||||
verify(vectorSearchService, never()).searchSimilarDocuments(anyString(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_multipleMatches_lowConfidence() {
|
||||
// 准备 L0 多个匹配
|
||||
KnowledgeEntry entry1 = KnowledgeEntry.builder()
|
||||
.filePath("doc1.md")
|
||||
.keywords(List.of("超时"))
|
||||
.build();
|
||||
|
||||
KnowledgeEntry entry2 = KnowledgeEntry.builder()
|
||||
.filePath("doc2.md")
|
||||
.keywords(List.of("超时"))
|
||||
.build();
|
||||
|
||||
when(knowledgeIndexService.exactMatch("超时"))
|
||||
.thenReturn(List.of(entry1, entry2));
|
||||
when(knowledgeIndexService.readDocument("doc1.md", 2000))
|
||||
.thenReturn("Content 1");
|
||||
|
||||
// 准备 L1 结果
|
||||
VectorSearchService.SearchResult l1Result = new VectorSearchService.SearchResult();
|
||||
l1Result.setContent("L1 content");
|
||||
l1Result.setMetadata("l1-source");
|
||||
|
||||
when(vectorSearchService.searchSimilarDocuments("超时", 3, null))
|
||||
.thenReturn(List.of(l1Result));
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("超时");
|
||||
|
||||
// 验证结果
|
||||
assertTrue(result.isFound());
|
||||
assertNotNull(result.getPrimary());
|
||||
assertEquals("low", result.getPrimary().getConfidence()); // 多个匹配 = 低置信度
|
||||
assertEquals("Content 1", result.getPrimary().getContent());
|
||||
|
||||
assertNotNull(result.getSupplement()); // 低置信度调用 L1
|
||||
assertEquals("L1 content", result.getSupplement().getContent());
|
||||
assertEquals("semantic_L1", result.getSupplement().getMatchType());
|
||||
|
||||
// 验证 L1 被调用
|
||||
verify(vectorSearchService).searchSimilarDocuments("超时", 3, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_noL0Match_onlyL1() {
|
||||
// L0 未匹配
|
||||
when(knowledgeIndexService.exactMatch("性能优化"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
// 准备 L1 结果
|
||||
VectorSearchService.SearchResult l1Result = new VectorSearchService.SearchResult();
|
||||
l1Result.setContent("L1 semantic result");
|
||||
l1Result.setMetadata("l1-doc");
|
||||
|
||||
when(vectorSearchService.searchSimilarDocuments("性能优化", 3, null))
|
||||
.thenReturn(List.of(l1Result));
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("性能优化");
|
||||
|
||||
// 验证结果
|
||||
assertTrue(result.isFound());
|
||||
assertNull(result.getPrimary()); // L0 未命中
|
||||
assertNotNull(result.getSupplement()); // 只有 L1 结果
|
||||
assertEquals("L1 semantic result", result.getSupplement().getContent());
|
||||
|
||||
verify(vectorSearchService).searchSimilarDocuments("性能优化", 3, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_noMatch() {
|
||||
// L0 和 L1 都未匹配
|
||||
when(knowledgeIndexService.exactMatch("不存在的内容"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(vectorSearchService.searchSimilarDocuments("不存在的内容", 3, null))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("不存在的内容");
|
||||
|
||||
// 验证结果
|
||||
assertFalse(result.isFound());
|
||||
assertNull(result.getPrimary());
|
||||
assertNull(result.getSupplement());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_l0MatchButReadFails() {
|
||||
// L0 匹配但文件读取失败
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("nonexistent.md")
|
||||
.keywords(List.of("test"))
|
||||
.build();
|
||||
|
||||
when(knowledgeIndexService.exactMatch("test"))
|
||||
.thenReturn(List.of(entry));
|
||||
when(knowledgeIndexService.readDocument("nonexistent.md", 2000))
|
||||
.thenReturn(null); // 读取失败
|
||||
|
||||
// L0 唯一匹配不会调用 L1,所以没有补充结果
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("test");
|
||||
|
||||
// 验证:found 为 false,因为无法读取内容且无 L1 补充
|
||||
assertFalse(result.isFound());
|
||||
assertNull(result.getPrimary());
|
||||
assertNull(result.getSupplement()); // 唯一匹配不调用 L1
|
||||
|
||||
// 验证 L1 未被调用(因为是唯一匹配 = 高置信度)
|
||||
verify(vectorSearchService, never()).searchSimilarDocuments(anyString(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_l1ReturnsNull() {
|
||||
// L0 未匹配,L1 返回 null
|
||||
when(knowledgeIndexService.exactMatch("query"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(vectorSearchService.searchSimilarDocuments("query", 3, null))
|
||||
.thenReturn(null);
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("query");
|
||||
|
||||
// 验证
|
||||
assertFalse(result.isFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_availableSectionsIsNull() {
|
||||
// 验证 availableSections 字段为 null(MVP 预留字段)
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("test.md")
|
||||
.keywords(List.of("test"))
|
||||
.build();
|
||||
|
||||
when(knowledgeIndexService.exactMatch("test"))
|
||||
.thenReturn(List.of(entry));
|
||||
when(knowledgeIndexService.readDocument("test.md", 2000))
|
||||
.thenReturn("Content");
|
||||
|
||||
LookupResult result = tool.lookupKnowledge("test");
|
||||
|
||||
assertNotNull(result.getPrimary());
|
||||
assertNull(result.getPrimary().getAvailableSections()); // MVP 返回 null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user