fix(goal): refresh progress when requirements are appended

This commit is contained in:
mateaix 2026-09-13 23:46:31 +08:00
parent 47066c6091
commit 0498530f3a
3 changed files with 52 additions and 6 deletions

View File

@ -549,12 +549,7 @@ public class GoalServiceImpl implements GoalService {
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);
setChecklistProgress(w, current);
} else {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
@ -675,6 +670,7 @@ public class GoalServiceImpl implements GoalService {
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getCriteria, criteriaJson)
.set(GoalEntity::getExitCriteria, mergedText);
setChecklistProgress(w, list);
bumpVersionAndTime(w);
return w;
});
@ -688,6 +684,16 @@ public class GoalServiceImpl implements GoalService {
return g;
}
/** Both evaluation and user edits project the checklist written by this CAS. */
private static void setChecklistProgress(LambdaUpdateWrapper<GoalEntity> update, List<GoalCriterion> criteria) {
List<GoalCriterion> remaining = GoalCriteriaCodec.remaining(criteria);
double score = criteria.isEmpty() ? 0.0 : (double) (criteria.size() - remaining.size()) / criteria.size();
String gap = remaining.isEmpty() ? "" : "Still missing: " + remaining.stream()
.map(GoalCriterion::text).collect(java.util.stream.Collectors.joining("; "));
update.set(GoalEntity::getCompletionScore, score)
.set(GoalEntity::getProgressSummary, gap);
}
// ==================== Internals ====================
/**

View File

@ -238,6 +238,24 @@ class GoalPersistenceIntegrationTest {
return goalService.getById(goal.getId());
}
@Test
void appendedRequirementImmediatelyRefreshesProgressWithoutLosingPriorEvidence() {
GoalEntity goal = readyForCompletion("append-progress", "report");
assertEquals(1.0, goal.getCompletionScore());
GoalEntity appended = goalService.appendCriterion(goal.getId(), "appendix", "alice");
assertEquals(0.5, appended.getCompletionScore());
assertEquals("Still missing: appendix", appended.getProgressSummary());
var criteria = vip.mate.goal.model.GoalCriteriaCodec.parse(appended.getCriteria(), new com.fasterxml.jackson.databind.ObjectMapper());
assertEquals(true, criteria.getFirst().passed());
assertEquals("report evidence", criteria.getFirst().evidence());
assertEquals(false, criteria.getLast().passed());
assertEquals(goal.getEvaluationRevision(), appended.getEvaluationRevision());
GoalEntity again = goalService.appendCriterion(goal.getId(), "sources", "alice");
assertEquals(1.0 / 3, again.getCompletionScore(), 0.00001);
assertEquals("Still missing: appendix; sources", again.getProgressSummary());
assertEquals(GoalStatus.ACTIVE, goalService.getById(goal.getId()).getStatus());
}
@Test
void rolledBackCompletionDoesNotSyncMemory() {
GoalEntity goal = readyForCompletion("completion-memory-rollback", "report");

View File

@ -627,6 +627,28 @@ class GoalServiceTest {
assertTrue(evCaptor.getValue().getDetailJson().contains("tests pass"));
}
@Test
void appendProgressUsesFreshChecklistAfterCasConflict() {
GoalEntity original = persisted(1L, GoalStatus.ACTIVE);
original.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":true,\"evidence\":\"report written\"}]");
GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE);
fresh.setVersion(1);
fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":true,\"evidence\":\"report written\"},"
+ "{\"id\":\"C2\",\"text\":\"sources\",\"passed\":false,\"evidence\":\"\"}]");
when(goalMapper.selectById(1L)).thenReturn(original, fresh, fresh);
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0, 1);
service.appendCriterion(1L, "appendix", "alice");
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(0.5));
assertTrue(retry.getParamNameValuePairs().containsValue(1.0 / 3));
assertTrue(retry.getParamNameValuePairs().containsValue("Still missing: sources; appendix"));
}
@Test
void appendCriterion_rejectsBlankInput() {
// Validation happens before selectById, so we do NOT stub the mapper.