mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(goal): structured checklist data model — criteria column, criterion records, dual-carrier evaluation result
This commit is contained in:
parent
c92bfa1f12
commit
cd1c66fc0e
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user