371 lines
18 KiB
Java
371 lines
18 KiB
Java
package com.superbiz.agent.service;
|
|
|
|
import com.superbiz.agent.domain.entity.ToolInvocation;
|
|
import com.superbiz.agent.repository.ToolInvocationRepository;
|
|
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;
|
|
import static org.mockito.Mockito.mock;
|
|
import static org.mockito.Mockito.when;
|
|
|
|
class ExecutorGatekeeperServiceTest {
|
|
|
|
@Test
|
|
void ruleCatalogLoadsDefaultMetadata() {
|
|
GatekeeperRuleCatalog catalog = GatekeeperRuleCatalog.loadDefault(new com.fasterxml.jackson.databind.ObjectMapper());
|
|
|
|
assertEquals("gatekeeper-rules-v1", catalog.version());
|
|
assertFalse(catalog.auditRules().isEmpty());
|
|
assertTrue(catalog.auditRules().stream()
|
|
.anyMatch(rule -> "evidence.raw_path".equals(rule.get("id"))));
|
|
assertEquals(0.5, catalog.doubleParameter("evidence.excerpt_mismatch",
|
|
"min_token_overlap", 0.0), 0.001);
|
|
}
|
|
|
|
@Test
|
|
void validatePassesForExecutorEvidenceV2WithMatchingInvocation() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_metrics", "$.alerts[0]",
|
|
"HighCPUUsage firing, service=payment-service, current=92%, duration=25m")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.validate("session-1",
|
|
validOutput(101L, "query_metrics", "$.alerts[0]",
|
|
"HighCPUUsage firing, service=payment-service, current=92%"),
|
|
Map.of("status", "valid"));
|
|
|
|
assertEquals("pass", result.get("status"));
|
|
assertEquals("none", result.get("severity"));
|
|
assertRuleAudit(result);
|
|
assertTrue(((List<?>) result.get("failed_rules")).isEmpty());
|
|
}
|
|
|
|
@Test
|
|
void validatePassesForNoEvidenceReference() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_logs", "$.no_evidence",
|
|
"query_logs returned no evidence; evidence_status=no_evidence; query=inventory-service HikariCP; topic=application-logs; total=0; message=未找到匹配的日志")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.validate("session-1",
|
|
validOutput(101L, "query_logs", "$.no_evidence",
|
|
"query_logs returned no evidence; query=inventory-service HikariCP; total=0; evidence_status=no_evidence"),
|
|
Map.of("status", "valid"));
|
|
|
|
assertEquals("pass", result.get("status"));
|
|
assertEquals("none", result.get("severity"));
|
|
assertRuleAudit(result);
|
|
assertTrue(((List<?>) result.get("failed_rules")).isEmpty());
|
|
}
|
|
|
|
@Test
|
|
void validateBackfillsNoEvidenceInvocationByRawPathWhenToolHasMultipleCalls() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_logs", "$.no_evidence",
|
|
"query_logs returned no evidence; evidence_status=no_evidence; query=inventory-service HikariCP; total=0; message=未找到匹配的日志"),
|
|
invocation(102L, "query_logs", "$.logs[0]",
|
|
"order-service HikariCP active=50/50 waiting=32")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.validate("session-1",
|
|
validOutput(null, "query_logs", "$.no_evidence",
|
|
"query_logs returned no evidence; query=inventory-service HikariCP; total=0; evidence_status=no_evidence",
|
|
"negative_observation"),
|
|
Map.of("status", "valid"));
|
|
|
|
assertEquals("pass", result.get("status"));
|
|
assertEquals("none", result.get("severity"));
|
|
assertTrue(((List<?>) result.get("failed_rules")).isEmpty());
|
|
assertEquals("evidence.invocation_auto_backfill_by_raw_path",
|
|
((Map<?, ?>) ((List<?>) result.get("warnings")).get(0)).get("rule"));
|
|
}
|
|
|
|
@Test
|
|
void validateBackfillsNoEvidenceInvocationByExcerptWhenRawPathIsRepeated() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_logs", "$.no_evidence",
|
|
"query_logs returned no evidence; evidence_status=no_evidence; query=service:inventory-service AND HikariCP; total=0; message=未找到匹配的日志"),
|
|
invocation(102L, "query_logs", "$.no_evidence",
|
|
"query_logs returned no evidence; evidence_status=no_evidence; query=inventory-service; total=0; message=未找到匹配的日志")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.validate("session-1",
|
|
validOutput(null, "query_logs", "$.no_evidence",
|
|
"query_logs returned no evidence; query=service:inventory-service AND HikariCP; total=0; evidence_status=no_evidence",
|
|
"negative_observation"),
|
|
Map.of("status", "valid"));
|
|
|
|
assertEquals("pass", result.get("status"));
|
|
assertEquals("none", result.get("severity"));
|
|
assertEquals(101L,
|
|
((Map<?, ?>) ((List<?>) result.get("checked_bindings")).get(0)).get("source_invocation_id"));
|
|
}
|
|
|
|
@Test
|
|
void validateRejectsNoEvidenceReferenceWhenExcerptClaimsPositiveEvidence() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_logs", "$.no_evidence",
|
|
"query_logs returned no evidence; evidence_status=no_evidence; query=inventory-service HikariCP; total=0; message=未找到匹配的日志")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.validate("session-1",
|
|
validOutput(101L, "query_logs", "$.no_evidence",
|
|
"HikariCP active=50/50 waiting=32"),
|
|
Map.of("status", "valid"));
|
|
|
|
assertEquals("fail", result.get("status"));
|
|
assertEquals("reject", result.get("severity"));
|
|
assertRuleAudit(result);
|
|
assertTrue(((List<?>) result.get("failed_rules")).contains("evidence.excerpt_mismatch"));
|
|
}
|
|
|
|
@Test
|
|
void validateRejectsPositiveBindingOnNegativeObservation() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_logs", "$.logs[0]",
|
|
"order-service HikariCP active=50/50 waiting=32")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.validate("session-1",
|
|
validOutput(101L, "query_logs", "$.logs[0]",
|
|
"order-service HikariCP active=50/50 waiting=32",
|
|
"negative_observation"),
|
|
Map.of("status", "valid"));
|
|
|
|
assertEquals("fail", result.get("status"));
|
|
assertEquals("reject", result.get("severity"));
|
|
assertTrue(((List<?>) result.get("failed_rules")).contains("evidence.raw_path"));
|
|
}
|
|
|
|
@Test
|
|
void validateFailsWhenRemovedFieldsArePresent() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of());
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
Map<String, Object> output = validOutput(101L, "query_metrics", "$.alerts[0]", "cpu=92");
|
|
output.put("user_facing_answer", "旧版最终答案");
|
|
|
|
Map<String, Object> result = service.validate("session-1", output, Map.of("status", "valid"));
|
|
|
|
assertEquals("fail", result.get("status"));
|
|
assertEquals("reject", result.get("severity"));
|
|
assertTrue(((List<?>) result.get("failed_rules")).contains("schema.executor_v2"));
|
|
}
|
|
|
|
@Test
|
|
void validateFailsForFabricatedInvocationId() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_metrics", "$.alerts[0]", "cpu=92")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.validate("session-1",
|
|
validOutput(999L, "query_metrics", "$.alerts[0]", "cpu=92"),
|
|
Map.of("status", "valid"));
|
|
|
|
assertEquals("fail", result.get("status"));
|
|
assertEquals("reject", result.get("severity"));
|
|
assertRuleAudit(result);
|
|
assertTrue(((List<?>) result.get("failed_rules")).contains("evidence.invocation_ref"));
|
|
}
|
|
|
|
@Test
|
|
void failResultIncludesRuleAuditMetadata() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.fail("gatekeeper.internal_error", "gatekeeper", "boom");
|
|
|
|
assertEquals("fail", result.get("status"));
|
|
assertEquals("reject", result.get("severity"));
|
|
assertRuleAudit(result);
|
|
}
|
|
|
|
@Test
|
|
void validateFailsForToolNameMismatch() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_logs", "$.logs[0]", "cpu=92")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.validate("session-1",
|
|
validOutput(101L, "query_metrics", "$.alerts[0]", "cpu=92"),
|
|
Map.of("status", "valid"));
|
|
|
|
assertEquals("fail", result.get("status"));
|
|
assertEquals("reject", result.get("severity"));
|
|
assertTrue(((List<?>) result.get("failed_rules")).contains("evidence.invocation_ref"));
|
|
}
|
|
|
|
@Test
|
|
void validateDowngradesMissingRawPathToLowConfidence() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_metrics", "$.alerts[0]", "cpu=92")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
Map<String, Object> output = validOutput(101L, "query_metrics", null, "cpu=92");
|
|
|
|
Map<String, Object> result = service.validate("session-1", output, Map.of("status", "valid"));
|
|
|
|
assertEquals("fail", result.get("status"));
|
|
assertEquals("low_confid", result.get("severity"));
|
|
assertTrue(((List<?>) result.get("failed_rules")).contains("evidence.raw_path"));
|
|
}
|
|
|
|
@Test
|
|
void validateRejectsUnknownRawPath() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_metrics", "$.alerts[0]", "cpu=92")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.validate("session-1",
|
|
validOutput(101L, "query_metrics", "$.alerts[99]", "cpu=92"),
|
|
Map.of("status", "valid"));
|
|
|
|
assertEquals("fail", result.get("status"));
|
|
assertEquals("reject", result.get("severity"));
|
|
assertTrue(((List<?>) result.get("failed_rules")).contains("evidence.raw_path"));
|
|
}
|
|
|
|
@Test
|
|
void validateDowngradesOldInvocationWithoutEvidenceRefsToLowConfidence() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
ToolInvocation.builder().id(101L).sessionId("session-1").toolName("query_metrics")
|
|
.retrievalDetails("{\"evidence_status\":\"supported\"}")
|
|
.build()
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.validate("session-1",
|
|
validOutput(101L, "query_metrics", "$.alerts[0]", "cpu=92"),
|
|
Map.of("status", "valid"));
|
|
|
|
assertEquals("fail", result.get("status"));
|
|
assertEquals("low_confid", result.get("severity"));
|
|
assertTrue(((List<?>) result.get("failed_rules")).contains("evidence.missing"));
|
|
}
|
|
|
|
|
|
@Test
|
|
void validateRejectsMismatchedExcerpt() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_metrics", "$.alerts[0]", "HighCPUUsage firing service payment-service current 92")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
|
|
Map<String, Object> result = service.validate("session-1",
|
|
validOutput(101L, "query_metrics", "$.alerts[0]", "HikariCP active=50/50 waiting=32"),
|
|
Map.of("status", "valid"));
|
|
|
|
assertEquals("fail", result.get("status"));
|
|
assertEquals("reject", result.get("severity"));
|
|
assertTrue(((List<?>) result.get("failed_rules")).contains("evidence.excerpt_mismatch"));
|
|
}
|
|
|
|
@Test
|
|
void validateFailsForRecommendedActionFabricatedInvocationId() {
|
|
ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
|
|
when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
|
|
invocation(101L, "query_metrics", "$.alerts[0]", "cpu=92")
|
|
));
|
|
ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository);
|
|
Map<String, Object> output = validOutput(101L, "query_metrics", "$.alerts[0]", "cpu=92");
|
|
output.put("recommended_actions", List.of(Map.of(
|
|
"action_text", "restart service",
|
|
"reason", "alert is firing",
|
|
"evidence_bindings", List.of(Map.of(
|
|
"source_type", "tool_trace",
|
|
"source_id", "trace-1",
|
|
"tool_name", "query_metrics",
|
|
"source_invocation_id", 999L,
|
|
"raw_path", "$.alerts[0]",
|
|
"evidence_excerpt", "cpu=92"
|
|
))
|
|
)));
|
|
|
|
Map<String, Object> result = service.validate("session-1", output, Map.of("status", "valid"));
|
|
|
|
assertEquals("fail", result.get("status"));
|
|
assertEquals("reject", result.get("severity"));
|
|
assertTrue(((List<?>) result.get("failed_rules")).contains("evidence.invocation_ref"));
|
|
}
|
|
|
|
private ToolInvocation invocation(Long id, String toolName, String rawPath, String text) {
|
|
return ToolInvocation.builder()
|
|
.id(id)
|
|
.sessionId("session-1")
|
|
.toolName(toolName)
|
|
.retrievalDetails("{\"evidence_refs\":[{\"raw_path\":\"" + rawPath
|
|
+ "\",\"text\":\"" + text + "\"}]}")
|
|
.build();
|
|
}
|
|
|
|
private Map<String, Object> validOutput(Long invocationId, String toolName, String rawPath, String excerpt) {
|
|
return validOutput(invocationId, toolName, rawPath, excerpt, "symptom");
|
|
}
|
|
|
|
private Map<String, Object> validOutput(Long invocationId,
|
|
String toolName,
|
|
String rawPath,
|
|
String excerpt,
|
|
String claimType) {
|
|
Map<String, Object> binding = new java.util.LinkedHashMap<>();
|
|
binding.put("source_type", "tool_trace");
|
|
binding.put("source_id", "trace-1");
|
|
binding.put("tool_name", toolName);
|
|
if (invocationId != null) {
|
|
binding.put("source_invocation_id", invocationId);
|
|
}
|
|
if (rawPath != null) {
|
|
binding.put("raw_path", rawPath);
|
|
}
|
|
binding.put("evidence_excerpt", excerpt);
|
|
return new java.util.LinkedHashMap<>(Map.of(
|
|
"answer_version", "executor_evidence_v2",
|
|
"claims", List.of(Map.of(
|
|
"claim_id", "claim-1",
|
|
"claim_type", claimType,
|
|
"claim_text", "连接池 active 达到上限",
|
|
"support_level", "direct",
|
|
"evidence_bindings", List.of(binding)
|
|
)),
|
|
"hypotheses", List.of(),
|
|
"recommended_actions", List.of(),
|
|
"missing_info", List.of()
|
|
));
|
|
}
|
|
|
|
private void assertRuleAudit(Map<String, Object> result) {
|
|
assertEquals("gatekeeper-rules-v1", result.get("rule_set_version"));
|
|
assertTrue(result.get("rules") instanceof List<?>);
|
|
List<?> rules = (List<?>) result.get("rules");
|
|
assertFalse(rules.isEmpty());
|
|
assertTrue(rules.stream().anyMatch(rule ->
|
|
rule instanceof Map<?, ?> map && "evidence.raw_path".equals(map.get("id"))));
|
|
}
|
|
}
|