incremnt 0.8.3 → 0.8.5
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/package.json +1 -1
- package/src/ask-coach.js +412 -62
- package/src/contract.js +12 -1
- package/src/format.js +33 -0
- package/src/openrouter.js +6 -5
- package/src/program-schedule-action.js +107 -0
- package/src/prompt-changelog.js +18 -0
- package/src/queries.js +185 -2
- package/src/remote.js +7 -0
- package/src/sync-service.js +194 -55
package/src/ask-coach.js
CHANGED
|
@@ -278,7 +278,53 @@ function inferredSinceDate(question, today = new Date()) {
|
|
|
278
278
|
return candidate > dateOnlyString(today) ? `${year - 1}-${monthPart}-01` : candidate;
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
function
|
|
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 } = {}) {
|
|
282
328
|
const normalizedQuestion = normalizeExerciseName(question ?? '');
|
|
283
329
|
const namedExercises = namedExercisesFromQuestion(snapshot, question);
|
|
284
330
|
const sessionReference = referencedSessionFromQuestion(snapshot, question);
|
|
@@ -287,6 +333,10 @@ function routeAskQuestion(snapshot, question, { today = new Date() } = {}) {
|
|
|
287
333
|
const progressLanguage = /\b(progress|progressing|improve|improved|improvement|better|stronger|moved|moving|stalled|flat|since)\b/i.test(question ?? '');
|
|
288
334
|
const profileLanguage = /\b(know about me|about me as a lifter|me as a lifter|lifter profile|training profile)\b/i.test(question ?? '');
|
|
289
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 deloadScheduleLanguage = new RegExp(`\\b(?:make|schedule|set|turn|change|adjust)\\b[\\s\\S]{0,120}\\b(?:this|next|coming)\\s+(?:training\\s+)?week\\b[\\s\\S]{0,120}\\b${deloadWord}\\b`, 'i').test(question ?? '') ||
|
|
339
|
+
new RegExp(`\\b(?:make|schedule|set|turn|change|adjust)\\b[\\s\\S]{0,120}\\b${deloadWord}\\b[\\s\\S]{0,120}\\b(?:this|next|coming)\\s+(?:training\\s+)?week\\b`, 'i').test(question ?? '');
|
|
290
340
|
const windowDays = inferredRelativeWindowDays(question);
|
|
291
341
|
const reviewVerb = /\b(doing|going|look(?:s|ing|ed)?|progress\w*|train(?:s|ing)?|workouts?|fitness)\b/i.test(question ?? '');
|
|
292
342
|
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 ?? '');
|
|
@@ -303,6 +353,13 @@ function routeAskQuestion(snapshot, question, { today = new Date() } = {}) {
|
|
|
303
353
|
/\b(pr|prs|record|records|max|maxes|1rm|one rep max|one-rep max|strongest)\b/i.test(question ?? '') ||
|
|
304
354
|
/\b(next|tomorrow|up next|coming session|do next|what should i do)\b/i.test(question ?? '');
|
|
305
355
|
|
|
356
|
+
if (deloadScheduleLanguage) {
|
|
357
|
+
return { route: 'program_schedule_action', namedExercises, deloadScheduleContext };
|
|
358
|
+
}
|
|
359
|
+
if (hasProgramHistoryLanguage(question, { previousRoute, isFollowUp })) {
|
|
360
|
+
return { route: 'program_history', namedExercises, deloadScheduleContext };
|
|
361
|
+
}
|
|
362
|
+
|
|
306
363
|
// Broad "how am I doing / on the right track / last N weeks" reviews fan out to
|
|
307
364
|
// sessions + volume + records + body weight rather than a single narrow route.
|
|
308
365
|
// Requires an explicit review cue, or a relative window paired with a review verb.
|
|
@@ -311,16 +368,16 @@ function routeAskQuestion(snapshot, question, { today = new Date() } = {}) {
|
|
|
311
368
|
(!narrowSingleTopic || compositeReviewCue) &&
|
|
312
369
|
broadReviewIntent
|
|
313
370
|
) {
|
|
314
|
-
return { route: 'progress_review', namedExercises, since };
|
|
371
|
+
return { route: 'progress_review', namedExercises, since, deloadScheduleContext };
|
|
315
372
|
}
|
|
316
373
|
if (/\b(body ?weight|weigh|weight trends?|current weight|my weight)\b/i.test(question ?? '')) {
|
|
317
|
-
return { route: 'body_weight', namedExercises };
|
|
374
|
+
return { route: 'body_weight', namedExercises, deloadScheduleContext };
|
|
318
375
|
}
|
|
319
376
|
if (profileLanguage) {
|
|
320
|
-
return { route: 'training_profile', namedExercises, since };
|
|
377
|
+
return { route: 'training_profile', namedExercises, since, deloadScheduleContext };
|
|
321
378
|
}
|
|
322
379
|
if (programLanguage && progressLanguage) {
|
|
323
|
-
return { route: 'program_progress', namedExercises, since };
|
|
380
|
+
return { route: 'program_progress', namedExercises, since, deloadScheduleContext };
|
|
324
381
|
}
|
|
325
382
|
if (since && progressLanguage) {
|
|
326
383
|
return { route: 'exercise_progress_summary', namedExercises, since };
|
|
@@ -374,6 +431,9 @@ function isTerseFollowUpQuestion(question) {
|
|
|
374
431
|
|
|
375
432
|
function requestedActionForRoute(route, question, { isFollowUp = false, carriedPreviousTopic = false } = {}) {
|
|
376
433
|
const text = String(question ?? '').toLowerCase();
|
|
434
|
+
if (route === 'program_schedule_action') return 'schedule_program_action';
|
|
435
|
+
if (route === 'program_history' && /\b(undo|revert|restore|change it back|previous version)\b/.test(text)) return 'explain_restore_status';
|
|
436
|
+
if (route === 'program_history') return 'explain_program_history';
|
|
377
437
|
if (/\b(why|how come)\b/.test(text)) return 'explain_cause';
|
|
378
438
|
if (/\b(build|create|make|generate|draft|rewrite|revise|update)\b/.test(text)) return 'draft_plan';
|
|
379
439
|
if (/\badjust\b/.test(text) && /\b(program|plan|split|routine)\b/.test(text)) return 'draft_plan';
|
|
@@ -388,6 +448,7 @@ function requestedActionForRoute(route, question, { isFollowUp = false, carriedP
|
|
|
388
448
|
exercise_progress: 'explain_exercise',
|
|
389
449
|
volume: 'explain_volume',
|
|
390
450
|
next_session: 'recommend_next_session',
|
|
451
|
+
program_history: 'explain_program_history',
|
|
391
452
|
recovery: 'explain_recovery',
|
|
392
453
|
score: 'explain_score',
|
|
393
454
|
records: 'summarize_records',
|
|
@@ -406,6 +467,7 @@ export const ASK_RESPONSE_PROFILES = Object.freeze({
|
|
|
406
467
|
});
|
|
407
468
|
|
|
408
469
|
function responseProfileForAskIntent(route, requestedAction, question) {
|
|
470
|
+
if (route === 'program_schedule_action' || requestedAction === 'schedule_program_action') return ASK_RESPONSE_PROFILES.structured;
|
|
409
471
|
if (route === 'program_design' || requestedAction === 'draft_plan') return ASK_RESPONSE_PROFILES.structured;
|
|
410
472
|
const text = String(question ?? '').toLowerCase();
|
|
411
473
|
if (
|
|
@@ -445,23 +507,36 @@ function ambiguityFlagsForIntent({ route, namedExercises, question, isFollowUp,
|
|
|
445
507
|
return flags;
|
|
446
508
|
}
|
|
447
509
|
|
|
448
|
-
function classifyAskIntentWithPrevious(snapshot, question, { previousIntent = null, today = new Date() } = {}) {
|
|
449
|
-
const current = routeAskQuestion(snapshot, question, { today });
|
|
510
|
+
function classifyAskIntentWithPrevious(snapshot, question, { previousIntent = null, previousDeloadScheduleContext = null, today = new Date() } = {}) {
|
|
450
511
|
const previous = previousIntent
|
|
451
512
|
? {
|
|
452
513
|
route: previousIntent.route,
|
|
453
514
|
since: previousIntent.timeframe?.since ?? null,
|
|
454
515
|
namedExercises: previousIntent.entities?.exercises ?? [],
|
|
455
516
|
sessionLabel: previousIntent.entities?.sessionLabel ?? null,
|
|
456
|
-
sessionReference: previousIntent.entities?.sessionReference ?? null
|
|
517
|
+
sessionReference: previousIntent.entities?.sessionReference ?? null,
|
|
518
|
+
deloadScheduleContext: previousIntent.deloadScheduleContext ?? null
|
|
457
519
|
}
|
|
458
520
|
: null;
|
|
459
|
-
const isFollowUp = Boolean(previous && isTerseFollowUpQuestion(question));
|
|
521
|
+
const isFollowUp = Boolean((previous || previousDeloadScheduleContext) && isTerseFollowUpQuestion(question));
|
|
522
|
+
const current = routeAskQuestion(snapshot, question, {
|
|
523
|
+
today,
|
|
524
|
+
previousRoute: previous?.route ?? null,
|
|
525
|
+
isFollowUp
|
|
526
|
+
});
|
|
527
|
+
const currentDeloadScheduleContext = current.deloadScheduleContext ?? deloadScheduleContextFromText(question);
|
|
528
|
+
const carriedDeloadScheduleContext = currentDeloadScheduleContext ?? previousDeloadScheduleContext ?? previous?.deloadScheduleContext ?? null;
|
|
529
|
+
const isDeloadScheduleFollowUp = current.route !== 'program_schedule_action' &&
|
|
530
|
+
Boolean(carriedDeloadScheduleContext) &&
|
|
531
|
+
isDeloadScheduleConfirmation(question);
|
|
460
532
|
let route = current.route;
|
|
461
533
|
let since = current.since ?? null;
|
|
462
534
|
let carriedPreviousTopic = false;
|
|
463
535
|
|
|
464
|
-
if (
|
|
536
|
+
if (isDeloadScheduleFollowUp) {
|
|
537
|
+
route = 'program_schedule_action';
|
|
538
|
+
since = null;
|
|
539
|
+
} else if (
|
|
465
540
|
isFollowUp &&
|
|
466
541
|
previous?.route &&
|
|
467
542
|
current.namedExercises.length > 0 &&
|
|
@@ -508,6 +583,9 @@ function classifyAskIntentWithPrevious(snapshot, question, { previousIntent = nu
|
|
|
508
583
|
responseProfile,
|
|
509
584
|
isFollowUp,
|
|
510
585
|
previousRoute: previous?.route ?? null,
|
|
586
|
+
deloadScheduleContext: route === 'program_schedule_action'
|
|
587
|
+
? carriedDeloadScheduleContext
|
|
588
|
+
: currentDeloadScheduleContext,
|
|
511
589
|
ambiguityFlags: ambiguityFlagsForIntent({
|
|
512
590
|
route,
|
|
513
591
|
namedExercises,
|
|
@@ -523,7 +601,11 @@ export function classifyAskIntent(snapshot, question, { history = [], today = ne
|
|
|
523
601
|
for (const previousQuestion of historyUserQuestions(history)) {
|
|
524
602
|
previousIntent = classifyAskIntentWithPrevious(snapshot, previousQuestion, { previousIntent, today });
|
|
525
603
|
}
|
|
526
|
-
return classifyAskIntentWithPrevious(snapshot, question, {
|
|
604
|
+
return classifyAskIntentWithPrevious(snapshot, question, {
|
|
605
|
+
previousIntent,
|
|
606
|
+
previousDeloadScheduleContext: latestDeloadScheduleContext(history),
|
|
607
|
+
today
|
|
608
|
+
});
|
|
527
609
|
}
|
|
528
610
|
|
|
529
611
|
function pushAskContextHeader(lines, snapshot, today = new Date()) {
|
|
@@ -539,18 +621,19 @@ function pushAskContextHeader(lines, snapshot, today = new Date()) {
|
|
|
539
621
|
const ASK_FACT_KIND_BY_ROUTE = Object.freeze({
|
|
540
622
|
general: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
541
623
|
progress_review: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
542
|
-
exercise_progress: ['goal_signal', 'injury', 'constraint', 'preference'],
|
|
543
|
-
exercise_progress_summary: ['goal_signal', 'injury', 'constraint', 'preference'],
|
|
544
|
-
program_progress: ['goal_signal', 'preference', 'constraint', 'injury'],
|
|
624
|
+
exercise_progress: ['goal_signal', 'injury', 'constraint', 'preference', 'tone'],
|
|
625
|
+
exercise_progress_summary: ['goal_signal', 'injury', 'constraint', 'preference', 'tone'],
|
|
626
|
+
program_progress: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
627
|
+
program_schedule_action: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
545
628
|
training_profile: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
546
|
-
program_design: ['goal_signal', 'preference', 'constraint', 'injury'],
|
|
547
|
-
next_session: ['constraint', 'injury', 'preference', 'goal_signal'],
|
|
548
|
-
recent_session: ['injury', 'constraint', 'goal_signal'],
|
|
629
|
+
program_design: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
630
|
+
next_session: ['constraint', 'injury', 'preference', 'goal_signal', 'tone'],
|
|
631
|
+
recent_session: ['injury', 'constraint', 'goal_signal', 'tone'],
|
|
549
632
|
recovery: ['injury', 'constraint', 'tone'],
|
|
550
|
-
body_weight: ['goal_signal'],
|
|
551
|
-
volume: ['goal_signal', 'constraint'],
|
|
552
|
-
records: ['goal_signal'],
|
|
553
|
-
score: ['goal_signal', 'constraint']
|
|
633
|
+
body_weight: ['goal_signal', 'tone'],
|
|
634
|
+
volume: ['goal_signal', 'constraint', 'tone'],
|
|
635
|
+
records: ['goal_signal', 'tone'],
|
|
636
|
+
score: ['goal_signal', 'constraint', 'tone']
|
|
554
637
|
});
|
|
555
638
|
|
|
556
639
|
function normalizeCoachFactForContext(row) {
|
|
@@ -611,6 +694,13 @@ function appendCoachFactsContext(lines, facts) {
|
|
|
611
694
|
.join(', ');
|
|
612
695
|
lines.push(` [${fact.kind}] ${fact.fact}${provenance ? ` (${provenance})` : ''}`);
|
|
613
696
|
}
|
|
697
|
+
const toneFacts = facts
|
|
698
|
+
.filter((fact) => fact.kind === 'tone')
|
|
699
|
+
.map((fact) => fact.fact)
|
|
700
|
+
.filter(Boolean);
|
|
701
|
+
if (toneFacts.length > 0) {
|
|
702
|
+
lines.push(`Answer style preference to carry explicitly: ${toneFacts.join(' ')}`);
|
|
703
|
+
}
|
|
614
704
|
return facts.map((fact) => fact.id).filter(Boolean);
|
|
615
705
|
}
|
|
616
706
|
|
|
@@ -643,6 +733,8 @@ const ASK_ROUTE_REQUIRED_TOOLS = Object.freeze({
|
|
|
643
733
|
exercise_progress: ['get_exercise_history'],
|
|
644
734
|
exercise_progress_summary: ['get_exercise_progress_summary'],
|
|
645
735
|
program_progress: ['get_program_progress', 'get_exercise_progress_summary', 'get_cycle_progression_summary'],
|
|
736
|
+
program_history: ['get_program_change_history'],
|
|
737
|
+
program_schedule_action: ['get_program_progress', 'get_recent_sessions', 'get_readiness_snapshot'],
|
|
646
738
|
training_profile: ['get_training_profile'],
|
|
647
739
|
records: ['get_records'],
|
|
648
740
|
recent_session: ['get_recent_sessions'],
|
|
@@ -857,17 +949,29 @@ function appendReadinessSummary(lines, readiness) {
|
|
|
857
949
|
// Per-route prose builders that compose tool results into the routed
|
|
858
950
|
// Ask Coach context, attaching provenance for each section.
|
|
859
951
|
|
|
952
|
+
function appendWeeklyVolumeEvidence(lines, weeklyVolume) {
|
|
953
|
+
const facts = weeklyVolume?.facts ?? {};
|
|
954
|
+
const isPartial = facts.currentWeekIsPartial === true;
|
|
955
|
+
const currentLabel = isPartial ? 'Week-to-date strength volume' : 'This week strength volume';
|
|
956
|
+
const previousLabel = 'Previous full week strength volume';
|
|
957
|
+
lines.push(`${currentLabel}: ${facts.currentWeekVolume} kg across ${facts.currentWeekSessionCount} session${facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
|
|
958
|
+
lines.push(`${previousLabel}: ${facts.previousWeekVolume} kg across ${facts.previousWeekSessionCount} session${facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
|
|
959
|
+
if (facts.deltaPct != null) {
|
|
960
|
+
const prefix = facts.deltaPct >= 0 ? '+' : '';
|
|
961
|
+
const comparison = isPartial
|
|
962
|
+
? 'Week-to-date versus previous full week strength volume'
|
|
963
|
+
: 'Week-over-week strength volume change';
|
|
964
|
+
lines.push(`${comparison}: ${prefix}${facts.deltaPct}%.`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
860
968
|
function buildVolumeAskContext(snapshot, { exclude = new Set(), today = new Date() } = {}) {
|
|
861
969
|
const lines = [];
|
|
862
970
|
const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
|
|
863
971
|
pushAskContextHeader(lines, snapshot, today);
|
|
864
972
|
|
|
865
973
|
lines.push('');
|
|
866
|
-
lines
|
|
867
|
-
lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
|
|
868
|
-
if (weeklyVolume.facts.deltaPct != null) {
|
|
869
|
-
lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
|
|
870
|
-
}
|
|
974
|
+
appendWeeklyVolumeEvidence(lines, weeklyVolume);
|
|
871
975
|
const thisWeekRows = weeklyVolume.rows.filter((row) => row.week === 'current');
|
|
872
976
|
if (thisWeekRows.length > 0) {
|
|
873
977
|
lines.push('This week sessions:');
|
|
@@ -911,10 +1015,48 @@ function buildIncrementScoreAskContext(snapshot, { exclude = new Set(), today =
|
|
|
911
1015
|
}
|
|
912
1016
|
|
|
913
1017
|
function formattedCompletedSets(sets = []) {
|
|
914
|
-
|
|
1018
|
+
const groups = [];
|
|
1019
|
+
for (const set of sets) {
|
|
1020
|
+
const weight = Number(set.weight) || 0;
|
|
1021
|
+
const label = weight > 0 ? `${formatCompactWeight(weight)}kg` : 'BW';
|
|
1022
|
+
const previous = groups.at(-1);
|
|
1023
|
+
if (previous?.label === label) {
|
|
1024
|
+
previous.reps.push(set.reps);
|
|
1025
|
+
} else {
|
|
1026
|
+
groups.push({ label, reps: [set.reps] });
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return groups.map((group) => `${group.label}: ${group.reps.join('/')}`).join(', ');
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function formattedCompletedSetShorthand(sets = []) {
|
|
1033
|
+
const groups = [];
|
|
1034
|
+
for (const set of sets) {
|
|
915
1035
|
const weight = Number(set.weight) || 0;
|
|
916
|
-
|
|
917
|
-
|
|
1036
|
+
const label = weight > 0 ? formatCompactWeight(weight) : 'BW';
|
|
1037
|
+
const previous = groups.at(-1);
|
|
1038
|
+
if (previous?.label === label) {
|
|
1039
|
+
previous.reps.push(set.reps);
|
|
1040
|
+
} else {
|
|
1041
|
+
groups.push({ label, reps: [set.reps] });
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return groups.map((group) => `${group.label}x${group.reps.join('/')}`).join(', ');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function formattedSetShorthandList(sets = []) {
|
|
1048
|
+
return sets
|
|
1049
|
+
.map((set) => {
|
|
1050
|
+
const weight = Number(set.weight) || 0;
|
|
1051
|
+
const label = weight > 0 ? formatCompactWeight(weight) : 'BW';
|
|
1052
|
+
return `${label}x${set.reps}`;
|
|
1053
|
+
})
|
|
1054
|
+
.join(', ');
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function formatCompactWeight(weight) {
|
|
1058
|
+
const rounded = Math.round(Number(weight) * 10) / 10;
|
|
1059
|
+
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
|
918
1060
|
}
|
|
919
1061
|
|
|
920
1062
|
function formatRepDelta(delta) {
|
|
@@ -971,6 +1113,11 @@ function formatComparableSetDelta(exercise) {
|
|
|
971
1113
|
if (currentSets.length !== previousSets.length || currentTotalReps !== previousTotalReps) {
|
|
972
1114
|
details.push(`total reps ${currentTotalReps} vs ${previousTotalReps}${currentSets.length !== previousSets.length ? ` across ${currentSets.length} vs ${previousSets.length} sets` : ''}`);
|
|
973
1115
|
}
|
|
1116
|
+
const currentSetList = formattedSetShorthandList(currentSets);
|
|
1117
|
+
const previousSetList = formattedSetShorthandList(previousSets);
|
|
1118
|
+
if (currentSetList && previousSetList) {
|
|
1119
|
+
details.push(`current sets ${currentSetList}; previous sets ${previousSetList}`);
|
|
1120
|
+
}
|
|
974
1121
|
if (regressionFlag) {
|
|
975
1122
|
details.push('regression flag: reps dropped sharply despite the load/set context');
|
|
976
1123
|
}
|
|
@@ -1061,6 +1208,83 @@ function buildNextSessionAskContext(snapshot, { exclude = new Set(), today = new
|
|
|
1061
1208
|
return { context: lines.join('\n'), sections, tools: [nextSession], provenance: [coachToolProvenance('next_session_plan', nextSession)] };
|
|
1062
1209
|
}
|
|
1063
1210
|
|
|
1211
|
+
function mondayWeekStartDateOnly(today = new Date(), weekOffset = 0) {
|
|
1212
|
+
const base = new Date(`${dateOnlyString(today)}T00:00:00.000Z`);
|
|
1213
|
+
const day = base.getUTCDay();
|
|
1214
|
+
const daysSinceMonday = (day + 6) % 7;
|
|
1215
|
+
base.setUTCDate(base.getUTCDate() - daysSinceMonday + weekOffset * 7);
|
|
1216
|
+
return base.toISOString().slice(0, 10);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function deloadScheduleStartDate(question, today = new Date(), scheduleContext = null) {
|
|
1220
|
+
if (/\bthis\s+(?:training\s+)?week\b/i.test(question ?? '')) {
|
|
1221
|
+
return mondayWeekStartDateOnly(today, 0);
|
|
1222
|
+
}
|
|
1223
|
+
if (/\b(?:next|coming)\s+(?:training\s+)?week\b/i.test(question ?? '')) {
|
|
1224
|
+
return mondayWeekStartDateOnly(today, 1);
|
|
1225
|
+
}
|
|
1226
|
+
return scheduleContext?.week === 'this'
|
|
1227
|
+
? mondayWeekStartDateOnly(today, 0)
|
|
1228
|
+
: mondayWeekStartDateOnly(today, 1);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function appendProgramScheduleActionRequest(lines, { startDate, hasActiveProgram }) {
|
|
1232
|
+
lines.push('');
|
|
1233
|
+
lines.push('Program schedule action request:');
|
|
1234
|
+
if (!hasActiveProgram) {
|
|
1235
|
+
lines.push(' No active program is available. Do not append a <program_schedule_action> block.');
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
lines.push(' The user wants to schedule a one-week whole-program deload. This is not a new program draft and not an exercise-specific plan changeset.');
|
|
1239
|
+
lines.push(' If the request is clear, keep prose to 1-2 short sentences and append exactly one trailing <program_schedule_action>{JSON}</program_schedule_action>.');
|
|
1240
|
+
lines.push(` Use this exact JSON shape: {"action":"schedule_deload_week","startDate":"${startDate}","durationWeeks":1,"rationale":"..."}.`);
|
|
1241
|
+
lines.push(' The app will compute the weight changes when the week starts. Do not include weights, reps, set counts, deltas, targets, or any extra JSON keys.');
|
|
1242
|
+
lines.push(' Do not append a <program_draft> or <plan_changeset> block.');
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function buildProgramScheduleActionAskContext(snapshot, question, { exclude = new Set(), today = new Date(), scheduleContext = null } = {}) {
|
|
1246
|
+
const lines = [];
|
|
1247
|
+
const programProgress = executeCoachReadTool(snapshot, 'get_program_progress', { since: null, today, limitExercises: 10 });
|
|
1248
|
+
const recentSessions = executeCoachReadTool(snapshot, 'get_recent_sessions', { limit: 5, today });
|
|
1249
|
+
const readiness = executeCoachReadTool(snapshot, 'get_readiness_snapshot', { today });
|
|
1250
|
+
const program = activeProgram(snapshot);
|
|
1251
|
+
const startDate = deloadScheduleStartDate(question, today, scheduleContext);
|
|
1252
|
+
|
|
1253
|
+
pushAskContextHeader(lines, snapshot, today);
|
|
1254
|
+
lines.push('');
|
|
1255
|
+
lines.push(`Program schedule target: ${program?.name ?? 'No active program'}.`);
|
|
1256
|
+
const trainingLoad = programProgress.facts?.trainingLoad;
|
|
1257
|
+
if (trainingLoad) {
|
|
1258
|
+
const readinessBand = trainingLoad.readiness?.readinessBand ? `, readiness ${trainingLoad.readiness.readinessBand}` : '';
|
|
1259
|
+
lines.push(`Training-load signal: status ${trainingLoad.status ?? 'unknown'}${readinessBand}.`);
|
|
1260
|
+
}
|
|
1261
|
+
if (readiness.facts?.readinessBand) {
|
|
1262
|
+
lines.push(`Readiness signal: ${readiness.facts.readinessBand}.`);
|
|
1263
|
+
}
|
|
1264
|
+
appendActiveProgramScheduleContext(lines, snapshot);
|
|
1265
|
+
if (recentSessions.rows?.length > 0) {
|
|
1266
|
+
lines.push('');
|
|
1267
|
+
lines.push('Recent sessions:');
|
|
1268
|
+
for (const row of recentSessions.rows.slice(0, 5)) {
|
|
1269
|
+
const title = row.title ?? row.dayName ?? row.programDayTitle ?? 'Workout';
|
|
1270
|
+
lines.push(` ${row.date ?? 'unknown date'} - ${title}`);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
appendProgramScheduleActionRequest(lines, { startDate, hasActiveProgram: Boolean(program) });
|
|
1274
|
+
appendExcludeNote(lines, exclude);
|
|
1275
|
+
return {
|
|
1276
|
+
context: lines.join('\n'),
|
|
1277
|
+
sections: ['header', 'program_schedule_action', 'current_program_schedule', 'recent_sessions'],
|
|
1278
|
+
programScheduleActionStartDate: startDate,
|
|
1279
|
+
tools: [programProgress, recentSessions, readiness],
|
|
1280
|
+
provenance: [
|
|
1281
|
+
coachToolProvenance('program_schedule_program_progress', programProgress),
|
|
1282
|
+
coachToolProvenance('program_schedule_recent_sessions', recentSessions),
|
|
1283
|
+
coachToolProvenance('program_schedule_readiness', readiness)
|
|
1284
|
+
]
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1064
1288
|
function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = new Set(), today = new Date() } = {}) {
|
|
1065
1289
|
const lines = [];
|
|
1066
1290
|
const exerciseHistoryTool = executeCoachReadTool(snapshot, 'get_exercise_history', { exercises: namedExercises, limit: 6, today });
|
|
@@ -1079,7 +1303,9 @@ function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = n
|
|
|
1079
1303
|
for (const row of exerciseHistoryTool.rows) {
|
|
1080
1304
|
const comparison = formatTopSetComparison(row);
|
|
1081
1305
|
const warmups = row.warmupSetCount > 0 ? `; ${row.warmupSetCount} warmup set${row.warmupSetCount === 1 ? '' : 's'} excluded` : '';
|
|
1082
|
-
|
|
1306
|
+
const shorthand = formattedCompletedSetShorthand(row.sets);
|
|
1307
|
+
lines.push(` ${row.date}${formatRecencySuffix(row)} - ${row.exerciseName}: ${formattedCompletedSets(row.sets)}${shorthand ? ` (compact: ${shorthand})` : ''}${comparison ? `; ${comparison}` : ''}${warmups}`);
|
|
1308
|
+
if (row.recommendation) lines.push(` Recommendation after session: ${formatRecommendation(row.recommendation)}`);
|
|
1083
1309
|
}
|
|
1084
1310
|
appendExerciseHistoryNotes(lines, exerciseHistoryTool.rows);
|
|
1085
1311
|
}
|
|
@@ -1091,9 +1317,21 @@ function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = n
|
|
|
1091
1317
|
|
|
1092
1318
|
function formatProgressPoint(point) {
|
|
1093
1319
|
if (!point) return 'unknown';
|
|
1094
|
-
const load = Number(point.weight) > 0 ? `${point.weight}x${point.reps}` : `BWx${point.reps}`;
|
|
1095
|
-
|
|
1096
|
-
|
|
1320
|
+
const load = Number(point.weight) > 0 ? `${formatCompactWeight(point.weight)}x${point.reps}` : `BWx${point.reps}`;
|
|
1321
|
+
return `${point.date}: ${load}`;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function progressVerdict(row) {
|
|
1325
|
+
const bestDelta = Number(row?.bestDeltaFromFirst);
|
|
1326
|
+
const latestDelta = Number(row?.latestDeltaFromFirst);
|
|
1327
|
+
const latestFromBest = Number(row?.latestDeltaFromBest);
|
|
1328
|
+
if (Number.isFinite(bestDelta) && bestDelta > 0 && Number.isFinite(latestFromBest) && latestFromBest < 0) {
|
|
1329
|
+
return 'peaked early, then dipped';
|
|
1330
|
+
}
|
|
1331
|
+
if (Number.isFinite(latestDelta) && latestDelta > 0) return 'latest is up from first';
|
|
1332
|
+
if (Number.isFinite(latestDelta) && latestDelta < 0) return 'latest is down from first';
|
|
1333
|
+
if (Number.isFinite(latestDelta)) return 'latest matches first';
|
|
1334
|
+
return null;
|
|
1097
1335
|
}
|
|
1098
1336
|
|
|
1099
1337
|
function appendProgressSummaryRows(lines, rows = []) {
|
|
@@ -1111,7 +1349,8 @@ function appendProgressSummaryRows(lines, rows = []) {
|
|
|
1111
1349
|
const latestDropFromBest = row.latestDeltaFromBest == null
|
|
1112
1350
|
? ''
|
|
1113
1351
|
: `, latest vs best ${row.latestDeltaFromBest > 0 ? '+' : ''}${row.latestDeltaFromBest}`;
|
|
1114
|
-
|
|
1352
|
+
const verdict = progressVerdict(row);
|
|
1353
|
+
lines.push(` ${row.exerciseName}: first ${formatProgressPoint(row.first)}; best ${formatProgressPoint(row.best)}; latest ${formatProgressPoint(row.latest)}${bestDelta}${latestDelta}${latestDropFromBest}${verdict ? `; tracking verdict: ${verdict}` : ''}.`);
|
|
1115
1354
|
}
|
|
1116
1355
|
}
|
|
1117
1356
|
|
|
@@ -1209,6 +1448,37 @@ function buildProgramProgressAskContext(snapshot, { exclude = new Set(), since =
|
|
|
1209
1448
|
};
|
|
1210
1449
|
}
|
|
1211
1450
|
|
|
1451
|
+
function buildProgramHistoryAskContext(snapshot, { exclude = new Set(), today = new Date() } = {}) {
|
|
1452
|
+
const lines = [];
|
|
1453
|
+
const history = executeCoachReadTool(snapshot, 'get_program_change_history', { limit: 20 });
|
|
1454
|
+
pushAskContextHeader(lines, snapshot, today);
|
|
1455
|
+
lines.push('');
|
|
1456
|
+
lines.push(`Program change history: ${history.facts?.programName ?? 'No active program'}.`);
|
|
1457
|
+
if (history.rows.length === 0) {
|
|
1458
|
+
lines.push(' No durable program change records are available yet.');
|
|
1459
|
+
} else {
|
|
1460
|
+
for (const change of history.rows.slice(0, 10)) {
|
|
1461
|
+
const when = change.createdAt ? String(change.createdAt).slice(0, 10) : 'unknown date';
|
|
1462
|
+
const source = change.source ? `, source ${change.source}` : '';
|
|
1463
|
+
const status = change.status ? `, status ${change.status}` : '';
|
|
1464
|
+
lines.push(` ${when}: ${change.summary ?? change.kind ?? 'Program changed'} (${change.kind ?? 'unknown'}${source}${status}, id ${change.id ?? 'unknown'}).`);
|
|
1465
|
+
const exercises = change.affectedExercises?.length ? `exercises ${change.affectedExercises.slice(0, 4).join(', ')}` : null;
|
|
1466
|
+
const fields = change.affectedFields?.length ? `fields ${change.affectedFields.slice(0, 5).join(', ')}` : null;
|
|
1467
|
+
if (exercises || fields) lines.push(` Affected: ${[exercises, fields].filter(Boolean).join('; ')}.`);
|
|
1468
|
+
if (change.rationale) lines.push(` Rationale: ${change.rationale}`);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
lines.push('');
|
|
1472
|
+
lines.push('Restore status: automatic restore is not available in this slice. You may identify the likely change to restore, but do not claim the app restored or changed the program.');
|
|
1473
|
+
appendExcludeNote(lines, exclude);
|
|
1474
|
+
return {
|
|
1475
|
+
context: lines.join('\n'),
|
|
1476
|
+
sections: ['header', 'program_change_history'],
|
|
1477
|
+
tools: [history],
|
|
1478
|
+
provenance: [coachToolProvenance('program_change_history', history)]
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1212
1482
|
function buildTrainingProfileAskContext(snapshot, { exclude = new Set(), since = null, today = new Date() } = {}) {
|
|
1213
1483
|
const lines = [];
|
|
1214
1484
|
const trainingProfile = executeCoachReadTool(snapshot, 'get_training_profile', { since, today });
|
|
@@ -1345,9 +1615,11 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1345
1615
|
exclude = new Set(),
|
|
1346
1616
|
today = new Date(),
|
|
1347
1617
|
namedExercises = [],
|
|
1348
|
-
existingSections = []
|
|
1618
|
+
existingSections = [],
|
|
1619
|
+
omitSections = []
|
|
1349
1620
|
} = {}) {
|
|
1350
1621
|
const sections = new Set(existingSections);
|
|
1622
|
+
const omitted = new Set(omitSections);
|
|
1351
1623
|
const note = buildExcludeNote(exclude);
|
|
1352
1624
|
const noteAtEnd = note && lines.at(-1) === note;
|
|
1353
1625
|
if (noteAtEnd) {
|
|
@@ -1364,24 +1636,20 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1364
1636
|
addedSections.push(section);
|
|
1365
1637
|
};
|
|
1366
1638
|
|
|
1367
|
-
if (!sections.has('increment_score')) {
|
|
1639
|
+
if (!sections.has('increment_score') && !omitted.has('increment_score')) {
|
|
1368
1640
|
const incrementScore = executeCoachReadTool(snapshot, 'get_increment_score', { historyDays: 21 });
|
|
1369
1641
|
appendIncrementScoreEvidence(lines, incrementScore);
|
|
1370
1642
|
addTool('increment_score', incrementScore);
|
|
1371
1643
|
}
|
|
1372
1644
|
|
|
1373
|
-
if (!sections.has('weekly_volume')) {
|
|
1645
|
+
if (!sections.has('weekly_volume') && !omitted.has('weekly_volume')) {
|
|
1374
1646
|
const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
|
|
1375
1647
|
lines.push('');
|
|
1376
|
-
lines
|
|
1377
|
-
lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
|
|
1378
|
-
if (weeklyVolume.facts.deltaPct != null) {
|
|
1379
|
-
lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
|
|
1380
|
-
}
|
|
1648
|
+
appendWeeklyVolumeEvidence(lines, weeklyVolume);
|
|
1381
1649
|
addTool('weekly_volume', weeklyVolume);
|
|
1382
1650
|
}
|
|
1383
1651
|
|
|
1384
|
-
if (!sections.has('records')) {
|
|
1652
|
+
if (!sections.has('records') && !omitted.has('records')) {
|
|
1385
1653
|
const records = executeCoachReadTool(snapshot, 'get_records', {
|
|
1386
1654
|
exercises: namedExercises,
|
|
1387
1655
|
limit: namedExercises.length > 0 ? Math.max(5, namedExercises.length) : 10,
|
|
@@ -1391,7 +1659,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1391
1659
|
addTool('records', records);
|
|
1392
1660
|
}
|
|
1393
1661
|
|
|
1394
|
-
if (!sections.has('body_weight')) {
|
|
1662
|
+
if (!sections.has('body_weight') && !omitted.has('body_weight')) {
|
|
1395
1663
|
const bodyWeight = executeCoachReadTool(snapshot, 'get_body_weight_snapshot', {
|
|
1396
1664
|
recentDays: 30,
|
|
1397
1665
|
exclude: [...exclude],
|
|
@@ -1401,7 +1669,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1401
1669
|
addTool('body_weight', bodyWeight);
|
|
1402
1670
|
}
|
|
1403
1671
|
|
|
1404
|
-
if (!sections.has('readiness')) {
|
|
1672
|
+
if (!sections.has('readiness') && !omitted.has('readiness')) {
|
|
1405
1673
|
const readiness = executeCoachReadTool(snapshot, 'get_readiness_snapshot', {
|
|
1406
1674
|
recentDays: 14,
|
|
1407
1675
|
exclude: [...exclude],
|
|
@@ -1411,7 +1679,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1411
1679
|
addTool('readiness', readiness);
|
|
1412
1680
|
}
|
|
1413
1681
|
|
|
1414
|
-
if (!sections.has('goal_status')) {
|
|
1682
|
+
if (!sections.has('goal_status') && !omitted.has('goal_status')) {
|
|
1415
1683
|
const goalStatus = executeCoachReadTool(snapshot, 'get_goal_status', { limit: 5 });
|
|
1416
1684
|
appendGoalStatusEvidence(lines, goalStatus);
|
|
1417
1685
|
addTool('goal_status', goalStatus);
|
|
@@ -1468,6 +1736,7 @@ function buildRecentSessionAskContext(snapshot, { exclude = new Set(), today = n
|
|
|
1468
1736
|
const setsStr = formattedCompletedSets(exercise.sets);
|
|
1469
1737
|
const warmups = exercise.warmupSetCount > 0 ? `; ${exercise.warmupSetCount} warmup set${exercise.warmupSetCount === 1 ? '' : 's'} excluded` : '';
|
|
1470
1738
|
if (setsStr) lines.push(` ${exercise.name}: ${setsStr}${warmups}`);
|
|
1739
|
+
if (exercise.recommendation) lines.push(` Recommendation after session: ${formatRecommendation(exercise.recommendation)}`);
|
|
1471
1740
|
const setDelta = formatComparableSetDelta(exercise);
|
|
1472
1741
|
if (setDelta) lines.push(` ${setDelta}`);
|
|
1473
1742
|
}
|
|
@@ -1636,11 +1905,7 @@ function buildProgressReviewAskContext(snapshot, { exclude = new Set(), since =
|
|
|
1636
1905
|
|
|
1637
1906
|
// Weekly volume with week-over-week direction.
|
|
1638
1907
|
lines.push('');
|
|
1639
|
-
lines
|
|
1640
|
-
lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
|
|
1641
|
-
if (weeklyVolume.facts.deltaPct != null) {
|
|
1642
|
-
lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
|
|
1643
|
-
}
|
|
1908
|
+
appendWeeklyVolumeEvidence(lines, weeklyVolume);
|
|
1644
1909
|
|
|
1645
1910
|
// Per-session top sets so the model can see real progression, not just names.
|
|
1646
1911
|
const recent = recentSessions.rows.slice().reverse();
|
|
@@ -1965,10 +2230,13 @@ function confidenceBand(intentConfidence, missingDataFlags = []) {
|
|
|
1965
2230
|
return 'high';
|
|
1966
2231
|
}
|
|
1967
2232
|
|
|
1968
|
-
function recommendedActionsForAsk(route, requestedAction, programDraft) {
|
|
2233
|
+
function recommendedActionsForAsk(route, requestedAction, programDraft, programScheduleAction = null) {
|
|
1969
2234
|
if (programDraft) {
|
|
1970
2235
|
return [{ id: 'review-program-draft', label: 'Review drafted plan', kind: 'program_draft' }];
|
|
1971
2236
|
}
|
|
2237
|
+
if (programScheduleAction) {
|
|
2238
|
+
return [{ id: 'review-program-schedule-action', label: 'Review scheduled deload', kind: 'program_schedule_action' }];
|
|
2239
|
+
}
|
|
1972
2240
|
if (requestedAction === 'draft_plan') {
|
|
1973
2241
|
return [{ id: 'ask-for-plan-draft', label: 'Ask for a plan draft', kind: 'follow_up' }];
|
|
1974
2242
|
}
|
|
@@ -2048,10 +2316,11 @@ function followUpSuggestionsForAsk(route, intent, { question = '', missingDataFl
|
|
|
2048
2316
|
volume: ['What should I do next session?', 'Is this too much weekly volume?'],
|
|
2049
2317
|
next_session: ['What should I watch for during that session?', 'Should I adjust the first exercise?'],
|
|
2050
2318
|
recovery: ['Should I train tomorrow?', 'What would be a conservative version?'],
|
|
2051
|
-
recent_session: ['
|
|
2319
|
+
recent_session: ['What should I take from that session?', 'What should I aim for next time?', 'What should I change next time?'],
|
|
2052
2320
|
body_weight: bodyWeightFollowUpCandidates(missingDataFlags),
|
|
2053
2321
|
score: ['What is pulling my score down?', 'What should I focus on this week?'],
|
|
2054
2322
|
program_progress: ['Pull this block summary.', 'Break down a specific lift.', 'What is the next decision?'],
|
|
2323
|
+
program_history: ['What changed most recently?', 'Why did that change happen?', 'Can that be restored later?'],
|
|
2055
2324
|
program_design: ['Make this plan more conservative.', 'Explain the main changes.']
|
|
2056
2325
|
};
|
|
2057
2326
|
let candidates;
|
|
@@ -2106,7 +2375,7 @@ export function sanitizeAskAnswerVerificationReceipt(value) {
|
|
|
2106
2375
|
return Object.keys(result).length > 0 ? result : null;
|
|
2107
2376
|
}
|
|
2108
2377
|
|
|
2109
|
-
export function buildAskStructuredResponse(answer, routingMetadata = {}, { programDraft = null, planChangeset = null, question = '' } = {}) {
|
|
2378
|
+
export function buildAskStructuredResponse(answer, routingMetadata = {}, { programDraft = null, planChangeset = null, programScheduleAction = null, question = '' } = {}) {
|
|
2110
2379
|
const contextBundle = routingMetadata.contextBundle ?? {};
|
|
2111
2380
|
const intent = routingMetadata.intent ?? contextBundle.intent ?? {};
|
|
2112
2381
|
const answerVerification = sanitizeAskAnswerVerificationReceipt(routingMetadata.answerVerification);
|
|
@@ -2120,12 +2389,13 @@ export function buildAskStructuredResponse(answer, routingMetadata = {}, { progr
|
|
|
2120
2389
|
answer,
|
|
2121
2390
|
confidence,
|
|
2122
2391
|
evidenceUsed: contextBundle.evidenceUsed ?? evidenceUsedFromProvenance(routingMetadata.provenance ?? [], routingMetadata.tools ?? []),
|
|
2123
|
-
recommendedActions: recommendedActionsForAsk(intent.route ?? routingMetadata.route, intent.requestedAction, programDraft),
|
|
2392
|
+
recommendedActions: recommendedActionsForAsk(intent.route ?? routingMetadata.route, intent.requestedAction, programDraft, programScheduleAction),
|
|
2124
2393
|
followUpSuggestions: followUpSuggestionsForAsk(intent.route ?? routingMetadata.route, intent, { question, missingDataFlags }),
|
|
2125
2394
|
limitations: missingDataFlags.filter((flag) => !HIDDEN_LIMITATION_FLAGS.has(flag)).map(limitationText),
|
|
2126
2395
|
answerVerification,
|
|
2127
2396
|
programDraft: programDraft ?? null,
|
|
2128
|
-
planChangeset: planChangeset ?? null
|
|
2397
|
+
planChangeset: planChangeset ?? null,
|
|
2398
|
+
programScheduleAction: programScheduleAction ?? null
|
|
2129
2399
|
};
|
|
2130
2400
|
}
|
|
2131
2401
|
|
|
@@ -2229,6 +2499,7 @@ function appendAskAnswerContract(lines, {
|
|
|
2229
2499
|
namedExerciseLabels = [],
|
|
2230
2500
|
builtTools = [],
|
|
2231
2501
|
sessionObservationComparisons = [],
|
|
2502
|
+
includedFacts = [],
|
|
2232
2503
|
question = ''
|
|
2233
2504
|
} = {}) {
|
|
2234
2505
|
const note = buildExcludeNote(new Set());
|
|
@@ -2241,6 +2512,15 @@ function appendAskAnswerContract(lines, {
|
|
|
2241
2512
|
const contract = [];
|
|
2242
2513
|
const fullExerciseNames = namedExerciseLabels.filter(Boolean);
|
|
2243
2514
|
const text = String(question ?? '').toLowerCase();
|
|
2515
|
+
const toneFacts = includedFacts
|
|
2516
|
+
.filter((fact) => fact.kind === 'tone')
|
|
2517
|
+
.map((fact) => fact.fact)
|
|
2518
|
+
.filter(Boolean);
|
|
2519
|
+
|
|
2520
|
+
if (fullExerciseNames.length > 0) {
|
|
2521
|
+
contract.push('Answer contract: named exercise identity.');
|
|
2522
|
+
contract.push(` Use the exact exercise name(s): ${fullExerciseNames.join(', ')}.`);
|
|
2523
|
+
}
|
|
2244
2524
|
|
|
2245
2525
|
if (responseProfile === ASK_RESPONSE_PROFILES.defensive) {
|
|
2246
2526
|
contract.push('Answer contract: defensive decision.');
|
|
@@ -2248,12 +2528,17 @@ function appendAskAnswerContract(lines, {
|
|
|
2248
2528
|
contract.push(' Name the relevant exercise exactly as written in the evidence.');
|
|
2249
2529
|
contract.push(' Use compact set notation from the evidence when citing sets, e.g. 70x5 or 67.5x7.');
|
|
2250
2530
|
contract.push(' Do not mention record estimates or PRs unless the user explicitly asked about them.');
|
|
2251
|
-
contract.push(' If the latest relevant session is older than 14 days, do not use the word "recent"; say "latest logged" or give the days-ago label.');
|
|
2531
|
+
contract.push(' If the latest relevant session is older than 14 days, do not use the word "recent" anywhere; say "latest logged", "stale", or give the days-ago label.');
|
|
2252
2532
|
if (fullExerciseNames.length > 0) {
|
|
2253
2533
|
contract.push(` Relevant exercise name(s) to preserve: ${fullExerciseNames.join(', ')}.`);
|
|
2254
2534
|
}
|
|
2255
2535
|
}
|
|
2256
2536
|
|
|
2537
|
+
if (toneFacts.some((fact) => /\bconcise\b/i.test(fact))) {
|
|
2538
|
+
contract.push('Answer contract: typed tone fact.');
|
|
2539
|
+
contract.push(' Mention the user preference for concise coaching cues using the word "concise".');
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2257
2542
|
if (route === 'progress_review') {
|
|
2258
2543
|
const weeklyVolume = builtTools.find((tool) => tool.toolName === 'get_weekly_volume');
|
|
2259
2544
|
const recentSessions = builtTools.find((tool) => tool.toolName === 'get_recent_sessions');
|
|
@@ -2263,6 +2548,7 @@ function appendAskAnswerContract(lines, {
|
|
|
2263
2548
|
const previousSessions = weeklyVolume?.facts?.previousWeekSessionCount;
|
|
2264
2549
|
const strengthSessionCount = recentSessions?.rows?.length;
|
|
2265
2550
|
const volumeDeltaPct = weeklyVolume?.facts?.deltaPct;
|
|
2551
|
+
const currentWeekIsPartial = weeklyVolume?.facts?.currentWeekIsPartial === true;
|
|
2266
2552
|
const recentRecordCount = records?.facts?.recentRecordCount;
|
|
2267
2553
|
const readinessFacts = readiness?.facts ?? {};
|
|
2268
2554
|
const readinessPhrases = [
|
|
@@ -2276,10 +2562,14 @@ function appendAskAnswerContract(lines, {
|
|
|
2276
2562
|
if (Number.isFinite(Number(strengthSessionCount)) && Number(strengthSessionCount) > 0) {
|
|
2277
2563
|
contract.push(` Include this exact strength-session phrase: "${strengthSessionCount} sessions".`);
|
|
2278
2564
|
}
|
|
2279
|
-
if (
|
|
2565
|
+
if (
|
|
2566
|
+
Number.isFinite(Number(currentSessions))
|
|
2567
|
+
&& Number.isFinite(Number(previousSessions))
|
|
2568
|
+
&& Number(currentSessions) === Number(previousSessions)
|
|
2569
|
+
) {
|
|
2280
2570
|
contract.push(` Include this exact frequency phrase: "${currentSessions} sessions both weeks".`);
|
|
2281
2571
|
}
|
|
2282
|
-
if (Number.isFinite(Number(volumeDeltaPct))) {
|
|
2572
|
+
if (Number.isFinite(Number(volumeDeltaPct)) && !currentWeekIsPartial) {
|
|
2283
2573
|
const direction = Number(volumeDeltaPct) < 0 ? 'drop' : 'increase';
|
|
2284
2574
|
contract.push(` Include this exact weekly volume phrase: "${Math.abs(Number(volumeDeltaPct))}% ${direction}".`);
|
|
2285
2575
|
}
|
|
@@ -2295,6 +2585,14 @@ function appendAskAnswerContract(lines, {
|
|
|
2295
2585
|
contract.push(' End with this goal-clarifying question when no clear goal decides the tradeoff: "What are we measuring this against - size, strength, or staying lean?"');
|
|
2296
2586
|
}
|
|
2297
2587
|
|
|
2588
|
+
if (route === 'program_history') {
|
|
2589
|
+
contract.push('Answer contract: program change history.');
|
|
2590
|
+
contract.push(' Answer only from the durable program change history evidence.');
|
|
2591
|
+
contract.push(' If the user asks to undo, revert, restore, or change it back, say automatic restore is not available yet in this slice.');
|
|
2592
|
+
contract.push(' Identify the likely target change by date, summary, kind, and id when the evidence supports it.');
|
|
2593
|
+
contract.push(' Do not claim the program was changed, restored, reverted, or updated.');
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2298
2596
|
if (route === 'recent_session' && sessionObservationComparisons.length > 0) {
|
|
2299
2597
|
contract.push('Answer contract: current session plus durable observations.');
|
|
2300
2598
|
contract.push(' Say what improved in the current session first.');
|
|
@@ -2306,7 +2604,7 @@ function appendAskAnswerContract(lines, {
|
|
|
2306
2604
|
contract.push('Answer contract: verify the alleged drop-off against logged sets.');
|
|
2307
2605
|
contract.push(' Lead by accepting or rejecting the premise from logged working sets.');
|
|
2308
2606
|
contract.push(' If current working top load is higher than the prior comparable session, say it increased and do not describe the lift as declining.');
|
|
2309
|
-
contract.push(' When rejecting the premise because top load increased, avoid the words "drop-off", "dropping off", "decline", "declining", "regress", or "regressing" in the answer.');
|
|
2607
|
+
contract.push(' When rejecting the premise because top load increased, avoid the words "drop-off", "dropping off", "decline", "declining", "falling", "regress", or "regressing" in the answer.');
|
|
2310
2608
|
contract.push(' Mention warmups separately when the evidence marks warmup sets excluded.');
|
|
2311
2609
|
contract.push(' Do not mention record estimates unless the user asked for them.');
|
|
2312
2610
|
}
|
|
@@ -2388,6 +2686,44 @@ function compactExerciseRows(items, key = 'exercise') {
|
|
|
2388
2686
|
.map((name) => compactEvidenceRow(name, 'relevant lift history available'));
|
|
2389
2687
|
}
|
|
2390
2688
|
|
|
2689
|
+
function muscleVolumeTrendEvidenceRows(evidence) {
|
|
2690
|
+
const rows = Array.isArray(evidence?.muscles)
|
|
2691
|
+
? evidence.muscles
|
|
2692
|
+
: (evidence?.muscle ? [{ muscle: evidence.muscle }] : []);
|
|
2693
|
+
const isPartial = evidence?.currentWeek?.isPartial === true || evidence?.currentWeekIsPartial === true;
|
|
2694
|
+
return rows
|
|
2695
|
+
.slice(0, 3)
|
|
2696
|
+
.map((row) => {
|
|
2697
|
+
const muscle = String(row?.muscle ?? '').trim();
|
|
2698
|
+
if (!muscle) return null;
|
|
2699
|
+
return compactEvidenceRow(muscle, muscleVolumeTrendEvidenceValue(row, { isPartial }));
|
|
2700
|
+
})
|
|
2701
|
+
.filter(Boolean);
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
function muscleVolumeTrendEvidenceValue(row, { isPartial = false } = {}) {
|
|
2705
|
+
const pieces = [];
|
|
2706
|
+
const share = wholePercent(row?.latestSharePct);
|
|
2707
|
+
if (share) pieces.push(`${share} of ${isPartial ? 'this week-to-date volume' : "this week's volume"}`);
|
|
2708
|
+
const delta = directionalPercent(row?.deltaVsPriorAvgPct);
|
|
2709
|
+
if (delta) pieces.push(delta);
|
|
2710
|
+
return pieces.length > 0 ? pieces.join(' · ') : 'muscle breakdown available';
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
function wholePercent(value) {
|
|
2714
|
+
const number = Number(value);
|
|
2715
|
+
if (!Number.isFinite(number)) return null;
|
|
2716
|
+
return `${Math.round(number)}%`;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
function directionalPercent(value) {
|
|
2720
|
+
const number = Number(value);
|
|
2721
|
+
if (!Number.isFinite(number)) return null;
|
|
2722
|
+
const rounded = Math.round(number);
|
|
2723
|
+
if (rounded === 0) return 'flat versus recent weeks';
|
|
2724
|
+
return `${rounded > 0 ? 'up' : 'down'} ${Math.abs(rounded)}%`;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2391
2727
|
function humanObservationEvidenceRows(observation) {
|
|
2392
2728
|
const evidence = observation?.evidence;
|
|
2393
2729
|
if (!evidence || typeof evidence !== 'object') return [];
|
|
@@ -2399,6 +2735,8 @@ function humanObservationEvidenceRows(observation) {
|
|
|
2399
2735
|
rows.push(compactEvidenceRow('Weekly score', `${Math.round(Number(evidence.latestScore))} from ${Math.round(Number(evidence.previousScore))}`));
|
|
2400
2736
|
}
|
|
2401
2737
|
rows.push(compactEvidenceRow('Change', signedNumber(evidence.delta, { suffix: ' points' })));
|
|
2738
|
+
} else if (kind === 'muscle_volume_trend') {
|
|
2739
|
+
rows.push(...muscleVolumeTrendEvidenceRows(evidence));
|
|
2402
2740
|
} else if (kind === 'training_balance_skew') {
|
|
2403
2741
|
rows.push(compactEvidenceRow('Push work', Number.isFinite(Number(evidence.pushSets)) ? `${Math.round(Number(evidence.pushSets))} sets` : null));
|
|
2404
2742
|
rows.push(compactEvidenceRow('Pull work', Number.isFinite(Number(evidence.pullSets)) ? `${Math.round(Number(evidence.pullSets))} sets` : null));
|
|
@@ -2935,6 +3273,14 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
2935
3273
|
built = buildExerciseProgressSummaryAskContext(contextSnapshot, namedExerciseItems, { exclude, since, today });
|
|
2936
3274
|
} else if (route === 'program_progress') {
|
|
2937
3275
|
built = buildProgramProgressAskContext(contextSnapshot, { exclude, since, today });
|
|
3276
|
+
} else if (route === 'program_history') {
|
|
3277
|
+
built = buildProgramHistoryAskContext(contextSnapshot, { exclude, today });
|
|
3278
|
+
} else if (route === 'program_schedule_action') {
|
|
3279
|
+
built = buildProgramScheduleActionAskContext(contextSnapshot, question, {
|
|
3280
|
+
exclude,
|
|
3281
|
+
today,
|
|
3282
|
+
scheduleContext: evidencePlan.intent?.deloadScheduleContext ?? null
|
|
3283
|
+
});
|
|
2938
3284
|
} else if (route === 'training_profile') {
|
|
2939
3285
|
built = buildTrainingProfileAskContext(contextSnapshot, { exclude, since, today });
|
|
2940
3286
|
} else if (route === 'records') {
|
|
@@ -2971,7 +3317,8 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
2971
3317
|
exclude,
|
|
2972
3318
|
today,
|
|
2973
3319
|
namedExercises: namedExerciseItems,
|
|
2974
|
-
existingSections: built.sections
|
|
3320
|
+
existingSections: built.sections,
|
|
3321
|
+
omitSections: ['recent_session', 'exercise_progress', 'exercise_progress_summary', 'next_session'].includes(route) ? ['records'] : []
|
|
2975
3322
|
})
|
|
2976
3323
|
: { sections: [], tools: [], provenance: [] };
|
|
2977
3324
|
built = {
|
|
@@ -3020,6 +3367,7 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
3020
3367
|
namedExerciseLabels,
|
|
3021
3368
|
builtTools: tools,
|
|
3022
3369
|
sessionObservationComparisons,
|
|
3370
|
+
includedFacts,
|
|
3023
3371
|
question
|
|
3024
3372
|
});
|
|
3025
3373
|
const currentSessionIds = uniqueArray(sessionObservationComparisons.map((row) => row.sessionId));
|
|
@@ -3037,7 +3385,8 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
3037
3385
|
...(includedFacts.length > 0 ? ['coach_facts'] : []),
|
|
3038
3386
|
...(includedCoachObservationIds.length > 0 ? ['coach_observations'] : []),
|
|
3039
3387
|
...(sessionObservationComparisons.length > 0 ? ['session_observation_comparisons'] : [])
|
|
3040
|
-
]
|
|
3388
|
+
],
|
|
3389
|
+
programScheduleActionStartDate: built.programScheduleActionStartDate
|
|
3041
3390
|
};
|
|
3042
3391
|
const finalizedEvidencePlan = finalizeEvidencePlan(evidencePlan, tools);
|
|
3043
3392
|
const toolMetadata = askToolMetadata(tools, provenance, { requiredTools: finalizedEvidencePlan.requiredTools });
|
|
@@ -3077,6 +3426,7 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
3077
3426
|
includedCoachObservationIds,
|
|
3078
3427
|
coachObservationIds: includedCoachObservationIds,
|
|
3079
3428
|
currentSessionIds,
|
|
3429
|
+
...(built.programScheduleActionStartDate ? { programScheduleActionStartDate: built.programScheduleActionStartDate } : {}),
|
|
3080
3430
|
sessionObservationComparisons,
|
|
3081
3431
|
evidencePlan: finalizedEvidencePlan,
|
|
3082
3432
|
contextBundle: contextBundleForMetadata(contextBundle),
|