From 51977127191470399bb2e7f48e94c41a1b690d1c Mon Sep 17 00:00:00 2001 From: aruo <40362743+zyongxin@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:03:18 +0800 Subject: [PATCH] feat: add rag evidence postprocess blocks --- .../.openspec.yaml | 2 + .../design.md | 53 +++++++ .../proposal.md | 28 ++++ .../specs/rag-knowledge-retrieval/spec.md | 35 +++++ .../tasks.md | 26 ++++ .../specs/rag-knowledge-retrieval/spec.md | 34 ++++ .../com/superbiz/agent/dto/EvidenceBlock.java | 28 ++++ .../com/superbiz/agent/dto/LookupResult.java | 15 ++ .../agent/service/ToolInvocationRecorder.java | 41 ++++- .../agent/tool/LookupKnowledgeTool.java | 145 ++++++++++++++++++ .../service/ToolInvocationRecorderTest.java | 49 ++++++ .../agent/tool/LookupKnowledgeToolTest.java | 42 +++++ 12 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/archive/2026-07-04-rag-evidence-postprocess/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-04-rag-evidence-postprocess/design.md create mode 100644 openspec/changes/archive/2026-07-04-rag-evidence-postprocess/proposal.md create mode 100644 openspec/changes/archive/2026-07-04-rag-evidence-postprocess/specs/rag-knowledge-retrieval/spec.md create mode 100644 openspec/changes/archive/2026-07-04-rag-evidence-postprocess/tasks.md create mode 100644 src/main/java/com/superbiz/agent/dto/EvidenceBlock.java diff --git a/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/.openspec.yaml b/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/.openspec.yaml new file mode 100644 index 0000000..d86f152 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-04 diff --git a/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/design.md b/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/design.md new file mode 100644 index 0000000..3f3c774 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/design.md @@ -0,0 +1,53 @@ +## Context + +The previous change converted L0 into a hint provider and made L0/L1 cooperate. The next step is to stop treating the tool output as an unstructured primary/supplement pair. The Agent can keep receiving compatible fields, but retrieval internals and traces should have structured evidence blocks. + +This change is a bridge toward later DocumentPostProcessor-style behavior. It should be small enough to archive independently and should not introduce Spring AI dependencies. + +## Goals / Non-Goals + +**Goals:** + +- Add `EvidenceBlock` DTOs to `LookupResult`. +- Create evidence blocks from L0 matches and L1 results. +- Deduplicate evidence by stable source key. +- Capture source, title, breadcrumb, score, retrieval layer, hit reasons, and content preview. +- Persist evidence blocks and postprocess counts in `tool_invocation.retrieval_details`. + +**Non-Goals:** + +- Do not implement neighbor chunk expansion yet. +- Do not replace primary/supplement output. +- Do not add cross-encoder or LLM rerank. +- Do not migrate to Spring AI DocumentPostProcessor yet. + +## Decisions + +### Decision: Add evidence blocks while preserving existing result fields + +`LookupResult` will gain `List evidenceBlocks`. Existing `primary`, `supplement`, `found`, `relevanceLevel`, and `completenessHint` remain compatible. + +Rationale: this lets the Agent continue using the current shape while tests and traces begin validating the new evidence model. + +### Decision: Keep postprocess rule-based + +The evidence builder will use deterministic rules: + +- L0 entries become `L0` evidence. +- L1 candidates become `L1` evidence. +- Same source key is deduplicated. +- Hit reasons are collected from L0 hints, L1 rank, category filters, and fallback state. + +Rationale: this is explainable, cheap, and suitable before introducing framework postprocessors. + +### Decision: Persist compact evidence summaries + +`ToolInvocationRecorder` will store compact evidence block metadata, not full content, inside `retrieval_details`. + +Rationale: `tool_invocation` should remain useful for trace review without duplicating large chunks. + +## Risks / Trade-offs + +- Evidence source keys may be imperfect before full metadata normalization -> fall back to file path, metadata, title, then rank. +- Agent prompts may ignore `evidenceBlocks` initially -> keep primary/supplement compatibility. +- Adding content previews increases tool output size -> cap evidence content length. diff --git a/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/proposal.md b/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/proposal.md new file mode 100644 index 0000000..8618b5d --- /dev/null +++ b/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/proposal.md @@ -0,0 +1,28 @@ +## Why + +The current `lookup_knowledge` result is still shaped as one L0 primary result plus one L1 supplement. That makes retrieval evidence hard to inspect, hard to deduplicate, and hard to reuse later by verifier/evaluator code. The RAG refactor needs a structured evidence layer before Spring AI retriever migration. + +## What Changes + +- Add structured evidence blocks to `LookupResult`. +- Build evidence blocks from L0 hint matches and L1 candidates. +- Deduplicate evidence by source identity where possible. +- Add hit reasons such as L0 matched keywords, L1 semantic rank, category filter, and fallback. +- Persist evidence block summaries and postprocess counts in `tool_invocation.retrieval_details`. +- Keep existing `primary` and `supplement` fields for compatibility. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `rag-knowledge-retrieval`: Add evidence block post-processing requirements for explicit RAG knowledge retrieval. + +## Impact + +- Affects `LookupResult`, `LookupKnowledgeTool`, and `ToolInvocationRecorder`. +- Updates tool tests and trace recorder tests. +- Does not change Milvus schema, document upload, chunking, or Spring AI integration. diff --git a/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/specs/rag-knowledge-retrieval/spec.md b/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/specs/rag-knowledge-retrieval/spec.md new file mode 100644 index 0000000..056ebf6 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/specs/rag-knowledge-retrieval/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Knowledge retrieval SHALL return structured evidence blocks +The `lookup_knowledge` retrieval flow SHALL expose retrieved evidence as structured evidence blocks in addition to the existing compatibility fields. + +#### Scenario: Evidence block contains source metadata +- **WHEN** a `lookup_knowledge` call returns evidence +- **THEN** each evidence block SHALL include source, title when available, breadcrumb when available, retrieval layer, content, and hit reasons + +#### Scenario: Compatibility fields remain available +- **WHEN** evidence blocks are returned +- **THEN** the existing `primary` and `supplement` result fields SHALL remain available when their source evidence exists + +### Requirement: Knowledge retrieval SHALL deduplicate evidence blocks +The retrieval flow SHALL remove duplicate evidence blocks before returning them to the Agent. + +#### Scenario: Duplicate source deduplication +- **WHEN** L0 and L1 produce evidence with the same source identity +- **THEN** the retrieval flow SHALL keep a single evidence block for that source +- **AND** the evidence block SHALL preserve hit reasons from both retrieval paths when available + +#### Scenario: Postprocess count tracking +- **WHEN** evidence post-processing completes +- **THEN** the tool invocation details SHALL record candidate count and final evidence block count + +### Requirement: Knowledge retrieval SHALL persist evidence block summaries +The system SHALL persist compact evidence block summaries in `tool_invocation.retrieval_details`. + +#### Scenario: Evidence summaries are persisted +- **WHEN** a `lookup_knowledge` call records a tool invocation +- **THEN** `retrieval_details` SHALL include evidence block summaries containing source, title, retrieval layer, score when available, and hit reasons + +#### Scenario: Full content is not duplicated into retrieval details +- **WHEN** evidence block summaries are persisted +- **THEN** full evidence content SHALL be omitted or truncated so the trace record remains compact diff --git a/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/tasks.md b/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/tasks.md new file mode 100644 index 0000000..3267cf3 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-rag-evidence-postprocess/tasks.md @@ -0,0 +1,26 @@ +## 1. Evidence Model + +- [x] 1.1 Add an `EvidenceBlock` DTO. +- [x] 1.2 Add evidence block list and postprocess count fields to `LookupResult`. + +## 2. Evidence Postprocess + +- [x] 2.1 Build evidence blocks from L0 matches and L1 candidates in `LookupKnowledgeTool`. +- [x] 2.2 Deduplicate evidence by stable source key. +- [x] 2.3 Preserve existing primary/supplement compatibility behavior. + +## 3. Trace Recording + +- [x] 3.1 Extend `ToolInvocationRecorder.LookupKnowledgeRecord` with evidence block summaries and postprocess counts. +- [x] 3.2 Persist evidence block summaries in `retrieval_details`. + +## 4. Tests + +- [x] 4.1 Add or update tests for evidence block creation and deduplication. +- [x] 4.2 Add or update tests for persisted evidence block summaries. +- [x] 4.3 Run targeted tests and the RAG retrieval baseline evaluator. + +## 5. Validation + +- [x] 5.1 Run OpenSpec validation for the change. +- [x] 5.2 Review git diff to confirm only expected code/spec/test files changed. diff --git a/openspec/specs/rag-knowledge-retrieval/spec.md b/openspec/specs/rag-knowledge-retrieval/spec.md index 1b6c26b..926a701 100644 --- a/openspec/specs/rag-knowledge-retrieval/spec.md +++ b/openspec/specs/rag-knowledge-retrieval/spec.md @@ -48,3 +48,37 @@ The system SHALL persist L0 hint details in `tool_invocation.retrieval_details` #### Scenario: Retrieval layer reflects cooperating retrieval - **WHEN** both L0 hint data and L1 candidates participate in a lookup - **THEN** the recorded retrieval layer SHALL be `L0+L1` + +### Requirement: Knowledge retrieval SHALL return structured evidence blocks +The `lookup_knowledge` retrieval flow SHALL expose retrieved evidence as structured evidence blocks in addition to the existing compatibility fields. + +#### Scenario: Evidence block contains source metadata +- **WHEN** a `lookup_knowledge` call returns evidence +- **THEN** each evidence block SHALL include source, title when available, breadcrumb when available, retrieval layer, content, and hit reasons + +#### Scenario: Compatibility fields remain available +- **WHEN** evidence blocks are returned +- **THEN** the existing `primary` and `supplement` result fields SHALL remain available when their source evidence exists + +### Requirement: Knowledge retrieval SHALL deduplicate evidence blocks +The retrieval flow SHALL remove duplicate evidence blocks before returning them to the Agent. + +#### Scenario: Duplicate source deduplication +- **WHEN** L0 and L1 produce evidence with the same source identity +- **THEN** the retrieval flow SHALL keep a single evidence block for that source +- **AND** the evidence block SHALL preserve hit reasons from both retrieval paths when available + +#### Scenario: Postprocess count tracking +- **WHEN** evidence post-processing completes +- **THEN** the tool invocation details SHALL record candidate count and final evidence block count + +### Requirement: Knowledge retrieval SHALL persist evidence block summaries +The system SHALL persist compact evidence block summaries in `tool_invocation.retrieval_details`. + +#### Scenario: Evidence summaries are persisted +- **WHEN** a `lookup_knowledge` call records a tool invocation +- **THEN** `retrieval_details` SHALL include evidence block summaries containing source, title, retrieval layer, score when available, and hit reasons + +#### Scenario: Full content is not duplicated into retrieval details +- **WHEN** evidence block summaries are persisted +- **THEN** full evidence content SHALL be omitted or truncated so the trace record remains compact diff --git a/src/main/java/com/superbiz/agent/dto/EvidenceBlock.java b/src/main/java/com/superbiz/agent/dto/EvidenceBlock.java new file mode 100644 index 0000000..3d5c0fa --- /dev/null +++ b/src/main/java/com/superbiz/agent/dto/EvidenceBlock.java @@ -0,0 +1,28 @@ +package com.superbiz.agent.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * Structured evidence returned by knowledge retrieval. + */ +@Data +@Builder +public class EvidenceBlock { + + private String source; + + private String title; + + private String breadcrumb; + + private String retrievalLayer; + + private String content; + + private Double score; + + private List hitReasons; +} diff --git a/src/main/java/com/superbiz/agent/dto/LookupResult.java b/src/main/java/com/superbiz/agent/dto/LookupResult.java index ef2e780..0b91b5b 100644 --- a/src/main/java/com/superbiz/agent/dto/LookupResult.java +++ b/src/main/java/com/superbiz/agent/dto/LookupResult.java @@ -27,6 +27,21 @@ public class LookupResult { */ private SupplementResult supplement; + /** + * Structured evidence blocks after retrieval post-processing. + */ + private List evidenceBlocks; + + /** + * Candidate count before evidence deduplication. + */ + private Integer evidenceCandidateCount; + + /** + * Evidence block count after post-processing. + */ + private Integer evidenceBlockCount; + /** * 归一化质量等级:PRECISE / HIGHLY_RELEVANT / REFERENCE */ diff --git a/src/main/java/com/superbiz/agent/service/ToolInvocationRecorder.java b/src/main/java/com/superbiz/agent/service/ToolInvocationRecorder.java index ad0628e..be460fe 100644 --- a/src/main/java/com/superbiz/agent/service/ToolInvocationRecorder.java +++ b/src/main/java/com/superbiz/agent/service/ToolInvocationRecorder.java @@ -3,6 +3,7 @@ package com.superbiz.agent.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.superbiz.agent.domain.entity.ToolInvocation; +import com.superbiz.agent.dto.EvidenceBlock; import com.superbiz.agent.dto.LookupResult; import com.superbiz.agent.repository.ToolInvocationRepository; import com.superbiz.agent.dto.KnowledgeEntry; @@ -128,6 +129,15 @@ public class ToolInvocationRecorder { if (record.l1Scores() != null && !record.l1Scores().isEmpty()) { details.put("l1_scores", record.l1Scores()); } + if (record.evidenceCandidateCount() != null) { + details.put("evidence_candidate_count", record.evidenceCandidateCount()); + } + if (record.evidenceBlockCount() != null) { + details.put("evidence_block_count", record.evidenceBlockCount()); + } + if (record.evidenceBlocks() != null && !record.evidenceBlocks().isEmpty()) { + details.put("evidence_blocks", record.evidenceBlocks()); + } if (record.relevanceLevel() != null) { details.put("relevance_level", record.relevanceLevel()); } @@ -209,7 +219,10 @@ public class ToolInvocationRecorder { List l0Entities, Double l1TopScore, Double l1TopSimilarity, - List l1Scores + List l1Scores, + Integer evidenceCandidateCount, + Integer evidenceBlockCount, + List> evidenceBlocks ) { public static LookupKnowledgeRecord from(String query, KnowledgeIndexService.L0Hint l0Hint, @@ -290,7 +303,33 @@ public class ToolInvocationRecorder { .l1TopScore(hasL1 ? (double) l1Results.get(0).getScore() : null) .l1TopSimilarity(hasL1 ? l1TopSimilarity : null) .l1Scores(l1Scores) + .evidenceCandidateCount(result != null ? result.getEvidenceCandidateCount() : null) + .evidenceBlockCount(result != null ? result.getEvidenceBlockCount() : null) + .evidenceBlocks(result != null ? summarizeEvidenceBlocks(result.getEvidenceBlocks()) : List.of()) .build(); } + + private static List> summarizeEvidenceBlocks(List blocks) { + if (blocks == null || blocks.isEmpty()) { + return List.of(); + } + List> summaries = new ArrayList<>(); + for (int i = 0; i < Math.min(5, blocks.size()); i++) { + EvidenceBlock block = blocks.get(i); + Map summary = new LinkedHashMap<>(); + summary.put("source", block.getSource()); + summary.put("title", block.getTitle()); + summary.put("breadcrumb", block.getBreadcrumb()); + summary.put("retrieval_layer", block.getRetrievalLayer()); + summary.put("score", block.getScore()); + summary.put("hit_reasons", block.getHitReasons()); + String content = block.getContent(); + if (content != null) { + summary.put("content_preview", content.length() <= 180 ? content : content.substring(0, 180) + "..."); + } + summaries.add(summary); + } + return summaries; + } } } diff --git a/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java b/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java index daf0536..d864be3 100644 --- a/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java +++ b/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java @@ -13,8 +13,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.stream.Collectors; /** @@ -397,12 +400,154 @@ public class LookupKnowledgeTool { } builder.supplement(supplement); + EvidencePostprocessResult evidence = buildEvidenceBlocks(l0Matches, l1Results); + builder.evidenceBlocks(evidence.blocks()); + builder.evidenceCandidateCount(evidence.candidateCount()); + builder.evidenceBlockCount(evidence.blocks().size()); + boolean found = (primary != null) || (supplement != null); builder.found(found); return builder.build(); } + private EvidencePostprocessResult buildEvidenceBlocks( + List l0Matches, + List l1Results) { + Map deduped = new LinkedHashMap<>(); + int candidateCount = 0; + + if (l0Matches != null) { + for (int i = 0; i < l0Matches.size(); i++) { + KnowledgeEntry entry = l0Matches.get(i); + candidateCount++; + EvidenceBlock block = EvidenceBlock.builder() + .source(entry.getFilePath()) + .title(entry.getTitle()) + .breadcrumb(null) + .retrievalLayer("L0") + .content(buildMetadataOnlySummary(entry)) + .score(null) + .hitReasons(buildL0HitReasons(entry, i + 1)) + .build(); + mergeEvidence(deduped, sourceKey(block, "l0-" + i), block); + } + } + + if (l1Results != null) { + for (int i = 0; i < l1Results.size(); i++) { + VectorSearchService.SearchResult result = l1Results.get(i); + candidateCount++; + Map metadata = parseMetadata(result.getMetadata()); + String source = firstNonBlank( + metadata.get("_source"), + metadata.get("docId"), + result.getMetadata(), + result.getId() + ); + EvidenceBlock block = EvidenceBlock.builder() + .source(source) + .title(metadata.get("title")) + .breadcrumb(metadata.get("breadcrumb")) + .retrievalLayer("L1") + .content(truncate(result.getContent(), 800)) + .score((double) result.getScore()) + .hitReasons(List.of("semantic_rank:" + (i + 1))) + .build(); + mergeEvidence(deduped, sourceKey(block, "l1-" + i), block); + } + } + + return new EvidencePostprocessResult(candidateCount, new ArrayList<>(deduped.values())); + } + + private void mergeEvidence(Map deduped, String key, EvidenceBlock incoming) { + EvidenceBlock existing = deduped.get(key); + if (existing == null) { + deduped.put(key, incoming); + return; + } + + List mergedReasons = new ArrayList<>(); + if (existing.getHitReasons() != null) { + mergedReasons.addAll(existing.getHitReasons()); + } + if (incoming.getHitReasons() != null) { + for (String reason : incoming.getHitReasons()) { + if (!mergedReasons.contains(reason)) { + mergedReasons.add(reason); + } + } + } + + String mergedLayer = existing.getRetrievalLayer(); + if (incoming.getRetrievalLayer() != null && !incoming.getRetrievalLayer().equals(mergedLayer)) { + mergedLayer = "L0+L1"; + } + + existing.setRetrievalLayer(mergedLayer); + existing.setHitReasons(mergedReasons); + if (existing.getScore() == null && incoming.getScore() != null) { + existing.setScore(incoming.getScore()); + } + if ((existing.getBreadcrumb() == null || existing.getBreadcrumb().isBlank()) + && incoming.getBreadcrumb() != null) { + existing.setBreadcrumb(incoming.getBreadcrumb()); + } + } + + private List buildL0HitReasons(KnowledgeEntry entry, int rank) { + List reasons = new ArrayList<>(); + reasons.add("l0_rank:" + rank); + if (entry.getKeywords() != null && !entry.getKeywords().isEmpty()) { + reasons.add("l0_keywords:" + String.join(",", entry.getKeywords())); + } + if (entry.getCategory() != null && !entry.getCategory().isBlank()) { + reasons.add("domain:" + entry.getCategory()); + } + return reasons; + } + + private String sourceKey(EvidenceBlock block, String fallback) { + return firstNonBlank(block.getSource(), block.getTitle(), block.getBreadcrumb(), fallback); + } + + private Map parseMetadata(String metadata) { + if (metadata == null || metadata.isBlank()) { + return Map.of(); + } + try { + Map raw = objectMapper.readValue(metadata, Map.class); + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + result.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue())); + } + } + return result; + } catch (Exception e) { + return Map.of(); + } + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private String truncate(String text, int maxLength) { + if (text == null || text.length() <= maxLength) { + return text; + } + return text.substring(0, maxLength) + "..."; + } + + private record EvidencePostprocessResult(int candidateCount, List blocks) {} + private int countMdHeadings(String content) { if (content == null) return 0; return (int) content.lines() diff --git a/src/test/java/com/superbiz/agent/service/ToolInvocationRecorderTest.java b/src/test/java/com/superbiz/agent/service/ToolInvocationRecorderTest.java index 5fff1fb..54abfb3 100644 --- a/src/test/java/com/superbiz/agent/service/ToolInvocationRecorderTest.java +++ b/src/test/java/com/superbiz/agent/service/ToolInvocationRecorderTest.java @@ -2,6 +2,7 @@ package com.superbiz.agent.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.superbiz.agent.domain.entity.ToolInvocation; +import com.superbiz.agent.dto.EvidenceBlock; import com.superbiz.agent.repository.ToolInvocationRepository; import com.superbiz.agent.util.SessionContextHolder; import org.junit.jupiter.api.Test; @@ -78,6 +79,14 @@ class ToolInvocationRecorderTest { .l0MatchedKeywords(List.of("ERR_TIMEOUT")) .l0Domains(List.of("payment")) .l0Entities(List.of("ERR_TIMEOUT")) + .evidenceCandidateCount(2) + .evidenceBlockCount(1) + .evidenceBlocks(List.of(Map.of( + "source", "payment/errors.md", + "title", "payment/errors.md", + "retrieval_layer", "L0+L1", + "hit_reasons", List.of("l0_keywords:ERR_TIMEOUT", "semantic_rank:1") + ))) .build(); try { @@ -98,5 +107,45 @@ class ToolInvocationRecorderTest { assertTrue(saved.getRetrievalDetails().contains("\"l0_matched_keywords\":[\"ERR_TIMEOUT\"]")); assertTrue(saved.getRetrievalDetails().contains("\"l0_domains\":[\"payment\"]")); assertTrue(saved.getRetrievalDetails().contains("\"l0_entities\":[\"ERR_TIMEOUT\"]")); + assertTrue(saved.getRetrievalDetails().contains("\"evidence_candidate_count\":2")); + assertTrue(saved.getRetrievalDetails().contains("\"evidence_block_count\":1")); + assertTrue(saved.getRetrievalDetails().contains("\"evidence_blocks\"")); + } + + @Test + void lookupKnowledgeRecordFromSummarizesEvidenceBlocks() { + EvidenceBlock block = EvidenceBlock.builder() + .source("doc.md") + .title("Doc") + .breadcrumb("A > B") + .retrievalLayer("L1") + .score(0.42) + .hitReasons(List.of("semantic_rank:1")) + .content("x".repeat(220)) + .build(); + + com.superbiz.agent.dto.LookupResult result = com.superbiz.agent.dto.LookupResult.builder() + .found(true) + .evidenceCandidateCount(3) + .evidenceBlockCount(1) + .evidenceBlocks(List.of(block)) + .build(); + + ToolInvocationRecorder.LookupKnowledgeRecord record = ToolInvocationRecorder.LookupKnowledgeRecord.from( + "query", + KnowledgeIndexService.L0Hint.empty(), + List.of(), + false, + result, + null, + null, + 10, + -1 + ); + + assertEquals(3, record.evidenceCandidateCount()); + assertEquals(1, record.evidenceBlockCount()); + assertEquals(1, record.evidenceBlocks().size()); + assertTrue(String.valueOf(record.evidenceBlocks().get(0).get("content_preview")).endsWith("...")); } } diff --git a/src/test/java/com/superbiz/agent/tool/LookupKnowledgeToolTest.java b/src/test/java/com/superbiz/agent/tool/LookupKnowledgeToolTest.java index 426e3e1..4c60996 100644 --- a/src/test/java/com/superbiz/agent/tool/LookupKnowledgeToolTest.java +++ b/src/test/java/com/superbiz/agent/tool/LookupKnowledgeToolTest.java @@ -14,6 +14,7 @@ import org.mockito.MockitoAnnotations; import java.util.Collections; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -83,6 +84,12 @@ class LookupKnowledgeToolTest { 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); } @@ -290,6 +297,41 @@ class LookupKnowledgeToolTest { 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 KnowledgeIndexService.L0Hint hint(KnowledgeEntry... entries) { List matches = List.of(entries); List keywords = matches.stream()