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
@@ -19,9 +19,11 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
/**
* 知识库索引服务
@@ -123,39 +125,73 @@ public class KnowledgeIndexService {
}
public List<KnowledgeEntry> exactMatch(String query) {
return analyzeQuery(query).matches();
}
public L0Hint analyzeQuery(String query) {
long startTime = System.currentTimeMillis();
if (query == null || query.trim().isEmpty()) {
log.debug("查询关键词为空,返回空结果");
return List.of();
return L0Hint.empty();
}
String queryLower = query.toLowerCase();
List<KnowledgeEntry> results = new ArrayList<>();
Set<String> matchedKeywords = new LinkedHashSet<>();
Set<String> domains = new LinkedHashSet<>();
Set<String> entities = new LinkedHashSet<>();
Set<String> titles = new LinkedHashSet<>();
List<KnowledgeEntry> results = knowledgeIndex.stream()
.filter(entry -> matchesKeywords(entry, queryLower))
.collect(Collectors.toList());
for (KnowledgeEntry entry : knowledgeIndex) {
List<String> entryMatchedKeywords = matchedKeywords(entry, queryLower);
if (entryMatchedKeywords.isEmpty()) {
continue;
}
long elapsedTime = System.currentTimeMillis() - startTime;
log.debug("L0精确匹配: query={}, matches={}, indexSize={}, time={}ms",
query, results.size(), knowledgeIndex.size(), elapsedTime);
results.add(entry);
matchedKeywords.addAll(entryMatchedKeywords);
entities.addAll(entryMatchedKeywords);
return results;
}
private boolean matchesKeywords(KnowledgeEntry entry, String query) {
if (entry.getKeywords() == null || entry.getKeywords().isEmpty()) {
return false;
}
for (String keyword : entry.getKeywords()) {
String keywordLower = keyword.toLowerCase();
if (query.contains(keywordLower) || keywordLower.contains(query)) {
return true;
if (entry.getCategory() != null && !entry.getCategory().isBlank()) {
domains.add(entry.getCategory());
}
if (entry.getTitle() != null && !entry.getTitle().isBlank()) {
titles.add(entry.getTitle());
}
}
return false;
long elapsedTime = System.currentTimeMillis() - startTime;
log.debug("L0 Hint分析: query={}, matches={}, domains={}, keywords={}, indexSize={}, time={}ms",
query, results.size(), domains, matchedKeywords, knowledgeIndex.size(), elapsedTime);
return new L0Hint(
List.copyOf(results),
List.copyOf(matchedKeywords),
List.copyOf(domains),
List.copyOf(entities),
List.copyOf(titles)
);
}
private boolean matchesKeywords(KnowledgeEntry entry, String query) {
return !matchedKeywords(entry, query).isEmpty();
}
private List<String> matchedKeywords(KnowledgeEntry entry, String query) {
if (entry.getKeywords() == null || entry.getKeywords().isEmpty()) {
return List.of();
}
List<String> matches = new ArrayList<>();
for (String keyword : entry.getKeywords()) {
String keywordLower = keyword.toLowerCase();
if (query.contains(keywordLower) || keywordLower.contains(query)) {
matches.add(keyword);
}
}
return matches;
}
public String readDocument(String filePath, int maxChars) {
@@ -224,4 +260,20 @@ public class KnowledgeIndexService {
public List<KnowledgeEntry> getAllEntries() {
return List.copyOf(knowledgeIndex);
}
public record L0Hint(
List<KnowledgeEntry> matches,
List<String> matchedKeywords,
List<String> domains,
List<String> entities,
List<String> titles
) {
public static L0Hint empty() {
return new L0Hint(List.of(), List.of(), List.of(), List.of(), List.of());
}
public String singleDomainOrNull() {
return domains.size() == 1 ? domains.get(0) : null;
}
}
}
@@ -6,7 +6,6 @@ import com.superbiz.agent.domain.entity.ToolInvocation;
import com.superbiz.agent.dto.LookupResult;
import com.superbiz.agent.repository.ToolInvocationRepository;
import com.superbiz.agent.dto.KnowledgeEntry;
import com.superbiz.agent.service.VectorSearchService;
import com.superbiz.agent.util.SessionContextHolder;
import lombok.Builder;
import lombok.extern.slf4j.Slf4j;
@@ -108,6 +107,15 @@ public class ToolInvocationRecorder {
if (record.l0Titles() != null && !record.l0Titles().isEmpty()) {
details.put("l0_titles", record.l0Titles());
}
if (record.l0MatchedKeywords() != null && !record.l0MatchedKeywords().isEmpty()) {
details.put("l0_matched_keywords", record.l0MatchedKeywords());
}
if (record.l0Domains() != null && !record.l0Domains().isEmpty()) {
details.put("l0_domains", record.l0Domains());
}
if (record.l0Entities() != null && !record.l0Entities().isEmpty()) {
details.put("l0_entities", record.l0Entities());
}
if (record.l1TopScore() != null) {
details.put("l1_top_score", record.l1TopScore());
}
@@ -196,12 +204,15 @@ public class ToolInvocationRecorder {
String evidenceStatus,
String errorMessage,
List<String> l0Titles,
List<String> l0MatchedKeywords,
List<String> l0Domains,
List<String> l0Entities,
Double l1TopScore,
Double l1TopSimilarity,
List<Double> l1Scores
) {
public static LookupKnowledgeRecord from(String query,
List<KnowledgeEntry> l0Matches,
KnowledgeIndexService.L0Hint l0Hint,
List<VectorSearchService.SearchResult> l1Results,
boolean highConfidence,
LookupResult result,
@@ -209,6 +220,7 @@ public class ToolInvocationRecorder {
String dedupReason,
int durationMs,
double l1TopSimilarity) {
List<KnowledgeEntry> l0Matches = l0Hint != null ? l0Hint.matches() : List.of();
boolean hasL0 = l0Matches != null && !l0Matches.isEmpty();
boolean hasL1 = l1Results != null && !l1Results.isEmpty();
String layer;
@@ -272,6 +284,9 @@ public class ToolInvocationRecorder {
.success(true)
.evidenceStatus(evidenceStatus)
.l0Titles(l0Titles)
.l0MatchedKeywords(l0Hint != null ? l0Hint.matchedKeywords() : List.of())
.l0Domains(l0Hint != null ? l0Hint.domains() : List.of())
.l0Entities(l0Hint != null ? l0Hint.entities() : List.of())
.l1TopScore(hasL1 ? (double) l1Results.get(0).getScore() : null)
.l1TopSimilarity(hasL1 ? l1TopSimilarity : null)
.l1Scores(l1Scores)
@@ -35,13 +35,13 @@ public class LookupKnowledgeTool {
private static final String HINT_REFERENCE = "当前结果为相关参考,如需更精准信息请明确缺少的具体维度";
@Value("${retrieval.normalization.max-l2-distance:2.0}")
private double maxL2Distance;
private double maxL2Distance = 2.0;
@Value("${retrieval.normalization.highly-relevant-threshold:0.75}")
private double highlyRelevantThreshold;
private double highlyRelevantThreshold = 0.75;
@Value("${retrieval.normalization.reference-threshold:0.5}")
private double referenceThreshold;
private double referenceThreshold = 0.5;
@Autowired
private KnowledgeIndexService knowledgeIndexService;
@@ -83,30 +83,28 @@ public class LookupKnowledgeTool {
log.info(">>> RequestId: {}", requestId);
log.info("----------------------------------------");
// Step 1: L0 精确匹配
// Step 1: L0 hint 分析
long l0Start = System.currentTimeMillis();
List<KnowledgeEntry> l0Matches = knowledgeIndexService.exactMatch(query);
KnowledgeIndexService.L0Hint l0Hint = knowledgeIndexService.analyzeQuery(query);
List<KnowledgeEntry> l0Matches = l0Hint.matches();
long l0Time = System.currentTimeMillis() - l0Start;
log.info("[L0 精确匹配] 完成: matches={}, time={}ms", l0Matches.size(), l0Time);
log.info("[L0 Hint] 完成: matches={}, domains={}, keywords={}, time={}ms",
l0Matches.size(), l0Hint.domains(), l0Hint.matchedKeywords(), l0Time);
if (!l0Matches.isEmpty()) {
log.info("[L0 精确匹配] 找到文档:");
log.info("[L0 Hint] 找到文档:");
for (int i = 0; i < Math.min(3, l0Matches.size()); i++) {
KnowledgeEntry entry = l0Matches.get(i);
log.info(" - [{}] 标题: {}, 路径: {}, 域: {}", i+1, entry.getTitle(), entry.getFilePath(), entry.getCategory());
}
}
// Step 2: 判断是否高置信度(唯一匹配)
boolean highConfidence = (l0Matches.size() == 1);
log.info("[置信度判断] highConfidence={}, reason={}",
highConfidence, highConfidence ? "唯一匹配" : "多个或零个匹配");
// Step 3: L1 条件调用
List<VectorSearchService.SearchResult> l1Results = null;
if (!highConfidence) {
log.info("[L1 语义检索] L0非唯一匹配,触发L1语义检索...");
// Step 2: L1 默认调用;L0 只提供可解释 hint 和可选 category filter
List<VectorSearchService.SearchResult> l1Results = List.of();
String l0CategoryFilter = l0Hint.singleDomainOrNull();
try {
log.info("[L1 语义检索] 触发L1语义检索, categoryFilter={}", l0CategoryFilter);
long l1Start = System.currentTimeMillis();
l1Results = vectorSearchService.searchSimilarDocuments(query, 3, null);
l1Results = vectorSearchService.searchSimilarDocuments(query, 3, l0CategoryFilter);
long l1Time = System.currentTimeMillis() - l1Start;
log.info("[L1 语义检索] 完成: matches={}, time={}ms",
l1Results != null ? l1Results.size() : 0, l1Time);
@@ -117,25 +115,27 @@ public class LookupKnowledgeTool {
log.info(" - [{}] 文档ID: {}, L2距离: {}", i+1, result.getId(), String.format("%.4f", result.getScore()));
}
}
} else {
log.info("[L1 语义检索] L0唯一匹配,跳过L1检索");
} catch (Exception e) {
log.warn("[L1 语义检索] 调用失败,保留L0 fallback: {}", e.getMessage());
l1Results = List.of();
}
// Step 4: 归一化质量等级判定
// Step 3: 归一化质量等级判定
float l1TopScore = (l1Results != null && !l1Results.isEmpty()) ? l1Results.get(0).getScore() : Float.MAX_VALUE;
RelevanceAssessment assessment = computeRelevance(l0Matches.size(), l1TopScore);
boolean highConfidence = isHighConfidence(l0Matches.size(), l1TopScore);
log.info("[归一化] relevanceLevel={}, completenessHint={}", assessment.level, assessment.hint);
if (l1TopScore != Float.MAX_VALUE) {
double similarity = normalizeL2(l1TopScore);
log.info("[归一化] L2距离={}, similarity={}", String.format("%.4f", l1TopScore), String.format("%.4f", similarity));
}
// Step 5: 组装结果
// Step 4: 组装结果
LookupResult result = buildResult(l0Matches, l1Results, highConfidence);
result.setRelevanceLevel(assessment.level);
result.setCompletenessHint(assessment.hint);
// Step 6: session 级去重过滤 + 域级行动记忆
// Step 5: session 级去重过滤 + 域级行动记忆
String sessionId = SessionContextHolder.getSessionId();
String domain = extractDomain(l0Matches, l1Results);
@@ -144,7 +144,7 @@ public class LookupKnowledgeTool {
if (docKey != null && retrievedDocTracker.isAlreadyRetrieved(sessionId, docKey)) {
log.info("[去重] 文档已在本会话中检索过,跳过: {}", docKey);
List<String> retrievedDomains = retrievedDocTracker.getRetrievedDomains(sessionId);
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result, domain, "doc_retrieved");
saveToolInvocation(query, l0Hint, l1Results, highConfidence, startTime, result, domain, "doc_retrieved");
return LookupResult.builder()
.found(false)
.message("文档已在本会话中检索过,无需重复召回: " + docKey)
@@ -197,7 +197,7 @@ public class LookupKnowledgeTool {
log.info("========================================");
// 记录 tool_invocation
saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result, domain, null);
saveToolInvocation(query, l0Hint, l1Results, highConfidence, startTime, result, domain, null);
return result;
}
@@ -225,11 +225,16 @@ public class LookupKnowledgeTool {
RelevanceAssessment computeRelevance(int l0MatchCount, float l1TopScore) {
double l1Similarity = (l1TopScore != Float.MAX_VALUE) ? normalizeL2(l1TopScore) : 0.0;
// L0 唯一匹配 → PRECISE
if (l0MatchCount == 1) {
// L0 唯一匹配 + L1 高分 → PRECISE
if (l0MatchCount == 1 && l1Similarity >= highlyRelevantThreshold) {
return new RelevanceAssessment(LEVEL_PRECISE, HINT_PRECISE);
}
// L0 唯一匹配但缺少 L1 支持 → REFERENCE
if (l0MatchCount == 1) {
return new RelevanceAssessment(LEVEL_REFERENCE, HINT_REFERENCE);
}
// L0 命中 + L1 高分 → HIGHLY_RELEVANT
if (l0MatchCount > 1 && l1Similarity >= highlyRelevantThreshold) {
return new RelevanceAssessment(LEVEL_HIGHLY_RELEVANT, HINT_HIGHLY_RELEVANT);
@@ -259,6 +264,16 @@ public class LookupKnowledgeTool {
return new RelevanceAssessment(null, null);
}
boolean isHighConfidence(int l0MatchCount, float l1TopScore) {
if (l0MatchCount != 1) {
return false;
}
if (l1TopScore == Float.MAX_VALUE) {
return true;
}
return normalizeL2(l1TopScore) >= highlyRelevantThreshold;
}
/**
* 归一化评估结果
*/
@@ -302,7 +317,7 @@ public class LookupKnowledgeTool {
/**
* 保存工具调用明细到 tool_invocation 表
*/
private void saveToolInvocation(String query, List<KnowledgeEntry> l0Matches,
private void saveToolInvocation(String query, KnowledgeIndexService.L0Hint l0Hint,
List<VectorSearchService.SearchResult> l1Results,
boolean highConfidence, long startTime,
LookupResult result, String domain, String dedupReason) {
@@ -317,7 +332,7 @@ public class LookupKnowledgeTool {
ToolInvocationRecorder.LookupKnowledgeRecord record = ToolInvocationRecorder.LookupKnowledgeRecord.from(
query,
l0Matches,
l0Hint,
l1Results,
highConfidence,
result,
@@ -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);
}
}