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.
- package/README.md +9 -2
- 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 +24 -5
- package/src/exercise-aliases.js +163 -0
- package/src/format.js +59 -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/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 +2175 -197
- package/src/remote.js +51 -5
- package/src/state.js +9 -2
- package/src/stored-summary-eval-report.js +85 -52
- package/src/summary-evals.js +623 -17
- package/src/sync-service.js +1199 -131
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,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
|
-
|
|
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
|
-
|
|
125
|
+
result = await generateWorkoutCoachingSummary(context, { apiKey });
|
|
126
|
+
break;
|
|
97
127
|
case 'cycle':
|
|
98
|
-
|
|
128
|
+
result = await generateCoachingSummary(context, { apiKey });
|
|
129
|
+
break;
|
|
99
130
|
case 'checkpoint':
|
|
100
|
-
|
|
131
|
+
result = await generateCheckpointSummary(context, { apiKey });
|
|
132
|
+
break;
|
|
101
133
|
case 'vitals':
|
|
102
|
-
|
|
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) =>
|
|
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) =>
|
|
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 (
|
|
505
|
+
if ((!isStored && (sentences < 2 || sentences > 8)) || (isStored && (sentences < 1 || sentences > 10))) {
|
|
426
506
|
passed = false;
|
|
427
|
-
reasons.push(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|