incremnt 0.4.0 → 0.5.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,6 +11,8 @@ 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,
@@ -79,30 +83,83 @@ export function buildSummaryEvalContext(snapshot, testCase) {
79
83
  }
80
84
  case 'vitals':
81
85
  return vitalsSummaryContext(snapshot, { exclude: new Set(testCase.exclude ?? []) });
86
+ case 'ask': {
87
+ const question = testCase.context?.question ?? testCase.question ?? '';
88
+ const routed = question
89
+ ? askRoutedContext(snapshot, question, { exclude: new Set(testCase.exclude ?? []) })
90
+ : null;
91
+ return {
92
+ ...(testCase.context ?? {}),
93
+ trainingData: testCase.context?.trainingData ?? routed?.context ?? null,
94
+ routedMetadata: routed?.metadata ?? null
95
+ };
96
+ }
82
97
  default:
83
98
  throw new Error(`Unsupported summary eval surface: ${testCase.surface}`);
84
99
  }
85
100
  }
86
101
 
87
- export async function generateSummaryEvalOutput(testCase, context) {
102
+ function summaryEvalGenerationMetadata(result) {
103
+ if (!result || typeof result !== 'object') return {};
104
+ return Object.fromEntries(
105
+ Object.entries({
106
+ model: result.model,
107
+ durationMs: result.durationMs,
108
+ fallback: result.fallback,
109
+ langfuseTraceId: result.langfuseTraceId,
110
+ langfuseObservationId: result.langfuseObservationId
111
+ }).filter(([, value]) => value !== undefined && value !== null)
112
+ );
113
+ }
114
+
115
+ export async function generateSummaryEvalOutputWithMetadata(testCase, context, snapshot = null) {
88
116
  const liveGenerationEnabled = process.env.SUMMARY_EVALS_LIVE === '1';
89
117
  const apiKey = process.env.OPENROUTER_API_KEY;
90
118
  if (!liveGenerationEnabled || !apiKey || testCase.shouldPass === false) {
91
- return testCase.output;
119
+ return { output: testCase.output, metadata: {} };
92
120
  }
93
121
 
122
+ let result;
94
123
  switch (testCase.surface) {
95
124
  case 'workout':
96
- return (await generateWorkoutCoachingSummary(context, { apiKey })).text;
125
+ result = await generateWorkoutCoachingSummary(context, { apiKey });
126
+ break;
97
127
  case 'cycle':
98
- return (await generateCoachingSummary(context, { apiKey })).text;
128
+ result = await generateCoachingSummary(context, { apiKey });
129
+ break;
99
130
  case 'checkpoint':
100
- return (await generateCheckpointSummary(context, { apiKey })).text;
131
+ result = await generateCheckpointSummary(context, { apiKey });
132
+ break;
101
133
  case 'vitals':
102
- return (await generateVitalsSummary(context, { apiKey })).text;
134
+ result = await generateVitalsSummary(context, { apiKey });
135
+ break;
136
+ case 'ask': {
137
+ if (!snapshot) return { output: testCase.output, metadata: {} };
138
+ const question = context.question ?? testCase.question ?? '';
139
+ if (!question) return { output: testCase.output, metadata: {} };
140
+ const trainingContext = context.trainingData
141
+ ?? askRoutedContext(snapshot, question, { exclude: new Set(testCase.exclude ?? []) }).context
142
+ ?? askContext(snapshot, { exclude: new Set(testCase.exclude ?? []) });
143
+ result = await generateAskAnswer(trainingContext, question, {
144
+ apiKey,
145
+ history: context.history ?? [],
146
+ tone: context.tone,
147
+ model: context.model
148
+ });
149
+ break;
150
+ }
103
151
  default:
104
152
  throw new Error(`Unsupported summary eval surface: ${testCase.surface}`);
105
153
  }
154
+
155
+ return {
156
+ output: result.text,
157
+ metadata: summaryEvalGenerationMetadata(result)
158
+ };
159
+ }
160
+
161
+ export async function generateSummaryEvalOutput(testCase, context, snapshot = null) {
162
+ return (await generateSummaryEvalOutputWithMetadata(testCase, context, snapshot)).output;
106
163
  }
107
164
 
108
165
  function normalizeText(value) {
@@ -129,6 +186,21 @@ function lowerIncludes(text, snippet) {
129
186
  return normalizeText(text).toLowerCase().includes(String(snippet).toLowerCase());
130
187
  }
131
188
 
189
+ function phraseIncludes(text, snippet) {
190
+ const normalizedSnippet = normalizeText(snippet).trim();
191
+ if (!normalizedSnippet) return false;
192
+ const escapedSnippet = escapeRegExp(normalizedSnippet).replace(/\s+/g, '\\s+');
193
+ const startsWithWord = /^\w/.test(normalizedSnippet);
194
+ const endsWithWord = /\w$/.test(normalizedSnippet);
195
+ const pluralSuffix = /^[A-Za-z]{1,3}$/.test(normalizedSnippet) ? 's?' : '';
196
+ const pattern = `${startsWithWord ? '\\b' : ''}${escapedSnippet}${pluralSuffix}${endsWithWord ? '\\b' : ''}`;
197
+ return new RegExp(pattern, 'i').test(normalizeText(text));
198
+ }
199
+
200
+ function escapeRegExp(value) {
201
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
202
+ }
203
+
132
204
  function uniqueStrings(values) {
133
205
  return [...new Set((values ?? []).filter(Boolean))];
134
206
  }
@@ -260,6 +332,14 @@ function historicalExerciseVariants(name) {
260
332
  }
261
333
 
262
334
  function evaluateExerciseMentions(output, snapshot, context, surface, testCase) {
335
+ if (surface === 'ask') {
336
+ return {
337
+ key: 'exercise_mentions',
338
+ passed: true,
339
+ reason: 'Ask-coach answers may mention any exercise from the training history.'
340
+ };
341
+ }
342
+
263
343
  const isStored = testCase.source === 'stored';
264
344
  const allowed = new Set();
265
345
  for (const name of [
@@ -340,7 +420,7 @@ function evaluateAnyOfMentions(output, testCase) {
340
420
  }
341
421
 
342
422
  function evaluateForbiddenPhrases(output, testCase) {
343
- const hits = uniqueStrings(testCase.forbiddenPhrases).filter((phrase) => lowerIncludes(output, phrase));
423
+ const hits = uniqueStrings(testCase.forbiddenPhrases).filter((phrase) => phraseIncludes(output, phrase));
344
424
  return {
345
425
  key: 'forbidden_phrases',
346
426
  passed: hits.length === 0,
@@ -349,7 +429,7 @@ function evaluateForbiddenPhrases(output, testCase) {
349
429
  }
350
430
 
351
431
  function evaluateForbiddenMentions(output, testCase) {
352
- const hits = uniqueStrings(testCase.forbiddenMentions).filter((phrase) => lowerIncludes(output, phrase));
432
+ const hits = uniqueStrings(testCase.forbiddenMentions).filter((phrase) => phraseIncludes(output, phrase));
353
433
  return {
354
434
  key: 'forbidden_mentions',
355
435
  passed: hits.length === 0,
@@ -422,9 +502,17 @@ function evaluateShape(output, testCase) {
422
502
  }
423
503
  break;
424
504
  case 'cycle':
425
- if (paragraphs < 1 || paragraphs > 4) {
505
+ if ((!isStored && (sentences < 2 || sentences > 8)) || (isStored && (sentences < 1 || sentences > 10))) {
426
506
  passed = false;
427
- reasons.push(`Cycle summaries must be 1-4 paragraphs, got ${paragraphs}.`);
507
+ reasons.push(isStored
508
+ ? `Stored cycle summaries must be 1-10 sentences, got ${sentences}.`
509
+ : `Cycle summaries must be 2-8 sentences, got ${sentences}.`);
510
+ }
511
+ if ((!isStored && (paragraphs < 1 || paragraphs > 2)) || (isStored && (paragraphs < 1 || paragraphs > 3))) {
512
+ passed = false;
513
+ reasons.push(isStored
514
+ ? `Stored cycle summaries must be 1-3 paragraphs, got ${paragraphs}.`
515
+ : `Cycle summaries must be 1-2 paragraphs, got ${paragraphs}.`);
428
516
  }
429
517
  break;
430
518
  case 'checkpoint':
@@ -433,6 +521,12 @@ function evaluateShape(output, testCase) {
433
521
  reasons.push(`Checkpoint summaries must be 2-3 paragraphs, got ${paragraphs}.`);
434
522
  }
435
523
  break;
524
+ case 'ask':
525
+ if (sentences < 1 || sentences > 12) {
526
+ passed = false;
527
+ reasons.push(`Ask-coach answers must be 1-12 sentences, got ${sentences}.`);
528
+ }
529
+ break;
436
530
  default:
437
531
  break;
438
532
  }
@@ -700,18 +794,431 @@ function evaluateWorkoutClaims(output, context, testCase) {
700
794
  };
701
795
  }
702
796
 
797
+ function extractAskTargetHitClaims(text) {
798
+ const claims = [];
799
+ const patterns = [
800
+ /\b(?:you\s+)?hit(?:ting)?\s+all\s+(?:your\s+)?target(?:ed)?\s+reps?\b/gi,
801
+ /\b(?:you\s+)?hit\s+all\s+(?:the\s+)?targets?\b/gi,
802
+ /\b(?:you\s+)?hit\s+(?:the|your)\s+target\b(?!\s+(?:of|for|on))/gi
803
+ ];
804
+ for (const pattern of patterns) {
805
+ for (const match of text.matchAll(pattern)) {
806
+ claims.push({ text: match[0] });
807
+ }
808
+ }
809
+ return claims;
810
+ }
811
+
812
+ function extractAskCleanConsistencyClaims(text) {
813
+ const claims = [];
814
+ const patterns = [
815
+ /\bclean,\s+consistent\b/gi,
816
+ /\bclean\s+and\s+consistent\b/gi,
817
+ /\bconsistent\s+set\s+of\s+work\b/gi,
818
+ /\bacross\s+the\s+board\b/gi
819
+ ];
820
+ for (const pattern of patterns) {
821
+ for (const match of text.matchAll(pattern)) {
822
+ claims.push({ text: match[0] });
823
+ }
824
+ }
825
+ return claims;
826
+ }
827
+
828
+ function extractAskPlannedListClaims(text) {
829
+ const claims = [];
830
+ const pattern = /\((\s*\d+(?:\s*,\s*\d+){2,})\s+planned\s*\)/gi;
831
+ for (const match of text.matchAll(pattern)) {
832
+ const reps = match[1].split(',').map((value) => Number(value.trim())).filter(Number.isFinite);
833
+ claims.push({ text: match[0], reps, index: match.index ?? -1 });
834
+ }
835
+ return claims;
836
+ }
837
+
838
+ function sessionPlannedReps(session) {
839
+ const prescription = session?.prescriptionSnapshot?.exercises ?? [];
840
+ const values = [];
841
+ for (const exercise of prescription) {
842
+ const target = Number(exercise.targetReps);
843
+ if (Number.isFinite(target) && target > 0) values.push(target);
844
+ }
845
+ return values;
846
+ }
847
+
848
+ function findMentionedExercises(text, snapshot) {
849
+ const exercisesByName = new Map();
850
+ for (const session of snapshot?.sessions ?? []) {
851
+ for (const exercise of session.exercises ?? []) {
852
+ if (!exercise?.name) continue;
853
+ const normalizedName = normalizeExerciseName(exercise.name);
854
+ if (!normalizedName || exercisesByName.has(normalizedName)) continue;
855
+ exercisesByName.set(normalizedName, exercise.name);
856
+ }
857
+ for (const exercise of session.prescriptionSnapshot?.exercises ?? []) {
858
+ if (!exercise?.exerciseName) continue;
859
+ const normalizedName = normalizeExerciseName(exercise.exerciseName);
860
+ if (!normalizedName || exercisesByName.has(normalizedName)) continue;
861
+ exercisesByName.set(normalizedName, exercise.exerciseName);
862
+ }
863
+ }
864
+
865
+ const mentions = [];
866
+ for (const [normalizedName, displayName] of exercisesByName) {
867
+ const pattern = new RegExp(`\\b${escapeRegExp(displayName)}\\b`, 'gi');
868
+ for (const match of text.matchAll(pattern)) {
869
+ mentions.push({
870
+ index: match.index ?? -1,
871
+ end: (match.index ?? -1) + match[0].length,
872
+ name: displayName,
873
+ normalizedName
874
+ });
875
+ }
876
+ }
877
+ return mentions
878
+ .filter((mention, index, allMentions) => !allMentions.some((candidate, candidateIndex) =>
879
+ candidateIndex !== index &&
880
+ candidate.index <= mention.index &&
881
+ candidate.end >= mention.end &&
882
+ candidate.normalizedName.length > mention.normalizedName.length
883
+ ))
884
+ .sort((lhs, rhs) => lhs.index - rhs.index);
885
+ }
886
+
887
+ function findRecentSessionMisses(snapshot, { lookbackDays = 7, exerciseNames = null } = {}) {
888
+ const sessions = snapshot?.sessions ?? [];
889
+ const cutoff = Date.now() - lookbackDays * 24 * 60 * 60 * 1000;
890
+ const scopedExerciseNames = exerciseNames && exerciseNames.length > 0 ? new Set(exerciseNames) : null;
891
+ const misses = [];
892
+ for (const session of sessions) {
893
+ const completedAt = session.completedAt || session.date;
894
+ const completedTime = Date.parse(completedAt);
895
+ if (!Number.isFinite(completedTime) || completedTime < cutoff) continue;
896
+ const targetByExercise = new Map();
897
+ for (const planned of session.prescriptionSnapshot?.exercises ?? []) {
898
+ const target = Number(planned.targetReps);
899
+ if (Number.isFinite(target) && target > 0) {
900
+ targetByExercise.set(normalizeExerciseName(planned.exerciseName), target);
901
+ }
902
+ }
903
+ for (const exercise of session.exercises ?? []) {
904
+ const normalizedExerciseName = normalizeExerciseName(exercise.name);
905
+ if (scopedExerciseNames && !scopedExerciseNames.has(normalizedExerciseName)) continue;
906
+ const target = targetByExercise.get(normalizedExerciseName);
907
+ if (!Number.isFinite(target)) continue;
908
+ for (const set of exercise.sets ?? []) {
909
+ const reps = Number(set.reps);
910
+ if (set.isComplete && Number.isFinite(reps) && reps < target) {
911
+ misses.push({ sessionId: session.id, exerciseName: exercise.name, reps, target });
912
+ }
913
+ }
914
+ }
915
+ }
916
+ return misses;
917
+ }
918
+
919
+ function findNearestMentionedExercise(mentions, index) {
920
+ let candidate = null;
921
+ for (const mention of mentions) {
922
+ if (mention.index > index) break;
923
+ candidate = mention;
924
+ }
925
+ return candidate;
926
+ }
927
+
928
+ function hasAskFatigueSupport(snapshot, lookbackDays = 7) {
929
+ const cutoff = Date.now() - lookbackDays * 24 * 60 * 60 * 1000;
930
+ const withinCutoff = (dateValue) => {
931
+ const ms = Date.parse(dateValue);
932
+ return Number.isFinite(ms) && ms >= cutoff;
933
+ };
934
+
935
+ const vitalsSummaries = snapshot?.vitalsSummaries ?? [];
936
+ if (vitalsSummaries.some((entry) => withinCutoff(entry.date))) return true;
937
+
938
+ const metrics = snapshot?.healthMetrics ?? {};
939
+ for (const key of ['restingHR', 'hrv', 'sleep']) {
940
+ const readings = Array.isArray(metrics[key]) ? metrics[key] : [];
941
+ if (readings.some((reading) => withinCutoff(reading.date))) return true;
942
+ }
943
+
944
+ for (const session of snapshot?.sessions ?? []) {
945
+ const completedAt = session.completedAt || session.date;
946
+ if (!withinCutoff(completedAt)) continue;
947
+ for (const exercise of session.exercises ?? []) {
948
+ const reps = (exercise.sets ?? [])
949
+ .map((set) => Number(set.reps))
950
+ .filter((value) => Number.isFinite(value) && value > 0);
951
+ if (reps.length < 2) continue;
952
+ const first = reps[0];
953
+ const last = reps[reps.length - 1];
954
+ if (first > 0 && (first - last) / first >= 0.3) return true;
955
+ }
956
+ }
957
+
958
+ return false;
959
+ }
960
+
961
+ function extractAskWeightClaims(text) {
962
+ const claims = [];
963
+ const pattern = /\b(\d+(?:\.\d+)?)\s*(?:kg|kilograms?)\b/gi;
964
+ for (const match of text.matchAll(pattern)) {
965
+ claims.push({
966
+ text: match[0],
967
+ value: Number(match[1]),
968
+ index: match.index ?? -1
969
+ });
970
+ }
971
+ return claims;
972
+ }
973
+
974
+ function allowedWeightsForExercise(snapshot, normalizedExerciseName) {
975
+ const weights = [];
976
+ for (const session of snapshot?.sessions ?? []) {
977
+ for (const exercise of session.exercises ?? []) {
978
+ if (normalizeExerciseName(exercise.name) !== normalizedExerciseName) continue;
979
+ for (const set of exercise.sets ?? []) {
980
+ const weight = Number(set.weight);
981
+ if (Number.isFinite(weight)) weights.push(weight);
982
+ }
983
+ }
984
+ for (const exercise of session.prescriptionSnapshot?.exercises ?? []) {
985
+ if (normalizeExerciseName(exercise.exerciseName) !== normalizedExerciseName) continue;
986
+ const targetWeight = Number(exercise.targetWeight);
987
+ if (Number.isFinite(targetWeight)) weights.push(targetWeight);
988
+ for (const targetSet of exercise.targetSets ?? []) {
989
+ const weight = Number(targetSet.weight ?? targetSet.targetWeight);
990
+ if (Number.isFinite(weight)) weights.push(weight);
991
+ }
992
+ }
993
+ }
994
+ return weights;
995
+ }
996
+
997
+ function weightClaimSupported(claim, allowedWeights) {
998
+ return allowedWeights.some((weight) => Math.abs(weight - claim.value) < 0.01);
999
+ }
1000
+
1001
+ function isEstimatedOneRepMaxWeightClaim(text, claim) {
1002
+ const start = Math.max(0, claim.index - 40);
1003
+ const end = Math.min(text.length, claim.index + claim.text.length + 40);
1004
+ const window = text.slice(start, end);
1005
+ return /\b(?:estimated\s+)?(?:1rm|one[-\s]?rep\s+max)\b/i.test(window);
1006
+ }
1007
+
1008
+ function isVolumeWeightClaim(text, claim) {
1009
+ const start = Math.max(0, claim.index - 30);
1010
+ const end = Math.min(text.length, claim.index + claim.text.length + 30);
1011
+ const window = text.slice(start, end);
1012
+ return /\bvolume\b/i.test(window);
1013
+ }
1014
+
1015
+ function evaluateAskClaims(output, snapshot, testCase) {
1016
+ if (testCase.surface !== 'ask') {
1017
+ return { key: 'ask_claims', passed: true, reason: 'Not an ask answer.' };
1018
+ }
1019
+
1020
+ const normalized = normalizeText(output);
1021
+ if (normalized === 'NO_INSIGHT' || !normalized) {
1022
+ return { key: 'ask_claims', passed: true, reason: 'NO_INSIGHT bypasses ask claim validation.' };
1023
+ }
1024
+
1025
+ const failures = [];
1026
+ const mentionedExercises = findMentionedExercises(output, snapshot);
1027
+ const scopedExerciseNames = uniqueStrings(mentionedExercises.map((mention) => mention.normalizedName));
1028
+
1029
+ const targetHitClaims = extractAskTargetHitClaims(normalized);
1030
+ if (targetHitClaims.length > 0) {
1031
+ const misses = findRecentSessionMisses(snapshot, { exerciseNames: scopedExerciseNames });
1032
+ if (misses.length > 0) {
1033
+ const sample = misses[0];
1034
+ 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}.`);
1035
+ }
1036
+ }
1037
+
1038
+ const cleanConsistencyClaims = extractAskCleanConsistencyClaims(normalized);
1039
+ if (cleanConsistencyClaims.length > 0) {
1040
+ const misses = findRecentSessionMisses(snapshot, { exerciseNames: scopedExerciseNames });
1041
+ if (misses.length > 0) {
1042
+ const sample = misses[0];
1043
+ 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}.`);
1044
+ }
1045
+ }
1046
+
1047
+ for (const claim of extractAskPlannedListClaims(normalized)) {
1048
+ const uniquePlanned = new Set(claim.reps);
1049
+ if (uniquePlanned.size > 1) {
1050
+ 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.`);
1051
+ continue;
1052
+ }
1053
+ const plannedValue = claim.reps[0];
1054
+ const referencedExercise = findNearestMentionedExercise(mentionedExercises, claim.index);
1055
+ const anySessionHasThisPlan = (snapshot?.sessions ?? []).some((session) => {
1056
+ if (!referencedExercise) {
1057
+ return sessionPlannedReps(session).some((value) => value === plannedValue);
1058
+ }
1059
+ const plannedExercises = session?.prescriptionSnapshot?.exercises ?? [];
1060
+ return plannedExercises.some((exercise) => {
1061
+ const exerciseName = normalizeExerciseName(exercise.exerciseName);
1062
+ const target = Number(exercise.targetReps);
1063
+ return exerciseName === referencedExercise.normalizedName && target === plannedValue;
1064
+ });
1065
+ });
1066
+ if (!anySessionHasThisPlan) {
1067
+ if (referencedExercise) {
1068
+ 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.`);
1069
+ } else {
1070
+ failures.push(`Ask answer asserts plan "${claim.text}" but no session in the snapshot has that target rep count.`);
1071
+ }
1072
+ }
1073
+ }
1074
+
1075
+ for (const claim of extractAskWeightClaims(normalized)) {
1076
+ if (isEstimatedOneRepMaxWeightClaim(normalized, claim)) continue;
1077
+ if (isVolumeWeightClaim(normalized, claim)) continue;
1078
+ const referencedExercise = findNearestMentionedExercise(mentionedExercises, claim.index);
1079
+ if (!referencedExercise) continue;
1080
+ const allowedWeights = allowedWeightsForExercise(snapshot, referencedExercise.normalizedName);
1081
+ if (allowedWeights.length === 0) continue;
1082
+ if (!weightClaimSupported(claim, allowedWeights)) {
1083
+ failures.push(`Ask answer asserts ${claim.text} for ${referencedExercise.name}, but that weight is not present in recorded or planned sets for that exercise.`);
1084
+ }
1085
+ }
1086
+
1087
+ if (hasFatigueLanguage(normalized) && !hasAskFatigueSupport(snapshot)) {
1088
+ failures.push('Ask answer uses fatigue/recovery language but the snapshot has no recent vitals, sleep, or rep-dropoff signals to support it.');
1089
+ }
1090
+
1091
+ return {
1092
+ key: 'ask_claims',
1093
+ passed: failures.length === 0,
1094
+ reason: failures.length === 0
1095
+ ? 'Ask claims are supported by the snapshot.'
1096
+ : failures.join(' ')
1097
+ };
1098
+ }
1099
+
1100
+ function evaluateAskToolProvenance(output, context, testCase) {
1101
+ if (testCase.surface !== 'ask') {
1102
+ return { key: 'ask_tool_provenance', passed: true, reason: 'Not an ask answer.' };
1103
+ }
1104
+
1105
+ const routedMetadata = context?.routedMetadata ?? {};
1106
+ const toolsUsed = new Set(routedMetadata.toolsUsed ?? []);
1107
+ const failures = [];
1108
+ for (const toolName of uniqueStrings(testCase.requiredTools)) {
1109
+ if (!toolsUsed.has(toolName)) {
1110
+ failures.push(`Expected routed Ask Coach context to use ${toolName}.`);
1111
+ }
1112
+ }
1113
+
1114
+ if (/\b(?:estimated\s+)?(?:e1rm|1rm|one[- ]rep max)\b/i.test(output) && !toolsUsed.has('get_records')) {
1115
+ failures.push('Ask answer mentions e1RM/1RM, but routed context did not use get_records.');
1116
+ }
1117
+
1118
+ return {
1119
+ key: 'ask_tool_provenance',
1120
+ passed: failures.length === 0,
1121
+ reason: failures.length === 0
1122
+ ? 'Ask tool provenance matches requirements.'
1123
+ : failures.join(' ')
1124
+ };
1125
+ }
1126
+
1127
+ function personaEvalConfig(testCase) {
1128
+ if (testCase.personaEval === false) return null;
1129
+ if (testCase.personaEval && typeof testCase.personaEval === 'object') {
1130
+ return testCase.personaEval;
1131
+ }
1132
+ if (testCase.surface === 'workout' || testCase.surface === 'ask') {
1133
+ return {
1134
+ profile: 'motivated-athlete'
1135
+ };
1136
+ }
1137
+ return null;
1138
+ }
1139
+
1140
+ function hasPrSignal(output, context, testCase) {
1141
+ const normalized = normalizeText(output);
1142
+ if (/\b(?:new\s+)?(?:rep\s+)?prs?\b/i.test(normalized)) return true;
1143
+ if (/\bstrength prs?\b/i.test(normalized)) return true;
1144
+ if (/\bpersonal record\b/i.test(normalized)) return true;
1145
+ if (testCase.surface === 'workout') {
1146
+ const totalPrCount = (context.prs?.length ?? 0) + (context.bwPrs?.length ?? 0) + (context.repPrs?.length ?? 0);
1147
+ return totalPrCount > 0;
1148
+ }
1149
+ return false;
1150
+ }
1151
+
1152
+ function evaluatePersonaMotivation(output, context, testCase) {
1153
+ const config = personaEvalConfig(testCase);
1154
+ if (!config) {
1155
+ return {
1156
+ key: 'persona_motivation',
1157
+ passed: true,
1158
+ reason: 'Persona evaluation disabled.'
1159
+ };
1160
+ }
1161
+
1162
+ const normalized = normalizeText(output);
1163
+ if (normalized === 'NO_INSIGHT') {
1164
+ return {
1165
+ key: 'persona_motivation',
1166
+ passed: true,
1167
+ reason: 'NO_INSIGHT bypasses persona evaluation.'
1168
+ };
1169
+ }
1170
+
1171
+ const failures = [];
1172
+ const prSignal = hasPrSignal(normalized, context, testCase);
1173
+
1174
+ const minimizingPatterns = [
1175
+ /\bcould only\b/i,
1176
+ /\bonly\s+\d+\s+reps?\b/i,
1177
+ /\bjust\s+\d+\s+reps?\b/i,
1178
+ /\bbefore tapering to\b/i,
1179
+ /\bbefore dropping to\b/i,
1180
+ /\bbut dropped to\b/i,
1181
+ /\bbut fell to\b/i
1182
+ ];
1183
+
1184
+ if (prSignal && minimizingPatterns.some((pattern) => pattern.test(normalized))) {
1185
+ failures.push('Feedback acknowledges a PR/positive result but frames later-set dropoff in a demotivating way.');
1186
+ }
1187
+
1188
+ const discouragingPatterns = [
1189
+ /\bdisappointing\b/i,
1190
+ /\bunderwhelming\b/i,
1191
+ /\bunderperformed\b/i,
1192
+ /\bpoor\b/i,
1193
+ /\bnot enough\b/i,
1194
+ /\bfailed to\b/i,
1195
+ /\bstruggled\b/i
1196
+ ];
1197
+
1198
+ if (discouragingPatterns.some((pattern) => pattern.test(normalized))) {
1199
+ failures.push('Feedback uses discouraging language that is likely to reduce motivation.');
1200
+ }
1201
+
1202
+ return {
1203
+ key: 'persona_motivation',
1204
+ passed: failures.length === 0,
1205
+ reason: failures.length === 0
1206
+ ? `Output stays aligned with the ${config.profile ?? 'persona'} perspective.`
1207
+ : failures.join(' ')
1208
+ };
1209
+ }
1210
+
703
1211
  export async function runSummaryEvalCase(testCase) {
704
1212
  const snapshot = await loadSummaryEvalSnapshot(testCase);
705
1213
  return runSummaryEvalCaseFromSnapshot(testCase, snapshot);
706
1214
  }
707
1215
 
708
- export async function runSummaryEvalCaseFromSnapshot(testCase, snapshot) {
1216
+ export function evaluateSummaryOutputFromSnapshot(testCase, snapshot, output) {
709
1217
  const context = buildSummaryEvalContext(snapshot, testCase);
710
1218
  if (context == null) {
711
1219
  throw new Error(`Eval case ${testCase.id} produced no context`);
712
1220
  }
713
1221
 
714
- const output = await generateSummaryEvalOutput(testCase, context);
715
1222
  if (!normalizeText(output)) {
716
1223
  throw new Error(`Eval case ${testCase.id} produced an empty output`);
717
1224
  }
@@ -724,7 +1231,10 @@ export async function runSummaryEvalCaseFromSnapshot(testCase, snapshot) {
724
1231
  evaluateForbiddenPhrases(output, testCase),
725
1232
  evaluateForbiddenMentions(output, testCase),
726
1233
  evaluateExerciseMentions(output, snapshot, context, testCase.surface, testCase),
727
- evaluateWorkoutClaims(output, context, testCase)
1234
+ evaluateWorkoutClaims(output, context, testCase),
1235
+ evaluateAskClaims(output, snapshot, testCase),
1236
+ evaluateAskToolProvenance(output, context, testCase),
1237
+ evaluatePersonaMotivation(output, context, testCase)
728
1238
  ];
729
1239
 
730
1240
  return {
@@ -732,11 +1242,21 @@ export async function runSummaryEvalCaseFromSnapshot(testCase, snapshot) {
732
1242
  surface: testCase.surface,
733
1243
  name: testCase.name,
734
1244
  output,
1245
+ metadata: testCase.metadata ?? null,
735
1246
  passed: checks.every((check) => check.passed),
736
1247
  checks
737
1248
  };
738
1249
  }
739
1250
 
1251
+ export async function runSummaryEvalCaseFromSnapshot(testCase, snapshot) {
1252
+ const context = buildSummaryEvalContext(snapshot, testCase);
1253
+ if (context == null) {
1254
+ throw new Error(`Eval case ${testCase.id} produced no context`);
1255
+ }
1256
+ const output = await generateSummaryEvalOutput(testCase, context, snapshot);
1257
+ return evaluateSummaryOutputFromSnapshot(testCase, snapshot, output);
1258
+ }
1259
+
740
1260
  function genericForbiddenPhrasesForSurface(surface) {
741
1261
  switch (surface) {
742
1262
  case 'workout':
@@ -747,6 +1267,8 @@ function genericForbiddenPhrasesForSurface(surface) {
747
1267
  return ['solid progress', 'quality work', 'trust the process', 'in a great place'];
748
1268
  case 'vitals':
749
1269
  return [];
1270
+ case 'ask':
1271
+ return [];
750
1272
  default:
751
1273
  return [];
752
1274
  }
@@ -764,7 +1286,51 @@ function latestCycleSummariesWithAI(snapshot) {
764
1286
  return [...latestByProgram.values()];
765
1287
  }
766
1288
 
767
- export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapshot') {
1289
+ function normalizeStoredAIMetadata(surface, metadata, {
1290
+ model = null,
1291
+ generatedAt = null,
1292
+ promptVersion = null,
1293
+ gitSha = null
1294
+ } = {}) {
1295
+ if (!metadata && !model && !generatedAt && !promptVersion && !gitSha) {
1296
+ return null;
1297
+ }
1298
+
1299
+ return {
1300
+ surface,
1301
+ generatedAt: metadata?.generatedAt ?? generatedAt ?? null,
1302
+ model: metadata?.model ?? model ?? null,
1303
+ promptVersion: metadata?.promptVersion ?? promptVersion ?? null,
1304
+ gitSha: metadata?.gitSha ?? gitSha ?? null,
1305
+ langfuseTraceId: metadata?.langfuseTraceId ?? metadata?.traceId ?? null,
1306
+ langfuseObservationId: metadata?.langfuseObservationId ?? metadata?.observationId ?? null
1307
+ };
1308
+ }
1309
+
1310
+ export function currentPromptVersions() {
1311
+ return new Set(Object.values(AI_PROMPT_VERSIONS));
1312
+ }
1313
+
1314
+ function metadataPassesFilter(metadata, { onlyPromptVersions, since }) {
1315
+ if (onlyPromptVersions) {
1316
+ const version = metadata?.promptVersion;
1317
+ if (!version || !onlyPromptVersions.has(version)) return false;
1318
+ }
1319
+ if (since) {
1320
+ const generatedAt = metadata?.generatedAt;
1321
+ if (!generatedAt || generatedAt < since) return false;
1322
+ }
1323
+ return true;
1324
+ }
1325
+
1326
+ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapshot', options = {}) {
1327
+ const filter = {
1328
+ onlyPromptVersions: options.onlyPromptVersions ?? null,
1329
+ since: options.since ?? null
1330
+ };
1331
+ const pushIfIncluded = (bucket, item) => {
1332
+ if (metadataPassesFilter(item.metadata, filter)) bucket.push(item);
1333
+ };
768
1334
  const harvested = [];
769
1335
 
770
1336
  for (const session of snapshot.sessions ?? []) {
@@ -774,7 +1340,7 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
774
1340
  ...(session.exercises ?? []).map((exercise) => exercise.name),
775
1341
  session.dayName
776
1342
  ].filter(Boolean);
777
- harvested.push({
1343
+ pushIfIncluded(harvested, {
778
1344
  id: `stored-workout-${session.id}`,
779
1345
  name: `Stored workout summary ${session.id}`,
780
1346
  surface: 'workout',
@@ -782,10 +1348,14 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
782
1348
  snapshotLabel,
783
1349
  selector: { sessionId: session.id },
784
1350
  output: text,
1351
+ metadata: normalizeStoredAIMetadata('workout', session.summary?.aiCoachMetadata, {
1352
+ model: session.summary?.aiCoachModel ?? null
1353
+ }),
785
1354
  requiredMentions: [],
786
1355
  requiredAnyOfMentions: anyOf,
787
1356
  forbiddenPhrases: genericForbiddenPhrasesForSurface('workout'),
788
1357
  forbiddenMentions: [],
1358
+ personaEval: { profile: 'motivated-athlete' },
789
1359
  expectNoInsight: false,
790
1360
  shouldPass: true
791
1361
  });
@@ -799,7 +1369,7 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
799
1369
  'hrv',
800
1370
  'resting hr'
801
1371
  ].filter(Boolean);
802
- harvested.push({
1372
+ pushIfIncluded(harvested, {
803
1373
  id: `stored-cycle-${summary.id}`,
804
1374
  name: `Stored cycle summary ${summary.id}`,
805
1375
  surface: 'cycle',
@@ -807,10 +1377,14 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
807
1377
  snapshotLabel,
808
1378
  selector: { programId: summary.programId },
809
1379
  output: summary.aiSummary,
1380
+ metadata: normalizeStoredAIMetadata('cycle', summary.aiSummaryMetadata, {
1381
+ model: summary.aiSummaryModel ?? null
1382
+ }),
810
1383
  requiredMentions: [],
811
1384
  requiredAnyOfMentions: anyOf,
812
1385
  forbiddenPhrases: genericForbiddenPhrasesForSurface('cycle'),
813
1386
  forbiddenMentions: [],
1387
+ personaEval: false,
814
1388
  expectNoInsight: false,
815
1389
  shouldPass: true
816
1390
  });
@@ -818,7 +1392,7 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
818
1392
 
819
1393
  const latestVitals = stableSortByDateDesc(snapshot.vitalsSummaries ?? [], (entry) => entry.date)[0];
820
1394
  if (latestVitals?.summary) {
821
- harvested.push({
1395
+ pushIfIncluded(harvested, {
822
1396
  id: `stored-vitals-${latestVitals.id ?? latestVitals.date}`,
823
1397
  name: `Stored vitals summary ${latestVitals.date}`,
824
1398
  surface: 'vitals',
@@ -826,10 +1400,42 @@ export function harvestStoredSummaryEvalCases(snapshot, snapshotLabel = 'snapsho
826
1400
  snapshotLabel,
827
1401
  selector: {},
828
1402
  output: latestVitals.summary,
1403
+ metadata: normalizeStoredAIMetadata('vitals', latestVitals.metadata, {
1404
+ model: latestVitals.model ?? null,
1405
+ generatedAt: latestVitals.date ?? null
1406
+ }),
829
1407
  requiredMentions: [],
830
1408
  requiredAnyOfMentions: ['recovery', 'readiness', 'train', 'session', 'today'],
831
1409
  forbiddenPhrases: genericForbiddenPhrasesForSurface('vitals'),
832
1410
  forbiddenMentions: [],
1411
+ personaEval: false,
1412
+ expectNoInsight: false,
1413
+ shouldPass: true
1414
+ });
1415
+ }
1416
+
1417
+ for (const conversation of stableSortByDateDesc(snapshot.askConversations ?? [], (entry) => entry.createdAt ?? '')) {
1418
+ const answer = conversation?.messages?.filter((message) => message?.role === 'assistant').at(-1)?.content ?? null;
1419
+ const question = conversation?.messages?.find((message) => message?.role === 'user')?.content ?? '';
1420
+ if (!answer) continue;
1421
+
1422
+ pushIfIncluded(harvested, {
1423
+ id: `stored-ask-${conversation.id}`,
1424
+ name: `Stored ask conversation ${conversation.id}`,
1425
+ surface: 'ask',
1426
+ source: 'stored',
1427
+ snapshotLabel,
1428
+ context: { question, conversationId: conversation.id },
1429
+ output: answer,
1430
+ metadata: normalizeStoredAIMetadata('ask', conversation.metadata, {
1431
+ model: conversation.model ?? null,
1432
+ generatedAt: conversation.createdAt ?? null
1433
+ }),
1434
+ requiredMentions: [],
1435
+ requiredAnyOfMentions: [],
1436
+ forbiddenPhrases: genericForbiddenPhrasesForSurface('ask'),
1437
+ forbiddenMentions: [],
1438
+ personaEval: { profile: 'motivated-athlete' },
833
1439
  expectNoInsight: false,
834
1440
  shouldPass: true
835
1441
  });