feat(rag): modularize knowledge retrieval pipeline
This commit is contained in:
@@ -1,27 +1,36 @@
|
||||
package com.superbiz.agent.tool;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||
import com.superbiz.agent.dto.LookupResult;
|
||||
import com.superbiz.agent.service.KnowledgeContextPacker;
|
||||
import com.superbiz.agent.service.KnowledgeDocumentRetriever;
|
||||
import com.superbiz.agent.service.KnowledgeEvidencePostProcessor;
|
||||
import com.superbiz.agent.service.KnowledgeIndexService;
|
||||
import com.superbiz.agent.service.KnowledgeQueryTransformer;
|
||||
import com.superbiz.agent.service.LookupResultAssembler;
|
||||
import com.superbiz.agent.service.ToolInvocationRecorder;
|
||||
import com.superbiz.agent.service.VectorSearchService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.util.SessionContextHolder;
|
||||
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 org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* LookupKnowledgeTool 单元测试
|
||||
* LookupKnowledgeTool evidence-first contract tests.
|
||||
*/
|
||||
class LookupKnowledgeToolTest {
|
||||
|
||||
@@ -37,305 +46,271 @@ class LookupKnowledgeToolTest {
|
||||
@Mock
|
||||
private RetrievedDocTracker retrievedDocTracker;
|
||||
|
||||
@Mock
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@InjectMocks
|
||||
private LookupKnowledgeTool tool;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
|
||||
KnowledgeEvidencePostProcessor postProcessor = new KnowledgeEvidencePostProcessor();
|
||||
KnowledgeContextPacker contextPacker = new KnowledgeContextPacker();
|
||||
tool = new LookupKnowledgeTool();
|
||||
ReflectionTestUtils.setField(tool, "queryTransformer", new KnowledgeQueryTransformer(knowledgeIndexService));
|
||||
ReflectionTestUtils.setField(tool, "documentRetriever",
|
||||
new KnowledgeDocumentRetriever(vectorSearchService, new ObjectMapper()));
|
||||
ReflectionTestUtils.setField(tool, "evidencePostProcessor", postProcessor);
|
||||
ReflectionTestUtils.setField(tool, "contextPacker", contextPacker);
|
||||
ReflectionTestUtils.setField(tool, "resultAssembler", new LookupResultAssembler());
|
||||
ReflectionTestUtils.setField(tool, "toolInvocationRecorder", toolInvocationRecorder);
|
||||
ReflectionTestUtils.setField(tool, "retrievedDocTracker", retrievedDocTracker);
|
||||
ReflectionTestUtils.setField(tool, "topK", 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_uniqueMatch_usesL0HintAndL1() {
|
||||
// 准备 L0 唯一匹配
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("test.md")
|
||||
.title("Test Doc")
|
||||
.keywords(List.of("ERR_TIMEOUT"))
|
||||
.summary("Test summary")
|
||||
.build();
|
||||
void filteredL1SuccessDoesNotRetry() {
|
||||
KnowledgeEntry entry = entry("db.md", "Database Doc", "mysql", "database");
|
||||
VectorSearchService.SearchResult result = searchResult(
|
||||
"vec-1",
|
||||
"db.md",
|
||||
"{\"_source\":\"db.md\",\"title\":\"Database Doc\",\"category\":\"database\"}",
|
||||
"mysql timeout runbook",
|
||||
0.2f);
|
||||
|
||||
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));
|
||||
when(knowledgeIndexService.analyzeQuery("mysql timeout")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("mysql timeout", 3, "database"))
|
||||
.thenReturn(List.of(result));
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("ERR_TIMEOUT");
|
||||
LookupResult lookup = tool.lookupKnowledge("mysql timeout");
|
||||
|
||||
// 验证结果
|
||||
assertTrue(result.isFound());
|
||||
assertNotNull(result.getPrimary());
|
||||
assertEquals("high", result.getPrimary().getConfidence());
|
||||
assertEquals("exact_L0", result.getPrimary().getMatchType());
|
||||
// 唯一匹配 → buildCompactSummary(),内容为结构化摘要
|
||||
String content = result.getPrimary().getContent();
|
||||
assertTrue(content.contains("文档: Test Doc"));
|
||||
assertTrue(content.contains("摘要: Test summary"));
|
||||
assertTrue(content.contains("Test content"));
|
||||
assertNotNull(result.getSupplement()); // L0 唯一命中仍调用 L1
|
||||
assertEquals("PRECISE", result.getRelevanceLevel());
|
||||
assertNotNull(result.getEvidenceBlocks());
|
||||
assertEquals(2, result.getEvidenceCandidateCount());
|
||||
assertEquals(2, result.getEvidenceBlockCount());
|
||||
assertEquals("L0", result.getEvidenceBlocks().get(0).getRetrievalLayer());
|
||||
assertTrue(result.getEvidenceBlocks().get(0).getHitReasons().stream()
|
||||
.anyMatch(reason -> reason.contains("ERR_TIMEOUT")));
|
||||
|
||||
verify(vectorSearchService).searchSimilarDocuments("ERR_TIMEOUT", 3, null);
|
||||
assertTrue(lookup.isFound());
|
||||
assertEquals(1, lookup.getEvidenceBlockCount());
|
||||
assertEquals("db.md", lookup.getEvidenceBlocks().get(0).getSource());
|
||||
assertNotNull(lookup.getContextPack());
|
||||
assertTrue(lookup.getContextPack().getPackedText().contains("mysql timeout runbook"));
|
||||
assertEquals("FILTERED_VECTOR", lookup.getRetrievalTrace().getSelectedAttempt());
|
||||
assertEquals(1, lookup.getRetrievalTrace().getAttempts().size());
|
||||
assertEquals("PRECISE", lookup.getRelevanceLevel());
|
||||
verify(vectorSearchService).searchSimilarDocuments("mysql timeout", 3, "database");
|
||||
verify(vectorSearchService, never()).searchSimilarDocuments("mysql timeout", 3, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_multipleMatches_lowConfidence() {
|
||||
// 准备 L0 多个匹配
|
||||
KnowledgeEntry entry1 = KnowledgeEntry.builder()
|
||||
.filePath("doc1.md")
|
||||
.title("测试文档1")
|
||||
.keywords(List.of("超时"))
|
||||
.summary("这是一个测试文档")
|
||||
.build();
|
||||
void filteredLowQualityTriggersRawUnfilteredRetry() {
|
||||
KnowledgeEntry entry = entry("db.md", "Database Doc", "mysql", "database");
|
||||
VectorSearchService.SearchResult weak = searchResult(
|
||||
"weak",
|
||||
"weak.md",
|
||||
"{\"_source\":\"weak.md\",\"title\":\"Weak\"}",
|
||||
"weak candidate",
|
||||
1.4f);
|
||||
VectorSearchService.SearchResult strong = searchResult(
|
||||
"strong",
|
||||
"strong.md",
|
||||
"{\"_source\":\"strong.md\",\"title\":\"Strong\"}",
|
||||
"mysql timeout strong runbook",
|
||||
0.2f);
|
||||
|
||||
KnowledgeEntry entry2 = KnowledgeEntry.builder()
|
||||
.filePath("doc2.md")
|
||||
.keywords(List.of("超时"))
|
||||
.build();
|
||||
when(knowledgeIndexService.analyzeQuery("mysql timeout")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("mysql timeout", 3, "database"))
|
||||
.thenReturn(List.of(weak));
|
||||
when(vectorSearchService.searchSimilarDocuments("mysql timeout", 3, null))
|
||||
.thenReturn(List.of(strong));
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("超时"))
|
||||
.thenReturn(hint(entry1, entry2));
|
||||
// 多匹配 + L1 有结果 → buildMetadataOnlySummary(),不读文件,不调用 readDocument
|
||||
LookupResult lookup = tool.lookupKnowledge("mysql timeout");
|
||||
|
||||
// 准备 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()); // 多个匹配 = 低置信度
|
||||
// 多匹配 + L1 有结果 → 仅元数据摘要
|
||||
String content = result.getPrimary().getContent();
|
||||
assertTrue(content.contains("文档: 测试文档1"));
|
||||
assertTrue(content.contains("摘要: 这是一个测试文档"));
|
||||
assertTrue(content.contains("关键词: 超时"));
|
||||
assertTrue(content.contains("来源: doc1.md"));
|
||||
|
||||
assertNotNull(result.getSupplement()); // 低置信度调用 L1
|
||||
assertEquals("L1 content", result.getSupplement().getContent());
|
||||
assertEquals("semantic_L1", result.getSupplement().getMatchType());
|
||||
|
||||
// 验证 L1 被调用,readDocument 未被调用(多匹配不走 buildCompactSummary)
|
||||
verify(vectorSearchService).searchSimilarDocuments("超时", 3, null);
|
||||
verify(knowledgeIndexService, never()).readDocument(anyString(), anyInt());
|
||||
assertTrue(lookup.isFound());
|
||||
assertEquals("UNFILTERED_VECTOR_RETRY", lookup.getRetrievalTrace().getSelectedAttempt());
|
||||
assertEquals("filtered_vector_low_quality", lookup.getRetrievalTrace().getFallbackReason());
|
||||
assertEquals(2, lookup.getRetrievalTrace().getAttempts().size());
|
||||
assertEquals("strong.md", lookup.getEvidenceBlocks().get(0).getSource());
|
||||
verify(vectorSearchService).searchSimilarDocuments("mysql timeout", 3, "database");
|
||||
verify(vectorSearchService).searchSimilarDocuments("mysql timeout", 3, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_noL0Match_onlyL1() {
|
||||
// L0 未匹配
|
||||
void filteredNoEvidenceTriggersRawUnfilteredRetry() {
|
||||
KnowledgeEntry entry = entry("db.md", "Database Doc", "mysql", "database");
|
||||
VectorSearchService.SearchResult strong = searchResult(
|
||||
"strong",
|
||||
"strong.md",
|
||||
"{\"_source\":\"strong.md\",\"title\":\"Strong\"}",
|
||||
"mysql timeout strong runbook",
|
||||
0.2f);
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("mysql timeout")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("mysql timeout", 3, "database"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(vectorSearchService.searchSimilarDocuments("mysql timeout", 3, null))
|
||||
.thenReturn(List.of(strong));
|
||||
|
||||
LookupResult lookup = tool.lookupKnowledge("mysql timeout");
|
||||
|
||||
assertTrue(lookup.isFound());
|
||||
assertEquals("UNFILTERED_VECTOR_RETRY", lookup.getRetrievalTrace().getSelectedAttempt());
|
||||
assertEquals("filtered_vector_no_evidence", lookup.getRetrievalTrace().getFallbackReason());
|
||||
assertEquals("strong.md", lookup.getEvidenceBlocks().get(0).getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void l0HintsDoNotBecomeStandaloneEvidenceWhenL1Fails() {
|
||||
KnowledgeEntry entry = entry("fallback.md", "Fallback Doc", "fallback", "database");
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("fallback")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("fallback", 3, "database"))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(vectorSearchService.searchSimilarDocuments("fallback", 3, null))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
LookupResult lookup = tool.lookupKnowledge("fallback");
|
||||
|
||||
assertFalse(lookup.isFound());
|
||||
assertEquals(0, lookup.getEvidenceBlockCount());
|
||||
assertTrue(lookup.getEvidenceBlocks().isEmpty());
|
||||
assertEquals("no_evidence", lookup.getRetrievalTrace().getEvidenceStatus());
|
||||
assertTrue(String.valueOf(lookup.getRetrievalTrace().getQueryHints()).contains("Fallback Doc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noL0HintUsesUnfilteredVectorSearch() {
|
||||
VectorSearchService.SearchResult result = searchResult(
|
||||
"vec-1",
|
||||
"perf.md",
|
||||
"{\"_source\":\"perf.md\",\"title\":\"Perf\"}",
|
||||
"performance tuning guide",
|
||||
0.3f);
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("性能优化"))
|
||||
.thenReturn(KnowledgeIndexService.L0Hint.empty());
|
||||
|
||||
// 准备 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));
|
||||
.thenReturn(List.of(result));
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("性能优化");
|
||||
|
||||
// 验证结果
|
||||
assertTrue(result.isFound());
|
||||
assertNull(result.getPrimary()); // L0 未命中
|
||||
assertNotNull(result.getSupplement()); // 只有 L1 结果
|
||||
assertEquals("L1 semantic result", result.getSupplement().getContent());
|
||||
LookupResult lookup = tool.lookupKnowledge("性能优化");
|
||||
|
||||
assertTrue(lookup.isFound());
|
||||
assertEquals("UNFILTERED_VECTOR", lookup.getRetrievalTrace().getSelectedAttempt());
|
||||
assertEquals("perf.md", lookup.getEvidenceBlocks().get(0).getSource());
|
||||
verify(vectorSearchService).searchSimilarDocuments("性能优化", 3, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_noMatch() {
|
||||
// L0 和 L1 都未匹配
|
||||
when(knowledgeIndexService.analyzeQuery("不存在的内容"))
|
||||
.thenReturn(KnowledgeIndexService.L0Hint.empty());
|
||||
when(vectorSearchService.searchSimilarDocuments("不存在的内容", 3, null))
|
||||
.thenReturn(Collections.emptyList());
|
||||
void rerankUsesHintMatchesAndContextPackPreservesMetadata() {
|
||||
KnowledgeEntry entry = entry("payment.md", "Payment", "ERR_TIMEOUT", "payment");
|
||||
VectorSearchService.SearchResult first = searchResult(
|
||||
"a",
|
||||
"a.md",
|
||||
"{\"_source\":\"a.md\",\"title\":\"Generic\",\"category\":\"other\"}",
|
||||
"generic troubleshooting",
|
||||
0.4f);
|
||||
VectorSearchService.SearchResult second = searchResult(
|
||||
"b",
|
||||
"b.md",
|
||||
"{\"_source\":\"b.md\",\"title\":\"Payment ERR_TIMEOUT\",\"breadcrumb\":\"Payment > Timeout\",\"category\":\"payment\"}",
|
||||
"payment ERR_TIMEOUT timeout diagnosis",
|
||||
0.45f);
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("不存在的内容");
|
||||
when(knowledgeIndexService.analyzeQuery("ERR_TIMEOUT")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("ERR_TIMEOUT", 3, "payment"))
|
||||
.thenReturn(List.of(first, second));
|
||||
|
||||
// 验证结果
|
||||
assertFalse(result.isFound());
|
||||
assertNull(result.getPrimary());
|
||||
assertNull(result.getSupplement());
|
||||
LookupResult lookup = tool.lookupKnowledge("ERR_TIMEOUT");
|
||||
|
||||
assertTrue(lookup.isFound());
|
||||
assertEquals("b.md", lookup.getEvidenceBlocks().get(0).getSource());
|
||||
assertTrue(lookup.getRerankTrace().getItems().get(0).getBoostReasons().stream()
|
||||
.anyMatch(reason -> reason.startsWith("domain_match")));
|
||||
assertTrue(lookup.getContextPack().getPackedText().contains("Payment > Timeout"));
|
||||
assertTrue(lookup.getContextPack().getPackedText().contains("reasons:"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_l0MatchButReadFails() {
|
||||
// L0 匹配但文件读取失败
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("nonexistent.md")
|
||||
.keywords(List.of("test"))
|
||||
void deduplicatesEvidenceBlocksBySource() {
|
||||
KnowledgeEntry entry = entry("shared.md", "Shared", "shared", "payment");
|
||||
VectorSearchService.SearchResult first = searchResult(
|
||||
"a",
|
||||
"shared.md",
|
||||
"{\"_source\":\"shared.md\",\"title\":\"Shared\"}",
|
||||
"shared content 1",
|
||||
0.2f);
|
||||
VectorSearchService.SearchResult second = searchResult(
|
||||
"b",
|
||||
"shared.md",
|
||||
"{\"_source\":\"shared.md\",\"title\":\"Shared\"}",
|
||||
"shared content 2",
|
||||
0.25f);
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("shared")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("shared", 3, "payment"))
|
||||
.thenReturn(List.of(first, second));
|
||||
|
||||
LookupResult lookup = tool.lookupKnowledge("shared");
|
||||
|
||||
assertTrue(lookup.isFound());
|
||||
assertEquals(2, lookup.getEvidenceCandidateCount());
|
||||
assertEquals(1, lookup.getEvidenceBlockCount());
|
||||
assertEquals("shared.md", lookup.getEvidenceBlocks().get(0).getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionDedupDoesNotReturnConsumableEvidenceAgain() {
|
||||
KnowledgeEntry entry = entry("payment.md", "Payment", "ERR_TIMEOUT", "payment");
|
||||
VectorSearchService.SearchResult result = searchResult(
|
||||
"vec-1",
|
||||
"payment.md",
|
||||
"{\"_source\":\"payment.md\",\"title\":\"Payment\",\"category\":\"payment\"}",
|
||||
"payment timeout runbook",
|
||||
0.2f);
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("ERR_TIMEOUT")).thenReturn(hint(entry));
|
||||
when(vectorSearchService.searchSimilarDocuments("ERR_TIMEOUT", 3, "payment"))
|
||||
.thenReturn(List.of(result));
|
||||
when(retrievedDocTracker.isAlreadyRetrieved("session-1", "payment.md")).thenReturn(true);
|
||||
when(retrievedDocTracker.getRetrievedDomains("session-1")).thenReturn(List.of("payment"));
|
||||
|
||||
SessionContextHolder.setSessionId("session-1");
|
||||
try {
|
||||
LookupResult lookup = tool.lookupKnowledge("ERR_TIMEOUT");
|
||||
|
||||
assertFalse(lookup.isFound());
|
||||
assertEquals(0, lookup.getEvidenceBlockCount());
|
||||
assertTrue(lookup.getEvidenceBlocks().isEmpty());
|
||||
assertTrue(lookup.getMessage().contains("无需重复召回"));
|
||||
assertEquals(List.of("payment"), lookup.getRetrievedDomainsThisSession());
|
||||
} finally {
|
||||
SessionContextHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private KnowledgeEntry entry(String filePath, String title, String keyword, String category) {
|
||||
return KnowledgeEntry.builder()
|
||||
.filePath(filePath)
|
||||
.title(title)
|
||||
.keywords(List.of(keyword))
|
||||
.summary(title + " summary")
|
||||
.category(category)
|
||||
.build();
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("test"))
|
||||
.thenReturn(hint(entry));
|
||||
when(knowledgeIndexService.readDocument("nonexistent.md", 2000))
|
||||
.thenReturn(null); // 读取失败
|
||||
when(vectorSearchService.searchSimilarDocuments("test", 3, null))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
// 执行查询
|
||||
LookupResult result = tool.lookupKnowledge("test");
|
||||
|
||||
// 验证:found 为 false,因为无法读取内容且无 L1 补充
|
||||
assertFalse(result.isFound());
|
||||
assertNull(result.getPrimary());
|
||||
assertNull(result.getSupplement());
|
||||
|
||||
verify(vectorSearchService).searchSimilarDocuments("test", 3, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_l1ReturnsNull() {
|
||||
// L0 未匹配,L1 返回 null
|
||||
when(knowledgeIndexService.analyzeQuery("query"))
|
||||
.thenReturn(KnowledgeIndexService.L0Hint.empty());
|
||||
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.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);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookup_deduplicatesEvidenceBlocksBySource() throws Exception {
|
||||
KnowledgeEntry entry = KnowledgeEntry.builder()
|
||||
.filePath("shared.md")
|
||||
.title("Shared Doc")
|
||||
.keywords(List.of("shared"))
|
||||
.summary("Shared summary")
|
||||
.build();
|
||||
VectorSearchService.SearchResult l1Result = new VectorSearchService.SearchResult();
|
||||
l1Result.setContent("Shared semantic content");
|
||||
String metadata = "{\"docId\":\"doc-1\",\"_source\":\"shared.md\",\"title\":\"Shared Doc\"}";
|
||||
l1Result.setMetadata(metadata);
|
||||
l1Result.setScore(0.2f);
|
||||
|
||||
when(knowledgeIndexService.analyzeQuery("shared"))
|
||||
.thenReturn(hint(entry));
|
||||
when(knowledgeIndexService.readDocument("shared.md", 2000))
|
||||
.thenReturn("Shared content");
|
||||
when(vectorSearchService.searchSimilarDocuments("shared", 3, null))
|
||||
.thenReturn(List.of(l1Result));
|
||||
when(objectMapper.readValue(metadata, Map.class))
|
||||
.thenReturn(Map.of("docId", "doc-1", "_source", "shared.md", "title", "Shared Doc"));
|
||||
|
||||
LookupResult result = tool.lookupKnowledge("shared");
|
||||
|
||||
assertTrue(result.isFound());
|
||||
assertEquals(2, result.getEvidenceCandidateCount());
|
||||
assertEquals(1, result.getEvidenceBlockCount());
|
||||
assertEquals("shared.md", result.getEvidenceBlocks().get(0).getSource());
|
||||
assertEquals("L0+L1", result.getEvidenceBlocks().get(0).getRetrievalLayer());
|
||||
assertTrue(result.getEvidenceBlocks().get(0).getHitReasons().contains("semantic_rank:1"));
|
||||
assertTrue(result.getEvidenceBlocks().get(0).getHitReasons().stream()
|
||||
.anyMatch(reason -> reason.startsWith("l0_keywords:")));
|
||||
private VectorSearchService.SearchResult searchResult(String id,
|
||||
String source,
|
||||
String metadata,
|
||||
String content,
|
||||
float score) {
|
||||
VectorSearchService.SearchResult result = new VectorSearchService.SearchResult();
|
||||
result.setId(id);
|
||||
result.setMetadata(metadata);
|
||||
result.setContent(content);
|
||||
result.setScore(score);
|
||||
result.setRawScore((double) score);
|
||||
result.setScoreLabel("l2_distance");
|
||||
return result;
|
||||
}
|
||||
|
||||
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())
|
||||
.flatMap(entry -> entry.getKeywords() == null
|
||||
? java.util.stream.Stream.empty()
|
||||
: entry.getKeywords().stream())
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> domains = matches.stream()
|
||||
|
||||
Reference in New Issue
Block a user