incremnt 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -1
- package/package.json +2 -1
- package/src/ask-answer-verifier.js +857 -0
- package/src/ask-coach.js +2634 -0
- package/src/ask-replay.js +358 -0
- package/src/auth.js +169 -15
- package/src/coach-facts.js +14 -1
- package/src/contract.js +160 -3
- package/src/format.js +68 -2
- package/src/lib.js +205 -17
- package/src/mcp.js +88 -24
- package/src/openrouter.js +261 -33
- package/src/plan-changeset.js +132 -0
- package/src/plan-comparison.js +245 -0
- package/src/program-draft.js +230 -0
- package/src/prompt-changelog.js +184 -0
- package/src/promptfoo-evals.js +10 -4
- package/src/promptfoo-langfuse-scores.js +55 -0
- package/src/queries.js +1442 -786
- package/src/remote.js +465 -12
- package/src/score-context.js +14 -7
- package/src/score-prelude.js +113 -0
- package/src/service-url.js +9 -0
- package/src/summary-evals.js +1192 -44
- package/src/sync-service.js +1383 -367
- package/src/transport.js +119 -3
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Single source of truth for planned-vs-actual training data.
|
|
2
|
+
//
|
|
3
|
+
// Historically this concept was re-derived in six places (the AI workout-coach
|
|
4
|
+
// context, the MCP planned-vs-actual tool, two SQL marts, and two iOS paths)
|
|
5
|
+
// with no shared definition — warmup handling, plan source, and readiness
|
|
6
|
+
// adaptation all disagreed. This module is the canonical JS computation; other
|
|
7
|
+
// JS consumers (queries.js adapters, the analytics ETL) call it so they cannot
|
|
8
|
+
// drift. iOS keeps a twin pinned to the same golden fixtures.
|
|
9
|
+
//
|
|
10
|
+
// Locked decisions (see docs/plans/eval-bigpush/planned-vs-actual-design.md):
|
|
11
|
+
// - Working sets are the unit on BOTH sides (warmups excluded); raw totals are
|
|
12
|
+
// kept alongside for surfaces that want them.
|
|
13
|
+
// - The planned list passed in is already resolved (prescriptionSnapshot →
|
|
14
|
+
// program-day fallback) and readiness-adapted by the caller; `planSource` and
|
|
15
|
+
// `readinessAdapted` are recorded so consumers can disclose them.
|
|
16
|
+
|
|
17
|
+
function plannedSetList(exercise) {
|
|
18
|
+
if (Array.isArray(exercise?.sets)) return exercise.sets;
|
|
19
|
+
if (Array.isArray(exercise?.targetSets)) return exercise.targetSets;
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function workingSets(sets) {
|
|
24
|
+
return (sets ?? []).filter((set) => !set?.isWarmup);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function completedWorkingSets(sets) {
|
|
28
|
+
return (sets ?? []).filter((set) => set?.isComplete && !set?.isWarmup);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sumReps(sets) {
|
|
32
|
+
return (sets ?? []).reduce((total, set) => total + (Number(set?.reps) || 0), 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function topWeight(sets) {
|
|
36
|
+
return (sets ?? []).reduce((max, set) => {
|
|
37
|
+
const weight = Number(set?.weight);
|
|
38
|
+
return Number.isFinite(weight) && weight > max ? weight : max;
|
|
39
|
+
}, 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ratio(actual, planned) {
|
|
43
|
+
if (!planned) return null;
|
|
44
|
+
return actual / planned;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Planned exercises carry the name on `exerciseName` (prescription/program shape);
|
|
48
|
+
// performed exercises carry it on `name` (session shape). One helper localizes
|
|
49
|
+
// that schema asymmetry.
|
|
50
|
+
function nameOf(exercise) {
|
|
51
|
+
return exercise?.name ?? exercise?.exerciseName;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const defaultCanonicalize = (value) => String(value ?? '').toLowerCase().trim();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the planned exercise list for a session, with the canonical source
|
|
58
|
+
* priority: the logged point-in-time prescriptionSnapshot, else the program day,
|
|
59
|
+
* else nothing. Centralized here so every consumer (workout coach context, the
|
|
60
|
+
* MCP planned-vs-actual tool, the analytics ETL) agrees on what was planned —
|
|
61
|
+
* the "plan source differs" divergence from the design.
|
|
62
|
+
*
|
|
63
|
+
* Readiness adaptation is intentionally NOT applied here; callers that want it
|
|
64
|
+
* (the workout coach) apply it on the returned list and report it via
|
|
65
|
+
* `readinessAdapted`.
|
|
66
|
+
*
|
|
67
|
+
* @returns { plannedExercises, planSource } where planSource is
|
|
68
|
+
* 'prescriptionSnapshot' | 'programDay' | 'none'.
|
|
69
|
+
*/
|
|
70
|
+
export function resolvePlannedExercises(session, snapshot, { dayName = null } = {}) {
|
|
71
|
+
if (session?.prescriptionSnapshot?.exercises?.length > 0) {
|
|
72
|
+
return { plannedExercises: session.prescriptionSnapshot.exercises, planSource: 'prescriptionSnapshot' };
|
|
73
|
+
}
|
|
74
|
+
if (session?.programId) {
|
|
75
|
+
const program = (snapshot?.programs ?? []).find((p) => p.id === session.programId);
|
|
76
|
+
const title = dayName ?? session?.dayName ?? null;
|
|
77
|
+
const days = program?.days ?? [];
|
|
78
|
+
const byTitle = title != null ? days.find((d) => d.title === title) : null;
|
|
79
|
+
const byIndex = Number.isInteger(session.programDayIndex)
|
|
80
|
+
? days[session.programDayIndex]
|
|
81
|
+
: null;
|
|
82
|
+
const matchingDay = byIndex && (title == null || byIndex.title === title)
|
|
83
|
+
? byIndex
|
|
84
|
+
: byTitle;
|
|
85
|
+
if (matchingDay?.exercises?.length > 0) {
|
|
86
|
+
return { plannedExercises: matchingDay.exercises, planSource: 'programDay' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { plannedExercises: [], planSource: 'none' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compute the canonical plan comparison for a session.
|
|
94
|
+
*
|
|
95
|
+
* @param session the session, with `exercises` = performed exercises.
|
|
96
|
+
* @param plannedExercises the already-resolved, readiness-adapted planned list.
|
|
97
|
+
* @param options.canonicalize exercise-name canonicalizer (queries.js passes
|
|
98
|
+
* the alias-aware `canonicalExerciseName`; tests may pass a stub).
|
|
99
|
+
* @param options.planSource 'prescriptionSnapshot' | 'programDay' | 'none'.
|
|
100
|
+
* @param options.readinessAdapted whether the planned list was reduced.
|
|
101
|
+
* @returns the rich PlanComparison model, or null when there is no plan.
|
|
102
|
+
*/
|
|
103
|
+
export function computePlanComparison(session, plannedExercises, {
|
|
104
|
+
canonicalize = defaultCanonicalize,
|
|
105
|
+
planSource = null,
|
|
106
|
+
readinessAdapted = false
|
|
107
|
+
} = {}) {
|
|
108
|
+
if (!Array.isArray(plannedExercises) || plannedExercises.length === 0) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const performed = session?.exercises ?? [];
|
|
113
|
+
// First occurrence wins, matching the legacy Array.prototype.find lookup when a
|
|
114
|
+
// session logs the same exercise twice.
|
|
115
|
+
const performedByCanonical = new Map();
|
|
116
|
+
for (const exercise of performed) {
|
|
117
|
+
const key = canonicalize(nameOf(exercise));
|
|
118
|
+
if (!performedByCanonical.has(key)) performedByCanonical.set(key, exercise);
|
|
119
|
+
}
|
|
120
|
+
const plannedCanonical = new Set(
|
|
121
|
+
plannedExercises.map((exercise) => canonicalize(nameOf(exercise)))
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const exercises = [];
|
|
125
|
+
|
|
126
|
+
// Planned exercises, in planned order: completed / partial / skipped.
|
|
127
|
+
for (const planned of plannedExercises) {
|
|
128
|
+
const displayName = nameOf(planned);
|
|
129
|
+
const canonicalName = canonicalize(displayName);
|
|
130
|
+
const plannedSets = plannedSetList(planned);
|
|
131
|
+
const performedExercise = performedByCanonical.get(canonicalName);
|
|
132
|
+
const performedSets = performedExercise?.sets ?? [];
|
|
133
|
+
|
|
134
|
+
const plannedWorking = workingSets(plannedSets).length;
|
|
135
|
+
const actualWorking = completedWorkingSets(performedSets).length;
|
|
136
|
+
|
|
137
|
+
let status;
|
|
138
|
+
if (!performedExercise) {
|
|
139
|
+
status = 'skipped';
|
|
140
|
+
} else if (actualWorking < plannedWorking) {
|
|
141
|
+
status = 'partial';
|
|
142
|
+
} else {
|
|
143
|
+
status = 'completed';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
exercises.push({
|
|
147
|
+
canonicalName,
|
|
148
|
+
displayName,
|
|
149
|
+
status,
|
|
150
|
+
swappedFrom: performedExercise?.swappedFrom ?? null,
|
|
151
|
+
planned: {
|
|
152
|
+
workingSets: plannedWorking,
|
|
153
|
+
totalSets: plannedSets.length,
|
|
154
|
+
reps: sumReps(workingSets(plannedSets)),
|
|
155
|
+
// Working sets only, symmetric with actual.topWeight — a warmup weight
|
|
156
|
+
// must not inflate the planned top.
|
|
157
|
+
topWeight: topWeight(workingSets(plannedSets))
|
|
158
|
+
},
|
|
159
|
+
actual: {
|
|
160
|
+
workingSets: actualWorking,
|
|
161
|
+
totalSets: (performedSets ?? []).filter((set) => set?.isComplete).length,
|
|
162
|
+
reps: sumReps(completedWorkingSets(performedSets)),
|
|
163
|
+
topWeight: topWeight(completedWorkingSets(performedSets))
|
|
164
|
+
},
|
|
165
|
+
setCompletionRatio: ratio(actualWorking, plannedWorking),
|
|
166
|
+
repCompletionRatio: ratio(
|
|
167
|
+
sumReps(completedWorkingSets(performedSets)),
|
|
168
|
+
sumReps(workingSets(plannedSets))
|
|
169
|
+
)
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Added exercises, in session order: performed but not planned.
|
|
174
|
+
for (const performedExercise of performed) {
|
|
175
|
+
const canonicalName = canonicalize(nameOf(performedExercise));
|
|
176
|
+
if (plannedCanonical.has(canonicalName)) continue;
|
|
177
|
+
const performedSets = performedExercise.sets ?? [];
|
|
178
|
+
const actualWorking = completedWorkingSets(performedSets).length;
|
|
179
|
+
exercises.push({
|
|
180
|
+
canonicalName,
|
|
181
|
+
displayName: nameOf(performedExercise),
|
|
182
|
+
status: 'added',
|
|
183
|
+
swappedFrom: performedExercise.swappedFrom ?? null,
|
|
184
|
+
planned: { workingSets: 0, totalSets: 0, reps: 0, topWeight: 0 },
|
|
185
|
+
actual: {
|
|
186
|
+
workingSets: actualWorking,
|
|
187
|
+
totalSets: performedSets.filter((set) => set?.isComplete).length,
|
|
188
|
+
reps: sumReps(completedWorkingSets(performedSets)),
|
|
189
|
+
topWeight: topWeight(completedWorkingSets(performedSets))
|
|
190
|
+
},
|
|
191
|
+
setCompletionRatio: null,
|
|
192
|
+
repCompletionRatio: null
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Planned totals exclude added (unplanned) work; actual totals include it.
|
|
197
|
+
// So setCompletionRatio can exceed 1.0 when a user does extra unplanned sets —
|
|
198
|
+
// that is intentional: added work is real volume, but it was never "planned".
|
|
199
|
+
const planned = exercises.filter((entry) => entry.status !== 'added');
|
|
200
|
+
const plannedWorkingSets = planned.reduce((sum, entry) => sum + entry.planned.workingSets, 0);
|
|
201
|
+
const actualWorkingSets = exercises.reduce((sum, entry) => sum + entry.actual.workingSets, 0);
|
|
202
|
+
const plannedReps = planned.reduce((sum, entry) => sum + entry.planned.reps, 0);
|
|
203
|
+
const actualReps = exercises.reduce((sum, entry) => sum + entry.actual.reps, 0);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
sessionId: session?.id ?? null,
|
|
207
|
+
planSource,
|
|
208
|
+
readinessAdapted: Boolean(readinessAdapted),
|
|
209
|
+
exercises,
|
|
210
|
+
rollup: {
|
|
211
|
+
plannedWorkingSets,
|
|
212
|
+
actualWorkingSets,
|
|
213
|
+
plannedReps,
|
|
214
|
+
actualReps,
|
|
215
|
+
setCompletionRatio: ratio(actualWorkingSets, plannedWorkingSets),
|
|
216
|
+
repCompletionRatio: ratio(actualReps, plannedReps),
|
|
217
|
+
skipped: exercises.filter((entry) => entry.status === 'skipped').map((entry) => entry.displayName),
|
|
218
|
+
added: exercises.filter((entry) => entry.status === 'added').map((entry) => entry.displayName),
|
|
219
|
+
underCompleted: exercises
|
|
220
|
+
.filter((entry) => entry.status === 'partial')
|
|
221
|
+
.map((entry) => entry.displayName)
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Adapt the canonical model to the legacy `{ skipped, added, setsComparison }`
|
|
228
|
+
* shape the AI workout-coach context and summary-evals consume. Behaviour is
|
|
229
|
+
* identical to the previous inline buildPlanComparison (working-set counts,
|
|
230
|
+
* planned-order, performed-only setsComparison).
|
|
231
|
+
*/
|
|
232
|
+
export function toLegacyPlanComparison(model) {
|
|
233
|
+
if (!model) return undefined;
|
|
234
|
+
return {
|
|
235
|
+
skipped: model.rollup.skipped,
|
|
236
|
+
added: model.rollup.added,
|
|
237
|
+
setsComparison: model.exercises
|
|
238
|
+
.filter((entry) => entry.status !== 'skipped' && entry.status !== 'added')
|
|
239
|
+
.map((entry) => ({
|
|
240
|
+
exercise: entry.displayName,
|
|
241
|
+
planned: entry.planned.workingSets,
|
|
242
|
+
completed: entry.actual.workingSets
|
|
243
|
+
}))
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Single source of truth for the AI coach's <program_draft> block: extraction,
|
|
2
|
+
// JSON-shape validation, and normalization. Lives here (not in sync-service.js)
|
|
3
|
+
// so both the runtime (askCoach drops invalid drafts) and the eval harness
|
|
4
|
+
// (summary-evals.js catches malformed drafts in CI, before they ship and get
|
|
5
|
+
// silently dropped in prod) validate against the exact same rules. Moved verbatim
|
|
6
|
+
// from sync-service.js — behaviour-preserving.
|
|
7
|
+
|
|
8
|
+
export const PROGRAM_DRAFT_VERSION = 1;
|
|
9
|
+
export const VALID_PROGRAM_DRAFT_EQUIPMENT_TIERS = new Set(['fullGym', 'benchDumbbells', 'dumbbellsOnly', 'bodyweightOnly']);
|
|
10
|
+
export const VALID_PROGRAM_DRAFT_VOLUME_LEVELS = new Set(['minimum', 'moderate', 'high']);
|
|
11
|
+
|
|
12
|
+
export const PROGRAM_DRAFT_LIMITS = {
|
|
13
|
+
nameMaxLen: 120,
|
|
14
|
+
muscleGroupMaxLen: 60,
|
|
15
|
+
dayLabelMaxLen: 60,
|
|
16
|
+
dayTitleMaxLen: 120,
|
|
17
|
+
daySubtitleMaxLen: 120,
|
|
18
|
+
noteMaxLen: 1000,
|
|
19
|
+
minWeight: 0,
|
|
20
|
+
maxWeight: 600,
|
|
21
|
+
minReps: 1,
|
|
22
|
+
maxReps: 30,
|
|
23
|
+
minRir: 0,
|
|
24
|
+
maxRir: 5,
|
|
25
|
+
minSetsPerExercise: 1,
|
|
26
|
+
maxSetsPerExercise: 12,
|
|
27
|
+
minExercisesPerDay: 1,
|
|
28
|
+
maxExercisesPerDay: 24,
|
|
29
|
+
minDaysPerWeek: 1,
|
|
30
|
+
maxDaysPerWeek: 7,
|
|
31
|
+
minDays: 1,
|
|
32
|
+
maxDays: 14
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function collapseBlankLines(text) {
|
|
36
|
+
return String(text ?? '')
|
|
37
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
38
|
+
.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function titleCaseExerciseName(name) {
|
|
42
|
+
return String(name ?? '')
|
|
43
|
+
.split(' ')
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
46
|
+
.join(' ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizedExerciseDisplayName(name, canonicalizeExerciseName) {
|
|
50
|
+
const trimmed = String(name ?? '').trim();
|
|
51
|
+
if (!trimmed) return '';
|
|
52
|
+
const canonical = canonicalizeExerciseName ? canonicalizeExerciseName(trimmed) : trimmed.toLowerCase();
|
|
53
|
+
return titleCaseExerciseName(canonical);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasOnlyAllowedKeys(value, allowedKeys) {
|
|
57
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
58
|
+
return Object.keys(value).every((key) => allowedKeys.has(key));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeProgramDraftSet(set) {
|
|
62
|
+
if (!hasOnlyAllowedKeys(set, new Set(['weight', 'reps', 'isWarmup']))) return null;
|
|
63
|
+
|
|
64
|
+
const weight = Number(set?.weight);
|
|
65
|
+
const reps = Number(set?.reps);
|
|
66
|
+
if (!Number.isFinite(weight) || !Number.isInteger(reps)) return null;
|
|
67
|
+
if (
|
|
68
|
+
weight < PROGRAM_DRAFT_LIMITS.minWeight ||
|
|
69
|
+
weight > PROGRAM_DRAFT_LIMITS.maxWeight ||
|
|
70
|
+
reps < PROGRAM_DRAFT_LIMITS.minReps ||
|
|
71
|
+
reps > PROGRAM_DRAFT_LIMITS.maxReps
|
|
72
|
+
) return null;
|
|
73
|
+
return {
|
|
74
|
+
weight,
|
|
75
|
+
reps,
|
|
76
|
+
isComplete: false,
|
|
77
|
+
isWarmup: set?.isWarmup === true
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeProgramDraftExercise(exercise, canonicalizeExerciseName, strict = false) {
|
|
82
|
+
if (!hasOnlyAllowedKeys(exercise, new Set(['name', 'muscleGroup', 'sets', 'rir', 'note']))) return null;
|
|
83
|
+
|
|
84
|
+
const name = normalizedExerciseDisplayName(exercise?.name, canonicalizeExerciseName);
|
|
85
|
+
const muscleGroup = String(exercise?.muscleGroup ?? '').trim();
|
|
86
|
+
// strict (eval): any invalid set rejects the whole draft — catches partial
|
|
87
|
+
// malformation as a regression signal. lenient (runtime, default): drop the
|
|
88
|
+
// bad set and salvage a usable program for the user.
|
|
89
|
+
const mappedSets = Array.isArray(exercise?.sets) ? exercise.sets.map(normalizeProgramDraftSet) : [];
|
|
90
|
+
if (strict && mappedSets.some((set) => !set)) return null;
|
|
91
|
+
const sets = mappedSets.filter(Boolean);
|
|
92
|
+
|
|
93
|
+
if (!name || name.length > PROGRAM_DRAFT_LIMITS.nameMaxLen) return null;
|
|
94
|
+
if (!muscleGroup || muscleGroup.length > PROGRAM_DRAFT_LIMITS.muscleGroupMaxLen) return null;
|
|
95
|
+
if (
|
|
96
|
+
sets.length < PROGRAM_DRAFT_LIMITS.minSetsPerExercise ||
|
|
97
|
+
sets.length > PROGRAM_DRAFT_LIMITS.maxSetsPerExercise
|
|
98
|
+
) return null;
|
|
99
|
+
|
|
100
|
+
const rir = exercise?.rir == null ? null : Number(exercise.rir);
|
|
101
|
+
if (rir != null && (
|
|
102
|
+
!Number.isInteger(rir) ||
|
|
103
|
+
rir < PROGRAM_DRAFT_LIMITS.minRir ||
|
|
104
|
+
rir > PROGRAM_DRAFT_LIMITS.maxRir
|
|
105
|
+
)) return null;
|
|
106
|
+
|
|
107
|
+
const note = exercise?.note == null ? null : String(exercise.note);
|
|
108
|
+
if (note && note.length > PROGRAM_DRAFT_LIMITS.noteMaxLen) return null;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
name,
|
|
112
|
+
muscleGroup,
|
|
113
|
+
lastSuggestion: '',
|
|
114
|
+
nextSuggestion: '',
|
|
115
|
+
sets,
|
|
116
|
+
...(note ? { note } : {}),
|
|
117
|
+
...(rir != null ? { rir } : {})
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeProgramDraftDay(day, canonicalizeExerciseName, strict = false) {
|
|
122
|
+
if (!hasOnlyAllowedKeys(day, new Set(['dayLabel', 'title', 'subtitle', 'exercises']))) return null;
|
|
123
|
+
|
|
124
|
+
const dayLabel = String(day?.dayLabel ?? '').trim();
|
|
125
|
+
const title = String(day?.title ?? '').trim();
|
|
126
|
+
const subtitle = String(day?.subtitle ?? '').trim();
|
|
127
|
+
const mappedExercises = Array.isArray(day?.exercises)
|
|
128
|
+
? day.exercises.map((exercise) => normalizeProgramDraftExercise(exercise, canonicalizeExerciseName, strict))
|
|
129
|
+
: [];
|
|
130
|
+
if (strict && mappedExercises.some((exercise) => !exercise)) return null;
|
|
131
|
+
const exercises = mappedExercises.filter(Boolean);
|
|
132
|
+
|
|
133
|
+
if (!dayLabel || dayLabel.length > PROGRAM_DRAFT_LIMITS.dayLabelMaxLen) return null;
|
|
134
|
+
if (!title || title.length > PROGRAM_DRAFT_LIMITS.dayTitleMaxLen) return null;
|
|
135
|
+
if (subtitle.length > PROGRAM_DRAFT_LIMITS.daySubtitleMaxLen) return null;
|
|
136
|
+
if (
|
|
137
|
+
exercises.length < PROGRAM_DRAFT_LIMITS.minExercisesPerDay ||
|
|
138
|
+
exercises.length > PROGRAM_DRAFT_LIMITS.maxExercisesPerDay
|
|
139
|
+
) return null;
|
|
140
|
+
|
|
141
|
+
return { dayLabel, title, subtitle, exercises };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function normalizeProgramDraft(rawProgram, { canonicalizeExerciseName, strict = false } = {}) {
|
|
145
|
+
if (!rawProgram || typeof rawProgram !== 'object' || Array.isArray(rawProgram)) return null;
|
|
146
|
+
if (!hasOnlyAllowedKeys(rawProgram, new Set([
|
|
147
|
+
'name',
|
|
148
|
+
'daysPerWeek',
|
|
149
|
+
'equipmentTier',
|
|
150
|
+
'volumeLevel',
|
|
151
|
+
'currentDayIndex',
|
|
152
|
+
'days'
|
|
153
|
+
]))) return null;
|
|
154
|
+
|
|
155
|
+
const name = String(rawProgram.name ?? '').trim();
|
|
156
|
+
const mappedDays = Array.isArray(rawProgram.days)
|
|
157
|
+
? rawProgram.days.map((day) => normalizeProgramDraftDay(day, canonicalizeExerciseName, strict))
|
|
158
|
+
: [];
|
|
159
|
+
if (strict && mappedDays.some((day) => !day)) return null;
|
|
160
|
+
const days = mappedDays.filter(Boolean);
|
|
161
|
+
const daysPerWeek = Number(rawProgram.daysPerWeek);
|
|
162
|
+
const currentDayIndex = rawProgram.currentDayIndex == null ? 0 : Number(rawProgram.currentDayIndex);
|
|
163
|
+
const equipmentTier = String(rawProgram.equipmentTier ?? 'fullGym').trim();
|
|
164
|
+
const volumeLevel = String(rawProgram.volumeLevel ?? 'moderate').trim();
|
|
165
|
+
|
|
166
|
+
if (!name || name.length > PROGRAM_DRAFT_LIMITS.nameMaxLen) return null;
|
|
167
|
+
if (days.length < PROGRAM_DRAFT_LIMITS.minDays || days.length > PROGRAM_DRAFT_LIMITS.maxDays) return null;
|
|
168
|
+
if (
|
|
169
|
+
!Number.isInteger(daysPerWeek) ||
|
|
170
|
+
daysPerWeek < PROGRAM_DRAFT_LIMITS.minDaysPerWeek ||
|
|
171
|
+
daysPerWeek > PROGRAM_DRAFT_LIMITS.maxDaysPerWeek
|
|
172
|
+
) return null;
|
|
173
|
+
if (!Number.isInteger(currentDayIndex) || currentDayIndex < 0 || currentDayIndex >= days.length) return null;
|
|
174
|
+
if (!VALID_PROGRAM_DRAFT_EQUIPMENT_TIERS.has(equipmentTier) || !VALID_PROGRAM_DRAFT_VOLUME_LEVELS.has(volumeLevel)) return null;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
name,
|
|
178
|
+
daysPerWeek,
|
|
179
|
+
equipmentTier,
|
|
180
|
+
volumeLevel,
|
|
181
|
+
source: 'guided',
|
|
182
|
+
days,
|
|
183
|
+
currentDayIndex
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function extractAskProgramDraft(rawText, { canonicalizeExerciseName, strict = false } = {}) {
|
|
188
|
+
const text = String(rawText ?? '');
|
|
189
|
+
const match = text.match(/<program_draft>\s*([\s\S]*?)\s*<\/program_draft>/i);
|
|
190
|
+
if (!match) {
|
|
191
|
+
return { answerText: text.trim(), programDraft: null };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const answerText = collapseBlankLines(text.replace(match[0], ''));
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(match[1]);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.warn('askCoach: <program_draft> JSON parse failed — dropping draft:', err.message);
|
|
200
|
+
return { answerText, programDraft: null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const program = normalizeProgramDraft(parsed, { canonicalizeExerciseName, strict });
|
|
204
|
+
if (!program) {
|
|
205
|
+
console.warn('askCoach: <program_draft> payload failed validation — dropping draft');
|
|
206
|
+
return { answerText, programDraft: null };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
answerText,
|
|
211
|
+
programDraft: {
|
|
212
|
+
program,
|
|
213
|
+
provenance: {
|
|
214
|
+
source: 'ai-coach',
|
|
215
|
+
type: 'program',
|
|
216
|
+
version: PROGRAM_DRAFT_VERSION,
|
|
217
|
+
createdAt: new Date().toISOString(),
|
|
218
|
+
tokenHint: null
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Whether `rawText` contains a <program_draft> tag at all (valid or not).
|
|
226
|
+
* Lets the eval distinguish "no draft" from "malformed draft".
|
|
227
|
+
*/
|
|
228
|
+
export function hasProgramDraftBlock(rawText) {
|
|
229
|
+
return /<\s*\/?\s*program_draft\b[^>]*>/i.test(String(rawText ?? ''));
|
|
230
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Append-only semantic changelog for AI prompt versions.
|
|
2
|
+
//
|
|
3
|
+
// Every value in AI_PROMPT_VERSIONS (openrouter.js) must have a matching entry
|
|
4
|
+
// here — enforced by prompt-changelog.test.js. A version string records THAT a
|
|
5
|
+
// prompt changed; this records WHAT changed and WHY, so a bump is never a silent
|
|
6
|
+
// edit. For `fix` and `safety` changes, reference the eval/validator that guards
|
|
7
|
+
// the change (`eval`), so a regression has a named tripwire.
|
|
8
|
+
//
|
|
9
|
+
// Entry shape:
|
|
10
|
+
// { version, surface, date (YYYY-MM-DD), type, summary, eval? }
|
|
11
|
+
// type: 'init' | 'fix' | 'safety' | 'tuning' | 'feature'
|
|
12
|
+
//
|
|
13
|
+
// Add new entries at the top of the array for that surface; do not rewrite
|
|
14
|
+
// existing entries.
|
|
15
|
+
|
|
16
|
+
export const PROMPT_CHANGELOG_TYPES = Object.freeze([
|
|
17
|
+
'init',
|
|
18
|
+
'fix',
|
|
19
|
+
'safety',
|
|
20
|
+
'tuning',
|
|
21
|
+
'feature'
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const PROMPT_CHANGELOG = Object.freeze([
|
|
25
|
+
{
|
|
26
|
+
version: 'ask_agentic_v2026_06_02_1',
|
|
27
|
+
surface: 'askAgentic',
|
|
28
|
+
date: '2026-06-02',
|
|
29
|
+
type: 'feature',
|
|
30
|
+
summary:
|
|
31
|
+
'Broad progress/bodyweight/on-track answers use coach-operator shape: verdict, signal, evidence, caveat, and the next decision. Progress reviews may ask one goal-defining question when body-composition tradeoffs depend on missing goal context, and now synthesize bodyweight/readiness evidence when routed context provides it.',
|
|
32
|
+
eval: 'ask_progress_review_golden'
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
version: 'ask_v2026_06_02_1',
|
|
36
|
+
surface: 'ask',
|
|
37
|
+
date: '2026-06-02',
|
|
38
|
+
type: 'feature',
|
|
39
|
+
summary:
|
|
40
|
+
'Broad progress/bodyweight/on-track answers use coach-operator shape: verdict, signal, evidence, caveat, and the next decision. Progress reviews may ask one goal-defining question when body-composition tradeoffs depend on missing goal context, and now synthesize bodyweight/readiness evidence when routed context provides it.',
|
|
41
|
+
eval: 'ask_progress_review_golden'
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
version: 'ask_agentic_v2026_06_01_1',
|
|
45
|
+
surface: 'askAgentic',
|
|
46
|
+
date: '2026-06-01',
|
|
47
|
+
type: 'safety',
|
|
48
|
+
summary:
|
|
49
|
+
'Hoist a high-salience "Hard limits" block to the top of ASK_RULES restating the most-violated nevers (no 1RM/PR/records unless asked, except the routed broad-review PR count; no fatigue/recovery/readiness language without an explicit signal; no warmup/backoff loads as working sets; no raw Increment Score sub-scores). Also: speak in the first person (never "the coach"/"the coach observation"/"the system") and never volunteer the overall score number unless asked — paired with a question-gated score prelude that withholds the numeric headline on non-score questions. Reinforcement of buried rules plus the self-reference and volunteered-score fixes the live history showed.',
|
|
50
|
+
eval: 'ask_why_failed_no_vitals'
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
version: 'ask_v2026_06_01_1',
|
|
54
|
+
surface: 'ask',
|
|
55
|
+
date: '2026-06-01',
|
|
56
|
+
type: 'safety',
|
|
57
|
+
summary:
|
|
58
|
+
'Hoist a high-salience "Hard limits" block to the top of ASK_RULES restating the most-violated nevers (no 1RM/PR/records unless asked, except the routed broad-review PR count; no fatigue/recovery/readiness language without an explicit signal; no warmup/backoff loads as working sets; no raw Increment Score sub-scores; speak in the first person, never "the coach"/"the system"; never volunteer the overall score number unless asked). Reinforcement of buried rules plus self-reference and volunteered-score fixes.',
|
|
59
|
+
eval: 'ask_why_failed_no_vitals'
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
version: 'ask_agentic_v2026_05_30_3',
|
|
63
|
+
surface: 'askAgentic',
|
|
64
|
+
date: '2026-05-30',
|
|
65
|
+
type: 'fix',
|
|
66
|
+
summary:
|
|
67
|
+
'Broad progress reviews must include the observed training frequency/session count alongside volume, body-weight, and recent PR-count evidence, so live Ask replays do not skip the basic activity denominator.',
|
|
68
|
+
eval: 'ask_progress_review_golden'
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
version: 'ask_agentic_v2026_05_30_2',
|
|
72
|
+
surface: 'askAgentic',
|
|
73
|
+
date: '2026-05-30',
|
|
74
|
+
type: 'fix',
|
|
75
|
+
summary:
|
|
76
|
+
'For broad progress-review questions, carry the base Ask rule that recent all-time estimated 1RM PR counts must be mentioned when the routed context provides them; preserves the bounded read-only tool loop from ask_agentic_v2026_05_30_1.',
|
|
77
|
+
eval: 'ask_progress_review_golden'
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
version: 'ask_agentic_v2026_05_30_1',
|
|
81
|
+
surface: 'askAgentic',
|
|
82
|
+
date: '2026-05-30',
|
|
83
|
+
type: 'feature',
|
|
84
|
+
summary:
|
|
85
|
+
'Agentic Ask generation: the model receives the routed context as a warm start plus a read-only tool menu (records, body weight, weekly volume, readiness, etc.) and fetches missing evidence over a bounded, deduped loop instead of answering one-shot from a fixed route. Server-side privacy exclusions are forced into every tool call; all fetched tools are folded into provenance. Inherits the ask_v2026_05_30_1 rules via an appended tool-use addendum.',
|
|
86
|
+
eval: 'ask_tool_provenance'
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
version: 'workout_v2026_05_23_1',
|
|
90
|
+
surface: 'workout',
|
|
91
|
+
date: '2026-05-23',
|
|
92
|
+
type: 'fix',
|
|
93
|
+
summary:
|
|
94
|
+
'Keep skipped-exercise mentions generic unless plan comparison supports naming the lift; anchor the note to completed-session work.',
|
|
95
|
+
eval: 'exercise_mentions'
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
version: 'ask_v2026_05_30_3',
|
|
99
|
+
surface: 'ask',
|
|
100
|
+
date: '2026-05-30',
|
|
101
|
+
type: 'fix',
|
|
102
|
+
summary:
|
|
103
|
+
'Broad progress reviews must include the observed training frequency/session count alongside volume, body-weight, and recent PR-count evidence, so the answer keeps the activity denominator visible.',
|
|
104
|
+
eval: 'ask_progress_review_golden'
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
version: 'ask_v2026_05_30_2',
|
|
108
|
+
surface: 'ask',
|
|
109
|
+
date: '2026-05-30',
|
|
110
|
+
type: 'fix',
|
|
111
|
+
summary:
|
|
112
|
+
'Broad progress reviews must explicitly mention the recent all-time estimated 1RM PR count when the context includes it, preventing recent PR density from being softened into vague "several lifts moved" language.',
|
|
113
|
+
eval: 'ask_progress_review_golden'
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
version: 'ask_v2026_05_30_1',
|
|
117
|
+
surface: 'ask',
|
|
118
|
+
date: '2026-05-30',
|
|
119
|
+
type: 'safety',
|
|
120
|
+
summary:
|
|
121
|
+
'Enforce score-voice: name the Increment Score and its overall value/direction, but never recite raw component sub-scores, decimals, or daily score lists; translate the score into training reality. Answer training questions first (no score-dump lead), do not re-recite the breakdown on follow-ups, and answer retrospectives at the multi-week altitude asked. Paired with a voice-safe formatIncrementScorePrelude.',
|
|
122
|
+
eval: 'ask_score_voice'
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
version: 'ask_v2026_05_23_1',
|
|
126
|
+
surface: 'ask',
|
|
127
|
+
date: '2026-05-23',
|
|
128
|
+
type: 'fix',
|
|
129
|
+
summary:
|
|
130
|
+
'Carry relevant typed coach facts through explicitly (including tone preferences like concise cues); never claim one note/fact is the only relevant one; name warmups when disproving an apparent within-session drop-off.',
|
|
131
|
+
eval: 'ask_claims'
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
version: 'cycle_v2026_04_18_1',
|
|
135
|
+
surface: 'cycle',
|
|
136
|
+
date: '2026-04-18',
|
|
137
|
+
type: 'init',
|
|
138
|
+
summary: 'Cycle close-out note baseline — synthesize the week, do not restate the UI.'
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
version: 'vitals_v2026_04_16_1',
|
|
142
|
+
surface: 'vitals',
|
|
143
|
+
date: '2026-04-16',
|
|
144
|
+
type: 'init',
|
|
145
|
+
summary: 'Morning vitals/readiness summary baseline — interpret signals, never give medical advice.'
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
version: 'checkpoint_v2026_04_16_1',
|
|
149
|
+
surface: 'checkpoint',
|
|
150
|
+
date: '2026-04-16',
|
|
151
|
+
type: 'init',
|
|
152
|
+
summary: 'Mid-plan checkpoint summary baseline against e1RM targets.'
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
version: 'weekly_checkin_v2026_04_23_1',
|
|
156
|
+
surface: 'weeklyCheckin',
|
|
157
|
+
date: '2026-04-23',
|
|
158
|
+
type: 'init',
|
|
159
|
+
summary: 'Sunday weekly check-in ritual baseline.'
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
version: 'coach_commitments_v2026_04_25_1',
|
|
163
|
+
surface: 'coachCommitments',
|
|
164
|
+
date: '2026-04-25',
|
|
165
|
+
type: 'init',
|
|
166
|
+
summary: 'Coach commitment extraction baseline.'
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
version: 'coach_facts_v2026_04_25_1',
|
|
170
|
+
surface: 'coachFacts',
|
|
171
|
+
date: '2026-04-25',
|
|
172
|
+
type: 'init',
|
|
173
|
+
summary: 'Typed coach-fact extraction baseline.'
|
|
174
|
+
}
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
/** The most recent changelog entry per surface, by array order (newest first). */
|
|
178
|
+
export function latestChangelogBySurface() {
|
|
179
|
+
const latest = new Map();
|
|
180
|
+
for (const entry of PROMPT_CHANGELOG) {
|
|
181
|
+
if (!latest.has(entry.surface)) latest.set(entry.surface, entry);
|
|
182
|
+
}
|
|
183
|
+
return latest;
|
|
184
|
+
}
|
package/src/promptfoo-evals.js
CHANGED
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
loadSummaryEvalSnapshot,
|
|
5
5
|
summaryEvalFixturesRoot,
|
|
6
6
|
buildSummaryEvalContext,
|
|
7
|
-
generateSummaryEvalOutputWithMetadata
|
|
7
|
+
generateSummaryEvalOutputWithMetadata,
|
|
8
|
+
summaryEvalsLiveGenerationEnabled
|
|
8
9
|
} from './summary-evals.js';
|
|
9
10
|
import { publishPromptfooLangfuseScore } from './promptfoo-langfuse-scores.js';
|
|
10
11
|
|
|
@@ -130,15 +131,20 @@ export async function assertPromptfooDomain(output, context = {}) {
|
|
|
130
131
|
|
|
131
132
|
export async function callPromptfooProvider(prompt, context = {}) {
|
|
132
133
|
const { testCase, snapshot } = await resolvePromptfooEval(context.vars ?? {});
|
|
133
|
-
const liveGenerationEnabled =
|
|
134
|
+
const liveGenerationEnabled = summaryEvalsLiveGenerationEnabled();
|
|
134
135
|
|
|
135
136
|
if (!liveGenerationEnabled) {
|
|
137
|
+
const evalContext = buildSummaryEvalContext(snapshot, testCase);
|
|
138
|
+
const generation = await generateSummaryEvalOutputWithMetadata(testCase, evalContext, snapshot);
|
|
139
|
+
promptfooProviderMetadata.set(promptfooMetadataKey(context.vars ?? {}), generation.metadata);
|
|
140
|
+
|
|
136
141
|
return {
|
|
137
|
-
output:
|
|
142
|
+
output: generation.output,
|
|
138
143
|
metadata: {
|
|
139
144
|
caseId: testCase.id,
|
|
140
145
|
surface: testCase.surface,
|
|
141
|
-
mode: 'stored'
|
|
146
|
+
mode: 'stored',
|
|
147
|
+
...generation.metadata
|
|
142
148
|
}
|
|
143
149
|
};
|
|
144
150
|
}
|