From cd1c66fc0e26c5ef89bf960670daf65347bc18e2 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 3 Jun 2026 21:19:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(goal):=20structured=20checklist=20data=20m?= =?UTF-8?q?odel=20=E2=80=94=20criteria=20column,=20criterion=20records,=20?= =?UTF-8?q?dual-carrier=20evaluation=20result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mate/goal/model/GoalChecklistVerdict.java | 24 +++++++++++ .../mate/goal/model/GoalCreateRequest.java | 10 +++++ .../mate/goal/model/GoalCriteriaDraft.java | 19 ++++++++ .../vip/mate/goal/model/GoalCriterion.java | 19 ++++++++ .../java/vip/mate/goal/model/GoalEntity.java | 11 +++++ .../mate/goal/model/GoalEvaluationResult.java | 32 ++++++++++---- .../goal/service/GoalEvaluationService.java | 3 +- .../mate/goal/service/GoalServiceImpl.java | 43 +++++++++++++++++++ .../mate/tool/builtin/GoalManagementTool.java | 3 +- .../h2/V140__goal_criteria_checklist.sql | 8 ++++ .../mysql/V140__goal_criteria_checklist.sql | 16 +++++++ .../goal/service/GoalFollowupServiceTest.java | 3 +- .../mate/goal/service/GoalServiceTest.java | 3 +- 13 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/model/GoalChecklistVerdict.java create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriteriaDraft.java create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriterion.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V140__goal_criteria_checklist.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V140__goal_criteria_checklist.sql diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalChecklistVerdict.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalChecklistVerdict.java new file mode 100644 index 00000000..e976b1a2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalChecklistVerdict.java @@ -0,0 +1,24 @@ +package vip.mate.goal.model; + +import java.util.List; + +/** + * Evaluator output for the verdict round — applied once a goal's + * checklist already exists. + * + *

The evaluator only takes a position on existing criteria by id; it does + * not re-emit the criterion text. The service merges each {@link CriterionVerdict} + * into the persistent {@code List} by id (text and untouched + * criteria are preserved), then derives completion from "all passed". + * + *

This is a per-round delta — never the full outward-facing checklist. + * Outward payloads always carry the full {@code GoalResponse.criteria} array. + */ +public record GoalChecklistVerdict( + List criterionVerdicts, + String summary) { + + /** Per-criterion delta: latest passed state + evidence, keyed by id. */ + public record CriterionVerdict(String id, boolean passed, String evidence) { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java index 3b2bcae7..fc084d98 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java @@ -2,6 +2,8 @@ package vip.mate.goal.model; import lombok.Data; +import java.util.List; + /** * Request body for {@code POST /api/v1/goals}. * @@ -30,4 +32,12 @@ public class GoalCreateRequest { private Integer llmCallBudget; private Boolean autoFollowupEnabled; private Integer followupCooldownSeconds; + + /** + * Optional initial checklist. Callers supply only {@code text} per item; + * the service normalizes ids ({@code C1..Cn}), forces {@code passed=false} + * and clears {@code evidence} on create. An empty/omitted list defers to + * first-evaluation bootstrap. + */ + private List criteria; } diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriteriaDraft.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriteriaDraft.java new file mode 100644 index 00000000..590acbd4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriteriaDraft.java @@ -0,0 +1,19 @@ +package vip.mate.goal.model; + +import java.util.List; + +/** + * Evaluator output for the bootstrap round — the first evaluation of a + * goal that has no criteria yet. + * + *

When {@code mate_agent_goal.criteria} is empty there is nothing to score + * by id, so the evaluator instead decomposes the goal (title / description / + * exit criteria) into a full checklist with text. The service persists this + * as the goal's initial criteria (all {@code passed=false}); completion is + * not judged on the bootstrap round. + * + *

Distinct from {@link GoalChecklistVerdict}, which is the per-round delta + * used once the checklist already exists. + */ +public record GoalCriteriaDraft(List criteria) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriterion.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriterion.java new file mode 100644 index 00000000..8f033520 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriterion.java @@ -0,0 +1,19 @@ +package vip.mate.goal.model; + +/** + * One checkable item of a goal's exit checklist — the persistent unit. + * + *

Stored as part of the JSON array in {@code mate_agent_goal.criteria} + * and surfaced to clients as an element of {@code GoalResponse.criteria}. + * Completion of a goal is derived from "every criterion passed" rather than + * a fuzzy completion score. + * + * @param id stable identifier ({@code C1}, {@code C2}, ...), assigned + * by the service on create/append; callers never mint ids + * @param text the criterion statement (human + LLM readable) + * @param passed whether the evaluator has judged this criterion satisfied + * @param evidence concrete justification for {@code passed} (an output line, + * a file excerpt, a command result); empty until evaluated + */ +public record GoalCriterion(String id, String text, boolean passed, String evidence) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java index 96159277..bbff046e 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java @@ -90,6 +90,17 @@ public class GoalEntity { @TableField(value = "completion_score", updateStrategy = FieldStrategy.ALWAYS) private Double completionScore; + /** + * Checkable exit checklist as a JSON array of {@link GoalCriterion}. + * Completion is derived from "all criteria passed". Nullable: a missing + * list bootstraps on first evaluation. ALWAYS strategy so clearing / + * empty-list writes are persisted. Serialized as text; the service layer + * maps to/from {@code List} and exposes the parsed array + * to clients via {@code GoalResponse.criteria}. + */ + @TableField(value = "criteria", updateStrategy = FieldStrategy.ALWAYS) + private String criteria; + @TableField(value = "last_evaluation_at", updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime lastEvaluationAt; diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java index d1d87a4d..d1fa4cdb 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java @@ -1,6 +1,7 @@ package vip.mate.goal.model; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -8,15 +9,24 @@ import java.util.Map; * {@code GoalEvaluationService} to {@code GoalEvaluationNode} and on to * {@code GoalService.recordEvaluation}. * - *

Defined in PR1 so the service-layer signature is stable; the actual - * evaluator implementation lands in PR2. - * - *

{@link #completed} means "evaluator judged this turn satisfies all - * exit criteria". It does not mean "graph FINISH_REASON should change" — - * goal status and graph FinishReason are independent (RFC 48 §3.1 v2). + *

{@link #completed} means "evaluator judged this turn satisfies every + * exit criterion". It does not mean "graph FINISH_REASON should change" — + * goal status and graph FinishReason are independent. * *

{@link #llmCallsConsumed} is the evaluator-side delta only; the * agent-side delta is read from graph state by the node itself. + * + *

{@link #criterionVerdicts} and {@link #bootstrapCriteria} are mutually + * exclusive carriers for the checklist: + *

+ * Neither is the outward-facing full list — clients always receive the merged + * checklist via {@code GoalResponse.criteria}. */ public record GoalEvaluationResult( double score, @@ -25,7 +35,9 @@ public record GoalEvaluationResult( boolean completed, String evaluatorModel, int llmCallsConsumed, - long latencyMs) { + long latencyMs, + List criterionVerdicts, + List bootstrapCriteria) { public static final String DECISION_COMPLETED = "completed"; public static final String DECISION_CONTINUE = "continue"; @@ -37,7 +49,8 @@ public record GoalEvaluationResult( return new GoalEvaluationResult( 0.0, "evaluator unavailable: " + reason, DECISION_FALLBACK, false, - "", 0, 0L); + "", 0, 0L, + List.of(), null); } public Map toMap() { @@ -49,6 +62,9 @@ public record GoalEvaluationResult( m.put("evaluatorModel", evaluatorModel == null ? "" : evaluatorModel); m.put("llmCallsConsumed", llmCallsConsumed); m.put("latencyMs", latencyMs); + // Per-round delta, for debugging/detail only. UI progress is driven by + // the full GoalResponse.criteria array, never reconstructed from this. + m.put("criterionVerdicts", criterionVerdicts == null ? List.of() : criterionVerdicts); return m; } } diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java index 5d37f3e2..3f36cbc1 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java @@ -271,7 +271,8 @@ public class GoalEvaluationService { : GoalEvaluationResult.DECISION_CONTINUE; return new GoalEvaluationResult( score, gap, decision, completed, - modelName != null ? modelName : "", 1, latencyMs); + modelName != null ? modelName : "", 1, latencyMs, + List.of(), null); } catch (Exception e) { log.warn("[GoalEvaluation] JSON parse failed: {} — body={}", e.getMessage(), 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 0a264479..fdab8bc2 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,6 +14,7 @@ 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.GoalCriterion; import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalEvaluationResult; import vip.mate.goal.model.GoalEventEntity; @@ -115,6 +116,10 @@ public class GoalServiceImpl implements GoalService { entity.setAutoFollowupEnabled(Boolean.TRUE.equals(req.getAutoFollowupEnabled())); entity.setFollowupCooldownSeconds(req.getFollowupCooldownSeconds() != null ? req.getFollowupCooldownSeconds() : properties.getAutoFollowupCooldownSeconds()); + // Normalize any caller-supplied checklist: assign C1..Cn, force + // passed=false, clear evidence. Empty/omitted -> null column so the + // first evaluation bootstraps the list. + entity.setCriteria(serializeCriteria(normalizeInitialCriteria(req.getCriteria()))); entity.setVersion(0); entity.setDeleted(0); LocalDateTime now = LocalDateTime.now(); @@ -435,6 +440,44 @@ public class GoalServiceImpl implements GoalService { // ==================== Internals ==================== + /** + * Normalize a caller-supplied initial checklist: keep only non-blank + * {@code text}, assign stable ids {@code C1..Cn} (ignore any caller ids), + * force {@code passed=false} and clear evidence. Returns {@code null} for + * an empty/null result so the column stays NULL and the first evaluation + * bootstraps the list. + */ + private List normalizeInitialCriteria(List raw) { + if (raw == null || raw.isEmpty()) { + return null; + } + List out = new java.util.ArrayList<>(raw.size()); + int n = 1; + for (GoalCriterion c : raw) { + if (c == null || c.text() == null || c.text().isBlank()) { + continue; + } + out.add(new GoalCriterion("C" + n, c.text().trim(), false, "")); + n++; + } + return out.isEmpty() ? null : out; + } + + /** Serialize a checklist to JSON text, or {@code null} for a null list. */ + private String serializeCriteria(List criteria) { + if (criteria == 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; + } + } + private void validateCreate(GoalCreateRequest req) { if (req == null) { throw new MateClawException("err.goal.bad_request", 400, "Request body required"); 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 9b61bdcd..cebe11a3 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 @@ -145,7 +145,8 @@ public class GoalManagementTool { // Synthesize a completion-style evaluation result for the audit trail. GoalEvaluationResult synthetic = new GoalEvaluationResult( 1.0, "completed by agent", GoalEvaluationResult.DECISION_COMPLETED, - true, "manual", 0, 0L); + true, "manual", 0, 0L, + java.util.List.of(), null); try { GoalEntity completed = goalService.markCompleted(goal.getId(), synthetic); // Broadcast a goal_completed event with the same shape as the diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V140__goal_criteria_checklist.sql b/mateclaw-server/src/main/resources/db/migration/h2/V140__goal_criteria_checklist.sql new file mode 100644 index 00000000..1999e67d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V140__goal_criteria_checklist.sql @@ -0,0 +1,8 @@ +-- V140: Structured, checkable criteria for goals (H2). +-- +-- Adds a nullable JSON-text column holding the goal's checklist: +-- [{ "id": "C1", "text": "...", "passed": false, "evidence": "" }, ...] +-- Completion is derived from "all criteria passed" rather than a fuzzy +-- completion_score threshold. The column is additive and nullable, so +-- existing goals load unchanged (a NULL list bootstraps on first evaluation). +ALTER TABLE mate_agent_goal ADD COLUMN IF NOT EXISTS criteria CLOB; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V140__goal_criteria_checklist.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V140__goal_criteria_checklist.sql new file mode 100644 index 00000000..1ddf1da2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V140__goal_criteria_checklist.sql @@ -0,0 +1,16 @@ +-- V140: Structured, checkable criteria for goals (MySQL). +-- +-- See the H2 counterpart for the full rationale. MySQL has no +-- "ADD COLUMN IF NOT EXISTS", so the column is guarded with an +-- INFORMATION_SCHEMA existence check + prepared statement for idempotency. +-- +-- The column holds the goal's checklist as JSON: +-- [{ "id": "C1", "text": "...", "passed": false, "evidence": "" }, ...] +-- Additive and nullable, so existing goals load unchanged (a NULL list +-- bootstraps on first evaluation). +SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_agent_goal' AND COLUMN_NAME = 'criteria'); +SET @stmt := IF(@col_exists = 0, + 'ALTER TABLE mate_agent_goal ADD COLUMN criteria JSON NULL', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java index 3082e49b..530ddb66 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java @@ -38,7 +38,8 @@ class GoalFollowupServiceTest { return new GoalEvaluationResult( score, "missing X", decision, false, - "stub", 0, 0L); + "stub", 0, 0L, + java.util.List.of(), null); } @Test 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 a39cfc9d..76d2b675 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 @@ -241,7 +241,8 @@ class GoalServiceTest { GoalEvaluationResult r = new GoalEvaluationResult( 0.62, "DNS still missing", "continue", false, - "qwen-turbo", 1, 800L); + "qwen-turbo", 1, 800L, + java.util.List.of(), null); service.recordEvaluation(1L, r, 3, 1); ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);