diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java index 076811d6..f3a43207 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java @@ -25,9 +25,8 @@ import java.util.Optional; /** * Sits between FinalAnswerNode (or PlanSummaryNode) and the graph END. * - *

Per RFC 48 v3 §3.3, evaluation runs on a settled terminal answer so - * upstream finishReason / evidence checks are already authoritative. The - * node: + *

Evaluation runs on a settled terminal answer so upstream finishReason / + * evidence checks are already authoritative. The node: *

    *
  1. 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 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(); } diff --git a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java index 2e84f595..0862616d 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java @@ -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 create(@RequestBody GoalCreateRequest req, Authentication auth) { + public R 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 findActive(@PathVariable String conversationId, Authentication auth) { + public R 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 get(@PathVariable Long id, Authentication auth) { + public R 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(@RequestParam(required = false) String status, + public R> 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 update(@PathVariable Long id, + public R 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 pause(@PathVariable Long id, Authentication auth) { + public R 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 resume(@PathVariable Long id, Authentication auth) { + public R 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 abandon(@PathVariable Long id, Authentication auth) { + public R 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 addCriterion(@PathVariable Long id, + public R addCriterion(@PathVariable Long id, @RequestBody Map 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 ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java new file mode 100644 index 00000000..ea272710 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java @@ -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} 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. + * + *

    {@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 criteria; +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java index e8537e7e..a90d6ea6 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java @@ -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}. * *

    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} 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 toResponseList(List entities); } 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 fdab8bc2..5684c663 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 @@ -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 existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper); + if (!existing.isEmpty()) { + List 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 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 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 out = new java.util.ArrayList<>(raw.size()); - int n = 1; + List 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 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 toResponseList(List entities) { + if (entities == null) { + return List.of(); } + return entities.stream().map(this::toResponse).toList(); } private void validateCreate(GoalCreateRequest req) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java index b8ec1377..ea3e88d5 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java @@ -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()); }