diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index 66b5f80e..b83ecbbe 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -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 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 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 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()); } diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java index 5e6dea56..75360adb 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java @@ -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() { diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java index 476117ad..7c550559 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java @@ -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 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