feat(graph): add diagnosis real nodes
This commit is contained in:
@@ -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")));
|
||||
}
|
||||
|
||||
|
||||
+24
@@ -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")));
|
||||
}
|
||||
}
|
||||
+399
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user