mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 12:27:53 +08:00
fix(goal): drop score gate, bill failed evaluator calls, fix Evaluator SPI + tool prompt + bootstrap cap
This commit is contained in:
parent
a756da7817
commit
3883b68cca
@ -43,8 +43,8 @@ public record GoalEvaluationResult(
|
|||||||
public static final String DECISION_CONTINUE = "continue";
|
public static final String DECISION_CONTINUE = "continue";
|
||||||
public static final String DECISION_FALLBACK = "fallback";
|
public static final String DECISION_FALLBACK = "fallback";
|
||||||
|
|
||||||
/** Failure fallback used when the evaluator LLM call errors out.
|
/** Failure fallback for the "no call was made" cases (no goal, empty
|
||||||
* Does NOT charge eval_llm_calls_used. */
|
* answer, no model). Does NOT charge eval_llm_calls_used. */
|
||||||
public static GoalEvaluationResult fallback(String reason) {
|
public static GoalEvaluationResult fallback(String reason) {
|
||||||
return new GoalEvaluationResult(
|
return new GoalEvaluationResult(
|
||||||
0.0, "evaluator unavailable: " + reason,
|
0.0, "evaluator unavailable: " + reason,
|
||||||
@ -53,6 +53,18 @@ public record GoalEvaluationResult(
|
|||||||
List.of(), null);
|
List.of(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Failure fallback for cases where the evaluator LLM call already
|
||||||
|
* succeeded but its output was unusable (empty / unparseable). The call
|
||||||
|
* was really spent, so it charges {@code llmCallsConsumed = 1} and
|
||||||
|
* records the model + latency for accurate budget accounting. */
|
||||||
|
public static GoalEvaluationResult fallbackAfterCall(String reason, String model, long latencyMs) {
|
||||||
|
return new GoalEvaluationResult(
|
||||||
|
0.0, "evaluator unavailable: " + reason,
|
||||||
|
DECISION_FALLBACK, false,
|
||||||
|
model == null ? "" : model, 1, latencyMs,
|
||||||
|
List.of(), null);
|
||||||
|
}
|
||||||
|
|
||||||
public Map<String, Object> toMap() {
|
public Map<String, Object> toMap() {
|
||||||
Map<String, Object> m = new LinkedHashMap<>();
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
m.put("completionScore", score);
|
m.put("completionScore", score);
|
||||||
|
|||||||
@ -145,7 +145,8 @@ public class GoalEvaluationService implements Evaluator {
|
|||||||
String body = extractText(response);
|
String body = extractText(response);
|
||||||
if (body == null || body.isBlank()) {
|
if (body == null || body.isBlank()) {
|
||||||
log.warn("[GoalEvaluation] empty response from evaluator model={}", model.getModelName());
|
log.warn("[GoalEvaluation] empty response from evaluator model={}", model.getModelName());
|
||||||
return GoalEvaluationResult.fallback("empty_response");
|
// The call was really spent — bill it.
|
||||||
|
return GoalEvaluationResult.fallbackAfterCall("empty_response", model.getModelName(), elapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
return bootstrap
|
return bootstrap
|
||||||
@ -162,24 +163,31 @@ public class GoalEvaluationService implements Evaluator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic SPI surface: judge whether {@code request.getResponseContent()}
|
* Generic SPI surface: judge whether {@code request.getResponseContent()}
|
||||||
* satisfies the objective in {@code request.getUserText()}. Used for
|
* satisfies the objective stated in {@code request.getUserText()}.
|
||||||
* standardization/testing; goal-aware callers use the
|
*
|
||||||
* {@link #evaluate(GoalEntity, List, String)} overload which carries the
|
* <p>The objective is wrapped as a single checklist criterion so the call
|
||||||
* checklist context the request cannot. Detail rides in metadata.
|
* runs in <b>verdict</b> mode (a real pass/fail judgement of the response),
|
||||||
|
* not bootstrap mode. {@code isPass()} is true only when that criterion is
|
||||||
|
* satisfied; detail rides in {@code metadata.criterionVerdicts}.
|
||||||
|
*
|
||||||
|
* <p>Goal-aware callers use the {@link #evaluate(GoalEntity, List, String)}
|
||||||
|
* overload, which carries the full multi-criterion checklist context the
|
||||||
|
* generic request cannot.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public EvaluationResponse evaluate(EvaluationRequest request) {
|
public EvaluationResponse evaluate(EvaluationRequest request) {
|
||||||
|
String objective = request.getUserText() != null ? request.getUserText() : "";
|
||||||
GoalEntity probe = new GoalEntity();
|
GoalEntity probe = new GoalEntity();
|
||||||
probe.setTitle(request.getUserText());
|
probe.setTitle("Does the response satisfy the objective?");
|
||||||
probe.setDescription("");
|
probe.setDescription(objective);
|
||||||
|
// One criterion = the objective -> non-empty criteria -> verdict mode.
|
||||||
|
probe.setCriteria(GoalCriteriaCodec.serialize(
|
||||||
|
List.of(new GoalCriterion("C1", objective, false, "")), objectMapper));
|
||||||
|
|
||||||
GoalEvaluationResult r = evaluate(probe, List.of(), request.getResponseContent());
|
GoalEvaluationResult r = evaluate(probe, List.of(), request.getResponseContent());
|
||||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||||
metadata.put("decision", r.decision());
|
metadata.put("decision", r.decision());
|
||||||
if (r.bootstrapCriteria() != null) {
|
metadata.put("criterionVerdicts", r.criterionVerdicts());
|
||||||
metadata.put("bootstrapCriteria", r.bootstrapCriteria());
|
|
||||||
} else {
|
|
||||||
metadata.put("criterionVerdicts", r.criterionVerdicts());
|
|
||||||
}
|
|
||||||
return new EvaluationResponse(r.completed(), (float) r.score(),
|
return new EvaluationResponse(r.completed(), (float) r.score(),
|
||||||
r.gap() == null ? "" : r.gap(), metadata);
|
r.gap() == null ? "" : r.gap(), metadata);
|
||||||
}
|
}
|
||||||
@ -267,16 +275,21 @@ public class GoalEvaluationService implements Evaluator {
|
|||||||
try {
|
try {
|
||||||
GoalCriteriaDraft dto = draftConverter.convert(stripFences(body));
|
GoalCriteriaDraft dto = draftConverter.convert(stripFences(body));
|
||||||
if (dto == null || dto.criteria() == null || dto.criteria().isEmpty()) {
|
if (dto == null || dto.criteria() == null || dto.criteria().isEmpty()) {
|
||||||
return GoalEvaluationResult.fallback("bootstrap_empty");
|
return GoalEvaluationResult.fallbackAfterCall("bootstrap_empty", modelName, latencyMs);
|
||||||
}
|
}
|
||||||
List<GoalCriterion> normalized = new ArrayList<>();
|
List<GoalCriterion> normalized = new ArrayList<>();
|
||||||
for (GoalCriterion c : dto.criteria()) {
|
for (GoalCriterion c : dto.criteria()) {
|
||||||
if (c != null && c.text() != null && !c.text().isBlank()) {
|
if (c != null && c.text() != null && !c.text().isBlank()) {
|
||||||
normalized.add(new GoalCriterion("", c.text().trim(), false, ""));
|
normalized.add(new GoalCriterion("", c.text().trim(), false, ""));
|
||||||
}
|
}
|
||||||
|
// Hard cap regardless of what the model returned — the prompt
|
||||||
|
// asks for <= MAX but a verbose model could exceed it.
|
||||||
|
if (normalized.size() >= MAX_BOOTSTRAP_CRITERIA) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (normalized.isEmpty()) {
|
if (normalized.isEmpty()) {
|
||||||
return GoalEvaluationResult.fallback("bootstrap_empty");
|
return GoalEvaluationResult.fallbackAfterCall("bootstrap_empty", modelName, latencyMs);
|
||||||
}
|
}
|
||||||
normalized = GoalCriteriaCodec.reindex(normalized);
|
normalized = GoalCriteriaCodec.reindex(normalized);
|
||||||
// Bootstrap never judges completion: the checklist is freshly created.
|
// Bootstrap never judges completion: the checklist is freshly created.
|
||||||
@ -286,7 +299,7 @@ public class GoalEvaluationService implements Evaluator {
|
|||||||
List.of(), normalized);
|
List.of(), normalized);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[GoalEvaluation] bootstrap parse failed: {}", e.getMessage());
|
log.warn("[GoalEvaluation] bootstrap parse failed: {}", e.getMessage());
|
||||||
return GoalEvaluationResult.fallback("parse_failed");
|
return GoalEvaluationResult.fallbackAfterCall("parse_failed", modelName, latencyMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -314,7 +327,7 @@ public class GoalEvaluationService implements Evaluator {
|
|||||||
deltas, null);
|
deltas, null);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[GoalEvaluation] verdict parse failed: {}", e.getMessage());
|
log.warn("[GoalEvaluation] verdict parse failed: {}", e.getMessage());
|
||||||
return GoalEvaluationResult.fallback("parse_failed");
|
return GoalEvaluationResult.fallbackAfterCall("parse_failed", modelName, latencyMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@ public class GoalFollowupService {
|
|||||||
* <li>{@code allow-auto-followup} runtime hard gate (operator kill
|
* <li>{@code allow-auto-followup} runtime hard gate (operator kill
|
||||||
* switch; overrides per-goal flag).</li>
|
* switch; overrides per-goal flag).</li>
|
||||||
* <li>Per-goal {@code autoFollowupEnabled}.</li>
|
* <li>Per-goal {@code autoFollowupEnabled}.</li>
|
||||||
* <li>Evaluator decision is "continue" with score < 0.95.</li>
|
* <li>Evaluator decision is "continue" (not all criteria passed).</li>
|
||||||
* <li>Cooldown since the last follow-up has elapsed.</li>
|
* <li>Cooldown since the last follow-up has elapsed.</li>
|
||||||
* <li>turn_budget has at least one slot left after this turn.</li>
|
* <li>turn_budget has at least one slot left after this turn.</li>
|
||||||
* <li>(agent + eval) LLM calls below 90% of llm_call_budget.</li>
|
* <li>(agent + eval) LLM calls below 90% of llm_call_budget.</li>
|
||||||
@ -49,10 +49,13 @@ public class GoalFollowupService {
|
|||||||
// Runtime hard gate first — overrides any per-goal flag.
|
// Runtime hard gate first — overrides any per-goal flag.
|
||||||
if (!properties.isAllowAutoFollowup()) return Optional.empty();
|
if (!properties.isAllowAutoFollowup()) return Optional.empty();
|
||||||
if (!Boolean.TRUE.equals(goal.getAutoFollowupEnabled())) return Optional.empty();
|
if (!Boolean.TRUE.equals(goal.getAutoFollowupEnabled())) return Optional.empty();
|
||||||
|
// Completion is deterministic now: the evaluator sets decision=completed
|
||||||
|
// only when every checklist criterion passed. Anything still "continue"
|
||||||
|
// has remaining work regardless of the numeric score, so there is no
|
||||||
|
// score threshold here — a 20/21 goal (score 0.95) must still follow up.
|
||||||
if (!GoalEvaluationResult.DECISION_CONTINUE.equals(result.decision())) {
|
if (!GoalEvaluationResult.DECISION_CONTINUE.equals(result.decision())) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
if (result.score() >= 0.95) return Optional.empty();
|
|
||||||
|
|
||||||
// Cooldown — last_followup_at recorded by recordFollowupInjected().
|
// Cooldown — last_followup_at recorded by recordFollowupInjected().
|
||||||
Integer cooldownSec = goal.getFollowupCooldownSeconds();
|
Integer cooldownSec = goal.getFollowupCooldownSeconds();
|
||||||
|
|||||||
@ -146,10 +146,14 @@ public class GoalManagementTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Tool(description = """
|
@Tool(description = """
|
||||||
Explicitly mark the active goal as completed. Use ONLY when all \
|
Explicitly mark the active goal as completed. Use ONLY when EVERY \
|
||||||
exit criteria are satisfied (e.g. tests passed, feature deployed, \
|
checklist criterion is genuinely satisfied with concrete evidence \
|
||||||
user confirmed). The runtime evaluator will also mark goals \
|
in the conversation (e.g. tests actually passed, feature actually \
|
||||||
completed automatically when score >= 0.95 — prefer that path.""")
|
deployed, user confirmed). Do NOT call this to close out work that \
|
||||||
|
is unfinished, blocked, or impossible. In normal operation you do \
|
||||||
|
not need this tool at all: the runtime evaluator marks the goal \
|
||||||
|
completed automatically once all checklist criteria pass — prefer \
|
||||||
|
that path and just keep working.""")
|
||||||
public String completeGoal(@Nullable ToolContext ctx) {
|
public String completeGoal(@Nullable ToolContext ctx) {
|
||||||
if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled");
|
if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled");
|
||||||
GoalEntity goal = resolveActive(ctx);
|
GoalEntity goal = resolveActive(ctx);
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import org.springframework.ai.chat.model.ChatModel;
|
|||||||
import org.springframework.ai.chat.model.ChatResponse;
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
import org.springframework.ai.chat.model.Generation;
|
import org.springframework.ai.chat.model.Generation;
|
||||||
import org.springframework.ai.chat.prompt.Prompt;
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
import org.springframework.ai.evaluation.EvaluationRequest;
|
||||||
|
import org.springframework.ai.evaluation.EvaluationResponse;
|
||||||
import org.springframework.retry.support.RetryTemplate;
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
import vip.mate.goal.config.GoalProperties;
|
import vip.mate.goal.config.GoalProperties;
|
||||||
import vip.mate.goal.model.GoalEntity;
|
import vip.mate.goal.model.GoalEntity;
|
||||||
@ -188,7 +190,8 @@ class GoalEvaluationServiceTest {
|
|||||||
stubChatResponse("I think it's about 60% done.");
|
stubChatResponse("I think it's about 60% done.");
|
||||||
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x");
|
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x");
|
||||||
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
|
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
|
||||||
assertEquals(0, r.llmCallsConsumed());
|
// The call was made and returned garbage — it still spends one call.
|
||||||
|
assertEquals(1, r.llmCallsConsumed());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -249,4 +252,54 @@ class GoalEvaluationServiceTest {
|
|||||||
assertFalse(r.completed());
|
assertFalse(r.completed());
|
||||||
assertTrue(r.gap().contains("evaluator unavailable"));
|
assertTrue(r.gap().contains("evaluator unavailable"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Post-call billing (failed output still spends a call) ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyResponseAfterCall_billsOneLlmCall() {
|
||||||
|
stubChatResponse(" ");
|
||||||
|
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x");
|
||||||
|
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
|
||||||
|
assertEquals(1, r.llmCallsConsumed(), "a spent-but-empty evaluator call must charge 1");
|
||||||
|
assertEquals("qwen-turbo", r.evaluatorModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseFailureAfterCall_billsOneLlmCall() {
|
||||||
|
stubChatResponse("{\"criteria\": }"); // malformed -> parse fail (call already spent)
|
||||||
|
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x");
|
||||||
|
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
|
||||||
|
assertEquals(1, r.llmCallsConsumed());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Bootstrap criteria cap ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bootstrap_capsCriteriaAtMax() {
|
||||||
|
StringBuilder sb = new StringBuilder("{\"criteria\":[");
|
||||||
|
for (int i = 0; i < 12; i++) {
|
||||||
|
if (i > 0) sb.append(',');
|
||||||
|
sb.append("{\"text\":\"criterion ").append(i).append("\"}");
|
||||||
|
}
|
||||||
|
sb.append("]}");
|
||||||
|
stubChatResponse(sb.toString());
|
||||||
|
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x");
|
||||||
|
assertNotNull(r.bootstrapCriteria());
|
||||||
|
assertTrue(r.bootstrapCriteria().size() <= 8,
|
||||||
|
"bootstrap must cap criteria at MAX_BOOTSTRAP_CRITERIA; got " + r.bootstrapCriteria().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Evaluator SPI ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evaluatorSpi_judgesResponseInVerdictMode() {
|
||||||
|
// The objective is wrapped as criterion C1; a verdict JSON marking C1
|
||||||
|
// passed must surface as isPass()=true with score 1.0 — NOT a bootstrap.
|
||||||
|
stubChatResponse("{\"criterionVerdicts\":[{\"id\":\"C1\",\"passed\":true,\"evidence\":\"matches\"}],\"summary\":\"ok\"}");
|
||||||
|
EvaluationResponse resp = svc.evaluate(
|
||||||
|
new EvaluationRequest("Return a greeting", "Hello, world!"));
|
||||||
|
assertTrue(resp.isPass());
|
||||||
|
assertEquals(1.0f, resp.getScore(), 1e-6);
|
||||||
|
assertTrue(resp.getMetadata().containsKey("criterionVerdicts"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,11 +75,14 @@ class GoalFollowupServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void highScore_returnsEmpty() {
|
void highScoreButStillContinue_followsUp() {
|
||||||
|
// No score gate: completion is decided by decision==completed, not a
|
||||||
|
// numeric threshold. A 20/21 goal (score ~0.95) that is still "continue"
|
||||||
|
// has remaining criteria and MUST follow up.
|
||||||
Optional<String> out = svc.maybeBuildFollowup(
|
Optional<String> out = svc.maybeBuildFollowup(
|
||||||
goal(true),
|
goal(true),
|
||||||
res(0.96, GoalEvaluationResult.DECISION_CONTINUE));
|
res(0.96, GoalEvaluationResult.DECISION_CONTINUE));
|
||||||
assertTrue(out.isEmpty());
|
assertTrue(out.isPresent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -1316,8 +1316,8 @@ export interface Goal {
|
|||||||
lastFollowupAt?: string | null
|
lastFollowupAt?: string | null
|
||||||
createTime: string
|
createTime: string
|
||||||
updateTime: string
|
updateTime: string
|
||||||
/** Parsed checklist; always an array on the wire (empty when none). */
|
/** Parsed checklist; the backend always sends an array (empty when none). */
|
||||||
criteria?: GoalCriterion[]
|
criteria: GoalCriterion[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GoalEvent {
|
export interface GoalEvent {
|
||||||
|
|||||||
@ -3842,6 +3842,7 @@ export default {
|
|||||||
inlinePromptAccept: 'Yes',
|
inlinePromptAccept: 'Yes',
|
||||||
inlinePromptDecline: 'No thanks',
|
inlinePromptDecline: 'No thanks',
|
||||||
autoFollowup: 'Auto continuation',
|
autoFollowup: 'Auto continuation',
|
||||||
|
sidebarActive: 'This conversation has an active goal',
|
||||||
completedTitle: 'Goal completed',
|
completedTitle: 'Goal completed',
|
||||||
completedDetail: 'Stored in long-term memory; askable later',
|
completedDetail: 'Stored in long-term memory; askable later',
|
||||||
exhaustedTitle: 'Budget exhausted',
|
exhaustedTitle: 'Budget exhausted',
|
||||||
|
|||||||
@ -3934,6 +3934,7 @@ export default {
|
|||||||
inlinePromptAccept: '好',
|
inlinePromptAccept: '好',
|
||||||
inlinePromptDecline: '不用',
|
inlinePromptDecline: '不用',
|
||||||
autoFollowup: '自动延续',
|
autoFollowup: '自动延续',
|
||||||
|
sidebarActive: '此对话有正在进行的目标',
|
||||||
completedTitle: '目标达成',
|
completedTitle: '目标达成',
|
||||||
completedDetail: '已存入长期记忆,下次问起能找回来',
|
completedDetail: '已存入长期记忆,下次问起能找回来',
|
||||||
exhaustedTitle: '这次的预算用完了',
|
exhaustedTitle: '这次的预算用完了',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user