mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +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 lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request body for {@code POST /api/v1/goals}.
|
* Request body for {@code POST /api/v1/goals}.
|
||||||
*
|
*
|
||||||
@ -30,4 +32,12 @@ public class GoalCreateRequest {
|
|||||||
private Integer llmCallBudget;
|
private Integer llmCallBudget;
|
||||||
private Boolean autoFollowupEnabled;
|
private Boolean autoFollowupEnabled;
|
||||||
private Integer followupCooldownSeconds;
|
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)
|
@TableField(value = "completion_score", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private Double completionScore;
|
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)
|
@TableField(value = "last_evaluation_at", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private LocalDateTime lastEvaluationAt;
|
private LocalDateTime lastEvaluationAt;
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package vip.mate.goal.model;
|
package vip.mate.goal.model;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -8,15 +9,24 @@ import java.util.Map;
|
|||||||
* {@code GoalEvaluationService} to {@code GoalEvaluationNode} and on to
|
* {@code GoalEvaluationService} to {@code GoalEvaluationNode} and on to
|
||||||
* {@code GoalService.recordEvaluation}.
|
* {@code GoalService.recordEvaluation}.
|
||||||
*
|
*
|
||||||
* <p>Defined in PR1 so the service-layer signature is stable; the actual
|
* <p>{@link #completed} means "evaluator judged this turn satisfies every
|
||||||
* evaluator implementation lands in PR2.
|
* exit criterion". It does not mean "graph FINISH_REASON should change" —
|
||||||
*
|
* goal status and graph FinishReason are independent.
|
||||||
* <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 #llmCallsConsumed} is the evaluator-side delta only; the
|
* <p>{@link #llmCallsConsumed} is the evaluator-side delta only; the
|
||||||
* agent-side delta is read from graph state by the node itself.
|
* 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(
|
public record GoalEvaluationResult(
|
||||||
double score,
|
double score,
|
||||||
@ -25,7 +35,9 @@ public record GoalEvaluationResult(
|
|||||||
boolean completed,
|
boolean completed,
|
||||||
String evaluatorModel,
|
String evaluatorModel,
|
||||||
int llmCallsConsumed,
|
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_COMPLETED = "completed";
|
||||||
public static final String DECISION_CONTINUE = "continue";
|
public static final String DECISION_CONTINUE = "continue";
|
||||||
@ -37,7 +49,8 @@ public record GoalEvaluationResult(
|
|||||||
return new GoalEvaluationResult(
|
return new GoalEvaluationResult(
|
||||||
0.0, "evaluator unavailable: " + reason,
|
0.0, "evaluator unavailable: " + reason,
|
||||||
DECISION_FALLBACK, false,
|
DECISION_FALLBACK, false,
|
||||||
"", 0, 0L);
|
"", 0, 0L,
|
||||||
|
List.of(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> toMap() {
|
public Map<String, Object> toMap() {
|
||||||
@ -49,6 +62,9 @@ public record GoalEvaluationResult(
|
|||||||
m.put("evaluatorModel", evaluatorModel == null ? "" : evaluatorModel);
|
m.put("evaluatorModel", evaluatorModel == null ? "" : evaluatorModel);
|
||||||
m.put("llmCallsConsumed", llmCallsConsumed);
|
m.put("llmCallsConsumed", llmCallsConsumed);
|
||||||
m.put("latencyMs", latencyMs);
|
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;
|
return m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -271,7 +271,8 @@ public class GoalEvaluationService {
|
|||||||
: GoalEvaluationResult.DECISION_CONTINUE;
|
: GoalEvaluationResult.DECISION_CONTINUE;
|
||||||
return new GoalEvaluationResult(
|
return new GoalEvaluationResult(
|
||||||
score, gap, decision, completed,
|
score, gap, decision, completed,
|
||||||
modelName != null ? modelName : "", 1, latencyMs);
|
modelName != null ? modelName : "", 1, latencyMs,
|
||||||
|
List.of(), null);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[GoalEvaluation] JSON parse failed: {} — body={}",
|
log.warn("[GoalEvaluation] JSON parse failed: {} — body={}",
|
||||||
e.getMessage(),
|
e.getMessage(),
|
||||||
|
|||||||
@ -14,6 +14,7 @@ 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.GoalCriterion;
|
||||||
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;
|
||||||
@ -115,6 +116,10 @@ public class GoalServiceImpl implements GoalService {
|
|||||||
entity.setAutoFollowupEnabled(Boolean.TRUE.equals(req.getAutoFollowupEnabled()));
|
entity.setAutoFollowupEnabled(Boolean.TRUE.equals(req.getAutoFollowupEnabled()));
|
||||||
entity.setFollowupCooldownSeconds(req.getFollowupCooldownSeconds() != null
|
entity.setFollowupCooldownSeconds(req.getFollowupCooldownSeconds() != null
|
||||||
? req.getFollowupCooldownSeconds() : properties.getAutoFollowupCooldownSeconds());
|
? 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.setVersion(0);
|
||||||
entity.setDeleted(0);
|
entity.setDeleted(0);
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
@ -435,6 +440,44 @@ public class GoalServiceImpl implements GoalService {
|
|||||||
|
|
||||||
// ==================== Internals ====================
|
// ==================== 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) {
|
private void validateCreate(GoalCreateRequest req) {
|
||||||
if (req == null) {
|
if (req == null) {
|
||||||
throw new MateClawException("err.goal.bad_request", 400, "Request body required");
|
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.
|
// Synthesize a completion-style evaluation result for the audit trail.
|
||||||
GoalEvaluationResult synthetic = new GoalEvaluationResult(
|
GoalEvaluationResult synthetic = new GoalEvaluationResult(
|
||||||
1.0, "completed by agent", GoalEvaluationResult.DECISION_COMPLETED,
|
1.0, "completed by agent", GoalEvaluationResult.DECISION_COMPLETED,
|
||||||
true, "manual", 0, 0L);
|
true, "manual", 0, 0L,
|
||||||
|
java.util.List.of(), null);
|
||||||
try {
|
try {
|
||||||
GoalEntity completed = goalService.markCompleted(goal.getId(), synthetic);
|
GoalEntity completed = goalService.markCompleted(goal.getId(), synthetic);
|
||||||
// Broadcast a goal_completed event with the same shape as the
|
// 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(
|
return new GoalEvaluationResult(
|
||||||
score, "missing X",
|
score, "missing X",
|
||||||
decision, false,
|
decision, false,
|
||||||
"stub", 0, 0L);
|
"stub", 0, 0L,
|
||||||
|
java.util.List.of(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -241,7 +241,8 @@ class GoalServiceTest {
|
|||||||
|
|
||||||
GoalEvaluationResult r = new GoalEvaluationResult(
|
GoalEvaluationResult r = new GoalEvaluationResult(
|
||||||
0.62, "DNS still missing", "continue", false,
|
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);
|
service.recordEvaluation(1L, r, 3, 1);
|
||||||
|
|
||||||
ArgumentCaptor<GoalEventEntity> evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);
|
ArgumentCaptor<GoalEventEntity> evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user