feat(rag): modularize knowledge retrieval pipeline

This commit is contained in:
zhuyongxin
2026-07-06 17:06:05 +08:00
parent a375daead7
commit cf3333d607
38 changed files with 2981 additions and 1033 deletions
@@ -2,7 +2,10 @@ package com.superbiz.agent.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.domain.entity.ToolInvocation;
import com.superbiz.agent.dto.ContextPack;
import com.superbiz.agent.dto.EvidenceBlock;
import com.superbiz.agent.dto.KnowledgeQuery;
import com.superbiz.agent.dto.RetrievalTrace;
import com.superbiz.agent.repository.ToolInvocationRepository;
import com.superbiz.agent.util.SessionContextHolder;
import org.junit.jupiter.api.Test;
@@ -129,23 +132,51 @@ class ToolInvocationRecorderTest {
.evidenceCandidateCount(3)
.evidenceBlockCount(1)
.evidenceBlocks(List.of(block))
.contextPack(ContextPack.builder()
.packedText("packed evidence")
.strategy("ranked_evidence_char_budget")
.charBudget(4000)
.usedChars(15)
.includedSources(List.of("doc.md"))
.omittedSources(List.of())
.build())
.retrievalTrace(RetrievalTrace.builder()
.originalQuery("query")
.rewrittenQuery("query")
.selectedAttempt("UNFILTERED_VECTOR")
.evidenceStatus(ToolInvocationRecorder.EVIDENCE_STATUS_SUPPORTED)
.attempts(List.of(RetrievalTrace.Attempt.builder()
.name("UNFILTERED_VECTOR")
.candidateCount(3)
.topScore(0.42)
.topSimilarity(0.79)
.usable(true)
.build()))
.build())
.build();
KnowledgeQuery query = KnowledgeQuery.builder()
.originalQuery("query")
.rewrittenQuery("query")
.domainHints(List.of())
.matchedKeywords(List.of())
.entities(List.of())
.l0Titles(List.of())
.l0MatchCount(0)
.build();
ToolInvocationRecorder.LookupKnowledgeRecord record = ToolInvocationRecorder.LookupKnowledgeRecord.from(
"query",
KnowledgeIndexService.L0Hint.empty(),
List.of(),
false,
query,
result,
null,
null,
10,
-1
10
);
assertEquals(3, record.evidenceCandidateCount());
assertEquals(1, record.evidenceBlockCount());
assertEquals(1, record.evidenceBlocks().size());
assertTrue(String.valueOf(record.evidenceBlocks().get(0).get("content_preview")).endsWith("..."));
assertEquals("UNFILTERED_VECTOR", record.retrievalLayer());
assertEquals(3, record.l1MatchCount());
}
}
@@ -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()