mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
feat(goal): persist checklist end-to-end + GoalResponse wire DTO
This commit is contained in:
parent
b65887f93d
commit
0fc8579a3e
@ -25,9 +25,8 @@ import java.util.Optional;
|
|||||||
/**
|
/**
|
||||||
* Sits between FinalAnswerNode (or PlanSummaryNode) and the graph END.
|
* Sits between FinalAnswerNode (or PlanSummaryNode) and the graph END.
|
||||||
*
|
*
|
||||||
* <p>Per RFC 48 v3 §3.3, evaluation runs on a settled terminal answer so
|
* <p>Evaluation runs on a settled terminal answer so upstream finishReason /
|
||||||
* upstream finishReason / evidence checks are already authoritative. The
|
* evidence checks are already authoritative. The node:
|
||||||
* node:
|
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>Bails out for the "this turn shouldn't count" finishReasons
|
* <li>Bails out for the "this turn shouldn't count" finishReasons
|
||||||
* (evidence_insufficient, stopped, error_fallback, return_direct,
|
* (evidence_insufficient, stopped, error_fallback, return_direct,
|
||||||
@ -50,8 +49,8 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
private final GoalFollowupService followupService;
|
private final GoalFollowupService followupService;
|
||||||
private final GoalService goalService;
|
private final GoalService goalService;
|
||||||
private final GoalProperties properties;
|
private final GoalProperties properties;
|
||||||
private final ConversationWindowManager windowManager; // unused PR2, kept for PR5
|
private final ConversationWindowManager windowManager; // reserved for evaluator context windowing
|
||||||
private final ConversationService conversationService; // unused PR2, kept for PR5
|
private final ConversationService conversationService; // reserved for evaluator context lookups
|
||||||
private final GraphFlavor flavor;
|
private final GraphFlavor flavor;
|
||||||
|
|
||||||
public GoalEvaluationNode(GoalEvaluationService evaluationService,
|
public GoalEvaluationNode(GoalEvaluationService evaluationService,
|
||||||
@ -72,7 +71,7 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> apply(OverAllState state) throws Exception {
|
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()) {
|
if (!properties.isEnabled()) {
|
||||||
return Map.of();
|
return Map.of();
|
||||||
}
|
}
|
||||||
@ -187,29 +186,31 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
// and abort the streamed answer the user already sees.
|
// and abort the streamed answer the user already sees.
|
||||||
try {
|
try {
|
||||||
if (result.completed() || result.score() >= 0.95) {
|
if (result.completed() || result.score() >= 0.95) {
|
||||||
goalService.markCompleted(refreshed.getId(), result);
|
GoalEntity completed = goalService.markCompleted(refreshed.getId(), result);
|
||||||
return MateClawStateAccessor.output()
|
return MateClawStateAccessor.output()
|
||||||
.goalEvaluationResult(result.toMap())
|
.goalEvaluationResult(result.toMap())
|
||||||
.goalEvaluatedThisRun(true)
|
.goalEvaluatedThisRun(true)
|
||||||
.events(List.of(goalEvent("goal_completed", Map.of(
|
.events(List.of(goalEvent("goal_completed", Map.of(
|
||||||
"goalId", String.valueOf(refreshed.getId()),
|
"goalId", String.valueOf(completed.getId()),
|
||||||
"score", result.score()))))
|
"score", result.score(),
|
||||||
|
"goal", goalService.toResponse(completed)))))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (goalService.isBudgetExhausted(refreshed)) {
|
if (goalService.isBudgetExhausted(refreshed)) {
|
||||||
String reason = goalService.exhaustionReason(refreshed);
|
String reason = goalService.exhaustionReason(refreshed);
|
||||||
goalService.markExhausted(refreshed.getId(), reason);
|
GoalEntity exhausted = goalService.markExhausted(refreshed.getId(), reason);
|
||||||
return MateClawStateAccessor.output()
|
return MateClawStateAccessor.output()
|
||||||
.goalEvaluationResult(result.toMap())
|
.goalEvaluationResult(result.toMap())
|
||||||
.goalEvaluatedThisRun(true)
|
.goalEvaluatedThisRun(true)
|
||||||
.events(List.of(goalEvent("goal_exhausted", Map.of(
|
.events(List.of(goalEvent("goal_exhausted", Map.of(
|
||||||
"goalId", String.valueOf(refreshed.getId()),
|
"goalId", String.valueOf(exhausted.getId()),
|
||||||
"turnsUsed", refreshed.getTurnsUsed(),
|
"turnsUsed", exhausted.getTurnsUsed(),
|
||||||
"agentLlmCallsUsed", refreshed.getAgentLlmCallsUsed(),
|
"agentLlmCallsUsed", exhausted.getAgentLlmCallsUsed(),
|
||||||
"evalLlmCallsUsed", refreshed.getEvalLlmCallsUsed(),
|
"evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(),
|
||||||
"totalLlmCallsUsed", refreshed.totalLlmCallsUsed(),
|
"totalLlmCallsUsed", exhausted.totalLlmCallsUsed(),
|
||||||
"reason", reason))))
|
"reason", reason,
|
||||||
|
"goal", goalService.toResponse(exhausted)))))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
@ -270,7 +271,8 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
.needsToolCall(false)
|
.needsToolCall(false)
|
||||||
.events(List.of(goalEvent("goal_followup", Map.of(
|
.events(List.of(goalEvent("goal_followup", Map.of(
|
||||||
"goalId", String.valueOf(refreshed.getId()),
|
"goalId", String.valueOf(refreshed.getId()),
|
||||||
"prompt", followup.get()))));
|
"prompt", followup.get(),
|
||||||
|
"goal", goalService.toResponse(refreshed)))));
|
||||||
|
|
||||||
if (flavor == GraphFlavor.REACT) {
|
if (flavor == GraphFlavor.REACT) {
|
||||||
// ReAct: append the followup as a fresh user message via the
|
// 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(
|
.events(List.of(goalEvent("goal_evaluated", Map.of(
|
||||||
"goalId", String.valueOf(refreshed.getId()),
|
"goalId", String.valueOf(refreshed.getId()),
|
||||||
"score", result.score(),
|
"score", result.score(),
|
||||||
"gap", result.gap() == null ? "" : result.gap()))))
|
"gap", result.gap() == null ? "" : result.gap(),
|
||||||
|
"goal", goalService.toResponse(refreshed)))))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import vip.mate.exception.MateClawException;
|
|||||||
import vip.mate.goal.model.GoalCreateRequest;
|
import vip.mate.goal.model.GoalCreateRequest;
|
||||||
import vip.mate.goal.model.GoalEntity;
|
import vip.mate.goal.model.GoalEntity;
|
||||||
import vip.mate.goal.model.GoalEventEntity;
|
import vip.mate.goal.model.GoalEventEntity;
|
||||||
|
import vip.mate.goal.model.GoalResponse;
|
||||||
import vip.mate.goal.model.GoalUpdateRequest;
|
import vip.mate.goal.model.GoalUpdateRequest;
|
||||||
import vip.mate.goal.service.GoalService;
|
import vip.mate.goal.service.GoalService;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
@ -51,7 +52,7 @@ public class GoalController {
|
|||||||
|
|
||||||
@Operation(summary = "Create a persistent goal for a conversation")
|
@Operation(summary = "Create a persistent goal for a conversation")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public R<GoalEntity> create(@RequestBody GoalCreateRequest req, Authentication auth) {
|
public R<GoalResponse> create(@RequestBody GoalCreateRequest req, Authentication auth) {
|
||||||
String username = currentUsername(auth);
|
String username = currentUsername(auth);
|
||||||
requireOwner(req.getConversationId(), username);
|
requireOwner(req.getConversationId(), username);
|
||||||
// Derive agentId/workspaceId from the conversation itself so the
|
// Derive agentId/workspaceId from the conversation itself so the
|
||||||
@ -71,22 +72,22 @@ public class GoalController {
|
|||||||
req.setAgentId(conv.getAgentId());
|
req.setAgentId(conv.getAgentId());
|
||||||
req.setWorkspaceId(conv.getWorkspaceId() != null ? conv.getWorkspaceId() : 1L);
|
req.setWorkspaceId(conv.getWorkspaceId() != null ? conv.getWorkspaceId() : 1L);
|
||||||
GoalEntity g = goalService.create(req, username);
|
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)")
|
@Operation(summary = "Get the active goal bound to a conversation (or null)")
|
||||||
@GetMapping("/by-conversation/{conversationId}")
|
@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));
|
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")
|
@Operation(summary = "Get goal detail by id")
|
||||||
@GetMapping("/{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);
|
GoalEntity g = goalService.getById(id);
|
||||||
requireOwner(g.getConversationId(), currentUsername(auth));
|
requireOwner(g.getConversationId(), currentUsername(auth));
|
||||||
return R.ok(g);
|
return R.ok(goalService.toResponse(g));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "Get the event timeline for a goal")
|
@Operation(summary = "Get the event timeline for a goal")
|
||||||
@ -101,61 +102,61 @@ public class GoalController {
|
|||||||
|
|
||||||
@Operation(summary = "List goals (optionally filtered by status)")
|
@Operation(summary = "List goals (optionally filtered by status)")
|
||||||
@GetMapping
|
@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,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
Authentication auth) {
|
Authentication auth) {
|
||||||
// List is owner-scoped — only your own goals are visible.
|
// 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")
|
@Operation(summary = "Sparse update of a non-terminal goal")
|
||||||
@PatchMapping("/{id}")
|
@PatchMapping("/{id}")
|
||||||
public R<GoalEntity> update(@PathVariable Long id,
|
public R<GoalResponse> update(@PathVariable Long id,
|
||||||
@RequestBody GoalUpdateRequest req,
|
@RequestBody GoalUpdateRequest req,
|
||||||
Authentication auth) {
|
Authentication auth) {
|
||||||
GoalEntity g = goalService.getById(id);
|
GoalEntity g = goalService.getById(id);
|
||||||
String username = currentUsername(auth);
|
String username = currentUsername(auth);
|
||||||
requireOwner(g.getConversationId(), username);
|
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")
|
@Operation(summary = "Pause an active goal")
|
||||||
@PostMapping("/{id}/pause")
|
@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);
|
GoalEntity g = goalService.getById(id);
|
||||||
String username = currentUsername(auth);
|
String username = currentUsername(auth);
|
||||||
requireOwner(g.getConversationId(), username);
|
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")
|
@Operation(summary = "Resume a paused goal")
|
||||||
@PostMapping("/{id}/resume")
|
@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);
|
GoalEntity g = goalService.getById(id);
|
||||||
String username = currentUsername(auth);
|
String username = currentUsername(auth);
|
||||||
requireOwner(g.getConversationId(), username);
|
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)")
|
@Operation(summary = "Abandon a goal (terminal)")
|
||||||
@PostMapping("/{id}/abandon")
|
@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);
|
GoalEntity g = goalService.getById(id);
|
||||||
String username = currentUsername(auth);
|
String username = currentUsername(auth);
|
||||||
requireOwner(g.getConversationId(), username);
|
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")
|
@Operation(summary = "Append a sub-criterion to an active goal")
|
||||||
@PostMapping("/{id}/criteria")
|
@PostMapping("/{id}/criteria")
|
||||||
public R<GoalEntity> addCriterion(@PathVariable Long id,
|
public R<GoalResponse> addCriterion(@PathVariable Long id,
|
||||||
@RequestBody Map<String, String> body,
|
@RequestBody Map<String, String> body,
|
||||||
Authentication auth) {
|
Authentication auth) {
|
||||||
GoalEntity g = goalService.getById(id);
|
GoalEntity g = goalService.getById(id);
|
||||||
String username = currentUsername(auth);
|
String username = currentUsername(auth);
|
||||||
requireOwner(g.getConversationId(), username);
|
requireOwner(g.getConversationId(), username);
|
||||||
String criterion = body != null ? body.get("criterion") : null;
|
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 ====================
|
// ==================== Helpers ====================
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -4,13 +4,14 @@ import vip.mate.goal.model.GoalCreateRequest;
|
|||||||
import vip.mate.goal.model.GoalEntity;
|
import vip.mate.goal.model.GoalEntity;
|
||||||
import vip.mate.goal.model.GoalEvaluationResult;
|
import vip.mate.goal.model.GoalEvaluationResult;
|
||||||
import vip.mate.goal.model.GoalEventEntity;
|
import vip.mate.goal.model.GoalEventEntity;
|
||||||
|
import vip.mate.goal.model.GoalResponse;
|
||||||
import vip.mate.goal.model.GoalUpdateRequest;
|
import vip.mate.goal.model.GoalUpdateRequest;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent goal service — CRUD, status transitions, and bookkeeping
|
* 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=?}
|
* <p>Concurrency model: writes use a per-row {@code WHERE version=?}
|
||||||
* compare-and-set. On conflict the service retries up to 3 times before
|
* 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. */
|
/** Append a sub-criterion without restarting the goal. */
|
||||||
GoalEntity appendCriterion(Long id, String criterion, String username);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,10 @@ import vip.mate.audit.service.AuditEventService;
|
|||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.goal.config.GoalProperties;
|
import vip.mate.goal.config.GoalProperties;
|
||||||
import vip.mate.goal.model.GoalCreateRequest;
|
import vip.mate.goal.model.GoalCreateRequest;
|
||||||
|
import vip.mate.goal.model.GoalCriteriaCodec;
|
||||||
import vip.mate.goal.model.GoalCriterion;
|
import vip.mate.goal.model.GoalCriterion;
|
||||||
import vip.mate.goal.model.GoalEntity;
|
import vip.mate.goal.model.GoalEntity;
|
||||||
|
import vip.mate.goal.model.GoalResponse;
|
||||||
import vip.mate.goal.model.GoalEvaluationResult;
|
import vip.mate.goal.model.GoalEvaluationResult;
|
||||||
import vip.mate.goal.model.GoalEventEntity;
|
import vip.mate.goal.model.GoalEventEntity;
|
||||||
import vip.mate.goal.model.GoalEventType;
|
import vip.mate.goal.model.GoalEventType;
|
||||||
@ -283,6 +285,18 @@ public class GoalServiceImpl implements GoalService {
|
|||||||
w.set(GoalEntity::getCompletionScore, result.score())
|
w.set(GoalEntity::getCompletionScore, result.score())
|
||||||
.set(GoalEntity::getProgressSummary, result.gap());
|
.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);
|
bumpVersionAndTime(w);
|
||||||
return w;
|
return w;
|
||||||
});
|
});
|
||||||
@ -290,6 +304,7 @@ public class GoalServiceImpl implements GoalService {
|
|||||||
detail.put("finalScore", result != null ? result.score() : null);
|
detail.put("finalScore", result != null ? result.score() : null);
|
||||||
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
|
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
|
||||||
detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
|
detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
|
||||||
|
detail.put("criteria", GoalCriteriaCodec.parse(g.getCriteria(), objectMapper));
|
||||||
writeEvent(id, GoalEventType.COMPLETED, null, detail);
|
writeEvent(id, GoalEventType.COMPLETED, null, detail);
|
||||||
recordAudit("goal.completed", g, detail);
|
recordAudit("goal.completed", g, detail);
|
||||||
|
|
||||||
@ -349,7 +364,7 @@ public class GoalServiceImpl implements GoalService {
|
|||||||
int agentDelta = Math.max(0, agentLlmCallsDelta);
|
int agentDelta = Math.max(0, agentLlmCallsDelta);
|
||||||
int evalDelta = Math.max(0, evalLlmCallsDelta);
|
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
|
if (fresh.getStatus().isTerminal()) return null; // ignore late evaluations
|
||||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
|
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
|
||||||
.setSql("turns_used = turns_used + 1")
|
.setSql("turns_used = turns_used + 1")
|
||||||
@ -359,6 +374,13 @@ public class GoalServiceImpl implements GoalService {
|
|||||||
if (result != null) {
|
if (result != null) {
|
||||||
w.set(GoalEntity::getCompletionScore, result.score())
|
w.set(GoalEntity::getCompletionScore, result.score())
|
||||||
.set(GoalEntity::getProgressSummary, result.gap());
|
.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);
|
bumpVersionAndTime(w);
|
||||||
return w;
|
return w;
|
||||||
@ -374,9 +396,33 @@ public class GoalServiceImpl implements GoalService {
|
|||||||
}
|
}
|
||||||
detail.put("agentLlmCallsDelta", agentDelta);
|
detail.put("agentLlmCallsDelta", agentDelta);
|
||||||
detail.put("evalLlmCallsDelta", evalDelta);
|
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);
|
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
|
@Override
|
||||||
public boolean isBudgetExhausted(GoalEntity goal) {
|
public boolean isBudgetExhausted(GoalEntity goal) {
|
||||||
int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0;
|
int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0;
|
||||||
@ -451,31 +497,62 @@ public class GoalServiceImpl implements GoalService {
|
|||||||
if (raw == null || raw.isEmpty()) {
|
if (raw == null || raw.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
List<GoalCriterion> out = new java.util.ArrayList<>(raw.size());
|
List<GoalCriterion> kept = new java.util.ArrayList<>(raw.size());
|
||||||
int n = 1;
|
|
||||||
for (GoalCriterion c : raw) {
|
for (GoalCriterion c : raw) {
|
||||||
if (c == null || c.text() == null || c.text().isBlank()) {
|
if (c != null && c.text() != null && !c.text().isBlank()) {
|
||||||
continue;
|
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. */
|
/** Serialize a checklist to JSON text, or {@code null} for a null list. */
|
||||||
private String serializeCriteria(List<GoalCriterion> criteria) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
GoalResponse r = new GoalResponse();
|
||||||
return objectMapper.writeValueAsString(criteria);
|
r.setId(e.getId());
|
||||||
} catch (JsonProcessingException e) {
|
r.setConversationId(e.getConversationId());
|
||||||
// Should never happen for a plain record list; fail soft to NULL
|
r.setAgentId(e.getAgentId());
|
||||||
// (bootstrap path) rather than aborting goal creation.
|
r.setWorkspaceId(e.getWorkspaceId());
|
||||||
log.warn("[Goal] failed to serialize criteria, storing null: {}", e.getMessage());
|
r.setCreatedBy(e.getCreatedBy());
|
||||||
return null;
|
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) {
|
private void validateCreate(GoalCreateRequest req) {
|
||||||
|
|||||||
@ -246,7 +246,7 @@ public class GoalManagementTool {
|
|||||||
streamTracker.broadcastObject(conversationId, eventName, Map.of(
|
streamTracker.broadcastObject(conversationId, eventName, Map.of(
|
||||||
"goalId", String.valueOf(goal.getId()),
|
"goalId", String.valueOf(goal.getId()),
|
||||||
"conversationId", conversationId,
|
"conversationId", conversationId,
|
||||||
"goal", goal));
|
"goal", goalService.toResponse(goal)));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("[GoalManagementTool] broadcast {} failed: {}", eventName, e.getMessage());
|
log.debug("[GoalManagementTool] broadcast {} failed: {}", eventName, e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user