feat(harness): add evidence and semantic guards

This commit is contained in:
zhuyongxin
2026-07-21 23:50:51 +08:00
parent 2362665519
commit ee0949d464
40 changed files with 2980 additions and 2 deletions
@@ -0,0 +1,44 @@
package com.superbiz.agent.harness.release;
import com.superbiz.agent.harness.contract.DiagnosisDraft;
import com.superbiz.agent.harness.contract.ReleaseOutcome;
import com.superbiz.agent.harness.contract.SafeFallback;
import com.superbiz.agent.harness.guard.evidence.VerifiedEvidenceSnapshot;
import java.util.Objects;
public record DiagnosisReleaseResult(
ReleaseOutcome outcome,
DiagnosisDraft draft,
SafeFallback fallback,
VerifiedEvidenceSnapshot verifiedEvidence) {
public DiagnosisReleaseResult {
Objects.requireNonNull(outcome, "outcome must not be null");
Objects.requireNonNull(verifiedEvidence, "verifiedEvidence must not be null");
if (outcome == ReleaseOutcome.SUCCESS) {
Objects.requireNonNull(draft, "successful release requires draft");
if (fallback != null) {
throw new IllegalArgumentException("successful release must not contain fallback");
}
} else if (outcome == ReleaseOutcome.FALLBACK) {
Objects.requireNonNull(fallback, "fallback release requires fallback");
if (draft != null) {
throw new IllegalArgumentException("fallback release must not contain draft");
}
} else {
throw new IllegalArgumentException("release use case supports SUCCESS or FALLBACK only");
}
}
public static DiagnosisReleaseResult success(
DiagnosisDraft draft, VerifiedEvidenceSnapshot verifiedEvidence) {
return new DiagnosisReleaseResult(
ReleaseOutcome.SUCCESS, draft, null, verifiedEvidence);
}
public static DiagnosisReleaseResult fallback(SafeFallback fallback) {
return new DiagnosisReleaseResult(
ReleaseOutcome.FALLBACK, null, fallback, VerifiedEvidenceSnapshot.empty());
}
}
@@ -0,0 +1,91 @@
package com.superbiz.agent.harness.release;
import com.superbiz.agent.harness.contract.DiagnosisDraft;
import com.superbiz.agent.harness.contract.SemanticVerdict;
import com.superbiz.agent.harness.core.BudgetExceededException;
import com.superbiz.agent.harness.core.RunAbortedException;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.guard.evidence.EvidenceGuard;
import com.superbiz.agent.harness.guard.evidence.EvidenceGuardResult;
import com.superbiz.agent.harness.guard.evidence.VerifiedEvidenceSnapshot;
import com.superbiz.agent.harness.guard.semantic.SemanticGuard;
import com.superbiz.agent.harness.guard.semantic.SemanticGuardDecision;
import com.superbiz.agent.harness.guard.semantic.SemanticGuardInput;
import com.superbiz.agent.harness.retry.RetryExecutionException;
import com.superbiz.agent.harness.retry.RetryFailure;
import java.util.Objects;
public final class DiagnosisReleaseUseCase {
private final EvidenceGuard evidenceGuard;
private final EvidenceRepair evidenceRepair;
private final SemanticGuard semanticGuard;
private final SafeFallbackFactory fallbackFactory;
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
EvidenceRepair evidenceRepair,
SemanticGuard semanticGuard,
SafeFallbackFactory fallbackFactory) {
this.evidenceGuard = Objects.requireNonNull(evidenceGuard, "evidenceGuard must not be null");
this.evidenceRepair = Objects.requireNonNull(evidenceRepair, "evidenceRepair must not be null");
this.semanticGuard = Objects.requireNonNull(semanticGuard, "semanticGuard must not be null");
this.fallbackFactory = Objects.requireNonNull(
fallbackFactory, "fallbackFactory must not be null");
}
public DiagnosisReleaseResult execute(RunContext context, String query, DiagnosisDraft draft) {
Objects.requireNonNull(context, "context must not be null");
if (query == null || query.isBlank()) {
throw new IllegalArgumentException("query must not be blank");
}
Objects.requireNonNull(draft, "draft must not be null");
DiagnosisDraft candidate = draft;
EvidenceGuardResult evidence = evidenceGuard.validate(context, candidate);
if (!evidence.valid()) {
try {
candidate = evidenceRepair.repair(context, query, draft, evidence.violations());
evidence = evidenceGuard.validate(context, candidate);
} catch (RuntimeException exception) {
propagateTerminal(exception);
return evidenceFailure();
}
if (!evidence.valid()) {
return evidenceFailure();
}
}
VerifiedEvidenceSnapshot snapshot = evidence.verifiedSnapshot().orElseThrow();
SemanticGuardDecision decision;
try {
decision = semanticGuard.review(
context, SemanticGuardInput.from(query, candidate, snapshot));
} catch (RuntimeException exception) {
propagateTerminal(exception);
return DiagnosisReleaseResult.fallback(
fallbackFactory.semanticUnavailable(snapshot));
}
return decision.verdict() == SemanticVerdict.SUPPORTED
? DiagnosisReleaseResult.success(candidate, snapshot)
: DiagnosisReleaseResult.fallback(
fallbackFactory.semanticUnsupported(snapshot));
}
private DiagnosisReleaseResult evidenceFailure() {
return DiagnosisReleaseResult.fallback(
fallbackFactory.evidenceValidationFailed());
}
private void propagateTerminal(RuntimeException exception) {
if (exception instanceof RunAbortedException
|| exception instanceof BudgetExceededException) {
throw exception;
}
if (exception instanceof RetryExecutionException retry
&& (retry.failure() == RetryFailure.CANCELLED
|| retry.failure() == RetryFailure.BUDGET_EXHAUSTED)) {
throw retry;
}
}
}
@@ -0,0 +1,125 @@
package com.superbiz.agent.harness.release;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.superbiz.agent.harness.contract.DiagnosisDraft;
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.guard.evidence.EvidenceViolation;
import com.superbiz.agent.harness.guard.semantic.GuardModelCall;
import com.superbiz.agent.harness.guard.semantic.GuardModelCallException;
import com.superbiz.agent.harness.guard.semantic.SemanticDraftView;
import com.superbiz.agent.harness.retry.HarnessRetryExecutor;
import com.superbiz.agent.harness.retry.RetryAttempt;
import com.superbiz.agent.harness.retry.RetryFailure;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
public final class EvidenceRepair {
private final DiagnosisHarnessCore core;
private final HarnessRetryExecutor retryExecutor;
private final GuardModelCall modelCall;
private final ObjectMapper objectMapper;
private final ObjectReader draftReader;
private final EvidenceRepairLimits limits;
private final Consumer<RetryAttempt> attemptRecorder;
private final String prompt;
public EvidenceRepair(DiagnosisHarnessCore core,
HarnessRetryExecutor retryExecutor,
GuardModelCall modelCall,
ObjectMapper objectMapper,
EvidenceRepairLimits limits,
Consumer<RetryAttempt> attemptRecorder) {
this.core = Objects.requireNonNull(core, "core must not be null");
this.retryExecutor = Objects.requireNonNull(retryExecutor, "retryExecutor must not be null");
this.modelCall = Objects.requireNonNull(modelCall, "modelCall must not be null");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
this.draftReader = objectMapper.readerFor(DiagnosisDraft.class)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
this.limits = Objects.requireNonNull(limits, "limits must not be null");
this.attemptRecorder = Objects.requireNonNull(
attemptRecorder, "attemptRecorder must not be null");
this.prompt = EvidenceRepairPrompt.load();
}
public DiagnosisDraft repair(RunContext context, String query, DiagnosisDraft original,
List<EvidenceViolation> violations) {
Objects.requireNonNull(context, "context must not be null");
if (query == null || query.isBlank()) {
throw new IllegalArgumentException("query must not be blank");
}
Objects.requireNonNull(original, "original must not be null");
List<EvidenceViolation> safeViolations = violations == null
? List.of() : List.copyOf(violations);
String inputJson = serialize(new RepairInput(query, original, safeViolations));
long inputBytes = utf8Bytes(inputJson);
if (inputBytes > limits.maxInputBytes()) {
throw new GuardModelCallException(
RetryFailure.SCHEMA_INVALID, "Evidence repair input exceeded limit");
}
core.reserveRunBytes(context, inputBytes);
Prompt modelPrompt = new Prompt(List.of(
new SystemMessage(prompt), new UserMessage(inputJson)));
SemanticDraftView originalSemantics = SemanticDraftView.from(original);
return retryExecutor.execute(
context,
context.retryPolicies().evidenceRepair(),
() -> {
DiagnosisDraft repaired = parse(modelCall.call(
context, modelPrompt, limits.timeout(), limits.maxOutputBytes()));
if (!originalSemantics.hasSameUserVisibleSemantics(
SemanticDraftView.from(repaired))) {
throw new GuardModelCallException(
RetryFailure.SCHEMA_INVALID,
"Evidence repair changed user-visible semantics");
}
return repaired;
},
this::classify,
attemptRecorder);
}
private DiagnosisDraft parse(String output) {
try {
return draftReader.readValue(output);
} catch (JsonProcessingException exception) {
throw new GuardModelCallException(
RetryFailure.PARSE_ERROR, "Evidence repair output is invalid", exception);
}
}
private RetryFailure classify(Exception exception) {
return exception instanceof GuardModelCallException failure
? failure.failure() : RetryFailure.UNKNOWN;
}
private String serialize(RepairInput input) {
try {
return objectMapper.writeValueAsString(input);
} catch (JsonProcessingException exception) {
throw new GuardModelCallException(
RetryFailure.SCHEMA_INVALID, "Evidence repair input is not serializable", exception);
}
}
private static long utf8Bytes(String value) {
return value.getBytes(StandardCharsets.UTF_8).length;
}
private record RepairInput(
String query,
DiagnosisDraft draft,
List<EvidenceViolation> violations) {
}
}
@@ -0,0 +1,21 @@
package com.superbiz.agent.harness.release;
import java.time.Duration;
import java.util.Objects;
public record EvidenceRepairLimits(
long maxInputBytes,
long maxOutputBytes,
Duration timeout) {
public EvidenceRepairLimits {
if (maxInputBytes <= 0 || maxOutputBytes <= 0) {
throw new IllegalArgumentException("byte limits must be positive");
}
Objects.requireNonNull(timeout, "timeout must not be null");
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("timeout must be positive");
}
timeout.toNanos();
}
}
@@ -0,0 +1,24 @@
package com.superbiz.agent.harness.release;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
final class EvidenceRepairPrompt {
private static final String RESOURCE_PATH = "prompts/evidence-repair-prompt.md";
private EvidenceRepairPrompt() {
}
static String load() {
ClassPathResource resource = new ClassPathResource(RESOURCE_PATH);
try (InputStream input = resource.getInputStream()) {
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new IllegalStateException("Failed to load evidence repair prompt", exception);
}
}
}
@@ -0,0 +1,51 @@
package com.superbiz.agent.harness.release;
import com.superbiz.agent.harness.contract.FallbackType;
import com.superbiz.agent.harness.contract.SafeFallback;
import com.superbiz.agent.harness.guard.evidence.VerifiedEvidenceSnapshot;
import java.util.List;
import java.util.Objects;
public final class SafeFallbackFactory {
public SafeFallback evidenceValidationFailed() {
return fallback(
FallbackType.EVIDENCE_VALIDATION_FAILED,
List.of(),
"当前证据无法完成真实性校验,无法确认根因",
"证据引用校验未通过",
"重新收集当前诊断范围内的证据后再发起诊断");
}
public SafeFallback semanticUnsupported(VerifiedEvidenceSnapshot snapshot) {
return fallback(
FallbackType.SEMANTIC_UNSUPPORTED,
sources(snapshot),
"当前证据不足,无法确认根因",
"语义校验未通过",
"补充当前缺失的数据后重新发起诊断");
}
public SafeFallback semanticUnavailable(VerifiedEvidenceSnapshot snapshot) {
return fallback(
FallbackType.SEMANTIC_UNAVAILABLE,
sources(snapshot),
"当前证据暂时无法完成语义校验,无法确认根因",
"语义校验暂不可用",
"稍后重试或补充当前缺失的数据");
}
private SafeFallback fallback(FallbackType type,
List<SafeFallback.VerifiedSource> sources,
String message,
String limitation,
String nextStep) {
return new SafeFallback(
type, null, message, sources, List.of(limitation), List.of(nextStep));
}
private List<SafeFallback.VerifiedSource> sources(VerifiedEvidenceSnapshot snapshot) {
return Objects.requireNonNull(snapshot, "snapshot must not be null").verifiedSources();
}
}