incremnt 0.8.7 → 0.8.8
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 +2 -1
- package/SKILL.md +2 -1
- package/package.json +4 -1
- package/src/ask-answer-verifier.js +23 -2
- package/src/ask-coach/contexts.js +1472 -0
- package/src/ask-coach/evidence-plan.js +342 -0
- package/src/ask-coach/observations.js +8 -0
- package/src/ask-coach/orchestrator.js +1641 -0
- package/src/ask-coach/renderers.js +4 -0
- package/src/ask-coach/routing.js +610 -0
- package/src/ask-coach/structured-response.js +5 -0
- package/src/ask-coach-routing.js +4 -0
- package/src/ask-coach.js +26 -3506
- package/src/ask-replay.js +70 -4
- package/src/ask-starter-prompts.js +206 -0
- package/src/auth.js +26 -0
- package/src/coach-advice.js +32 -0
- package/src/coach-facts.js +214 -0
- package/src/coach-prompt-layers.js +26 -30
- package/src/contract.js +27 -7
- package/src/exercise-aliases.js +6 -0
- package/src/format.js +17 -0
- package/src/index.js +1 -1
- package/src/lib.js +41 -36
- package/src/mcp.js +1 -1
- package/src/openrouter.js +279 -161
- package/src/plan-changeset.js +14 -8
- package/src/program-draft.js +97 -1
- package/src/prompt-changelog.js +16 -0
- package/src/prompt-security.js +34 -3
- package/src/promptfoo-evals.js +2 -0
- package/src/promptfoo-langfuse-scores.js +10 -0
- package/src/prompts/ask.js +22 -0
- package/src/prompts/checkpoint.js +14 -0
- package/src/prompts/coach-facts.js +24 -0
- package/src/prompts/cycle.js +23 -0
- package/src/prompts/starter-graph.js +7 -0
- package/src/prompts/vitals.js +9 -0
- package/src/prompts/weekly-checkin.js +20 -0
- package/src/prompts/workout.js +47 -0
- package/src/queries/coach-observations.js +8 -0
- package/src/queries/coach-read-tools.js +6 -0
- package/src/queries/commands.js +3 -0
- package/src/queries/common.js +9 -0
- package/src/queries/core.js +6354 -0
- package/src/queries/exercise-identity.js +6 -0
- package/src/queries/health.js +12 -0
- package/src/queries/programs.js +17 -0
- package/src/queries/records-progress.js +10 -0
- package/src/queries/sessions.js +12 -0
- package/src/queries/weekly.js +5 -0
- package/src/queries.js +10 -6283
- package/src/remote.js +51 -0
- package/src/score-context.js +58 -3
- package/src/summary-evals.js +201 -45
- package/src/sync-service.js +1118 -104
- package/src/training-language-public-terms.json +97 -0
- package/src/training-language.js +94 -0
- package/src/transport.js +7 -1
- package/src/validate.js +11 -1
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { dateOnlyString, relativeDateString } from '../queries/common.js';
|
|
2
|
+
import { canonicalExerciseName, normalizeExerciseName } from '../queries/exercise-identity.js';
|
|
3
|
+
|
|
4
|
+
function allExerciseNames(snapshot) {
|
|
5
|
+
const names = new Map();
|
|
6
|
+
for (const session of snapshot.sessions ?? []) {
|
|
7
|
+
for (const exercise of session.exercises ?? []) {
|
|
8
|
+
if (!exercise.name) continue;
|
|
9
|
+
names.set(canonicalExerciseName(exercise.name), exercise.name);
|
|
10
|
+
}
|
|
11
|
+
for (const exercise of session.prescriptionSnapshot?.exercises ?? []) {
|
|
12
|
+
const name = exercise.exerciseName ?? exercise.name;
|
|
13
|
+
if (!name) continue;
|
|
14
|
+
names.set(canonicalExerciseName(name), name);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (const program of snapshot.programs ?? []) {
|
|
18
|
+
for (const day of program.days ?? []) {
|
|
19
|
+
for (const exercise of day.exercises ?? []) {
|
|
20
|
+
const name = exercise.name ?? exercise.exerciseName;
|
|
21
|
+
if (!name) continue;
|
|
22
|
+
names.set(canonicalExerciseName(name), name);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return names;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function namedExercisesFromQuestion(snapshot, question) {
|
|
30
|
+
const normalizedQuestion = normalizeExerciseName(question ?? '');
|
|
31
|
+
const matches = new Map();
|
|
32
|
+
const knownExercises = allExerciseNames(snapshot);
|
|
33
|
+
const knownDisplayByCanonical = new Map(knownExercises);
|
|
34
|
+
const shorthandAliases = new Map([
|
|
35
|
+
['bench', 'bench press'],
|
|
36
|
+
['row', 'bent over row'],
|
|
37
|
+
['rows', 'bent over row'],
|
|
38
|
+
['squat', 'squat'],
|
|
39
|
+
['deadlift', 'deadlift'],
|
|
40
|
+
['pullups', 'pull ups'],
|
|
41
|
+
['pull ups', 'pull ups'],
|
|
42
|
+
['pull up', 'pull ups']
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
for (const [canonical, displayName] of knownExercises) {
|
|
46
|
+
const normalizedDisplay = normalizeExerciseName(displayName);
|
|
47
|
+
if (
|
|
48
|
+
normalizedQuestion.includes(canonical) ||
|
|
49
|
+
normalizedQuestion.includes(normalizedDisplay)
|
|
50
|
+
) {
|
|
51
|
+
matches.set(canonical, displayName);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (matches.size === 0) {
|
|
56
|
+
for (const [canonical, displayName] of knownExercises) {
|
|
57
|
+
const displayTokens = normalizeExerciseName(displayName).split(' ').filter(Boolean);
|
|
58
|
+
const partials = [];
|
|
59
|
+
if (displayTokens.length >= 3) {
|
|
60
|
+
for (let index = 0; index < displayTokens.length; index += 1) {
|
|
61
|
+
const partial = displayTokens.filter((_, tokenIndex) => tokenIndex !== index).join(' ');
|
|
62
|
+
if (partial.split(' ').length >= 2) partials.push(partial);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (partials.some((partial) => new RegExp(`(?:^| )${partial}(?: |$)`).test(normalizedQuestion))) {
|
|
66
|
+
matches.set(canonical, displayName);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const [alias, canonical] of shorthandAliases) {
|
|
72
|
+
if (
|
|
73
|
+
(alias === 'row' || alias === 'rows') &&
|
|
74
|
+
[...matches.keys()].some((matchedCanonical) => matchedCanonical.split(' ').includes('row'))
|
|
75
|
+
) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (new RegExp(`(?:^| )${alias}(?: |$)`).test(normalizedQuestion)) {
|
|
79
|
+
const aliasCanonical = canonicalExerciseName(canonical);
|
|
80
|
+
matches.set(aliasCanonical, knownDisplayByCanonical.get(aliasCanonical) ?? canonical);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const [canonical, displayName] of knownExercises) {
|
|
85
|
+
if (matches.has(canonical)) continue;
|
|
86
|
+
const normalizedDisplay = normalizeExerciseName(displayName);
|
|
87
|
+
if (matches.size > 0) continue;
|
|
88
|
+
const firstToken = normalizedDisplay.split(' ')[0];
|
|
89
|
+
if (firstToken && firstToken.length >= 5 && new RegExp(`(?:^| )${firstToken}(?: |$)`).test(normalizedQuestion)) {
|
|
90
|
+
matches.set(canonical, displayName);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return [...matches.entries()].map(([canonical, displayName]) => ({ canonical, displayName }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function knownSessionLabels(snapshot) {
|
|
98
|
+
const labels = new Map();
|
|
99
|
+
for (const session of snapshot.sessions ?? []) {
|
|
100
|
+
const label = String(session.dayName ?? session.programName ?? '').trim();
|
|
101
|
+
if (label) labels.set(normalizeExerciseName(label), label);
|
|
102
|
+
}
|
|
103
|
+
for (const program of snapshot.programs ?? []) {
|
|
104
|
+
for (const day of program.days ?? []) {
|
|
105
|
+
const label = String(day.title ?? '').trim();
|
|
106
|
+
if (label) labels.set(normalizeExerciseName(label), label);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return [...labels.entries()]
|
|
110
|
+
.filter(([normalized]) => normalized.split(' ').length > 1)
|
|
111
|
+
.sort((a, b) => b[0].length - a[0].length);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function referencedSessionLabelFromQuestion(snapshot, question) {
|
|
115
|
+
const normalizedQuestion = normalizeExerciseName(question ?? '');
|
|
116
|
+
if (!normalizedQuestion) return null;
|
|
117
|
+
const matched = knownSessionLabels(snapshot).find(([normalized]) => normalizedQuestion.includes(normalized));
|
|
118
|
+
return matched?.[1] ?? null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function textSentenceBounds(text, start, end = start) {
|
|
122
|
+
const before = Math.max(
|
|
123
|
+
text.lastIndexOf('.', start),
|
|
124
|
+
text.lastIndexOf('!', start),
|
|
125
|
+
text.lastIndexOf('?', start),
|
|
126
|
+
text.lastIndexOf('\n', start)
|
|
127
|
+
);
|
|
128
|
+
const afterCandidates = ['.', '!', '?', '\n']
|
|
129
|
+
.map((char) => text.indexOf(char, end))
|
|
130
|
+
.filter((index) => index >= 0);
|
|
131
|
+
return {
|
|
132
|
+
start: before >= 0 ? before + 1 : 0,
|
|
133
|
+
end: afterCandidates.length > 0 ? Math.min(...afterCandidates) : text.length
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function referencedWeightedSets(question) {
|
|
138
|
+
const sets = [];
|
|
139
|
+
const pattern = /\b(\d+(?:\.\d+)?)\s*(?:kg|kgs|kilograms?)?\s*(?:x|×|for)\s*(\d+)\b/gi;
|
|
140
|
+
for (const match of String(question ?? '').matchAll(pattern)) {
|
|
141
|
+
sets.push({
|
|
142
|
+
weight: Number(match[1]),
|
|
143
|
+
reps: Number(match[2]),
|
|
144
|
+
index: match.index ?? -1,
|
|
145
|
+
end: (match.index ?? -1) + match[0].length
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return sets;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function referencedExerciseMentions(snapshot, question) {
|
|
152
|
+
const text = String(question ?? '');
|
|
153
|
+
const normalized = normalizeExerciseName(text);
|
|
154
|
+
const mentions = [];
|
|
155
|
+
for (const [canonical, displayName] of allExerciseNames(snapshot)) {
|
|
156
|
+
const normalizedDisplay = normalizeExerciseName(displayName);
|
|
157
|
+
const normalizedIndex = normalized.indexOf(normalizedDisplay);
|
|
158
|
+
if (normalizedIndex < 0) continue;
|
|
159
|
+
const sourceIndex = text.toLowerCase().indexOf(String(displayName).toLowerCase());
|
|
160
|
+
mentions.push({
|
|
161
|
+
canonical,
|
|
162
|
+
displayName,
|
|
163
|
+
index: sourceIndex >= 0 ? sourceIndex : normalizedIndex,
|
|
164
|
+
end: (sourceIndex >= 0 ? sourceIndex : normalizedIndex) + displayName.length
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return mentions.sort((lhs, rhs) => lhs.index - rhs.index);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function nearestExerciseMentionForSet(mentions, question, set) {
|
|
171
|
+
if (mentions.length === 0) return null;
|
|
172
|
+
if (mentions.length === 1) return mentions[0];
|
|
173
|
+
const bounds = textSentenceBounds(String(question ?? ''), set.index, set.end);
|
|
174
|
+
return mentions
|
|
175
|
+
.filter((mention) => mention.index >= bounds.start && mention.end <= bounds.end)
|
|
176
|
+
.map((mention) => ({
|
|
177
|
+
mention,
|
|
178
|
+
distance: mention.end <= set.index
|
|
179
|
+
? set.index - mention.end
|
|
180
|
+
: mention.index >= set.end
|
|
181
|
+
? mention.index - set.end
|
|
182
|
+
: 0
|
|
183
|
+
}))
|
|
184
|
+
.sort((lhs, rhs) => lhs.distance - rhs.distance)[0]?.mention ?? null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function referencedSessionFromQuestion(snapshot, question) {
|
|
188
|
+
const label = referencedSessionLabelFromQuestion(snapshot, question);
|
|
189
|
+
if (!label) return null;
|
|
190
|
+
const mentions = referencedExerciseMentions(snapshot, question);
|
|
191
|
+
const setHints = referencedWeightedSets(question)
|
|
192
|
+
.map((set) => {
|
|
193
|
+
const mention = nearestExerciseMentionForSet(mentions, question, set);
|
|
194
|
+
return mention ? {
|
|
195
|
+
exerciseCanonical: mention.canonical,
|
|
196
|
+
exerciseName: mention.displayName,
|
|
197
|
+
weight: set.weight,
|
|
198
|
+
reps: set.reps
|
|
199
|
+
} : null;
|
|
200
|
+
})
|
|
201
|
+
.filter(Boolean);
|
|
202
|
+
return {
|
|
203
|
+
label,
|
|
204
|
+
setHints
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function hasSessionReviewLanguage(question) {
|
|
209
|
+
return /\b(session|workout|you noted|tell me more|went|go|did my)\b/i.test(question ?? '');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function hasNamedExerciseActionLanguage(question) {
|
|
213
|
+
return /\b(should|change|swap|deload|increase|decrease|adjust|keep|load|next time)\b/i.test(question ?? '');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const REVIEW_WORD_NUMBERS = Object.freeze({
|
|
217
|
+
a: 1, an: 1, one: 1, couple: 2, two: 2, three: 3, four: 4, five: 5, six: 6, several: 4
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
function validDateOnlyString(value) {
|
|
221
|
+
const text = String(value ?? '').slice(0, 10);
|
|
222
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return null;
|
|
223
|
+
const ms = Date.parse(`${text}T00:00:00.000Z`);
|
|
224
|
+
if (!Number.isFinite(ms)) return null;
|
|
225
|
+
return new Date(ms).toISOString().slice(0, 10) === text ? text : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Parse relative windows like "last two weeks", "past 10 days", "this month",
|
|
229
|
+
// "couple of weeks", "fortnight" into a day count. Returns null when no relative
|
|
230
|
+
// window is present so callers can fall back to absolute/`since` parsing.
|
|
231
|
+
function inferredRelativeWindowDays(question) {
|
|
232
|
+
const text = String(question ?? '').toLowerCase();
|
|
233
|
+
if (/\bfortnight\b/.test(text)) return 14;
|
|
234
|
+
const match = text.match(
|
|
235
|
+
/\b(?:last|past|previous|recent|this)\s+(?:(\d+|a|an|one|couple|two|three|four|five|six|several)\s+)?(?:of\s+)?(day|days|week|weeks|month|months)\b/
|
|
236
|
+
);
|
|
237
|
+
if (!match) return null;
|
|
238
|
+
const rawCount = match[1];
|
|
239
|
+
const unit = match[2];
|
|
240
|
+
let count;
|
|
241
|
+
if (rawCount == null) count = 1;
|
|
242
|
+
else if (/^\d+$/.test(rawCount)) count = Number.parseInt(rawCount, 10);
|
|
243
|
+
else count = REVIEW_WORD_NUMBERS[rawCount] ?? null;
|
|
244
|
+
if (count == null || count <= 0) return null;
|
|
245
|
+
const perUnit = unit.startsWith('day') ? 1 : unit.startsWith('week') ? 7 : 30;
|
|
246
|
+
return Math.min(count * perUnit, 370);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function inferredSinceDate(question, today = new Date()) {
|
|
250
|
+
const text = String(question ?? '');
|
|
251
|
+
const explicitDate = text.match(/\bsince\s+(\d{4}-\d{2}-\d{2})\b/i);
|
|
252
|
+
if (explicitDate) return validDateOnlyString(explicitDate[1]);
|
|
253
|
+
const explicitYearMonth = text.match(/\bsince\s+(\d{4}-\d{2})\b/i);
|
|
254
|
+
if (explicitYearMonth) return validDateOnlyString(`${explicitYearMonth[1]}-01`);
|
|
255
|
+
const relativeWindow = inferredRelativeWindowDays(text);
|
|
256
|
+
if (relativeWindow != null) return relativeDateString(today, -relativeWindow);
|
|
257
|
+
const monthMatch = text.match(/\bsince\s+(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b/i);
|
|
258
|
+
if (!monthMatch) return null;
|
|
259
|
+
const months = {
|
|
260
|
+
jan: 1, january: 1,
|
|
261
|
+
feb: 2, february: 2,
|
|
262
|
+
mar: 3, march: 3,
|
|
263
|
+
apr: 4, april: 4,
|
|
264
|
+
may: 5,
|
|
265
|
+
jun: 6, june: 6,
|
|
266
|
+
jul: 7, july: 7,
|
|
267
|
+
aug: 8, august: 8,
|
|
268
|
+
sep: 9, sept: 9, september: 9,
|
|
269
|
+
oct: 10, october: 10,
|
|
270
|
+
nov: 11, november: 11,
|
|
271
|
+
dec: 12, december: 12
|
|
272
|
+
};
|
|
273
|
+
const month = months[monthMatch[1].toLowerCase()];
|
|
274
|
+
if (!month) return null;
|
|
275
|
+
const year = new Date(today).getUTCFullYear();
|
|
276
|
+
const monthPart = String(month).padStart(2, '0');
|
|
277
|
+
const candidate = `${year}-${monthPart}-01`;
|
|
278
|
+
return candidate > dateOnlyString(today) ? `${year - 1}-${monthPart}-01` : candidate;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function deloadScheduleContextFromText(text) {
|
|
282
|
+
const raw = String(text ?? '');
|
|
283
|
+
if (!/\bd(?:e)?load\b/i.test(raw)) return null;
|
|
284
|
+
const match = raw.match(/\b(this|next|coming)\s+(?:training\s+)?week\b/i);
|
|
285
|
+
if (!match) return null;
|
|
286
|
+
return {
|
|
287
|
+
week: match[1].toLowerCase() === 'this' ? 'this' : 'next'
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function latestDeloadScheduleContext(history = []) {
|
|
292
|
+
const messages = (Array.isArray(history) ? history : []).slice(-2);
|
|
293
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
294
|
+
const message = messages[index];
|
|
295
|
+
if (!['user', 'assistant'].includes(message?.role) || typeof message.content !== 'string') continue;
|
|
296
|
+
const context = deloadScheduleContextFromText(message.content);
|
|
297
|
+
if (context) return context;
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isDeloadScheduleConfirmation(question) {
|
|
303
|
+
const text = String(question ?? '').trim().toLowerCase();
|
|
304
|
+
if (!text) return false;
|
|
305
|
+
if (/\b(no|don'?t|do not|cancel|undo|stop|never mind|nevermind)\b/i.test(text)) return false;
|
|
306
|
+
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
307
|
+
if (wordCount > 8) return false;
|
|
308
|
+
return /^(yes|yeah|yep|ok|okay|sure)\b/i.test(text) ||
|
|
309
|
+
/\b(do it|schedule it|set it|make it happen|schedule that|set that)\b/i.test(text);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function hasProgramHistoryLanguage(question, { previousRoute = null, isFollowUp = false } = {}) {
|
|
313
|
+
const text = String(question ?? '').toLowerCase();
|
|
314
|
+
const hasProgramLanguage = /\b(program|plan|routine|split)\b/i.test(text);
|
|
315
|
+
if (/\bwhat changed\b[\s\S]{0,80}\b(program|plan|routine|split)\b/i.test(text) ||
|
|
316
|
+
/\b(program|plan|routine|split)\b[\s\S]{0,80}\bwhat changed\b/i.test(text) ||
|
|
317
|
+
/\bwhy\b[\s\S]{0,80}\b(program|plan|routine|split)\b[\s\S]{0,80}\bchang/i.test(text)) {
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const restoreLanguage = /\b(undo that|undo it|revert that|revert it|restore that|restore it|change it back|previous version|last change)\b/i.test(text);
|
|
322
|
+
if (!restoreLanguage) return false;
|
|
323
|
+
if (hasProgramLanguage) return true;
|
|
324
|
+
return isFollowUp && ['program_history', 'program_progress', 'program_schedule_action'].includes(previousRoute);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function routeAskQuestion(snapshot, question, { today = new Date(), previousRoute = null, isFollowUp = false } = {}) {
|
|
328
|
+
const normalizedQuestion = normalizeExerciseName(question ?? '');
|
|
329
|
+
const namedExercises = namedExercisesFromQuestion(snapshot, question);
|
|
330
|
+
const sessionReference = referencedSessionFromQuestion(snapshot, question);
|
|
331
|
+
const sessionLabel = sessionReference?.label ?? null;
|
|
332
|
+
const since = inferredSinceDate(question, today);
|
|
333
|
+
const progressLanguage = /\b(progress|progressing|improve|improved|improvement|better|stronger|moved|moving|stalled|flat|since)\b/i.test(question ?? '');
|
|
334
|
+
const profileLanguage = /\b(know about me|about me as a lifter|me as a lifter|lifter profile|training profile)\b/i.test(question ?? '');
|
|
335
|
+
const programLanguage = /\b(program|plan|split|routine|full gym|ppl|upper lower)\b/i.test(question ?? '');
|
|
336
|
+
const deloadWord = 'd(?:e)?load';
|
|
337
|
+
const deloadScheduleContext = deloadScheduleContextFromText(question);
|
|
338
|
+
const deloadScheduleVerb = '(?:make|schedule|set|program|turn|change|adjust)';
|
|
339
|
+
const deloadScheduleLanguage = new RegExp(`\\b${deloadScheduleVerb}\\b[\\s\\S]{0,120}\\b(?:this|next|coming)\\s+(?:training\\s+)?week\\b[\\s\\S]{0,120}\\b${deloadWord}\\b`, 'i').test(question ?? '') ||
|
|
340
|
+
new RegExp(`\\b${deloadScheduleVerb}\\b[\\s\\S]{0,120}\\b${deloadWord}\\b[\\s\\S]{0,120}\\b(?:this|next|coming)\\s+(?:training\\s+)?week\\b`, 'i').test(question ?? '');
|
|
341
|
+
const windowDays = inferredRelativeWindowDays(question);
|
|
342
|
+
const reviewVerb = /\b(doing|going|look(?:s|ing|ed)?|progress\w*|train(?:s|ing)?|workouts?|fitness)\b/i.test(question ?? '');
|
|
343
|
+
const explicitReview = /\b(on (?:the )?(?:right )?track|right track|making (?:good )?progress|overall progress|review (?:my|the)\b|check (?:out\s+)?my\b|how(?:'?s| is| has| have)\s+(?:my|the)\s+(?:training|progress|fitness))\b/i.test(question ?? '');
|
|
344
|
+
const broadReviewIntent = explicitReview || (windowDays != null && reviewVerb);
|
|
345
|
+
const compositeReviewCue =
|
|
346
|
+
broadReviewIntent &&
|
|
347
|
+
/\b(include|including|along with|plus|and)\b/i.test(question ?? '') &&
|
|
348
|
+
/\b(workouts?|training|progress|overall|track)\b/i.test(question ?? '');
|
|
349
|
+
// Questions that clearly target one dedicated route should keep using it.
|
|
350
|
+
const narrowSingleTopic =
|
|
351
|
+
/\b(volume|workload|tonnage|load this week|weekly load)\b/i.test(question ?? '') ||
|
|
352
|
+
/\b(recover|recovery|readiness|hrv|sleep|resting heart|fatigue|tired|sore)\b/i.test(question ?? '') ||
|
|
353
|
+
/\b(increment(?:\s|-)?score|incremnt(?:\s|-)?score|my score|overall score|score (?:trend|trending|going)|what(?:'?s| is) my score|how(?:'?s| is) my score)\b/i.test(question ?? '') ||
|
|
354
|
+
/\b(pr|prs|record|records|max|maxes|1rm|one rep max|one-rep max|strongest)\b/i.test(question ?? '') ||
|
|
355
|
+
/\b(next|tomorrow|up next|coming session|do next|what should i do)\b/i.test(question ?? '');
|
|
356
|
+
|
|
357
|
+
if (deloadScheduleLanguage) {
|
|
358
|
+
return { route: 'program_schedule_action', namedExercises, deloadScheduleContext };
|
|
359
|
+
}
|
|
360
|
+
if (hasProgramHistoryLanguage(question, { previousRoute, isFollowUp })) {
|
|
361
|
+
return { route: 'program_history', namedExercises, deloadScheduleContext };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Broad "how am I doing / on the right track / last N weeks" reviews fan out to
|
|
365
|
+
// sessions + volume + records + body weight rather than a single narrow route.
|
|
366
|
+
// Requires an explicit review cue, or a relative window paired with a review verb.
|
|
367
|
+
if (
|
|
368
|
+
namedExercises.length === 0 &&
|
|
369
|
+
(!narrowSingleTopic || compositeReviewCue) &&
|
|
370
|
+
broadReviewIntent
|
|
371
|
+
) {
|
|
372
|
+
return { route: 'progress_review', namedExercises, since, deloadScheduleContext };
|
|
373
|
+
}
|
|
374
|
+
if (/\b(body ?weight|weigh|weight trends?|current weight|my weight)\b/i.test(question ?? '')) {
|
|
375
|
+
return { route: 'body_weight', namedExercises, deloadScheduleContext };
|
|
376
|
+
}
|
|
377
|
+
if (profileLanguage) {
|
|
378
|
+
return { route: 'training_profile', namedExercises, since, deloadScheduleContext };
|
|
379
|
+
}
|
|
380
|
+
if (programLanguage && progressLanguage) {
|
|
381
|
+
return { route: 'program_progress', namedExercises, since, deloadScheduleContext };
|
|
382
|
+
}
|
|
383
|
+
if (since && progressLanguage) {
|
|
384
|
+
return { route: 'exercise_progress_summary', namedExercises, since };
|
|
385
|
+
}
|
|
386
|
+
if (/\b(volume|workload|tonnage|load this week|weekly load)\b/i.test(question ?? '')) {
|
|
387
|
+
return { route: 'volume', namedExercises };
|
|
388
|
+
}
|
|
389
|
+
if (namedExercises.length > 0 && hasNamedExerciseActionLanguage(question)) {
|
|
390
|
+
return { route: 'exercise_progress', namedExercises };
|
|
391
|
+
}
|
|
392
|
+
if (sessionLabel && hasSessionReviewLanguage(question)) {
|
|
393
|
+
return { route: 'recent_session', namedExercises, sessionLabel, sessionReference };
|
|
394
|
+
}
|
|
395
|
+
if (/\b(next|tomorrow|up next|coming session|do next|what should i do)\b/i.test(question ?? '')) {
|
|
396
|
+
return { route: 'next_session', namedExercises };
|
|
397
|
+
}
|
|
398
|
+
if (/\b(recover|recovery|readiness|hrv|sleep|resting heart|fatigue|tired|sore)\b/i.test(question ?? '')) {
|
|
399
|
+
return { route: 'recovery', namedExercises };
|
|
400
|
+
}
|
|
401
|
+
if (/\b(increment(?:\s|-)?score|incremnt(?:\s|-)?score|my score|overall score|score (?:trend|trending|going)|what(?:'?s| is) my score|how(?:'?s| is) my score)\b/i.test(question ?? '')) {
|
|
402
|
+
return { route: 'score', namedExercises };
|
|
403
|
+
}
|
|
404
|
+
if (/\b(pr|prs|record|records|max|maxes|1rm|one rep max|one-rep max|strongest)\b/i.test(question ?? '')) {
|
|
405
|
+
return { route: 'records', namedExercises };
|
|
406
|
+
}
|
|
407
|
+
if (/\b(build|create|make|generate|draft|rewrite|revise|update|adjust)\b.*\b(program|plan|split|routine)\b/i.test(question ?? '')) {
|
|
408
|
+
return { route: 'program_design', namedExercises };
|
|
409
|
+
}
|
|
410
|
+
if (/\b(session|workout|today|yesterday|last time|went|go|fail|failed|miss|missed|last set|last two sets)\b/i.test(question ?? '') && namedExercises.length === 0) {
|
|
411
|
+
return { route: 'recent_session', namedExercises, sessionLabel, sessionReference };
|
|
412
|
+
}
|
|
413
|
+
if (namedExercises.length > 0 || normalizedQuestion.includes('going')) {
|
|
414
|
+
if (progressLanguage) return { route: 'exercise_progress_summary', namedExercises, since };
|
|
415
|
+
return { route: 'exercise_progress', namedExercises };
|
|
416
|
+
}
|
|
417
|
+
return { route: 'general', namedExercises };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function historyUserQuestions(history = []) {
|
|
421
|
+
return (Array.isArray(history) ? history : [])
|
|
422
|
+
.filter((message) => message?.role === 'user' && typeof message.content === 'string')
|
|
423
|
+
.map((message) => message.content);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function isTerseFollowUpQuestion(question) {
|
|
427
|
+
const text = String(question ?? '').trim().toLowerCase();
|
|
428
|
+
if (!text) return false;
|
|
429
|
+
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
430
|
+
return wordCount <= 5 || /^(why|how come|what about|and |should i|can i|do that|make it|swap|change it)/i.test(text);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function requestedActionForRoute(route, question, { isFollowUp = false, carriedPreviousTopic = false } = {}) {
|
|
434
|
+
const text = String(question ?? '').toLowerCase();
|
|
435
|
+
if (route === 'program_schedule_action') return 'schedule_program_action';
|
|
436
|
+
if (route === 'program_history' && /\b(undo|revert|restore|change it back|previous version)\b/.test(text)) return 'explain_restore_status';
|
|
437
|
+
if (route === 'program_history') return 'explain_program_history';
|
|
438
|
+
if (/\b(why|how come)\b/.test(text)) return 'explain_cause';
|
|
439
|
+
if (/\b(build|create|make|generate|draft|rewrite|revise|update)\b/.test(text)) return 'draft_plan';
|
|
440
|
+
if (/\badjust\b/.test(text) && /\b(program|plan|split|routine)\b/.test(text)) return 'draft_plan';
|
|
441
|
+
if (/\b(should|change|swap|deload|increase|decrease|adjust)\b/.test(text)) return 'recommend_action';
|
|
442
|
+
if (carriedPreviousTopic) return 'compare_previous_topic';
|
|
443
|
+
if (isFollowUp) return 'continue_previous_topic';
|
|
444
|
+
const byRoute = {
|
|
445
|
+
body_weight: 'explain_body_weight',
|
|
446
|
+
training_profile: 'summarize_profile',
|
|
447
|
+
program_progress: 'explain_program_progress',
|
|
448
|
+
exercise_progress_summary: 'explain_progress',
|
|
449
|
+
exercise_progress: 'explain_exercise',
|
|
450
|
+
volume: 'explain_volume',
|
|
451
|
+
next_session: 'recommend_next_session',
|
|
452
|
+
program_history: 'explain_program_history',
|
|
453
|
+
recovery: 'explain_recovery',
|
|
454
|
+
score: 'explain_score',
|
|
455
|
+
records: 'summarize_records',
|
|
456
|
+
program_design: 'draft_plan',
|
|
457
|
+
recent_session: 'review_recent_session',
|
|
458
|
+
progress_review: 'summarize_progress',
|
|
459
|
+
general: 'answer_training_question'
|
|
460
|
+
};
|
|
461
|
+
return byRoute[route] ?? 'answer_training_question';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export const ASK_RESPONSE_PROFILES = Object.freeze({
|
|
465
|
+
expansive: 'expansive',
|
|
466
|
+
defensive: 'defensive',
|
|
467
|
+
structured: 'structured'
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
function responseProfileForAskIntent(route, requestedAction, question) {
|
|
471
|
+
if (route === 'program_schedule_action' || requestedAction === 'schedule_program_action') return ASK_RESPONSE_PROFILES.structured;
|
|
472
|
+
if (route === 'program_design' || requestedAction === 'draft_plan') return ASK_RESPONSE_PROFILES.structured;
|
|
473
|
+
const text = String(question ?? '').toLowerCase();
|
|
474
|
+
if (
|
|
475
|
+
requestedAction === 'recommend_action' ||
|
|
476
|
+
requestedAction === 'recommend_next_session' ||
|
|
477
|
+
requestedAction === 'explain_cause' ||
|
|
478
|
+
/\b(should|can i|safe|increase|decrease|deload|swap|change|adjust)\b/.test(text) ||
|
|
479
|
+
/\b(what should i do|do next|next session|up next)\b/.test(text) ||
|
|
480
|
+
/\b(did i hit|hit target|below target|missed?|failed?|why did i fail)\b/.test(text) ||
|
|
481
|
+
// Decision questions phrased without an imperative verb still want a crisp
|
|
482
|
+
// recommendation, not an expansive dashboard.
|
|
483
|
+
/\b(too heavy|too light|too much|too easy|ready to|time to|worth|good idea|enough|move up|add weight|bump up|push (?:harder|up)|stall(?:ing|ed)?|plateau(?:ing|ed)?|drop[- ]?off|dropping off|falling off|declining|regressing)\b/.test(text) ||
|
|
484
|
+
/\bam i ready\b/.test(text)
|
|
485
|
+
) {
|
|
486
|
+
return ASK_RESPONSE_PROFILES.defensive;
|
|
487
|
+
}
|
|
488
|
+
return ASK_RESPONSE_PROFILES.expansive;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function confidenceForIntent({ rawRoute, route, namedExercises, question, previousRoute, isFollowUp }) {
|
|
492
|
+
let confidence = 0.72;
|
|
493
|
+
if (route !== 'general') confidence += 0.1;
|
|
494
|
+
if (namedExercises.length > 0) confidence += 0.06;
|
|
495
|
+
if (previousRoute && isFollowUp) confidence += 0.04;
|
|
496
|
+
if (rawRoute !== route) confidence -= 0.03;
|
|
497
|
+
if (/\b(or|maybe|not sure|confused)\b/i.test(question ?? '')) confidence -= 0.12;
|
|
498
|
+
return Math.max(0.35, Math.min(0.95, Number(confidence.toFixed(2))));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function ambiguityFlagsForIntent({ route, namedExercises, question, isFollowUp, previousRoute }) {
|
|
502
|
+
const flags = [];
|
|
503
|
+
const text = String(question ?? '');
|
|
504
|
+
if (route === 'general') flags.push('no_specific_route');
|
|
505
|
+
if (/\b(or|maybe|not sure|confused)\b/i.test(text)) flags.push('ambiguous_user_intent');
|
|
506
|
+
if (isFollowUp && !previousRoute) flags.push('missing_previous_context');
|
|
507
|
+
if (/\b(this|that|it)\b/i.test(text) && namedExercises.length === 0 && !previousRoute) flags.push('unresolved_reference');
|
|
508
|
+
return flags;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function classifyAskIntentWithPrevious(snapshot, question, { previousIntent = null, previousDeloadScheduleContext = null, today = new Date() } = {}) {
|
|
512
|
+
const previous = previousIntent
|
|
513
|
+
? {
|
|
514
|
+
route: previousIntent.route,
|
|
515
|
+
since: previousIntent.timeframe?.since ?? null,
|
|
516
|
+
namedExercises: previousIntent.entities?.exercises ?? [],
|
|
517
|
+
sessionLabel: previousIntent.entities?.sessionLabel ?? null,
|
|
518
|
+
sessionReference: previousIntent.entities?.sessionReference ?? null,
|
|
519
|
+
deloadScheduleContext: previousIntent.deloadScheduleContext ?? null
|
|
520
|
+
}
|
|
521
|
+
: null;
|
|
522
|
+
const isFollowUp = Boolean((previous || previousDeloadScheduleContext) && isTerseFollowUpQuestion(question));
|
|
523
|
+
const current = routeAskQuestion(snapshot, question, {
|
|
524
|
+
today,
|
|
525
|
+
previousRoute: previous?.route ?? null,
|
|
526
|
+
isFollowUp
|
|
527
|
+
});
|
|
528
|
+
const currentDeloadScheduleContext = current.deloadScheduleContext ?? deloadScheduleContextFromText(question);
|
|
529
|
+
const carriedDeloadScheduleContext = currentDeloadScheduleContext ?? previousDeloadScheduleContext ?? previous?.deloadScheduleContext ?? null;
|
|
530
|
+
const isDeloadScheduleFollowUp = current.route !== 'program_schedule_action' &&
|
|
531
|
+
Boolean(carriedDeloadScheduleContext) &&
|
|
532
|
+
isDeloadScheduleConfirmation(question);
|
|
533
|
+
let route = current.route;
|
|
534
|
+
let since = current.since ?? null;
|
|
535
|
+
let carriedPreviousTopic = false;
|
|
536
|
+
|
|
537
|
+
if (isDeloadScheduleFollowUp) {
|
|
538
|
+
route = 'program_schedule_action';
|
|
539
|
+
since = null;
|
|
540
|
+
} else if (
|
|
541
|
+
isFollowUp &&
|
|
542
|
+
previous?.route &&
|
|
543
|
+
current.namedExercises.length > 0 &&
|
|
544
|
+
['exercise_progress', 'exercise_progress_summary', 'program_progress', 'records'].includes(previous.route) &&
|
|
545
|
+
['exercise_progress', 'general'].includes(current.route)
|
|
546
|
+
) {
|
|
547
|
+
route = previous.route;
|
|
548
|
+
since = current.since ?? previous.since ?? null;
|
|
549
|
+
carriedPreviousTopic = true;
|
|
550
|
+
} else if (isFollowUp && current.route === 'general' && previous?.route) {
|
|
551
|
+
route = previous.route;
|
|
552
|
+
since = previous.since ?? null;
|
|
553
|
+
carriedPreviousTopic = true;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const namedExercises = carriedPreviousTopic && current.namedExercises.length === 0
|
|
557
|
+
? previous?.namedExercises ?? []
|
|
558
|
+
: current.namedExercises;
|
|
559
|
+
const sessionLabel = current.sessionLabel ?? (carriedPreviousTopic ? previous?.sessionLabel ?? null : null);
|
|
560
|
+
const sessionReference = current.sessionReference ?? (carriedPreviousTopic ? previous?.sessionReference ?? null : null);
|
|
561
|
+
const requestedAction = requestedActionForRoute(route, question, { isFollowUp, carriedPreviousTopic });
|
|
562
|
+
const responseProfile = responseProfileForAskIntent(route, requestedAction, question);
|
|
563
|
+
return {
|
|
564
|
+
route,
|
|
565
|
+
effectiveRoute: route === 'exercise_progress' && namedExercises.length === 0 ? 'general' : route,
|
|
566
|
+
confidence: confidenceForIntent({
|
|
567
|
+
rawRoute: current.route,
|
|
568
|
+
route,
|
|
569
|
+
namedExercises,
|
|
570
|
+
question,
|
|
571
|
+
previousRoute: previous?.route ?? null,
|
|
572
|
+
isFollowUp
|
|
573
|
+
}),
|
|
574
|
+
entities: {
|
|
575
|
+
exercises: namedExercises.map((exercise) => ({
|
|
576
|
+
canonical: exercise.canonical,
|
|
577
|
+
displayName: exercise.displayName
|
|
578
|
+
})),
|
|
579
|
+
sessionLabel,
|
|
580
|
+
sessionReference
|
|
581
|
+
},
|
|
582
|
+
timeframe: since ? { since } : null,
|
|
583
|
+
requestedAction,
|
|
584
|
+
responseProfile,
|
|
585
|
+
isFollowUp,
|
|
586
|
+
previousRoute: previous?.route ?? null,
|
|
587
|
+
deloadScheduleContext: route === 'program_schedule_action'
|
|
588
|
+
? carriedDeloadScheduleContext
|
|
589
|
+
: currentDeloadScheduleContext,
|
|
590
|
+
ambiguityFlags: ambiguityFlagsForIntent({
|
|
591
|
+
route,
|
|
592
|
+
namedExercises,
|
|
593
|
+
question,
|
|
594
|
+
isFollowUp,
|
|
595
|
+
previousRoute: previous?.route ?? null
|
|
596
|
+
})
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function classifyAskIntent(snapshot, question, { history = [], today = new Date() } = {}) {
|
|
601
|
+
let previousIntent = null;
|
|
602
|
+
for (const previousQuestion of historyUserQuestions(history)) {
|
|
603
|
+
previousIntent = classifyAskIntentWithPrevious(snapshot, previousQuestion, { previousIntent, today });
|
|
604
|
+
}
|
|
605
|
+
return classifyAskIntentWithPrevious(snapshot, question, {
|
|
606
|
+
previousIntent,
|
|
607
|
+
previousDeloadScheduleContext: latestDeloadScheduleContext(history),
|
|
608
|
+
today
|
|
609
|
+
});
|
|
610
|
+
}
|