incremnt 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -1
- package/package.json +2 -1
- package/src/ask-answer-verifier.js +857 -0
- package/src/ask-coach.js +2634 -0
- package/src/ask-replay.js +358 -0
- package/src/auth.js +169 -15
- package/src/coach-facts.js +14 -1
- package/src/contract.js +160 -3
- package/src/format.js +68 -2
- package/src/lib.js +205 -17
- package/src/mcp.js +88 -24
- package/src/openrouter.js +261 -33
- package/src/plan-changeset.js +132 -0
- package/src/plan-comparison.js +245 -0
- package/src/program-draft.js +230 -0
- package/src/prompt-changelog.js +184 -0
- package/src/promptfoo-evals.js +10 -4
- package/src/promptfoo-langfuse-scores.js +55 -0
- package/src/queries.js +1442 -786
- package/src/remote.js +465 -12
- package/src/score-context.js +14 -7
- package/src/score-prelude.js +113 -0
- package/src/service-url.js +9 -0
- package/src/summary-evals.js +1192 -44
- package/src/sync-service.js +1383 -367
- package/src/transport.js +119 -3
package/src/summary-evals.js
CHANGED
|
@@ -3,13 +3,16 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import {
|
|
5
5
|
askContext,
|
|
6
|
-
|
|
6
|
+
canonicalExerciseName,
|
|
7
7
|
checkpointContext,
|
|
8
8
|
cycleSummaryContext,
|
|
9
|
+
executeCoachReadTool,
|
|
9
10
|
normalizeExerciseName,
|
|
10
11
|
workoutSummaryContext,
|
|
11
12
|
vitalsSummaryContext
|
|
12
13
|
} from './queries.js';
|
|
14
|
+
import { askRoutedContext, buildAskStructuredResponse } from './ask-coach.js';
|
|
15
|
+
import { formatIncrementScorePrelude, isScoreQuestion } from './score-prelude.js';
|
|
13
16
|
import {
|
|
14
17
|
AI_PROMPT_VERSIONS,
|
|
15
18
|
generateAskAnswer,
|
|
@@ -19,6 +22,8 @@ import {
|
|
|
19
22
|
generateWorkoutCoachingSummary
|
|
20
23
|
} from './openrouter.js';
|
|
21
24
|
import { computeScoreBand } from './score-context.js';
|
|
25
|
+
import { stripXMLTagBlocks } from './prompt-security.js';
|
|
26
|
+
import { extractAskProgramDraft, hasProgramDraftBlock } from './program-draft.js';
|
|
22
27
|
|
|
23
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
24
29
|
const __dirname = path.dirname(__filename);
|
|
@@ -29,6 +34,14 @@ export function defaultCaseSetName() {
|
|
|
29
34
|
return process.env.SUMMARY_EVAL_CASE_SET || 'synthetic';
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
function envFlag(name, env = process.env) {
|
|
38
|
+
return ['1', 'true', 'yes'].includes(String(env[name] ?? '').toLowerCase());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function summaryEvalsLiveGenerationEnabled(env = process.env) {
|
|
42
|
+
return envFlag('SUMMARY_EVALS_LIVE', env) || envFlag('PROMPTFOO_LIVE', env);
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
function stableSortByDateDesc(items, selector) {
|
|
33
46
|
return [...items].sort((lhs, rhs) => String(selector(rhs)).localeCompare(String(selector(lhs))));
|
|
34
47
|
}
|
|
@@ -86,12 +99,22 @@ export function buildSummaryEvalContext(snapshot, testCase) {
|
|
|
86
99
|
return vitalsSummaryContext(snapshot, { exclude: new Set(testCase.exclude ?? []) });
|
|
87
100
|
case 'ask': {
|
|
88
101
|
const question = testCase.context?.question ?? testCase.question ?? '';
|
|
102
|
+
const today = testCase.context?.today ?? testCase.today ?? null;
|
|
103
|
+
const history = Array.isArray(testCase.context?.history) ? testCase.context.history : [];
|
|
89
104
|
const routed = question
|
|
90
|
-
? askRoutedContext(snapshot, question, { exclude: new Set(testCase.exclude ?? []) })
|
|
105
|
+
? askRoutedContext(snapshot, question, { exclude: new Set(testCase.exclude ?? []), history, today: today ?? new Date() })
|
|
91
106
|
: null;
|
|
107
|
+
// Mirror production: the live /cli/ask path prepends the Increment Score
|
|
108
|
+
// prelude to the routed context. Including it here means a live eval feeds
|
|
109
|
+
// the model the same dump-prone material, so evaluateAskScoreVoice actually
|
|
110
|
+
// guards the prompt, not just the checker.
|
|
111
|
+
const prelude = formatIncrementScorePrelude(scoreHistoryFromSnapshot(snapshot), { question });
|
|
112
|
+
const routedContext = routed?.context ?? null;
|
|
113
|
+
const trainingData = testCase.context?.trainingData
|
|
114
|
+
?? (prelude && routedContext ? `${prelude}\n\n${routedContext}` : (routedContext ?? prelude));
|
|
92
115
|
return {
|
|
93
116
|
...(testCase.context ?? {}),
|
|
94
|
-
trainingData
|
|
117
|
+
trainingData,
|
|
95
118
|
routedMetadata: routed?.metadata ?? null
|
|
96
119
|
};
|
|
97
120
|
}
|
|
@@ -115,11 +138,38 @@ function summaryEvalGenerationMetadata(result) {
|
|
|
115
138
|
);
|
|
116
139
|
}
|
|
117
140
|
|
|
141
|
+
function buildAskEvalStructuredMetadata(testCase, context, output) {
|
|
142
|
+
if (testCase.surface !== 'ask') return {};
|
|
143
|
+
const parsedAsk = extractAskProgramDraft(output, {
|
|
144
|
+
canonicalizeExerciseName: canonicalExerciseName
|
|
145
|
+
});
|
|
146
|
+
const answer = stripXMLTagBlocks(parsedAsk.answerText);
|
|
147
|
+
const question = context?.question ?? testCase.context?.question ?? testCase.question ?? '';
|
|
148
|
+
const routingMetadata = context?.routedMetadata ?? null;
|
|
149
|
+
return {
|
|
150
|
+
routingMetadata,
|
|
151
|
+
structured: buildAskStructuredResponse(answer, routingMetadata ?? {}, {
|
|
152
|
+
programDraft: askStructuredProgramDraft(parsedAsk, routingMetadata),
|
|
153
|
+
question
|
|
154
|
+
})
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function summaryEvalProviderMetadata(testCase, context, output, result = null) {
|
|
159
|
+
return {
|
|
160
|
+
...summaryEvalGenerationMetadata(result),
|
|
161
|
+
...buildAskEvalStructuredMetadata(testCase, context, output)
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
118
165
|
export async function generateSummaryEvalOutputWithMetadata(testCase, context, snapshot = null) {
|
|
119
|
-
const liveGenerationEnabled =
|
|
166
|
+
const liveGenerationEnabled = summaryEvalsLiveGenerationEnabled();
|
|
120
167
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
121
168
|
if (!liveGenerationEnabled || !apiKey || testCase.shouldPass === false) {
|
|
122
|
-
return {
|
|
169
|
+
return {
|
|
170
|
+
output: testCase.output,
|
|
171
|
+
metadata: summaryEvalProviderMetadata(testCase, context, testCase.output)
|
|
172
|
+
};
|
|
123
173
|
}
|
|
124
174
|
|
|
125
175
|
let result;
|
|
@@ -149,7 +199,8 @@ export async function generateSummaryEvalOutputWithMetadata(testCase, context, s
|
|
|
149
199
|
apiKey,
|
|
150
200
|
history: context.history ?? [],
|
|
151
201
|
tone: context.tone,
|
|
152
|
-
model: context.model
|
|
202
|
+
model: context.model,
|
|
203
|
+
routingMetadata: context.routedMetadata ?? undefined
|
|
153
204
|
});
|
|
154
205
|
break;
|
|
155
206
|
}
|
|
@@ -159,7 +210,7 @@ export async function generateSummaryEvalOutputWithMetadata(testCase, context, s
|
|
|
159
210
|
|
|
160
211
|
return {
|
|
161
212
|
output: result.text,
|
|
162
|
-
metadata:
|
|
213
|
+
metadata: summaryEvalProviderMetadata(testCase, context, result.text, result)
|
|
163
214
|
};
|
|
164
215
|
}
|
|
165
216
|
|
|
@@ -258,8 +309,44 @@ function isSingleParagraph(text) {
|
|
|
258
309
|
return !normalizeText(text).includes('\n\n');
|
|
259
310
|
}
|
|
260
311
|
|
|
261
|
-
|
|
262
|
-
|
|
312
|
+
// Canonicalizes free-form coach text and required-mention snippets to the same
|
|
313
|
+
// surface form before substring matching. The goal is to keep grounding checks
|
|
314
|
+
// (does the answer cite this real number?) while tolerating the formatting an
|
|
315
|
+
// LLM legitimately varies: unicode × vs ASCII x, set-token unit placement,
|
|
316
|
+
// signed deltas, and rep-sequence separators (8/8/7 vs "8, 8, and 7").
|
|
317
|
+
// It only adds equivalences; it never strips the digits a check is grounded on,
|
|
318
|
+
// so a genuinely absent number still fails.
|
|
319
|
+
function normalizeForMention(value) {
|
|
320
|
+
let s = normalizeText(value).toLowerCase();
|
|
321
|
+
// Unicode multiplication / bullet / asterisk between digits -> ASCII x.
|
|
322
|
+
s = s.replace(/(\d)\s*[×✕╳·∗*]\s*(\d)/g, '$1x$2');
|
|
323
|
+
// Drop weight units that sit inside set tokens: "80kg x 7" / "40 kg" -> "80 x 7" / "40".
|
|
324
|
+
s = s.replace(/(\d(?:\.\d+)?)\s*(?:kgs?|lbs?|pounds)\b/g, '$1');
|
|
325
|
+
// Collapse spaces around an x that joins two numbers: "80 x 7" -> "80x7".
|
|
326
|
+
s = s.replace(/(\d)\s*x\s*(?=\d)/g, '$1x');
|
|
327
|
+
// Unify rep-sequence separators: "8/8/7" and "8, 8, and 7" -> "8,8,7".
|
|
328
|
+
// Lookahead keeps the trailing digit so chained separators all collapse.
|
|
329
|
+
s = s.replace(/(\d)\s*\/\s*(?=\d)/g, '$1,');
|
|
330
|
+
s = s.replace(/(\d)\s*,\s*and\s+(?=\d)/g, '$1,');
|
|
331
|
+
s = s.replace(/(\d)\s*,\s*(?=\d)/g, '$1,');
|
|
332
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// A required/any-of mention may be a string or an array of acceptable
|
|
336
|
+
// alternatives (matches if any alternative is present). Arrays express
|
|
337
|
+
// AND-of-ORs at the fixture level: every top-level entry must match, and an
|
|
338
|
+
// array entry matches when any of its phrasings appears.
|
|
339
|
+
function mentionMatches(output, mention) {
|
|
340
|
+
const normalizedOutput = normalizeForMention(output);
|
|
341
|
+
const alternatives = Array.isArray(mention) ? mention : [mention];
|
|
342
|
+
return alternatives
|
|
343
|
+
.map((alternative) => normalizeForMention(alternative))
|
|
344
|
+
.filter(Boolean)
|
|
345
|
+
.some((alternative) => normalizedOutput.includes(alternative));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function describeMention(mention) {
|
|
349
|
+
return Array.isArray(mention) ? `one of [${mention.join(' | ')}]` : String(mention);
|
|
263
350
|
}
|
|
264
351
|
|
|
265
352
|
function phraseIncludes(text, snippet) {
|
|
@@ -305,7 +392,6 @@ const historicalExerciseModifiers = new Set([
|
|
|
305
392
|
'leg',
|
|
306
393
|
'weighted',
|
|
307
394
|
'romanian',
|
|
308
|
-
'hack',
|
|
309
395
|
'full',
|
|
310
396
|
'grip'
|
|
311
397
|
]);
|
|
@@ -358,6 +444,18 @@ function collectAllowedExerciseNames(surface, context) {
|
|
|
358
444
|
for (const sc of context.planComparison?.setsComparison ?? []) {
|
|
359
445
|
if (sc.exercise) names.add(sc.exercise);
|
|
360
446
|
}
|
|
447
|
+
// Planned-but-skipped and unplanned-but-added exercises are legitimate
|
|
448
|
+
// planned-vs-actual subjects: the model is handed "skipped X, added Y" in
|
|
449
|
+
// its context, so a correct "you skipped X" note must not be flagged as an
|
|
450
|
+
// unauthorized mention. `setsComparison` only carries *performed* planned
|
|
451
|
+
// exercises (queries.js builds it with a performedNames filter), so skipped
|
|
452
|
+
// lifts would otherwise never be authorized.
|
|
453
|
+
for (const exerciseName of context.planComparison?.skipped ?? []) {
|
|
454
|
+
if (exerciseName) names.add(exerciseName);
|
|
455
|
+
}
|
|
456
|
+
for (const exerciseName of context.planComparison?.added ?? []) {
|
|
457
|
+
if (exerciseName) names.add(exerciseName);
|
|
458
|
+
}
|
|
361
459
|
}
|
|
362
460
|
|
|
363
461
|
if (surface === 'cycle' && context && typeof context === 'object') {
|
|
@@ -399,6 +497,49 @@ function collectAllowedExerciseNames(surface, context) {
|
|
|
399
497
|
return [...names];
|
|
400
498
|
}
|
|
401
499
|
|
|
500
|
+
// Project the allow-set from the actual context object the model was handed.
|
|
501
|
+
// Any known exercise name (from the snapshot's vocabulary) that appears anywhere
|
|
502
|
+
// in the serialized context — structured fields, plan comparison, prior-session
|
|
503
|
+
// comparisons, nearby cardio, or free-text session/exercise notes — is something
|
|
504
|
+
// the model could legitimately reference. Deriving authorization this way means
|
|
505
|
+
// the allow-set can never drift behind a newly added context field: the failure
|
|
506
|
+
// mode that flagged a correct "you skipped Hack Squat" note and a note-echoed
|
|
507
|
+
// lift. A genuine invention is a known exercise present in the output but absent
|
|
508
|
+
// from the context entirely.
|
|
509
|
+
function collectContextExerciseNames(context, knownNames) {
|
|
510
|
+
if (!context || typeof context !== 'object') return [];
|
|
511
|
+
let serialized;
|
|
512
|
+
try {
|
|
513
|
+
serialized = JSON.stringify(context);
|
|
514
|
+
} catch {
|
|
515
|
+
return [];
|
|
516
|
+
}
|
|
517
|
+
const contextText = normalizeExerciseName(serialized);
|
|
518
|
+
if (!contextText) return [];
|
|
519
|
+
const matches = [];
|
|
520
|
+
for (const name of knownNames) {
|
|
521
|
+
const normalized = normalizeExerciseName(name);
|
|
522
|
+
if (!normalized) continue;
|
|
523
|
+
const pattern = new RegExp(`(?<!\\S)${escapeRegex(normalized)}(?!\\S)`, 'g');
|
|
524
|
+
for (const match of contextText.matchAll(pattern)) {
|
|
525
|
+
matches.push({
|
|
526
|
+
name,
|
|
527
|
+
normalized,
|
|
528
|
+
start: match.index,
|
|
529
|
+
end: (match.index ?? 0) + normalized.length
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return uniqueStrings(matches
|
|
534
|
+
.filter((match) => !matches.some((candidate) =>
|
|
535
|
+
candidate !== match &&
|
|
536
|
+
candidate.normalized.length > match.normalized.length &&
|
|
537
|
+
candidate.start <= match.start &&
|
|
538
|
+
candidate.end >= match.end
|
|
539
|
+
))
|
|
540
|
+
.map((match) => match.name));
|
|
541
|
+
}
|
|
542
|
+
|
|
402
543
|
function historicalExerciseVariants(name) {
|
|
403
544
|
const normalized = normalizeExerciseName(name);
|
|
404
545
|
if (!normalized) return [];
|
|
@@ -431,9 +572,15 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
|
|
|
431
572
|
|
|
432
573
|
const outputText = surface === 'scoreCommentary' ? scoreCommentaryText(output) : output;
|
|
433
574
|
const isStored = testCase.source === 'stored';
|
|
575
|
+
const allNames = collectAllExerciseNames(snapshot);
|
|
576
|
+
|
|
577
|
+
// Union the hand-built field list with a projection of the actual context, so
|
|
578
|
+
// this can only ever *reduce* false positives. testCase.allowedExerciseMentions
|
|
579
|
+
// stays as an explicit override for cases that need it.
|
|
434
580
|
const allowed = new Set();
|
|
435
581
|
for (const name of [
|
|
436
582
|
...collectAllowedExerciseNames(surface, context),
|
|
583
|
+
...collectContextExerciseNames(context, allNames),
|
|
437
584
|
...(testCase.allowedExerciseMentions ?? [])
|
|
438
585
|
]) {
|
|
439
586
|
const variants = isStored ? historicalExerciseVariants(name) : [normalizeExerciseName(name)];
|
|
@@ -441,7 +588,6 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
|
|
|
441
588
|
allowed.add(variant);
|
|
442
589
|
}
|
|
443
590
|
}
|
|
444
|
-
const allNames = collectAllExerciseNames(snapshot);
|
|
445
591
|
const normalizedOutput = normalizeExerciseName(outputText);
|
|
446
592
|
const mentions = [];
|
|
447
593
|
|
|
@@ -463,8 +609,14 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
|
|
|
463
609
|
|
|
464
610
|
const unauthorized = mentions
|
|
465
611
|
.filter((mention) => !mention.allowed)
|
|
612
|
+
// Collapse a shorter mention into any longer mention that spans the same
|
|
613
|
+
// text — regardless of whether the covering mention is itself allowed.
|
|
614
|
+
// "Squat" matches inside "Hack Squat" (whitespace word boundary), so without
|
|
615
|
+
// this an unauthorized "Hack Squat" was double-counted as both "Hack Squat"
|
|
616
|
+
// and "Squat". The covering mention carries the real verdict; the substring
|
|
617
|
+
// is never a distinct mention.
|
|
466
618
|
.filter((mention) => !mentions.some((candidate) =>
|
|
467
|
-
candidate
|
|
619
|
+
candidate !== mention &&
|
|
468
620
|
candidate.normalizedName.length > mention.normalizedName.length &&
|
|
469
621
|
candidate.start <= mention.start &&
|
|
470
622
|
candidate.end >= mention.end
|
|
@@ -481,11 +633,14 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
|
|
|
481
633
|
}
|
|
482
634
|
|
|
483
635
|
function evaluateRequiredMentions(output, testCase) {
|
|
484
|
-
const
|
|
636
|
+
const mentions = Array.isArray(testCase.requiredMentions) ? testCase.requiredMentions : [];
|
|
637
|
+
const missing = mentions.filter((mention) => !mentionMatches(output, mention));
|
|
485
638
|
return {
|
|
486
639
|
key: 'required_mentions',
|
|
487
640
|
passed: missing.length === 0,
|
|
488
|
-
reason: missing.length === 0
|
|
641
|
+
reason: missing.length === 0
|
|
642
|
+
? 'All required mentions present.'
|
|
643
|
+
: `Missing required mention(s): ${missing.map(describeMention).join(', ')}`
|
|
489
644
|
};
|
|
490
645
|
}
|
|
491
646
|
|
|
@@ -499,7 +654,7 @@ function evaluateAnyOfMentions(output, testCase) {
|
|
|
499
654
|
};
|
|
500
655
|
}
|
|
501
656
|
|
|
502
|
-
const matched = candidates.some((mention) =>
|
|
657
|
+
const matched = candidates.some((mention) => mentionMatches(output, mention));
|
|
503
658
|
return {
|
|
504
659
|
key: 'required_any_of_mentions',
|
|
505
660
|
passed: matched,
|
|
@@ -1095,19 +1250,48 @@ function hasAskFatigueSupport(snapshot, lookbackDays = 7) {
|
|
|
1095
1250
|
return false;
|
|
1096
1251
|
}
|
|
1097
1252
|
|
|
1253
|
+
function parseWeightNumber(raw) {
|
|
1254
|
+
return Number(String(raw).replace(/,/g, ''));
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1098
1257
|
function extractAskWeightClaims(text) {
|
|
1099
1258
|
const claims = [];
|
|
1100
|
-
|
|
1259
|
+
// Accept comma-grouped thousands ("40,500 kg") as a single number so volume
|
|
1260
|
+
// figures are not shredded into bogus "500 kg" / "000 kg" claims. Volume/total
|
|
1261
|
+
// figures are excluded by isVolumeWeightClaim at the call sites, not by a
|
|
1262
|
+
// magnitude cap — heavy machine work (leg press, sled) legitimately exceeds
|
|
1263
|
+
// 1000 kg, and a fabricated heavy load must still be graded.
|
|
1264
|
+
const pattern = /\b(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)\s*(?:kg|kilograms?)\b/gi;
|
|
1101
1265
|
for (const match of text.matchAll(pattern)) {
|
|
1102
1266
|
claims.push({
|
|
1103
1267
|
text: match[0],
|
|
1104
|
-
value:
|
|
1268
|
+
value: parseWeightNumber(match[1]),
|
|
1105
1269
|
index: match.index ?? -1
|
|
1106
1270
|
});
|
|
1107
1271
|
}
|
|
1108
1272
|
return claims;
|
|
1109
1273
|
}
|
|
1110
1274
|
|
|
1275
|
+
function extractAskWeightedSetClaims(text) {
|
|
1276
|
+
const claims = [];
|
|
1277
|
+
// A weight×reps pair is only unambiguous with "x"/"×" (e.g. "70 kg x 5"), or
|
|
1278
|
+
// an explicit "for N rep(s)". Bare "X kg for N" is NOT a rep claim — N is
|
|
1279
|
+
// almost always a SET count ("70 kg for 4 working sets") or a duration, and
|
|
1280
|
+
// treating it as reps flags real data as a fabricated pair. So match only the
|
|
1281
|
+
// unambiguous forms; the plain-weight loop still grounds the weight itself.
|
|
1282
|
+
const pattern = /\b(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)\s*(?:kg|kilograms?)\s*(?:(?:x|×)\s*(\d+)|for\s+(\d+)\s*reps?)\b/gi;
|
|
1283
|
+
for (const match of text.matchAll(pattern)) {
|
|
1284
|
+
claims.push({
|
|
1285
|
+
text: match[0],
|
|
1286
|
+
weight: parseWeightNumber(match[1]),
|
|
1287
|
+
reps: Number(match[2] ?? match[3]),
|
|
1288
|
+
index: match.index ?? -1,
|
|
1289
|
+
end: (match.index ?? -1) + match[0].length
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
return claims;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1111
1295
|
function allowedWeightsForExercise(snapshot, normalizedExerciseName) {
|
|
1112
1296
|
const weights = [];
|
|
1113
1297
|
for (const session of snapshot?.sessions ?? []) {
|
|
@@ -1143,10 +1327,359 @@ function isEstimatedOneRepMaxWeightClaim(text, claim) {
|
|
|
1143
1327
|
}
|
|
1144
1328
|
|
|
1145
1329
|
function isVolumeWeightClaim(text, claim) {
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1330
|
+
// A kg figure in a clause about volume/tonnage/total load is a workload total
|
|
1331
|
+
// (e.g. "weekly strength volume fell from 44,000 kg to 40,500 kg"), not an
|
|
1332
|
+
// exercise load. Scope to the claim's clause so a fabricated exercise load
|
|
1333
|
+
// earlier in the same sentence is still graded.
|
|
1334
|
+
return /\b(?:volume|tonnage|total\s+(?:load|work|volume|tonnage))\b/i.test(claimClause(text, claim));
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function claimClause(text, claim) {
|
|
1338
|
+
const boundaries = [
|
|
1339
|
+
'\n',
|
|
1340
|
+
'. ',
|
|
1341
|
+
';',
|
|
1342
|
+
', while',
|
|
1343
|
+
', whereas',
|
|
1344
|
+
', but',
|
|
1345
|
+
' while ',
|
|
1346
|
+
' whereas ',
|
|
1347
|
+
' but '
|
|
1348
|
+
];
|
|
1349
|
+
let start = 0;
|
|
1350
|
+
for (const boundary of boundaries) {
|
|
1351
|
+
const index = text.lastIndexOf(boundary, claim.index);
|
|
1352
|
+
if (index >= 0) start = Math.max(start, index + boundary.length);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
let end = text.length;
|
|
1356
|
+
for (const boundary of boundaries) {
|
|
1357
|
+
const index = text.indexOf(boundary, claim.index + claim.text.length);
|
|
1358
|
+
if (index >= 0) end = Math.min(end, index);
|
|
1359
|
+
}
|
|
1360
|
+
return text.slice(start, end);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Returns the sentence containing the claim, so context guards can look at the
|
|
1364
|
+
// whole clause rather than a fixed-width window (body-weight phrasing can put
|
|
1365
|
+
// the "body weight" anchor well before the kg figure).
|
|
1366
|
+
function claimSentence(text, claim) {
|
|
1367
|
+
const before = text.slice(0, claim.index);
|
|
1368
|
+
const startBreak = Math.max(before.lastIndexOf('. '), before.lastIndexOf('\n'));
|
|
1369
|
+
const start = startBreak >= 0 ? startBreak + 1 : 0;
|
|
1370
|
+
const after = text.slice(claim.index);
|
|
1371
|
+
const breaks = [after.indexOf('. '), after.indexOf('\n')].filter((i) => i >= 0);
|
|
1372
|
+
const end = breaks.length ? claim.index + Math.min(...breaks) : text.length;
|
|
1373
|
+
return text.slice(start, end);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Body-weight figures ("body weight is up 0.6 kg", "80.0 kg latest") are not
|
|
1377
|
+
// exercise-load claims. findNearestMentionedExercise would otherwise attribute
|
|
1378
|
+
// them to the previously named lift and flag a correct answer as a
|
|
1379
|
+
// hallucination, so skip any kg figure stated in a body-weight clause.
|
|
1380
|
+
function isBodyWeightClaim(text, claim) {
|
|
1381
|
+
return /\bbody\s*weight\b|\bbodyweight\b|\bweight\s+trend\b/i.test(claimSentence(text, claim));
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function askWorkingTopSetRows(snapshot) {
|
|
1385
|
+
const rows = [];
|
|
1386
|
+
for (const session of stableSortByDateDesc(snapshot?.sessions ?? [], (session) => session.completedAt ?? session.date)) {
|
|
1387
|
+
const completedAt = session.completedAt ?? session.date;
|
|
1388
|
+
for (const exercise of session.exercises ?? []) {
|
|
1389
|
+
const workingSets = (exercise.sets ?? [])
|
|
1390
|
+
.filter((set) => set?.isComplete && !set?.isWarmup)
|
|
1391
|
+
.map((set) => ({
|
|
1392
|
+
weight: Number(set.weight) || 0,
|
|
1393
|
+
reps: Number(set.reps) || 0
|
|
1394
|
+
}));
|
|
1395
|
+
if (workingSets.length === 0) continue;
|
|
1396
|
+
const topSet = workingSets.sort((a, b) => b.weight - a.weight || b.reps - a.reps)[0];
|
|
1397
|
+
rows.push({
|
|
1398
|
+
sessionId: session.id ?? null,
|
|
1399
|
+
date: String(completedAt ?? '').slice(0, 10),
|
|
1400
|
+
exerciseName: exercise.name,
|
|
1401
|
+
normalizedName: normalizeExerciseName(exercise.name),
|
|
1402
|
+
...topSet
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
return rows;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function daysAgoForEval(date, testCase) {
|
|
1410
|
+
const today = testCase.context?.today ?? testCase.today;
|
|
1411
|
+
if (!today) return null;
|
|
1412
|
+
const dateMs = Date.parse(`${String(date ?? '').slice(0, 10)}T00:00:00.000Z`);
|
|
1413
|
+
const todayMs = Date.parse(`${String(today).slice(0, 10)}T00:00:00.000Z`);
|
|
1414
|
+
if (!Number.isFinite(dateMs) || !Number.isFinite(todayMs)) return null;
|
|
1415
|
+
return Math.max(0, Math.round((todayMs - dateMs) / (24 * 60 * 60 * 1000)));
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function hasUnqualifiedDeclineLanguage(window) {
|
|
1419
|
+
const text = normalizeText(window);
|
|
1420
|
+
const decline = /\b(drop(?:ped|ping|s)?(?: off)?|drop-off|declin(?:e|ed|ing)|regress(?:ed|ion|ing)?|fell|fall(?:ing)?|decreas(?:e|ed|ing)|lower|worse|slid|slipped)\b/i;
|
|
1421
|
+
if (!decline.test(text)) return false;
|
|
1422
|
+
if (/\b(?:no|not|isn'?t|wasn'?t|without|rather than)\b.{0,45}\b(drop(?:ped|ping|s)?(?: off)?|drop-off|declin(?:e|ed|ing)?|decreas(?:e|ed|ing)?|regress(?:ed|ion|ing)?|fall(?:ing)?|fell|lower|worse|slid|slipped)\b/i.test(text)) return false;
|
|
1423
|
+
if (/\b(?:rep|reps)\b.{0,20}\b(drop(?:ped|ping|s)?(?: off)?|drop-off|slip(?:ped|ping)?|fell|fall(?:ing)?|lower|declin(?:e|ed|ing)?|decreas(?:e|ed|ing)?|worse)\b/i.test(text)) return false;
|
|
1424
|
+
if (/\b(drop(?:ped|ping|s)?(?: off)?|drop-off|slip(?:ped|ping)?|fell|fall(?:ing)?|lower|declin(?:e|ed|ing)?|decreas(?:e|ed|ing)?|worse)\b.{0,20}\b(?:rep|reps)\b/i.test(text)) return false;
|
|
1425
|
+
return true;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function hasUnqualifiedImprovementLanguage(window) {
|
|
1429
|
+
const text = normalizeText(window);
|
|
1430
|
+
const improvement = /\b(improv(?:e|ed|ing|ement)|progress(?:ed|ing)?|stronger|increas(?:e|ed|ing)|moving up|went up|up from|load jump|jumped)\b/i;
|
|
1431
|
+
if (!improvement.test(text)) return false;
|
|
1432
|
+
if (/\b(?:no|not|isn'?t|wasn'?t|without|rather than)\b.{0,35}\b(improv(?:e|ed|ing|ement)?|progress(?:ed|ing)?|stronger|increas(?:e|ed|ing)?|moving up|went up|up from|load jump|jump(?:ed|ing)?)\b/i.test(text)) return false;
|
|
1433
|
+
if (/\b(?:rep|reps)\b.{0,20}\b(improv(?:e|ed|ing|ement)?|increas(?:e|ed|ing)?|better|moving up|went up|up from|load jump|jump(?:ed|ing)?)\b/i.test(text)) return false;
|
|
1434
|
+
if (/\b(improv(?:e|ed|ing|ement)?|increas(?:e|ed|ing)?|better|moving up|went up|up from|load jump|jump(?:ed|ing)?)\b.{0,20}\b(?:rep|reps)\b/i.test(text)) return false;
|
|
1435
|
+
return true;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
function isReferentialDirectionContinuation(sentence) {
|
|
1439
|
+
return /^(?:that|this|it|there|still|the\s+(?:latest|top)|top\s+set|same\s+load)\b/i.test(sentence);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function directionEvaluationWindows(outputText, exerciseName, exerciseNames = []) {
|
|
1443
|
+
const normalizedExercise = normalizeExerciseName(exerciseName);
|
|
1444
|
+
const otherExercises = [...new Set(exerciseNames.map(normalizeExerciseName))]
|
|
1445
|
+
.filter((name) => name && name !== normalizedExercise);
|
|
1446
|
+
const sentences = outputText
|
|
1447
|
+
.split(/(?<=[.!?])\s+/)
|
|
1448
|
+
.map((sentence) => sentence.trim())
|
|
1449
|
+
.filter(Boolean);
|
|
1450
|
+
if (!normalizedExercise) return sentences;
|
|
1451
|
+
const windows = [];
|
|
1452
|
+
for (let index = 0; index < sentences.length; index++) {
|
|
1453
|
+
if (!normalizeExerciseName(sentences[index]).includes(normalizedExercise)) continue;
|
|
1454
|
+
windows.push(sentences[index]);
|
|
1455
|
+
for (let nextIndex = index + 1; nextIndex < sentences.length; nextIndex++) {
|
|
1456
|
+
const normalizedNext = normalizeExerciseName(sentences[nextIndex]);
|
|
1457
|
+
if (otherExercises.some((name) => normalizedNext.includes(name))) break;
|
|
1458
|
+
if (!isReferentialDirectionContinuation(sentences[nextIndex])) break;
|
|
1459
|
+
windows.push(sentences[nextIndex]);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
return windows.length > 0 ? [...new Set(windows)] : [outputText];
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function evaluateAskDirectionalConsistency(output, snapshot, testCase) {
|
|
1466
|
+
if (testCase.surface !== 'ask') {
|
|
1467
|
+
return { key: 'ask_directional_consistency', passed: true, reason: 'Not an ask answer.' };
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const required = Array.isArray(testCase.directionalConsistency)
|
|
1471
|
+
? testCase.directionalConsistency
|
|
1472
|
+
: [];
|
|
1473
|
+
if (required.length === 0) {
|
|
1474
|
+
return { key: 'ask_directional_consistency', passed: true, reason: 'No directional assertions configured.' };
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const rows = askWorkingTopSetRows(snapshot);
|
|
1478
|
+
const outputText = normalizeText(output);
|
|
1479
|
+
const failures = [];
|
|
1480
|
+
|
|
1481
|
+
for (const expectation of required) {
|
|
1482
|
+
const normalizedName = normalizeExerciseName(expectation.exercise ?? expectation.exerciseName);
|
|
1483
|
+
const history = rows.filter((row) => row.normalizedName === normalizedName);
|
|
1484
|
+
if (history.length < 2) continue;
|
|
1485
|
+
const latest = history[0];
|
|
1486
|
+
const previous = history[1];
|
|
1487
|
+
const loadDelta = latest.weight - previous.weight;
|
|
1488
|
+
const actualDirection = loadDelta > 0 ? 'up' : loadDelta < 0 ? 'down' : 'flat';
|
|
1489
|
+
const expectedDirection = expectation.loadDirection ?? actualDirection;
|
|
1490
|
+
if (expectedDirection !== actualDirection) {
|
|
1491
|
+
failures.push(`Configured expected direction for ${latest.exerciseName} is ${expectedDirection}, but snapshot top-load direction is ${actualDirection}.`);
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const windows = directionEvaluationWindows(
|
|
1496
|
+
outputText,
|
|
1497
|
+
expectation.exercise ?? expectation.exerciseName,
|
|
1498
|
+
rows.map((row) => row.exerciseName)
|
|
1499
|
+
);
|
|
1500
|
+
if (actualDirection === 'up' && windows.some(hasUnqualifiedDeclineLanguage)) {
|
|
1501
|
+
failures.push(`Ask answer frames ${latest.exerciseName} as declining/drop-off even though top load increased from ${previous.weight} kg to ${latest.weight} kg.`);
|
|
1502
|
+
}
|
|
1503
|
+
if (actualDirection === 'down' && windows.some(hasUnqualifiedImprovementLanguage)) {
|
|
1504
|
+
failures.push(`Ask answer frames ${latest.exerciseName} as improving even though top load decreased from ${previous.weight} kg to ${latest.weight} kg.`);
|
|
1505
|
+
}
|
|
1506
|
+
if (actualDirection === 'flat' && windows.some((window) => hasUnqualifiedDeclineLanguage(window) || hasUnqualifiedImprovementLanguage(window))) {
|
|
1507
|
+
failures.push(`Ask answer invents a load direction for ${latest.exerciseName}, but top load was flat at ${latest.weight} kg.`);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
return {
|
|
1512
|
+
key: 'ask_directional_consistency',
|
|
1513
|
+
passed: failures.length === 0,
|
|
1514
|
+
reason: failures.length === 0
|
|
1515
|
+
? 'Ask answer does not invert configured load directions.'
|
|
1516
|
+
: failures.join(' ')
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// Increment Score component names. Recited with a number, these are the raw
|
|
1521
|
+
// sub-scores the coach-observation-voice spec marks Tier 1 — never surface.
|
|
1522
|
+
const SCORE_COMPONENT_NAMES = ['coverage', 'stimulus', 'execution', 'progression', 'recovery'];
|
|
1523
|
+
|
|
1524
|
+
// A score-like magnitude: 1-3 digits, optional one decimal place.
|
|
1525
|
+
const SCORE_NUMBER = '\\d{1,3}(?:\\.\\d+)?';
|
|
1526
|
+
|
|
1527
|
+
// Contexts that mean the number is real-world data — reps, load, time, counts,
|
|
1528
|
+
// ratios, percentages, the /100 headline — not a raw component sub-score. A
|
|
1529
|
+
// number directly followed by one of these is left alone.
|
|
1530
|
+
const NON_SCORE_UNIT =
|
|
1531
|
+
'(?:kg|kilo|lbs?|pounds?|reps?|sets?|%|percent|pct|x\\b|for\\s+\\d|sessions?|days?|nights?|weeks?|months?|' +
|
|
1532
|
+
'years?|yrs?|hrs?|hours?|mins?|minutes?|secs?|seconds?|rpe|rir|am|pm|out\\s+of|of\\b|/\\s*\\d)';
|
|
1533
|
+
|
|
1534
|
+
// Heuristic, not a parser. Flags a component name followed — within a short,
|
|
1535
|
+
// period/newline-free gap (one clause) — by a score-like number that is not a
|
|
1536
|
+
// real-world unit. The bounded gap (excludes digits, so it can't skip a unit'd
|
|
1537
|
+
// number) catches the natural phrasings an LLM actually emits — "recovery 35",
|
|
1538
|
+
// "recovery is 35", "recovery is sitting at 35", "recovery came in at 35",
|
|
1539
|
+
// "recovery (35)", "recovery is much lower at 42.8", "coverage 100" — while the
|
|
1540
|
+
// unit lookahead keeps clean prose ("recovery over the last 3 sessions",
|
|
1541
|
+
// "recovery after 3 hours of sleep", "execution at 9/10 RPE") from tripping.
|
|
1542
|
+
const SCORE_COMPONENT_DUMP_PATTERN = new RegExp(
|
|
1543
|
+
// `(?!\\.\\d)` rejects a number that is really the integer part of a decimal —
|
|
1544
|
+
// without it, backtracking matches "2" in "progression of 2.5 kg" (the unit
|
|
1545
|
+
// guard only sees the ".5 kg" tail) and false-flags real load/time data.
|
|
1546
|
+
`\\b(${SCORE_COMPONENT_NAMES.join('|')})\\b[^.\\d\\n]{0,25}?(${SCORE_NUMBER})\\b(?!\\.\\d)(?!\\s*${NON_SCORE_UNIT})`,
|
|
1547
|
+
'gi'
|
|
1548
|
+
);
|
|
1549
|
+
|
|
1550
|
+
// The other dump the prelude used to emit and the model parroted: an explicit
|
|
1551
|
+
// day-over-day delta number ("-13 day-over-day delta", "down 11 points day over
|
|
1552
|
+
// day"). A bare "down day-over-day" with no number is fine.
|
|
1553
|
+
const SCORE_DELTA_DUMP_PATTERN =
|
|
1554
|
+
/[+-]\d+(?:\.\d+)?[^.\n]{0,16}?day[- ]over[- ]day|(?:\d+(?:\.\d+)?\s*points?)[^.\n]{0,16}?day[- ]over[- ]day|day[- ]over[- ]day[^.\n]{0,16}?(?:[+-]\d+(?:\.\d+)?|\d+(?:\.\d+)?\s*points?)/i;
|
|
1555
|
+
|
|
1556
|
+
export function evaluateAskScoreVoice(output, testCase) {
|
|
1557
|
+
if (testCase.surface !== 'ask') {
|
|
1558
|
+
return { key: 'ask_score_voice', passed: true, reason: 'Not an ask answer.' };
|
|
1559
|
+
}
|
|
1560
|
+
// Escape hatch for cases that legitimately need raw component values
|
|
1561
|
+
// (e.g. an ask case paired with the numbers-only tone).
|
|
1562
|
+
if (testCase.allowScoreComponents === true) {
|
|
1563
|
+
return { key: 'ask_score_voice', passed: true, reason: 'Score-component voice check opted out for this case.' };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const text = normalizeText(output);
|
|
1567
|
+
const hits = new Set();
|
|
1568
|
+
for (const match of text.matchAll(SCORE_COMPONENT_DUMP_PATTERN)) {
|
|
1569
|
+
hits.add(`${match[1]} ${match[2]}`);
|
|
1570
|
+
}
|
|
1571
|
+
if (SCORE_DELTA_DUMP_PATTERN.test(text)) {
|
|
1572
|
+
hits.add('day-over-day delta number');
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
return {
|
|
1576
|
+
key: 'ask_score_voice',
|
|
1577
|
+
passed: hits.size === 0,
|
|
1578
|
+
reason: hits.size === 0
|
|
1579
|
+
? 'Ask answer does not recite raw Increment Score component sub-scores.'
|
|
1580
|
+
: `Ask answer recites raw score internals: ${[...hits].join(', ')}. Speak in training reality, not raw sub-scores.`
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function relevantSessionsForStaleness(snapshot, testCase) {
|
|
1585
|
+
const configuredExercise = testCase.staleness?.exercise ?? testCase.staleness?.exerciseName
|
|
1586
|
+
?? testCase.directionalConsistency?.[0]?.exercise
|
|
1587
|
+
?? testCase.directionalConsistency?.[0]?.exerciseName
|
|
1588
|
+
?? null;
|
|
1589
|
+
if (!configuredExercise) return snapshot?.sessions ?? [];
|
|
1590
|
+
const normalized = normalizeExerciseName(configuredExercise);
|
|
1591
|
+
return (snapshot?.sessions ?? []).filter((session) => (
|
|
1592
|
+
(session.exercises ?? []).some((exercise) => normalizeExerciseName(exercise.name) === normalized)
|
|
1593
|
+
));
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// The coach IS the coach — it must speak in the first person and never refer to
|
|
1597
|
+
// itself or its own outputs as a third party ("the coach observation says…",
|
|
1598
|
+
// "the system shows…"). Own the observation instead ("I flagged…").
|
|
1599
|
+
const ASK_SELF_REFERENCE_PATTERNS = [
|
|
1600
|
+
/\bthe coach observations?\b/i,
|
|
1601
|
+
/\bthe coach\b/i,
|
|
1602
|
+
/\bthe ai coach\b/i,
|
|
1603
|
+
/\byour coach\b/i,
|
|
1604
|
+
/\bthis coach\b/i,
|
|
1605
|
+
/\bthe system\b/i,
|
|
1606
|
+
/\bthe assistant\b/i
|
|
1607
|
+
];
|
|
1608
|
+
|
|
1609
|
+
function evaluateAskSelfReference(output, testCase) {
|
|
1610
|
+
if (testCase.surface !== 'ask') {
|
|
1611
|
+
return { key: 'ask_self_reference', passed: true, reason: 'Not an ask answer.' };
|
|
1612
|
+
}
|
|
1613
|
+
const text = normalizeText(output);
|
|
1614
|
+
if (text === 'NO_INSIGHT' || !text) {
|
|
1615
|
+
return { key: 'ask_self_reference', passed: true, reason: 'No answer text.' };
|
|
1616
|
+
}
|
|
1617
|
+
const hits = [];
|
|
1618
|
+
for (const pattern of ASK_SELF_REFERENCE_PATTERNS) {
|
|
1619
|
+
const match = text.match(pattern);
|
|
1620
|
+
if (match) hits.push(match[0]);
|
|
1621
|
+
}
|
|
1622
|
+
const unique = uniqueStrings(hits);
|
|
1623
|
+
return {
|
|
1624
|
+
key: 'ask_self_reference',
|
|
1625
|
+
passed: unique.length === 0,
|
|
1626
|
+
reason: unique.length === 0
|
|
1627
|
+
? 'Ask answer speaks in the first person.'
|
|
1628
|
+
: `Ask answer refers to itself in the third person: ${unique.join(', ')}. You ARE the coach — own it ("I flagged…", "your data shows…").`
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// On a question that is not about the Increment Score, the coach must not
|
|
1633
|
+
// volunteer the bare overall score number (e.g. "your score is 92/100"). The
|
|
1634
|
+
// prelude withholds the number for non-score questions; this guards the answer.
|
|
1635
|
+
function evaluateAskVolunteeredScore(output, testCase) {
|
|
1636
|
+
if (testCase.surface !== 'ask') {
|
|
1637
|
+
return { key: 'ask_volunteered_score', passed: true, reason: 'Not an ask answer.' };
|
|
1638
|
+
}
|
|
1639
|
+
const question = testCase.context?.question ?? testCase.question ?? '';
|
|
1640
|
+
if (isScoreQuestion(question)) {
|
|
1641
|
+
return { key: 'ask_volunteered_score', passed: true, reason: 'Question is about the score; naming it is allowed.' };
|
|
1642
|
+
}
|
|
1643
|
+
const text = normalizeText(output);
|
|
1644
|
+
const volunteered = /\b\d{2,3}\s*\/\s*100\b/.test(text)
|
|
1645
|
+
|| /\b(?:increment\s+)?score\s+(?:is|of|at|sits at|currently|was)\b[^.\n]*\b\d{2,3}\b/i.test(text);
|
|
1646
|
+
return {
|
|
1647
|
+
key: 'ask_volunteered_score',
|
|
1648
|
+
passed: !volunteered,
|
|
1649
|
+
reason: volunteered
|
|
1650
|
+
? 'Ask answer volunteers the overall Increment Score number on a question that was not about the score. Translate it to the limiter instead.'
|
|
1651
|
+
: 'Ask answer does not volunteer the score number unprompted.'
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function evaluateAskStaleness(output, snapshot, testCase) {
|
|
1656
|
+
if (testCase.surface !== 'ask') {
|
|
1657
|
+
return { key: 'ask_staleness', passed: true, reason: 'Not an ask answer.' };
|
|
1658
|
+
}
|
|
1659
|
+
const maxRecentDays = testCase.staleness?.maxRecentDays;
|
|
1660
|
+
if (!Number.isFinite(Number(maxRecentDays))) {
|
|
1661
|
+
return { key: 'ask_staleness', passed: true, reason: 'No staleness assertion configured.' };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const latestSession = stableSortByDateDesc(relevantSessionsForStaleness(snapshot, testCase), (session) => session.completedAt ?? session.date)[0] ?? null;
|
|
1665
|
+
const daysAgo = daysAgoForEval(latestSession?.completedAt ?? latestSession?.date, testCase);
|
|
1666
|
+
if (daysAgo == null || daysAgo <= Number(maxRecentDays)) {
|
|
1667
|
+
return { key: 'ask_staleness', passed: true, reason: 'Latest session is inside the configured recency window.' };
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
const normalized = normalizeText(output);
|
|
1671
|
+
const claimsRecent = /\brecent(?:ly)?\b/i.test(normalized);
|
|
1672
|
+
const explicitlyNotRecent = /\b(?:not|isn'?t|wasn'?t|no longer)\s+(?:a\s+)?recent\b/i.test(normalized)
|
|
1673
|
+
|| /\brecent\b.{0,20}\b(?:not|isn'?t|wasn'?t)\b/i.test(normalized);
|
|
1674
|
+
const includesAge = new RegExp(`\\b${daysAgo}\\s+days?\\s+ago\\b`, 'i').test(normalized);
|
|
1675
|
+
const passed = !claimsRecent || explicitlyNotRecent || includesAge;
|
|
1676
|
+
return {
|
|
1677
|
+
key: 'ask_staleness',
|
|
1678
|
+
passed,
|
|
1679
|
+
reason: passed
|
|
1680
|
+
? 'Ask answer does not present stale sessions as simply recent.'
|
|
1681
|
+
: `Ask answer calls a ${daysAgo}-day-old session recent without the days-ago label.`
|
|
1682
|
+
};
|
|
1150
1683
|
}
|
|
1151
1684
|
|
|
1152
1685
|
function evaluateAskClaims(output, snapshot, testCase) {
|
|
@@ -1212,6 +1745,7 @@ function evaluateAskClaims(output, snapshot, testCase) {
|
|
|
1212
1745
|
for (const claim of extractAskWeightClaims(normalized)) {
|
|
1213
1746
|
if (isEstimatedOneRepMaxWeightClaim(normalized, claim)) continue;
|
|
1214
1747
|
if (isVolumeWeightClaim(normalized, claim)) continue;
|
|
1748
|
+
if (isBodyWeightClaim(normalized, claim)) continue;
|
|
1215
1749
|
const referencedExercise = findNearestMentionedExercise(mentionedExercises, claim.index);
|
|
1216
1750
|
if (!referencedExercise) continue;
|
|
1217
1751
|
const allowedWeights = allowedWeightsForExercise(snapshot, referencedExercise.normalizedName);
|
|
@@ -1234,14 +1768,195 @@ function evaluateAskClaims(output, snapshot, testCase) {
|
|
|
1234
1768
|
};
|
|
1235
1769
|
}
|
|
1236
1770
|
|
|
1237
|
-
function
|
|
1771
|
+
function routedToolResultsForEval(snapshot, context) {
|
|
1772
|
+
const routedMetadata = context?.routedMetadata ?? {};
|
|
1773
|
+
const toolParams = routedMetadata.toolParams ?? {};
|
|
1774
|
+
const toolResults = [];
|
|
1775
|
+
const replayFailures = [];
|
|
1776
|
+
for (const toolName of uniqueStrings(routedMetadata.toolsUsed ?? [])) {
|
|
1777
|
+
try {
|
|
1778
|
+
toolResults.push(executeCoachReadTool(snapshot, toolName, toolParams[toolName] ?? {}));
|
|
1779
|
+
} catch (error) {
|
|
1780
|
+
replayFailures.push(`Could not replay routed tool ${toolName}: ${error?.message ?? String(error)}`);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
return { toolResults, replayFailures };
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function addAskToolEvidenceRow(rows, toolName, row, inherited = {}) {
|
|
1787
|
+
const exerciseName = row?.exerciseName ?? row?.name ?? inherited.exerciseName ?? null;
|
|
1788
|
+
const normalizedName = normalizeExerciseName(exerciseName);
|
|
1789
|
+
if (!normalizedName) return;
|
|
1790
|
+
rows.push({
|
|
1791
|
+
toolName,
|
|
1792
|
+
exerciseName,
|
|
1793
|
+
normalizedName,
|
|
1794
|
+
date: row?.date ?? inherited.date ?? null,
|
|
1795
|
+
daysAgo: row?.daysAgo ?? inherited.daysAgo ?? null,
|
|
1796
|
+
recencyLabel: row?.recencyLabel ?? inherited.recencyLabel ?? null,
|
|
1797
|
+
isStale: row?.isStale ?? inherited.isStale ?? false,
|
|
1798
|
+
recencyCutoffDays: row?.recencyCutoffDays ?? inherited.recencyCutoffDays ?? null,
|
|
1799
|
+
warmupSetCount: row?.warmupSetCount ?? 0,
|
|
1800
|
+
workingSetCount: row?.workingSetCount ?? null,
|
|
1801
|
+
topSet: row?.topSet ?? null,
|
|
1802
|
+
comparedToPreviousSession: row?.comparedToPreviousSession ?? null,
|
|
1803
|
+
sets: Array.isArray(row?.sets) ? row.sets : []
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function askToolEvidenceRows(toolResults = []) {
|
|
1808
|
+
const rows = [];
|
|
1809
|
+
for (const toolResult of toolResults) {
|
|
1810
|
+
for (const row of toolResult?.rows ?? []) {
|
|
1811
|
+
if (Array.isArray(row?.exercises)) {
|
|
1812
|
+
for (const exercise of row.exercises) {
|
|
1813
|
+
addAskToolEvidenceRow(rows, toolResult.toolName, exercise, {
|
|
1814
|
+
date: row.date,
|
|
1815
|
+
daysAgo: row.daysAgo,
|
|
1816
|
+
recencyLabel: row.recencyLabel,
|
|
1817
|
+
isStale: row.isStale,
|
|
1818
|
+
recencyCutoffDays: row.recencyCutoffDays
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
} else {
|
|
1822
|
+
addAskToolEvidenceRow(rows, toolResult.toolName, row);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
return rows;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function askToolEvidenceWeights(rows = []) {
|
|
1830
|
+
const weights = [];
|
|
1831
|
+
for (const row of rows) {
|
|
1832
|
+
for (const set of row.sets ?? []) {
|
|
1833
|
+
const weight = Number(set.weight);
|
|
1834
|
+
if (Number.isFinite(weight)) weights.push(weight);
|
|
1835
|
+
}
|
|
1836
|
+
const topWeight = Number(row.topSet?.weight);
|
|
1837
|
+
if (Number.isFinite(topWeight)) weights.push(topWeight);
|
|
1838
|
+
const previousTopWeight = Number(row.comparedToPreviousSession?.previousTopSet?.weight);
|
|
1839
|
+
if (Number.isFinite(previousTopWeight)) weights.push(previousTopWeight);
|
|
1840
|
+
}
|
|
1841
|
+
return weights;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function askToolEvidenceSetPairs(rows = []) {
|
|
1845
|
+
const pairs = [];
|
|
1846
|
+
for (const row of rows) {
|
|
1847
|
+
for (const set of row.sets ?? []) {
|
|
1848
|
+
const weight = Number(set.weight);
|
|
1849
|
+
const reps = Number(set.reps);
|
|
1850
|
+
if (Number.isFinite(weight) && Number.isFinite(reps)) pairs.push({ weight, reps });
|
|
1851
|
+
}
|
|
1852
|
+
const topWeight = Number(row.topSet?.weight);
|
|
1853
|
+
const topReps = Number(row.topSet?.reps);
|
|
1854
|
+
if (Number.isFinite(topWeight) && Number.isFinite(topReps)) pairs.push({ weight: topWeight, reps: topReps });
|
|
1855
|
+
const previousTopWeight = Number(row.comparedToPreviousSession?.previousTopSet?.weight);
|
|
1856
|
+
const previousTopReps = Number(row.comparedToPreviousSession?.previousTopSet?.reps);
|
|
1857
|
+
if (Number.isFinite(previousTopWeight) && Number.isFinite(previousTopReps)) {
|
|
1858
|
+
pairs.push({ weight: previousTopWeight, reps: previousTopReps });
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
return pairs;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function toolEvidenceSupportsWeightClaim(claim, rows) {
|
|
1865
|
+
if (weightClaimSupported(claim, askToolEvidenceWeights(rows))) return true;
|
|
1866
|
+
return false;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function toolEvidenceSupportsWeightedSetClaim(claim, rows) {
|
|
1870
|
+
if (askToolEvidenceSetPairs(rows).some((pair) => (
|
|
1871
|
+
Math.abs(pair.weight - claim.weight) < 0.01 && pair.reps === claim.reps
|
|
1872
|
+
))) {
|
|
1873
|
+
return true;
|
|
1874
|
+
}
|
|
1875
|
+
return false;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function compareToolEvidenceRecency(lhs, rhs) {
|
|
1879
|
+
const lhsDaysAgo = Number(lhs?.daysAgo);
|
|
1880
|
+
const rhsDaysAgo = Number(rhs?.daysAgo);
|
|
1881
|
+
if (Number.isFinite(lhsDaysAgo) && Number.isFinite(rhsDaysAgo)) return lhsDaysAgo - rhsDaysAgo;
|
|
1882
|
+
if (Number.isFinite(lhsDaysAgo)) return -1;
|
|
1883
|
+
if (Number.isFinite(rhsDaysAgo)) return 1;
|
|
1884
|
+
return String(rhs?.date ?? '').localeCompare(String(lhs?.date ?? ''));
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function newestToolEvidenceRow(rows = [], predicate = () => true) {
|
|
1888
|
+
return rows
|
|
1889
|
+
.filter(predicate)
|
|
1890
|
+
.sort(compareToolEvidenceRecency)[0] ?? null;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
function latestComparableToolRow(rows = []) {
|
|
1894
|
+
return newestToolEvidenceRow(rows, (row) => row.comparedToPreviousSession?.loadDirection) ?? null;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function isWithinWeightedSetClaim(claim, weightedSetClaims) {
|
|
1898
|
+
return weightedSetClaims.some((setClaim) => claim.index >= setClaim.index && claim.index < setClaim.end);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
function rowIsStaleForEval(row, testCase) {
|
|
1902
|
+
const daysAgo = Number(row?.daysAgo);
|
|
1903
|
+
const cutoff = Number(testCase.staleness?.maxRecentDays ?? row?.recencyCutoffDays);
|
|
1904
|
+
if (!Number.isFinite(daysAgo) || !Number.isFinite(cutoff)) return Boolean(row?.isStale);
|
|
1905
|
+
return daysAgo > cutoff;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
function outputCallsStaleEvidenceRecent(outputText, row) {
|
|
1909
|
+
const normalized = normalizeText(outputText);
|
|
1910
|
+
const claimsRecent = /\brecent(?:ly)?\b/i.test(normalized);
|
|
1911
|
+
if (!claimsRecent) return false;
|
|
1912
|
+
const explicitlyNotRecent = /\b(?:not|isn'?t|wasn'?t|no longer)\s+(?:a\s+)?recent\b/i.test(normalized)
|
|
1913
|
+
|| /\brecent\b.{0,20}\b(?:not|isn'?t|wasn'?t)\b/i.test(normalized);
|
|
1914
|
+
if (explicitlyNotRecent) return false;
|
|
1915
|
+
const daysAgo = Number(row?.daysAgo);
|
|
1916
|
+
return !Number.isFinite(daysAgo) || !new RegExp(`\\b${daysAgo}\\s+days?\\s+ago\\b`, 'i').test(normalized);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function recencyEvaluationWindows(outputText, exerciseName, exerciseNames = []) {
|
|
1920
|
+
const normalizedExercise = normalizeExerciseName(exerciseName);
|
|
1921
|
+
const otherExercises = [...new Set(exerciseNames.map(normalizeExerciseName))]
|
|
1922
|
+
.filter((name) => name && name !== normalizedExercise);
|
|
1923
|
+
const windows = directionEvaluationWindows(outputText, exerciseName, exerciseNames);
|
|
1924
|
+
if (!normalizedExercise) return windows;
|
|
1925
|
+
const scoped = [];
|
|
1926
|
+
for (const window of windows) {
|
|
1927
|
+
const clauses = window
|
|
1928
|
+
.split(/\s*(?:[.;:]|,\s+|\b(?:while|whereas|but|and)\b)\s*/i)
|
|
1929
|
+
.map((clause) => clause.trim())
|
|
1930
|
+
.filter(Boolean);
|
|
1931
|
+
let matched = false;
|
|
1932
|
+
for (let index = 0; index < clauses.length; index++) {
|
|
1933
|
+
if (!normalizeExerciseName(clauses[index]).includes(normalizedExercise)) continue;
|
|
1934
|
+
matched = true;
|
|
1935
|
+
let scopedWindow = clauses[index];
|
|
1936
|
+
for (let nextIndex = index + 1; nextIndex < clauses.length; nextIndex++) {
|
|
1937
|
+
const normalizedNext = normalizeExerciseName(clauses[nextIndex]);
|
|
1938
|
+
if (otherExercises.some((name) => normalizedNext.includes(name))) break;
|
|
1939
|
+
scopedWindow += ` ${clauses[nextIndex]}`;
|
|
1940
|
+
}
|
|
1941
|
+
scoped.push(scopedWindow);
|
|
1942
|
+
}
|
|
1943
|
+
if (!matched) scoped.push(window);
|
|
1944
|
+
}
|
|
1945
|
+
return scoped.length > 0 ? [...new Set(scoped)] : windows;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function evaluateAskToolProvenance(output, context, testCase, snapshot) {
|
|
1238
1949
|
if (testCase.surface !== 'ask') {
|
|
1239
1950
|
return { key: 'ask_tool_provenance', passed: true, reason: 'Not an ask answer.' };
|
|
1240
1951
|
}
|
|
1241
1952
|
|
|
1242
1953
|
const routedMetadata = context?.routedMetadata ?? {};
|
|
1243
1954
|
const toolsUsed = new Set(routedMetadata.toolsUsed ?? []);
|
|
1244
|
-
const
|
|
1955
|
+
const { toolResults, replayFailures } = routedToolResultsForEval(snapshot, context);
|
|
1956
|
+
const evidenceRows = askToolEvidenceRows(toolResults);
|
|
1957
|
+
const mentionedExercises = findMentionedExercises(output, snapshot);
|
|
1958
|
+
const unroutedMentionNames = new Set();
|
|
1959
|
+
const failures = [...replayFailures];
|
|
1245
1960
|
for (const toolName of uniqueStrings(testCase.requiredTools)) {
|
|
1246
1961
|
if (!toolsUsed.has(toolName)) {
|
|
1247
1962
|
failures.push(`Expected routed Ask Coach context to use ${toolName}.`);
|
|
@@ -1252,6 +1967,75 @@ function evaluateAskToolProvenance(output, context, testCase) {
|
|
|
1252
1967
|
failures.push('Ask answer mentions e1RM/1RM, but routed context did not use get_records.');
|
|
1253
1968
|
}
|
|
1254
1969
|
|
|
1970
|
+
const weightedSetClaims = extractAskWeightedSetClaims(output);
|
|
1971
|
+
for (const claim of weightedSetClaims) {
|
|
1972
|
+
if (isEstimatedOneRepMaxWeightClaim(output, claim)) continue;
|
|
1973
|
+
const referencedExercise = findNearestMentionedExercise(mentionedExercises, claim.index);
|
|
1974
|
+
if (!referencedExercise) continue;
|
|
1975
|
+
const rows = evidenceRows.filter((row) => row.normalizedName === referencedExercise.normalizedName);
|
|
1976
|
+
if (rows.length === 0) {
|
|
1977
|
+
unroutedMentionNames.add(referencedExercise.normalizedName);
|
|
1978
|
+
failures.push(`Ask answer asserts ${claim.text} for ${referencedExercise.name}, but ${referencedExercise.name} was not present in routed tool outputs.`);
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
if (!toolEvidenceSupportsWeightedSetClaim(claim, rows)) {
|
|
1982
|
+
failures.push(`Ask answer asserts ${claim.text} for ${referencedExercise.name}, but routed tool outputs for ${referencedExercise.name} did not include that weight/reps pair.`);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
for (const claim of extractAskWeightClaims(output)) {
|
|
1987
|
+
if (isWithinWeightedSetClaim(claim, weightedSetClaims)) continue;
|
|
1988
|
+
if (isEstimatedOneRepMaxWeightClaim(output, claim)) continue;
|
|
1989
|
+
if (isVolumeWeightClaim(output, claim)) continue;
|
|
1990
|
+
if (isBodyWeightClaim(output, claim)) continue;
|
|
1991
|
+
const referencedExercise = findNearestMentionedExercise(mentionedExercises, claim.index);
|
|
1992
|
+
if (!referencedExercise) continue;
|
|
1993
|
+
const rows = evidenceRows.filter((row) => row.normalizedName === referencedExercise.normalizedName);
|
|
1994
|
+
if (rows.length === 0) {
|
|
1995
|
+
unroutedMentionNames.add(referencedExercise.normalizedName);
|
|
1996
|
+
failures.push(`Ask answer asserts ${claim.text} for ${referencedExercise.name}, but ${referencedExercise.name} was not present in routed tool outputs.`);
|
|
1997
|
+
continue;
|
|
1998
|
+
}
|
|
1999
|
+
if (!toolEvidenceSupportsWeightClaim(claim, rows)) {
|
|
2000
|
+
failures.push(`Ask answer asserts ${claim.text} for ${referencedExercise.name}, but routed tool outputs for ${referencedExercise.name} did not include that weight.`);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
const exerciseNames = evidenceRows.map((row) => row.exerciseName);
|
|
2005
|
+
for (const mention of mentionedExercises) {
|
|
2006
|
+
const rows = evidenceRows.filter((row) => row.normalizedName === mention.normalizedName);
|
|
2007
|
+
if (rows.length === 0) {
|
|
2008
|
+
if (toolResults.length > 0 && !unroutedMentionNames.has(mention.normalizedName)) {
|
|
2009
|
+
unroutedMentionNames.add(mention.normalizedName);
|
|
2010
|
+
failures.push(`Ask answer mentions ${mention.name}, but ${mention.name} was not present in routed tool outputs.`);
|
|
2011
|
+
}
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
2014
|
+
const comparable = latestComparableToolRow(rows);
|
|
2015
|
+
if (comparable) {
|
|
2016
|
+
const direction = comparable.comparedToPreviousSession.loadDirection;
|
|
2017
|
+
const previous = comparable.comparedToPreviousSession.previousTopSet;
|
|
2018
|
+
const windows = directionEvaluationWindows(output, mention.name, exerciseNames);
|
|
2019
|
+
if (direction === 'up' && windows.some(hasUnqualifiedDeclineLanguage)) {
|
|
2020
|
+
failures.push(`Ask answer frames ${mention.name} as declining/drop-off, but routed ${comparable.toolName} evidence says top load increased from ${previous.weight} kg to ${comparable.topSet.weight} kg.`);
|
|
2021
|
+
}
|
|
2022
|
+
if (direction === 'down' && windows.some(hasUnqualifiedImprovementLanguage)) {
|
|
2023
|
+
failures.push(`Ask answer frames ${mention.name} as improving, but routed ${comparable.toolName} evidence says top load decreased from ${previous.weight} kg to ${comparable.topSet.weight} kg.`);
|
|
2024
|
+
}
|
|
2025
|
+
if (direction === 'flat' && windows.some((window) => hasUnqualifiedDeclineLanguage(window) || hasUnqualifiedImprovementLanguage(window))) {
|
|
2026
|
+
failures.push(`Ask answer invents a load direction for ${mention.name}, but routed ${comparable.toolName} evidence says top load was flat at ${comparable.topSet.weight} kg.`);
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
const latestDatedRow = newestToolEvidenceRow(rows, (row) => row.daysAgo != null);
|
|
2031
|
+
if (latestDatedRow && rowIsStaleForEval(latestDatedRow, testCase)) {
|
|
2032
|
+
const windows = recencyEvaluationWindows(output, mention.name, exerciseNames);
|
|
2033
|
+
if (windows.some((window) => outputCallsStaleEvidenceRecent(window, latestDatedRow))) {
|
|
2034
|
+
failures.push(`Ask answer calls ${mention.name} recent, but routed tool evidence says the latest relevant session was ${latestDatedRow.daysAgo} days ago.`);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
1255
2039
|
return {
|
|
1256
2040
|
key: 'ask_tool_provenance',
|
|
1257
2041
|
passed: failures.length === 0,
|
|
@@ -1261,6 +2045,309 @@ function evaluateAskToolProvenance(output, context, testCase) {
|
|
|
1261
2045
|
};
|
|
1262
2046
|
}
|
|
1263
2047
|
|
|
2048
|
+
function scoreFormulaEntries(snapshot) {
|
|
2049
|
+
const seen = new Set();
|
|
2050
|
+
return scoreHistoryFromSnapshot(snapshot).filter((entry) => {
|
|
2051
|
+
if (!entry) return false;
|
|
2052
|
+
const key = entry.id ?? entry.snapshotAt;
|
|
2053
|
+
if (key == null) return true;
|
|
2054
|
+
if (seen.has(key)) return false;
|
|
2055
|
+
seen.add(key);
|
|
2056
|
+
return true;
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function evaluateFormulaVersion(_output, snapshot, testCase) {
|
|
2061
|
+
const expected = testCase.expectedFormulaVersion ?? testCase.formulaVersion ?? null;
|
|
2062
|
+
if (!expected) {
|
|
2063
|
+
return { key: 'formula_version', passed: true, reason: 'No formula version pin configured.' };
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
const entries = scoreFormulaEntries(snapshot);
|
|
2067
|
+
const missingCount = entries.filter((entry) => !entry?.formulaVersion).length;
|
|
2068
|
+
const versions = uniqueStrings(entries.map((entry) => entry?.formulaVersion));
|
|
2069
|
+
const passed = entries.length > 0 && missingCount === 0 && versions.every((version) => version === expected);
|
|
2070
|
+
return {
|
|
2071
|
+
key: 'formula_version',
|
|
2072
|
+
passed,
|
|
2073
|
+
reason: passed
|
|
2074
|
+
? `Formula version is pinned to ${expected}.`
|
|
2075
|
+
: missingCount > 0
|
|
2076
|
+
? `Expected formula version ${expected}, but ${missingCount} score snapshot(s) have no formula version.`
|
|
2077
|
+
: versions.length > 0
|
|
2078
|
+
? `Expected formula version ${expected}, got ${versions.join(', ')}.`
|
|
2079
|
+
: `Expected formula version ${expected}, but snapshot has no increment score formula version.`
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
function arrayContainsAll(actual = [], expected = []) {
|
|
2084
|
+
const actualSet = new Set(actual ?? []);
|
|
2085
|
+
return (expected ?? []).every((item) => actualSet.has(item));
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
function arrayEquals(actual = [], expected = []) {
|
|
2089
|
+
if (!Array.isArray(actual) || !Array.isArray(expected) || actual.length !== expected.length) {
|
|
2090
|
+
return false;
|
|
2091
|
+
}
|
|
2092
|
+
const sortedActual = [...actual].sort();
|
|
2093
|
+
const sortedExpected = [...expected].sort();
|
|
2094
|
+
return sortedActual.every((item, index) => item === sortedExpected[index]);
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
function askObservationCheckMatches(actualCheck, expectedCheck) {
|
|
2098
|
+
return Object.entries(expectedCheck ?? {}).every(([key, value]) => {
|
|
2099
|
+
if (Array.isArray(value)) return arrayContainsAll(actualCheck?.[key], value);
|
|
2100
|
+
return actualCheck?.[key] === value;
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function evaluateAskEvidencePlan(_output, context, testCase) {
|
|
2105
|
+
if (testCase.surface !== 'ask') {
|
|
2106
|
+
return { key: 'ask_evidence_plan', passed: true, reason: 'Not an ask answer.' };
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const expected = testCase.expectedEvidencePlan ?? null;
|
|
2110
|
+
if (!expected) {
|
|
2111
|
+
return { key: 'ask_evidence_plan', passed: true, reason: 'No evidence plan assertion configured.' };
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
const plan = context?.routedMetadata?.evidencePlan ?? null;
|
|
2115
|
+
const failures = [];
|
|
2116
|
+
if (!plan) {
|
|
2117
|
+
failures.push('Routed Ask context did not expose metadata.evidencePlan.');
|
|
2118
|
+
} else {
|
|
2119
|
+
for (const key of ['route', 'effectiveRoute', 'fallbackRoute']) {
|
|
2120
|
+
if (key in expected && plan[key] !== expected[key]) {
|
|
2121
|
+
failures.push(`Expected evidencePlan.${key}=${expected[key] ?? 'null'}, got ${plan[key] ?? 'null'}.`);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
for (const key of ['requiredTools', 'optionalTools', 'executedTools', 'evidenceGaps']) {
|
|
2126
|
+
if (Array.isArray(expected[key]) && !arrayEquals(plan[key], expected[key])) {
|
|
2127
|
+
failures.push(`Expected evidencePlan.${key} to equal ${expected[key].join(', ')}; got ${(plan[key] ?? []).join(', ')}.`);
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
if (Array.isArray(expected.excludedExecutedTools)) {
|
|
2132
|
+
const executed = new Set(plan.executedTools ?? []);
|
|
2133
|
+
const hits = expected.excludedExecutedTools.filter((toolName) => executed.has(toolName));
|
|
2134
|
+
if (hits.length > 0) {
|
|
2135
|
+
failures.push(`Expected evidencePlan.executedTools to exclude ${hits.join(', ')}.`);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
for (const expectedCheck of expected.observationChecks ?? []) {
|
|
2140
|
+
const matched = (plan.observationChecks ?? []).some((actualCheck) => askObservationCheckMatches(actualCheck, expectedCheck));
|
|
2141
|
+
if (!matched) {
|
|
2142
|
+
failures.push(`Expected observation check ${JSON.stringify(expectedCheck)}; got ${JSON.stringify(plan.observationChecks ?? [])}.`);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
if (Array.isArray(expected.observationChecks) && (plan.observationChecks ?? []).length !== expected.observationChecks.length) {
|
|
2146
|
+
failures.push(`Expected ${expected.observationChecks.length} observation check(s), got ${(plan.observationChecks ?? []).length}.`);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
return {
|
|
2151
|
+
key: 'ask_evidence_plan',
|
|
2152
|
+
passed: failures.length === 0,
|
|
2153
|
+
reason: failures.length === 0
|
|
2154
|
+
? 'Ask evidence plan matches configured assertions.'
|
|
2155
|
+
: failures.join(' ')
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function askMetadataObservationReferences(metadata) {
|
|
2160
|
+
const references = new Set([
|
|
2161
|
+
...(metadata?.includedCoachObservationIds ?? []),
|
|
2162
|
+
...(metadata?.coachObservationIds ?? [])
|
|
2163
|
+
]);
|
|
2164
|
+
for (const comparison of metadata?.sessionObservationComparisons ?? []) {
|
|
2165
|
+
if (comparison?.observationId) references.add(comparison.observationId);
|
|
2166
|
+
}
|
|
2167
|
+
for (const item of metadata?.provenance ?? []) {
|
|
2168
|
+
for (const sourceId of item?.sourceIds ?? []) {
|
|
2169
|
+
references.add(sourceId);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
return references;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function evaluateAskMetadata(output, context, testCase) {
|
|
2176
|
+
if (testCase.surface !== 'ask') {
|
|
2177
|
+
return { key: 'ask_metadata', passed: true, reason: 'Not an ask answer.' };
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
const expected = testCase.expectedMetadata ?? null;
|
|
2181
|
+
if (!expected) {
|
|
2182
|
+
return { key: 'ask_metadata', passed: true, reason: 'No Ask metadata assertion configured.' };
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
const metadata = context?.routedMetadata ?? {};
|
|
2186
|
+
const failures = [];
|
|
2187
|
+
|
|
2188
|
+
if (Array.isArray(expected.includedCoachObservationIds)) {
|
|
2189
|
+
const included = new Set(metadata.includedCoachObservationIds ?? []);
|
|
2190
|
+
const missing = expected.includedCoachObservationIds.filter((id) => !included.has(id));
|
|
2191
|
+
if (missing.length > 0) {
|
|
2192
|
+
failures.push(`Expected included coach observation id(s): ${missing.join(', ')}.`);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
if (Array.isArray(expected.excludedCoachObservationIds)) {
|
|
2197
|
+
const references = askMetadataObservationReferences(metadata);
|
|
2198
|
+
const hits = expected.excludedCoachObservationIds.filter((id) => references.has(id));
|
|
2199
|
+
if (hits.length > 0) {
|
|
2200
|
+
failures.push(`Expected coach observation id(s) to be excluded from rendered metadata: ${hits.join(', ')}.`);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
if (Array.isArray(expected.forbiddenObservationPhrases)) {
|
|
2205
|
+
const hits = uniqueStrings(expected.forbiddenObservationPhrases).filter((phrase) => phraseIncludes(output, phrase));
|
|
2206
|
+
if (hits.length > 0) {
|
|
2207
|
+
failures.push(`Dismissed or excluded observation phrase(s) leaked into Ask answer: ${hits.join(', ')}.`);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
return {
|
|
2212
|
+
key: 'ask_metadata',
|
|
2213
|
+
passed: failures.length === 0,
|
|
2214
|
+
reason: failures.length === 0
|
|
2215
|
+
? 'Ask metadata matches configured assertions.'
|
|
2216
|
+
: failures.join(' ')
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
function normalizedStructuredText(value) {
|
|
2221
|
+
return String(value ?? '')
|
|
2222
|
+
.toLowerCase()
|
|
2223
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
2224
|
+
.replace(/\b(my|the|a|an)\b/g, ' ')
|
|
2225
|
+
.replace(/\s+/g, ' ')
|
|
2226
|
+
.trim();
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function structuredStringArray(value) {
|
|
2230
|
+
return Array.isArray(value)
|
|
2231
|
+
? value.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
2232
|
+
: [];
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function structuredObjectStringArray(items, key) {
|
|
2236
|
+
return Array.isArray(items)
|
|
2237
|
+
? items.map((item) => String(item?.[key] ?? '').trim()).filter(Boolean)
|
|
2238
|
+
: [];
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
function requireStructuredStrings(actual, expected, label, failures) {
|
|
2242
|
+
if (!Array.isArray(expected)) return;
|
|
2243
|
+
const actualSet = new Set(structuredStringArray(actual));
|
|
2244
|
+
const missing = expected.filter((item) => !actualSet.has(item));
|
|
2245
|
+
if (missing.length > 0) {
|
|
2246
|
+
failures.push(`Expected structured ${label}: ${missing.join(', ')}.`);
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
function forbidStructuredSuggestions(actual, forbidden, failures) {
|
|
2251
|
+
if (!Array.isArray(forbidden)) return;
|
|
2252
|
+
const normalizedActual = new Set(structuredStringArray(actual).map(normalizedStructuredText).filter(Boolean));
|
|
2253
|
+
const hits = forbidden.filter((item) => normalizedActual.has(normalizedStructuredText(item)));
|
|
2254
|
+
if (hits.length > 0) {
|
|
2255
|
+
failures.push(`Forbidden follow-up suggestion(s) present: ${hits.join(', ')}.`);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function evaluateAskStructuredResponse(_output, context, testCase, structured) {
|
|
2260
|
+
if (testCase.surface !== 'ask') {
|
|
2261
|
+
return { key: 'ask_structured_response', passed: true, reason: 'Not an ask answer.' };
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
const expected = testCase.expectedStructuredResponse ?? null;
|
|
2265
|
+
if (!expected) {
|
|
2266
|
+
return { key: 'ask_structured_response', passed: true, reason: 'No structured response assertion configured.' };
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
const failures = [];
|
|
2270
|
+
if (!structured || typeof structured !== 'object' || Array.isArray(structured)) {
|
|
2271
|
+
failures.push('Ask structured response was not generated.');
|
|
2272
|
+
} else {
|
|
2273
|
+
if (expected.confidence && structured.confidence !== expected.confidence) {
|
|
2274
|
+
failures.push(`Expected structured confidence ${expected.confidence}, got ${structured.confidence ?? 'null'}.`);
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
requireStructuredStrings(
|
|
2278
|
+
structuredObjectStringArray(structured.evidenceUsed, 'toolName'),
|
|
2279
|
+
expected.requiredEvidenceTools,
|
|
2280
|
+
'evidence tool(s)',
|
|
2281
|
+
failures
|
|
2282
|
+
);
|
|
2283
|
+
requireStructuredStrings(
|
|
2284
|
+
structuredObjectStringArray(structured.evidenceUsed, 'label'),
|
|
2285
|
+
expected.requiredEvidenceLabels,
|
|
2286
|
+
'evidence label(s)',
|
|
2287
|
+
failures
|
|
2288
|
+
);
|
|
2289
|
+
requireStructuredStrings(
|
|
2290
|
+
structuredObjectStringArray(structured.recommendedActions, 'label'),
|
|
2291
|
+
expected.requiredRecommendedActionLabels,
|
|
2292
|
+
'recommended action label(s)',
|
|
2293
|
+
failures
|
|
2294
|
+
);
|
|
2295
|
+
requireStructuredStrings(
|
|
2296
|
+
structured.followUpSuggestions,
|
|
2297
|
+
expected.requiredFollowUpSuggestions,
|
|
2298
|
+
'follow-up suggestion(s)',
|
|
2299
|
+
failures
|
|
2300
|
+
);
|
|
2301
|
+
requireStructuredStrings(
|
|
2302
|
+
structured.limitations,
|
|
2303
|
+
expected.requiredLimitations,
|
|
2304
|
+
'limitation(s)',
|
|
2305
|
+
failures
|
|
2306
|
+
);
|
|
2307
|
+
forbidStructuredSuggestions(structured.followUpSuggestions, expected.forbiddenFollowUpSuggestions, failures);
|
|
2308
|
+
|
|
2309
|
+
const followUps = structuredStringArray(structured.followUpSuggestions);
|
|
2310
|
+
const normalizedFollowUps = followUps.map(normalizedStructuredText).filter(Boolean);
|
|
2311
|
+
const duplicateCount = normalizedFollowUps.length - new Set(normalizedFollowUps).size;
|
|
2312
|
+
if (duplicateCount > 0) {
|
|
2313
|
+
failures.push('Structured follow-up suggestions must be unique.');
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
const normalizedQuestion = normalizedStructuredText(context?.question ?? testCase.context?.question ?? testCase.question ?? '');
|
|
2317
|
+
if (normalizedQuestion && normalizedFollowUps.includes(normalizedQuestion)) {
|
|
2318
|
+
failures.push('Structured follow-up suggestions must not repeat the current user question.');
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
if (Number.isFinite(expected.maxFollowUpSuggestions) && followUps.length > expected.maxFollowUpSuggestions) {
|
|
2322
|
+
failures.push(`Expected at most ${expected.maxFollowUpSuggestions} follow-up suggestion(s), got ${followUps.length}.`);
|
|
2323
|
+
}
|
|
2324
|
+
if (Number.isFinite(expected.minFollowUpSuggestions) && followUps.length < expected.minFollowUpSuggestions) {
|
|
2325
|
+
failures.push(`Expected at least ${expected.minFollowUpSuggestions} follow-up suggestion(s), got ${followUps.length}.`);
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
if (typeof expected.programDraftPresent === 'boolean') {
|
|
2329
|
+
const hasProgramDraft = structured.programDraft != null;
|
|
2330
|
+
if (hasProgramDraft !== expected.programDraftPresent) {
|
|
2331
|
+
failures.push(`Expected programDraft present=${expected.programDraftPresent}, got ${hasProgramDraft}.`);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
return {
|
|
2337
|
+
key: 'ask_structured_response',
|
|
2338
|
+
passed: failures.length === 0,
|
|
2339
|
+
reason: failures.length === 0
|
|
2340
|
+
? 'Ask structured response matches configured assertions.'
|
|
2341
|
+
: failures.join(' ')
|
|
2342
|
+
};
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function askStructuredProgramDraft(parsedAsk, routingMetadata) {
|
|
2346
|
+
const shouldSuppressDraft = routingMetadata?.requestedCoachObservationIntent === 'successor_plan'
|
|
2347
|
+
&& routingMetadata?.coachObservationFollowUpMissing === true;
|
|
2348
|
+
return shouldSuppressDraft ? undefined : parsedAsk?.programDraft;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
1264
2351
|
function firstAction(payload) {
|
|
1265
2352
|
const actions = Array.isArray(payload?.recommendedNextActions) ? payload.recommendedNextActions : [];
|
|
1266
2353
|
return actions.find((action) => typeof action?.action === 'string' && action.action.trim());
|
|
@@ -1489,17 +2576,20 @@ function evaluatePersonaMotivation(output, context, testCase) {
|
|
|
1489
2576
|
failures.push('Feedback acknowledges a PR/positive result but frames later-set dropoff in a demotivating way.');
|
|
1490
2577
|
}
|
|
1491
2578
|
|
|
2579
|
+
const dataLimitationCaveat = /\bnot enough\s+(?:\w+\s+){0,5}?(?:data|details?|context|evidence|information|info|signals?|metrics?)\b/i.test(normalized)
|
|
2580
|
+
|| /\bnot enough\s+(?:\w+\s+){0,8}?to\s+(?:separate|infer|tie|connect|attribute|blame|claim|say|show|prove|know|call)\b/i.test(normalized);
|
|
2581
|
+
|
|
1492
2582
|
const discouragingPatterns = [
|
|
1493
2583
|
/\bdisappointing\b/i,
|
|
1494
2584
|
/\bunderwhelming\b/i,
|
|
1495
2585
|
/\bunderperformed\b/i,
|
|
1496
2586
|
/\bpoor\b/i,
|
|
1497
|
-
/\bnot enough\b/i,
|
|
2587
|
+
/\bnot enough\s+(?:effort|work|volume|intensity|reps?|sets?|weight|load|progress|consistency)\b/i,
|
|
1498
2588
|
/\bfailed to\b/i,
|
|
1499
2589
|
/\bstruggled\b/i
|
|
1500
2590
|
];
|
|
1501
2591
|
|
|
1502
|
-
if (discouragingPatterns.some((pattern) => pattern.test(normalized))) {
|
|
2592
|
+
if (!dataLimitationCaveat && discouragingPatterns.some((pattern) => pattern.test(normalized))) {
|
|
1503
2593
|
failures.push('Feedback uses discouraging language that is likely to reduce motivation.');
|
|
1504
2594
|
}
|
|
1505
2595
|
|
|
@@ -1517,6 +2607,33 @@ export async function runSummaryEvalCase(testCase) {
|
|
|
1517
2607
|
return runSummaryEvalCaseFromSnapshot(testCase, snapshot);
|
|
1518
2608
|
}
|
|
1519
2609
|
|
|
2610
|
+
// When an ask answer emits a <program_draft> block, it must be valid JSON in the
|
|
2611
|
+
// exact Program shape (enums, limits, no forbidden keys) — validated by the same
|
|
2612
|
+
// normalizer the runtime uses to accept/drop drafts. Catches malformed drafts in
|
|
2613
|
+
// CI instead of silently dropping them in prod. No block = nothing to check.
|
|
2614
|
+
function evaluateProgramDraft(output, testCase, parsedAsk = null) {
|
|
2615
|
+
if (testCase.surface !== 'ask') {
|
|
2616
|
+
return { key: 'program_draft', passed: true, reason: 'Not an ask answer.' };
|
|
2617
|
+
}
|
|
2618
|
+
if (!hasProgramDraftBlock(output)) {
|
|
2619
|
+
return { key: 'program_draft', passed: true, reason: 'No program draft block.' };
|
|
2620
|
+
}
|
|
2621
|
+
// Validate against the EXACT runtime rules — the runtime passes
|
|
2622
|
+
// canonicalExerciseName, which strips non-alphanumerics; without it the eval
|
|
2623
|
+
// would green-light drafts (e.g. punctuation-only names) that prod silently drops.
|
|
2624
|
+
const { programDraft } = parsedAsk ?? extractAskProgramDraft(output, {
|
|
2625
|
+
canonicalizeExerciseName: canonicalExerciseName,
|
|
2626
|
+
strict: true
|
|
2627
|
+
});
|
|
2628
|
+
return {
|
|
2629
|
+
key: 'program_draft',
|
|
2630
|
+
passed: programDraft != null,
|
|
2631
|
+
reason: programDraft != null
|
|
2632
|
+
? 'Program draft is valid JSON matching the required shape.'
|
|
2633
|
+
: 'Program draft block is malformed (invalid JSON, or fails shape/enum/limit validation).'
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
|
|
1520
2637
|
export function evaluateSummaryOutputFromSnapshot(testCase, snapshot, output) {
|
|
1521
2638
|
const context = buildSummaryEvalContext(snapshot, testCase);
|
|
1522
2639
|
if (context == null) {
|
|
@@ -1527,24 +2644,53 @@ export function evaluateSummaryOutputFromSnapshot(testCase, snapshot, output) {
|
|
|
1527
2644
|
throw new Error(`Eval case ${testCase.id} produced an empty output`);
|
|
1528
2645
|
}
|
|
1529
2646
|
|
|
2647
|
+
// strict: eval rejects a draft with any malformed nested item (the runtime
|
|
2648
|
+
// salvages it, but partial malformation is a regression signal). The parsed
|
|
2649
|
+
// result also feeds <program_draft> stripping for the other checks.
|
|
2650
|
+
const parsedAsk = testCase.surface === 'ask'
|
|
2651
|
+
? extractAskProgramDraft(output, { canonicalizeExerciseName: canonicalExerciseName, strict: true })
|
|
2652
|
+
: null;
|
|
2653
|
+
const structuredParsedAsk = testCase.surface === 'ask'
|
|
2654
|
+
? extractAskProgramDraft(output, { canonicalizeExerciseName: canonicalExerciseName })
|
|
2655
|
+
: null;
|
|
2656
|
+
const visibleOutput = parsedAsk
|
|
2657
|
+
? stripXMLTagBlocks(parsedAsk.answerText)
|
|
2658
|
+
: output;
|
|
2659
|
+
const structuredAsk = testCase.surface === 'ask'
|
|
2660
|
+
? buildAskStructuredResponse(visibleOutput, context.routedMetadata ?? {}, {
|
|
2661
|
+
programDraft: askStructuredProgramDraft(structuredParsedAsk, context.routedMetadata),
|
|
2662
|
+
question: context.question ?? testCase.context?.question ?? testCase.question ?? ''
|
|
2663
|
+
})
|
|
2664
|
+
: null;
|
|
2665
|
+
|
|
1530
2666
|
const checks = [
|
|
1531
|
-
evaluateNoInsight(
|
|
1532
|
-
evaluateShape(
|
|
1533
|
-
evaluateRequiredMentions(
|
|
1534
|
-
evaluateAnyOfMentions(
|
|
1535
|
-
evaluateForbiddenPhrases(
|
|
1536
|
-
evaluateForbiddenMentions(
|
|
1537
|
-
evaluateExerciseMentions(
|
|
1538
|
-
evaluateWorkoutClaims(
|
|
1539
|
-
evaluateAskClaims(
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
2667
|
+
evaluateNoInsight(visibleOutput, testCase),
|
|
2668
|
+
evaluateShape(visibleOutput, testCase),
|
|
2669
|
+
evaluateRequiredMentions(visibleOutput, testCase),
|
|
2670
|
+
evaluateAnyOfMentions(visibleOutput, testCase),
|
|
2671
|
+
evaluateForbiddenPhrases(visibleOutput, testCase),
|
|
2672
|
+
evaluateForbiddenMentions(visibleOutput, testCase),
|
|
2673
|
+
evaluateExerciseMentions(visibleOutput, snapshot, context, testCase.surface, testCase),
|
|
2674
|
+
evaluateWorkoutClaims(visibleOutput, context, testCase),
|
|
2675
|
+
evaluateAskClaims(visibleOutput, snapshot, testCase),
|
|
2676
|
+
evaluateAskDirectionalConsistency(visibleOutput, snapshot, testCase),
|
|
2677
|
+
evaluateAskScoreVoice(visibleOutput, testCase),
|
|
2678
|
+
evaluateAskSelfReference(visibleOutput, testCase),
|
|
2679
|
+
evaluateAskVolunteeredScore(visibleOutput, testCase),
|
|
2680
|
+
evaluateAskStaleness(visibleOutput, snapshot, testCase),
|
|
2681
|
+
evaluateAskToolProvenance(visibleOutput, context, testCase, snapshot),
|
|
2682
|
+
evaluateFormulaVersion(visibleOutput, snapshot, testCase),
|
|
2683
|
+
evaluateAskEvidencePlan(visibleOutput, context, testCase),
|
|
2684
|
+
evaluateAskMetadata(visibleOutput, context, testCase),
|
|
2685
|
+
evaluateAskStructuredResponse(visibleOutput, context, testCase, structuredAsk),
|
|
2686
|
+
evaluateScoreCommentaryAction(visibleOutput, context, testCase),
|
|
2687
|
+
evaluateScoreCommentarySynthesis(visibleOutput, context, testCase),
|
|
2688
|
+
evaluateScoreCommentaryExerciseInvention(visibleOutput, snapshot, context, testCase),
|
|
2689
|
+
evaluateScoreCommentaryBand(visibleOutput, context, testCase),
|
|
2690
|
+
evaluateScoreCommentaryTone(visibleOutput, testCase),
|
|
2691
|
+
evaluateScoreCommentaryLength(visibleOutput, testCase),
|
|
2692
|
+
evaluatePersonaMotivation(visibleOutput, context, testCase),
|
|
2693
|
+
evaluateProgramDraft(output, testCase, parsedAsk)
|
|
1548
2694
|
];
|
|
1549
2695
|
|
|
1550
2696
|
return {
|
|
@@ -1567,12 +2713,14 @@ export async function runSummaryEvalCaseFromSnapshot(testCase, snapshot) {
|
|
|
1567
2713
|
return evaluateSummaryOutputFromSnapshot(testCase, snapshot, output);
|
|
1568
2714
|
}
|
|
1569
2715
|
|
|
1570
|
-
function genericForbiddenPhrasesForSurface(surface) {
|
|
2716
|
+
export function genericForbiddenPhrasesForSurface(surface) {
|
|
1571
2717
|
switch (surface) {
|
|
1572
2718
|
case 'workout':
|
|
1573
2719
|
return ['solid progress', 'trust the process', 'keep it up', 'quality work', 'in a great place', 'continue progressive overload', 'as fatigue accumulates'];
|
|
1574
2720
|
case 'cycle':
|
|
1575
|
-
|
|
2721
|
+
// 'solid first week' enforces the FIRST_WEEK_CYCLE_PROMPT's "do not say
|
|
2722
|
+
// solid first week" rule, which was previously prompt-only (unguarded).
|
|
2723
|
+
return ['solid progress', 'trust the process', 'in a great place', 'continue progressive overload', 'as fatigue accumulates', 'solid session', 'quality work', 'solid first week'];
|
|
1576
2724
|
case 'checkpoint':
|
|
1577
2725
|
return ['solid progress', 'quality work', 'trust the process', 'in a great place'];
|
|
1578
2726
|
case 'vitals':
|