feat(goal): structured checklist data model — criteria column, criterion records, dual-carrier evaluation result

This commit is contained in:
matevip 2026-06-03 21:19:26 +08:00
parent c92bfa1f12
commit cd1c66fc0e
13 changed files with 182 additions and 12 deletions

View File

@ -0,0 +1,24 @@
package vip.mate.goal.model;
import java.util.List;
/**
* Evaluator output for the <b>verdict</b> round applied once a goal's
* checklist already exists.
*
* <p>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<GoalCriterion>} by id (text and untouched
* criteria are preserved), then derives completion from "all passed".
*
* <p>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<CriterionVerdict> criterionVerdicts,
String summary) {
/** Per-criterion delta: latest passed state + evidence, keyed by id. */
public record CriterionVerdict(String id, boolean passed, String evidence) {
}
}

View File

@ -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<GoalCriterion> criteria;
}

View File

@ -0,0 +1,19 @@
package vip.mate.goal.model;
import java.util.List;
/**
* Evaluator output for the <b>bootstrap</b> round the first evaluation of a
* goal that has no criteria yet.
*
* <p>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.
*
* <p>Distinct from {@link GoalChecklistVerdict}, which is the per-round delta
* used once the checklist already exists.
*/
public record GoalCriteriaDraft(List<GoalCriterion> criteria) {
}

View File

@ -0,0 +1,19 @@
package vip.mate.goal.model;
/**
* One checkable item of a goal's exit checklist the persistent unit.
*
* <p>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) {
}

View File

@ -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<GoalCriterion>} 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;

View File

@ -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}.
*
* <p>Defined in PR1 so the service-layer signature is stable; the actual
* evaluator implementation lands in PR2.
*
* <p>{@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).
* <p>{@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.
*
* <p>{@link #llmCallsConsumed} is the evaluator-side delta only; the
* agent-side delta is read from graph state by the node itself.
*
* <p>{@link #criterionVerdicts} and {@link #bootstrapCriteria} are mutually
* exclusive carriers for the checklist:
* <ul>
* <li><b>verdict round</b> (checklist already exists): {@code criterionVerdicts}
* holds the per-criterion delta (by id), {@code bootstrapCriteria} is null.</li>
* <li><b>bootstrap round</b> (no criteria yet): {@code bootstrapCriteria}
* holds the freshly decomposed full checklist, {@code criterionVerdicts}
* is empty.</li>
* </ul>
* 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<GoalChecklistVerdict.CriterionVerdict> criterionVerdicts,
List<GoalCriterion> 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<String, Object> 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;
}
}

View File

@ -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(),

View File

@ -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<GoalCriterion> normalizeInitialCriteria(List<GoalCriterion> raw) {
if (raw == null || raw.isEmpty()) {
return null;
}
List<GoalCriterion> 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<GoalCriterion> 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");

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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<GoalEventEntity> evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);