feat: add rag evidence postprocess blocks
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-04
|
||||||
@@ -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<EvidenceBlock> 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.
|
||||||
@@ -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.
|
||||||
+35
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -48,3 +48,37 @@ The system SHALL persist L0 hint details in `tool_invocation.retrieval_details`
|
|||||||
#### Scenario: Retrieval layer reflects cooperating retrieval
|
#### Scenario: Retrieval layer reflects cooperating retrieval
|
||||||
- **WHEN** both L0 hint data and L1 candidates participate in a lookup
|
- **WHEN** both L0 hint data and L1 candidates participate in a lookup
|
||||||
- **THEN** the recorded retrieval layer SHALL be `L0+L1`
|
- **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
|
||||||
|
|||||||
@@ -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<String> hitReasons;
|
||||||
|
}
|
||||||
@@ -27,6 +27,21 @@ public class LookupResult {
|
|||||||
*/
|
*/
|
||||||
private SupplementResult supplement;
|
private SupplementResult supplement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured evidence blocks after retrieval post-processing.
|
||||||
|
*/
|
||||||
|
private List<EvidenceBlock> evidenceBlocks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate count before evidence deduplication.
|
||||||
|
*/
|
||||||
|
private Integer evidenceCandidateCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evidence block count after post-processing.
|
||||||
|
*/
|
||||||
|
private Integer evidenceBlockCount;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 归一化质量等级:PRECISE / HIGHLY_RELEVANT / REFERENCE
|
* 归一化质量等级:PRECISE / HIGHLY_RELEVANT / REFERENCE
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.superbiz.agent.service;
|
|||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.superbiz.agent.domain.entity.ToolInvocation;
|
import com.superbiz.agent.domain.entity.ToolInvocation;
|
||||||
|
import com.superbiz.agent.dto.EvidenceBlock;
|
||||||
import com.superbiz.agent.dto.LookupResult;
|
import com.superbiz.agent.dto.LookupResult;
|
||||||
import com.superbiz.agent.repository.ToolInvocationRepository;
|
import com.superbiz.agent.repository.ToolInvocationRepository;
|
||||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||||
@@ -128,6 +129,15 @@ public class ToolInvocationRecorder {
|
|||||||
if (record.l1Scores() != null && !record.l1Scores().isEmpty()) {
|
if (record.l1Scores() != null && !record.l1Scores().isEmpty()) {
|
||||||
details.put("l1_scores", record.l1Scores());
|
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) {
|
if (record.relevanceLevel() != null) {
|
||||||
details.put("relevance_level", record.relevanceLevel());
|
details.put("relevance_level", record.relevanceLevel());
|
||||||
}
|
}
|
||||||
@@ -209,7 +219,10 @@ public class ToolInvocationRecorder {
|
|||||||
List<String> l0Entities,
|
List<String> l0Entities,
|
||||||
Double l1TopScore,
|
Double l1TopScore,
|
||||||
Double l1TopSimilarity,
|
Double l1TopSimilarity,
|
||||||
List<Double> l1Scores
|
List<Double> l1Scores,
|
||||||
|
Integer evidenceCandidateCount,
|
||||||
|
Integer evidenceBlockCount,
|
||||||
|
List<Map<String, Object>> evidenceBlocks
|
||||||
) {
|
) {
|
||||||
public static LookupKnowledgeRecord from(String query,
|
public static LookupKnowledgeRecord from(String query,
|
||||||
KnowledgeIndexService.L0Hint l0Hint,
|
KnowledgeIndexService.L0Hint l0Hint,
|
||||||
@@ -290,7 +303,33 @@ public class ToolInvocationRecorder {
|
|||||||
.l1TopScore(hasL1 ? (double) l1Results.get(0).getScore() : null)
|
.l1TopScore(hasL1 ? (double) l1Results.get(0).getScore() : null)
|
||||||
.l1TopSimilarity(hasL1 ? l1TopSimilarity : null)
|
.l1TopSimilarity(hasL1 ? l1TopSimilarity : null)
|
||||||
.l1Scores(l1Scores)
|
.l1Scores(l1Scores)
|
||||||
|
.evidenceCandidateCount(result != null ? result.getEvidenceCandidateCount() : null)
|
||||||
|
.evidenceBlockCount(result != null ? result.getEvidenceBlockCount() : null)
|
||||||
|
.evidenceBlocks(result != null ? summarizeEvidenceBlocks(result.getEvidenceBlocks()) : List.of())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<Map<String, Object>> summarizeEvidenceBlocks(List<EvidenceBlock> blocks) {
|
||||||
|
if (blocks == null || blocks.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> summaries = new ArrayList<>();
|
||||||
|
for (int i = 0; i < Math.min(5, blocks.size()); i++) {
|
||||||
|
EvidenceBlock block = blocks.get(i);
|
||||||
|
Map<String, Object> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -397,12 +400,154 @@ public class LookupKnowledgeTool {
|
|||||||
}
|
}
|
||||||
builder.supplement(supplement);
|
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);
|
boolean found = (primary != null) || (supplement != null);
|
||||||
builder.found(found);
|
builder.found(found);
|
||||||
|
|
||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private EvidencePostprocessResult buildEvidenceBlocks(
|
||||||
|
List<KnowledgeEntry> l0Matches,
|
||||||
|
List<VectorSearchService.SearchResult> l1Results) {
|
||||||
|
Map<String, EvidenceBlock> 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<String, String> 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<String, EvidenceBlock> deduped, String key, EvidenceBlock incoming) {
|
||||||
|
EvidenceBlock existing = deduped.get(key);
|
||||||
|
if (existing == null) {
|
||||||
|
deduped.put(key, incoming);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> 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<String> buildL0HitReasons(KnowledgeEntry entry, int rank) {
|
||||||
|
List<String> 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<String, String> parseMetadata(String metadata) {
|
||||||
|
if (metadata == null || metadata.isBlank()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<?, ?> raw = objectMapper.readValue(metadata, Map.class);
|
||||||
|
Map<String, String> 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<EvidenceBlock> blocks) {}
|
||||||
|
|
||||||
private int countMdHeadings(String content) {
|
private int countMdHeadings(String content) {
|
||||||
if (content == null) return 0;
|
if (content == null) return 0;
|
||||||
return (int) content.lines()
|
return (int) content.lines()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.superbiz.agent.service;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.superbiz.agent.domain.entity.ToolInvocation;
|
import com.superbiz.agent.domain.entity.ToolInvocation;
|
||||||
|
import com.superbiz.agent.dto.EvidenceBlock;
|
||||||
import com.superbiz.agent.repository.ToolInvocationRepository;
|
import com.superbiz.agent.repository.ToolInvocationRepository;
|
||||||
import com.superbiz.agent.util.SessionContextHolder;
|
import com.superbiz.agent.util.SessionContextHolder;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -78,6 +79,14 @@ class ToolInvocationRecorderTest {
|
|||||||
.l0MatchedKeywords(List.of("ERR_TIMEOUT"))
|
.l0MatchedKeywords(List.of("ERR_TIMEOUT"))
|
||||||
.l0Domains(List.of("payment"))
|
.l0Domains(List.of("payment"))
|
||||||
.l0Entities(List.of("ERR_TIMEOUT"))
|
.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();
|
.build();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -98,5 +107,45 @@ class ToolInvocationRecorderTest {
|
|||||||
assertTrue(saved.getRetrievalDetails().contains("\"l0_matched_keywords\":[\"ERR_TIMEOUT\"]"));
|
assertTrue(saved.getRetrievalDetails().contains("\"l0_matched_keywords\":[\"ERR_TIMEOUT\"]"));
|
||||||
assertTrue(saved.getRetrievalDetails().contains("\"l0_domains\":[\"payment\"]"));
|
assertTrue(saved.getRetrievalDetails().contains("\"l0_domains\":[\"payment\"]"));
|
||||||
assertTrue(saved.getRetrievalDetails().contains("\"l0_entities\":[\"ERR_TIMEOUT\"]"));
|
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("..."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.mockito.MockitoAnnotations;
|
|||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
@@ -83,6 +84,12 @@ class LookupKnowledgeToolTest {
|
|||||||
assertTrue(content.contains("Test content"));
|
assertTrue(content.contains("Test content"));
|
||||||
assertNotNull(result.getSupplement()); // L0 唯一命中仍调用 L1
|
assertNotNull(result.getSupplement()); // L0 唯一命中仍调用 L1
|
||||||
assertEquals("PRECISE", result.getRelevanceLevel());
|
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);
|
verify(vectorSearchService).searchSimilarDocuments("ERR_TIMEOUT", 3, null);
|
||||||
}
|
}
|
||||||
@@ -290,6 +297,41 @@ class LookupKnowledgeToolTest {
|
|||||||
verify(vectorSearchService).searchSimilarDocuments("fallback", 3, null);
|
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) {
|
private KnowledgeIndexService.L0Hint hint(KnowledgeEntry... entries) {
|
||||||
List<KnowledgeEntry> matches = List.of(entries);
|
List<KnowledgeEntry> matches = List.of(entries);
|
||||||
List<String> keywords = matches.stream()
|
List<String> keywords = matches.stream()
|
||||||
|
|||||||
Reference in New Issue
Block a user