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

Persist provider reasoning and assistant text separately on agent_reasoning_audit
(DeepSeekAssistantMessage path), extract diagnosis_run.conclusion, enrich RAG
tool audit (step_id/query/qualityScore), gate empty mysql tools, drop devtools,
and align MVP docs after live E2E verification.
This commit is contained in:
zhuyongxin
2026-07-28 19:43:13 +08:00
parent 2f40536248
commit 7ae9707a3b
116 changed files with 8364 additions and 1141 deletions
@@ -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, " "));
}
}