fix: derive goal progress from the current merged checklist

This commit is contained in:
mateaix 2026-09-13 22:04:57 +08:00
parent c024e2d9f0
commit 6a00c3869d
3 changed files with 68 additions and 4 deletions

View File

@ -473,8 +473,6 @@ public class GoalServiceImpl implements GoalService {
// persistent pause/input boundary established while the call ran.
if (result != null && (!Boolean.TRUE.equals(fresh.getPersistentExecution())
|| fresh.getStatus() == GoalStatus.ACTIVE)) {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
// Persist the checklist by carrier: bootstrap writes the fresh
// draft; verdict merges the per-criterion delta into the
// current list (re-read on the locked `fresh` to avoid races).
@ -482,6 +480,21 @@ public class GoalServiceImpl implements GoalService {
if (criteriaJson != null) {
w.set(GoalEntity::getCriteria, criteriaJson);
}
List<GoalCriterion> current = GoalCriteriaCodec.parse(
criteriaJson != null ? criteriaJson : fresh.getCriteria(), objectMapper);
if (!current.isEmpty() && !GoalEvaluationResult.DECISION_FALLBACK.equals(result.decision())) {
// The model may have seen fewer criteria. Derive the public
// projection from the same fresh checklist this CAS writes.
List<GoalCriterion> remaining = GoalCriteriaCodec.remaining(current);
double score = (double) (current.size() - remaining.size()) / current.size();
String gap = remaining.isEmpty() ? "" : "Still missing: " + remaining.stream()
.map(GoalCriterion::text).collect(java.util.stream.Collectors.joining("; "));
w.set(GoalEntity::getCompletionScore, score)
.set(GoalEntity::getProgressSummary, gap);
} else {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
}
}
bumpVersionAndTime(w);
return w;
@ -489,9 +502,11 @@ public class GoalServiceImpl implements GoalService {
Map<String, Object> detail = new LinkedHashMap<>();
if (result != null) {
detail.put("completionScore", result.score());
detail.put("gap", result.gap());
detail.put("completionScore", g.getCompletionScore());
detail.put("gap", g.getProgressSummary());
detail.put("decision", result.decision());
detail.put("evaluatorScore", result.score());
detail.put("evaluatorGap", result.gap());
detail.put("evaluatorModel", result.evaluatorModel());
detail.put("latencyMs", result.latencyMs());
}

View File

@ -95,6 +95,30 @@ class GoalPersistenceIntegrationTest {
assertEquals(2, saved.getAgentLlmCallsUsed());
}
@Test
void lateVerdictProjectsProgressFromCurrentChecklist() throws Exception {
GoalEntity created = goalService.create(req("late-verdict-current-progress", "prepare a report"), "alice");
goalService.appendCriterion(created.getId(), "write the report", "alice");
// The model evaluated only C1. A second condition commits before the result.
goalService.appendCriterion(created.getId(), "include an appendix", "alice");
var delayed = new vip.mate.goal.model.GoalEvaluationResult(1.0, "", "completed", true,
"fixture", 1, 0, java.util.List.of(
new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict("C1", true, "report written")), null);
goalService.recordEvaluation(created.getId(), delayed, 2, 1);
GoalEntity saved = goalService.getById(created.getId());
assertEquals(0.5, saved.getCompletionScore());
var event = goalService.listEvents(created.getId(), 20).stream()
.filter(e -> "evaluated".equals(e.getEventType())).findFirst().orElseThrow();
var detail = new com.fasterxml.jackson.databind.ObjectMapper().readTree(event.getDetailJson());
assertEquals(0.5, detail.get("completionScore").asDouble());
assertEquals(1.0, detail.get("evaluatorScore").asDouble());
org.junit.jupiter.api.Assertions.assertTrue(detail.get("gap").asText().contains("include an appendix"));
org.junit.jupiter.api.Assertions.assertTrue(saved.getProgressSummary().contains("include an appendix"));
assertEquals(GoalStatus.ACTIVE, saved.getStatus());
assertEquals(1, saved.getEvalLlmCallsUsed());
assertThrows(MateClawException.class, () -> goalService.markEvaluatedCompleted(created.getId(), delayed));
}
@Test
@DisplayName("GoalStatus values persist as lowercase literals — load-bearing for uk_agent_goal_active_conv")
void status_persistsAsLowercaseString() {

View File

@ -665,6 +665,31 @@ class GoalServiceTest {
assertFalse(setsProperty(writes.getAllValues().get(1), "criteria"), "retry must not replace newly established criteria");
}
@Test
void verdictProgressRecomputesAfterCasConflict() {
GoalEntity original = persisted(1L, GoalStatus.ACTIVE);
original.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":false,\"evidence\":\"\"}]");
GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE);
fresh.setVersion(1);
fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":false,\"evidence\":\"\"},"
+ "{\"id\":\"C2\",\"text\":\"appendix\",\"passed\":false,\"evidence\":\"\"}]");
when(goalMapper.selectById(1L)).thenReturn(original, original, fresh, fresh);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0, 1);
var result = new GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0,
java.util.List.of(new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict(
"C1", true, "report written")), null);
service.recordEvaluation(1L, result, 2, 1);
ArgumentCaptor<LambdaUpdateWrapper> writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(goalMapper, times(2)).update(any(), writes.capture());
var first = writes.getAllValues().getFirst();
var retry = writes.getAllValues().getLast();
assertTrue(setsProperty(first, "completionScore"));
assertTrue(setsProperty(retry, "completionScore"));
assertTrue(first.getParamNameValuePairs().containsValue(1.0));
assertTrue(retry.getParamNameValuePairs().containsValue(0.5));
assertTrue(retry.getParamNameValuePairs().containsValue("Still missing: appendix"));
}
// ==================== criteria checklist ====================
@Test