diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriteriaCodec.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriteriaCodec.java index f059c6f1..470cd6f8 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriteriaCodec.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriteriaCodec.java @@ -58,8 +58,8 @@ public final class GoalCriteriaCodec { /** * Merge a per-round verdict delta into the full checklist by id. Criteria - * absent from the delta are preserved unchanged; the criterion text is - * always kept from the existing item (the verdict never carries text). + * absent from the delta retain their state unless their pass lacks evidence. + * The criterion text is always kept from the existing item (the verdict never carries text). */ public static List merge(List existing, List verdicts) { @@ -77,18 +77,22 @@ public final class GoalCriteriaCodec { List merged = new ArrayList<>(existing.size()); for (GoalCriterion c : existing) { GoalChecklistVerdict.CriterionVerdict v = byId.get(c.id()); - merged.add(v == null - ? c + GoalCriterion candidate = v == null ? c : new GoalCriterion(c.id(), c.text(), v.passed(), - v.evidence() != null ? v.evidence() : "")); + v.evidence() != null ? v.evidence() : ""); + // A model boolean alone cannot satisfy even the legacy semantic + // checklist. This checks presence, not truth or execution provenance. + merged.add(candidate.passed() && !hasEvidence(candidate) + ? new GoalCriterion(candidate.id(), candidate.text(), false, candidate.evidence()) + : candidate); } return merged; } - /** True only when the list is non-empty and every criterion is passed. */ + /** True only when the list is non-empty and every criterion is passed with nonblank evidence. */ public static boolean allPassed(List criteria) { return criteria != null && !criteria.isEmpty() - && criteria.stream().allMatch(GoalCriterion::passed); + && criteria.stream().allMatch(c -> c != null && c.passed() && hasEvidence(c)); } /** Criteria not yet passed (used for the continuation prompt + gap text). */ @@ -96,7 +100,11 @@ public final class GoalCriteriaCodec { if (criteria == null) { return List.of(); } - return criteria.stream().filter(c -> !c.passed()).toList(); + return criteria.stream().filter(c -> !c.passed() || !hasEvidence(c)).toList(); + } + + private static boolean hasEvidence(GoalCriterion criterion) { + return criterion.evidence() != null && !criterion.evidence().isBlank(); } /** Reassign stable ids {@code C1..Cn} in list order. */ diff --git a/mateclaw-server/src/test/java/vip/mate/goal/model/GoalCriteriaCodecTest.java b/mateclaw-server/src/test/java/vip/mate/goal/model/GoalCriteriaCodecTest.java index 48f82af2..44bd3ace 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/model/GoalCriteriaCodecTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/model/GoalCriteriaCodecTest.java @@ -2,6 +2,9 @@ package vip.mate.goal.model; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import java.util.List; @@ -77,6 +80,23 @@ class GoalCriteriaCodecTest { assertFalse(merged.get(0).passed()); } + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t\n"}) + void blankEvidenceCannotPassNewOrPersistedCriteria(String evidence) { + var existing = List.of(new GoalCriterion("C1", "deliver report", false, "")); + var merged = GoalCriteriaCodec.merge(existing, List.of( + new GoalChecklistVerdict.CriterionVerdict("C1", true, evidence))); + assertFalse(merged.getFirst().passed()); + assertFalse(GoalCriteriaCodec.allPassed(merged)); + assertEquals(1, GoalCriteriaCodec.remaining(merged).size()); + + var persisted = List.of(new GoalCriterion("C1", "deliver report", true, evidence)); + assertFalse(GoalCriteriaCodec.allPassed(persisted)); + assertEquals(1, GoalCriteriaCodec.remaining(persisted).size()); + assertFalse(GoalCriteriaCodec.merge(persisted, List.of()).getFirst().passed()); + } + // ---------- allPassed / remaining ---------- @Test diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java index e254b499..5b6c266b 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java @@ -94,6 +94,20 @@ class GoalEvaluationServiceTest { when(chatModel.call(any(Prompt.class))).thenReturn(response); } + @Test + void blankEvidenceVerdictCannotCompleteOrInflateProgress() { + stubChatResponse(""" + {"criterionVerdicts":[ + {"id":"C1","passed":true,"evidence":""}, + {"id":"C2","passed":true,"evidence":null}],"summary":"done"} + """); + var result = svc.evaluate(goalWithCriteria(), List.of(), "All done"); + assertFalse(result.completed()); + assertEquals(0.0, result.score()); + assertTrue(result.gap().contains("DNS configured")); + assertTrue(result.gap().contains("TLS enabled")); + } + // ==================== Pre-flight guards ==================== @Test