feat: treat l0 retrieval as domain hint

This commit is contained in:
aruo
2026-07-05 02:18:40 +08:00
parent 9a2a44d1b5
commit 4a94c14feb
12 changed files with 494 additions and 74 deletions
@@ -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);
}
}