feat: treat l0 retrieval as domain hint
This commit is contained in:
@@ -98,6 +98,46 @@ class KnowledgeIndexServiceTest {
|
||||
assertEquals(2, results.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAnalyzeQuery_returnsStructuredHint() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("mysql.md")
|
||||
.title("MySQL Doc")
|
||||
.keywords(List.of("mysql", "connection pool"))
|
||||
.category("database")
|
||||
.build();
|
||||
|
||||
service.addToIndex(entry);
|
||||
|
||||
KnowledgeIndexService.L0Hint hint = service.analyzeQuery("mysql connection pool timeout");
|
||||
|
||||
assertEquals(1, hint.matches().size());
|
||||
assertEquals(List.of("mysql", "connection pool"), hint.matchedKeywords());
|
||||
assertEquals(List.of("database"), hint.domains());
|
||||
assertEquals(List.of("mysql", "connection pool"), hint.entities());
|
||||
assertEquals(List.of("MySQL Doc"), hint.titles());
|
||||
assertEquals("database", hint.singleDomainOrNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAnalyzeQuery_multipleDomainsHasNoSingleDomain() {
|
||||
service.addToIndex(KnowledgeEntry.builder()
|
||||
.filePath("mysql.md")
|
||||
.keywords(List.of("timeout"))
|
||||
.category("database")
|
||||
.build());
|
||||
service.addToIndex(KnowledgeEntry.builder()
|
||||
.filePath("api.md")
|
||||
.keywords(List.of("timeout"))
|
||||
.category("api")
|
||||
.build());
|
||||
|
||||
KnowledgeIndexService.L0Hint hint = service.analyzeQuery("timeout");
|
||||
|
||||
assertEquals(2, hint.matches().size());
|
||||
assertNull(hint.singleDomainOrNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExactMatch_noMatch() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
|
||||
@@ -75,6 +75,9 @@ class ToolInvocationRecorderTest {
|
||||
.success(true)
|
||||
.evidenceStatus(ToolInvocationRecorder.EVIDENCE_STATUS_DEDUPED)
|
||||
.l0Titles(List.of("payment/errors.md"))
|
||||
.l0MatchedKeywords(List.of("ERR_TIMEOUT"))
|
||||
.l0Domains(List.of("payment"))
|
||||
.l0Entities(List.of("ERR_TIMEOUT"))
|
||||
.build();
|
||||
|
||||
try {
|
||||
@@ -92,5 +95,8 @@ class ToolInvocationRecorderTest {
|
||||
assertEquals("doc_retrieved", saved.getDedupReason());
|
||||
assertTrue(saved.getRetrievalDetails().contains("\"evidence_status\":\"deduped\""));
|
||||
assertTrue(saved.getRetrievalDetails().contains("\"retrieved_domains\":[\"payment\"]"));
|
||||
assertTrue(saved.getRetrievalDetails().contains("\"l0_matched_keywords\":[\"ERR_TIMEOUT\"]"));
|
||||
assertTrue(saved.getRetrievalDetails().contains("\"l0_domains\":[\"payment\"]"));
|
||||
assertTrue(saved.getRetrievalDetails().contains("\"l0_entities\":[\"ERR_TIMEOUT\"]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ class LookupKnowledgeToolTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_uniqueMatch_highConfidence() {
|
||||
void testLookup_uniqueMatch_usesL0HintAndL1() {
|
||||
// 准备 L0 唯一匹配
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("test.md")
|
||||
@@ -57,10 +57,16 @@ class LookupKnowledgeToolTest {
|
||||
.summary("Test summary")
|
||||
.build();
|
||||
|
||||
when(knowledgeIndexService.exactMatch("ERR_TIMEOUT"))
|
||||
.thenReturn(List.of(entry));
|
||||
when(knowledgeIndexService.analyzeQuery("ERR_TIMEOUT"))
|
||||
.thenReturn(hint(entry));
|
||||
when(knowledgeIndexService.readDocument("test.md", 2000))
|
||||
.thenReturn("Test content * 用于构建紧凑摘要 * keyword2");
|
||||
VectorSearchService.SearchResult l1Result = new VectorSearchService.SearchResult();
|
||||
l1Result.setContent("L1 supporting content");
|
||||
l1Result.setMetadata("l1-source");
|
||||
l1Result.setScore(0.4f);
|
||||
when(vectorSearchService.searchSimilarDocuments("ERR_TIMEOUT", 3, null))
|
||||
.thenReturn(List.of(l1Result));
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("ERR_TIMEOUT");
|
||||
@@ -75,10 +81,10 @@ class LookupKnowledgeToolTest {
|
||||
assertTrue(content.contains("文档: Test Doc"));
|
||||
assertTrue(content.contains("摘要: Test summary"));
|
||||
assertTrue(content.contains("Test content"));
|
||||
assertNull(result.getSupplement()); // 高置信度不调用 L1
|
||||
assertNotNull(result.getSupplement()); // L0 唯一命中仍调用 L1
|
||||
assertEquals("PRECISE", result.getRelevanceLevel());
|
||||
|
||||
// 验证 L1 未被调用
|
||||
verify(vectorSearchService, never()).searchSimilarDocuments(anyString(), anyInt(), any());
|
||||
verify(vectorSearchService).searchSimilarDocuments("ERR_TIMEOUT", 3, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -96,8 +102,8 @@ class LookupKnowledgeToolTest {
|
||||
.keywords(List.of("超时"))
|
||||
.build();
|
||||
|
||||
when(knowledgeIndexService.exactMatch("超时"))
|
||||
.thenReturn(List.of(entry1, entry2));
|
||||
when(knowledgeIndexService.analyzeQuery("超时"))
|
||||
.thenReturn(hint(entry1, entry2));
|
||||
// 多匹配 + L1 有结果 → buildMetadataOnlySummary(),不读文件,不调用 readDocument
|
||||
|
||||
// 准备 L1 结果
|
||||
@@ -134,8 +140,8 @@ class LookupKnowledgeToolTest {
|
||||
@Test
|
||||
void testLookup_noL0Match_onlyL1() {
|
||||
// L0 未匹配
|
||||
when(knowledgeIndexService.exactMatch("性能优化"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(knowledgeIndexService.analyzeQuery("性能优化"))
|
||||
.thenReturn(KnowledgeIndexService.L0Hint.empty());
|
||||
|
||||
// 准备 L1 结果
|
||||
VectorSearchService.SearchResult l1Result = new VectorSearchService.SearchResult();
|
||||
@@ -160,8 +166,8 @@ class LookupKnowledgeToolTest {
|
||||
@Test
|
||||
void testLookup_noMatch() {
|
||||
// L0 和 L1 都未匹配
|
||||
when(knowledgeIndexService.exactMatch("不存在的内容"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(knowledgeIndexService.analyzeQuery("不存在的内容"))
|
||||
.thenReturn(KnowledgeIndexService.L0Hint.empty());
|
||||
when(vectorSearchService.searchSimilarDocuments("不存在的内容", 3, null))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
@@ -182,12 +188,12 @@ class LookupKnowledgeToolTest {
|
||||
.keywords(List.of("test"))
|
||||
.build();
|
||||
|
||||
when(knowledgeIndexService.exactMatch("test"))
|
||||
.thenReturn(List.of(entry));
|
||||
when(knowledgeIndexService.analyzeQuery("test"))
|
||||
.thenReturn(hint(entry));
|
||||
when(knowledgeIndexService.readDocument("nonexistent.md", 2000))
|
||||
.thenReturn(null); // 读取失败
|
||||
|
||||
// L0 唯一匹配不会调用 L1,所以没有补充结果
|
||||
when(vectorSearchService.searchSimilarDocuments("test", 3, null))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("test");
|
||||
@@ -195,17 +201,16 @@ class LookupKnowledgeToolTest {
|
||||
// 验证:found 为 false,因为无法读取内容且无 L1 补充
|
||||
assertFalse(result.isFound());
|
||||
assertNull(result.getPrimary());
|
||||
assertNull(result.getSupplement()); // 唯一匹配不调用 L1
|
||||
assertNull(result.getSupplement());
|
||||
|
||||
// 验证 L1 未被调用(因为是唯一匹配 = 高置信度)
|
||||
verify(vectorSearchService, never()).searchSimilarDocuments(anyString(), anyInt(), any());
|
||||
verify(vectorSearchService).searchSimilarDocuments("test", 3, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_l1ReturnsNull() {
|
||||
// L0 未匹配,L1 返回 null
|
||||
when(knowledgeIndexService.exactMatch("query"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(knowledgeIndexService.analyzeQuery("query"))
|
||||
.thenReturn(KnowledgeIndexService.L0Hint.empty());
|
||||
when(vectorSearchService.searchSimilarDocuments("query", 3, null))
|
||||
.thenReturn(null);
|
||||
|
||||
@@ -224,14 +229,83 @@ class LookupKnowledgeToolTest {
|
||||
.keywords(List.of("test"))
|
||||
.build();
|
||||
|
||||
when(knowledgeIndexService.exactMatch("test"))
|
||||
.thenReturn(List.of(entry));
|
||||
when(knowledgeIndexService.analyzeQuery("test"))
|
||||
.thenReturn(hint(entry));
|
||||
when(knowledgeIndexService.readDocument("test.md", 2000))
|
||||
.thenReturn("Content");
|
||||
when(vectorSearchService.searchSimilarDocuments("test", 3, null))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
LookupResult result = tool.lookupKnowledge("test");
|
||||
|
||||
assertNotNull(result.getPrimary());
|
||||
assertNull(result.getPrimary().getAvailableSections()); // MVP 返回 null
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_appliesSingleL0DomainAsL1Filter() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("db.md")
|
||||
.title("Database Doc")
|
||||
.keywords(List.of("mysql"))
|
||||
.summary("Database summary")
|
||||
.category("database")
|
||||
.build();
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("mysql timeout"))
|
||||
.thenReturn(hint(entry));
|
||||
when(knowledgeIndexService.readDocument("db.md", 2000))
|
||||
.thenReturn("Database content");
|
||||
when(vectorSearchService.searchSimilarDocuments("mysql timeout", 3, "database"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
LookupResult result = tool.lookupKnowledge("mysql timeout");
|
||||
|
||||
assertTrue(result.isFound());
|
||||
verify(vectorSearchService).searchSimilarDocuments("mysql timeout", 3, "database");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_l1FailureKeepsL0Fallback() {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("fallback.md")
|
||||
.title("Fallback Doc")
|
||||
.keywords(List.of("fallback"))
|
||||
.summary("Fallback summary")
|
||||
.build();
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("fallback"))
|
||||
.thenReturn(hint(entry));
|
||||
when(knowledgeIndexService.readDocument("fallback.md", 2000))
|
||||
.thenReturn("Fallback content");
|
||||
when(vectorSearchService.searchSimilarDocuments("fallback", 3, null))
|
||||
.thenThrow(new RuntimeException("milvus unavailable"));
|
||||
|
||||
LookupResult result = tool.lookupKnowledge("fallback");
|
||||
|
||||
assertTrue(result.isFound());
|
||||
assertNotNull(result.getPrimary());
|
||||
assertNull(result.getSupplement());
|
||||
assertEquals("REFERENCE", result.getRelevanceLevel());
|
||||
verify(vectorSearchService).searchSimilarDocuments("fallback", 3, null);
|
||||
}
|
||||
|
||||
private KnowledgeIndexService.L0Hint hint(KnowledgeEntry... entries) {
|
||||
List<KnowledgeEntry> matches = List.of(entries);
|
||||
List<String> keywords = matches.stream()
|
||||
.flatMap(entry -> entry.getKeywords() == null ? java.util.stream.Stream.empty() : entry.getKeywords().stream())
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> domains = matches.stream()
|
||||
.map(KnowledgeEntry::getCategory)
|
||||
.filter(category -> category != null && !category.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> titles = matches.stream()
|
||||
.map(KnowledgeEntry::getTitle)
|
||||
.filter(title -> title != null && !title.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
return new KnowledgeIndexService.L0Hint(matches, keywords, domains, keywords, titles);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user