feat(goal): persist checklist end-to-end + GoalResponse wire DTO

This commit is contained in:
matevip 2026-06-03 21:19:26 +08:00
parent b65887f93d
commit 0fc8579a3e
6 changed files with 203 additions and 54 deletions

View File

@ -25,9 +25,8 @@ import java.util.Optional;
/**
* Sits between FinalAnswerNode (or PlanSummaryNode) and the graph END.
*
* <p>Per RFC 48 v3 §3.3, evaluation runs on a settled terminal answer so
* upstream finishReason / evidence checks are already authoritative. The
* node:
* <p>Evaluation runs on a settled terminal answer so upstream finishReason /
* evidence checks are already authoritative. The node:
* <ol>
* <li>Bails out for the "this turn shouldn't count" finishReasons
* (evidence_insufficient, stopped, error_fallback, return_direct,
@ -50,8 +49,8 @@ public class GoalEvaluationNode implements NodeAction {
private final GoalFollowupService followupService;
private final GoalService goalService;
private final GoalProperties properties;
private final ConversationWindowManager windowManager; // unused PR2, kept for PR5
private final ConversationService conversationService; // unused PR2, kept for PR5
private final ConversationWindowManager windowManager; // reserved for evaluator context windowing
private final ConversationService conversationService; // reserved for evaluator context lookups
private final GraphFlavor flavor;
public GoalEvaluationNode(GoalEvaluationService evaluationService,
@ -72,7 +71,7 @@ public class GoalEvaluationNode implements NodeAction {
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
// Master kill switch node stays inert until PR5 flips this.
// Master kill switch when disabled the node stays inert.
if (!properties.isEnabled()) {
return Map.of();
}
@ -187,29 +186,31 @@ public class GoalEvaluationNode implements NodeAction {
// and abort the streamed answer the user already sees.
try {
if (result.completed() || result.score() >= 0.95) {
goalService.markCompleted(refreshed.getId(), result);
GoalEntity completed = goalService.markCompleted(refreshed.getId(), result);
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_completed", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"score", result.score()))))
"goalId", String.valueOf(completed.getId()),
"score", result.score(),
"goal", goalService.toResponse(completed)))))
.build();
}
if (goalService.isBudgetExhausted(refreshed)) {
String reason = goalService.exhaustionReason(refreshed);
goalService.markExhausted(refreshed.getId(), reason);
GoalEntity exhausted = goalService.markExhausted(refreshed.getId(), reason);
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_exhausted", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"turnsUsed", refreshed.getTurnsUsed(),
"agentLlmCallsUsed", refreshed.getAgentLlmCallsUsed(),
"evalLlmCallsUsed", refreshed.getEvalLlmCallsUsed(),
"totalLlmCallsUsed", refreshed.totalLlmCallsUsed(),
"reason", reason))))
"goalId", String.valueOf(exhausted.getId()),
"turnsUsed", exhausted.getTurnsUsed(),
"agentLlmCallsUsed", exhausted.getAgentLlmCallsUsed(),
"evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(),
"totalLlmCallsUsed", exhausted.totalLlmCallsUsed(),
"reason", reason,
"goal", goalService.toResponse(exhausted)))))
.build();
}
} catch (Throwable t) {
@ -270,7 +271,8 @@ public class GoalEvaluationNode implements NodeAction {
.needsToolCall(false)
.events(List.of(goalEvent("goal_followup", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"prompt", followup.get()))));
"prompt", followup.get(),
"goal", goalService.toResponse(refreshed)))));
if (flavor == GraphFlavor.REACT) {
// ReAct: append the followup as a fresh user message via the
@ -309,7 +311,8 @@ public class GoalEvaluationNode implements NodeAction {
.events(List.of(goalEvent("goal_evaluated", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"score", result.score(),
"gap", result.gap() == null ? "" : result.gap()))))
"gap", result.gap() == null ? "" : result.gap(),
"goal", goalService.toResponse(refreshed)))))
.build();
}

View File

@ -18,6 +18,7 @@ import vip.mate.exception.MateClawException;
import vip.mate.goal.model.GoalCreateRequest;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEventEntity;
import vip.mate.goal.model.GoalResponse;
import vip.mate.goal.model.GoalUpdateRequest;
import vip.mate.goal.service.GoalService;
import vip.mate.workspace.conversation.ConversationService;
@ -51,7 +52,7 @@ public class GoalController {
@Operation(summary = "Create a persistent goal for a conversation")
@PostMapping
public R<GoalEntity> create(@RequestBody GoalCreateRequest req, Authentication auth) {
public R<GoalResponse> create(@RequestBody GoalCreateRequest req, Authentication auth) {
String username = currentUsername(auth);
requireOwner(req.getConversationId(), username);
// Derive agentId/workspaceId from the conversation itself so the
@ -71,22 +72,22 @@ public class GoalController {
req.setAgentId(conv.getAgentId());
req.setWorkspaceId(conv.getWorkspaceId() != null ? conv.getWorkspaceId() : 1L);
GoalEntity g = goalService.create(req, username);
return R.ok(g);
return R.ok(goalService.toResponse(g));
}
@Operation(summary = "Get the active goal bound to a conversation (or null)")
@GetMapping("/by-conversation/{conversationId}")
public R<GoalEntity> findActive(@PathVariable String conversationId, Authentication auth) {
public R<GoalResponse> findActive(@PathVariable String conversationId, Authentication auth) {
requireOwner(conversationId, currentUsername(auth));
return R.ok(goalService.findActiveByConversation(conversationId));
return R.ok(goalService.toResponse(goalService.findActiveByConversation(conversationId)));
}
@Operation(summary = "Get goal detail by id")
@GetMapping("/{id}")
public R<GoalEntity> get(@PathVariable Long id, Authentication auth) {
public R<GoalResponse> get(@PathVariable Long id, Authentication auth) {
GoalEntity g = goalService.getById(id);
requireOwner(g.getConversationId(), currentUsername(auth));
return R.ok(g);
return R.ok(goalService.toResponse(g));
}
@Operation(summary = "Get the event timeline for a goal")
@ -101,61 +102,61 @@ public class GoalController {
@Operation(summary = "List goals (optionally filtered by status)")
@GetMapping
public R<List<GoalEntity>> list(@RequestParam(required = false) String status,
public R<List<GoalResponse>> list(@RequestParam(required = false) String status,
@RequestParam(defaultValue = "50") int limit,
Authentication auth) {
// List is owner-scoped only your own goals are visible.
return R.ok(goalService.list(status, currentUsername(auth), limit));
return R.ok(goalService.toResponseList(goalService.list(status, currentUsername(auth), limit)));
}
@Operation(summary = "Sparse update of a non-terminal goal")
@PatchMapping("/{id}")
public R<GoalEntity> update(@PathVariable Long id,
public R<GoalResponse> update(@PathVariable Long id,
@RequestBody GoalUpdateRequest req,
Authentication auth) {
GoalEntity g = goalService.getById(id);
String username = currentUsername(auth);
requireOwner(g.getConversationId(), username);
return R.ok(goalService.update(id, req, username));
return R.ok(goalService.toResponse(goalService.update(id, req, username)));
}
@Operation(summary = "Pause an active goal")
@PostMapping("/{id}/pause")
public R<GoalEntity> pause(@PathVariable Long id, Authentication auth) {
public R<GoalResponse> pause(@PathVariable Long id, Authentication auth) {
GoalEntity g = goalService.getById(id);
String username = currentUsername(auth);
requireOwner(g.getConversationId(), username);
return R.ok(goalService.pause(id, username));
return R.ok(goalService.toResponse(goalService.pause(id, username)));
}
@Operation(summary = "Resume a paused goal")
@PostMapping("/{id}/resume")
public R<GoalEntity> resume(@PathVariable Long id, Authentication auth) {
public R<GoalResponse> resume(@PathVariable Long id, Authentication auth) {
GoalEntity g = goalService.getById(id);
String username = currentUsername(auth);
requireOwner(g.getConversationId(), username);
return R.ok(goalService.resume(id, username));
return R.ok(goalService.toResponse(goalService.resume(id, username)));
}
@Operation(summary = "Abandon a goal (terminal)")
@PostMapping("/{id}/abandon")
public R<GoalEntity> abandon(@PathVariable Long id, Authentication auth) {
public R<GoalResponse> abandon(@PathVariable Long id, Authentication auth) {
GoalEntity g = goalService.getById(id);
String username = currentUsername(auth);
requireOwner(g.getConversationId(), username);
return R.ok(goalService.abandon(id, username));
return R.ok(goalService.toResponse(goalService.abandon(id, username)));
}
@Operation(summary = "Append a sub-criterion to an active goal")
@PostMapping("/{id}/criteria")
public R<GoalEntity> addCriterion(@PathVariable Long id,
public R<GoalResponse> addCriterion(@PathVariable Long id,
@RequestBody Map<String, String> body,
Authentication auth) {
GoalEntity g = goalService.getById(id);
String username = currentUsername(auth);
requireOwner(g.getConversationId(), username);
String criterion = body != null ? body.get("criterion") : null;
return R.ok(goalService.appendCriterion(id, criterion, username));
return R.ok(goalService.toResponse(goalService.appendCriterion(id, criterion, username)));
}
// ==================== Helpers ====================

View File

@ -0,0 +1,54 @@
package vip.mate.goal.model;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* Outward-facing shape of a goal. Identical to {@link GoalEntity} except the
* checklist is the parsed {@code List<GoalCriterion>} array rather than the
* raw JSON String stored in the column so REST responses and SSE payloads
* always carry {@code criteria} as an array, never a string.
*
* <p>{@code criteria} is never null on the wire: a missing / unparseable
* column maps to an empty list.
*/
@Data
public class GoalResponse {
private Long id;
private String conversationId;
private Long agentId;
private Long workspaceId;
private String createdBy;
private String title;
private String description;
private String exitCriteria;
private String successCheckPrompt;
private GoalStatus status;
private Integer turnBudget;
private Integer turnsUsed;
private Integer llmCallBudget;
private Integer agentLlmCallsUsed;
private Integer evalLlmCallsUsed;
private int totalLlmCallsUsed;
private String progressSummary;
private Double completionScore;
private LocalDateTime lastEvaluationAt;
private Boolean autoFollowupEnabled;
private Integer followupCooldownSeconds;
private LocalDateTime lastFollowupAt;
private Integer version;
private LocalDateTime createTime;
private LocalDateTime updateTime;
/** Parsed checklist; empty (never null) when the column is null/unparseable. */
private List<GoalCriterion> criteria;
}

