feat(harness,rag): dual LLM audit fields, run conclusion, and hybrid quality

Persist provider reasoning and assistant text separately on agent_reasoning_audit
(DeepSeekAssistantMessage path), extract diagnosis_run.conclusion, enrich RAG
tool audit (step_id/query/qualityScore), gate empty mysql tools, drop devtools,
and align MVP docs after live E2E verification.
This commit is contained in:
zhuyongxin
2026-07-28 19:43:13 +08:00
parent 2f40536248
commit 7ae9707a3b
116 changed files with 8364 additions and 1141 deletions
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.config.MysqlToolProperties.DataSourceProperties;
import com.superbiz.agent.harness.agent.HarnessEvidenceTools;
import com.superbiz.agent.harness.agent.DiagnosisAgentFactory;
import com.superbiz.agent.harness.audit.AgentStepAuditTracker;
import com.superbiz.agent.harness.agent.DiagnosisAgentLimits;
import com.superbiz.agent.harness.agent.DiagnosisAgentUseCase;
import com.superbiz.agent.harness.application.ChatApplicationUseCase;
@@ -153,8 +154,9 @@ public class HarnessChatConfiguration {
@Bean
public ToolBoundary toolBoundary(DiagnosisHarnessCore core, ToolCallKeyFactory keyFactory,
CanonicalInvocationStore store, ObjectMapper objectMapper, Clock clock,
ToolInvocationAuditSink auditSink) {
return new ToolBoundary(core, keyFactory, store, objectMapper, clock, auditSink);
ToolInvocationAuditSink auditSink,
AgentStepAuditTracker stepTracker) {
return new ToolBoundary(core, keyFactory, store, objectMapper, clock, auditSink, stepTracker);
}
@Bean
@@ -240,8 +242,15 @@ public class HarnessChatConfiguration {
@Bean
public HarnessEvidenceTools harnessEvidenceTools(RagToolAdapter rag, QueryLogsToolAdapter logs,
MysqlToolAdapter mysql) {
return HarnessEvidenceTools.fromAdapters(rag, logs, mysql);
MysqlToolAdapter mysql,
MysqlToolProperties mysqlToolProperties) {
// query_mysql is only exposed when at least one logical datasource is configured.
// Default application.yml has harness.mysql-tools.data-sources: {} — do not inject a dead tool.
boolean mysqlEnabled = mysqlToolProperties != null
&& mysqlToolProperties.getDataSources() != null
&& mysqlToolProperties.getDataSources().values().stream()
.anyMatch(ds -> ds != null && ds.getJdbcUrl() != null && !ds.getJdbcUrl().isBlank());
return HarnessEvidenceTools.fromAdapters(rag, logs, mysqlEnabled ? mysql : null);
}
@Bean
@@ -250,10 +259,12 @@ public class HarnessChatConfiguration {
AgentStepRepository steps,
AgentReasoningAuditRepository reasoningAudits,
DiagnosisTraceRecorder traceRecorder,
ModelCallAuditor modelCallAuditor) {
ModelCallAuditor modelCallAuditor,
AgentStepAuditTracker stepTracker) {
return new DiagnosisAgentFactory(chatModel, core, tools, mapper,
List.of(new HarnessAgentAuditHook(
steps, mapper, DiagnosisAgentFactory.AGENT_NAME, traceRecorder, reasoningAudits)),
steps, mapper, DiagnosisAgentFactory.AGENT_NAME, traceRecorder, reasoningAudits,
stepTracker)),
traceRecorder, modelCallAuditor);
}
@@ -6,10 +6,15 @@ import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
/**
* Milvus configuration notes.
* Milvus 知识路径配置说明(无额外 Bean 装配)。
*
* <p>Knowledge RAG uses {@link MilvusHybridKnowledgeStore} (MilvusClientV2) exclusively.
* Legacy {@code MilvusServiceClient} bean is no longer created for the knowledge path.</p>
* <p>知识库 RAG 唯一实现:{@link MilvusHybridKnowledgeStore}({@code MilvusClientV2})。</p>
* <ul>
* <li>支持 dense 与 dense+BM25 {@code hybridSearch}+RRF。</li>
* <li>不再为知识路径创建 legacy {@code MilvusServiceClient} Bean。</li>
* <li>Spring AI {@code VectorStore} starter 仍可存在于 classpath,但只作 sidecar,
* 不作 lookup_knowledge 主路径(starter 无 BM25 hybrid API)。</li>
* </ul>
*/
@Configuration
public class MilvusConfig {
@@ -42,12 +42,32 @@ public class AgentReasoningAudit {
@Column(name = "agent_name", nullable = false, length = 64)
private String agentName;
/** True when provider returned a non-blank reasoning/thinking chain. */
@Column(name = "reasoning_available", nullable = false)
private Boolean reasoningAvailable;
/**
* Provider chain-of-thought / reasoning_content when available.
* Never stores tool result payloads.
*/
@Column(name = "reasoning_content", columnDefinition = "LONGTEXT")
private String reasoningContent;
/**
* Assistant-visible text for this model step (final prose and/or tool-call plan).
* Tool result message bodies are not stored here.
*/
@Column(name = "assistant_text", columnDefinition = "LONGTEXT")
private String assistantText;
/**
* Summary of what was stored:
* PROVIDER_REASONING+ASSISTANT_TEXT | PROVIDER_REASONING | ASSISTANT_TEXT | TOOL_CALL_PLAN | NONE
*/
@Column(name = "content_source", length = 64)
private String contentSource;
/** UTF-8 byte length of reasoning_content + assistant_text combined. */
@Column(name = "content_bytes", nullable = false)
private Integer contentBytes;
@@ -41,6 +41,10 @@ public class DiagnosisRun {
@Column(name = "query", nullable = false, columnDefinition = "TEXT")
private String query;
/** Extracted final conclusion text, parallel to query for simple readout. */
@Column(name = "conclusion", columnDefinition = "TEXT")
private String conclusion;
@Column(name = "status", length = 16)
private String status = "PENDING";
@@ -71,6 +71,8 @@ public class DiagnosisTraceResponse {
private String runId;
private String sessionId;
private String query;
/** Extracted conclusion text, parallel to query. */
private String conclusion;
private String status;
private String agentFlow;
private String intent;
@@ -164,7 +166,11 @@ public class DiagnosisTraceResponse {
private Integer stepIndex;
private String agentName;
private Boolean reasoningAvailable;
/** Provider thinking / CoT when available. */
private String reasoningContent;
/** Assistant visible text and/or tool-call plan (no tool results). */
private String assistantText;
private String contentSource;
private Integer contentBytes;
private LocalDateTime createdAt;
}
@@ -31,7 +31,7 @@ public class EvidencePostprocessResult {
private RerankTrace rerankTrace;
/**
* 排序第一名的 baseScore(0~1 相似度,不含规则 boost)。
* 排序第一名(originalRank 最优)的 qualityScore(0~1,越大越好)。
* 用于 isLowQuality 与 attempt.topSimilarity。
*/
private Double topSimilarity;
@@ -9,70 +9,59 @@ import java.util.Map;
/**
* L1 向量命中后、后处理前的统一候选结构。
*
* <p>由检索适配器从 {@code KnowledgeSearchHit} / 向量结果映射而来。
* 后处理会基于它做归一化、规则 boost、chunk 级去重并生成 {@link EvidenceBlock}。</p>
* <p>后处理:{@code RetrievalScoreNormalizer} → qualityScore;按 {@link #originalRank} 保序;
* evidenceKey 去重 / 截断;不再用关键词 boost 改序。</p>
*/
@Data
@Builder
public class RetrievedEvidenceCandidate {
/** 向量库记录 id。 */
private String id;
/**
* 文档级 id(metadata.docId 等)。
* 用于每文档 chunk 上限;不等于 evidenceKey。
*/
private String docId;
/** 文档内切片序号;可能为空(老数据)。 */
private Integer chunkIndex;
/**
* 片段级去重/投影主键。
* 通常为 docId#chunk-N,fallback 为 vector:{id}。
*/
private String evidenceKey;
/**
* 来源标识(_source / source / filePath / docId 等)。
* 可与同文档其他 chunk 重复;不再作为唯一去重键。
*/
private String source;
private String title;
private String breadcrumb;
/** chunk 正文原文(后处理前未截断或仅底层原样)。 */
private String content;
/** 固定为 L1(向量层);预留多路召回标记。 */
private String retrievalLayer;
/** 所属 attempt 名,如 FILTERED_VECTOR。 */
private String retrievalAttempt;
/**
* 兼容 L2 距离分(越小越相似),后处理会 normalize 成 baseScore。
* 引擎主分:dense=L2;hybrid=融合分。量纲由 {@link #scoreLabel} 解释。
*/
private Double score;
/** 底层原始分。 */
private Double rawScore;
/** rawScore 语义标签:l2_distance / similarity。 */
/**
* {@code dense} | {@code hybrid}(及可被 canonicalize 的历史别名)。
*/
private String scoreLabel;
/** 向量召回顺序(从 1 起),规则 rerank 前的名次。 */
/**
* 检索返回名次(从 1 起)——后处理排序权威。
*/
private Integer originalRank;
/**
* 扁平化 metadata(string map)。
* 可能含 docId、chunkIndex、category、kb_scope 等。
* hybrid 命中可选的 dense L2,仅供质量闸门;不参与排序。
*/
private Double denseDistance;
private Map<String, String> metadata;
/** 初步命中原因,后处理会追加 boost reasons。 */
/**
* 初步命中原因;后处理可追加 L0 重叠解释(无分值)。
*/
private List<String> hitReasons;
}
@@ -31,6 +31,10 @@ public final class HarnessEvidenceTools {
private final List<ToolCallback> callbacks;
private final Map<String, EvidenceToolInvoker> invokers;
/**
* @param mysqlInvoker optional; when null, {@code query_mysql} is not registered
* (no logical datasource configured / tool unavailable).
*/
public HarnessEvidenceTools(EvidenceToolInvoker ragInvoker,
EvidenceToolInvoker logsInvoker,
EvidenceToolInvoker mysqlInvoker) {
@@ -39,28 +43,38 @@ public final class HarnessEvidenceTools {
Objects.requireNonNull(ragInvoker, "ragInvoker must not be null"));
registered.put(AgentToolContracts.QUERY_LOGS,
Objects.requireNonNull(logsInvoker, "logsInvoker must not be null"));
registered.put(AgentToolContracts.QUERY_MYSQL,
Objects.requireNonNull(mysqlInvoker, "mysqlInvoker must not be null"));
if (mysqlInvoker != null) {
registered.put(AgentToolContracts.QUERY_MYSQL, mysqlInvoker);
}
this.invokers = Map.copyOf(registered);
this.callbacks = List.of(
definition(AgentToolContracts.LOOKUP_KNOWLEDGE,
AgentToolContracts.LOOKUP_KNOWLEDGE_DESCRIPTION, RagToolCall.class),
definition(AgentToolContracts.QUERY_LOGS,
AgentToolContracts.QUERY_LOGS_DESCRIPTION, QueryLogsToolCall.class),
definition(AgentToolContracts.QUERY_MYSQL,
AgentToolContracts.QUERY_MYSQL_DESCRIPTION, MysqlToolCall.class));
List<ToolCallback> built = new java.util.ArrayList<>();
built.add(definition(AgentToolContracts.LOOKUP_KNOWLEDGE,
AgentToolContracts.LOOKUP_KNOWLEDGE_DESCRIPTION, RagToolCall.class));
built.add(definition(AgentToolContracts.QUERY_LOGS,
AgentToolContracts.QUERY_LOGS_DESCRIPTION, QueryLogsToolCall.class));
if (mysqlInvoker != null) {
built.add(definition(AgentToolContracts.QUERY_MYSQL,
AgentToolContracts.QUERY_MYSQL_DESCRIPTION, MysqlToolCall.class));
}
this.callbacks = List.copyOf(built);
}
/**
* @param mysqlAdapter optional; omit registration when null or when no datasources are wired
*/
public static HarnessEvidenceTools fromAdapters(RagToolAdapter ragAdapter,
QueryLogsToolAdapter logsAdapter,
MysqlToolAdapter mysqlAdapter) {
Objects.requireNonNull(ragAdapter, "ragAdapter must not be null");
Objects.requireNonNull(logsAdapter, "logsAdapter must not be null");
Objects.requireNonNull(mysqlAdapter, "mysqlAdapter must not be null");
EvidenceToolInvoker mysql = mysqlAdapter == null
? null
: bridge(AgentToolContracts.QUERY_MYSQL, mysqlAdapter::execute);
return new HarnessEvidenceTools(
bridge(AgentToolContracts.LOOKUP_KNOWLEDGE, ragAdapter::execute),
bridge(AgentToolContracts.QUERY_LOGS, logsAdapter::execute),
bridge(AgentToolContracts.QUERY_MYSQL, mysqlAdapter::execute));
mysql);
}
public List<ToolCallback> callbacks() {
@@ -6,18 +6,21 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.superbiz.agent.domain.entity.ChatSession;
import com.superbiz.agent.domain.entity.DiagnosisRun;
import com.superbiz.agent.harness.audit.AgentStepAuditTracker;
import com.superbiz.agent.harness.audit.ModelCallComponent;
import com.superbiz.agent.harness.audit.RunConclusionExtractor;
import com.superbiz.agent.harness.contract.IntentType;
import com.superbiz.agent.harness.contract.PreviousTurn;
import com.superbiz.agent.harness.contract.PublishedResult;
import com.superbiz.agent.harness.contract.ReleaseOutcome;
import com.superbiz.agent.harness.core.RunBudgetUsage;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.audit.ModelCallComponent;
import com.superbiz.agent.repository.ChatSessionRepository;
import com.superbiz.agent.repository.DiagnosisRunRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.LocalDateTime;
import java.util.List;
@@ -32,19 +35,28 @@ public class JpaChatRunStore implements ChatRunStore {
private final ObjectMapper objectMapper;
private final ObjectReader publishedReader;
private final PublishedResultPolicy publishedPolicy;
private final AgentStepAuditTracker stepTracker;
public JpaChatRunStore(ChatSessionRepository chatSessions,
DiagnosisRunRepository runs,
ObjectMapper objectMapper) {
this(chatSessions, runs, objectMapper,
new PublishedResultPolicy(PreviousTurnLimits.defaults()));
new PublishedResultPolicy(PreviousTurnLimits.defaults()), null);
}
public JpaChatRunStore(ChatSessionRepository chatSessions,
DiagnosisRunRepository runs,
ObjectMapper objectMapper,
PublishedResultPolicy publishedPolicy) {
this(chatSessions, runs, objectMapper, publishedPolicy, null);
}
@Autowired
public JpaChatRunStore(ChatSessionRepository chatSessions,
DiagnosisRunRepository runs,
ObjectMapper objectMapper,
PublishedResultPolicy publishedPolicy) {
PublishedResultPolicy publishedPolicy,
@Nullable AgentStepAuditTracker stepTracker) {
this.chatSessions = Objects.requireNonNull(chatSessions, "chatSessions must not be null");
this.runs = Objects.requireNonNull(runs, "runs must not be null");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
@@ -52,6 +64,7 @@ public class JpaChatRunStore implements ChatRunStore {
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
this.publishedPolicy = Objects.requireNonNull(publishedPolicy, "publishedPolicy must not be null");
this.stepTracker = stepTracker;
}
@Override
@@ -110,27 +123,34 @@ public class JpaChatRunStore implements ChatRunStore {
String safeContentJson, PublishedResult publishedResult, int durationMs) {
Objects.requireNonNull(context, "context must not be null");
Objects.requireNonNull(outcome, "outcome must not be null");
DiagnosisRun run = requiredRun(context.runId());
run.setIntent(intent);
run.setReleaseOutcome(outcome);
run.setStatus(status(outcome));
run.setAnswer(safeContentJson);
run.setPublishedResult(outcome == ReleaseOutcome.SUCCESS && intent == IntentType.DIAGNOSIS
&& publishedResult != null ? write(publishedResult) : null);
run.setTotalDurationMs(Math.max(0, durationMs));
RunBudgetUsage usage = context.budget().snapshot();
run.setTotalTokenCount(saturatingInt(usage.totalTokens()));
run.setStepCount(context.modelCalls().componentCallCount(
ModelCallComponent.DIAGNOSIS_AGENT));
run.setToolCallCount(saturatingInt(usage.toolCalls()));
runs.save(run);
if (outcome == ReleaseOutcome.SUCCESS || outcome == ReleaseOutcome.FALLBACK) {
chatSessions.findBySessionId(context.sessionId()).ifPresent(session -> {
session.setLastActiveAt(LocalDateTime.now());
session.setMessagePairCount(
session.getMessagePairCount() == null ? 1 : session.getMessagePairCount() + 1);
chatSessions.save(session);
});
try {
DiagnosisRun run = requiredRun(context.runId());
run.setIntent(intent);
run.setReleaseOutcome(outcome);
run.setStatus(status(outcome));
run.setAnswer(safeContentJson);
run.setConclusion(RunConclusionExtractor.extract(objectMapper, safeContentJson));
run.setPublishedResult(outcome == ReleaseOutcome.SUCCESS && intent == IntentType.DIAGNOSIS
&& publishedResult != null ? write(publishedResult) : null);
run.setTotalDurationMs(Math.max(0, durationMs));
RunBudgetUsage usage = context.budget().snapshot();
run.setTotalTokenCount(saturatingInt(usage.totalTokens()));
run.setStepCount(context.modelCalls().componentCallCount(
ModelCallComponent.DIAGNOSIS_AGENT));
run.setToolCallCount(saturatingInt(usage.toolCalls()));
runs.save(run);
if (outcome == ReleaseOutcome.SUCCESS || outcome == ReleaseOutcome.FALLBACK) {
chatSessions.findBySessionId(context.sessionId()).ifPresent(session -> {
session.setLastActiveAt(LocalDateTime.now());
session.setMessagePairCount(
session.getMessagePairCount() == null ? 1 : session.getMessagePairCount() + 1);
chatSessions.save(session);
});
}
} finally {
if (stepTracker != null) {
stepTracker.clear(context.runId());
}
}
}
@@ -0,0 +1,39 @@
package com.superbiz.agent.harness.audit;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
/**
* Binds the in-flight {@code agent_step.id} for a run so tool audits can set {@code step_id}.
*
* <p>Lifecycle: {@link #bind} on Agent {@code beforeModel} (step row created). Binding stays
* until the next {@code beforeModel} for the same run, covering tool execution that happens
* after {@code afterModel} emits tool_calls. {@link #clear} on run finish is optional cleanup.</p>
*/
@Component
public final class AgentStepAuditTracker {
private final ConcurrentHashMap<String, Long> currentStepIdByRun = new ConcurrentHashMap<>();
public void bind(String runId, Long stepId) {
if (runId == null || runId.isBlank() || stepId == null) {
return;
}
currentStepIdByRun.put(runId, stepId);
}
public Long currentStepId(String runId) {
if (runId == null || runId.isBlank()) {
return null;
}
return currentStepIdByRun.get(runId);
}
public void clear(String runId) {
if (runId == null || runId.isBlank()) {
return;
}
currentStepIdByRun.remove(runId);
}
}
@@ -7,32 +7,53 @@ import com.alibaba.cloud.ai.graph.agent.hook.messages.AgentCommand;
import com.alibaba.cloud.ai.graph.agent.hook.messages.MessagesModelHook;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.domain.entity.AgentStep;
import com.superbiz.agent.domain.entity.AgentReasoningAudit;
import com.superbiz.agent.domain.entity.AgentStep;
import com.superbiz.agent.repository.AgentReasoningAuditRepository;
import com.superbiz.agent.repository.AgentStepRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.deepseek.DeepSeekAssistantMessage;
import java.nio.charset.StandardCharsets;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* Persists per-model-step audit for the diagnosis agent.
*
* <p>LLM-facing content policy for {@link AgentReasoningAudit}:</p>
* <ul>
* <li>{@code reasoning_content}: provider thinking/CoT when present</li>
* <li>{@code assistant_text}: assistant visible text and/or tool-call plan</li>
* <li>Tool <em>result</em> payloads are never stored here (use {@code tool_invocation})</li>
* </ul>
*/
@HookPositions({HookPosition.BEFORE_MODEL, HookPosition.AFTER_MODEL})
public final class HarnessAgentAuditHook extends MessagesModelHook {
private static final Logger log = LoggerFactory.getLogger(HarnessAgentAuditHook.class);
private static final int MAX_TEXT_CHARS = 32_000;
public static final String SOURCE_BOTH = "PROVIDER_REASONING+ASSISTANT_TEXT";
public static final String SOURCE_PROVIDER = "PROVIDER_REASONING";
public static final String SOURCE_ASSISTANT = "ASSISTANT_TEXT";
public static final String SOURCE_TOOL_PLAN = "TOOL_CALL_PLAN";
public static final String SOURCE_NONE = "NONE";
private final AgentStepRepository repository;
private final ObjectMapper objectMapper;
private final String agentName;
private final DiagnosisTraceRecorder traceRecorder;
private final AgentReasoningAuditRepository reasoningRepository;
private final AgentStepAuditTracker stepTracker;
private final ConcurrentHashMap<String, Integer> stepCounters = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, PendingStep> pendingSteps = new ConcurrentHashMap<>();
@@ -42,12 +63,19 @@ public final class HarnessAgentAuditHook extends MessagesModelHook {
public HarnessAgentAuditHook(AgentStepRepository repository, ObjectMapper objectMapper,
String agentName, DiagnosisTraceRecorder traceRecorder) {
this(repository, objectMapper, agentName, traceRecorder, null);
this(repository, objectMapper, agentName, traceRecorder, null, null);
}
public HarnessAgentAuditHook(AgentStepRepository repository, ObjectMapper objectMapper,
String agentName, DiagnosisTraceRecorder traceRecorder,
AgentReasoningAuditRepository reasoningRepository) {
this(repository, objectMapper, agentName, traceRecorder, reasoningRepository, null);
}
public HarnessAgentAuditHook(AgentStepRepository repository, ObjectMapper objectMapper,
String agentName, DiagnosisTraceRecorder traceRecorder,
AgentReasoningAuditRepository reasoningRepository,
AgentStepAuditTracker stepTracker) {
this.repository = Objects.requireNonNull(repository, "repository must not be null");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
if (agentName == null || agentName.isBlank()) {
@@ -56,6 +84,7 @@ public final class HarnessAgentAuditHook extends MessagesModelHook {
this.agentName = agentName;
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
this.reasoningRepository = reasoningRepository;
this.stepTracker = stepTracker;
}
@Override
@@ -82,6 +111,9 @@ public final class HarnessAgentAuditHook extends MessagesModelHook {
.hasToolCall(false)
.build());
pending = new PendingStep(saved.getId(), pending.startedNanos(), pending.input());
if (stepTracker != null && saved.getId() != null) {
stepTracker.bind(identity.runId(), saved.getId());
}
} catch (RuntimeException exception) {
log.warn("Failed to persist AgentStep audit before model: agent={}", agentName);
}
@@ -106,14 +138,15 @@ public final class HarnessAgentAuditHook extends MessagesModelHook {
? List.of()
: assistant.getToolCalls().stream().map(AssistantMessage.ToolCall::name)
.distinct().sorted().toList();
ReasoningContent reasoning = reasoningContent(assistant);
Map<String, Object> output = outputMetadata(assistant, toolNames, reasoning);
LlmTurnContent turn = llmTurnContent(assistant, toolNames);
Map<String, Object> output = outputMetadata(assistant, toolNames, turn);
int durationMs = durationMillis(pending.startedNanos());
try {
AgentStep step = pending.id() == null ? null : repository.findById(pending.id()).orElse(null);
if (step != null) {
step.setModelOutput(write(output));
step.setThought(null);
// Keep thought aligned with provider reasoning when present; else assistant text.
step.setThought(firstNonBlank(turn.reasoningContent(), turn.assistantText()));
step.setHasToolCall(!toolNames.isEmpty());
step.setDurationMs(durationMs);
repository.save(step);
@@ -121,7 +154,7 @@ public final class HarnessAgentAuditHook extends MessagesModelHook {
} catch (RuntimeException exception) {
log.warn("Failed to complete AgentStep audit: agent={}", agentName);
}
persistReasoning(identity, stepIndex, reasoning);
persistReasoning(identity, stepIndex, turn);
traceRecorder.record(TraceAuditEvents.agentModelStep(
identity.sessionId(), identity.runId(), agentName, stepIndex,
durationMs, pending.input(), output));
@@ -139,32 +172,148 @@ public final class HarnessAgentAuditHook extends MessagesModelHook {
}
private Map<String, Object> outputMetadata(AssistantMessage assistant, List<String> toolNames,
ReasoningContent reasoning) {
LlmTurnContent turn) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("has_text", assistant != null
&& assistant.getText() != null && !assistant.getText().isBlank());
metadata.put("tool_names", toolNames);
metadata.put("reasoning_available", reasoning.available());
metadata.put("reasoning_bytes", reasoning.bytes());
metadata.put("reasoning_available", turn.reasoningAvailable());
metadata.put("reasoning_bytes", turn.reasoningBytes());
metadata.put("assistant_bytes", turn.assistantBytes());
metadata.put("content_source", turn.contentSource());
return metadata;
}
private ReasoningContent reasoningContent(AssistantMessage assistant) {
if (assistant == null || assistant.getMetadata() == null) {
return ReasoningContent.empty();
/**
* Captures both provider reasoning and assistant-visible text.
* Tool <em>results</em> are never included.
*/
static LlmTurnContent llmTurnContent(AssistantMessage assistant, List<String> toolNames) {
String reasoning = providerReasoning(assistant);
String assistantBody = assistantText(assistant);
String toolPlan = toolCallPlan(assistant, toolNames);
String assistantCombined = joinNonBlank("\n", assistantBody, toolPlan);
boolean hasReasoning = hasText(reasoning);
boolean hasAssistant = hasText(assistantCombined);
String source;
if (hasReasoning && hasAssistant) {
source = SOURCE_BOTH;
} else if (hasReasoning) {
source = SOURCE_PROVIDER;
} else if (hasText(assistantBody)) {
source = SOURCE_ASSISTANT;
} else if (hasText(toolPlan)) {
source = SOURCE_TOOL_PLAN;
} else {
source = SOURCE_NONE;
}
for (String key : List.of("reasoning_content", "reasoningContent", "reasoning", "thinking")) {
Object value = assistant.getMetadata().get(key);
if (value instanceof CharSequence text && !text.toString().isBlank()) {
String content = bounded(text.toString());
return new ReasoningContent(content, true,
content.getBytes(StandardCharsets.UTF_8).length);
}
}
return ReasoningContent.empty();
String reasoningBound = bound(reasoning);
String assistantBound = bound(assistantCombined);
int bytes = utf8Bytes(reasoningBound) + utf8Bytes(assistantBound);
return new LlmTurnContent(
hasReasoning,
reasoningBound,
assistantBound,
source,
utf8Bytes(reasoningBound),
utf8Bytes(assistantBound),
bytes);
}
private void persistReasoning(AuditIdentity identity, int stepIndex, ReasoningContent reasoning) {
/**
* DeepSeek puts CoT on {@link DeepSeekAssistantMessage#getReasoningContent()},
* <em>not</em> on {@link AssistantMessage#getMetadata()}. Older docs/tests used metadata keys;
* keep those as fallback for mocks and non-DeepSeek providers.
*/
static String providerReasoning(AssistantMessage assistant) {
if (assistant == null) {
return null;
}
// 1) Native DeepSeek message field (primary path in production)
if (assistant instanceof DeepSeekAssistantMessage deepSeek) {
String nativeReasoning = blankToNull(deepSeek.getReasoningContent());
if (nativeReasoning != null) {
return nativeReasoning;
}
}
// 2) Reflective getReasoningContent() for subclasses / reloaded types
String reflective = invokeReasoningGetter(assistant);
if (reflective != null) {
return reflective;
}
// 3) Metadata keys (tests / other providers)
Map<String, Object> metadata = assistant.getMetadata();
if (metadata != null) {
for (String key : List.of(
"reasoning_content", "reasoningContent", "reasoning", "thinking",
"reasoning_text", "reasoningText")) {
Object value = metadata.get(key);
if (value instanceof CharSequence text) {
String trimmed = blankToNull(text.toString());
if (trimmed != null) {
return trimmed;
}
}
}
}
return null;
}
private static String invokeReasoningGetter(AssistantMessage assistant) {
try {
Method method = assistant.getClass().getMethod("getReasoningContent");
Object value = method.invoke(assistant);
return value instanceof CharSequence text ? blankToNull(text.toString()) : null;
} catch (ReflectiveOperationException ignored) {
return null;
}
}
private static String blankToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private static String assistantText(AssistantMessage assistant) {
if (assistant == null || assistant.getText() == null || assistant.getText().isBlank()) {
return null;
}
return assistant.getText().trim();
}
/**
* Records which tools the model decided to call (names + arg preview), not tool outputs.
*/
private static String toolCallPlan(AssistantMessage assistant, List<String> toolNames) {
if (assistant == null || assistant.getToolCalls() == null || assistant.getToolCalls().isEmpty()) {
return null;
}
List<String> lines = new ArrayList<>();
lines.add("tool_calls:");
for (AssistantMessage.ToolCall call : assistant.getToolCalls()) {
if (call == null) {
continue;
}
String name = call.name() == null ? "?" : call.name();
String args = call.arguments() == null ? "" : call.arguments().trim();
if (args.length() > 500) {
args = args.substring(0, 500) + "...";
}
lines.add("- " + name + (args.isEmpty() ? "" : " args=" + args));
}
if (lines.size() == 1 && toolNames != null && !toolNames.isEmpty()) {
lines.add("- " + String.join(", ", toolNames));
}
return lines.size() <= 1 ? null : String.join("\n", lines);
}
private void persistReasoning(AuditIdentity identity, int stepIndex, LlmTurnContent turn) {
if (reasoningRepository == null) {
return;
}
@@ -174,18 +323,51 @@ public final class HarnessAgentAuditHook extends MessagesModelHook {
.runId(identity.runId())
.stepIndex(stepIndex)
.agentName(agentName)
.reasoningAvailable(reasoning.available())
.reasoningContent(reasoning.content())
.contentBytes(reasoning.bytes())
.reasoningAvailable(turn.reasoningAvailable())
.reasoningContent(turn.reasoningContent())
.assistantText(turn.assistantText())
.contentSource(turn.contentSource())
.contentBytes(turn.totalBytes())
.build());
} catch (RuntimeException exception) {
log.warn("Failed to persist reasoning audit: agent={}, step={}", agentName, stepIndex);
}
}
private static String bounded(String value) {
int maxChars = 32_000;
return value.length() <= maxChars ? value : value.substring(0, maxChars);
private static String bound(String value) {
if (value == null) {
return null;
}
return value.length() <= MAX_TEXT_CHARS ? value : value.substring(0, MAX_TEXT_CHARS);
}
private static int utf8Bytes(String value) {
return value == null ? 0 : value.getBytes(StandardCharsets.UTF_8).length;
}
private static String joinNonBlank(String sep, String a, String b) {
boolean ha = hasText(a);
boolean hb = hasText(b);
if (ha && hb) {
return a + sep + b;
}
if (ha) {
return a;
}
if (hb) {
return b;
}
return null;
}
private static String firstNonBlank(String a, String b) {
if (hasText(a)) {
return a;
}
if (hasText(b)) {
return b;
}
return null;
}
private AuditIdentity identity(RunnableConfig config) {
@@ -233,10 +415,14 @@ public final class HarnessAgentAuditHook extends MessagesModelHook {
private record AuditIdentity(String sessionId, String runId) {
}
private record ReasoningContent(String content, boolean available, int bytes) {
private static ReasoningContent empty() {
return new ReasoningContent(null, false, 0);
}
record LlmTurnContent(
boolean reasoningAvailable,
String reasoningContent,
String assistantText,
String contentSource,
int reasoningBytes,
int assistantBytes,
int totalBytes) {
}
private record PendingStep(Long id, long startedNanos, Map<String, Object> input) {
@@ -1,66 +1,247 @@
package com.superbiz.agent.harness.audit;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.domain.entity.ToolInvocation;
import com.superbiz.agent.harness.contract.InvocationStatus;
import com.superbiz.agent.repository.ToolInvocationRepository;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Persists tool invocation audit rows.
*
* <p>For {@code lookup_knowledge}, enriches RAG-specific columns from internal LookupResult
* JSON when present. {@code relevance_level} stores RAG PRECISE/REFERENCE (not evidence_status).
* {@code evidence_status} remains in {@code retrieval_details} for all tools.</p>
*/
@Component
public final class JpaToolInvocationAuditSink implements ToolInvocationAuditSink {
/** Max chars stored for free-text query / sql / topic style fields. */
private static final int MAX_TEXT_PREVIEW = 160;
private static final int MAX_REQUEST_KEYS = 12;
private static final Set<String> SENSITIVE_KEY_FRAGMENTS = Set.of(
"password", "passwd", "secret", "token", "apikey", "api_key",
"authorization", "credential", "private_key", "access_key");
private final ToolInvocationRepository repository;
private final ObjectMapper objectMapper;
private final DiagnosisTraceRecorder traceRecorder;
public JpaToolInvocationAuditSink(ToolInvocationRepository repository, ObjectMapper objectMapper) {
this(repository, objectMapper, DiagnosisTraceRecorder.noop());
}
private final RagLookupAuditEnricher ragEnricher;
@Autowired
public JpaToolInvocationAuditSink(ToolInvocationRepository repository, ObjectMapper objectMapper,
DiagnosisTraceRecorder traceRecorder) {
public JpaToolInvocationAuditSink(ToolInvocationRepository repository,
ObjectMapper objectMapper,
DiagnosisTraceRecorder traceRecorder,
ObjectProvider<RagLookupAuditEnricher> ragEnricherProvider) {
this.repository = Objects.requireNonNull(repository, "repository must not be null");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
this.ragEnricher = ragEnricherProvider == null ? null : ragEnricherProvider.getIfAvailable();
}
/** Test helper with explicit enricher (may be null). */
static JpaToolInvocationAuditSink forTest(ToolInvocationRepository repository,
ObjectMapper objectMapper,
DiagnosisTraceRecorder traceRecorder,
RagLookupAuditEnricher ragEnricher) {
return new JpaToolInvocationAuditSink(repository, objectMapper, traceRecorder, ragEnricher, true);
}
private JpaToolInvocationAuditSink(ToolInvocationRepository repository,
ObjectMapper objectMapper,
DiagnosisTraceRecorder traceRecorder,
RagLookupAuditEnricher ragEnricher,
boolean testMarker) {
this.repository = Objects.requireNonNull(repository, "repository must not be null");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
this.ragEnricher = ragEnricher;
}
@Override
public void record(ToolInvocationAuditEvent event) {
Objects.requireNonNull(event, "event must not be null");
traceRecorder.record(TraceAuditEvents.toolInvocation(event));
Map<String, Object> details = baseResultMetadata(event);
String retrievalLayer = "HARNESS";
Integer l0 = null;
Integer l1 = null;
String relevanceLevel = null;
Boolean truncated = false;
String dedupReason = null;
String outputPreview = "status=%s,evidence_status=%s".formatted(
event.status(), event.evidenceStatus());
if (ragEnricher != null && ragEnricher.supports(event.toolName())) {
RagLookupAuditEnricher.Enrichment enrichment =
ragEnricher.enrich(event.rawResultJson(), event.agentResultJson());
if (enrichment.retrievalDetails() != null) {
details.putAll(enrichment.retrievalDetails());
}
if (enrichment.retrievalLayer() != null && !enrichment.retrievalLayer().isBlank()) {
retrievalLayer = enrichment.retrievalLayer();
}
l0 = enrichment.l0MatchCount();
l1 = enrichment.l1MatchCount();
// RAG semantic level only — never overwrite with evidence_status enum names
relevanceLevel = enrichment.relevanceLevel();
if (enrichment.truncated() != null) {
truncated = enrichment.truncated();
}
dedupReason = enrichment.dedupReason();
if (enrichment.outputPreview() != null && !enrichment.outputPreview().isBlank()) {
outputPreview = enrichment.outputPreview()
+ ",status=" + event.status()
+ ",evidence_status=" + event.evidenceStatus();
}
}
// Always keep harness evidence_status in details (column relevance_level is RAG-only when set)
details.put("evidence_status", event.evidenceStatus().name());
details.put("invocation_status", event.status().name());
repository.save(ToolInvocation.builder()
.sessionId(event.sessionId())
.runId(event.runId())
.stepId(event.stepId())
.toolName(event.toolName())
.inputParams(write(inputMetadata(event)))
.outputPreview("status=%s,evidence_status=%s".formatted(
event.status(), event.evidenceStatus()))
.outputPreview(outputPreview)
.outputLength(event.agentResultBytes())
.retrievalLayer("HARNESS")
.isTruncated(false)
.relevanceLevel(event.evidenceStatus().name())
.retrievalDetails(write(resultMetadata(event)))
.retrievalLayer(retrievalLayer)
.l0MatchCount(l0)
.l1MatchCount(l1)
.isTruncated(Boolean.TRUE.equals(truncated))
.relevanceLevel(relevanceLevel)
.dedupReason(dedupReason)
.retrievalDetails(write(details))
.durationMs(event.durationMs())
.success(event.status() == InvocationStatus.READY)
.errorMessage(event.errorCode())
.build());
}
private Map<String, Object> inputMetadata(ToolInvocationAuditEvent event) {
/**
* Bounded request audit: always tool_call_id + request_bytes; optionally step_id and
* safe scalar fields from request JSON (query/topic/region/limit/...). Never stores
* password/token-like keys or nested blobs wholesale.
*/
Map<String, Object> inputMetadata(ToolInvocationAuditEvent event) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("tool_call_id", event.toolCallId());
metadata.put("request_bytes", event.requestBytes());
if (event.stepId() != null) {
metadata.put("step_id", event.stepId());
}
appendSafeRequestFields(metadata, event.requestJson());
return metadata;
}
private Map<String, Object> resultMetadata(ToolInvocationAuditEvent event) {
private void appendSafeRequestFields(Map<String, Object> metadata, String requestJson) {
if (requestJson == null || requestJson.isBlank()) {
return;
}
try {
JsonNode root = objectMapper.readTree(requestJson);
if (root == null || !root.isObject()) {
return;
}
int added = 0;
Iterator<Map.Entry<String, JsonNode>> fields = root.fields();
while (fields.hasNext() && added < MAX_REQUEST_KEYS) {
Map.Entry<String, JsonNode> entry = fields.next();
String key = entry.getKey();
if (key == null || key.isBlank() || isSensitiveKey(key)) {
continue;
}
JsonNode value = entry.getValue();
if (value == null || value.isNull()) {
continue;
}
if (value.isTextual()) {
String text = value.asText();
if (text == null || text.isBlank()) {
continue;
}
metadata.put(key, truncate(text, MAX_TEXT_PREVIEW));
if (text.length() > MAX_TEXT_PREVIEW) {
metadata.put(key + "_truncated", true);
metadata.put(key + "_chars", text.length());
}
added++;
} else if (value.isNumber()) {
metadata.put(key, value.numberValue());
added++;
} else if (value.isBoolean()) {
metadata.put(key, value.booleanValue());
added++;
} else if (value.isArray() && isStringArray(value)) {
List<String> items = new ArrayList<>();
for (int i = 0; i < value.size() && items.size() < 8; i++) {
JsonNode item = value.get(i);
if (item != null && item.isTextual() && !item.asText().isBlank()) {
items.add(truncate(item.asText(), 64));
}
}
if (!items.isEmpty()) {
metadata.put(key, items);
added++;
}
}
// objects / mixed arrays intentionally omitted
}
} catch (Exception ignored) {
metadata.put("request_parse", "failed");
}
}
private static boolean isStringArray(JsonNode value) {
if (value == null || !value.isArray() || value.isEmpty()) {
return false;
}
for (JsonNode n : value) {
if (n == null || !n.isTextual()) {
return false;
}
}
return true;
}
private static boolean isSensitiveKey(String key) {
String normalized = key.toLowerCase(Locale.ROOT);
for (String fragment : SENSITIVE_KEY_FRAGMENTS) {
if (normalized.contains(fragment)) {
return true;
}
}
return false;
}
private static String truncate(String value, int maxChars) {
if (value == null) {
return null;
}
if (value.length() <= maxChars) {
return value;
}
return value.substring(0, maxChars);
}
private Map<String, Object> baseResultMetadata(ToolInvocationAuditEvent event) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("tool_call_id", event.toolCallId());
metadata.put("status", event.status().name());
@@ -0,0 +1,335 @@
package com.superbiz.agent.harness.audit;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.Objects;
/**
* Builds durable RAG audit fields from internal {@code LookupResult} JSON
* (and optional projected agent result) without dumping full excerpts/traces.
*/
@Component
public class RagLookupAuditEnricher {
public static final String TOOL_LOOKUP_KNOWLEDGE = "lookup_knowledge";
private static final int MAX_EVIDENCE_KEYS = 12;
private static final int MAX_HINT_VALUES = 8;
private static final int PREVIEW_CHARS = 160;
private final ObjectMapper objectMapper;
private final String searchMode;
public RagLookupAuditEnricher(
ObjectMapper objectMapper,
@Value("${retrieval.search.mode:hybrid}") String searchMode) {
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
this.searchMode = searchMode == null || searchMode.isBlank()
? "hybrid"
: searchMode.trim().toLowerCase(Locale.ROOT);
}
public boolean supports(String toolName) {
return TOOL_LOOKUP_KNOWLEDGE.equals(toolName);
}
/**
* @param rawLookupJson internal LookupResult JSON (may be null on hard failures)
* @param agentResultJson projected RagToolResult JSON (may be null)
*/
public Enrichment enrich(String rawLookupJson, String agentResultJson) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("audit_schema", "rag_lookup_v1");
details.put("search_mode", searchMode);
String retrievalLayer = "L1";
Integer l0MatchCount = null;
Integer l1MatchCount = null;
String relevanceLevel = null;
Boolean truncated = null;
String dedupReason = null;
String outputPreview = null;
try {
if (rawLookupJson != null && !rawLookupJson.isBlank()) {
JsonNode root = objectMapper.readTree(rawLookupJson);
if (root != null && root.isObject()) {
relevanceLevel = text(root, "relevanceLevel");
if (relevanceLevel == null) {
relevanceLevel = text(root, "relevance_level");
}
JsonNode blocks = root.get("evidenceBlocks");
if (blocks != null && blocks.isArray()) {
l1MatchCount = blocks.size();
List<String> keys = new ArrayList<>();
List<String> sources = new ArrayList<>();
String layer = null;
for (int i = 0; i < blocks.size() && keys.size() < MAX_EVIDENCE_KEYS; i++) {
JsonNode b = blocks.get(i);
if (b == null || !b.isObject()) {
continue;
}
String key = firstText(b, "evidenceKey", "evidence_key");
if (key != null) {
keys.add(key);
}
String source = text(b, "source");
if (source != null && sources.size() < MAX_EVIDENCE_KEYS) {
sources.add(source);
}
if (layer == null) {
layer = text(b, "retrievalLayer");
}
}
if (!keys.isEmpty()) {
details.put("evidence_keys", keys);
}
if (!sources.isEmpty()) {
details.put("sources", sources);
}
if (layer != null && !layer.isBlank()) {
retrievalLayer = layer;
}
}
Integer candidateCount = intVal(root, "evidenceCandidateCount");
if (candidateCount != null) {
details.put("evidence_candidate_count", candidateCount);
if (l1MatchCount == null) {
l1MatchCount = candidateCount;
}
}
Integer blockCount = intVal(root, "evidenceBlockCount");
if (blockCount != null) {
details.put("evidence_block_count", blockCount);
}
String completenessHint = text(root, "completenessHint");
if (completenessHint != null) {
details.put("completeness_hint", truncate(completenessHint, PREVIEW_CHARS));
}
JsonNode trace = root.get("retrievalTrace");
if (trace != null && trace.isObject()) {
putText(details, "selected_attempt", text(trace, "selectedAttempt"));
putText(details, "fallback_reason", text(trace, "fallbackReason"));
putText(details, "retrieval_evidence_status", text(trace, "evidenceStatus"));
putText(details, "category_filter", text(trace, "categoryFilter"));
// query texts intentionally omitted from durable audit by default (PII/size)
JsonNode hints = trace.get("queryHints");
if (hints != null && hints.isObject()) {
Integer l0 = intVal(hints, "l0_match_count");
if (l0 == null) {
l0 = arraySize(hints.get("domains"));
}
l0MatchCount = l0;
Map<String, Object> hintSnap = new LinkedHashMap<>();
putLimitedList(hintSnap, "domains", hints.get("domains"));
putLimitedList(hintSnap, "matched_keywords", hints.get("matched_keywords"));
if (hintSnap.isEmpty()) {
putLimitedList(hintSnap, "matched_keywords", hints.get("matchedKeywords"));
}
if (!hintSnap.isEmpty()) {
details.put("l0_hints", hintSnap);
}
}
JsonNode attempts = trace.get("attempts");
if (attempts != null && attempts.isArray()) {
List<Map<String, Object>> attemptSnap = new ArrayList<>();
for (JsonNode a : attempts) {
if (a == null || !a.isObject()) {
continue;
}
Map<String, Object> row = new LinkedHashMap<>();
putText(row, "name", text(a, "name"));
putText(row, "category_filter", text(a, "categoryFilter"));
if (a.has("candidateCount") && a.get("candidateCount").canConvertToInt()) {
row.put("candidate_count", a.get("candidateCount").asInt());
}
if (a.has("usable") && a.get("usable").isBoolean()) {
row.put("usable", a.get("usable").asBoolean());
}
if (a.has("topSimilarity") && a.get("topSimilarity").isNumber()) {
row.put("top_similarity", a.get("topSimilarity").asDouble());
}
if (a.has("durationMs") && a.get("durationMs").canConvertToInt()) {
row.put("duration_ms", a.get("durationMs").asInt());
}
if (!row.isEmpty()) {
attemptSnap.add(row);
}
}
if (!attemptSnap.isEmpty()) {
details.put("attempts", attemptSnap);
}
}
}
JsonNode pack = root.get("contextPack");
if (pack != null && pack.isObject()) {
if (pack.has("usedChars") && pack.get("usedChars").canConvertToInt()) {
details.put("context_used_chars", pack.get("usedChars").asInt());
}
if (pack.has("charBudget") && pack.get("charBudget").canConvertToInt()) {
details.put("context_char_budget", pack.get("charBudget").asInt());
}
}
// compact preview for list UIs
outputPreview = buildPreview(relevanceLevel, details);
}
}
} catch (Exception ignored) {
details.put("enrich_error", "lookup_result_parse_failed");
}
try {
if (agentResultJson != null && !agentResultJson.isBlank()) {
JsonNode agent = objectMapper.readTree(agentResultJson);
if (agent != null && agent.isObject()) {
if (agent.has("truncated") && agent.get("truncated").isBoolean()) {
truncated = agent.get("truncated").asBoolean();
details.put("truncated", truncated);
}
if (relevanceLevel == null) {
relevanceLevel = text(agent, "relevance_level");
if (relevanceLevel == null) {
relevanceLevel = text(agent, "relevanceLevel");
}
}
if (agent.has("returned_count") && agent.get("returned_count").canConvertToInt()) {
details.put("returned_count", agent.get("returned_count").asInt());
}
}
}
} catch (Exception ignored) {
details.put("agent_result_parse", "failed");
}
if (outputPreview == null) {
outputPreview = buildPreview(relevanceLevel, details);
}
return new Enrichment(
retrievalLayer,
l0MatchCount,
l1MatchCount,
relevanceLevel,
truncated,
dedupReason,
details,
outputPreview
);
}
private static String buildPreview(String relevanceLevel, Map<String, Object> details) {
String attempt = details.get("selected_attempt") == null ? null : String.valueOf(details.get("selected_attempt"));
String fallback = details.get("fallback_reason") == null ? null : String.valueOf(details.get("fallback_reason"));
StringBuilder sb = new StringBuilder("lookup_knowledge");
if (relevanceLevel != null) {
sb.append(" level=").append(relevanceLevel);
}
if (attempt != null) {
sb.append(" attempt=").append(attempt);
}
if (fallback != null) {
sb.append(" fallback=").append(fallback);
}
Object keys = details.get("evidence_keys");
if (keys instanceof List<?> list) {
sb.append(" keys=").append(list.size());
}
return truncate(sb.toString(), PREVIEW_CHARS);
}
private static void putLimitedList(Map<String, Object> target, String key, JsonNode node) {
if (node == null || !node.isArray() || node.isEmpty()) {
return;
}
List<String> values = new ArrayList<>();
for (int i = 0; i < node.size() && values.size() < MAX_HINT_VALUES; i++) {
JsonNode n = node.get(i);
if (n != null && n.isTextual() && !n.asText().isBlank()) {
values.add(n.asText());
}
}
if (!values.isEmpty()) {
target.put(key, values);
}
}
private static void putText(Map<String, Object> map, String key, String value) {
if (value != null && !value.isBlank()) {
map.put(key, value);
}
}
private static String firstText(JsonNode node, String... fields) {
for (String f : fields) {
String v = text(node, f);
if (v != null) {
return v;
}
}
return null;
}
private static String text(JsonNode node, String field) {
if (node == null || field == null || !node.has(field) || node.get(field).isNull()) {
return null;
}
String v = node.get(field).asText(null);
return v == null || v.isBlank() ? null : v;
}
private static Integer intVal(JsonNode node, String field) {
if (node == null || !node.has(field) || node.get(field).isNull()) {
return null;
}
JsonNode n = node.get(field);
if (n.isIntegralNumber() || n.canConvertToInt()) {
return n.asInt();
}
return null;
}
private static Integer arraySize(JsonNode node) {
if (node != null && node.isArray()) {
return node.size();
}
return null;
}
private static String truncate(String value, int max) {
if (value == null) {
return null;
}
if (value.length() <= max) {
return value;
}
return value.substring(0, max) + "...";
}
public record Enrichment(
String retrievalLayer,
Integer l0MatchCount,
Integer l1MatchCount,
String relevanceLevel,
Boolean truncated,
String dedupReason,
Map<String, Object> retrievalDetails,
String outputPreview
) {
}
}
@@ -0,0 +1,114 @@
package com.superbiz.agent.harness.audit;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Extracts a short human-readable conclusion string from released safe content JSON
* for {@code diagnosis_run.conclusion}.
*/
public final class RunConclusionExtractor {
private static final int MAX_CHARS = 4_000;
private RunConclusionExtractor() {
}
public static String extract(ObjectMapper objectMapper, String safeContentJson) {
if (safeContentJson == null || safeContentJson.isBlank()) {
return null;
}
try {
JsonNode root = objectMapper.readTree(safeContentJson);
if (root == null || !root.isObject()) {
return bound(safeContentJson.trim());
}
// DIAGNOSIS_REPORT payload shape stored in answer
String fromReport = text(root.path("report").path("conclusion").path("text"));
if (fromReport != null) {
return bound(fromReport);
}
// nested content envelope (defensive)
String nested = text(root.path("payload").path("report").path("conclusion").path("text"));
if (nested != null) {
return bound(nested);
}
// SAFE_FALLBACK envelope: {"fallback":{type,message,...}}
String fromFallback = fallbackConclusion(root.path("fallback"));
if (fromFallback != null) {
return bound(fromFallback);
}
String nestedFb = fallbackConclusion(root.path("payload").path("fallback"));
if (nestedFb != null) {
return bound(nestedFb);
}
// bare SafeFallback object (defensive)
if (root.hasNonNull("type") || root.has("message") || root.has("conclusion")) {
String bare = fallbackConclusion(root);
if (bare != null) {
return bound(bare);
}
}
// knowledge / plain answer shapes
String plain = text(root.path("answer"));
if (plain != null) {
return bound(plain);
}
return null;
} catch (Exception ignored) {
return bound(safeContentJson.trim());
}
}
/**
* Builds a short conclusion from a SafeFallback-shaped node.
* Prefer explicit conclusion text, else "type: message", else either alone.
*/
private static String fallbackConclusion(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull() || !node.isObject()) {
return null;
}
String conclusion = text(node.path("conclusion"));
if (conclusion != null) {
return conclusion;
}
String type = enumOrText(node.path("type"));
String message = text(node.path("message"));
if (type != null && message != null) {
return type + ": " + message;
}
if (message != null) {
return message;
}
return type;
}
private static String enumOrText(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull()) {
return null;
}
if (node.isTextual() || node.isNumber() || node.isBoolean()) {
String value = node.asText();
return value == null || value.isBlank() ? null : value.trim();
}
return null;
}
private static String text(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull()) {
return null;
}
if (!node.isTextual()) {
return null;
}
String value = node.asText();
return value == null || value.isBlank() ? null : value.trim();
}
private static String bound(String value) {
if (value == null) {
return null;
}
return value.length() <= MAX_CHARS ? value : value.substring(0, MAX_CHARS);
}
}
@@ -5,6 +5,17 @@ import com.superbiz.agent.harness.contract.InvocationStatus;
import java.util.Objects;
/**
* Durable tool-invocation audit event.
*
* <p>{@code rawResultJson} is optional internal executor output (e.g. full {@code LookupResult}
* before Agent projection). It is used only to enrich durable RAG fields and must not be
* echoed wholesale into agent-facing views.</p>
*
* <p>{@code stepId} links to {@code agent_step.id} when the in-flight Agent step is known.
* {@code requestJson} is the tool request envelope body used only to derive bounded
* {@code input_params} (e.g. query preview) — never dump secrets wholesale.</p>
*/
public record ToolInvocationAuditEvent(
String sessionId,
String runId,
@@ -15,7 +26,11 @@ public record ToolInvocationAuditEvent(
String errorCode,
int durationMs,
int requestBytes,
int agentResultBytes) {
int agentResultBytes,
String rawResultJson,
String agentResultJson,
Long stepId,
String requestJson) {
public ToolInvocationAuditEvent {
requireText(sessionId, "sessionId");
@@ -35,6 +50,38 @@ public record ToolInvocationAuditEvent(
}
}
/** Backward-compatible constructor without raw/agent JSON / step / request body. */
public ToolInvocationAuditEvent(String sessionId,
String runId,
String toolCallId,
String toolName,
InvocationStatus status,
EvidenceStatus evidenceStatus,
String errorCode,
int durationMs,
int requestBytes,
int agentResultBytes) {
this(sessionId, runId, toolCallId, toolName, status, evidenceStatus, errorCode,
durationMs, requestBytes, agentResultBytes, null, null, null, null);
}
/** Backward-compatible constructor with raw/agent JSON only. */
public ToolInvocationAuditEvent(String sessionId,
String runId,
String toolCallId,
String toolName,
InvocationStatus status,
EvidenceStatus evidenceStatus,
String errorCode,
int durationMs,
int requestBytes,
int agentResultBytes,
String rawResultJson,
String agentResultJson) {
this(sessionId, runId, toolCallId, toolName, status, evidenceStatus, errorCode,
durationMs, requestBytes, agentResultBytes, rawResultJson, agentResultJson, null, null);
}
private static void requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
@@ -129,9 +129,14 @@ public final class TraceAuditEvents {
details.put("evidence_status", tool.evidenceStatus().name());
details.put("request_bytes", tool.requestBytes());
details.put("agent_result_bytes", tool.agentResultBytes());
details.put("has_raw_result", tool.rawResultJson() != null && !tool.rawResultJson().isBlank());
if (tool.stepId() != null) {
details.put("step_id", tool.stepId());
}
if (tool.errorCode() != null) {
details.put("error_code", tool.errorCode());
}
// Do not embed raw LookupResult / agent JSON here — durable RAG fields go to tool_invocation.
return new DiagnosisTraceAuditEvent(
tool.sessionId(), tool.runId(), TracePhase.TOOL, TraceEventType.TOOL_INVOCATION,
tool.status() == com.superbiz.agent.harness.contract.InvocationStatus.READY
@@ -3,9 +3,9 @@ package com.superbiz.agent.harness.tool.boundary;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.audit.AgentStepAuditTracker;
import com.superbiz.agent.harness.audit.ToolInvocationAuditEvent;
import com.superbiz.agent.harness.audit.ToolInvocationAuditSink;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.core.BudgetExceededException;
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
import com.superbiz.agent.harness.core.RunAbortedException;
@@ -19,10 +19,10 @@ import com.superbiz.agent.harness.tool.store.ToolCallKeyFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
public final class ToolBoundary {
@@ -35,13 +35,14 @@ public final class ToolBoundary {
private final ObjectMapper objectMapper;
private final Clock clock;
private final ToolInvocationAuditSink auditSink;
private final AgentStepAuditTracker stepTracker;
public ToolBoundary(DiagnosisHarnessCore core,
ToolCallKeyFactory keyFactory,
CanonicalInvocationStore store,
ObjectMapper objectMapper,
Clock clock) {
this(core, keyFactory, store, objectMapper, clock, ToolInvocationAuditSink.noop());
this(core, keyFactory, store, objectMapper, clock, ToolInvocationAuditSink.noop(), null);
}
public ToolBoundary(DiagnosisHarnessCore core,
@@ -50,12 +51,23 @@ public final class ToolBoundary {
ObjectMapper objectMapper,
Clock clock,
ToolInvocationAuditSink auditSink) {
this(core, keyFactory, store, objectMapper, clock, auditSink, null);
}
public ToolBoundary(DiagnosisHarnessCore core,
ToolCallKeyFactory keyFactory,
CanonicalInvocationStore store,
ObjectMapper objectMapper,
Clock clock,
ToolInvocationAuditSink auditSink,
AgentStepAuditTracker stepTracker) {
this.core = Objects.requireNonNull(core, "core must not be null");
this.keyFactory = Objects.requireNonNull(keyFactory, "keyFactory must not be null");
this.store = Objects.requireNonNull(store, "store must not be null");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
this.clock = Objects.requireNonNull(clock, "clock must not be null");
this.auditSink = Objects.requireNonNull(auditSink, "auditSink must not be null");
this.stepTracker = stepTracker;
}
public ToolBoundaryResult execute(RunContext context,
@@ -63,15 +75,15 @@ public final class ToolBoundary {
ToolExecutor executor,
ToolResultProjector projector) {
Instant startedAt = clock.instant();
ToolBoundaryResult result = executeCanonical(context, request, executor, projector);
auditSafely(context, request, result, startedAt);
return result;
ExecutionOutcome outcome = executeCanonical(context, request, executor, projector);
auditSafely(context, request, outcome.result(), outcome.rawResponse(), startedAt);
return outcome.result();
}
private ToolBoundaryResult executeCanonical(RunContext context,
ToolCallRequestEnvelope request,
ToolExecutor executor,
ToolResultProjector projector) {
private ExecutionOutcome executeCanonical(RunContext context,
ToolCallRequestEnvelope request,
ToolExecutor executor,
ToolResultProjector projector) {
String toolCallId = request == null ? null : request.toolCallId();
String key;
try {
@@ -79,23 +91,23 @@ public final class ToolBoundary {
core.beforeToolCall(context, request.toolName());
long requestBytes = store.limits().utf8Bytes(request.requestJson());
if (requestBytes > store.limits().maxRecordBytes()) {
return errorAndNoRecord(toolCallId, ToolBoundaryErrorCode.RESULT_TOO_LARGE);
return ExecutionOutcome.of(errorAndNoRecord(toolCallId, ToolBoundaryErrorCode.RESULT_TOO_LARGE));
}
core.reserveRunBytes(context, requestBytes);
store.begin(key, CanonicalToolInvocation.projecting(
request.toolCallId(), request.runId(), request.toolName(),
request.requestJson(), clock.instant()));
} catch (DuplicateInvocationException e) {
return ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.DUPLICATE_TOOL_CALL);
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.DUPLICATE_TOOL_CALL));
} catch (RunAbortedException | BudgetExceededException e) {
return ToolBoundaryResult.error(toolCallId,
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId,
e instanceof BudgetExceededException
? ToolBoundaryErrorCode.BUDGET_EXHAUSTED
: ToolBoundaryErrorCode.RUN_INACTIVE);
: ToolBoundaryErrorCode.RUN_INACTIVE));
} catch (IllegalArgumentException e) {
return ToolBoundaryResult.error(toolCallId, classifyPreflightError(e));
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, classifyPreflightError(e)));
} catch (CanonicalStoreException e) {
return ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.STORE_ERROR);
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.STORE_ERROR));
}
String rawResponse;
@@ -107,7 +119,7 @@ public final class ToolBoundary {
}
} catch (Exception e) {
markErrorSafely(key, null, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR);
return ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR);
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR));
}
try {
@@ -115,13 +127,16 @@ public final class ToolBoundary {
core.reserveRunBytes(context, store.limits().utf8Bytes(rawResponse));
} catch (ResultTooLargeException e) {
markErrorSafely(key, null, ToolBoundaryErrorCode.RESULT_TOO_LARGE);
return ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RESULT_TOO_LARGE);
return new ExecutionOutcome(
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RESULT_TOO_LARGE), rawResponse);
} catch (BudgetExceededException e) {
markErrorSafely(key, null, ToolBoundaryErrorCode.BUDGET_EXHAUSTED);
return ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.BUDGET_EXHAUSTED);
return new ExecutionOutcome(
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.BUDGET_EXHAUSTED), rawResponse);
} catch (RunAbortedException e) {
markErrorSafely(key, null, ToolBoundaryErrorCode.RUN_INACTIVE);
return ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE);
return new ExecutionOutcome(
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
}
ProjectedToolResult projected;
@@ -133,29 +148,35 @@ public final class ToolBoundary {
}
} catch (Exception e) {
markErrorSafely(key, rawResponse, ToolBoundaryErrorCode.PROJECTION_ERROR);
return ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.PROJECTION_ERROR);
return new ExecutionOutcome(
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.PROJECTION_ERROR), rawResponse);
}
try {
store.limits().validateAgentResult(projected.agentResult());
core.reserveRunBytes(context, store.limits().utf8Bytes(projected.agentResult()));
store.markReady(key, rawResponse, projected.agentResult(), projected.evidenceStatus(), clock.instant());
return ToolBoundaryResult.ready(toolCallId, projected.agentResult(), projected.evidenceStatus());
return new ExecutionOutcome(
ToolBoundaryResult.ready(toolCallId, projected.agentResult(), projected.evidenceStatus()),
rawResponse);
} catch (ResultTooLargeException e) {
markErrorSafely(key, rawResponse, ToolBoundaryErrorCode.RESULT_TOO_LARGE);
return ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RESULT_TOO_LARGE);
return new ExecutionOutcome(
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RESULT_TOO_LARGE), rawResponse);
} catch (BudgetExceededException e) {
markErrorSafely(key, rawResponse, ToolBoundaryErrorCode.BUDGET_EXHAUSTED);
return ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.BUDGET_EXHAUSTED);
return new ExecutionOutcome(
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.BUDGET_EXHAUSTED), rawResponse);
} catch (RunAbortedException e) {
markErrorSafely(key, rawResponse, ToolBoundaryErrorCode.RUN_INACTIVE);
return ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE);
return new ExecutionOutcome(
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
} catch (CanonicalStoreException e) {
ToolBoundaryErrorCode code = e instanceof ResultTooLargeException
? ToolBoundaryErrorCode.RESULT_TOO_LARGE
: ToolBoundaryErrorCode.PROJECTION_ERROR;
markErrorSafely(key, rawResponse, code);
return ToolBoundaryResult.error(toolCallId, code);
return new ExecutionOutcome(ToolBoundaryResult.error(toolCallId, code), rawResponse);
}
}
@@ -222,6 +243,7 @@ public final class ToolBoundary {
private void auditSafely(RunContext context,
ToolCallRequestEnvelope request,
ToolBoundaryResult result,
String rawResponse,
Instant startedAt) {
if (context == null || request == null || result == null
|| !context.runId().equals(request.runId())
@@ -230,11 +252,16 @@ public final class ToolBoundary {
return;
}
try {
Long stepId = stepTracker == null ? null : stepTracker.currentStepId(context.runId());
auditSink.record(new ToolInvocationAuditEvent(
context.sessionId(), context.runId(), request.toolCallId(), request.toolName(),
result.status(), result.evidenceStatus(), result.errorCode(),
saturatingInt(Math.max(0L, Duration.between(startedAt, clock.instant()).toMillis())),
utf8Bytes(request.requestJson()), utf8Bytes(result.agentResult())));
utf8Bytes(request.requestJson()), utf8Bytes(result.agentResult()),
rawResponse,
result.agentResult(),
stepId,
request.requestJson()));
} catch (RuntimeException exception) {
log.warn("Failed to persist Tool durable audit: tool={}, status={}",
request.toolName(), result.status());
@@ -249,6 +276,12 @@ public final class ToolBoundary {
return value >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) value;
}
private record ExecutionOutcome(ToolBoundaryResult result, String rawResponse) {
static ExecutionOutcome of(ToolBoundaryResult result) {
return new ExecutionOutcome(result, null);
}
}
private static final class InvalidToolCallIdException extends IllegalArgumentException {
private InvalidToolCallIdException(Throwable cause) {
super("Invalid tool call ID", cause);
@@ -184,6 +184,7 @@ public class DiagnosisTraceService {
.runId(run.getRunId())
.sessionId(run.getSessionId())
.query(run.getQuery())
.conclusion(run.getConclusion())
.status(run.getStatus())
.agentFlow(run.getAgentFlow())
.intent(run.getIntent() == null ? null : run.getIntent().name())
@@ -239,6 +240,8 @@ public class DiagnosisTraceService {
.agentName(audit.getAgentName())
.reasoningAvailable(audit.getReasoningAvailable())
.reasoningContent(audit.getReasoningContent())
.assistantText(audit.getAssistantText())
.contentSource(audit.getContentSource())
.contentBytes(audit.getContentBytes())
.createdAt(audit.getCreatedAt())
.build();
@@ -86,6 +86,7 @@ public class KnowledgeDocumentRetriever {
.rawScore(hit.rawScore())
.scoreLabel(hit.scoreLabel())
.originalRank(hit.originalRank())
.denseDistance(hit.denseDistance())
.metadata(hit.metadata() == null ? java.util.Map.of() : hit.metadata())
.hitReasons(List.of("semantic_rank:" + hit.originalRank(), "attempt:" + attemptName))
.build());
@@ -6,6 +6,7 @@ import com.superbiz.agent.dto.KnowledgeQuery;
import com.superbiz.agent.dto.RerankTrace;
import com.superbiz.agent.dto.RetrievedEvidenceCandidate;
import com.superbiz.agent.service.retrieval.EvidenceIdentity;
import com.superbiz.agent.service.retrieval.RetrievalScoreNormalizer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -20,28 +21,25 @@ import java.util.Map;
import java.util.Set;
/**
* 检索后处理:分数归一化、规则 rerank、chunk 级证据组装、相关等级判定。
* 检索后处理:统一 qualityScore、保检索序、chunk 级装配、相关等级判定。
*
* <h3>处理步骤</h3>
* <ol>
* <li>把候选 L2 距离归一成 0~1 的 baseScore</li>
* <li>用 L0 hint 做规则加分,得到 finalScore</li>
* <li>按 finalScore 降序排序</li>
* <li>按 evidenceKey 去重</li>
* <li>按 maxChunksPerDocument 截断同文档 chunk</li>
* <li>按 return-n 截断最终 evidence 条数</li>
* <li>根据 top baseScore + hint 支撑计算 relevanceLevel</li>
* <li>{@link RetrievalScoreNormalizer#toQualityScore} → qualityScore(唯一 label 分支)</li>
* <li>按 {@code originalRank} 升序(检索权威序;不做关键词 boost 改序)</li>
* <li>evidenceKey 去重 / maxChunksPerDocument / return-n</li>
* <li>用 top qualityScore 定 relevanceLevel / 低质闸门字段</li>
* </ol>
*
* <p>L0 domain/keyword 命中仅写入解释性 {@code hitReasons},不改变分数与主序。</p>
*/
@Service
public class KnowledgeEvidencePostProcessor {
private static final String LEVEL_PRECISE = "PRECISE";
private static final String LEVEL_HIGHLY_RELEVANT = "HIGHLY_RELEVANT";
private static final String LEVEL_REFERENCE = "REFERENCE";
private static final String HINT_PRECISE = "知识库中不存在比上述结果更精准的文档";
private static final String HINT_HIGHLY_RELEVANT = "当前结果已高度相关,继续检索不太可能找到更精准的文档";
private static final String HINT_REFERENCE = "当前结果为相关参考,如需更精准信息请明确缺少的具体维度";
@Value("${retrieval.normalization.max-l2-distance:2.0}")
@@ -53,22 +51,21 @@ public class KnowledgeEvidencePostProcessor {
@Value("${retrieval.normalization.reference-threshold:0.5}")
private double referenceThreshold = 0.5;
/** 同一 docId 最多保留的 chunk 数。 */
@Value("${rag.max-chunks-per-document:2}")
private int maxChunksPerDocument = 2;
/**
* 后处理后最多返回的 evidence 条数。
* 0 或负数表示不在此层截断(仍可能被 projector 预算截断)。
*/
@Value("${rag.return-n:5}")
private int returnN = 5;
public EvidencePostprocessResult process(KnowledgeQuery query, List<RetrievedEvidenceCandidate> candidates) {
List<RetrievedEvidenceCandidate> safeCandidates = candidates == null ? List.of() : candidates;
int batchSize = safeCandidates.size();
List<ScoredCandidate> ranked = safeCandidates.stream()
.map(candidate -> score(query, candidate))
.sorted(Comparator.comparingDouble(ScoredCandidate::finalScore).reversed())
.map(candidate -> score(query, candidate, batchSize))
.sorted(Comparator
.comparingInt((ScoredCandidate s) -> rankOrMax(s.candidate().getOriginalRank()))
.thenComparing(s -> resolveEvidenceKey(s.candidate()), Comparator.nullsLast(String::compareTo)))
.toList();
Map<String, EvidenceBlock> deduped = new LinkedHashMap<>();
@@ -104,15 +101,15 @@ public class KnowledgeEvidencePostProcessor {
traceItems.add(RerankTrace.Item.builder()
.finalRank(finalRank++)
.source(candidate.getSource())
.baseScore(scored.baseScore())
.finalScore(scored.finalScore())
.boostReasons(scored.boostReasons())
.baseScore(scored.qualityScore())
.finalScore(scored.qualityScore())
.boostReasons(scored.explainReasons())
.build());
}
List<EvidenceBlock> blocks = new ArrayList<>(deduped.values());
Double topSimilarity = ranked.isEmpty() ? null : ranked.get(0).baseScore();
RelevanceAssessment assessment = computeRelevance(query, ranked);
Double topSimilarity = ranked.isEmpty() ? null : ranked.get(0).qualityScore();
RelevanceAssessment assessment = computeRelevance(ranked);
return EvidencePostprocessResult.builder()
.candidateCount(safeCandidates.size())
.evidenceBlockCount(blocks.size())
@@ -132,12 +129,11 @@ public class KnowledgeEvidencePostProcessor {
return topSimilarity == null || topSimilarity < referenceThreshold;
}
/**
* @deprecated 保留给旧测试/调用;新路径请用 {@link RetrievalScoreNormalizer#l2ToQuality}。
*/
public double normalizeL2(Double l2Score) {
if (l2Score == null) {
return 0.0;
}
double clamped = Math.min(l2Score, maxL2Distance);
return Math.max(0.0, 1.0 - clamped / maxL2Distance);
return RetrievalScoreNormalizer.l2ToQuality(l2Score, maxL2Distance);
}
public double getReferenceThreshold() {
@@ -157,7 +153,7 @@ public class KnowledgeEvidencePostProcessor {
.retrievalLayer(candidate.getRetrievalLayer())
.content(truncate(candidate.getContent(), 800))
.score(candidate.getScore())
.hitReasons(mergeReasons(candidate.getHitReasons(), scored.boostReasons()))
.hitReasons(mergeReasons(candidate.getHitReasons(), scored.explainReasons()))
.build();
}
@@ -177,33 +173,29 @@ public class KnowledgeEvidencePostProcessor {
if (docId != null) {
return docId;
}
// No docId: do not collapse unrelated fallback keys under one bucket.
return evidenceKey;
}
private ScoredCandidate score(KnowledgeQuery query, RetrievedEvidenceCandidate candidate) {
double baseScore = normalizeL2(candidate.getScore());
double finalScore = baseScore;
List<String> boosts = new ArrayList<>();
private ScoredCandidate score(KnowledgeQuery query, RetrievedEvidenceCandidate candidate, int batchSize) {
double quality = RetrievalScoreNormalizer.toQualityScore(
candidate.getScoreLabel(),
candidate.getScore(),
candidate.getOriginalRank(),
batchSize,
maxL2Distance,
candidate.getDenseDistance());
List<String> explain = new ArrayList<>();
// L0 重叠仅解释,不改变 quality / 排序
if (matchesAny(candidate, query.getDomainHints())) {
finalScore += 0.15;
boosts.add("domain_match:+0.15");
explain.add("l0_domain_overlap");
}
if (matchesAny(candidate, query.getEntities())) {
finalScore += 0.20;
boosts.add("entity_match:+0.20");
explain.add("l0_entity_overlap");
}
if (matchesAny(candidate, query.getMatchedKeywords())) {
finalScore += 0.10;
boosts.add("keyword_match:+0.10");
explain.add("l0_keyword_overlap");
}
if (isPreferredSourceType(candidate)) {
finalScore += 0.05;
boosts.add("source_type:+0.05");
}
return new ScoredCandidate(candidate, baseScore, finalScore, boosts);
return new ScoredCandidate(candidate, quality, explain);
}
private boolean matchesAny(RetrievedEvidenceCandidate candidate, List<String> hints) {
@@ -225,42 +217,21 @@ public class KnowledgeEvidencePostProcessor {
return false;
}
private boolean isPreferredSourceType(RetrievedEvidenceCandidate candidate) {
Map<String, String> metadata = candidate.getMetadata();
if (metadata == null || metadata.isEmpty()) {
return false;
}
String type = firstNonBlank(metadata.get("source_type"), metadata.get("documentType"), metadata.get("type"));
if (type == null) {
return false;
}
String normalized = type.toLowerCase(Locale.ROOT);
return normalized.contains("runbook") || normalized.contains("guide") || normalized.contains("case");
}
private RelevanceAssessment computeRelevance(KnowledgeQuery query, List<ScoredCandidate> ranked) {
private RelevanceAssessment computeRelevance(List<ScoredCandidate> ranked) {
if (ranked.isEmpty()) {
return new RelevanceAssessment(null, null);
}
ScoredCandidate top = ranked.get(0);
if (top.baseScore() >= highlyRelevantThreshold && hasHintSupport(query, top)) {
double top = ranked.get(0).qualityScore();
if (top >= highlyRelevantThreshold) {
// 不再要求 hasHintSupport;rank/L2 quality 足够即高相关/精准
return new RelevanceAssessment(LEVEL_PRECISE, HINT_PRECISE);
}
if (top.baseScore() >= highlyRelevantThreshold) {
return new RelevanceAssessment(LEVEL_HIGHLY_RELEVANT, HINT_HIGHLY_RELEVANT);
}
if (top.baseScore() >= referenceThreshold) {
if (top >= referenceThreshold) {
return new RelevanceAssessment(LEVEL_REFERENCE, HINT_REFERENCE);
}
return new RelevanceAssessment(null, null);
}
private boolean hasHintSupport(KnowledgeQuery query, ScoredCandidate top) {
return matchesAny(top.candidate(), query.getDomainHints())
|| matchesAny(top.candidate(), query.getEntities())
|| matchesAny(top.candidate(), query.getMatchedKeywords());
}
private void mergeEvidence(EvidenceBlock existing, EvidenceBlock incoming) {
Set<String> reasons = new LinkedHashSet<>();
if (existing.getHitReasons() != null) {
@@ -276,13 +247,13 @@ public class KnowledgeEvidencePostProcessor {
}
}
private List<String> mergeReasons(List<String> base, List<String> boosts) {
private List<String> mergeReasons(List<String> base, List<String> extra) {
Set<String> merged = new LinkedHashSet<>();
if (base != null) {
merged.addAll(base);
}
if (boosts != null) {
merged.addAll(boosts);
if (extra != null) {
merged.addAll(extra);
}
return new ArrayList<>(merged);
}
@@ -294,8 +265,8 @@ public class KnowledgeEvidencePostProcessor {
return text.substring(0, maxLength) + "...";
}
private String firstNonBlank(String... values) {
return EvidenceIdentity.firstNonBlank(values);
private static int rankOrMax(Integer rank) {
return rank == null || rank < 1 ? Integer.MAX_VALUE : rank;
}
private String nullToEmpty(String value) {
@@ -303,9 +274,8 @@ public class KnowledgeEvidencePostProcessor {
}
private record ScoredCandidate(RetrievedEvidenceCandidate candidate,
double baseScore,
double finalScore,
List<String> boostReasons) {
double qualityScore,
List<String> explainReasons) {
}
private record RelevanceAssessment(String level, String hint) {
@@ -23,8 +23,14 @@ import java.util.Map;
/**
* 向量索引写入服务(RAG 入库侧)。
*
* <p>写入单一后端 {@link MilvusHybridKnowledgeStore}(dense + BM25 search_text)。
* 不再使用 legacy {@code MilvusServiceClient} insert/delete。</p>
* <p>唯一后端 {@link MilvusHybridKnowledgeStore}(Milvus SDK v2):</p>
* <ul>
* <li>dense:应用侧 embedding → 字段 {@code vector}</li>
* <li>BM25:{@link #buildSearchText} → 字段 {@code search_text};
* sparse 由 collection 上 BM25 Function 自动生成,本类不写 sparse</li>
* </ul>
* <p>不再使用 legacy {@code MilvusServiceClient} insert/delete,
* 也不走 Spring AI {@code VectorStore#add}(starter 无 hybrid schema/BM25 Function)。</p>
*/
@Service
public class VectorIndexService {
@@ -118,12 +124,13 @@ public class VectorIndexService {
for (int i = 0; i < chunks.size(); i++) {
DocumentChunk chunk = chunks.get(i);
try {
// dense embedding 与 BM25 search_text 同源(title/path 增强)
List<Float> vector = embeddingService.generateEmbedding(buildEmbeddingText(chunk));
Map<String, Object> metadata = buildMetadata(path.toString(), chunk, chunks.size());
knowledgeStore.upsertChunk(
chunk.getContent(),
buildSearchText(chunk),
vector,
chunk.getContent(), // 返回原文
buildSearchText(chunk), // BM25 语料;sparse 由 Milvus Function 生成
vector, // dense 向量
metadata,
chunk.getChunkIndex());
logger.info("分片 {}/{} 索引成功", i + 1, chunks.size());
@@ -153,12 +160,13 @@ public class VectorIndexService {
for (int i = 0; i < chunks.size(); i++) {
DocumentChunk chunk = chunks.get(i);
try {
// dense embedding 与 BM25 search_text 同源(title/path 增强)
List<Float> vector = embeddingService.generateEmbedding(buildEmbeddingText(chunk));
Map<String, Object> metadata = buildDocumentMetadata(docId, chunk, chunks.size(), category, frontmatter);
knowledgeStore.upsertChunk(
chunk.getContent(),
buildSearchText(chunk),
vector,
chunk.getContent(), // 返回原文
buildSearchText(chunk), // BM25 语料;sparse 由 Milvus Function 生成
vector, // dense 向量
metadata,
chunk.getChunkIndex());
logger.info("文档分块 {}/{} 索引成功,docId: {}", i + 1, chunks.size(), docId);
@@ -212,12 +220,18 @@ public class VectorIndexService {
return metadata;
}
/**
* Dense embedding 输入。与 {@link #buildSearchText} 同源,保证 dense/BM25 看到同一增强文本。
*/
static String buildEmbeddingText(DocumentChunk chunk) {
return buildSearchText(chunk);
}
/**
* Text used for BM25 {@code search_text} and dense embedding.
* 构造写入 Milvus 的检索文本(BM25 {@code search_text},并复用为 dense embedding 输入)。
*
* <p>在正文前拼接 title / breadcrumb,提高「按标题或路径关键词」的 BM25 命中率,
* 同时让 dense 向量也编码结构信息。无标题路径时退回纯 content。</p>
*/
static String buildSearchText(DocumentChunk chunk) {
String content = trimToEmpty(chunk.getContent());
@@ -1,6 +1,7 @@
package com.superbiz.agent.service;
import com.superbiz.agent.service.milvus.MilvusHybridKnowledgeStore;
import com.superbiz.agent.service.retrieval.RetrievalScoreLabels;
import lombok.Getter;
import lombok.Setter;
import org.slf4j.Logger;
@@ -13,11 +14,18 @@ import java.util.List;
import java.util.Locale;
/**
* Knowledge vector retrieval facade.
* 知识库向量检索门面(lookup_knowledge / RAG 召回入口)。
*
* <p><b>Single backend:</b> {@link MilvusHybridKnowledgeStore} (Milvus Java SDK v2).
* Legacy {@code MilvusServiceClient} search and Spring AI VectorStore routing for
* {@code lookup_knowledge} have been removed.</p>
* <p><b>唯一后端:</b>{@link MilvusHybridKnowledgeStore}(Milvus Java SDK v2)。</p>
*
* <h3>模式切换</h3>
* <p>{@code retrieval.search.mode}(同库查询算法,非两套写入):</p>
* <ul>
* <li>{@code hybrid} —— 线上主路径:dense + 服务端 BM25 + RRF</li>
* <li>{@code dense} —— 对照/评测:仅 dense ANN</li>
* </ul>
* <p>命中 {@link SearchResult#scoreLabel} 仅为 {@link RetrievalScoreLabels#DENSE} /
* {@link RetrievalScoreLabels#HYBRID}。质量分由后处理 {@code RetrievalScoreNormalizer} 统一计算。</p>
*/
@Service
public class VectorSearchService {
@@ -31,7 +39,7 @@ public class VectorSearchService {
private VectorEmbeddingService embeddingService;
/**
* dense | hybrid
* 检索模式:{@code hybrid}(主路径)| {@code dense}(召回对照)。
*/
@Value("${retrieval.search.mode:dense}")
private String searchMode = "dense";
@@ -53,18 +61,34 @@ public class VectorSearchService {
return knowledgeStore.searchDense(query, queryVector, topK, category);
}
/**
* 单条召回结果。列表顺序即检索权威序(adapter 赋 originalRank=1..n)。
*
* <ul>
* <li>{@code scoreLabel=dense}:{@link #score} = L2 距离(越小越好)</li>
* <li>{@code scoreLabel=hybrid}:{@link #score}/{@link #rawScore} = 引擎融合分;
* 后处理 quality 主要按 rank 映射,不把 score 当 L2</li>
* </ul>
*/
@Setter
@Getter
public static class SearchResult {
private String id;
private String content;
/**
* Compatibility score for post-process normalizeL2.
* Dense path: L2 distance. Hybrid path: dense L2 when available.
* 引擎主分:dense=L2;hybrid=融合分(量纲由 scoreLabel 解释)。
*/
private float score;
/** 引擎原始分(与 score 同源或更细,便于调试)。 */
private Double rawScore;
/** {@link RetrievalScoreLabels#DENSE} 或 {@link RetrievalScoreLabels#HYBRID}。 */
private String scoreLabel;
/**
* Optional dense L2 for the same id (hybrid path only).
* Used for absolute quality / low-quality gates; does <b>not</b> replace sort order.
*/
private Double denseDistance;
/** metadata JSON 字符串(docId、source、title…)。 */
private String metadata;
}
}
@@ -5,6 +5,7 @@ import com.google.gson.JsonObject;
import com.superbiz.agent.config.MilvusProperties;
import com.superbiz.agent.constant.MilvusConstants;
import com.superbiz.agent.service.VectorSearchService;
import com.superbiz.agent.service.retrieval.RetrievalScoreLabels;
import io.milvus.common.clientenum.FunctionType;
import io.milvus.v2.client.ConnectConfig;
import io.milvus.v2.client.MilvusClientV2;
@@ -41,10 +42,35 @@ import java.util.Map;
import java.util.UUID;
/**
* Single knowledge vector backend (Milvus Java SDK v2).
* 知识库向量后端(Milvus Java SDK v2)—— dense + BM25 混合检索的唯一实现。
*
* <p>Supports dense ANN and dense+BM25 hybrid search via {@code hybridSearch} + {@link RRFRanker}.
* Legacy {@code MilvusServiceClient} search is not used.</p>
* <h3>为什么不用 Spring AI {@code spring-ai-starter-vector-store-milvus}</h3>
* <ul>
* <li>Spring AI Milvus starter(截至 2.0.0 / 1.1.8)只封装 dense {@code similaritySearch}。</li>
* <li>底层仍是 V1 {@code MilvusServiceClient} + 单路 {@code SearchParam},无 {@code hybridSearch} /
* BM25 Function / {@link RRFRanker}。</li>
* <li>真混合检索(dense ANN + 服务端 BM25 sparse,再 RRF 融合)必须走 Milvus SDK v2,
* 见 {@link #searchHybrid}。</li>
* </ul>
*
* <h3>Collection schema(默认名 {@code biz})</h3>
* <pre>
* id VarChar PK
* content VarChar —— 原文,返回给上层
* search_text VarChar+analyzer —— BM25 输入文本(可含 title/path 增强)
* sparse_vector SparseFloatVector —— 由 BM25 Function 从 search_text 自动生成,写入时不必填
* vector FloatVector —— dense 向量(应用侧 embedding)
* metadata JSON —— docId / source / category / kb_scope 等
* </pre>
*
* <h3>检索模式</h3>
* <ul>
* <li>{@link #searchDense}:单路 L2 ANN;{@code scoreLabel=dense}。</li>
* <li>{@link #searchHybrid}:dense + BM25 + 服务端 {@link RRFRanker};{@code scoreLabel=hybrid};
* 返回序即 RRF 序,不再用 dense L2 覆盖主分。</li>
* </ul>
*
* <p>配置入口:{@code milvus.collection}、{@code retrieval.search.mode}、{@code retrieval.hybrid.rrf-k}。</p>
*/
@Service
public class MilvusHybridKnowledgeStore {
@@ -52,11 +78,20 @@ public class MilvusHybridKnowledgeStore {
private static final Logger log = LoggerFactory.getLogger(MilvusHybridKnowledgeStore.class);
private static final Gson GSON = new Gson();
/** 主键(稳定 UUID,由 source + chunkIndex 派生,便于幂等重写)。 */
public static final String FIELD_ID = "id";
/** 返回给 LLM / 上层的原文 chunk。 */
public static final String FIELD_CONTENT = "content";
/**
* BM25 输入字段。写入明文;Milvus 侧 analyzer + BM25 Function 生成 {@link #FIELD_SPARSE}。
* 通常比 content 多带 title/path 等检索增强词。
*/
public static final String FIELD_SEARCH_TEXT = "search_text";
/** 稀疏向量字段;由 BM25 Function 自动产出,insert 时不要手动填。 */
public static final String FIELD_SPARSE = "sparse_vector";
/** Dense 向量字段(应用侧 EmbeddingModel 生成)。 */
public static final String FIELD_DENSE = "vector";
/** 业务元数据 JSON(过滤、证据身份、展示用)。 */
public static final String FIELD_METADATA = "metadata";
private final MilvusProperties milvusProperties;
@@ -64,12 +99,14 @@ public class MilvusHybridKnowledgeStore {
@Value("${milvus.collection:biz}")
private String collectionName = "biz";
/**
* RRF 平滑参数 k:score(d) = Σ 1/(k + rank_i(d))。
* k 越大,各路排名差异被压得越平;默认 60 与常见 RRF 设定一致。
*/
@Value("${retrieval.hybrid.rrf-k:60}")
private int rrfK = 60;
@Value("${retrieval.normalization.max-l2-distance:2.0}")
private double maxL2Distance = 2.0;
/** 非空时追加 {@code metadata.kb_scope} 过滤,实现多知识域隔离。 */
@Value("${retrieval.kb-scope:}")
private String kbScope = "";
@@ -79,6 +116,10 @@ public class MilvusHybridKnowledgeStore {
this.milvusProperties = milvusProperties;
}
/**
* 懒连接:首次调用时建连、确保 collection schema 存在并 load。
* 线程安全;后续检索/写入复用同一 {@link MilvusClientV2}。
*/
public synchronized MilvusClientV2 client() {
if (client == null) {
client = connect();
@@ -92,6 +133,21 @@ public class MilvusHybridKnowledgeStore {
return collectionName;
}
/**
* 写入单个 chunk(dense + BM25 所需明文)。
*
* <p>只插入 {@code content / search_text / vector / metadata};
* {@code sparse_vector} 由 collection 上的 BM25 Function 在服务端从 {@code search_text} 生成。</p>
*
* <p>id 由 {@code source|docId + chunkIndex} 的 nameUUID 派生,同一 chunk 重复写入会得到相同 id
*(配合先 delete 再 insert 的上层逻辑实现覆盖)。</p>
*
* @param content 原文(返回字段)
* @param searchText BM25 / 可与 dense embedding 同源的检索文本
* @param denseVector 应用侧 embedding
* @param metadata 须尽量带 {@code _source} 或 {@code docId},供 id 与过滤使用
* @param chunkIndex 分片序号
*/
public void upsertChunk(String content,
String searchText,
List<Float> denseVector,
@@ -110,6 +166,7 @@ public class MilvusHybridKnowledgeStore {
JsonObject row = new JsonObject();
row.addProperty(FIELD_ID, id);
row.addProperty(FIELD_CONTENT, content == null ? "" : content);
// 仅写明文;sparse 由 BM25 Function(search_text -> sparse_vector) 自动生成
row.addProperty(FIELD_SEARCH_TEXT, searchText == null ? "" : searchText);
row.add(FIELD_DENSE, GSON.toJsonTree(denseVector));
row.add(FIELD_METADATA, GSON.toJsonTree(metadata == null ? Map.of() : metadata));
@@ -120,6 +177,7 @@ public class MilvusHybridKnowledgeStore {
.build());
}
/** 按 metadata.docId 删除该文档全部 chunk(重建/覆盖前调用)。 */
public void deleteByDocId(String docId) {
if (docId == null || docId.isBlank()) {
return;
@@ -131,6 +189,7 @@ public class MilvusHybridKnowledgeStore {
.build());
}
/** 按 metadata._source(规范化路径)删除,用于按文件路径重索引。 */
public void deleteBySource(String sourcePath) {
if (sourcePath == null || sourcePath.isBlank()) {
return;
@@ -144,8 +203,8 @@ public class MilvusHybridKnowledgeStore {
}
/**
* Drop the configured knowledge collection (if present) and recreate empty dense+BM25 schema.
* Used by knowledge rebuild scripts. Existing vectors in this collection are destroyed.
* 删除并重建当前知识 collection(空的 dense+BM25 schema)。
* 供 {@code /api/knowledge/rebuild-hybrid} 与重建脚本使用;会销毁该 collection 全部向量。
*/
public synchronized Map<String, Object> dropAndRecreateCollection() {
Map<String, Object> result = new LinkedHashMap<>();
@@ -178,6 +237,10 @@ public class MilvusHybridKnowledgeStore {
return result;
}
/**
* 单路 dense ANN(L2)。
* {@code score} = L2 距离(越小越好);{@code scoreLabel} = {@link RetrievalScoreLabels#DENSE}。
*/
public List<VectorSearchService.SearchResult> searchDense(String queryEmbeddingText,
List<Float> queryVector,
int topK,
@@ -194,12 +257,22 @@ public class MilvusHybridKnowledgeStore {
builder.filter(filter);
}
SearchResp resp = client().search(builder.build());
return toSearchResults(resp, "l2_distance", false);
return toSearchResults(resp, RetrievalScoreLabels.DENSE);
}
/**
* Dense + BM25 hybrid fused by RRF. Dense L2 scores are attached when the same id
* appears in a parallel dense search so quality thresholds stay meaningful.
* Dense + BM25 真混合检索(Milvus 服务端融合)。
*
* <ol>
* <li>dense 子路:{@code vector},L2</li>
* <li>BM25 子路:{@code sparse_vector} + {@link EmbeddedText}</li>
* <li>{@link HybridSearchReq} + {@link RRFRanker} → 返回序即权威序</li>
* </ol>
*
* <p>{@code scoreLabel=hybrid};{@code score}/{@code rawScore} 保留引擎融合分,
* <b>不</b>用 dense L2 覆盖主分或改 label。可选并行 dense 探测仅填充
* {@link VectorSearchService.SearchResult#setDenseDistance},供后处理绝对质量闸门
* (如 L0 filter low-quality → unfiltered retry),排序仍以 RRF 返回序为准。</p>
*/
public List<VectorSearchService.SearchResult> searchHybrid(String queryText,
List<Float> queryVector,
@@ -236,37 +309,45 @@ public class MilvusHybridKnowledgeStore {
.build();
SearchResp hybridResp = client().hybridSearch(hybridReq);
List<VectorSearchService.SearchResult> fused = toSearchResults(hybridResp, "rrf_fused", true);
// Attach dense-compatible L2 when available.
Map<String, Float> denseScores = new HashMap<>();
try {
for (VectorSearchService.SearchResult denseHit :
searchDense(queryText, queryVector, pathTopK, category)) {
if (denseHit.getId() != null) {
denseScores.put(denseHit.getId(), denseHit.getScore());
}
}
} catch (Exception e) {
log.warn("Dense score enrichment failed: {}", e.getMessage());
}
for (VectorSearchService.SearchResult hit : fused) {
Float dense = denseScores.get(hit.getId());
if (dense != null) {
hit.setScore(dense);
hit.setScoreLabel("l2_distance");
} else {
// BM25-only hit: treat as weak for legacy thresholds
hit.setScore((float) maxL2Distance);
hit.setScoreLabel("bm25_only_no_dense");
}
}
List<VectorSearchService.SearchResult> fused = toSearchResults(hybridResp, RetrievalScoreLabels.HYBRID);
attachDenseDistances(fused, queryText, queryVector, pathTopK, category);
return fused;
}
private List<VectorSearchService.SearchResult> toSearchResults(SearchResp resp,
String scoreLabel,
boolean fused) {
/**
* Attach dense L2 by id for quality gates only — never overwrites hybrid score/label/order.
*/
private void attachDenseDistances(List<VectorSearchService.SearchResult> fused,
String queryText,
List<Float> queryVector,
int pathTopK,
String category) {
if (fused == null || fused.isEmpty()) {
return;
}
try {
Map<String, Float> denseById = new HashMap<>();
for (VectorSearchService.SearchResult denseHit :
searchDense(queryText, queryVector, pathTopK, category)) {
if (denseHit.getId() != null) {
denseById.put(denseHit.getId(), denseHit.getScore());
}
}
for (VectorSearchService.SearchResult hit : fused) {
Float l2 = denseById.get(hit.getId());
if (l2 != null) {
hit.setDenseDistance(l2.doubleValue());
}
}
} catch (Exception e) {
log.warn("Dense distance attach for hybrid quality gate failed: {}", e.getMessage());
}
}
/**
* 将 Milvus {@link SearchResp} 映射为上层结果;列表顺序即检索权威序(adapter 赋 originalRank)。
*/
private List<VectorSearchService.SearchResult> toSearchResults(SearchResp resp, String scoreLabel) {
List<VectorSearchService.SearchResult> out = new ArrayList<>();
if (resp == null || resp.getSearchResults() == null || resp.getSearchResults().isEmpty()) {
return out;
@@ -293,27 +374,16 @@ public class MilvusHybridKnowledgeStore {
Float score = row.getScore();
mapped.setRawScore(score == null ? null : score.doubleValue());
mapped.setScoreLabel(scoreLabel);
if (fused) {
// temporary; may be overwritten with dense L2
mapped.setScore(score == null ? (float) maxL2Distance : invertUnknownScore(score));
} else {
mapped.setScore(score == null ? (float) maxL2Distance : score);
}
// dense: L2;hybrid: 引擎融合分(后处理 quality 主要看 rank,不依赖此量纲)
mapped.setScore(score == null ? 0f : score);
out.add(mapped);
}
return out;
}
private float invertUnknownScore(float score) {
// RRF-like small scores: map higher better -> small L2-like distance
double bounded = Math.max(0.0, Math.min(1.0, score));
if (score > 1.0f) {
// already distance-like
return score;
}
return (float) ((1.0 - bounded) * maxL2Distance);
}
/**
* 组装标量过滤表达式:category、kb_scope(配置级)可叠加,用 {@code &&} 连接。
*/
private String buildFilter(String category) {
List<String> parts = new ArrayList<>();
String categoryFilter = trimToNull(category);
@@ -352,6 +422,17 @@ public class MilvusHybridKnowledgeStore {
return new MilvusClientV2(builder.build());
}
/**
* 若不存在则创建 dense+BM25 hybrid collection。
*
* <p>关键点:</p>
* <ul>
* <li>{@code search_text} 开启 analyzer,作为 BM25 语料。</li>
* <li>{@link FunctionType#BM25}:input={@code search_text} → output={@code sparse_vector}。</li>
* <li>dense:IVF_FLAT + L2;sparse:SPARSE_INVERTED_INDEX + BM25。</li>
* </ul>
* <p>已存在的 collection 不会改 schema;schema 变更需走 {@link #dropAndRecreateCollection()}。</p>
*/
private void ensureCollection(MilvusClientV2 milvusClient) {
Boolean exists = milvusClient.hasCollection(HasCollectionReq.builder()
.collectionName(collectionName)
@@ -376,6 +457,7 @@ public class MilvusHybridKnowledgeStore {
.dataType(DataType.VarChar)
.maxLength(MilvusConstants.CONTENT_MAX_LENGTH)
.build());
// BM25 语料字段:必须 enableAnalyzer,Function 才能从文本生成 sparse
schema.addField(AddFieldReq.builder()
.fieldName(FIELD_SEARCH_TEXT)
.dataType(DataType.VarChar)
@@ -395,6 +477,7 @@ public class MilvusHybridKnowledgeStore {
.fieldName(FIELD_METADATA)
.dataType(DataType.JSON)
.build());
// 写入 search_text 时,Milvus 自动维护 sparse_vector(应用层 insert 不填 sparse)
schema.addFunction(CreateCollectionReq.Function.builder()
.functionType(FunctionType.BM25)
.name("bm25_fn")
@@ -446,6 +529,7 @@ public class MilvusHybridKnowledgeStore {
}
}
/** 过滤表达式字符串转义,防止引号打断 expr。 */
private static String escapeFilter(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
@@ -19,6 +19,8 @@ public record KnowledgeSearchHit(
String source,
String title,
String breadcrumb,
int originalRank
int originalRank,
/** Optional dense L2 for hybrid quality gates; null on dense-only hits. */
Double denseDistance
) {
}
@@ -1,10 +1,20 @@
package com.superbiz.agent.service.retrieval;
/**
* Retrieval mode for {@link KnowledgeSearchPort}.
* Delivery 1 only requires {@link #DENSE}; hybrid arrives in a later change.
* {@link KnowledgeSearchPort} 检索模式。
*
* <ul>
* <li>{@link #DENSE} —— 单路向量 ANN(L2)</li>
* <li>{@link #HYBRID} —— dense + Milvus 服务端 BM25 + RRF 融合</li>
* </ul>
*
* <p>当前实际生效模式由全局配置 {@code retrieval.search.mode} 决定
*(见 {@link com.superbiz.agent.service.VectorSearchService});
* 请求里的 mode 预留作将来 per-call 覆盖,adapter 暂未按请求切换。</p>
*/
public enum KnowledgeSearchMode {
/** 仅 dense 向量检索。 */
DENSE,
/** dense + BM25 hybrid(Milvus {@code hybridSearch} + RRF)。 */
HYBRID
}
@@ -3,8 +3,11 @@ package com.superbiz.agent.service.retrieval;
import java.util.List;
/**
* Application boundary for knowledge semantic search.
* Implementations may wrap VectorStore, hybrid engines, etc. without leaking SDK details upward.
* 知识语义检索的应用边界端口。
*
* <p>实现可对接 dense / hybrid 等引擎,但不得向上层泄漏 SDK 类型。
* 当前实现:{@link VectorKnowledgeSearchAdapter} → {@code VectorSearchService}
* → {@code MilvusHybridKnowledgeStore}(Milvus SDK v2 dense 或 dense+BM25 RRF)。</p>
*/
public interface KnowledgeSearchPort {
@@ -0,0 +1,45 @@
package com.superbiz.agent.service.retrieval;
/**
* 检索结果一级 {@code scoreLabel} 约定。
*
* <p>只区分两种检索形态(与 {@code retrieval.search.mode} 对齐),
* 不再使用 {@code bm25_only_*} 等作为正式一级 label。</p>
*/
public final class RetrievalScoreLabels {
/** dense-only ANN:{@code score} 为 L2 距离(越小越好)。 */
public static final String DENSE = "dense";
/** hybrid(dense+BM25+RRF):{@code score}/raw 为融合侧信号;质量分主要看 rank。 */
public static final String HYBRID = "hybrid";
private RetrievalScoreLabels() {
}
/**
* 将历史/别名 label 归一到 {@link #DENSE} 或 {@link #HYBRID}。
* 未知或空 → dense(保守,按 L2 解释失败时 quality 偏低)。
*/
public static String canonicalize(String scoreLabel) {
if (scoreLabel == null || scoreLabel.isBlank()) {
return DENSE;
}
String label = scoreLabel.trim().toLowerCase();
return switch (label) {
case DENSE, "l2_distance", "l2" -> DENSE;
case HYBRID, "rrf_fused", "rrf", "bm25_only_no_dense", "bm25_only" -> HYBRID;
default -> label.contains("hybrid") || label.contains("rrf") || label.contains("bm25")
? HYBRID
: DENSE;
};
}
public static boolean isHybrid(String scoreLabel) {
return HYBRID.equals(canonicalize(scoreLabel));
}
public static boolean isDense(String scoreLabel) {
return DENSE.equals(canonicalize(scoreLabel));
}
}
@@ -0,0 +1,71 @@
package com.superbiz.agent.service.retrieval;
/**
* 检索分 → 统一 {@code qualityScore ∈ [0,1]}(越大越好)的唯一转换点。
*
* <p>后处理排序仍按 {@code originalRank};本类只负责质量闸门 / relevance 用分。</p>
*
* <ul>
* <li>{@link RetrievalScoreLabels#DENSE}:{@code score} = L2 → {@code 1 - clamp(l2)/maxL2}</li>
* <li>{@link RetrievalScoreLabels#HYBRID}:优先用可选 {@code denseDistance} 做绝对质量
* (恢复 L0 filter low-quality 等闸门);无 dense 时回退 rank 映射</li>
* </ul>
*/
public final class RetrievalScoreNormalizer {
private RetrievalScoreNormalizer() {
}
/**
* @param scoreLabel {@link RetrievalScoreLabels#DENSE} / {@link RetrievalScoreLabels#HYBRID}
* @param score 引擎主分:dense=L2;hybrid=融合分(hybrid 质量不依赖其量纲)
* @param originalRank 检索名次(1-based)
* @param batchSize 本轮候选数(rank 回退映射用)
* @param maxL2Distance L2 上界
* @param denseDistance hybrid 命中上可选的 dense L2;dense 模式可传 null
*/
public static double toQualityScore(String scoreLabel,
Double score,
Integer originalRank,
int batchSize,
double maxL2Distance,
Double denseDistance) {
String label = RetrievalScoreLabels.canonicalize(scoreLabel);
if (RetrievalScoreLabels.HYBRID.equals(label)) {
if (denseDistance != null) {
return l2ToQuality(denseDistance, maxL2Distance);
}
// BM25-only hybrid hit (no dense neighbor): conservative mid quality via rank
return rankToQuality(originalRank, batchSize);
}
return l2ToQuality(score, maxL2Distance);
}
/** Backward-compatible overload without denseDistance. */
public static double toQualityScore(String scoreLabel,
Double score,
Integer originalRank,
int batchSize,
double maxL2Distance) {
return toQualityScore(scoreLabel, score, originalRank, batchSize, maxL2Distance, null);
}
public static double l2ToQuality(Double l2Score, double maxL2Distance) {
if (l2Score == null) {
return 0.0;
}
double max = maxL2Distance > 0 ? maxL2Distance : 2.0;
double clamped = Math.min(Math.max(l2Score, 0.0), max);
return Math.max(0.0, 1.0 - clamped / max);
}
public static double rankToQuality(Integer originalRank, int batchSize) {
int rank = originalRank == null || originalRank < 1 ? 1 : originalRank;
int n = batchSize > 0 ? Math.max(batchSize, rank) : Math.max(rank, 1);
if (n <= 1) {
return 1.0;
}
double quality = 1.0 - (rank - 1) / (double) n;
return Math.max(1.0 / n, Math.min(1.0, quality));
}
}
@@ -10,11 +10,11 @@ import java.util.List;
import java.util.Map;
/**
* {@link KnowledgeSearchPort} adapter.
* {@link KnowledgeSearchPort} 适配器:把向量检索结果映射为带 evidenceKey 的命中结构。
*
* <p>Delegates to {@link VectorSearchService}, which is backed solely by
* Milvus V2 dense / dense+BM25 hybrid store. Mode selection lives in
* {@code retrieval.search.mode}.</p>
* <p>委托 {@link VectorSearchService}(背后仅 {@code MilvusHybridKnowledgeStore}):
* dense 或 dense+BM25 hybrid 由配置 {@code retrieval.search.mode} 选择。
* 本类负责 metadata 解析、docId/chunk 身份与 evidenceKey,不碰 SDK。</p>
*/
@Component
public class VectorKnowledgeSearchAdapter implements KnowledgeSearchPort {
@@ -76,7 +76,8 @@ public class VectorKnowledgeSearchAdapter implements KnowledgeSearchPort {
source,
EvidenceIdentity.metadataValue(metadata, "title"),
EvidenceIdentity.metadataValue(metadata, "breadcrumb"),
originalRank
originalRank,
result.getDenseDistance()
);
}
+13 -7
View File
@@ -167,17 +167,21 @@ rag:
enabled: false
content-preview-limit: 300
# 检索配置(单一 Milvus V2 后端;已移除 sdk/spring/auto 路由)
# 检索配置
# 知识主路径:Milvus Java SDK v2(MilvusHybridKnowledgeStore),非 Spring AI VectorStore starter。
# 原因:starter(含 2.0.0)仅 dense similarity,无 hybridSearch / BM25 Function / RRFRanker。
# 已移除 legacy sdk/spring/auto 多后端路由。
retrieval:
kb-scope: "" # empty means search all documents in hybrid collection
kb-scope: "" # 非空则过滤 metadata.kb_scope;空=不过滤
search:
mode: hybrid # dense | hybrid (dense + BM25 RRF)
# hybrid=线上主路径;dense=同库对照/评测/排障(非第二套线上策略)。见 mvp/architecture/rag-knowledge-retrieval-architecture.md §6.0
mode: hybrid # dense=单路L2对照 | hybrid=dense+服务端BM25+RRF
hybrid:
rrf-k: 60
rrf-k: 60 # RRF 平滑参数 k,score=Σ 1/(k+rank)
normalization:
max-l2-distance: 2.0 # L2 距离上界(BGE-M3 单位向量 = 2.0)
highly-relevant-threshold: 0.75 # similarity >= 0.75 → HIGHLY_RELEVANT
reference-threshold: 0.5 # similarity >= 0.5 → REFERENCE
max-l2-distance: 2.0 # dense quality:L2 上界(单位向量 ≈ 2.0)
highly-relevant-threshold: 0.75 # qualityScore >= 0.75 → PRECISE(hybrid 为序数分,见架构 §6)
reference-threshold: 0.5 # qualityScore >= 0.5 → REFERENCE;低于则低质/可 unfiltered retry
# Prometheus 配置
prometheus:
@@ -213,6 +217,8 @@ logging:
# Agent-facing MySQL Tool uses independent logical datasources only.
# Production entries are supplied by a dedicated profile and Secret injection.
# When data-sources is empty (default), query_mysql is NOT registered on the Diagnosis Agent
# (avoids a permanently broken tool that the model can still call).
harness:
chat:
worker-core-pool-size: 2
@@ -0,0 +1,3 @@
-- V016: expose a first-class conclusion field next to query for run-level readout.
ALTER TABLE diagnosis_run
ADD COLUMN conclusion TEXT NULL COMMENT 'Extracted final conclusion text (from report or fallback message)' AFTER query;
@@ -0,0 +1,9 @@
-- V017: store provider reasoning and assistant text separately on agent model steps.
-- Tool result payloads remain out of this table (see tool_invocation).
ALTER TABLE agent_reasoning_audit
ADD COLUMN assistant_text LONGTEXT NULL
COMMENT 'Assistant visible text / tool-call plan for this model step (no tool results)'
AFTER reasoning_content,
ADD COLUMN content_source VARCHAR(64) NULL
COMMENT 'PROVIDER_REASONING+ASSISTANT_TEXT | PROVIDER_REASONING | ASSISTANT_TEXT | TOOL_CALL_PLAN | NONE'
AFTER assistant_text;
@@ -91,6 +91,8 @@ class HarnessChatConfigurationTest {
.withBean(LookupResultAssembler.class, () -> mock(LookupResultAssembler.class))
.withBean(ToolInvocationAuditSink.class, ToolInvocationAuditSink::noop)
.withBean(DiagnosisTraceRecorder.class, DiagnosisTraceRecorder::noop)
.withBean(com.superbiz.agent.harness.audit.AgentStepAuditTracker.class,
com.superbiz.agent.harness.audit.AgentStepAuditTracker::new)
.withBean(AgentStepRepository.class, () -> mock(AgentStepRepository.class))
.withBean(AgentReasoningAuditRepository.class,
() -> mock(AgentReasoningAuditRepository.class))
@@ -10,18 +10,29 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Locale;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Generates RAG retrieval fixtures from the real LookupKnowledgeTool bean.
* Generates RAG retrieval fixtures from the real {@link LookupKnowledgeTool} bean.
*
* <p>This class is disabled by default because it writes repository files and
* depends on the configured runtime retrieval stack.</p>
* <p>Disabled by default: writes repository files and needs the live retrieval stack
* (embedding + Milvus hybrid collection + optional MySQL/L0).</p>
*
* <p>System properties (via Maven {@code -D}):</p>
* <ul>
* <li>{@code rag.snapshot.enabled=true} — required to run</li>
* <li>{@code retrieval.search.mode=hybrid|dense} — default hybrid</li>
* <li>{@code retrieval.kb-scope} — default empty unless set (scripts use {@code rag-eval})</li>
* <li>{@code rag.snapshot.cases} / {@code rag.snapshot.fixtures} / {@code rag.snapshot.retrievedAt}</li>
* </ul>
*/
@SpringBootTest(
classes = Main.class,
@@ -40,11 +51,31 @@ class RagLookupSnapshotGeneratorTest {
@Autowired
private ObjectMapper objectMapper;
/**
* Bind retrieval mode/scope early so {@code VectorSearchService} / store filters see them.
*/
@DynamicPropertySource
static void retrievalProperties(DynamicPropertyRegistry registry) {
String mode = System.getProperty("retrieval.search.mode", "hybrid");
if (mode == null || mode.isBlank()) {
mode = "hybrid";
}
String normalized = mode.trim().toLowerCase(Locale.ROOT);
registry.add("retrieval.search.mode", () -> normalized);
String kbScope = System.getProperty("retrieval.kb-scope", "");
if (kbScope != null && !kbScope.isBlank()) {
registry.add("retrieval.kb-scope", kbScope::trim);
}
}
@Test
void generateLookupResultFixtures() throws Exception {
Path casesPath = Path.of(System.getProperty("rag.snapshot.cases", DEFAULT_CASES.toString()));
Path fixturesDir = Path.of(System.getProperty("rag.snapshot.fixtures", DEFAULT_FIXTURES.toString()));
String retrievedAt = System.getProperty("rag.snapshot.retrievedAt", Instant.now().toString());
String searchMode = normalizeMode(System.getProperty("retrieval.search.mode", "hybrid"));
String kbScope = blankToNull(System.getProperty("retrieval.kb-scope", ""));
JsonNode root = objectMapper.readTree(casesPath.toFile());
JsonNode cases = root.path("cases");
@@ -61,6 +92,10 @@ class RagLookupSnapshotGeneratorTest {
fixture.put("caseId", caseId);
fixture.put("query", query);
fixture.put("retrievedAt", retrievedAt);
fixture.put("searchMode", searchMode);
if (kbScope != null) {
fixture.put("kbScope", kbScope);
}
fixture.set("lookupResult", objectMapper.valueToTree(lookupResult));
Path output = fixturesDir.resolve(caseId + ".json");
@@ -68,6 +103,20 @@ class RagLookupSnapshotGeneratorTest {
}
}
private static String normalizeMode(String mode) {
if (mode == null || mode.isBlank()) {
return "hybrid";
}
return mode.trim().toLowerCase(Locale.ROOT);
}
private static String blankToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private String requiredText(JsonNode node, String fieldName) {
JsonNode value = node.get(fieldName);
if (value == null || value.asText().isBlank()) {
@@ -0,0 +1,44 @@
package com.superbiz.agent.harness.agent;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryResult;
import com.superbiz.agent.harness.tool.contract.AgentToolContracts;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HarnessEvidenceToolsTest {
@Test
void omitsQueryMysqlWhenInvokerIsNull() {
EvidenceToolInvoker unused = (context, id, args) ->
ToolBoundaryResult.ready(id, "{}", EvidenceStatus.NO_EVIDENCE);
HarnessEvidenceTools tools = new HarnessEvidenceTools(unused, unused, null);
assertTrue(tools.supports(AgentToolContracts.LOOKUP_KNOWLEDGE));
assertTrue(tools.supports(AgentToolContracts.QUERY_LOGS));
assertFalse(tools.supports(AgentToolContracts.QUERY_MYSQL));
List<String> names = tools.callbacks().stream().map(ToolCallback::getToolDefinition)
.map(def -> def.name()).toList();
assertEquals(2, names.size());
assertTrue(names.contains(AgentToolContracts.LOOKUP_KNOWLEDGE));
assertTrue(names.contains(AgentToolContracts.QUERY_LOGS));
assertFalse(names.contains(AgentToolContracts.QUERY_MYSQL));
}
@Test
void registersQueryMysqlWhenInvokerPresent() {
EvidenceToolInvoker unused = (context, id, args) ->
ToolBoundaryResult.ready(id, "{}", EvidenceStatus.NO_EVIDENCE);
HarnessEvidenceTools tools = new HarnessEvidenceTools(unused, unused, unused);
assertTrue(tools.supports(AgentToolContracts.QUERY_MYSQL));
assertEquals(3, tools.callbacks().size());
}
}
@@ -33,6 +33,7 @@ import java.util.Optional;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -106,7 +107,7 @@ class PublishedResultPersistenceTest {
context.modelCalls().begin(ModelCallComponent.DIAGNOSIS_AGENT);
store.finish(context, IntentType.DIAGNOSIS, ReleaseOutcome.FALLBACK,
"{\"type\":\"SEMANTIC_UNAVAILABLE\"}",
"{\"fallback\":{\"type\":\"SEMANTIC_UNAVAILABLE\",\"message\":\"语义校验不可用\",\"conclusion\":null}}",
new PublishedResult("q", "c", "scope", List.of(),
List.of(new SourceDocument("doc", "title"))), 12);
@@ -115,6 +116,32 @@ class PublishedResultPersistenceTest {
assertNull(entity.getPublishedResult());
assertEquals(12, entity.getTotalDurationMs());
assertEquals(2, entity.getStepCount());
assertNotNull(entity.getConclusion());
assertTrue(entity.getConclusion().contains("SEMANTIC_UNAVAILABLE"));
assertTrue(entity.getConclusion().contains("语义校验不可用"));
}
@Test
void successCompletionExtractsConclusionBesideQuery() {
DiagnosisRunRepository runs = mock(DiagnosisRunRepository.class);
ChatSessionRepository sessions = mock(ChatSessionRepository.class);
DiagnosisRun entity = DiagnosisRun.builder()
.runId("run-ok").sessionId("session-1").query("pool?").status("RUNNING").build();
when(runs.findByRunId("run-ok")).thenReturn(Optional.of(entity));
when(runs.save(any(DiagnosisRun.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(sessions.findBySessionId("session-1")).thenReturn(Optional.empty());
JpaChatRunStore store = new JpaChatRunStore(sessions, runs, objectMapper);
RunContext context = core().startRun("session-1", "run-ok");
store.finish(context, IntentType.DIAGNOSIS, ReleaseOutcome.SUCCESS,
"{\"report\":{\"conclusion\":{\"text\":\"Pool exhausted per runbook.\"}}}",
new PublishedResult("pool?", "Pool exhausted per runbook.", "scope", List.of(),
List.of(new SourceDocument("doc", "title"))), 20);
assertEquals("Pool exhausted per runbook.", entity.getConclusion());
assertEquals("pool?", entity.getQuery());
assertNotNull(entity.getAnswer());
assertNotNull(entity.getPublishedResult());
}
@Test
@@ -2,23 +2,24 @@ package com.superbiz.agent.harness.audit;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.domain.entity.AgentStep;
import com.superbiz.agent.domain.entity.AgentReasoningAudit;
import com.superbiz.agent.domain.entity.AgentStep;
import com.superbiz.agent.harness.agent.DiagnosisAgentFactory;
import com.superbiz.agent.repository.AgentStepRepository;
import com.superbiz.agent.repository.AgentReasoningAuditRepository;
import com.superbiz.agent.repository.AgentStepRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.deepseek.DeepSeekAssistantMessage;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -30,7 +31,7 @@ import static org.mockito.Mockito.when;
class HarnessAgentAuditHookTest {
@Test
void persistsMetadataAndReasoningInSeparateAudit() {
void persistsBothProviderReasoningAndAssistantText() {
AgentStepRepository repository = mock(AgentStepRepository.class);
AgentReasoningAuditRepository reasoningRepository = mock(AgentReasoningAuditRepository.class);
AgentStep persisted = AgentStep.builder().id(7L).build();
@@ -47,7 +48,7 @@ class HarnessAgentAuditHookTest {
hook.beforeModel(List.of(new UserMessage("secret-query")), config);
AssistantMessage response = AssistantMessage.builder()
.content("secret-model-output")
.content("public assistant conclusion text")
.properties(java.util.Map.of(
"reasoning_content", "inspect bounded evidence before selecting query_logs"))
.toolCalls(List.of(new AssistantMessage.ToolCall(
@@ -61,13 +62,16 @@ class HarnessAgentAuditHookTest {
AgentStep completed = captor.getAllValues().get(1);
assertEquals("session-audit", started.getSessionId());
assertEquals("run-audit", started.getRunId());
// model_input stays summary-only (no raw user text)
assertFalse(started.getModelInput().contains("secret-query"));
assertFalse(completed.getModelOutput().contains("secret-model-output"));
// model_output stays summary-only
assertFalse(completed.getModelOutput().contains("public assistant conclusion text"));
assertFalse(completed.getModelOutput().contains("secret-argument"));
assertEquals("{\"has_text\":true,\"tool_names\":[\"query_logs\"],"
+ "\"reasoning_available\":true,\"reasoning_bytes\":52}",
completed.getModelOutput());
assertNull(completed.getThought());
assertTrue(completed.getModelOutput().contains("\"reasoning_available\":true"));
assertTrue(completed.getModelOutput().contains("PROVIDER_REASONING+ASSISTANT_TEXT"));
// thought keeps provider reasoning preferentially
assertEquals("inspect bounded evidence before selecting query_logs", completed.getThought());
ArgumentCaptor<AgentReasoningAudit> reasoningCaptor =
ArgumentCaptor.forClass(AgentReasoningAudit.class);
verify(reasoningRepository).save(reasoningCaptor.capture());
@@ -78,16 +82,88 @@ class HarnessAgentAuditHookTest {
assertTrue(reasoning.getReasoningAvailable());
assertEquals("inspect bounded evidence before selecting query_logs",
reasoning.getReasoningContent());
assertTrue(reasoning.getAssistantText().contains("public assistant conclusion text"));
assertTrue(reasoning.getAssistantText().contains("tool_calls:"));
assertTrue(reasoning.getAssistantText().contains("query_logs"));
assertEquals(HarnessAgentAuditHook.SOURCE_BOTH, reasoning.getContentSource());
assertTrue(reasoning.getContentBytes() > 0);
assertNotNull(trace.get());
assertEquals(TraceEventType.AGENT_MODEL_STEP, trace.get().eventType());
String traceText = trace.get().details().toString();
assertFalse(traceText.contains("secret-query"));
assertFalse(traceText.contains("secret-model-output"));
assertFalse(traceText.contains("secret-argument"));
assertFalse(traceText.contains("public assistant conclusion text"));
assertFalse(traceText.contains("inspect bounded evidence"));
assertTrue(traceText.contains("reasoning_available=true"));
}
@Test
void extractsReasoningFromDeepSeekAssistantMessageField() {
AgentStepRepository repository = mock(AgentStepRepository.class);
AgentReasoningAuditRepository reasoningRepository = mock(AgentReasoningAuditRepository.class);
AgentStep persisted = AgentStep.builder().id(9L).build();
when(repository.save(any(AgentStep.class))).thenReturn(persisted);
when(repository.findById(9L)).thenReturn(Optional.of(persisted));
HarnessAgentAuditHook hook = new HarnessAgentAuditHook(
repository, new ObjectMapper(), DiagnosisAgentFactory.AGENT_NAME,
DiagnosisTraceRecorder.noop(), reasoningRepository);
RunnableConfig config = RunnableConfig.builder()
.addMetadata("sessionId", "session-ds")
.addMetadata("runId", "run-ds")
.build();
// Production path: Spring AI DeepSeekChatModel returns DeepSeekAssistantMessage
// with reasoning on the dedicated field, NOT metadata.
DeepSeekAssistantMessage response = new DeepSeekAssistantMessage.Builder()
.content("final answer body")
.reasoningContent("step1: inspect evidence\nstep2: call lookup_knowledge")
.properties(Map.of())
.build();
hook.beforeModel(List.of(new UserMessage("q")), config);
hook.afterModel(List.of(response), config);
ArgumentCaptor<AgentReasoningAudit> captor = ArgumentCaptor.forClass(AgentReasoningAudit.class);
verify(reasoningRepository).save(captor.capture());
AgentReasoningAudit row = captor.getValue();
assertTrue(row.getReasoningAvailable());
assertEquals("step1: inspect evidence\nstep2: call lookup_knowledge",
row.getReasoningContent());
assertEquals("final answer body", row.getAssistantText());
assertEquals(HarnessAgentAuditHook.SOURCE_BOTH, row.getContentSource());
// metadata-only path must not be required
assertTrue(HarnessAgentAuditHook.providerReasoning(response).contains("inspect evidence"));
}
@Test
void withoutProviderReasoningStillStoresAssistantText() {
AgentStepRepository repository = mock(AgentStepRepository.class);
AgentReasoningAuditRepository reasoningRepository = mock(AgentReasoningAuditRepository.class);
AgentStep persisted = AgentStep.builder().id(8L).build();
when(repository.save(any(AgentStep.class))).thenReturn(persisted);
when(repository.findById(8L)).thenReturn(Optional.of(persisted));
HarnessAgentAuditHook hook = new HarnessAgentAuditHook(
repository, new ObjectMapper(), DiagnosisAgentFactory.AGENT_NAME,
DiagnosisTraceRecorder.noop(), reasoningRepository);
RunnableConfig config = RunnableConfig.builder()
.addMetadata("sessionId", "session-2")
.addMetadata("runId", "run-2")
.build();
hook.beforeModel(List.of(new UserMessage("q")), config);
hook.afterModel(List.of(AssistantMessage.builder()
.content("only assistant body")
.build()), config);
ArgumentCaptor<AgentReasoningAudit> captor = ArgumentCaptor.forClass(AgentReasoningAudit.class);
verify(reasoningRepository).save(captor.capture());
AgentReasoningAudit row = captor.getValue();
assertFalse(row.getReasoningAvailable());
assertNullSafe(row.getReasoningContent());
assertEquals("only assistant body", row.getAssistantText());
assertEquals(HarnessAgentAuditHook.SOURCE_ASSISTANT, row.getContentSource());
}
@Test
void missingIdentitySkipsPersistence() {
AgentStepRepository repository = mock(AgentStepRepository.class);
@@ -98,4 +174,27 @@ class HarnessAgentAuditHookTest {
verify(repository, never()).save(any());
}
@Test
void bindsStepIdToTrackerForToolAuditLinkage() {
AgentStepRepository repository = mock(AgentStepRepository.class);
AgentStep persisted = AgentStep.builder().id(42L).build();
when(repository.save(any(AgentStep.class))).thenReturn(persisted);
AgentStepAuditTracker tracker = new AgentStepAuditTracker();
HarnessAgentAuditHook hook = new HarnessAgentAuditHook(
repository, new ObjectMapper(), DiagnosisAgentFactory.AGENT_NAME,
DiagnosisTraceRecorder.noop(), null, tracker);
RunnableConfig config = RunnableConfig.builder()
.addMetadata("sessionId", "session-bind")
.addMetadata("runId", "run-bind")
.build();
hook.beforeModel(List.of(new UserMessage("q")), config);
assertEquals(42L, tracker.currentStepId("run-bind"));
}
private static void assertNullSafe(String value) {
assertTrue(value == null || value.isBlank());
}
}
@@ -10,17 +10,19 @@ import org.mockito.ArgumentCaptor;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class JpaToolInvocationAuditSinkTest {
@Test
void persistsOnlyBoundedStableMetadata() {
void persistsOnlyBoundedStableMetadataForGenericTools() {
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
DiagnosisTraceRecorder traceRecorder = mock(DiagnosisTraceRecorder.class);
JpaToolInvocationAuditSink sink = new JpaToolInvocationAuditSink(
repository, new ObjectMapper(), traceRecorder);
JpaToolInvocationAuditSink sink = JpaToolInvocationAuditSink.forTest(
repository, new ObjectMapper(), traceRecorder, null);
sink.record(new ToolInvocationAuditEvent(
"session-1", "run-1", "call-1", "query_logs",
@@ -36,14 +38,100 @@ class JpaToolInvocationAuditSinkTest {
assertEquals("{\"tool_call_id\":\"call-1\",\"request_bytes\":83}", saved.getInputParams());
assertEquals("status=ERROR,evidence_status=ERROR", saved.getOutputPreview());
assertEquals("TOOL_EXECUTION_ERROR", saved.getErrorMessage());
// generic tools must not put evidence_status into relevance_level column
assertNull(saved.getRelevanceLevel());
assertNull(saved.getStepId());
String serialized = saved.getInputParams() + saved.getOutputPreview() + saved.getRetrievalDetails();
assertFalse(serialized.contains("query"));
assertFalse(serialized.contains("raw_response"));
assertTrue(saved.getRetrievalDetails().contains("evidence_status"));
ArgumentCaptor<DiagnosisTraceAuditEvent> traceCaptor =
ArgumentCaptor.forClass(DiagnosisTraceAuditEvent.class);
verify(traceRecorder).record(traceCaptor.capture());
assertEquals(TraceEventType.TOOL_INVOCATION, traceCaptor.getValue().eventType());
assertEquals("call-1", traceCaptor.getValue().details().get("tool_call_id"));
assertFalse(traceCaptor.getValue().details().toString().contains("raw_response"));
}
@Test
void persistsStepIdAndSafeQueryPreviewInInputParams() {
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
DiagnosisTraceRecorder traceRecorder = mock(DiagnosisTraceRecorder.class);
JpaToolInvocationAuditSink sink = JpaToolInvocationAuditSink.forTest(
repository, new ObjectMapper(), traceRecorder, null);
String longQuery = "q".repeat(200);
sink.record(new ToolInvocationAuditEvent(
"session-1", "run-1", "call-q", "lookup_knowledge",
InvocationStatus.READY, EvidenceStatus.EVIDENCE_FOUND, null,
12, 50, 80, null, null, 99L,
"{\"query\":\"" + longQuery + "\",\"password\":\"should-not-store\",\"limit\":5}"));
ArgumentCaptor<ToolInvocation> captor = ArgumentCaptor.forClass(ToolInvocation.class);
verify(repository).save(captor.capture());
ToolInvocation saved = captor.getValue();
assertEquals(99L, saved.getStepId());
assertTrue(saved.getInputParams().contains("\"step_id\":99"));
assertTrue(saved.getInputParams().contains("\"query\":\"" + "q".repeat(160) + "\""));
assertTrue(saved.getInputParams().contains("\"query_truncated\":true"));
assertTrue(saved.getInputParams().contains("\"query_chars\":200"));
assertTrue(saved.getInputParams().contains("\"limit\":5"));
assertFalse(saved.getInputParams().contains("should-not-store"));
assertFalse(saved.getInputParams().contains("password"));
ArgumentCaptor<DiagnosisTraceAuditEvent> traceCaptor =
ArgumentCaptor.forClass(DiagnosisTraceAuditEvent.class);
verify(traceRecorder).record(traceCaptor.capture());
assertEquals(99L, traceCaptor.getValue().details().get("step_id"));
}
@Test
void enrichesLookupKnowledgeWithRagFieldsAndTrueRelevanceLevel() {
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
DiagnosisTraceRecorder traceRecorder = mock(DiagnosisTraceRecorder.class);
RagLookupAuditEnricher enricher = new RagLookupAuditEnricher(new ObjectMapper(), "hybrid");
JpaToolInvocationAuditSink sink = JpaToolInvocationAuditSink.forTest(
repository, new ObjectMapper(), traceRecorder, enricher);
String lookup = """
{
"found": true,
"relevanceLevel": "REFERENCE",
"evidenceBlockCount": 1,
"evidenceBlocks": [
{"evidenceKey":"doc#chunk-0","source":"doc","retrievalLayer":"L1","content":"body"}
],
"retrievalTrace": {
"selectedAttempt": "FILTERED_VECTOR",
"fallbackReason": null,
"queryHints": {"l0_match_count": 2, "domains": ["mysql"]},
"attempts": [{"name":"FILTERED_VECTOR","candidateCount":3,"usable":true,"topSimilarity":0.8}]
}
}
""";
String agent = """
{"evidence_status":"EVIDENCE_FOUND","returned_count":1,"truncated":false,"relevance_level":"REFERENCE"}
""";
sink.record(new ToolInvocationAuditEvent(
"session-1", "run-1", "call-rag", "lookup_knowledge",
InvocationStatus.READY, EvidenceStatus.EVIDENCE_FOUND, null,
42, 10, 100, lookup, agent, 7L,
"{\"query\":\"MySQL HikariCP pool exhausted\"}"));
ArgumentCaptor<ToolInvocation> captor = ArgumentCaptor.forClass(ToolInvocation.class);
verify(repository).save(captor.capture());
ToolInvocation saved = captor.getValue();
assertEquals(7L, saved.getStepId());
assertEquals("L1", saved.getRetrievalLayer());
assertEquals("REFERENCE", saved.getRelevanceLevel());
assertEquals(2, saved.getL0MatchCount());
assertEquals(1, saved.getL1MatchCount());
assertTrue(saved.getRetrievalDetails().contains("FILTERED_VECTOR"));
assertTrue(saved.getRetrievalDetails().contains("search_mode"));
assertTrue(saved.getRetrievalDetails().contains("EVIDENCE_FOUND"));
assertTrue(saved.getOutputPreview().contains("REFERENCE"));
assertTrue(saved.getInputParams().contains("MySQL HikariCP pool exhausted"));
assertTrue(saved.getInputParams().contains("\"step_id\":7"));
// must not store full excerpt dump as sole content; body may appear only if tiny — ensure attempt present
assertTrue(saved.getRetrievalDetails().contains("attempts"));
}
}
@@ -0,0 +1,90 @@
package com.superbiz.agent.harness.audit;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RagLookupAuditEnricherTest {
private final RagLookupAuditEnricher enricher =
new RagLookupAuditEnricher(new ObjectMapper(), "hybrid");
@Test
void extractsBoundedRagFieldsWithoutFullQueryOrExcerpt() {
String lookup = """
{
"found": true,
"relevanceLevel": "PRECISE",
"evidenceCandidateCount": 8,
"evidenceBlockCount": 2,
"evidenceBlocks": [
{
"docId": "mysql-pool",
"evidenceKey": "mysql-pool#chunk-0",
"source": "mysql-pool",
"retrievalLayer": "L1",
"content": "HikariCP details should not be required in audit details dump"
}
],
"retrievalTrace": {
"selectedAttempt": "UNFILTERED_VECTOR_RETRY",
"fallbackReason": "filtered_vector_low_quality",
"categoryFilter": "overfilter-decoy",
"evidenceStatus": "supported",
"originalQuery": "secret user query should not be stored",
"queryHints": {
"l0_match_count": 1,
"domains": ["rag"],
"matched_keywords": ["pool"]
},
"attempts": [
{
"name": "FILTERED_VECTOR",
"categoryFilter": "overfilter-decoy",
"candidateCount": 2,
"usable": false,
"topSimilarity": 0.3,
"durationMs": 12
},
{
"name": "UNFILTERED_VECTOR_RETRY",
"candidateCount": 5,
"usable": true,
"topSimilarity": 0.9,
"durationMs": 20
}
]
}
}
""";
String agent = """
{"evidence_status":"EVIDENCE_FOUND","returned_count":2,"truncated":true,"relevance_level":"PRECISE"}
""";
RagLookupAuditEnricher.Enrichment e = enricher.enrich(lookup, agent);
assertEquals("L1", e.retrievalLayer());
assertEquals(1, e.l0MatchCount());
assertEquals(1, e.l1MatchCount()); // one evidence block in fixture
assertEquals(8, e.retrievalDetails().get("evidence_candidate_count"));
assertEquals("PRECISE", e.relevanceLevel());
assertTrue(e.truncated());
assertEquals("UNFILTERED_VECTOR_RETRY", e.retrievalDetails().get("selected_attempt"));
assertEquals("filtered_vector_low_quality", e.retrievalDetails().get("fallback_reason"));
assertEquals("hybrid", e.retrievalDetails().get("search_mode"));
assertEquals(List.of("mysql-pool#chunk-0"), e.retrievalDetails().get("evidence_keys"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> attempts = (List<Map<String, Object>>) e.retrievalDetails().get("attempts");
assertEquals(2, attempts.size());
String blob = e.retrievalDetails().toString() + e.outputPreview();
assertFalse(blob.contains("secret user query"));
assertFalse(blob.contains("HikariCP details should not"));
}
}
@@ -0,0 +1,43 @@
package com.superbiz.agent.harness.audit;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RunConclusionExtractorTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void extractsReportConclusion() {
String json = """
{"report":{"conclusion":{"text":"Pool is exhausted.","based_on_analysis_ids":["a1"]}}}
""";
assertEquals("Pool is exhausted.", RunConclusionExtractor.extract(mapper, json));
}
@Test
void extractsFallbackMessageWithType() {
String json = """
{"fallback":{"type":"INSUFFICIENT_EVIDENCE","message":"证据不足","conclusion":null}}
""";
String out = RunConclusionExtractor.extract(mapper, json);
assertTrue(out.contains("INSUFFICIENT_EVIDENCE"));
assertTrue(out.contains("证据不足"));
}
@Test
void extractsBareFallbackTypeOnly() {
assertEquals("SEMANTIC_UNAVAILABLE",
RunConclusionExtractor.extract(mapper, "{\"type\":\"SEMANTIC_UNAVAILABLE\"}"));
}
@Test
void nullOrBlankReturnsNull() {
assertNull(RunConclusionExtractor.extract(mapper, null));
assertNull(RunConclusionExtractor.extract(mapper, " "));
}
}
@@ -1,6 +1,7 @@
package com.superbiz.agent.harness.tool.boundary;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.audit.AgentStepAuditTracker;
import com.superbiz.agent.harness.audit.ToolInvocationAuditEvent;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.contract.InvocationStatus;
@@ -179,6 +180,8 @@ class ToolBoundaryTest {
assertEquals("session-audit", audit.get().sessionId());
assertEquals("run-audit", audit.get().runId());
assertEquals("call-audit", audit.get().toolCallId());
assertEquals("{\"query\":\"timeout\"}", audit.get().requestJson());
assertNull(audit.get().stepId());
FakeStore failingAuditStore = new FakeStore(
new CanonicalInvocationLimits(Duration.ofHours(2), 4096, 1024));
@@ -195,6 +198,28 @@ class ToolBoundaryTest {
assertEquals(EvidenceStatus.NO_EVIDENCE, unchanged.evidenceStatus());
}
@Test
void auditEventCarriesBoundStepIdFromTracker() {
MutableClock clock = new MutableClock(Instant.parse("2026-07-21T10:00:00Z"));
FakeStore store = new FakeStore(new CanonicalInvocationLimits(Duration.ofHours(2), 4096, 1024));
DiagnosisHarnessCore core = HarnessCoreFixtures.core(clock);
AgentStepAuditTracker tracker = new AgentStepAuditTracker();
tracker.bind("run-step", 55L);
AtomicReference<ToolInvocationAuditEvent> audit = new AtomicReference<>();
ToolBoundary boundary = new ToolBoundary(core,
new ToolCallKeyFactory("superbiz:harness:tool-call"),
store, objectMapper, clock, audit::set, tracker);
RunContext context = core.startRun("session-step", "run-step");
boundary.execute(
context, request("run-step", "call-step", true, true),
ignored -> "raw",
ignored -> new ProjectedToolResult("agent", EvidenceStatus.EVIDENCE_FOUND));
assertEquals(55L, audit.get().stepId());
assertEquals("{\"query\":\"timeout\"}", audit.get().requestJson());
}
private ToolBoundary boundary(MutableClock clock, FakeStore store) {
DiagnosisHarnessCore core = HarnessCoreFixtures.core(clock);
return new ToolBoundary(core, new ToolCallKeyFactory("superbiz:harness:tool-call"),
@@ -3,6 +3,7 @@ package com.superbiz.agent.service;
import com.superbiz.agent.dto.EvidencePostprocessResult;
import com.superbiz.agent.dto.KnowledgeQuery;
import com.superbiz.agent.dto.RetrievedEvidenceCandidate;
import com.superbiz.agent.service.retrieval.RetrievalScoreLabels;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -22,26 +23,78 @@ class KnowledgeEvidencePostProcessorTest {
processor = new KnowledgeEvidencePostProcessor();
ReflectionTestUtils.setField(processor, "maxChunksPerDocument", 2);
ReflectionTestUtils.setField(processor, "returnN", 5);
ReflectionTestUtils.setField(processor, "maxL2Distance", 2.0);
ReflectionTestUtils.setField(processor, "highlyRelevantThreshold", 0.75);
ReflectionTestUtils.setField(processor, "referenceThreshold", 0.5);
}
@Test
void keepsDistinctChunksFromSameDocument() {
EvidencePostprocessResult result = processor.process(query(), List.of(
candidate("shared", 0, "shared#chunk-0", "c0", 0.2),
candidate("shared", 1, "shared#chunk-1", "c1", 0.3)
candidate("shared", 0, "shared#chunk-0", "c0", 0.2, 1),
candidate("shared", 1, "shared#chunk-1", "c1", 0.3, 2)
));
assertEquals(2, result.getEvidenceBlockCount());
assertEquals("shared#chunk-0", result.getEvidenceBlocks().get(0).getEvidenceKey());
assertEquals("shared#chunk-1", result.getEvidenceBlocks().get(1).getEvidenceKey());
assertEquals("c0", result.getEvidenceBlocks().get(0).getContent());
assertEquals("c1", result.getEvidenceBlocks().get(1).getContent());
}
@Test
void preservesRetrievalRankOverKeywordOverlap() {
KnowledgeQuery q = KnowledgeQuery.builder()
.originalQuery("payment")
.rewrittenQuery("payment")
.domainHints(List.of("payment"))
.matchedKeywords(List.of("ERR_TIMEOUT"))
.entities(List.of())
.build();
RetrievedEvidenceCandidate first = RetrievedEvidenceCandidate.builder()
.id("a")
.docId("a")
.chunkIndex(0)
.evidenceKey("a#chunk-0")
.source("a.md")
.title("Generic")
.content("generic troubleshooting")
.score(0.4)
.scoreLabel(RetrievalScoreLabels.DENSE)
.originalRank(1)
.hitReasons(List.of("base"))
.metadata(Map.of("category", "other"))
.build();
RetrievedEvidenceCandidate second = RetrievedEvidenceCandidate.builder()
.id("b")
.docId("b")
.chunkIndex(0)
.evidenceKey("b#chunk-0")
.source("b.md")
.title("Payment ERR_TIMEOUT")
.content("payment ERR_TIMEOUT timeout diagnosis")
.score(0.45)
.scoreLabel(RetrievalScoreLabels.DENSE)
.originalRank(2)
.hitReasons(List.of("base"))
.metadata(Map.of("category", "payment"))
.build();
EvidencePostprocessResult result = processor.process(q, List.of(first, second));
assertEquals("a.md", result.getEvidenceBlocks().get(0).getSource());
assertEquals("b.md", result.getEvidenceBlocks().get(1).getSource());
// explain-only overlap on second, no score delta boost strings
assertTrue(result.getEvidenceBlocks().get(1).getHitReasons().stream()
.anyMatch(r -> r.equals("l0_domain_overlap") || r.equals("l0_keyword_overlap")));
assertTrue(result.getRerankTrace().getItems().stream()
.flatMap(i -> i.getBoostReasons() == null ? java.util.stream.Stream.empty() : i.getBoostReasons().stream())
.noneMatch(r -> r.contains(":+0.")));
}
@Test
void mergesTrueDuplicateEvidenceKeysWithoutReplacingContent() {
EvidencePostprocessResult result = processor.process(query(), List.of(
candidate("shared", 0, "shared#chunk-0", "keep-me", 0.2),
candidate("shared", 0, "shared#chunk-0", "keep-me", 0.2, 1),
RetrievedEvidenceCandidate.builder()
.id("dup")
.docId("shared")
@@ -50,6 +103,7 @@ class KnowledgeEvidencePostProcessorTest {
.source("shared.md")
.content("drop-me")
.score(0.25)
.scoreLabel(RetrievalScoreLabels.DENSE)
.originalRank(2)
.hitReasons(List.of("extra"))
.metadata(Map.of())
@@ -65,10 +119,10 @@ class KnowledgeEvidencePostProcessorTest {
void enforcesMaxChunksPerDocument() {
ReflectionTestUtils.setField(processor, "maxChunksPerDocument", 2);
EvidencePostprocessResult result = processor.process(query(), List.of(
candidate("shared", 0, "shared#chunk-0", "c0", 0.1),
candidate("shared", 1, "shared#chunk-1", "c1", 0.2),
candidate("shared", 2, "shared#chunk-2", "c2", 0.3),
candidate("other", 0, "other#chunk-0", "o0", 0.15)
candidate("shared", 0, "shared#chunk-0", "c0", 0.1, 1),
candidate("shared", 1, "shared#chunk-1", "c1", 0.2, 2),
candidate("shared", 2, "shared#chunk-2", "c2", 0.3, 3),
candidate("other", 0, "other#chunk-0", "o0", 0.15, 4)
));
assertEquals(3, result.getEvidenceBlockCount());
@@ -83,14 +137,36 @@ class KnowledgeEvidencePostProcessorTest {
void enforcesReturnN() {
ReflectionTestUtils.setField(processor, "returnN", 1);
EvidencePostprocessResult result = processor.process(query(), List.of(
candidate("a", 0, "a#chunk-0", "a0", 0.1),
candidate("b", 0, "b#chunk-0", "b0", 0.2)
candidate("a", 0, "a#chunk-0", "a0", 0.1, 1),
candidate("b", 0, "b#chunk-0", "b0", 0.2, 2)
));
assertEquals(1, result.getEvidenceBlockCount());
assertEquals("a#chunk-0", result.getEvidenceBlocks().get(0).getEvidenceKey());
}
@Test
void hybridQualityUsesRankNotScoreMagnitude() {
EvidencePostprocessResult result = processor.process(query(), List.of(
RetrievedEvidenceCandidate.builder()
.id("h1")
.docId("h1")
.chunkIndex(0)
.evidenceKey("h1#chunk-0")
.source("h1.md")
.content("top hybrid")
.score(0.01)
.scoreLabel(RetrievalScoreLabels.HYBRID)
.originalRank(1)
.hitReasons(List.of())
.metadata(Map.of())
.build()
));
assertEquals(1.0, result.getTopSimilarity(), 1e-9);
assertEquals("PRECISE", result.getRelevanceLevel());
}
private static KnowledgeQuery query() {
return KnowledgeQuery.builder()
.originalQuery("q")
@@ -105,7 +181,8 @@ class KnowledgeEvidencePostProcessorTest {
int chunkIndex,
String evidenceKey,
String content,
double score) {
double score,
int originalRank) {
return RetrievedEvidenceCandidate.builder()
.id(evidenceKey)
.docId(docId)
@@ -114,7 +191,8 @@ class KnowledgeEvidencePostProcessorTest {
.source(docId + ".md")
.content(content)
.score(score)
.originalRank(chunkIndex + 1)
.scoreLabel(RetrievalScoreLabels.DENSE)
.originalRank(originalRank)
.hitReasons(List.of("base"))
.metadata(Map.of("docId", docId, "chunkIndex", String.valueOf(chunkIndex)))
.build();
@@ -63,7 +63,7 @@ class VectorSearchServiceTest {
result.setId(id);
result.setScore(score);
result.setRawScore((double) score);
result.setScoreLabel("l2_distance");
result.setScoreLabel("dense");
result.setContent("content");
result.setMetadata("{}");
return result;
@@ -0,0 +1,56 @@
package com.superbiz.agent.service.retrieval;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RetrievalScoreNormalizerTest {
@Test
void denseL2MapsToSimilarity() {
assertEquals(1.0, RetrievalScoreNormalizer.l2ToQuality(0.0, 2.0), 1e-9);
assertEquals(0.0, RetrievalScoreNormalizer.l2ToQuality(2.0, 2.0), 1e-9);
assertEquals(0.9, RetrievalScoreNormalizer.l2ToQuality(0.2, 2.0), 1e-9);
assertEquals(0.0, RetrievalScoreNormalizer.l2ToQuality(null, 2.0), 1e-9);
}
@Test
void hybridRankIsMonotonicAndRankOneIsBest() {
double r1 = RetrievalScoreNormalizer.rankToQuality(1, 5);
double r3 = RetrievalScoreNormalizer.rankToQuality(3, 5);
double r5 = RetrievalScoreNormalizer.rankToQuality(5, 5);
assertEquals(1.0, r1, 1e-9);
assertTrue(r1 > r3 && r3 > r5);
assertTrue(r5 > 0.0);
}
@Test
void toQualityScoreUsesLabel() {
double dense = RetrievalScoreNormalizer.toQualityScore(
RetrievalScoreLabels.DENSE, 0.2, 1, 3, 2.0);
assertEquals(0.9, dense, 1e-9);
// hybrid without denseDistance falls back to rank (rank1 → 1.0)
double hybridRank = RetrievalScoreNormalizer.toQualityScore(
RetrievalScoreLabels.HYBRID, 99.0, 1, 4, 2.0, null);
assertEquals(1.0, hybridRank, 1e-9);
// hybrid with dense L2 uses absolute quality (gate / relevance)
double hybridDenseFar = RetrievalScoreNormalizer.toQualityScore(
RetrievalScoreLabels.HYBRID, 99.0, 1, 4, 2.0, 1.4);
assertEquals(0.3, hybridDenseFar, 1e-9);
assertTrue(hybridDenseFar < 0.5);
double hybridRank2 = RetrievalScoreNormalizer.toQualityScore(
"rrf_fused", 0.01, 2, 4, 2.0, null);
assertTrue(hybridRank2 < hybridRank);
}
@Test
void canonicalizeAliases() {
assertEquals(RetrievalScoreLabels.DENSE, RetrievalScoreLabels.canonicalize("l2_distance"));
assertEquals(RetrievalScoreLabels.HYBRID, RetrievalScoreLabels.canonicalize("rrf_fused"));
assertEquals(RetrievalScoreLabels.HYBRID, RetrievalScoreLabels.canonicalize("bm25_only_no_dense"));
}
}
@@ -42,7 +42,7 @@ class VectorKnowledgeSearchAdapterHybridTest {
result.setContent(content);
result.setScore(score);
result.setRawScore((double) score);
result.setScoreLabel("l2_distance");
result.setScoreLabel("dense");
return result;
}
}
@@ -182,7 +182,7 @@ class LookupKnowledgeToolTest {
}
@Test
void rerankUsesHintMatchesAndContextPackPreservesMetadata() {
void preservesRetrievalOrderAndContextPackMetadataWithoutBoostRerank() {
KnowledgeEntry entry = entry("payment.md", "Payment", "ERR_TIMEOUT", "payment");
VectorSearchService.SearchResult first = searchResult(
"a",
@@ -202,9 +202,14 @@ class LookupKnowledgeToolTest {
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")));
// originalRank order wins; keyword/domain boost must not promote second over first
assertEquals("a.md", lookup.getEvidenceBlocks().get(0).getSource());
assertEquals("b.md", lookup.getEvidenceBlocks().get(1).getSource());
assertTrue(lookup.getRerankTrace().getItems().stream()
.flatMap(item -> item.getBoostReasons() == null
? java.util.stream.Stream.empty()
: item.getBoostReasons().stream())
.noneMatch(reason -> reason.contains(":+0.")));
assertTrue(lookup.getContextPack().getPackedText().contains("Payment > Timeout"));
assertTrue(lookup.getContextPack().getPackedText().contains("reasons:"));
}
@@ -258,7 +263,7 @@ class LookupKnowledgeToolTest {
result.setContent(content);
result.setScore(score);
result.setRawScore((double) score);
result.setScoreLabel("l2_distance");
result.setScoreLabel("dense");
return result;
}