incremnt 0.4.0 → 0.6.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 +16 -3
- package/package.json +20 -4
- package/src/anonymize.js +12 -0
- package/src/coach-bakeoff.js +300 -0
- package/src/coach-facts.js +100 -0
- package/src/coach-prompt-variants.js +106 -0
- package/src/contract.js +32 -5
- package/src/exercise-aliases.js +163 -0
- package/src/format.js +104 -1
- package/src/increment-score-replay-data.js +486 -0
- package/src/increment-score-replay.js +822 -0
- package/src/lib.js +14 -2
- package/src/local.js +3 -3
- package/src/mcp.js +67 -0
- package/src/openrouter.js +979 -182
- package/src/program-phase-resolver.js +206 -0
- package/src/prompt-security.js +1 -1
- package/src/promptfoo-domain-assert.cjs +4 -0
- package/src/promptfoo-evals.js +166 -0
- package/src/promptfoo-langfuse-scores.js +354 -0
- package/src/promptfoo-provider.cjs +14 -0
- package/src/promptfoo-tests.cjs +4 -0
- package/src/queries.js +2281 -197
- package/src/remote.js +99 -6
- package/src/score-context.js +182 -0
- package/src/state.js +9 -2
- package/src/stored-summary-eval-report.js +85 -52
- package/src/summary-evals.js +900 -21
- package/src/sync-service.js +1275 -131
- package/src/transport.js +9 -1
package/src/summary-evals.js
CHANGED
|
@@ -2,6 +2,8 @@ import { readFile, readdir } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import {
|
|
5
|
+
askContext,
|
|
6
|
+
askRoutedContext,
|
|
5
7
|
checkpointContext,
|
|
6
8
|
cycleSummaryContext,
|
|
7
9
|
normalizeExerciseName,
|
|
@@ -9,11 +11,14 @@ import {
|
|
|
9
11
|
vitalsSummaryContext
|
|
10
12
|
} from './queries.js';
|
|
11
13
|
import {
|
|
14
|
+
AI_PROMPT_VERSIONS,
|
|
15
|
+
generateAskAnswer,
|
|
12
16
|
generateCheckpointSummary,
|
|
13
17
|
generateCoachingSummary,
|
|
14
18
|
generateVitalsSummary,
|
|
15
19
|
generateWorkoutCoachingSummary
|
|
16
20
|
} from './openrouter.js';
|
|
21
|
+
import { computeScoreBand } from './score-context.js';
|
|
17
22
|
|
|
18
23
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
24
|
const __dirname = path.dirname(__filename);
|
|
@@ -79,36 +84,164 @@ export function buildSummaryEvalContext(snapshot, testCase) {
|
|
|
79
84
|
}
|
|
80
85
|
case 'vitals':
|
|
81
86
|
return vitalsSummaryContext(snapshot, { exclude: new Set(testCase.exclude ?? []) });
|
|
87
|
+
case 'ask': {
|
|
88
|
+
const question = testCase.context?.question ?? testCase.question ?? '';
|
|
89
|
+
const routed = question
|
|
90
|
+
? askRoutedContext(snapshot, question, { exclude: new Set(testCase.exclude ?? []) })
|
|
91
|
+
: null;
|
|
92
|
+
return {
|
|
93
|
+
...(testCase.context ?? {}),
|
|
94
|
+
trainingData: testCase.context?.trainingData ?? routed?.context ?? null,
|
|
95
|
+
routedMetadata: routed?.metadata ?? null
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
case 'scoreCommentary':
|
|
99
|
+
return buildScoreCommentaryContext(snapshot, testCase);
|
|
82
100
|
default:
|
|
83
101
|
throw new Error(`Unsupported summary eval surface: ${testCase.surface}`);
|
|
84
102
|
}
|
|
85
103
|
}
|
|
86
104
|
|
|
87
|
-
|
|
105
|
+
function summaryEvalGenerationMetadata(result) {
|
|
106
|
+
if (!result || typeof result !== 'object') return {};
|
|
107
|
+
return Object.fromEntries(
|
|
108
|
+
Object.entries({
|
|
109
|
+
model: result.model,
|
|
110
|
+
durationMs: result.durationMs,
|
|
111
|
+
fallback: result.fallback,
|
|
112
|
+
langfuseTraceId: result.langfuseTraceId,
|
|
113
|
+
langfuseObservationId: result.langfuseObservationId
|
|
114
|
+
}).filter(([, value]) => value !== undefined && value !== null)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function generateSummaryEvalOutputWithMetadata(testCase, context, snapshot = null) {
|
|
88
119
|
const liveGenerationEnabled = process.env.SUMMARY_EVALS_LIVE === '1';
|
|
89
120
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
90
121
|
if (!liveGenerationEnabled || !apiKey || testCase.shouldPass === false) {
|
|
91
|
-
return testCase.output;
|
|
122
|
+
return { output: testCase.output, metadata: {} };
|
|
92
123
|
}
|
|
93
124
|
|
|
125
|
+
let result;
|
|
94
126
|
switch (testCase.surface) {
|
|
127
|
+
case 'scoreCommentary':
|
|
128
|
+
return { output: testCase.output, metadata: {} };
|
|
95
129
|
case 'workout':
|
|
96
|
-
|
|
130
|
+
result = await generateWorkoutCoachingSummary(context, { apiKey });
|
|
131
|
+
break;
|
|
97
132
|
case 'cycle':
|
|
98
|
-
|
|
133
|
+
result = await generateCoachingSummary(context, { apiKey });
|
|
134
|
+
break;
|
|
99
135
|
case 'checkpoint':
|
|
100
|
-
|
|
136
|
+
result = await generateCheckpointSummary(context, { apiKey });
|
|
137
|
+
break;
|
|
101
138
|
case 'vitals':
|
|
102
|
-
|
|
139
|
+
result = await generateVitalsSummary(context, { apiKey });
|
|
140
|
+
break;
|
|
141
|
+
case 'ask': {
|
|
142
|
+
if (!snapshot) return { output: testCase.output, metadata: {} };
|
|
143
|
+
const question = context.question ?? testCase.question ?? '';
|
|
144
|
+
if (!question) return { output: testCase.output, metadata: {} };
|
|
145
|
+
const trainingContext = context.trainingData
|
|
146
|
+
?? askRoutedContext(snapshot, question, { exclude: new Set(testCase.exclude ?? []) }).context
|
|
147
|
+
?? askContext(snapshot, { exclude: new Set(testCase.exclude ?? []) });
|
|
148
|
+
result = await generateAskAnswer(trainingContext, question, {
|
|
149
|
+
apiKey,
|
|
150
|
+
history: context.history ?? [],
|
|
151
|
+
tone: context.tone,
|
|
152
|
+
model: context.model
|
|
153
|
+
});
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
103
156
|
default:
|
|
104
157
|
throw new Error(`Unsupported summary eval surface: ${testCase.surface}`);
|
|
105
158
|
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
output: result.text,
|
|
162
|
+
metadata: summaryEvalGenerationMetadata(result)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function generateSummaryEvalOutput(testCase, context, snapshot = null) {
|
|
167
|
+
return (await generateSummaryEvalOutputWithMetadata(testCase, context, snapshot)).output;
|
|
106
168
|
}
|
|
107
169
|
|
|
108
170
|
function normalizeText(value) {
|
|
109
171
|
return String(value ?? '').trim();
|
|
110
172
|
}
|
|
111
173
|
|
|
174
|
+
function parseJsonOutput(output) {
|
|
175
|
+
const normalized = normalizeText(output);
|
|
176
|
+
if (!normalized) return null;
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(normalized);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function scoreCommentaryPayload(output) {
|
|
185
|
+
const parsed = parseJsonOutput(output);
|
|
186
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
187
|
+
return parsed;
|
|
188
|
+
}
|
|
189
|
+
return { summaryText: normalizeText(output) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function scoreCommentaryText(output) {
|
|
193
|
+
const payload = scoreCommentaryPayload(output);
|
|
194
|
+
const pieces = [
|
|
195
|
+
payload.headline,
|
|
196
|
+
payload.subhead,
|
|
197
|
+
payload.summaryText,
|
|
198
|
+
payload.scoreBand
|
|
199
|
+
];
|
|
200
|
+
const actions = Array.isArray(payload.recommendedNextActions) ? payload.recommendedNextActions : [];
|
|
201
|
+
for (const action of actions) {
|
|
202
|
+
pieces.push(action?.driverMessage, action?.action);
|
|
203
|
+
}
|
|
204
|
+
const deltaDrivers = Array.isArray(payload.deltaDrivers) ? payload.deltaDrivers : [];
|
|
205
|
+
for (const driver of deltaDrivers) {
|
|
206
|
+
pieces.push(driver?.component);
|
|
207
|
+
}
|
|
208
|
+
return pieces.filter(Boolean).join(' ');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function scoreHistoryFromSnapshot(snapshot) {
|
|
212
|
+
const raw = snapshot?.incrementScore;
|
|
213
|
+
if (Array.isArray(raw)) return raw;
|
|
214
|
+
|
|
215
|
+
const history = Array.isArray(raw?.history) ? raw.history : [];
|
|
216
|
+
const latest = raw?.latest;
|
|
217
|
+
if (!latest) return history;
|
|
218
|
+
|
|
219
|
+
return [
|
|
220
|
+
latest,
|
|
221
|
+
...history.filter((entry) => {
|
|
222
|
+
if (!entry) return false;
|
|
223
|
+
if (latest?.id != null && entry?.id != null) return entry.id !== latest.id;
|
|
224
|
+
if (latest?.snapshotAt != null && entry?.snapshotAt != null) {
|
|
225
|
+
return entry.snapshotAt !== latest.snapshotAt;
|
|
226
|
+
}
|
|
227
|
+
return entry !== latest;
|
|
228
|
+
})
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildScoreCommentaryContext(snapshot, testCase) {
|
|
233
|
+
const history = scoreHistoryFromSnapshot(snapshot);
|
|
234
|
+
const selector = testCase.selector ?? {};
|
|
235
|
+
if (selector.snapshotId) {
|
|
236
|
+
return history.find((entry) => entry.id === selector.snapshotId) ?? null;
|
|
237
|
+
}
|
|
238
|
+
if (selector.snapshotAt) {
|
|
239
|
+
return history.find((entry) => entry.snapshotAt === selector.snapshotAt) ?? null;
|
|
240
|
+
}
|
|
241
|
+
const index = Number.isInteger(selector.index) ? selector.index : 0;
|
|
242
|
+
return history[index] ?? null;
|
|
243
|
+
}
|
|
244
|
+
|
|
112
245
|
function paragraphCount(text) {
|
|
113
246
|
return normalizeText(text)
|
|
114
247
|
.split(/\n\s*\n/)
|
|
@@ -129,6 +262,21 @@ function lowerIncludes(text, snippet) {
|
|
|
129
262
|
return normalizeText(text).toLowerCase().includes(String(snippet).toLowerCase());
|
|
130
263
|
}
|
|
131
264
|
|
|
265
|
+
function phraseIncludes(text, snippet) {
|
|
266
|
+
const normalizedSnippet = normalizeText(snippet).trim();
|
|
267
|
+
if (!normalizedSnippet) return false;
|
|
268
|
+
const escapedSnippet = escapeRegExp(normalizedSnippet).replace(/\s+/g, '\\s+');
|
|
269
|
+
const startsWithWord = /^\w/.test(normalizedSnippet);
|
|
270
|
+
const endsWithWord = /\w$/.test(normalizedSnippet);
|
|
271
|
+
const pluralSuffix = /^[A-Za-z]{1,3}$/.test(normalizedSnippet) ? 's?' : '';
|
|
272
|
+
const pattern = `${startsWithWord ? '\\b' : ''}${escapedSnippet}${pluralSuffix}${endsWithWord ? '\\b' : ''}`;
|
|
273
|
+
return new RegExp(pattern, 'i').test(normalizeText(text));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function escapeRegExp(value) {
|
|
277
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
278
|
+
}
|
|
279
|
+
|
|
132
280
|
function uniqueStrings(values) {
|
|
133
281
|
return [...new Set((values ?? []).filter(Boolean))];
|
|
134
282
|
}
|
|
@@ -207,6 +355,9 @@ function collectAllowedExerciseNames(surface, context) {
|
|
|
207
355
|
for (const pr of context.repPrs ?? []) {
|
|
208
356
|
if (pr.exerciseName) names.add(pr.exerciseName);
|
|
209
357
|
}
|
|
358
|
+
for (const sc of context.planComparison?.setsComparison ?? []) {
|
|
359
|
+
if (sc.exercise) names.add(sc.exercise);
|
|
360
|
+
}
|
|
210
361
|
}
|
|
211
362
|
|
|
212
363
|
if (surface === 'cycle' && context && typeof context === 'object') {
|
|
@@ -235,6 +386,16 @@ function collectAllowedExerciseNames(surface, context) {
|
|
|
235
386
|
}
|
|
236
387
|
}
|
|
237
388
|
|
|
389
|
+
if (surface === 'scoreCommentary' && context && typeof context === 'object') {
|
|
390
|
+
for (const driver of [
|
|
391
|
+
...(context.topPositiveDrivers ?? []),
|
|
392
|
+
...(context.topNegativeDrivers ?? [])
|
|
393
|
+
]) {
|
|
394
|
+
if (driver?.relatedExerciseName) names.add(driver.relatedExerciseName);
|
|
395
|
+
if (driver?.exerciseName) names.add(driver.exerciseName);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
238
399
|
return [...names];
|
|
239
400
|
}
|
|
240
401
|
|
|
@@ -260,6 +421,15 @@ function historicalExerciseVariants(name) {
|
|
|
260
421
|
}
|
|
261
422
|
|
|
262
423
|
function evaluateExerciseMentions(output, snapshot, context, surface, testCase) {
|
|
424
|
+
if (surface === 'ask') {
|
|
425
|
+
return {
|
|
426
|
+
key: 'exercise_mentions',
|
|
427
|
+
passed: true,
|
|
428
|
+
reason: 'Ask-coach answers may mention any exercise from the training history.'
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const outputText = surface === 'scoreCommentary' ? scoreCommentaryText(output) : output;
|
|
263
433
|
const isStored = testCase.source === 'stored';
|
|
264
434
|
const allowed = new Set();
|
|
265
435
|
for (const name of [
|
|
@@ -272,7 +442,7 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
|
|
|
272
442
|
}
|
|
273
443
|
}
|
|
274
444
|
const allNames = collectAllExerciseNames(snapshot);
|
|
275
|
-
const normalizedOutput = normalizeExerciseName(
|
|
445
|
+
const normalizedOutput = normalizeExerciseName(outputText);
|
|
276
446
|
const mentions = [];
|
|
277
447
|
|
|
278
448
|
for (const exerciseName of allNames) {
|
|
@@ -340,7 +510,7 @@ function evaluateAnyOfMentions(output, testCase) {
|
|
|
340
510
|
}
|
|
341
511
|
|
|
342
512
|
function evaluateForbiddenPhrases(output, testCase) {
|
|
343
|
-
const hits = uniqueStrings(testCase.forbiddenPhrases).filter((phrase) =>
|
|
513
|
+
const hits = uniqueStrings(testCase.forbiddenPhrases).filter((phrase) => phraseIncludes(output, phrase));
|
|
344
514
|
return {
|
|
345
515
|
key: 'forbidden_phrases',
|
|
346
516
|
passed: hits.length === 0,
|
|
@@ -349,7 +519,7 @@ function evaluateForbiddenPhrases(output, testCase) {
|
|
|
349
519
|
}
|
|
350
520
|
|
|
351
521
|
function evaluateForbiddenMentions(output, testCase) {
|
|
352
|
-
const hits = uniqueStrings(testCase.forbiddenMentions).filter((phrase) =>
|
|
522
|
+
const hits = uniqueStrings(testCase.forbiddenMentions).filter((phrase) => phraseIncludes(output, phrase));
|
|
353
523
|
return {
|
|
354
524
|
key: 'forbidden_mentions',
|
|
355
525
|
passed: hits.length === 0,
|
|
@@ -373,7 +543,9 @@ function evaluateNoInsight(output, testCase) {
|
|
|
373
543
|
}
|
|
374
544
|
|
|
375
545
|
function evaluateShape(output, testCase) {
|
|
376
|
-
const normalized =
|
|
546
|
+
const normalized = testCase.surface === 'scoreCommentary'
|
|
547
|
+
? scoreCommentaryText(output)
|
|
548
|
+
: normalizeText(output);
|
|
377
549
|
if (normalized === 'NO_INSIGHT') {
|
|
378
550
|
return {
|
|
379
551
|
key: 'shape',
|
|
@@ -382,8 +554,8 @@ function evaluateShape(output, testCase) {
|
|
|
382
554
|
};
|
|
383
555
|
}
|
|
384
556
|
|
|
385
|
-
const sentences = sentenceCount(
|
|
386
|
-
const paragraphs = paragraphCount(
|
|
557
|
+
const sentences = sentenceCount(normalized);
|
|
558
|
+
const paragraphs = paragraphCount(normalized);
|
|
387
559
|
const isStored = testCase.source === 'stored';
|
|
388
560
|
let passed = true;
|
|
389
561
|
const reasons = [];
|
|
@@ -422,9 +594,17 @@ function evaluateShape(output, testCase) {
|
|
|
422
594
|
}
|
|
423
595
|
break;
|
|
424
596
|
case 'cycle':
|
|
425
|
-
if (
|
|
597
|
+
if ((!isStored && (sentences < 2 || sentences > 8)) || (isStored && (sentences < 1 || sentences > 10))) {
|
|
426
598
|
passed = false;
|
|
427
|
-
reasons.push(
|
|
599
|
+
reasons.push(isStored
|
|
600
|
+
? `Stored cycle summaries must be 1-10 sentences, got ${sentences}.`
|
|
601
|
+
: `Cycle summaries must be 2-8 sentences, got ${sentences}.`);
|
|
602
|
+
}
|
|
603
|
+
if ((!isStored && (paragraphs < 1 || paragraphs > 2)) || (isStored && (paragraphs < 1 || paragraphs > 3))) {
|
|
604
|
+
passed = false;
|
|
605
|
+
reasons.push(isStored
|
|
606
|
+
? `Stored cycle summaries must be 1-3 paragraphs, got ${paragraphs}.`
|
|
607
|
+
: `Cycle summaries must be 1-2 paragraphs, got ${paragraphs}.`);
|
|
428
608
|
}
|
|
429
609
|
break;
|
|
430
610
|
case 'checkpoint':
|
|
@@ -433,6 +613,18 @@ function evaluateShape(output, testCase) {
|
|
|
433
613
|
reasons.push(`Checkpoint summaries must be 2-3 paragraphs, got ${paragraphs}.`);
|
|
434
614
|
}
|
|
435
615
|
break;
|
|
616
|
+
case 'ask':
|
|
617
|
+
if (sentences < 1 || sentences > 12) {
|
|
618
|
+
passed = false;
|
|
619
|
+
reasons.push(`Ask-coach answers must be 1-12 sentences, got ${sentences}.`);
|
|
620
|
+
}
|
|
621
|
+
break;
|
|
622
|
+
case 'scoreCommentary':
|
|
623
|
+
if (sentences < 1 || sentences > 8) {
|
|
624
|
+
passed = false;
|
|
625
|
+
reasons.push(`Score commentary must be 1-8 sentences, got ${sentences}.`);
|
|
626
|
+
}
|
|
627
|
+
break;
|
|
436
628
|
default:
|
|
437
629
|
break;
|
|
438
630
|
}
|
|
@@ -700,18 +892,598 @@ function evaluateWorkoutClaims(output, context, testCase) {
|
|
|
700
892
|
};
|
|
701
893
|
}
|
|
702
894
|
|
|
895
|
+
function extractAskTargetHitClaims(text) {
|
|
896
|
+
const claims = [];
|
|
897
|
+
const patterns = [
|
|
898
|
+
/\b(?:you\s+)?hit(?:ting)?\s+all\s+(?:your\s+)?target(?:ed)?\s+reps?\b/gi,
|
|
899
|
+
/\b(?:you\s+)?hit\s+all\s+(?:the\s+)?targets?\b/gi,
|
|
900
|
+
/\b(?:you\s+)?hit\s+(?:the|your)\s+target\b(?!\s+(?:of|for|on))/gi
|
|
901
|
+
];
|
|
902
|
+
for (const pattern of patterns) {
|
|
903
|
+
for (const match of text.matchAll(pattern)) {
|
|
904
|
+
claims.push({ text: match[0] });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return claims;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function extractAskCleanConsistencyClaims(text) {
|
|
911
|
+
const claims = [];
|
|
912
|
+
const patterns = [
|
|
913
|
+
/\bclean,\s+consistent\b/gi,
|
|
914
|
+
/\bclean\s+and\s+consistent\b/gi,
|
|
915
|
+
/\bconsistent\s+set\s+of\s+work\b/gi,
|
|
916
|
+
/\bacross\s+the\s+board\b/gi
|
|
917
|
+
];
|
|
918
|
+
for (const pattern of patterns) {
|
|
919
|
+
for (const match of text.matchAll(pattern)) {
|
|
920
|
+
claims.push({ text: match[0] });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return claims;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function extractAskPlannedListClaims(text) {
|
|
927
|
+
const claims = [];
|
|
928
|
+
const pattern = /\((\s*\d+(?:\s*,\s*\d+){2,})\s+planned\s*\)/gi;
|
|
929
|
+
for (const match of text.matchAll(pattern)) {
|
|
930
|
+
const reps = match[1].split(',').map((value) => Number(value.trim())).filter(Number.isFinite);
|
|
931
|
+
claims.push({ text: match[0], reps, index: match.index ?? -1 });
|
|
932
|
+
}
|
|
933
|
+
return claims;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function sessionPlannedReps(session) {
|
|
937
|
+
const prescription = session?.prescriptionSnapshot?.exercises ?? [];
|
|
938
|
+
const values = [];
|
|
939
|
+
for (const exercise of prescription) {
|
|
940
|
+
const target = Number(exercise.targetReps);
|
|
941
|
+
if (Number.isFinite(target) && target > 0) values.push(target);
|
|
942
|
+
}
|
|
943
|
+
return values;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function findMentionedExercises(text, snapshot) {
|
|
947
|
+
const exercisesByName = new Map();
|
|
948
|
+
for (const session of snapshot?.sessions ?? []) {
|
|
949
|
+
for (const exercise of session.exercises ?? []) {
|
|
950
|
+
if (!exercise?.name) continue;
|
|
951
|
+
const normalizedName = normalizeExerciseName(exercise.name);
|
|
952
|
+
if (!normalizedName || exercisesByName.has(normalizedName)) continue;
|
|
953
|
+
exercisesByName.set(normalizedName, exercise.name);
|
|
954
|
+
}
|
|
955
|
+
for (const exercise of session.prescriptionSnapshot?.exercises ?? []) {
|
|
956
|
+
if (!exercise?.exerciseName) continue;
|
|
957
|
+
const normalizedName = normalizeExerciseName(exercise.exerciseName);
|
|
958
|
+
if (!normalizedName || exercisesByName.has(normalizedName)) continue;
|
|
959
|
+
exercisesByName.set(normalizedName, exercise.exerciseName);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const mentions = [];
|
|
964
|
+
for (const [normalizedName, displayName] of exercisesByName) {
|
|
965
|
+
const pattern = new RegExp(`\\b${escapeRegExp(displayName)}\\b`, 'gi');
|
|
966
|
+
for (const match of text.matchAll(pattern)) {
|
|
967
|
+
mentions.push({
|
|
968
|
+
index: match.index ?? -1,
|
|
969
|
+
end: (match.index ?? -1) + match[0].length,
|
|
970
|
+
name: displayName,
|
|
971
|
+
normalizedName
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return mentions
|
|
976
|
+
.filter((mention, index, allMentions) => !allMentions.some((candidate, candidateIndex) =>
|
|
977
|
+
candidateIndex !== index &&
|
|
978
|
+
candidate.index <= mention.index &&
|
|
979
|
+
candidate.end >= mention.end &&
|
|
980
|
+
candidate.normalizedName.length > mention.normalizedName.length
|
|
981
|
+
))
|
|
982
|
+
.sort((lhs, rhs) => lhs.index - rhs.index);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function findRecentSessionMisses(snapshot, { lookbackDays = 7, exerciseNames = null } = {}) {
|
|
986
|
+
const sessions = snapshot?.sessions ?? [];
|
|
987
|
+
const cutoff = Date.now() - lookbackDays * 24 * 60 * 60 * 1000;
|
|
988
|
+
const scopedExerciseNames = exerciseNames && exerciseNames.length > 0 ? new Set(exerciseNames) : null;
|
|
989
|
+
const misses = [];
|
|
990
|
+
for (const session of sessions) {
|
|
991
|
+
const completedAt = session.completedAt || session.date;
|
|
992
|
+
const completedTime = Date.parse(completedAt);
|
|
993
|
+
if (!Number.isFinite(completedTime) || completedTime < cutoff) continue;
|
|
994
|
+
const targetByExercise = new Map();
|
|
995
|
+
for (const planned of session.prescriptionSnapshot?.exercises ?? []) {
|
|
996
|
+
const target = Number(planned.targetReps);
|
|
997
|
+
if (Number.isFinite(target) && target > 0) {
|
|
998
|
+
targetByExercise.set(normalizeExerciseName(planned.exerciseName), target);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
for (const exercise of session.exercises ?? []) {
|
|
1002
|
+
const normalizedExerciseName = normalizeExerciseName(exercise.name);
|
|
1003
|
+
if (scopedExerciseNames && !scopedExerciseNames.has(normalizedExerciseName)) continue;
|
|
1004
|
+
const target = targetByExercise.get(normalizedExerciseName);
|
|
1005
|
+
if (!Number.isFinite(target)) continue;
|
|
1006
|
+
for (const set of exercise.sets ?? []) {
|
|
1007
|
+
const reps = Number(set.reps);
|
|
1008
|
+
if (set.isComplete && Number.isFinite(reps) && reps < target) {
|
|
1009
|
+
misses.push({ sessionId: session.id, exerciseName: exercise.name, reps, target });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return misses;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function findNearestMentionedExercise(mentions, index) {
|
|
1018
|
+
let candidate = null;
|
|
1019
|
+
for (const mention of mentions) {
|
|
1020
|
+
if (mention.index > index) break;
|
|
1021
|
+
candidate = mention;
|
|
1022
|
+
}
|
|
1023
|
+
return candidate;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function hasAskFatigueSupport(snapshot, lookbackDays = 7) {
|
|
1027
|
+
const cutoff = Date.now() - lookbackDays * 24 * 60 * 60 * 1000;
|
|
1028
|
+
const withinCutoff = (dateValue) => {
|
|
1029
|
+
const ms = Date.parse(dateValue);
|
|
1030
|
+
return Number.isFinite(ms) && ms >= cutoff;
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
const vitalsSummaries = snapshot?.vitalsSummaries ?? [];
|
|
1034
|
+
if (vitalsSummaries.some((entry) => withinCutoff(entry.date))) return true;
|
|
1035
|
+
|
|
1036
|
+
const metrics = snapshot?.healthMetrics ?? {};
|
|
1037
|
+
for (const key of ['restingHR', 'hrv', 'sleep']) {
|
|
1038
|
+
const readings = Array.isArray(metrics[key]) ? metrics[key] : [];
|
|
1039
|
+
if (readings.some((reading) => withinCutoff(reading.date))) return true;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
for (const session of snapshot?.sessions ?? []) {
|
|
1043
|
+
const completedAt = session.completedAt || session.date;
|
|
1044
|
+
if (!withinCutoff(completedAt)) continue;
|
|
1045
|
+
for (const exercise of session.exercises ?? []) {
|
|
1046
|
+
const reps = (exercise.sets ?? [])
|
|
1047
|
+
.map((set) => Number(set.reps))
|
|
1048
|
+
.filter((value) => Number.isFinite(value) && value > 0);
|
|
1049
|
+
if (reps.length < 2) continue;
|
|
1050
|
+
const first = reps[0];
|
|
1051
|
+
const last = reps[reps.length - 1];
|
|
1052
|
+
if (first > 0 && (first - last) / first >= 0.3) return true;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return false;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function extractAskWeightClaims(text) {
|
|
1060
|
+
const claims = [];
|
|
1061
|
+
const pattern = /\b(\d+(?:\.\d+)?)\s*(?:kg|kilograms?)\b/gi;
|
|
1062
|
+
for (const match of text.matchAll(pattern)) {
|
|
1063
|
+
claims.push({
|
|
1064
|
+
text: match[0],
|
|
1065
|
+
value: Number(match[1]),
|
|
1066
|
+
index: match.index ?? -1
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
return claims;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function allowedWeightsForExercise(snapshot, normalizedExerciseName) {
|
|
1073
|
+
const weights = [];
|
|
1074
|
+
for (const session of snapshot?.sessions ?? []) {
|
|
1075
|
+
for (const exercise of session.exercises ?? []) {
|
|
1076
|
+
if (normalizeExerciseName(exercise.name) !== normalizedExerciseName) continue;
|
|
1077
|
+
for (const set of exercise.sets ?? []) {
|
|
1078
|
+
const weight = Number(set.weight);
|
|
1079
|
+
if (Number.isFinite(weight)) weights.push(weight);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
for (const exercise of session.prescriptionSnapshot?.exercises ?? []) {
|
|
1083
|
+
if (normalizeExerciseName(exercise.exerciseName) !== normalizedExerciseName) continue;
|
|
1084
|
+
const targetWeight = Number(exercise.targetWeight);
|
|
1085
|
+
if (Number.isFinite(targetWeight)) weights.push(targetWeight);
|
|
1086
|
+
for (const targetSet of exercise.targetSets ?? []) {
|
|
1087
|
+
const weight = Number(targetSet.weight ?? targetSet.targetWeight);
|
|
1088
|
+
if (Number.isFinite(weight)) weights.push(weight);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return weights;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function weightClaimSupported(claim, allowedWeights) {
|
|
1096
|
+
return allowedWeights.some((weight) => Math.abs(weight - claim.value) < 0.01);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function isEstimatedOneRepMaxWeightClaim(text, claim) {
|
|
1100
|
+
const start = Math.max(0, claim.index - 40);
|
|
1101
|
+
const end = Math.min(text.length, claim.index + claim.text.length + 40);
|
|
1102
|
+
const window = text.slice(start, end);
|
|
1103
|
+
return /\b(?:estimated\s+)?(?:1rm|one[-\s]?rep\s+max)\b/i.test(window);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function isVolumeWeightClaim(text, claim) {
|
|
1107
|
+
const start = Math.max(0, claim.index - 30);
|
|
1108
|
+
const end = Math.min(text.length, claim.index + claim.text.length + 30);
|
|
1109
|
+
const window = text.slice(start, end);
|
|
1110
|
+
return /\bvolume\b/i.test(window);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function evaluateAskClaims(output, snapshot, testCase) {
|
|
1114
|
+
if (testCase.surface !== 'ask') {
|
|
1115
|
+
return { key: 'ask_claims', passed: true, reason: 'Not an ask answer.' };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const normalized = normalizeText(output);
|
|
1119
|
+
if (normalized === 'NO_INSIGHT' || !normalized) {
|
|
1120
|
+
return { key: 'ask_claims', passed: true, reason: 'NO_INSIGHT bypasses ask claim validation.' };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const failures = [];
|
|
1124
|
+
const mentionedExercises = findMentionedExercises(output, snapshot);
|
|
1125
|
+
const scopedExerciseNames = uniqueStrings(mentionedExercises.map((mention) => mention.normalizedName));
|
|
1126
|
+
|
|
1127
|
+
const targetHitClaims = extractAskTargetHitClaims(normalized);
|
|
1128
|
+
if (targetHitClaims.length > 0) {
|
|
1129
|
+
const misses = findRecentSessionMisses(snapshot, { exerciseNames: scopedExerciseNames });
|
|
1130
|
+
if (misses.length > 0) {
|
|
1131
|
+
const sample = misses[0];
|
|
1132
|
+
failures.push(`Ask answer claims targets hit ("${targetHitClaims[0].text}") but recent session ${sample.sessionId} has ${sample.exerciseName} at ${sample.reps} reps below target ${sample.target}.`);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const cleanConsistencyClaims = extractAskCleanConsistencyClaims(normalized);
|
|
1137
|
+
if (cleanConsistencyClaims.length > 0) {
|
|
1138
|
+
const misses = findRecentSessionMisses(snapshot, { exerciseNames: scopedExerciseNames });
|
|
1139
|
+
if (misses.length > 0) {
|
|
1140
|
+
const sample = misses[0];
|
|
1141
|
+
failures.push(`Ask answer frames missed target reps as "${cleanConsistencyClaims[0].text}", but recent session ${sample.sessionId} has ${sample.exerciseName} at ${sample.reps} reps below target ${sample.target}.`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
for (const claim of extractAskPlannedListClaims(normalized)) {
|
|
1146
|
+
const uniquePlanned = new Set(claim.reps);
|
|
1147
|
+
if (uniquePlanned.size > 1) {
|
|
1148
|
+
failures.push(`Ask answer asserts non-uniform plan "${claim.text}". Plans are uniform per exercise; this list likely restates actual reps as if they were planned.`);
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
const plannedValue = claim.reps[0];
|
|
1152
|
+
const referencedExercise = findNearestMentionedExercise(mentionedExercises, claim.index);
|
|
1153
|
+
const anySessionHasThisPlan = (snapshot?.sessions ?? []).some((session) => {
|
|
1154
|
+
if (!referencedExercise) {
|
|
1155
|
+
return sessionPlannedReps(session).some((value) => value === plannedValue);
|
|
1156
|
+
}
|
|
1157
|
+
const plannedExercises = session?.prescriptionSnapshot?.exercises ?? [];
|
|
1158
|
+
return plannedExercises.some((exercise) => {
|
|
1159
|
+
const exerciseName = normalizeExerciseName(exercise.exerciseName);
|
|
1160
|
+
const target = Number(exercise.targetReps);
|
|
1161
|
+
return exerciseName === referencedExercise.normalizedName && target === plannedValue;
|
|
1162
|
+
});
|
|
1163
|
+
});
|
|
1164
|
+
if (!anySessionHasThisPlan) {
|
|
1165
|
+
if (referencedExercise) {
|
|
1166
|
+
failures.push(`Ask answer asserts plan "${claim.text}" for ${referencedExercise.name} but no session in the snapshot has that target rep count for that exercise.`);
|
|
1167
|
+
} else {
|
|
1168
|
+
failures.push(`Ask answer asserts plan "${claim.text}" but no session in the snapshot has that target rep count.`);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
for (const claim of extractAskWeightClaims(normalized)) {
|
|
1174
|
+
if (isEstimatedOneRepMaxWeightClaim(normalized, claim)) continue;
|
|
1175
|
+
if (isVolumeWeightClaim(normalized, claim)) continue;
|
|
1176
|
+
const referencedExercise = findNearestMentionedExercise(mentionedExercises, claim.index);
|
|
1177
|
+
if (!referencedExercise) continue;
|
|
1178
|
+
const allowedWeights = allowedWeightsForExercise(snapshot, referencedExercise.normalizedName);
|
|
1179
|
+
if (allowedWeights.length === 0) continue;
|
|
1180
|
+
if (!weightClaimSupported(claim, allowedWeights)) {
|
|
1181
|
+
failures.push(`Ask answer asserts ${claim.text} for ${referencedExercise.name}, but that weight is not present in recorded or planned sets for that exercise.`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
if (hasFatigueLanguage(normalized) && !hasAskFatigueSupport(snapshot)) {
|
|
1186
|
+
failures.push('Ask answer uses fatigue/recovery language but the snapshot has no recent vitals, sleep, or rep-dropoff signals to support it.');
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
return {
|
|
1190
|
+
key: 'ask_claims',
|
|
1191
|
+
passed: failures.length === 0,
|
|
1192
|
+
reason: failures.length === 0
|
|
1193
|
+
? 'Ask claims are supported by the snapshot.'
|
|
1194
|
+
: failures.join(' ')
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function evaluateAskToolProvenance(output, context, testCase) {
|
|
1199
|
+
if (testCase.surface !== 'ask') {
|
|
1200
|
+
return { key: 'ask_tool_provenance', passed: true, reason: 'Not an ask answer.' };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
const routedMetadata = context?.routedMetadata ?? {};
|
|
1204
|
+
const toolsUsed = new Set(routedMetadata.toolsUsed ?? []);
|
|
1205
|
+
const failures = [];
|
|
1206
|
+
for (const toolName of uniqueStrings(testCase.requiredTools)) {
|
|
1207
|
+
if (!toolsUsed.has(toolName)) {
|
|
1208
|
+
failures.push(`Expected routed Ask Coach context to use ${toolName}.`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
if (/\b(?:estimated\s+)?(?:e1rm|1rm|one[- ]rep max)\b/i.test(output) && !toolsUsed.has('get_records')) {
|
|
1213
|
+
failures.push('Ask answer mentions e1RM/1RM, but routed context did not use get_records.');
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
return {
|
|
1217
|
+
key: 'ask_tool_provenance',
|
|
1218
|
+
passed: failures.length === 0,
|
|
1219
|
+
reason: failures.length === 0
|
|
1220
|
+
? 'Ask tool provenance matches requirements.'
|
|
1221
|
+
: failures.join(' ')
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function firstAction(payload) {
|
|
1226
|
+
const actions = Array.isArray(payload?.recommendedNextActions) ? payload.recommendedNextActions : [];
|
|
1227
|
+
return actions.find((action) => typeof action?.action === 'string' && action.action.trim());
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function evaluateScoreCommentaryAction(output, context, testCase) {
|
|
1231
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1232
|
+
return { key: 'score_commentary_action', passed: true, reason: 'Not score commentary.' };
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const payload = scoreCommentaryPayload(output);
|
|
1236
|
+
const hasNegativeDrivers = Array.isArray(context?.topNegativeDrivers) && context.topNegativeDrivers.length > 0;
|
|
1237
|
+
const hasAction = Boolean(firstAction(payload));
|
|
1238
|
+
const noTrainingSignal = context?.dataTier === 'noTrainingSignal';
|
|
1239
|
+
const failures = [];
|
|
1240
|
+
|
|
1241
|
+
if (noTrainingSignal && hasAction) {
|
|
1242
|
+
failures.push('No-training-signal score commentary must not include a recommended action.');
|
|
1243
|
+
}
|
|
1244
|
+
if (!noTrainingSignal && hasNegativeDrivers && !hasAction) {
|
|
1245
|
+
failures.push('Score commentary with negative drivers must include a recommended action.');
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
return {
|
|
1249
|
+
key: 'score_commentary_action',
|
|
1250
|
+
passed: failures.length === 0,
|
|
1251
|
+
reason: failures.length === 0
|
|
1252
|
+
? 'Score commentary action presence matches score state.'
|
|
1253
|
+
: failures.join(' ')
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function driverKeyword(driver) {
|
|
1258
|
+
const text = normalizeText(driver?.message ?? driver?.label ?? driver?.driverMessage ?? driver?.component ?? '');
|
|
1259
|
+
const tokens = text
|
|
1260
|
+
.toLowerCase()
|
|
1261
|
+
.split(/[^a-z0-9]+/)
|
|
1262
|
+
.filter((token) => token.length >= 4 && !['score', 'sets', 'work', 'this', 'that', 'with', 'from', 'your'].includes(token));
|
|
1263
|
+
return tokens[0] ?? null;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function evaluateScoreCommentarySynthesis(output, context, testCase) {
|
|
1267
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1268
|
+
return { key: 'score_commentary_synthesis', passed: true, reason: 'Not score commentary.' };
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const positives = context?.topPositiveDrivers ?? [];
|
|
1272
|
+
const negatives = context?.topNegativeDrivers ?? [];
|
|
1273
|
+
if (positives.length === 0 || negatives.length === 0) {
|
|
1274
|
+
return {
|
|
1275
|
+
key: 'score_commentary_synthesis',
|
|
1276
|
+
passed: true,
|
|
1277
|
+
reason: 'Mixed-driver synthesis is not required for one-sided commentary.'
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const text = scoreCommentaryText(output).toLowerCase();
|
|
1282
|
+
const positiveKeyword = driverKeyword(positives[0]);
|
|
1283
|
+
const negativeKeyword = driverKeyword(negatives[0]);
|
|
1284
|
+
const hasPositive = positiveKeyword ? text.includes(positiveKeyword) : true;
|
|
1285
|
+
const hasNegative = negativeKeyword ? text.includes(negativeKeyword) : true;
|
|
1286
|
+
const hasContrast = /\bbut\b|\bwhile\b|\balthough\b|\bhowever\b/.test(text);
|
|
1287
|
+
|
|
1288
|
+
return {
|
|
1289
|
+
key: 'score_commentary_synthesis',
|
|
1290
|
+
passed: hasPositive && hasNegative && hasContrast,
|
|
1291
|
+
reason: hasPositive && hasNegative && hasContrast
|
|
1292
|
+
? 'Mixed positive and negative drivers are synthesized.'
|
|
1293
|
+
: 'Mixed-driver commentary must reference both directions with contrast language.'
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function evaluateScoreCommentaryExerciseInvention(output, snapshot, context, testCase) {
|
|
1298
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1299
|
+
return { key: 'score_commentary_no_exercise_invention', passed: true, reason: 'Not score commentary.' };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const allowedText = [
|
|
1303
|
+
...(context?.topPositiveDrivers ?? []),
|
|
1304
|
+
...(context?.topNegativeDrivers ?? [])
|
|
1305
|
+
].map((driver) => [
|
|
1306
|
+
driver?.message,
|
|
1307
|
+
driver?.label,
|
|
1308
|
+
driver?.relatedExerciseName,
|
|
1309
|
+
driver?.relatedExerciseSlug,
|
|
1310
|
+
driver?.exerciseName
|
|
1311
|
+
].filter(Boolean).join(' ')).join(' ');
|
|
1312
|
+
const normalizedAllowed = normalizeExerciseName(allowedText);
|
|
1313
|
+
const normalizedOutput = normalizeExerciseName(scoreCommentaryText(output));
|
|
1314
|
+
const invented = [];
|
|
1315
|
+
|
|
1316
|
+
for (const exerciseName of collectAllExerciseNames(snapshot)) {
|
|
1317
|
+
const normalizedName = normalizeExerciseName(exerciseName);
|
|
1318
|
+
if (!normalizedName) continue;
|
|
1319
|
+
const pattern = new RegExp(`(?<!\\S)${escapeRegex(normalizedName)}(?!\\S)`);
|
|
1320
|
+
if (pattern.test(normalizedOutput) && !pattern.test(normalizedAllowed)) {
|
|
1321
|
+
invented.push(exerciseName);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
return {
|
|
1326
|
+
key: 'score_commentary_no_exercise_invention',
|
|
1327
|
+
passed: invented.length === 0,
|
|
1328
|
+
reason: invented.length === 0
|
|
1329
|
+
? 'Score commentary only names exercises present in score drivers.'
|
|
1330
|
+
: `Invented exercise mention(s): ${uniqueStrings(invented).join(', ')}`
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function evaluateScoreCommentaryBand(output, context, testCase) {
|
|
1335
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1336
|
+
return { key: 'score_commentary_score_band', passed: true, reason: 'Not score commentary.' };
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const payload = scoreCommentaryPayload(output);
|
|
1340
|
+
const expected = computeScoreBand(context?.score);
|
|
1341
|
+
const actual = payload.scoreBand ?? null;
|
|
1342
|
+
return {
|
|
1343
|
+
key: 'score_commentary_score_band',
|
|
1344
|
+
passed: actual === expected,
|
|
1345
|
+
reason: actual === expected
|
|
1346
|
+
? 'Score band matches canonical thresholds.'
|
|
1347
|
+
: `Expected scoreBand ${expected ?? 'null'}, got ${actual ?? 'null'}.`
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function evaluateScoreCommentaryTone(output, testCase) {
|
|
1352
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1353
|
+
return { key: 'score_commentary_tone', passed: true, reason: 'Not score commentary.' };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const text = scoreCommentaryText(output);
|
|
1357
|
+
const failures = [];
|
|
1358
|
+
if (text.includes('!')) failures.push('exclamation mark');
|
|
1359
|
+
if (/\b(?:great job|awesome|crushing it|amazing work)\b/i.test(text)) failures.push('cheerleading phrase');
|
|
1360
|
+
if (/[\u{1F300}-\u{1FAFF}]/u.test(text)) failures.push('emoji');
|
|
1361
|
+
|
|
1362
|
+
return {
|
|
1363
|
+
key: 'score_commentary_tone',
|
|
1364
|
+
passed: failures.length === 0,
|
|
1365
|
+
reason: failures.length === 0
|
|
1366
|
+
? 'Score commentary tone is calm and non-cheerleading.'
|
|
1367
|
+
: `Tone violation(s): ${failures.join(', ')}.`
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function evaluateScoreCommentaryLength(output, testCase) {
|
|
1372
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1373
|
+
return { key: 'score_commentary_length', passed: true, reason: 'Not score commentary.' };
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const payload = scoreCommentaryPayload(output);
|
|
1377
|
+
const summary = normalizeText(payload.subhead ?? payload.summaryText);
|
|
1378
|
+
const action = normalizeText(firstAction(payload)?.action);
|
|
1379
|
+
const failures = [];
|
|
1380
|
+
if (summary.length > 140) failures.push(`summaryText/subhead is ${summary.length} chars, expected <= 140.`);
|
|
1381
|
+
if (action && action.length > 140) failures.push(`action is ${action.length} chars, expected <= 140.`);
|
|
1382
|
+
|
|
1383
|
+
return {
|
|
1384
|
+
key: 'score_commentary_length',
|
|
1385
|
+
passed: failures.length === 0,
|
|
1386
|
+
reason: failures.length === 0
|
|
1387
|
+
? 'Score commentary text fits length bounds.'
|
|
1388
|
+
: failures.join(' ')
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function personaEvalConfig(testCase) {
|
|
1393
|
+
if (testCase.personaEval === false) return null;
|
|
1394
|
+
if (testCase.personaEval && typeof testCase.personaEval === 'object') {
|
|
1395
|
+
return testCase.personaEval;
|
|
1396
|
+
}
|
|
1397
|
+
if (testCase.surface === 'workout' || testCase.surface === 'ask') {
|
|
1398
|
+
return {
|
|
1399
|
+
profile: 'motivated-athlete'
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
return null;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function hasPrSignal(output, context, testCase) {
|
|
1406
|
+
const normalized = normalizeText(output);
|
|
1407
|
+
if (/\b(?:new\s+)?(?:rep\s+)?prs?\b/i.test(normalized)) return true;
|
|
1408
|
+
if (/\bstrength prs?\b/i.test(normalized)) return true;
|
|
1409
|
+
if (/\bpersonal record\b/i.test(normalized)) return true;
|
|
1410
|
+
if (testCase.surface === 'workout') {
|
|
1411
|
+
const totalPrCount = (context.prs?.length ?? 0) + (context.bwPrs?.length ?? 0) + (context.repPrs?.length ?? 0);
|
|
1412
|
+
return totalPrCount > 0;
|
|
1413
|
+
}
|
|
1414
|
+
return false;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function evaluatePersonaMotivation(output, context, testCase) {
|
|
1418
|
+
const config = personaEvalConfig(testCase);
|
|
1419
|
+
if (!config) {
|
|
1420
|
+
return {
|
|
1421
|
+
key: 'persona_motivation',
|
|
1422
|
+
passed: true,
|
|
1423
|
+
reason: 'Persona evaluation disabled.'
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
const normalized = normalizeText(output);
|
|
1428
|
+
if (normalized === 'NO_INSIGHT') {
|
|
1429
|
+
return {
|
|
1430
|
+
key: 'persona_motivation',
|
|
1431
|
+
passed: true,
|
|
1432
|
+
reason: 'NO_INSIGHT bypasses persona evaluation.'
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const failures = [];
|
|
1437
|
+
const prSignal = hasPrSignal(normalized, context, testCase);
|
|
1438
|
+
|
|
1439
|
+
const minimizingPatterns = [
|
|
1440
|
+
/\bcould only\b/i,
|
|
1441
|
+
/\bonly\s+\d+\s+reps?\b/i,
|
|
1442
|
+
/\bjust\s+\d+\s+reps?\b/i,
|
|
1443
|
+
/\bbefore tapering to\b/i,
|
|
1444
|
+
/\bbefore dropping to\b/i,
|
|
1445
|
+
/\bbut dropped to\b/i,
|
|
1446
|
+
/\bbut fell to\b/i
|
|
1447
|
+
];
|
|
1448
|
+
|
|
1449
|
+
if (prSignal && minimizingPatterns.some((pattern) => pattern.test(normalized))) {
|
|
1450
|
+
failures.push('Feedback acknowledges a PR/positive result but frames later-set dropoff in a demotivating way.');
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const discouragingPatterns = [
|
|
1454
|
+
/\bdisappointing\b/i,
|
|
1455
|
+
/\bunderwhelming\b/i,
|
|
1456
|
+
/\bunderperformed\b/i,
|
|
1457
|
+
/\bpoor\b/i,
|
|
1458
|
+
/\bnot enough\b/i,
|
|
1459
|
+
/\bfailed to\b/i,
|
|
1460
|
+
/\bstruggled\b/i
|
|
1461
|
+
];
|
|
1462
|
+
|
|
1463
|
+
if (discouragingPatterns.some((pattern) => pattern.test(normalized))) {
|
|
1464
|
+
failures.push('Feedback uses discouraging language that is likely to reduce motivation.');
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
return {
|
|
1468
|
+
key: 'persona_motivation',
|
|
1469
|
+
passed: failures.length === 0,
|
|
1470
|
+
reason: failures.length === 0
|
|
1471
|
+
? `Output stays aligned with the ${config.profile ?? 'persona'} perspective.`
|
|
1472
|
+
: failures.join(' ')
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
703
1476
|
export async function runSummaryEvalCase(testCase) {
|
|
704
1477
|
const snapshot = await loadSummaryEvalSnapshot(testCase);
|
|
705
1478
|
return runSummaryEvalCaseFromSnapshot(testCase, snapshot);
|
|
706
1479
|
}
|
|
707
1480
|
|
|
708
|
-
export
|
|
1481
|
+
export function evaluateSummaryOutputFromSnapshot(testCase, snapshot, output) {
|
|
709
1482
|
const context = buildSummaryEvalContext(snapshot, testCase);
|
|
710
1483
|
if (context == null) {
|
|
711
1484
|
throw new Error(`Eval case ${testCase.id} produced no context`);
|
|
712
1485
|
}
|
|
713
1486
|
|
|
714
|
-
const output = await generateSummaryEvalOutput(testCase, context);
|
|
715
1487
|
if (!normalizeText(output)) {
|
|
716
1488
|
throw new Error(`Eval case ${testCase.id} produced an empty output`);
|
|
717
1489
|
}
|
|
@@ -724,7 +1496,16 @@ export async function runSummaryEvalCaseFromSnapshot(testCase, snapshot) {
|
|
|
724
1496
|
evaluateForbiddenPhrases(output, testCase),
|
|
725
1497
|
evaluateForbiddenMentions(output, testCase),
|
|
726
1498
|
evaluateExerciseMentions(output, snapshot, context, testCase.surface, testCase),
|
|
727
|
-
evaluateWorkoutClaims(output, context, testCase)
|
|
1499
|
+
evaluateWorkoutClaims(output, context, testCase),
|
|
1500
|
+
evaluateAskClaims(output, snapshot, testCase),
|
|
1501
|
+
evaluateAskToolProvenance(output, context, testCase),
|
|
1502
|
+
evaluateScoreCommentaryAction(output, context, testCase),
|
|
1503
|
+
evaluateScoreCommentarySynthesis(output, context, testCase),
|
|
1504
|
+
evaluateScoreCommentaryExerciseInvention(output, snapshot, context, testCase),
|
|
1505
|
+
evaluateScoreCommentaryBand(output, context, testCase),
|
|
1506
|
+
evaluateScoreCommentaryTone(output, testCase),
|
|
1507
|
+
evaluateScoreCommentaryLength(output, testCase),
|
|
1508
|
+
evaluatePersonaMotivation(output, context, testCase)
|
|
728
1509
|
];
|
|
729
1510
|
|
|
730
1511
|
return {
|
|
@@ -732,11 +1513,21 @@ export async function runSummaryEvalCaseFromSnapshot(testCase, snapshot) {
|
|
|
732
1513
|
surface: testCase.surface,
|
|
733
1514
|
name: testCase.name,
|
|
734
1515
|
output,
|
|
1516
|
+
metadata: testCase.metadata ?? null,
|
|
735
1517
|
passed: checks.every((check) => check.passed),
|
|
736
1518
|
checks
|
|
737
1519
|
};
|
|
738
1520
|
}
|
|
739
1521
|
|
|
1522
|
+
export async function runSummaryEvalCaseFromSnapshot(testCase, snapshot) {
|
|
1523
|
+
const context = buildSummaryEvalContext(snapshot, testCase);
|
|
1524
|
+
if (context == null) {
|
|
1525
|
+
throw new Error(`Eval case ${testCase.id} produced no context`);
|
|
1526
|
+
}
|
|
1527
|
+
const output = await generateSummaryEvalOutput(testCase, context, snapshot);
|
|
1528
|
+
return evaluateSummaryOutputFromSnapshot(testCase, snapshot, output);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
740
1531
|
function genericForbiddenPhrasesForSurface(surface) {
|
|
741
1532
|
switch (surface) {
|
|
742
1533
|
case 'workout':
|
|
@@ -747,6 +1538,10 @@ function genericForbiddenPhrasesForSurface(surface) {
|
|
|
747
1538
|
return ['solid progress', 'quality work', 'trust the process', 'in a great place'];
|
|
748
1539
|
case 'vitals':
|
|
749
1540
|
return [];
|
|
1541
|
+
case 'ask':
|
|
1542
|
+
return [];
|
|
1543
|
+
case 'scoreCommentary':
|
|
1544
|
+
return ['great job', 'awesome', 'crushing it', 'amazing work'];
|
|
750
1545
|
default:
|
|
751
1546
|
return [];
|
|
752
1547
|
}
|
|
@@ -764,7 +1559,51 @@ function latestCycleSummariesWithAI(snapshot) {
|
|
|
764
1559
|
return [...latestByProgram.values()];
|
|
765
1560
|
}
|
|
766
1561
|
|
|
767
|
-
|
|
1562
|
+
function normalizeStoredAIMetadata(surface, metadata, {
|
|
1563
|
+
model = null,
|
|
1564
|
+
generatedAt = null,
|
|
1565
|
+
promptVersion = null,
|
|
1566
|
+
gitSha = null
|
|
1567
|
+
} = {}) {
|
|
1568
|
+
if (!metadata && !model && !generatedAt && !promptVersion && !gitSha) {
|
|
1569
|
+
return null;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
return {
|
|
1573
|
+
surface,
|
|
1574
|
+
generatedAt: metadata?.generatedAt ?? generatedAt ?? null,
|
|
1575
|
+
model: metadata?.model ?? model ?? null,
|
|
1576
|
+
promptVersion: metadata?.promptVersion ?? promptVersion ?? null,
|
|
1577
|
+
gitSha: metadata?.gitSha ?? gitSha ?? null,
|
|
1578
|
+
langfuseTraceId: metadata?.langfuseTraceId ?? metadata?.traceId ?? null,
|
|
1579
|
+
langfuseObservationId: metadata?.langfuseObservationId ?? metadata?.observationId ?? null
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
export function currentPromptVersions() {
|
|
1584
|
+
return new Set(Object.values(AI_PROMPT_VERSIONS));
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function metadataPassesFilter(metadata, { onlyPromptVersions, since }) {
|
|
1588
|
+
if (onlyPromptVersions) {
|
|
1589
|
+
const version = metadata?.promptVersion;
|
|
1590
|
+
if (!version || !onlyPromptVersions.has(version)) return false;
|
|
1591
|
+
}
|
|
1592
|
+
if (since) {
|
|
1593
|
+
const generatedAt = metadata?.generatedAt;
|
|
1594
|
+
if (!generatedAt || generatedAt < since) return false;
|
|
1595
|
+
}
|
|
1596
|
+
return true;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapshot', options = {}) {
|
|
1600
|
+
const filter = {
|
|
1601
|
+
onlyPromptVersions: options.onlyPromptVersions ?? null,
|
|
1602
|
+
since: options.since ?? null
|
|
1603
|
+
};
|
|
1604
|
+
const pushIfIncluded = (bucket, item) => {
|
|
1605
|
+
if (metadataPassesFilter(item.metadata, filter)) bucket.push(item);
|
|
1606
|
+
};
|
|
768
1607
|
const harvested = [];
|
|
769
1608
|
|
|
770
1609
|
for (const session of snapshot.sessions ?? []) {
|
|
@@ -774,7 +1613,7 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
|
|
|
774
1613
|
...(session.exercises ?? []).map((exercise) => exercise.name),
|
|
775
1614
|
session.dayName
|
|
776
1615
|
].filter(Boolean);
|
|
777
|
-
harvested
|
|
1616
|
+
pushIfIncluded(harvested, {
|
|
778
1617
|
id: `stored-workout-${session.id}`,
|
|
779
1618
|
name: `Stored workout summary ${session.id}`,
|
|
780
1619
|
surface: 'workout',
|
|
@@ -782,10 +1621,14 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
|
|
|
782
1621
|
snapshotLabel,
|
|
783
1622
|
selector: { sessionId: session.id },
|
|
784
1623
|
output: text,
|
|
1624
|
+
metadata: normalizeStoredAIMetadata('workout', session.summary?.aiCoachMetadata, {
|
|
1625
|
+
model: session.summary?.aiCoachModel ?? null
|
|
1626
|
+
}),
|
|
785
1627
|
requiredMentions: [],
|
|
786
1628
|
requiredAnyOfMentions: anyOf,
|
|
787
1629
|
forbiddenPhrases: genericForbiddenPhrasesForSurface('workout'),
|
|
788
1630
|
forbiddenMentions: [],
|
|
1631
|
+
personaEval: { profile: 'motivated-athlete' },
|
|
789
1632
|
expectNoInsight: false,
|
|
790
1633
|
shouldPass: true
|
|
791
1634
|
});
|
|
@@ -799,7 +1642,7 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
|
|
|
799
1642
|
'hrv',
|
|
800
1643
|
'resting hr'
|
|
801
1644
|
].filter(Boolean);
|
|
802
|
-
harvested
|
|
1645
|
+
pushIfIncluded(harvested, {
|
|
803
1646
|
id: `stored-cycle-${summary.id}`,
|
|
804
1647
|
name: `Stored cycle summary ${summary.id}`,
|
|
805
1648
|
surface: 'cycle',
|
|
@@ -807,10 +1650,14 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
|
|
|
807
1650
|
snapshotLabel,
|
|
808
1651
|
selector: { programId: summary.programId },
|
|
809
1652
|
output: summary.aiSummary,
|
|
1653
|
+
metadata: normalizeStoredAIMetadata('cycle', summary.aiSummaryMetadata, {
|
|
1654
|
+
model: summary.aiSummaryModel ?? null
|
|
1655
|
+
}),
|
|
810
1656
|
requiredMentions: [],
|
|
811
1657
|
requiredAnyOfMentions: anyOf,
|
|
812
1658
|
forbiddenPhrases: genericForbiddenPhrasesForSurface('cycle'),
|
|
813
1659
|
forbiddenMentions: [],
|
|
1660
|
+
personaEval: false,
|
|
814
1661
|
expectNoInsight: false,
|
|
815
1662
|
shouldPass: true
|
|
816
1663
|
});
|
|
@@ -818,7 +1665,7 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
|
|
|
818
1665
|
|
|
819
1666
|
const latestVitals = stableSortByDateDesc(snapshot.vitalsSummaries ?? [], (entry) => entry.date)[0];
|
|
820
1667
|
if (latestVitals?.summary) {
|
|
821
|
-
harvested
|
|
1668
|
+
pushIfIncluded(harvested, {
|
|
822
1669
|
id: `stored-vitals-${latestVitals.id ?? latestVitals.date}`,
|
|
823
1670
|
name: `Stored vitals summary ${latestVitals.date}`,
|
|
824
1671
|
surface: 'vitals',
|
|
@@ -826,10 +1673,42 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
|
|
|
826
1673
|
snapshotLabel,
|
|
827
1674
|
selector: {},
|
|
828
1675
|
output: latestVitals.summary,
|
|
1676
|
+
metadata: normalizeStoredAIMetadata('vitals', latestVitals.metadata, {
|
|
1677
|
+
model: latestVitals.model ?? null,
|
|
1678
|
+
generatedAt: latestVitals.date ?? null
|
|
1679
|
+
}),
|
|
829
1680
|
requiredMentions: [],
|
|
830
1681
|
requiredAnyOfMentions: ['recovery', 'readiness', 'train', 'session', 'today'],
|
|
831
1682
|
forbiddenPhrases: genericForbiddenPhrasesForSurface('vitals'),
|
|
832
1683
|
forbiddenMentions: [],
|
|
1684
|
+
personaEval: false,
|
|
1685
|
+
expectNoInsight: false,
|
|
1686
|
+
shouldPass: true
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
for (const conversation of stableSortByDateDesc(snapshot.askConversations ?? [], (entry) => entry.createdAt ?? '')) {
|
|
1691
|
+
const answer = conversation?.messages?.filter((message) => message?.role === 'assistant').at(-1)?.content ?? null;
|
|
1692
|
+
const question = conversation?.messages?.find((message) => message?.role === 'user')?.content ?? '';
|
|
1693
|
+
if (!answer) continue;
|
|
1694
|
+
|
|
1695
|
+
pushIfIncluded(harvested, {
|
|
1696
|
+
id: `stored-ask-${conversation.id}`,
|
|
1697
|
+
name: `Stored ask conversation ${conversation.id}`,
|
|
1698
|
+
surface: 'ask',
|
|
1699
|
+
source: 'stored',
|
|
1700
|
+
snapshotLabel,
|
|
1701
|
+
context: { question, conversationId: conversation.id },
|
|
1702
|
+
output: answer,
|
|
1703
|
+
metadata: normalizeStoredAIMetadata('ask', conversation.metadata, {
|
|
1704
|
+
model: conversation.model ?? null,
|
|
1705
|
+
generatedAt: conversation.createdAt ?? null
|
|
1706
|
+
}),
|
|
1707
|
+
requiredMentions: [],
|
|
1708
|
+
requiredAnyOfMentions: [],
|
|
1709
|
+
forbiddenPhrases: genericForbiddenPhrasesForSurface('ask'),
|
|
1710
|
+
forbiddenMentions: [],
|
|
1711
|
+
personaEval: { profile: 'motivated-athlete' },
|
|
833
1712
|
expectNoInsight: false,
|
|
834
1713
|
shouldPass: true
|
|
835
1714
|
});
|