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:
zhuyongxin
2026-06-24 16:07:10 +08:00
parent c86045b33f
commit d6229f3385
32 changed files with 5396 additions and 67 deletions
@@ -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
}
}