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:
@@ -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() {
|
||||
|
||||
+45
-25
@@ -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));
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -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"));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user