feat(graph): add diagnosis real nodes

This commit is contained in:
zhuyongxin
2026-07-17 16:44:05 +08:00
parent 42ba204532
commit 1460dd1e99
57 changed files with 4380 additions and 710 deletions
@@ -0,0 +1,34 @@
package com.superbiz.agent.diagnosis.protocol;
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 ComposerOutputParserTest {
private final ComposerOutputParser parser = new ComposerOutputParser();
@Test
void malformedOutputUsesSafeAllowedMaterialFallback() {
ComposerRenderResult result = parser.parse(
"not-json",
Map.of(
"verdict", "LOW_CONFID",
"allowed_claims", List.of(Map.of(
"claim_text", "连接数达到上限")),
"allowed_hypotheses", List.of(),
"missing_info", List.of("缺少泄漏检测日志"),
"recommended_actions", List.of(),
"raw_executor_output", "未验证的秘密根因"),
List.of("人工查看 Trace"));
assertEquals("composer_malformed", result.audit().get("status"));
assertTrue(result.answer().contains("连接数达到上限"));
assertFalse(result.answer().contains("未验证的秘密根因"));
}
}
@@ -0,0 +1,52 @@
package com.superbiz.agent.diagnosis.protocol;
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;
class ComposerSafeInputBuilderTest {
private final ComposerSafeInputBuilder builder = new ComposerSafeInputBuilder();
@Test
@SuppressWarnings("unchecked")
void rejectInputContainsOnlyVerifierAllowedClaimsAndMissingInformation() {
Map<String, Object> executorOutput = Map.of(
"answer_version", "executor_evidence_v2",
"claims", List.of(
Map.of("claim_id", "c1", "claim_type", "symptom",
"claim_text", "连接数达到上限", "support_level", "direct"),
Map.of("claim_id", "c2", "claim_type", "root_cause",
"claim_text", "存在连接泄漏", "support_level", "inference")),
"hypotheses", List.of(Map.of("hypothesis_text", "raw hypothesis")),
"missing_info", List.of("缺少泄漏检测日志"),
"recommended_actions", List.of(Map.of(
"action_text", "开启泄漏检测", "reason", "补齐证据")));
VerifierDecision decision = new VerifierDecision(
"REJECT", 0.4, 2,
List.of(
Map.of("claim_id", "c1", "claim_text", "连接数达到上限",
"claim_type", "symptom", "verification", "direct_observation",
"detail", "指标直接支持"),
Map.of("claim_id", "c2", "claim_text", "存在连接泄漏",
"claim_type", "root_cause", "verification", "unsupported",
"detail", "没有泄漏检测日志")),
List.of(), "核心原因未证实", 1);
Map<String, Object> input = builder.build("分析连接池", decision, executorOutput);
List<Map<String, Object>> allowed =
(List<Map<String, Object>>) input.get("allowed_claims");
assertEquals(List.of("连接数达到上限"),
allowed.stream().map(claim -> claim.get("claim_text")).toList());
assertEquals(List.of(), input.get("allowed_hypotheses"));
assertEquals(List.of("缺少泄漏检测日志", "存在连接泄漏:没有泄漏检测日志"),
input.get("missing_info"));
assertFalse(input.containsKey("executor_output"));
assertFalse(input.toString().contains("raw hypothesis"));
}
}
@@ -0,0 +1,63 @@
package com.superbiz.agent.diagnosis.protocol;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ExecutorEvidenceParserTest {
private final ExecutorEvidenceParser parser = new ExecutorEvidenceParser();
@Test
void parsesLegalNoEvidenceSnapshotAsValid() {
ExecutorEvidenceParser.ParseResult result = parser.parse("""
{
"answer_version": "executor_evidence_v2",
"claims": [],
"hypotheses": [],
"recommended_actions": [],
"missing_info": ["当前查询没有返回可引用证据"]
}
""");
assertEquals("valid", result.status().get("status"));
assertEquals("executor_evidence_v2", result.structuredOutput().get("answer_version"));
assertEquals(List.of(), result.structuredOutput().get("claims"));
}
@Test
@SuppressWarnings("unchecked")
void enrichesUniqueInvocationWithoutChangingClaimText() {
ExecutorEvidenceParser.ParseResult parsed = parser.parse("""
{
"answer_version": "executor_evidence_v2",
"claims": [{
"claim_id": "claim-1",
"claim_text": "连接数达到上限",
"evidence_bindings": [{
"tool_name": "queryPrometheusAlerts",
"raw_path": "$.alerts[0]",
"evidence_excerpt": "active=50 max=50"
}]
}]
}
""");
ExecutorEvidenceParser.ParseResult enriched = parser.enrich(parsed, List.of(Map.of(
"tool_name", "query_metrics",
"source_invocation_ids", List.of(17L))));
Map<String, Object> claim = ((List<Map<String, Object>>)
enriched.structuredOutput().get("claims")).get(0);
Map<String, Object> binding = ((List<Map<String, Object>>)
claim.get("evidence_bindings")).get(0);
assertEquals("连接数达到上限", claim.get("claim_text"));
assertEquals("query_metrics", binding.get("tool_name"));
assertEquals(17L, binding.get("source_invocation_id"));
assertEquals(1, ((List<?>) enriched.structuredOutput()
.get("_gatekeeper_warnings")).size());
}
}
@@ -0,0 +1,34 @@
package com.superbiz.agent.diagnosis.protocol;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SafeFallbackRendererTest {
private final SafeFallbackRenderer renderer = new SafeFallbackRenderer();
@Test
void rendersOnlyAllowedMaterial() {
String answer = renderer.render(Map.of(
"verdict", "REJECT",
"allowed_claims", List.of(Map.of(
"claim_text", "连接数达到上限")),
"allowed_hypotheses", List.of(),
"missing_info", List.of("缺少泄漏检测日志"),
"recommended_actions", List.of(Map.of(
"action_text", "开启泄漏检测", "reason", "补齐证据")),
"raw_executor_output", "秘密的未验证根因"),
List.of("人工查看 Trace"));
assertTrue(answer.contains("当前无法基于已获取证据生成可靠结论"));
assertTrue(answer.contains("连接数达到上限"));
assertTrue(answer.contains("缺少泄漏检测日志"));
assertTrue(answer.contains("开启泄漏检测:补齐证据"));
assertFalse(answer.contains("秘密的未验证根因"));
}
}
@@ -0,0 +1,40 @@
package com.superbiz.agent.diagnosis.protocol;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
class VerifierOutputParserTest {
private final VerifierOutputParser parser = new VerifierOutputParser();
@Test
void appliesLowConfidenceCeilingWithoutChangingModelVerdict() {
VerifierDecision modelDecision = parser.parse("""
{
"verdict": "PASS",
"groundedness_score": 0.92,
"critical_fact_count": 1,
"facts_checked": [{
"fact": "连接数达到上限",
"is_critical": true,
"verification": "direct_evidence",
"detail": "active=50 max=50",
"evidence_refs": []
}],
"rationale": "证据充分"
}
""", 1);
VerifierDecision effectiveDecision = parser.applyCeiling(
modelDecision,
"valid",
Map.of("status", "fail", "severity", "low_confid"));
assertEquals("PASS", modelDecision.verdict());
assertEquals("LOW_CONFID", effectiveDecision.verdict());
assertEquals(1, effectiveDecision.criticalFactCount());
}
}
@@ -0,0 +1,84 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class ComposerNodeAdapterTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void usesStableAllowedMaterialInputAcrossTechnicalRetry() throws Exception {
CapturingInvoker invoker = new CapturingInvoker("""
{
"answer_summary": "已确认连接数达到上限",
"recommended_actions": [],
"user_facing_answer": "已确认连接数达到上限。"
}
""");
ComposerNodeAdapter adapter = new ComposerNodeAdapter(invoker);
Map<String, Object> common = new LinkedHashMap<>();
common.put(DiagnosisGraphState.DIAGNOSIS_CONTEXT,
Map.of("query", "分析连接池"));
common.put(DiagnosisGraphState.VERIFIED_EXECUTOR_OUTPUT, Map.of(
"answer_version", "executor_evidence_v2",
"claims", List.of(Map.of(
"claim_id", "c1", "claim_type", "symptom",
"claim_text", "连接数达到上限", "support_level", "direct"))));
common.put(DiagnosisGraphState.VERIFIER_OUTPUT, Map.of(
"verdict", "PASS",
"groundedness_score", 1.0,
"critical_fact_count", 1,
"claim_checks", List.of(Map.of(
"claim_id", "c1", "claim_type", "symptom",
"claim_text", "连接数达到上限",
"verification", "direct_observation", "detail", "direct")),
"facts_checked", List.of(),
"rationale", "证据充分"));
common.put(DiagnosisGraphState.EFFECTIVE_VERDICT, "PASS");
common.put(DiagnosisGraphState.EXECUTOR_OUTPUT, "must-not-leak");
Map<String, Object> first = adapter.apply(
new OverAllState(common),
RunnableConfig.builder().threadId("run-composer").build()).get();
Map<String, Object> retryState = new LinkedHashMap<>(common);
retryState.put(DiagnosisGraphState.COMPOSER_RETRY_COUNT, 1);
adapter.apply(new OverAllState(retryState),
RunnableConfig.builder().threadId("run-composer").build()).get();
JsonNode input = objectMapper.readTree(invoker.inputs.get(0));
assertEquals(invoker.inputs.get(0), invoker.inputs.get(1));
assertFalse(input.has("executor_output"));
assertFalse(input.toString().contains("must-not-leak"));
assertEquals("COMPLETED", first.get(DiagnosisGraphState.COMPOSER_STATUS));
assertEquals("已确认连接数达到上限。",
first.get(DiagnosisGraphState.FINAL_ANSWER));
}
private static final class CapturingInvoker implements DiagnosisAgentInvoker {
private final String output;
private final List<String> inputs = new ArrayList<>();
private CapturingInvoker(String output) {
this.output = output;
}
@Override
public String invoke(String input, RunnableConfig config) {
inputs.add(input);
return output;
}
}
}
@@ -325,6 +325,25 @@ class DiagnosisGraphRoutingTest {
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.COMPOSER));
}
@Test
void verifierLowConfidenceWithOnlyNonCriticalGapDoesNotRetryEvidence()
throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
appendThroughVerifiedInput(script);
verifier(script, VerifierStatus.COMPLETED, Verdict.LOW_CONFID,
Verdict.PASS, Map.of("facts_checked", List.of(
Map.of(
"fact", "optional detail remains unverified",
"is_critical", false,
"verification", "no_evidence"))));
composerCompleted(script);
run(script);
assertEquals(0, script.calls(DiagnosisGraphTopology.Node.EVIDENCE_RETRY));
assertEquals(1, script.calls(DiagnosisGraphTopology.Node.COMPOSER));
}
@Test
void verifierRejectStillRunsComposerWithSafeMaterial() throws Exception {
ScriptedDiagnosisGraphActions script = new ScriptedDiagnosisGraphActions();
@@ -583,6 +602,7 @@ class DiagnosisGraphRoutingTest {
return Map.of("facts_checked", List.of(
Map.of(
"fact", "database saturation remains unverified",
"is_critical", true,
"verification", "no_evidence")));
}
@@ -0,0 +1,24 @@
package com.superbiz.agent.graph.diagnosis;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.concurrent.TimeoutException;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DiagnosisNodeFailureClassifierTest {
private final DiagnosisNodeFailureClassifier classifier =
DiagnosisNodeFailureClassifier.defaults();
@Test
void classifiesOnlyRecognizedTransientFailuresAsRetryable() {
assertEquals(DiagnosisNodeFailureClassifier.FailureKind.RETRYABLE,
classifier.classify(new TimeoutException("model timed out")));
assertEquals(DiagnosisNodeFailureClassifier.FailureKind.RETRYABLE,
classifier.classify(new RuntimeException(new IOException("connection reset"))));
assertEquals(DiagnosisNodeFailureClassifier.FailureKind.NON_RETRYABLE,
classifier.classify(new IllegalStateException("unknown failure")));
}
}
@@ -0,0 +1,399 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.superbiz.agent.service.ExecutorGatekeeperService;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class DiagnosisRealGraphIntegrationTest {
@Test
void legalNoEvidenceWithVerifiedBindingContinuesUnderLowConfidenceCeiling()
throws Exception {
ExecutorGatekeeperService gatekeeper = mock(ExecutorGatekeeperService.class);
when(gatekeeper.validateRun(eq("run-no-evidence"), anyMap(), anyMap()))
.thenReturn(Map.of(
"status", "fail",
"severity", "low_confid",
"checked_bindings", List.of(Map.of(
"claim_id", "c-no", "status", "pass",
"tool_name", "query_logs",
"source_invocation_id", 31L,
"raw_path", "$.no_evidence",
"matched_text", "query_logs total=0 evidence_status=no_evidence")),
"failed_rules", List.of("evidence.missing")));
String noEvidenceOutput = """
{"answer_version":"executor_evidence_v2","claims":[{
"claim_id":"c-no","claim_type":"negative_observation",
"claim_text":"当前查询未检索到连接泄漏日志","support_level":"direct",
"evidence_bindings":[{"tool_name":"query_logs",
"source_invocation_id":31,"raw_path":"$.no_evidence",
"evidence_excerpt":"query_logs total=0 evidence_status=no_evidence"}]}],
"hypotheses":[],"recommended_actions":[],"missing_info":[]}
""";
String verifier = """
{"verdict":"PASS","groundedness_score":0.8,
"critical_fact_count":1,"claim_checks":[{
"claim_id":"c-no","claim_text":"当前查询未检索到连接泄漏日志",
"claim_type":"negative_observation","verification":"direct_observation",
"detail":"no-evidence binding verified","evidence_refs":[]}],
"facts_checked":[],"rationale":"查询结果可信但证据有限"}
""";
DiagnosisGraphActions actions = new DiagnosisRealGraphActionsFactory().create(
constant(plannerOutput()), constant(noEvidenceOutput), constant(verifier),
constant(composerOutput()), gatekeeper);
OverAllState state = new DiagnosisGraphFactory().compile(actions)
.invoke(initialState(), config("run-no-evidence"))
.orElseThrow();
assertEquals("LOW_CONFID", DiagnosisGraphState.stringValue(
state, DiagnosisGraphState.EFFECTIVE_VERDICT));
assertEquals(List.of("planner", "executor", "gatekeeper", "verified_input",
"verifier", "composer"), eventNodes(state));
}
@Test
void criticalGapPerformsOneIncrementalRoundAndRevalidatesCompleteSnapshot()
throws Exception {
ExecutorGatekeeperService gatekeeper = mock(ExecutorGatekeeperService.class);
when(gatekeeper.validateRun(eq("run-critical-retry"), anyMap(), anyMap()))
.thenReturn(passResult())
.thenReturn(passResultWithSecondClaim());
QueueInvoker planner = new QueueInvoker(plannerOutput(), """
{"selected_skill":null,"selection_reason":"evidence gap",
"plan":["只补查泄漏检测日志"],"reasoning":"增量补证据"}
""");
QueueInvoker executor = new QueueInvoker(executorOutput(), executorFullSnapshot());
QueueInvoker verifier = new QueueInvoker("""
{"verdict":"LOW_CONFID","groundedness_score":0.5,
"critical_fact_count":1,"claim_checks":[],
"facts_checked":[{"fact":"c2: 是否存在连接泄漏",
"is_critical":true,"verification":"no_evidence",
"detail":"缺少泄漏检测日志","evidence_refs":[]}],
"rationale":"需补证据"}
""", verifierSecondPassOutput());
QueueInvoker composer = new QueueInvoker(composerOutput());
DiagnosisGraphActions actions = new DiagnosisRealGraphActionsFactory().create(
planner, executor, verifier, composer, gatekeeper);
OverAllState state = new DiagnosisGraphFactory().compile(actions)
.invoke(initialState(), config("run-critical-retry"))
.orElseThrow();
assertEquals(2, planner.inputs.size());
assertEquals(2, executor.inputs.size());
assertEquals(2, verifier.inputs.size());
assertEquals(1, composer.inputs.size());
assertEquals(1, DiagnosisGraphState.intValue(
state, DiagnosisGraphState.EVIDENCE_RETRY_COUNT));
assertEquals(true, new com.fasterxml.jackson.databind.ObjectMapper()
.readTree(executor.inputs.get(1))
.path("execution_constraints")
.path("only_execute_incremental_queries").asBoolean());
assertEquals("executor_evidence_v2",
new com.fasterxml.jackson.databind.ObjectMapper()
.readTree(executor.inputs.get(1))
.path("retry_context")
.path("prior_verified_executor_output")
.path("answer_version").asText());
assertEquals(List.of("planner", "executor", "gatekeeper", "verified_input",
"verifier", "evidence_retry", "planner", "executor",
"gatekeeper", "verified_input", "verifier", "composer"),
eventNodes(state));
verify(gatekeeper, times(2)).validateRun(
eq("run-critical-retry"), anyMap(), anyMap());
}
@Test
void gatekeeperRejectSkipsVerifierAndUsesPreVerificationFallback() throws Exception {
ExecutorGatekeeperService gatekeeper = mock(ExecutorGatekeeperService.class);
when(gatekeeper.validateRun(eq("run-real-reject"), anyMap(), anyMap()))
.thenReturn(Map.of(
"status", "fail",
"severity", "reject",
"checked_bindings", List.of(),
"failed_rules", List.of("evidence.raw_path")));
DiagnosisGraphActions actions = new DiagnosisRealGraphActionsFactory().create(
constant(plannerOutput()),
constant(executorOutput()),
(input, config) -> { throw new AssertionError("verifier must not run"); },
(input, config) -> { throw new AssertionError("composer must not run"); },
gatekeeper);
OverAllState state = new DiagnosisGraphFactory().compile(actions)
.invoke(initialState(), config("run-real-reject"))
.orElseThrow();
String answer = DiagnosisGraphState.stringValue(
state, DiagnosisGraphState.FINAL_ANSWER);
assertFalse(answer.contains("连接数达到上限"));
assertEquals(List.of("planner", "executor", "gatekeeper", "fallback"),
eventNodes(state));
}
@Test
void exhaustedComposerRetryUsesVerifiedMaterialWithoutRerunningPredecessors()
throws Exception {
ExecutorGatekeeperService gatekeeper = mock(ExecutorGatekeeperService.class);
when(gatekeeper.validateRun(eq("run-composer-fallback"), anyMap(), anyMap()))
.thenReturn(passResult());
QueueInvoker planner = new QueueInvoker(plannerOutput());
QueueInvoker executor = new QueueInvoker(executorOutput());
QueueInvoker verifier = new QueueInvoker(verifierPassOutput());
QueueInvoker composer = new QueueInvoker("not-json", "still-not-json");
DiagnosisGraphActions actions = new DiagnosisRealGraphActionsFactory().create(
planner, executor, verifier, composer, gatekeeper);
OverAllState state = new DiagnosisGraphFactory().compile(actions)
.invoke(initialState(), config("run-composer-fallback"))
.orElseThrow();
String answer = DiagnosisGraphState.stringValue(
state, DiagnosisGraphState.FINAL_ANSWER);
assertEquals(1, planner.inputs.size());
assertEquals(1, executor.inputs.size());
assertEquals(1, verifier.inputs.size());
assertEquals(2, composer.inputs.size());
assertEquals(composer.inputs.get(0), composer.inputs.get(1));
assertFalse(answer.contains("秘密的未验证根因"));
assertEquals(List.of("planner", "executor", "gatekeeper", "verified_input",
"verifier", "composer", "composer", "fallback"),
eventNodes(state));
}
@Test
void compiledRealNodeGraphCompletesPassPathInExactOrder() throws Exception {
ExecutorGatekeeperService gatekeeper = mock(ExecutorGatekeeperService.class);
when(gatekeeper.validateRun(eq("run-real-pass"), anyMap(), anyMap()))
.thenReturn(Map.of(
"status", "pass",
"severity", "none",
"checked_bindings", List.of(Map.of(
"claim_id", "c1",
"status", "pass",
"tool_name", "query_metrics",
"source_invocation_id", 17L,
"raw_path", "$.alerts[0]",
"matched_text", "active=50 max=50")),
"failed_rules", List.of()));
DiagnosisRealGraphActionsFactory actionFactory =
new DiagnosisRealGraphActionsFactory();
DiagnosisGraphActions actions = actionFactory.create(
constant("""
{"selected_skill":null,"selection_reason":"none",
"plan":["查询指标"],"reasoning":"确认现象"}
"""),
constant("""
{"answer_version":"executor_evidence_v2","claims":[{
"claim_id":"c1","claim_type":"symptom",
"claim_text":"连接数达到上限","support_level":"direct",
"evidence_bindings":[{"tool_name":"query_metrics",
"source_invocation_id":17,"raw_path":"$.alerts[0]",
"evidence_excerpt":"active=50 max=50"}]}],
"hypotheses":[],"recommended_actions":[],"missing_info":[]}
"""),
constant("""
{"verdict":"PASS","groundedness_score":1.0,
"critical_fact_count":1,"claim_checks":[{
"claim_id":"c1","claim_text":"连接数达到上限",
"claim_type":"symptom","verification":"direct_observation",
"detail":"指标直接支持","evidence_refs":[]}],
"facts_checked":[],"rationale":"证据充分"}
"""),
constant("""
{"answer_summary":"已确认连接数达到上限",
"recommended_actions":[],
"user_facing_answer":"已确认连接数达到上限。"}
"""),
gatekeeper);
CompiledGraph graph = new DiagnosisGraphFactory().compile(actions);
OverAllState state = graph.invoke(Map.of(
DiagnosisGraphState.DIAGNOSIS_CONTEXT,
Map.of("query", "分析连接池"),
DiagnosisGraphState.PLANNER_RETRY_COUNT, 0,
DiagnosisGraphState.VERIFIER_RETRY_COUNT, 0,
DiagnosisGraphState.COMPOSER_RETRY_COUNT, 0,
DiagnosisGraphState.EVIDENCE_RETRY_COUNT, 0,
DiagnosisGraphState.PLANNER_MODE, "NORMAL"),
RunnableConfig.builder()
.threadId("run-real-pass")
.addMetadata("runId", "run-real-pass")
.build()).orElseThrow();
assertEquals("已确认连接数达到上限。", DiagnosisGraphState.stringValue(
state, DiagnosisGraphState.FINAL_ANSWER));
List<?> events = DiagnosisGraphState.listValue(
state, DiagnosisGraphState.ORCHESTRATION_EVENTS);
assertEquals(List.of("planner", "executor", "gatekeeper", "verified_input",
"verifier", "composer"),
events.stream().map(event -> ((OrchestrationEvent) event).node()).toList());
verify(gatekeeper, times(1)).validateRun(
eq("run-real-pass"), anyMap(), anyMap());
}
private DiagnosisAgentInvoker constant(String output) {
return (input, config) -> output;
}
private Map<String, Object> initialState() {
return Map.of(
DiagnosisGraphState.DIAGNOSIS_CONTEXT,
Map.of("query", "分析连接池"),
DiagnosisGraphState.PLANNER_RETRY_COUNT, 0,
DiagnosisGraphState.VERIFIER_RETRY_COUNT, 0,
DiagnosisGraphState.COMPOSER_RETRY_COUNT, 0,
DiagnosisGraphState.EVIDENCE_RETRY_COUNT, 0,
DiagnosisGraphState.PLANNER_MODE, "NORMAL");
}
private RunnableConfig config(String runId) {
return RunnableConfig.builder()
.threadId(runId)
.addMetadata("runId", runId)
.build();
}
private List<String> eventNodes(OverAllState state) {
return DiagnosisGraphState.listValue(state, DiagnosisGraphState.ORCHESTRATION_EVENTS)
.stream()
.map(event -> ((OrchestrationEvent) event).node())
.toList();
}
private String plannerOutput() {
return """
{"selected_skill":null,"selection_reason":"none",
"plan":["查询指标"],"reasoning":"确认现象"}
""";
}
private String executorOutput() {
return """
{"answer_version":"executor_evidence_v2","claims":[{
"claim_id":"c1","claim_type":"symptom",
"claim_text":"连接数达到上限","support_level":"direct",
"evidence_bindings":[{"tool_name":"query_metrics",
"source_invocation_id":17,"raw_path":"$.alerts[0]",
"evidence_excerpt":"active=50 max=50"}]}],
"hypotheses":[{"hypothesis_text":"秘密的未验证根因"}],
"recommended_actions":[],"missing_info":[]}
""";
}
private String verifierPassOutput() {
return """
{"verdict":"PASS","groundedness_score":1.0,
"critical_fact_count":1,"claim_checks":[{
"claim_id":"c1","claim_text":"连接数达到上限",
"claim_type":"symptom","verification":"direct_observation",
"detail":"指标直接支持","evidence_refs":[]}],
"facts_checked":[],"rationale":"证据充分"}
""";
}
private String verifierSecondPassOutput() {
return """
{"verdict":"PASS","groundedness_score":1.0,
"critical_fact_count":2,"claim_checks":[
{"claim_id":"c1","claim_text":"连接数达到上限",
"claim_type":"symptom","verification":"direct_observation",
"detail":"指标直接支持","evidence_refs":[]},
{"claim_id":"c2","claim_text":"泄漏日志存在",
"claim_type":"root_cause","verification":"direct_observation",
"detail":"日志直接支持","evidence_refs":[]}],
"facts_checked":[],"rationale":"补证据完成"}
""";
}
private String composerOutput() {
return """
{"answer_summary":"诊断完成","recommended_actions":[],
"user_facing_answer":"诊断完成。"}
""";
}
private String executorFullSnapshot() {
return """
{"answer_version":"executor_evidence_v2","claims":[
{"claim_id":"c1","claim_type":"symptom",
"claim_text":"连接数达到上限","support_level":"direct",
"evidence_bindings":[{"tool_name":"query_metrics",
"source_invocation_id":17,"raw_path":"$.alerts[0]",
"evidence_excerpt":"active=50 max=50"}]},
{"claim_id":"c2","claim_type":"root_cause",
"claim_text":"泄漏日志存在","support_level":"direct",
"evidence_bindings":[{"tool_name":"query_logs",
"source_invocation_id":18,"raw_path":"$.logs[0]",
"evidence_excerpt":"connection leak detected"}]}],
"hypotheses":[],"recommended_actions":[],"missing_info":[]}
""";
}
private Map<String, Object> passResult() {
return Map.of(
"status", "pass",
"severity", "none",
"checked_bindings", List.of(Map.of(
"claim_id", "c1", "status", "pass",
"tool_name", "query_metrics",
"source_invocation_id", 17L,
"raw_path", "$.alerts[0]",
"matched_text", "active=50 max=50")),
"failed_rules", List.of());
}
private Map<String, Object> passResultWithSecondClaim() {
return Map.of(
"status", "pass",
"severity", "none",
"checked_bindings", List.of(
Map.of("claim_id", "c1", "status", "pass",
"tool_name", "query_metrics",
"source_invocation_id", 17L,
"raw_path", "$.alerts[0]",
"matched_text", "active=50 max=50"),
Map.of("claim_id", "c2", "status", "pass",
"tool_name", "query_logs",
"source_invocation_id", 18L,
"raw_path", "$.logs[0]",
"matched_text", "connection leak detected")),
"failed_rules", List.of());
}
private static final class QueueInvoker implements DiagnosisAgentInvoker {
private final Deque<String> outputs = new ArrayDeque<>();
private final List<String> inputs = new ArrayList<>();
private QueueInvoker(String... outputs) {
this.outputs.addAll(List.of(outputs));
}
@Override
public String invoke(String input, RunnableConfig config) {
inputs.add(input);
if (outputs.isEmpty()) {
throw new IllegalStateException("unexpected invoker call");
}
return outputs.removeFirst();
}
}
}
@@ -0,0 +1,59 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
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.assertSame;
class EvidenceRetryPrepareNodeTest {
@Test
@SuppressWarnings("unchecked")
void buildsBoundedContextFromCriticalGapsAndCompletedQueryRefs() throws Exception {
Map<String, Object> priorOutput = Map.of(
"answer_version", "executor_evidence_v2",
"claims", List.of(Map.of("claim_id", "c1", "claim_text", "已确认现象")));
List<Map<String, Object>> priorEvidence = List.of(
Map.of("claim_id", "c1", "tool_name", "query_metrics",
"source_invocation_id", 17L, "raw_path", "$.alerts[0]",
"matched_text", "active=50 max=50"),
Map.of("claim_id", "c2", "tool_name", "query_metrics",
"source_invocation_id", 17L, "raw_path", "$.alerts[0]",
"matched_text", "duplicate ref"));
Map<String, Object> verifierOutput = Map.of(
"facts_checked", List.of(
Map.of("fact", "c2: 是否泄漏",
"is_critical", true,
"verification", "no_evidence",
"detail", "缺少泄漏日志"),
Map.of("fact", "可选细节",
"is_critical", false,
"verification", "no_evidence")));
EvidenceRetryPrepareNode node = new EvidenceRetryPrepareNode();
Map<String, Object> update = node.apply(new OverAllState(Map.of(
DiagnosisGraphState.VERIFIED_EXECUTOR_OUTPUT, priorOutput,
DiagnosisGraphState.VERIFIED_EVIDENCE, priorEvidence,
DiagnosisGraphState.VERIFIER_OUTPUT, verifierOutput,
DiagnosisGraphState.EVIDENCE_RETRY_COUNT, 0)),
RunnableConfig.builder().threadId("run-retry").build()).get();
Map<String, Object> context = (Map<String, Object>) update.get(
DiagnosisGraphState.RETRY_CONTEXT);
assertSame(priorOutput, context.get("prior_verified_executor_output"));
assertEquals(priorEvidence, context.get("prior_verified_evidence"));
assertEquals(List.of("c2"), ((List<Map<String, Object>>) context.get(
"evidence_gaps")).stream().map(gap -> gap.get("claim_id")).toList());
assertEquals(1, ((List<?>) context.get("completed_query_refs")).size());
Map<String, Object> constraints =
(Map<String, Object>) context.get("constraints");
assertEquals(1, constraints.get("max_retry"));
assertEquals(true, constraints.get("only_execute_incremental_queries"));
assertEquals(true, constraints.get("preserve_prior_verified_claims"));
}
}
@@ -0,0 +1,75 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.fasterxml.jackson.databind.JsonNode;
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 ExecutorNodeAdapterTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void evidenceRetryRequestsIncrementalQueriesAndCompleteSnapshot() throws Exception {
CapturingInvoker invoker = new CapturingInvoker("""
{
"answer_version": "executor_evidence_v2",
"claims": [],
"hypotheses": [],
"recommended_actions": [],
"missing_info": ["增量查询仍无证据"]
}
""");
ExecutorNodeAdapter adapter = new ExecutorNodeAdapter(invoker);
OverAllState state = new OverAllState(Map.of(
DiagnosisGraphState.DIAGNOSIS_CONTEXT, Map.of(
"query", "分析连接池耗尽"),
DiagnosisGraphState.PLANNER_PLAN, Map.of(
"plan", List.of("只补查泄漏检测日志")),
DiagnosisGraphState.PLANNER_MODE, "EVIDENCE_GAP_ONLY",
DiagnosisGraphState.EVIDENCE_RETRY_COUNT, 1,
DiagnosisGraphState.RETRY_CONTEXT, Map.of(
"completed_query_refs", List.of("query_metrics:17:$.alerts[0]"))));
Map<String, Object> update = adapter.apply(
state, RunnableConfig.builder().threadId("run-executor").build()).get();
JsonNode input = objectMapper.readTree(invoker.input);
assertEquals("EVIDENCE_GAP_ONLY", input.path("planner_mode").asText());
assertTrue(input.path("execution_constraints")
.path("only_execute_incremental_queries").asBoolean());
assertTrue(input.path("execution_constraints")
.path("return_complete_executor_evidence_v2_snapshot").asBoolean());
assertFalse(input.has("verified_evidence"));
assertEquals("COMPLETED", update.get(DiagnosisGraphState.EXECUTOR_STATUS));
assertEquals(List.of(), ((Map<?, ?>) update.get(
DiagnosisGraphState.EXECUTOR_OUTPUT)).get("claims"));
OrchestrationEvent event = (OrchestrationEvent) ((List<?>) update.get(
DiagnosisGraphState.ORCHESTRATION_EVENTS)).get(0);
assertEquals(2, event.attempt());
}
private static final class CapturingInvoker implements DiagnosisAgentInvoker {
private final String output;
private String input;
private CapturingInvoker(String output) {
this.output = output;
}
@Override
public String invoke(String input, RunnableConfig config) {
this.input = input;
return output;
}
}
}
@@ -0,0 +1,68 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FallbackNodeTest {
@Test
void preVerificationFallbackNeverRendersExecutorClaims() throws Exception {
FallbackNode node = new FallbackNode();
Map<String, Object> update = node.apply(new OverAllState(Map.of(
DiagnosisGraphState.FAILURE_REASON, "gatekeeper_reject",
DiagnosisGraphState.EXECUTOR_OUTPUT, Map.of(
"claims", List.of(Map.of(
"claim_text", "秘密的未验证根因"))),
DiagnosisGraphState.EVIDENCE_RETRY_COUNT, 0)),
RunnableConfig.builder().threadId("run-fallback").build()).get();
String answer = String.valueOf(update.get(DiagnosisGraphState.FINAL_ANSWER));
assertTrue(answer.contains("当前无法基于已获取证据生成可靠结论"));
assertTrue(answer.contains("查看当前 Run Trace"));
assertFalse(answer.contains("秘密的未验证根因"));
}
@Test
void postComposerFallbackRendersOnlyVerifierAllowedMaterial() throws Exception {
FallbackNode node = new FallbackNode();
Map<String, Object> update = node.apply(new OverAllState(Map.of(
DiagnosisGraphState.DIAGNOSIS_CONTEXT,
Map.of("query", "分析连接池"),
DiagnosisGraphState.COMPOSER_STATUS,
"NON_RETRYABLE_FAILED",
DiagnosisGraphState.EFFECTIVE_VERDICT,
"LOW_CONFID",
DiagnosisGraphState.VERIFIED_EXECUTOR_OUTPUT,
Map.of("answer_version", "executor_evidence_v2",
"claims", List.of(Map.of(
"claim_id", "c1",
"claim_type", "symptom",
"claim_text", "连接数达到上限",
"support_level", "direct"))),
DiagnosisGraphState.VERIFIER_OUTPUT,
Map.of("verdict", "LOW_CONFID",
"claim_checks", List.of(Map.of(
"claim_id", "c1",
"claim_type", "symptom",
"claim_text", "连接数达到上限",
"verification", "direct_observation",
"detail", "指标直接支持")),
"facts_checked", List.of(),
"rationale", "仍缺少根因证据"),
DiagnosisGraphState.EXECUTOR_OUTPUT,
Map.of("claims", List.of(Map.of(
"claim_text", "秘密的未验证根因"))))),
RunnableConfig.builder().threadId("run-post-fallback").build()).get();
String answer = String.valueOf(update.get(DiagnosisGraphState.FINAL_ANSWER));
assertTrue(answer.contains("连接数达到上限"));
assertFalse(answer.contains("秘密的未验证根因"));
}
}
@@ -0,0 +1,88 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.superbiz.agent.service.ExecutorGatekeeperService;
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.assertSame;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class GatekeeperNodeTest {
@Test
void normalizesPassAndUnknownResultsDeterministically() throws Exception {
ExecutorGatekeeperService service = mock(ExecutorGatekeeperService.class);
GatekeeperNode node = new GatekeeperNode(service);
Map<String, Object> executorOutput = Map.of(
"answer_version", "executor_evidence_v2", "claims", List.of());
RunnableConfig config = RunnableConfig.builder()
.addMetadata("runId", "run-normalization")
.build();
when(service.validateRun(anyString(), anyMap(), anyMap()))
.thenReturn(Map.of(
"status", "pass",
"severity", "none",
"checked_bindings", List.of()))
.thenReturn(Map.of(
"status", "mystery",
"severity", "unknown",
"checked_bindings", List.of()));
Map<String, Object> pass = node.apply(new OverAllState(Map.of(
DiagnosisGraphState.EXECUTOR_OUTPUT, executorOutput)), config).get();
Map<String, Object> unknown = node.apply(new OverAllState(Map.of(
DiagnosisGraphState.EXECUTOR_OUTPUT, executorOutput)), config).get();
assertEquals("PASS", pass.get(DiagnosisGraphState.GATEKEEPER_STATUS));
assertEquals("PASS", pass.get(
DiagnosisGraphState.VERIFIER_VERDICT_CEILING));
assertEquals("REJECT", unknown.get(
DiagnosisGraphState.GATEKEEPER_STATUS));
}
@Test
void preservesRawLowConfidenceResultAndCountsPassedBindings() throws Exception {
ExecutorGatekeeperService service = mock(ExecutorGatekeeperService.class);
Map<String, Object> executorOutput = Map.of(
"answer_version", "executor_evidence_v2", "claims", List.of());
Map<String, Object> rawResult = Map.of(
"status", "fail",
"severity", "low_confid",
"checked_bindings", List.of(
Map.of("claim_id", "c1", "status", "pass"),
Map.of("claim_id", "c2", "status", "fail")),
"failed_rules", List.of("evidence.missing"));
when(service.validateRun("run-gatekeeper", executorOutput, Map.of(
"status", "valid", "detail", "graph executor completed")))
.thenReturn(rawResult);
GatekeeperNode node = new GatekeeperNode(service);
Map<String, Object> update = node.apply(
new OverAllState(Map.of(
DiagnosisGraphState.EXECUTOR_OUTPUT, executorOutput)),
RunnableConfig.builder()
.addMetadata("runId", "run-gatekeeper")
.build()).get();
assertSame(rawResult, update.get(DiagnosisGraphState.GATEKEEPER_RESULT));
assertEquals("LOW_CONFID", update.get(
DiagnosisGraphState.GATEKEEPER_STATUS));
assertEquals(1, update.get(DiagnosisGraphState.VERIFIED_BINDING_COUNT));
assertEquals("LOW_CONFID", update.get(
DiagnosisGraphState.VERIFIER_VERDICT_CEILING));
verify(service, times(1)).validateRun(
"run-gatekeeper", executorOutput, Map.of(
"status", "valid", "detail", "graph executor completed"));
}
}
@@ -0,0 +1,75 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.fasterxml.jackson.databind.JsonNode;
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.assertSame;
class PlannerNodeAdapterTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void projectsOnlyPlannerInputAndParsesCompletedPlan() throws Exception {
CapturingInvoker invoker = new CapturingInvoker("""
{
"selected_skill": null,
"selection_reason": "无需 skill",
"plan": ["查询连接池指标"],
"reasoning": "先确认现象"
}
""");
PlannerNodeAdapter adapter = new PlannerNodeAdapter(invoker);
RunnableConfig config = RunnableConfig.builder()
.threadId("run-planner")
.addMetadata("runId", "run-planner")
.build();
OverAllState state = new OverAllState(Map.of(
DiagnosisGraphState.DIAGNOSIS_CONTEXT, Map.of(
"query", "分析连接池耗尽",
"history", List.of()),
DiagnosisGraphState.PLANNER_MODE, "NORMAL",
DiagnosisGraphState.PLANNER_RETRY_COUNT, 0,
DiagnosisGraphState.EXECUTOR_OUTPUT, "must-not-leak"));
Map<String, Object> update = adapter.apply(state, config).get();
JsonNode input = objectMapper.readTree(invoker.input);
assertEquals("分析连接池耗尽",
input.path("diagnosis_context").path("query").asText());
assertEquals("NORMAL", input.path("planner_mode").asText());
assertFalse(input.has("executor_output"));
assertSame(config, invoker.config);
assertEquals("COMPLETED", update.get(DiagnosisGraphState.PLANNER_STATUS));
assertEquals(List.of("查询连接池指标"),
((Map<?, ?>) update.get(DiagnosisGraphState.PLANNER_PLAN)).get("plan"));
assertEquals(1, ((List<?>) update.get(
DiagnosisGraphState.ORCHESTRATION_EVENTS)).size());
}
private static final class CapturingInvoker implements DiagnosisAgentInvoker {
private final String output;
private String input;
private RunnableConfig config;
private CapturingInvoker(String output) {
this.output = output;
}
@Override
public String invoke(String input, RunnableConfig config) {
this.input = input;
this.config = config;
return output;
}
}
}
@@ -0,0 +1,30 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.alibaba.cloud.ai.graph.agent.ReactAgent;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ReactAgentDiagnosisInvokerTest {
@Test
void forwardsExactInputAndRunnableConfig() throws Exception {
ReactAgent agent = mock(ReactAgent.class);
RunnableConfig config = RunnableConfig.builder()
.threadId("run-invoker")
.addMetadata("runId", "run-invoker")
.build();
when(agent.call(eq("projected input"), same(config)))
.thenReturn(new AssistantMessage("agent output"));
DiagnosisAgentInvoker invoker = new ReactAgentDiagnosisInvoker(agent);
assertEquals("agent output", invoker.invoke("projected input", config));
}
}
@@ -0,0 +1,79 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
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;
class VerifiedInputNodeTest {
@Test
@SuppressWarnings("unchecked")
void projectsOnlyClaimsWithExactlyMatchedPassedBindings() throws Exception {
Map<String, Object> passedBinding = Map.of(
"tool_name", "query_metrics",
"source_invocation_id", 11L,
"raw_path", "$.alerts[0]",
"evidence_excerpt", "active=50 max=50");
Map<String, Object> failedBinding = Map.of(
"tool_name", "query_logs",
"source_invocation_id", 12L,
"raw_path", "$.logs[0]",
"evidence_excerpt", "suspected leak");
Map<String, Object> executorOutput = Map.of(
"answer_version", "executor_evidence_v2",
"claims", List.of(
Map.of("claim_id", "c1", "claim_text", "连接数达到上限",
"claim_type", "symptom", "support_level", "direct",
"evidence_bindings", List.of(passedBinding, failedBinding)),
Map.of("claim_id", "c2", "claim_text", "存在连接泄漏",
"claim_type", "root_cause", "support_level", "indirect",
"evidence_bindings", List.of(failedBinding))),
"hypotheses", List.of(Map.of("hypothesis_text", "must-not-leak")));
Map<String, Object> gatekeeper = Map.of(
"status", "fail",
"severity", "low_confid",
"checked_bindings", List.of(
Map.of("claim_id", "c1", "status", "pass",
"tool_name", "query_metrics",
"source_invocation_id", 11L,
"raw_path", "$.alerts[0]",
"matched_text", "active=50 max=50"),
Map.of("claim_id", "c1", "status", "fail",
"tool_name", "query_logs",
"source_invocation_id", 12L,
"raw_path", "$.logs[0]"),
Map.of("claim_id", "c3", "status", "pass",
"tool_name", "query_metrics",
"source_invocation_id", 99L,
"raw_path", "$.alerts[9]",
"matched_text", "unreferenced")));
VerifiedInputNode node = new VerifiedInputNode();
Map<String, Object> update = node.apply(new OverAllState(Map.of(
DiagnosisGraphState.EXECUTOR_OUTPUT, executorOutput,
DiagnosisGraphState.GATEKEEPER_RESULT, gatekeeper,
DiagnosisGraphState.EVIDENCE_RETRY_COUNT, 0)),
RunnableConfig.builder().threadId("run-verified").build()).get();
Map<String, Object> verifiedOutput = (Map<String, Object>) update.get(
DiagnosisGraphState.VERIFIED_EXECUTOR_OUTPUT);
List<Map<String, Object>> claims =
(List<Map<String, Object>>) verifiedOutput.get("claims");
assertEquals(List.of("c1"),
claims.stream().map(claim -> claim.get("claim_id")).toList());
assertEquals(List.of(passedBinding), claims.get(0).get("evidence_bindings"));
assertFalse(verifiedOutput.containsKey("hypotheses"));
List<Map<String, Object>> evidence =
(List<Map<String, Object>>) update.get(
DiagnosisGraphState.VERIFIED_EVIDENCE);
assertEquals(List.of("active=50 max=50"),
evidence.stream().map(item -> item.get("matched_text")).toList());
assertEquals(1, update.get(DiagnosisGraphState.VERIFIED_BINDING_COUNT));
}
}
@@ -0,0 +1,83 @@
package com.superbiz.agent.graph.diagnosis;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class VerifierNodeAdapterTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void separatesModelAndEffectiveVerdictWithStableWhitelistedInput() throws Exception {
CapturingInvoker invoker = new CapturingInvoker("""
{
"verdict": "PASS",
"groundedness_score": 0.9,
"critical_fact_count": 1,
"facts_checked": [{
"fact": "c1: 连接数达到上限",
"is_critical": true,
"verification": "direct_evidence",
"detail": "指标直接支持",
"evidence_refs": []
}],
"rationale": "证据充分"
}
""");
VerifierNodeAdapter adapter = new VerifierNodeAdapter(invoker);
Map<String, Object> common = Map.of(
DiagnosisGraphState.DIAGNOSIS_CONTEXT, Map.of("query", "分析连接池"),
DiagnosisGraphState.VERIFIED_EXECUTOR_OUTPUT, Map.of(
"answer_version", "executor_evidence_v2", "claims", List.of()),
DiagnosisGraphState.VERIFIED_EVIDENCE, List.of(Map.of(
"claim_id", "c1", "matched_text", "active=50 max=50")),
DiagnosisGraphState.GATEKEEPER_RESULT, Map.of(
"status", "fail", "severity", "low_confid"),
DiagnosisGraphState.VERIFIER_VERDICT_CEILING, "LOW_CONFID",
DiagnosisGraphState.EXECUTOR_OUTPUT, "must-not-leak");
Map<String, Object> first = adapter.apply(
new OverAllState(new java.util.LinkedHashMap<>(common)),
RunnableConfig.builder().threadId("run-verifier").build()).get();
Map<String, Object> retryState = new java.util.LinkedHashMap<>(common);
retryState.put(DiagnosisGraphState.VERIFIER_RETRY_COUNT, 1);
Map<String, Object> second = adapter.apply(
new OverAllState(retryState),
RunnableConfig.builder().threadId("run-verifier").build()).get();
JsonNode input = objectMapper.readTree(invoker.inputs.get(0));
assertFalse(input.has("executor_output"));
assertFalse(input.has("tool_trace_summary"));
assertEquals(invoker.inputs.get(0), invoker.inputs.get(1));
assertEquals("COMPLETED", first.get(DiagnosisGraphState.VERIFIER_STATUS));
assertEquals("PASS", first.get(DiagnosisGraphState.VERIFIER_MODEL_VERDICT));
assertEquals("LOW_CONFID", first.get(DiagnosisGraphState.EFFECTIVE_VERDICT));
assertEquals("COMPLETED", second.get(DiagnosisGraphState.VERIFIER_STATUS));
}
private static final class CapturingInvoker implements DiagnosisAgentInvoker {
private final String output;
private final List<String> inputs = new ArrayList<>();
private CapturingInvoker(String output) {
this.output = output;
}
@Override
public String invoke(String input, RunnableConfig config) {
inputs.add(input);
return output;
}
}
}