View File

@ -4,13 +4,14 @@ import vip.mate.goal.model.GoalCreateRequest;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.model.GoalEventEntity;
import vip.mate.goal.model.GoalResponse;
import vip.mate.goal.model.GoalUpdateRequest;
import java.util.List;
/**
* Persistent goal service CRUD, status transitions, and bookkeeping
* called from {@code GoalEvaluationNode} (PR2).
* called from {@code GoalEvaluationNode}.
*
* <p>Concurrency model: writes use a per-row {@code WHERE version=?}
* compare-and-set. On conflict the service retries up to 3 times before
@ -78,4 +79,17 @@ public interface GoalService {
/** Append a sub-criterion without restarting the goal. */
GoalEntity appendCriterion(Long id, String criterion, String username);
// ==================== Response mapping ====================
/**
* Map an entity to its outward-facing form: {@code criteria} becomes a
* parsed {@code List<GoalCriterion>} array (empty when null/unparseable),
* never the raw JSON String. Use at every REST return point and SSE
* payload so clients never see the string form.
*/
GoalResponse toResponse(GoalEntity entity);
/** Convenience: {@link #toResponse} over a list. */
List<GoalResponse> toResponseList(List<GoalEntity> entities);
}

View File

@ -14,8 +14,10 @@ import vip.mate.audit.service.AuditEventService;
import vip.mate.exception.MateClawException;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalCreateRequest;
import vip.mate.goal.model.GoalCriteriaCodec;
import vip.mate.goal.model.GoalCriterion;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalResponse;
import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.model.GoalEventEntity;
import vip.mate.goal.model.GoalEventType;
@ -283,6 +285,18 @@ public class GoalServiceImpl implements GoalService {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
}
// Snapshot the checklist as fully satisfied. Idempotent for the
// auto path (recordEvaluation already merged all-passed); required
// for manual completion, which has no preceding verdict.
List<GoalCriterion> existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper);
if (!existing.isEmpty()) {
List<GoalCriterion> allPassed = existing.stream()
.map(c -> c.passed() ? c : new GoalCriterion(c.id(), c.text(), true,
c.evidence() == null || c.evidence().isBlank()
? "marked complete" : c.evidence()))
.toList();
w.set(GoalEntity::getCriteria, GoalCriteriaCodec.serialize(allPassed, objectMapper));
}
bumpVersionAndTime(w);
return w;
});
@ -290,6 +304,7 @@ public class GoalServiceImpl implements GoalService {
detail.put("finalScore", result != null ? result.score() : null);
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
detail.put("criteria", GoalCriteriaCodec.parse(g.getCriteria(), objectMapper));
writeEvent(id, GoalEventType.COMPLETED, null, detail);
recordAudit("goal.completed", g, detail);
@ -349,7 +364,7 @@ public class GoalServiceImpl implements GoalService {
int agentDelta = Math.max(0, agentLlmCallsDelta);
int evalDelta = Math.max(0, evalLlmCallsDelta);
retryOptimistic(id, "recordEvaluation", fresh -> {
GoalEntity g = retryOptimistic(id, "recordEvaluation", fresh -> {
if (fresh.getStatus().isTerminal()) return null; // ignore late evaluations
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.setSql("turns_used = turns_used + 1")
@ -359,6 +374,13 @@ public class GoalServiceImpl implements GoalService {
if (result != null) {
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).
String criteriaJson = nextCriteriaJson(fresh, result);
if (criteriaJson != null) {
w.set(GoalEntity::getCriteria, criteriaJson);
}
}
bumpVersionAndTime(w);
return w;
@ -374,9 +396,33 @@ public class GoalServiceImpl implements GoalService {
}
detail.put("agentLlmCallsDelta", agentDelta);
detail.put("evalLlmCallsDelta", evalDelta);
// Full checklist (array) so the timeline / SSE consumer never sees the
// raw String column or has to reconstruct from the per-round delta.
detail.put("criteria", GoalCriteriaCodec.parse(g.getCriteria(), objectMapper));
writeEvent(id, GoalEventType.EVALUATED, null, detail);
}
/**
* Compute the next criteria JSON for a record-evaluation write, or
* {@code null} when the result carries no checklist change. Bootstrap
* results replace the list with the freshly derived draft; verdict
* results merge their per-criterion delta into the locked-row list.
*/
private String nextCriteriaJson(GoalEntity fresh, GoalEvaluationResult result) {
if (result.bootstrapCriteria() != null && !result.bootstrapCriteria().isEmpty()) {
return GoalCriteriaCodec.serialize(result.bootstrapCriteria(), objectMapper);
}
if (result.criterionVerdicts() != null && !result.criterionVerdicts().isEmpty()) {
List<GoalCriterion> existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper);
if (existing.isEmpty()) {
return null;
}
return GoalCriteriaCodec.serialize(
GoalCriteriaCodec.merge(existing, result.criterionVerdicts()), objectMapper);
}
return null;
}
@Override
public boolean isBudgetExhausted(GoalEntity goal) {
int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0;
@ -451,31 +497,62 @@ public class GoalServiceImpl implements GoalService {
if (raw == null || raw.isEmpty()) {
return null;
}
List<GoalCriterion> out = new java.util.ArrayList<>(raw.size());
int n = 1;
List<GoalCriterion> kept = new java.util.ArrayList<>(raw.size());
for (GoalCriterion c : raw) {
if (c == null || c.text() == null || c.text().isBlank()) {
continue;
if (c != null && c.text() != null && !c.text().isBlank()) {
kept.add(new GoalCriterion("", c.text().trim(), false, ""));
}
out.add(new GoalCriterion("C" + n, c.text().trim(), false, ""));
n++;
}
return out.isEmpty() ? null : out;
return kept.isEmpty() ? null : GoalCriteriaCodec.reindex(kept);
}
/** Serialize a checklist to JSON text, or {@code null} for a null list. */
private String serializeCriteria(List<GoalCriterion> criteria) {
if (criteria == null) {
return GoalCriteriaCodec.serialize(criteria, objectMapper);
}
@Override
public GoalResponse toResponse(GoalEntity e) {
if (e == null) {
return null;
}
try {
return objectMapper.writeValueAsString(criteria);
} catch (JsonProcessingException e) {
// Should never happen for a plain record list; fail soft to NULL
// (bootstrap path) rather than aborting goal creation.
log.warn("[Goal] failed to serialize criteria, storing null: {}", e.getMessage());
return null;
GoalResponse r = new GoalResponse();
r.setId(e.getId());
r.setConversationId(e.getConversationId());
r.setAgentId(e.getAgentId());
r.setWorkspaceId(e.getWorkspaceId());
r.setCreatedBy(e.getCreatedBy());
r.setTitle(e.getTitle());
r.setDescription(e.getDescription());
r.setExitCriteria(e.getExitCriteria());
r.setSuccessCheckPrompt(e.getSuccessCheckPrompt());
r.setStatus(e.getStatus());
r.setTurnBudget(e.getTurnBudget());
r.setTurnsUsed(e.getTurnsUsed());
r.setLlmCallBudget(e.getLlmCallBudget());
r.setAgentLlmCallsUsed(e.getAgentLlmCallsUsed());
r.setEvalLlmCallsUsed(e.getEvalLlmCallsUsed());
r.setTotalLlmCallsUsed(e.totalLlmCallsUsed());
r.setProgressSummary(e.getProgressSummary());
r.setCompletionScore(e.getCompletionScore());
r.setLastEvaluationAt(e.getLastEvaluationAt());
r.setAutoFollowupEnabled(e.getAutoFollowupEnabled());
r.setFollowupCooldownSeconds(e.getFollowupCooldownSeconds());
r.setLastFollowupAt(e.getLastFollowupAt());
r.setVersion(e.getVersion());
r.setCreateTime(e.getCreateTime());
r.setUpdateTime(e.getUpdateTime());
// Always an array; empty when the column is null/unparseable.
r.setCriteria(GoalCriteriaCodec.parse(e.getCriteria(), objectMapper));
return r;
}
@Override
public List<GoalResponse> toResponseList(List<GoalEntity> entities) {
if (entities == null) {
return List.of();
}
return entities.stream().map(this::toResponse).toList();
}
private void validateCreate(GoalCreateRequest req) {

View File

@ -246,7 +246,7 @@ public class GoalManagementTool {
streamTracker.broadcastObject(conversationId, eventName, Map.of(
"goalId", String.valueOf(goal.getId()),
"conversationId", conversationId,
"goal", goal));
"goal", goalService.toResponse(goal)));
} catch (Exception e) {
log.debug("[GoalManagementTool] broadcast {} failed: {}", eventName, e.getMessage());
}