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,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