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.
@@ -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
- export async function generateSummaryEvalOutput(testCase, context) {
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
- return (await generateWorkoutCoachingSummary(context, { apiKey })).text;
130
+ result = await generateWorkoutCoachingSummary(context, { apiKey });
131
+ break;
97
132
  case 'cycle':
98
- return (await generateCoachingSummary(context, { apiKey })).text;
133
+ result = await generateCoachingSummary(context, { apiKey });
134
+ break;
99
135
  case 'checkpoint':
100
- return (await generateCheckpointSummary(context, { apiKey })).text;
136
+ result = await generateCheckpointSummary(context, { apiKey });
137
+ break;
101
138
  case 'vitals':
102
- return (await generateVitalsSummary(context, { apiKey })).text;
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(output);
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) => lowerIncludes(output, 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) => lowerIncludes(output, 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 = normalizeText(output);
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(output);
386
- const paragraphs = paragraphCount(output);
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 (paragraphs < 1 || paragraphs > 4) {
597
+ if ((!isStored && (sentences < 2 || sentences > 8)) || (isStored && (sentences < 1 || sentences > 10))) {
426
598
  passed = false;
427
- reasons.push(`Cycle summaries must be 1-4 paragraphs, got ${paragraphs}.`);
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 async function runSummaryEvalCaseFromSnapshot(testCase, snapshot) {
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
- export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapshot') {
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.push({
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.push({
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.push({
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
  });