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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user