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 输入)。
+ *
+ * 在正文前拼接 title / breadcrumb,提高「按标题或路径关键词」的 BM25 命中率,
+ * 同时让 dense 向量也编码结构信息。无标题路径时退回纯 content。
*/
static String buildSearchText(DocumentChunk chunk) {
String content = trimToEmpty(chunk.getContent());
diff --git a/src/main/java/com/superbiz/agent/service/VectorSearchService.java b/src/main/java/com/superbiz/agent/service/VectorSearchService.java
index 3aa7105..4b6deee 100644
--- a/src/main/java/com/superbiz/agent/service/VectorSearchService.java
+++ b/src/main/java/com/superbiz/agent/service/VectorSearchService.java
@@ -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 召回入口)。
*
- * Single backend: {@link MilvusHybridKnowledgeStore} (Milvus Java SDK v2).
- * Legacy {@code MilvusServiceClient} search and Spring AI VectorStore routing for
- * {@code lookup_knowledge} have been removed.
+ * 唯一后端:{@link MilvusHybridKnowledgeStore}(Milvus Java SDK v2)。
+ *
+ * 模式切换
+ * {@code retrieval.search.mode}(同库查询算法,非两套写入):
+ *
+ * - {@code hybrid} —— 线上主路径:dense + 服务端 BM25 + RRF
+ * - {@code dense} —— 对照/评测:仅 dense ANN
+ *
+ * 命中 {@link SearchResult#scoreLabel} 仅为 {@link RetrievalScoreLabels#DENSE} /
+ * {@link RetrievalScoreLabels#HYBRID}。质量分由后处理 {@code RetrievalScoreNormalizer} 统一计算。
*/
@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)。
+ *
+ *
+ * - {@code scoreLabel=dense}:{@link #score} = L2 距离(越小越好)
+ * - {@code scoreLabel=hybrid}:{@link #score}/{@link #rawScore} = 引擎融合分;
+ * 后处理 quality 主要按 rank 映射,不把 score 当 L2
+ *
+ */
@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 not replace sort order.
+ */
+ private Double denseDistance;
+ /** metadata JSON 字符串(docId、source、title…)。 */
private String metadata;
}
}
diff --git a/src/main/java/com/superbiz/agent/service/milvus/MilvusHybridKnowledgeStore.java b/src/main/java/com/superbiz/agent/service/milvus/MilvusHybridKnowledgeStore.java
index c954f89..3f3c6e3 100644
--- a/src/main/java/com/superbiz/agent/service/milvus/MilvusHybridKnowledgeStore.java
+++ b/src/main/java/com/superbiz/agent/service/milvus/MilvusHybridKnowledgeStore.java
@@ -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 混合检索的唯一实现。
*
- * Supports dense ANN and dense+BM25 hybrid search via {@code hybridSearch} + {@link RRFRanker}.
- * Legacy {@code MilvusServiceClient} search is not used.
+ * 为什么不用 Spring AI {@code spring-ai-starter-vector-store-milvus}
+ *
+ * - Spring AI Milvus starter(截至 2.0.0 / 1.1.8)只封装 dense {@code similaritySearch}。
+ * - 底层仍是 V1 {@code MilvusServiceClient} + 单路 {@code SearchParam},无 {@code hybridSearch} /
+ * BM25 Function / {@link RRFRanker}。
+ * - 真混合检索(dense ANN + 服务端 BM25 sparse,再 RRF 融合)必须走 Milvus SDK v2,
+ * 见 {@link #searchHybrid}。
+ *
+ *
+ * Collection schema(默认名 {@code biz})
+ *
+ * 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 等
+ *
+ *
+ * 检索模式
+ *
+ * - {@link #searchDense}:单路 L2 ANN;{@code scoreLabel=dense}。
+ * - {@link #searchHybrid}:dense + BM25 + 服务端 {@link RRFRanker};{@code scoreLabel=hybrid};
+ * 返回序即 RRF 序,不再用 dense L2 覆盖主分。
+ *
+ *
+ * 配置入口:{@code milvus.collection}、{@code retrieval.search.mode}、{@code retrieval.hybrid.rrf-k}。
*/
@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 所需明文)。
+ *
+ * 只插入 {@code content / search_text / vector / metadata};
+ * {@code sparse_vector} 由 collection 上的 BM25 Function 在服务端从 {@code search_text} 生成。
+ *
+ * id 由 {@code source|docId + chunkIndex} 的 nameUUID 派生,同一 chunk 重复写入会得到相同 id
+ *(配合先 delete 再 insert 的上层逻辑实现覆盖)。
+ *
+ * @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 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 dropAndRecreateCollection() {
Map 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 searchDense(String queryEmbeddingText,
List 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 服务端融合)。
+ *
+ *
+ * - dense 子路:{@code vector},L2
+ * - BM25 子路:{@code sparse_vector} + {@link EmbeddedText}
+ * - {@link HybridSearchReq} + {@link RRFRanker} → 返回序即权威序
+ *
+ *
+ * {@code scoreLabel=hybrid};{@code score}/{@code rawScore} 保留引擎融合分,
+ * 不用 dense L2 覆盖主分或改 label。可选并行 dense 探测仅填充
+ * {@link VectorSearchService.SearchResult#setDenseDistance},供后处理绝对质量闸门
+ * (如 L0 filter low-quality → unfiltered retry),排序仍以 RRF 返回序为准。
*/
public List searchHybrid(String queryText,
List queryVector,
@@ -236,37 +309,45 @@ public class MilvusHybridKnowledgeStore {
.build();
SearchResp hybridResp = client().hybridSearch(hybridReq);
- List fused = toSearchResults(hybridResp, "rrf_fused", true);
-
- // Attach dense-compatible L2 when available.
- Map 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 fused = toSearchResults(hybridResp, RetrievalScoreLabels.HYBRID);
+ attachDenseDistances(fused, queryText, queryVector, pathTopK, category);
return fused;
}
- private List 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 fused,
+ String queryText,
+ List queryVector,
+ int pathTopK,
+ String category) {
+ if (fused == null || fused.isEmpty()) {
+ return;
+ }
+ try {
+ Map 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 toSearchResults(SearchResp resp, String scoreLabel) {
List 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 parts = new ArrayList<>();
String categoryFilter = trimToNull(category);
@@ -352,6 +422,17 @@ public class MilvusHybridKnowledgeStore {
return new MilvusClientV2(builder.build());
}
+ /**
+ * 若不存在则创建 dense+BM25 hybrid collection。
+ *
+ * 关键点:
+ *
+ * - {@code search_text} 开启 analyzer,作为 BM25 语料。
+ * - {@link FunctionType#BM25}:input={@code search_text} → output={@code sparse_vector}。
+ * - dense:IVF_FLAT + L2;sparse:SPARSE_INVERTED_INDEX + BM25。
+ *
+ * 已存在的 collection 不会改 schema;schema 变更需走 {@link #dropAndRecreateCollection()}。
+ */
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("\"", "\\\"");
}
diff --git a/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchHit.java b/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchHit.java
index e841eea..d71bcc6 100644
--- a/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchHit.java
+++ b/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchHit.java
@@ -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
) {
}
diff --git a/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchMode.java b/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchMode.java
index 0808e6f..bd0a65e 100644
--- a/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchMode.java
+++ b/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchMode.java
@@ -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} 检索模式。
+ *
+ *
+ * - {@link #DENSE} —— 单路向量 ANN(L2)
+ * - {@link #HYBRID} —— dense + Milvus 服务端 BM25 + RRF 融合
+ *
+ *
+ * 当前实际生效模式由全局配置 {@code retrieval.search.mode} 决定
+ *(见 {@link com.superbiz.agent.service.VectorSearchService});
+ * 请求里的 mode 预留作将来 per-call 覆盖,adapter 暂未按请求切换。
*/
public enum KnowledgeSearchMode {
+ /** 仅 dense 向量检索。 */
DENSE,
+ /** dense + BM25 hybrid(Milvus {@code hybridSearch} + RRF)。 */
HYBRID
}
diff --git a/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchPort.java b/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchPort.java
index f94c9ed..b878891 100644
--- a/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchPort.java
+++ b/src/main/java/com/superbiz/agent/service/retrieval/KnowledgeSearchPort.java
@@ -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.
+ * 知识语义检索的应用边界端口。
+ *
+ * 实现可对接 dense / hybrid 等引擎,但不得向上层泄漏 SDK 类型。
+ * 当前实现:{@link VectorKnowledgeSearchAdapter} → {@code VectorSearchService}
+ * → {@code MilvusHybridKnowledgeStore}(Milvus SDK v2 dense 或 dense+BM25 RRF)。
*/
public interface KnowledgeSearchPort {
diff --git a/src/main/java/com/superbiz/agent/service/retrieval/RetrievalScoreLabels.java b/src/main/java/com/superbiz/agent/service/retrieval/RetrievalScoreLabels.java
new file mode 100644
index 0000000..ee9bf7e
--- /dev/null
+++ b/src/main/java/com/superbiz/agent/service/retrieval/RetrievalScoreLabels.java
@@ -0,0 +1,45 @@
+package com.superbiz.agent.service.retrieval;
+
+/**
+ * 检索结果一级 {@code scoreLabel} 约定。
+ *
+ * 只区分两种检索形态(与 {@code retrieval.search.mode} 对齐),
+ * 不再使用 {@code bm25_only_*} 等作为正式一级 label。
+ */
+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));
+ }
+}
diff --git a/src/main/java/com/superbiz/agent/service/retrieval/RetrievalScoreNormalizer.java b/src/main/java/com/superbiz/agent/service/retrieval/RetrievalScoreNormalizer.java
new file mode 100644
index 0000000..0e68d4a
--- /dev/null
+++ b/src/main/java/com/superbiz/agent/service/retrieval/RetrievalScoreNormalizer.java
@@ -0,0 +1,71 @@
+package com.superbiz.agent.service.retrieval;
+
+/**
+ * 检索分 → 统一 {@code qualityScore ∈ [0,1]}(越大越好)的唯一转换点。
+ *
+ * 后处理排序仍按 {@code originalRank};本类只负责质量闸门 / relevance 用分。
+ *
+ *
+ * - {@link RetrievalScoreLabels#DENSE}:{@code score} = L2 → {@code 1 - clamp(l2)/maxL2}
+ * - {@link RetrievalScoreLabels#HYBRID}:优先用可选 {@code denseDistance} 做绝对质量
+ * (恢复 L0 filter low-quality 等闸门);无 dense 时回退 rank 映射
+ *
+ */
+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));
+ }
+}
diff --git a/src/main/java/com/superbiz/agent/service/retrieval/VectorKnowledgeSearchAdapter.java b/src/main/java/com/superbiz/agent/service/retrieval/VectorKnowledgeSearchAdapter.java
index c7d2245..1dc60dc 100644
--- a/src/main/java/com/superbiz/agent/service/retrieval/VectorKnowledgeSearchAdapter.java
+++ b/src/main/java/com/superbiz/agent/service/retrieval/VectorKnowledgeSearchAdapter.java
@@ -10,11 +10,11 @@ import java.util.List;
import java.util.Map;
/**
- * {@link KnowledgeSearchPort} adapter.
+ * {@link KnowledgeSearchPort} 适配器:把向量检索结果映射为带 evidenceKey 的命中结构。
*
- * Delegates to {@link VectorSearchService}, which is backed solely by
- * Milvus V2 dense / dense+BM25 hybrid store. Mode selection lives in
- * {@code retrieval.search.mode}.
+ * 委托 {@link VectorSearchService}(背后仅 {@code MilvusHybridKnowledgeStore}):
+ * dense 或 dense+BM25 hybrid 由配置 {@code retrieval.search.mode} 选择。
+ * 本类负责 metadata 解析、docId/chunk 身份与 evidenceKey,不碰 SDK。
*/
@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()
);
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 6db6800..7a4d177 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -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
diff --git a/src/main/resources/db/migration/V016__add_conclusion_to_diagnosis_run.sql b/src/main/resources/db/migration/V016__add_conclusion_to_diagnosis_run.sql
new file mode 100644
index 0000000..55db01a
--- /dev/null
+++ b/src/main/resources/db/migration/V016__add_conclusion_to_diagnosis_run.sql
@@ -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;
diff --git a/src/main/resources/db/migration/V017__add_content_source_to_agent_reasoning_audit.sql b/src/main/resources/db/migration/V017__add_content_source_to_agent_reasoning_audit.sql
new file mode 100644
index 0000000..9965e97
--- /dev/null
+++ b/src/main/resources/db/migration/V017__add_content_source_to_agent_reasoning_audit.sql
@@ -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;
diff --git a/src/test/java/com/superbiz/agent/config/HarnessChatConfigurationTest.java b/src/test/java/com/superbiz/agent/config/HarnessChatConfigurationTest.java
index 2b04253..34b9e89 100644
--- a/src/test/java/com/superbiz/agent/config/HarnessChatConfigurationTest.java
+++ b/src/test/java/com/superbiz/agent/config/HarnessChatConfigurationTest.java
@@ -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))
diff --git a/src/test/java/com/superbiz/agent/eval/RagLookupSnapshotGeneratorTest.java b/src/test/java/com/superbiz/agent/eval/RagLookupSnapshotGeneratorTest.java
index 73825a6..011a47a 100644
--- a/src/test/java/com/superbiz/agent/eval/RagLookupSnapshotGeneratorTest.java
+++ b/src/test/java/com/superbiz/agent/eval/RagLookupSnapshotGeneratorTest.java
@@ -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.
*
- * This class is disabled by default because it writes repository files and
- * depends on the configured runtime retrieval stack.
+ * Disabled by default: writes repository files and needs the live retrieval stack
+ * (embedding + Milvus hybrid collection + optional MySQL/L0).
+ *
+ * System properties (via Maven {@code -D}):
+ *
+ * - {@code rag.snapshot.enabled=true} — required to run
+ * - {@code retrieval.search.mode=hybrid|dense} — default hybrid
+ * - {@code retrieval.kb-scope} — default empty unless set (scripts use {@code rag-eval})
+ * - {@code rag.snapshot.cases} / {@code rag.snapshot.fixtures} / {@code rag.snapshot.retrievedAt}
+ *
*/
@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()) {
diff --git a/src/test/java/com/superbiz/agent/harness/agent/HarnessEvidenceToolsTest.java b/src/test/java/com/superbiz/agent/harness/agent/HarnessEvidenceToolsTest.java
new file mode 100644
index 0000000..4f39218
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/harness/agent/HarnessEvidenceToolsTest.java
@@ -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 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());
+ }
+}
diff --git a/src/test/java/com/superbiz/agent/harness/application/PublishedResultPersistenceTest.java b/src/test/java/com/superbiz/agent/harness/application/PublishedResultPersistenceTest.java
index aec0cc5..c065bbb 100644
--- a/src/test/java/com/superbiz/agent/harness/application/PublishedResultPersistenceTest.java
+++ b/src/test/java/com/superbiz/agent/harness/application/PublishedResultPersistenceTest.java
@@ -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
diff --git a/src/test/java/com/superbiz/agent/harness/audit/HarnessAgentAuditHookTest.java b/src/test/java/com/superbiz/agent/harness/audit/HarnessAgentAuditHookTest.java
index b8a7c0a..2d73d02 100644
--- a/src/test/java/com/superbiz/agent/harness/audit/HarnessAgentAuditHookTest.java
+++ b/src/test/java/com/superbiz/agent/harness/audit/HarnessAgentAuditHookTest.java
@@ -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 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 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 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());
+ }
}
diff --git a/src/test/java/com/superbiz/agent/harness/audit/JpaToolInvocationAuditSinkTest.java b/src/test/java/com/superbiz/agent/harness/audit/JpaToolInvocationAuditSinkTest.java
index c1d0671..31c9bd8 100644
--- a/src/test/java/com/superbiz/agent/harness/audit/JpaToolInvocationAuditSinkTest.java
+++ b/src/test/java/com/superbiz/agent/harness/audit/JpaToolInvocationAuditSinkTest.java
@@ -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 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 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 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 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"));
}
}
diff --git a/src/test/java/com/superbiz/agent/harness/audit/RagLookupAuditEnricherTest.java b/src/test/java/com/superbiz/agent/harness/audit/RagLookupAuditEnricherTest.java
new file mode 100644
index 0000000..3784f5c
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/harness/audit/RagLookupAuditEnricherTest.java
@@ -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