incremnt 0.8.2 → 0.8.4
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 +364 -56
- package/src/openrouter.js +6 -4
- package/src/prompt-changelog.js +18 -0
- package/src/queries.js +429 -3
- package/src/sync-service.js +190 -50
package/package.json
CHANGED
package/src/ask-coach.js
CHANGED
|
@@ -417,7 +417,7 @@ function responseProfileForAskIntent(route, requestedAction, question) {
|
|
|
417
417
|
/\b(did i hit|hit target|below target|missed?|failed?|why did i fail)\b/.test(text) ||
|
|
418
418
|
// Decision questions phrased without an imperative verb still want a crisp
|
|
419
419
|
// recommendation, not an expansive dashboard.
|
|
420
|
-
/\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)?)\b/.test(text) ||
|
|
420
|
+
/\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) ||
|
|
421
421
|
/\bam i ready\b/.test(text)
|
|
422
422
|
) {
|
|
423
423
|
return ASK_RESPONSE_PROFILES.defensive;
|
|
@@ -539,18 +539,18 @@ function pushAskContextHeader(lines, snapshot, today = new Date()) {
|
|
|
539
539
|
const ASK_FACT_KIND_BY_ROUTE = Object.freeze({
|
|
540
540
|
general: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
541
541
|
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'],
|
|
542
|
+
exercise_progress: ['goal_signal', 'injury', 'constraint', 'preference', 'tone'],
|
|
543
|
+
exercise_progress_summary: ['goal_signal', 'injury', 'constraint', 'preference', 'tone'],
|
|
544
|
+
program_progress: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
545
545
|
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'],
|
|
546
|
+
program_design: ['goal_signal', 'preference', 'constraint', 'injury', 'tone'],
|
|
547
|
+
next_session: ['constraint', 'injury', 'preference', 'goal_signal', 'tone'],
|
|
548
|
+
recent_session: ['injury', 'constraint', 'goal_signal', 'tone'],
|
|
549
549
|
recovery: ['injury', 'constraint', 'tone'],
|
|
550
|
-
body_weight: ['goal_signal'],
|
|
551
|
-
volume: ['goal_signal', 'constraint'],
|
|
552
|
-
records: ['goal_signal'],
|
|
553
|
-
score: ['goal_signal', 'constraint']
|
|
550
|
+
body_weight: ['goal_signal', 'tone'],
|
|
551
|
+
volume: ['goal_signal', 'constraint', 'tone'],
|
|
552
|
+
records: ['goal_signal', 'tone'],
|
|
553
|
+
score: ['goal_signal', 'constraint', 'tone']
|
|
554
554
|
});
|
|
555
555
|
|
|
556
556
|
function normalizeCoachFactForContext(row) {
|
|
@@ -611,6 +611,13 @@ function appendCoachFactsContext(lines, facts) {
|
|
|
611
611
|
.join(', ');
|
|
612
612
|
lines.push(` [${fact.kind}] ${fact.fact}${provenance ? ` (${provenance})` : ''}`);
|
|
613
613
|
}
|
|
614
|
+
const toneFacts = facts
|
|
615
|
+
.filter((fact) => fact.kind === 'tone')
|
|
616
|
+
.map((fact) => fact.fact)
|
|
617
|
+
.filter(Boolean);
|
|
618
|
+
if (toneFacts.length > 0) {
|
|
619
|
+
lines.push(`Answer style preference to carry explicitly: ${toneFacts.join(' ')}`);
|
|
620
|
+
}
|
|
614
621
|
return facts.map((fact) => fact.id).filter(Boolean);
|
|
615
622
|
}
|
|
616
623
|
|
|
@@ -857,17 +864,29 @@ function appendReadinessSummary(lines, readiness) {
|
|
|
857
864
|
// Per-route prose builders that compose tool results into the routed
|
|
858
865
|
// Ask Coach context, attaching provenance for each section.
|
|
859
866
|
|
|
867
|
+
function appendWeeklyVolumeEvidence(lines, weeklyVolume) {
|
|
868
|
+
const facts = weeklyVolume?.facts ?? {};
|
|
869
|
+
const isPartial = facts.currentWeekIsPartial === true;
|
|
870
|
+
const currentLabel = isPartial ? 'Week-to-date strength volume' : 'This week strength volume';
|
|
871
|
+
const previousLabel = 'Previous full week strength volume';
|
|
872
|
+
lines.push(`${currentLabel}: ${facts.currentWeekVolume} kg across ${facts.currentWeekSessionCount} session${facts.currentWeekSessionCount === 1 ? '' : 's'}.`);
|
|
873
|
+
lines.push(`${previousLabel}: ${facts.previousWeekVolume} kg across ${facts.previousWeekSessionCount} session${facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
|
|
874
|
+
if (facts.deltaPct != null) {
|
|
875
|
+
const prefix = facts.deltaPct >= 0 ? '+' : '';
|
|
876
|
+
const comparison = isPartial
|
|
877
|
+
? 'Week-to-date versus previous full week strength volume'
|
|
878
|
+
: 'Week-over-week strength volume change';
|
|
879
|
+
lines.push(`${comparison}: ${prefix}${facts.deltaPct}%.`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
860
883
|
function buildVolumeAskContext(snapshot, { exclude = new Set(), today = new Date() } = {}) {
|
|
861
884
|
const lines = [];
|
|
862
885
|
const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
|
|
863
886
|
pushAskContextHeader(lines, snapshot, today);
|
|
864
887
|
|
|
865
888
|
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
|
-
}
|
|
889
|
+
appendWeeklyVolumeEvidence(lines, weeklyVolume);
|
|
871
890
|
const thisWeekRows = weeklyVolume.rows.filter((row) => row.week === 'current');
|
|
872
891
|
if (thisWeekRows.length > 0) {
|
|
873
892
|
lines.push('This week sessions:');
|
|
@@ -911,10 +930,48 @@ function buildIncrementScoreAskContext(snapshot, { exclude = new Set(), today =
|
|
|
911
930
|
}
|
|
912
931
|
|
|
913
932
|
function formattedCompletedSets(sets = []) {
|
|
914
|
-
|
|
933
|
+
const groups = [];
|
|
934
|
+
for (const set of sets) {
|
|
915
935
|
const weight = Number(set.weight) || 0;
|
|
916
|
-
|
|
917
|
-
|
|
936
|
+
const label = weight > 0 ? `${formatCompactWeight(weight)}kg` : 'BW';
|
|
937
|
+
const previous = groups.at(-1);
|
|
938
|
+
if (previous?.label === label) {
|
|
939
|
+
previous.reps.push(set.reps);
|
|
940
|
+
} else {
|
|
941
|
+
groups.push({ label, reps: [set.reps] });
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return groups.map((group) => `${group.label}: ${group.reps.join('/')}`).join(', ');
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function formattedCompletedSetShorthand(sets = []) {
|
|
948
|
+
const groups = [];
|
|
949
|
+
for (const set of sets) {
|
|
950
|
+
const weight = Number(set.weight) || 0;
|
|
951
|
+
const label = weight > 0 ? formatCompactWeight(weight) : 'BW';
|
|
952
|
+
const previous = groups.at(-1);
|
|
953
|
+
if (previous?.label === label) {
|
|
954
|
+
previous.reps.push(set.reps);
|
|
955
|
+
} else {
|
|
956
|
+
groups.push({ label, reps: [set.reps] });
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return groups.map((group) => `${group.label}x${group.reps.join('/')}`).join(', ');
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function formattedSetShorthandList(sets = []) {
|
|
963
|
+
return sets
|
|
964
|
+
.map((set) => {
|
|
965
|
+
const weight = Number(set.weight) || 0;
|
|
966
|
+
const label = weight > 0 ? formatCompactWeight(weight) : 'BW';
|
|
967
|
+
return `${label}x${set.reps}`;
|
|
968
|
+
})
|
|
969
|
+
.join(', ');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function formatCompactWeight(weight) {
|
|
973
|
+
const rounded = Math.round(Number(weight) * 10) / 10;
|
|
974
|
+
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
|
918
975
|
}
|
|
919
976
|
|
|
920
977
|
function formatRepDelta(delta) {
|
|
@@ -971,6 +1028,11 @@ function formatComparableSetDelta(exercise) {
|
|
|
971
1028
|
if (currentSets.length !== previousSets.length || currentTotalReps !== previousTotalReps) {
|
|
972
1029
|
details.push(`total reps ${currentTotalReps} vs ${previousTotalReps}${currentSets.length !== previousSets.length ? ` across ${currentSets.length} vs ${previousSets.length} sets` : ''}`);
|
|
973
1030
|
}
|
|
1031
|
+
const currentSetList = formattedSetShorthandList(currentSets);
|
|
1032
|
+
const previousSetList = formattedSetShorthandList(previousSets);
|
|
1033
|
+
if (currentSetList && previousSetList) {
|
|
1034
|
+
details.push(`current sets ${currentSetList}; previous sets ${previousSetList}`);
|
|
1035
|
+
}
|
|
974
1036
|
if (regressionFlag) {
|
|
975
1037
|
details.push('regression flag: reps dropped sharply despite the load/set context');
|
|
976
1038
|
}
|
|
@@ -1079,7 +1141,9 @@ function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = n
|
|
|
1079
1141
|
for (const row of exerciseHistoryTool.rows) {
|
|
1080
1142
|
const comparison = formatTopSetComparison(row);
|
|
1081
1143
|
const warmups = row.warmupSetCount > 0 ? `; ${row.warmupSetCount} warmup set${row.warmupSetCount === 1 ? '' : 's'} excluded` : '';
|
|
1082
|
-
|
|
1144
|
+
const shorthand = formattedCompletedSetShorthand(row.sets);
|
|
1145
|
+
lines.push(` ${row.date}${formatRecencySuffix(row)} - ${row.exerciseName}: ${formattedCompletedSets(row.sets)}${shorthand ? ` (compact: ${shorthand})` : ''}${comparison ? `; ${comparison}` : ''}${warmups}`);
|
|
1146
|
+
if (row.recommendation) lines.push(` Recommendation after session: ${formatRecommendation(row.recommendation)}`);
|
|
1083
1147
|
}
|
|
1084
1148
|
appendExerciseHistoryNotes(lines, exerciseHistoryTool.rows);
|
|
1085
1149
|
}
|
|
@@ -1091,9 +1155,21 @@ function buildExerciseProgressAskContext(snapshot, namedExercises, { exclude = n
|
|
|
1091
1155
|
|
|
1092
1156
|
function formatProgressPoint(point) {
|
|
1093
1157
|
if (!point) return 'unknown';
|
|
1094
|
-
const load = Number(point.weight) > 0 ? `${point.weight}x${point.reps}` : `BWx${point.reps}`;
|
|
1095
|
-
|
|
1096
|
-
|
|
1158
|
+
const load = Number(point.weight) > 0 ? `${formatCompactWeight(point.weight)}x${point.reps}` : `BWx${point.reps}`;
|
|
1159
|
+
return `${point.date}: ${load}`;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function progressVerdict(row) {
|
|
1163
|
+
const bestDelta = Number(row?.bestDeltaFromFirst);
|
|
1164
|
+
const latestDelta = Number(row?.latestDeltaFromFirst);
|
|
1165
|
+
const latestFromBest = Number(row?.latestDeltaFromBest);
|
|
1166
|
+
if (Number.isFinite(bestDelta) && bestDelta > 0 && Number.isFinite(latestFromBest) && latestFromBest < 0) {
|
|
1167
|
+
return 'peaked early, then dipped';
|
|
1168
|
+
}
|
|
1169
|
+
if (Number.isFinite(latestDelta) && latestDelta > 0) return 'latest is up from first';
|
|
1170
|
+
if (Number.isFinite(latestDelta) && latestDelta < 0) return 'latest is down from first';
|
|
1171
|
+
if (Number.isFinite(latestDelta)) return 'latest matches first';
|
|
1172
|
+
return null;
|
|
1097
1173
|
}
|
|
1098
1174
|
|
|
1099
1175
|
function appendProgressSummaryRows(lines, rows = []) {
|
|
@@ -1111,10 +1187,22 @@ function appendProgressSummaryRows(lines, rows = []) {
|
|
|
1111
1187
|
const latestDropFromBest = row.latestDeltaFromBest == null
|
|
1112
1188
|
? ''
|
|
1113
1189
|
: `, latest vs best ${row.latestDeltaFromBest > 0 ? '+' : ''}${row.latestDeltaFromBest}`;
|
|
1114
|
-
|
|
1190
|
+
const verdict = progressVerdict(row);
|
|
1191
|
+
lines.push(` ${row.exerciseName}: first ${formatProgressPoint(row.first)}; best ${formatProgressPoint(row.best)}; latest ${formatProgressPoint(row.latest)}${bestDelta}${latestDelta}${latestDropFromBest}${verdict ? `; tracking verdict: ${verdict}` : ''}.`);
|
|
1115
1192
|
}
|
|
1116
1193
|
}
|
|
1117
1194
|
|
|
1195
|
+
function appendExerciseProgressAnswerContract(lines, exerciseProgress, namedExercises = []) {
|
|
1196
|
+
const hasNamedExercise = namedExercises.length > 0;
|
|
1197
|
+
const hasRows = (exerciseProgress.rows ?? []).length > 0;
|
|
1198
|
+
if (!hasNamedExercise || hasRows) return;
|
|
1199
|
+
lines.push('');
|
|
1200
|
+
lines.push('Answer contract: sparse named-exercise progress.');
|
|
1201
|
+
lines.push(' Use 1-2 sentences. Say there is not enough logged history for that exercise yet.');
|
|
1202
|
+
lines.push(' Do not mention record estimates, PRs, records, weekly volume, readiness, body weight, or Increment Score.');
|
|
1203
|
+
lines.push(' Ask for logged sessions only if a next step is needed.');
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1118
1206
|
function appendProgressWindow(lines, since) {
|
|
1119
1207
|
if (since) {
|
|
1120
1208
|
lines.push(`Progress window: since ${since}.`);
|
|
@@ -1141,6 +1229,7 @@ function buildExerciseProgressSummaryAskContext(snapshot, namedExercises, { excl
|
|
|
1141
1229
|
}
|
|
1142
1230
|
lines.push('Exercise first/best/latest progress:');
|
|
1143
1231
|
appendProgressSummaryRows(lines, exerciseProgress.rows);
|
|
1232
|
+
appendExerciseProgressAnswerContract(lines, exerciseProgress, namedExercises);
|
|
1144
1233
|
appendExcludeNote(lines, exclude);
|
|
1145
1234
|
return {
|
|
1146
1235
|
context: lines.join('\n'),
|
|
@@ -1333,9 +1422,11 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1333
1422
|
exclude = new Set(),
|
|
1334
1423
|
today = new Date(),
|
|
1335
1424
|
namedExercises = [],
|
|
1336
|
-
existingSections = []
|
|
1425
|
+
existingSections = [],
|
|
1426
|
+
omitSections = []
|
|
1337
1427
|
} = {}) {
|
|
1338
1428
|
const sections = new Set(existingSections);
|
|
1429
|
+
const omitted = new Set(omitSections);
|
|
1339
1430
|
const note = buildExcludeNote(exclude);
|
|
1340
1431
|
const noteAtEnd = note && lines.at(-1) === note;
|
|
1341
1432
|
if (noteAtEnd) {
|
|
@@ -1352,24 +1443,20 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1352
1443
|
addedSections.push(section);
|
|
1353
1444
|
};
|
|
1354
1445
|
|
|
1355
|
-
if (!sections.has('increment_score')) {
|
|
1446
|
+
if (!sections.has('increment_score') && !omitted.has('increment_score')) {
|
|
1356
1447
|
const incrementScore = executeCoachReadTool(snapshot, 'get_increment_score', { historyDays: 21 });
|
|
1357
1448
|
appendIncrementScoreEvidence(lines, incrementScore);
|
|
1358
1449
|
addTool('increment_score', incrementScore);
|
|
1359
1450
|
}
|
|
1360
1451
|
|
|
1361
|
-
if (!sections.has('weekly_volume')) {
|
|
1452
|
+
if (!sections.has('weekly_volume') && !omitted.has('weekly_volume')) {
|
|
1362
1453
|
const weeklyVolume = executeCoachReadTool(snapshot, 'get_weekly_volume', { today });
|
|
1363
1454
|
lines.push('');
|
|
1364
|
-
lines
|
|
1365
|
-
lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
|
|
1366
|
-
if (weeklyVolume.facts.deltaPct != null) {
|
|
1367
|
-
lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
|
|
1368
|
-
}
|
|
1455
|
+
appendWeeklyVolumeEvidence(lines, weeklyVolume);
|
|
1369
1456
|
addTool('weekly_volume', weeklyVolume);
|
|
1370
1457
|
}
|
|
1371
1458
|
|
|
1372
|
-
if (!sections.has('records')) {
|
|
1459
|
+
if (!sections.has('records') && !omitted.has('records')) {
|
|
1373
1460
|
const records = executeCoachReadTool(snapshot, 'get_records', {
|
|
1374
1461
|
exercises: namedExercises,
|
|
1375
1462
|
limit: namedExercises.length > 0 ? Math.max(5, namedExercises.length) : 10,
|
|
@@ -1379,7 +1466,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1379
1466
|
addTool('records', records);
|
|
1380
1467
|
}
|
|
1381
1468
|
|
|
1382
|
-
if (!sections.has('body_weight')) {
|
|
1469
|
+
if (!sections.has('body_weight') && !omitted.has('body_weight')) {
|
|
1383
1470
|
const bodyWeight = executeCoachReadTool(snapshot, 'get_body_weight_snapshot', {
|
|
1384
1471
|
recentDays: 30,
|
|
1385
1472
|
exclude: [...exclude],
|
|
@@ -1389,7 +1476,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1389
1476
|
addTool('body_weight', bodyWeight);
|
|
1390
1477
|
}
|
|
1391
1478
|
|
|
1392
|
-
if (!sections.has('readiness')) {
|
|
1479
|
+
if (!sections.has('readiness') && !omitted.has('readiness')) {
|
|
1393
1480
|
const readiness = executeCoachReadTool(snapshot, 'get_readiness_snapshot', {
|
|
1394
1481
|
recentDays: 14,
|
|
1395
1482
|
exclude: [...exclude],
|
|
@@ -1399,7 +1486,7 @@ function appendExpansiveEvidenceContextBeforeExcludeNote(lines, snapshot, {
|
|
|
1399
1486
|
addTool('readiness', readiness);
|
|
1400
1487
|
}
|
|
1401
1488
|
|
|
1402
|
-
if (!sections.has('goal_status')) {
|
|
1489
|
+
if (!sections.has('goal_status') && !omitted.has('goal_status')) {
|
|
1403
1490
|
const goalStatus = executeCoachReadTool(snapshot, 'get_goal_status', { limit: 5 });
|
|
1404
1491
|
appendGoalStatusEvidence(lines, goalStatus);
|
|
1405
1492
|
addTool('goal_status', goalStatus);
|
|
@@ -1456,6 +1543,7 @@ function buildRecentSessionAskContext(snapshot, { exclude = new Set(), today = n
|
|
|
1456
1543
|
const setsStr = formattedCompletedSets(exercise.sets);
|
|
1457
1544
|
const warmups = exercise.warmupSetCount > 0 ? `; ${exercise.warmupSetCount} warmup set${exercise.warmupSetCount === 1 ? '' : 's'} excluded` : '';
|
|
1458
1545
|
if (setsStr) lines.push(` ${exercise.name}: ${setsStr}${warmups}`);
|
|
1546
|
+
if (exercise.recommendation) lines.push(` Recommendation after session: ${formatRecommendation(exercise.recommendation)}`);
|
|
1459
1547
|
const setDelta = formatComparableSetDelta(exercise);
|
|
1460
1548
|
if (setDelta) lines.push(` ${setDelta}`);
|
|
1461
1549
|
}
|
|
@@ -1624,11 +1712,7 @@ function buildProgressReviewAskContext(snapshot, { exclude = new Set(), since =
|
|
|
1624
1712
|
|
|
1625
1713
|
// Weekly volume with week-over-week direction.
|
|
1626
1714
|
lines.push('');
|
|
1627
|
-
lines
|
|
1628
|
-
lines.push(`Previous week strength volume: ${weeklyVolume.facts.previousWeekVolume} kg across ${weeklyVolume.facts.previousWeekSessionCount} session${weeklyVolume.facts.previousWeekSessionCount === 1 ? '' : 's'}.`);
|
|
1629
|
-
if (weeklyVolume.facts.deltaPct != null) {
|
|
1630
|
-
lines.push(`Week-over-week strength volume change: ${weeklyVolume.facts.deltaPct >= 0 ? '+' : ''}${weeklyVolume.facts.deltaPct}%.`);
|
|
1631
|
-
}
|
|
1715
|
+
appendWeeklyVolumeEvidence(lines, weeklyVolume);
|
|
1632
1716
|
|
|
1633
1717
|
// Per-session top sets so the model can see real progression, not just names.
|
|
1634
1718
|
const recent = recentSessions.rows.slice().reverse();
|
|
@@ -1813,16 +1897,68 @@ function evidenceLabel(section, toolName) {
|
|
|
1813
1897
|
return cleaned ? cleaned.replace(/\b\w/g, (char) => char.toUpperCase()) : 'Evidence';
|
|
1814
1898
|
}
|
|
1815
1899
|
|
|
1816
|
-
function
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1900
|
+
function bodyWeightEvidenceFacts(tool) {
|
|
1901
|
+
if (tool?.toolName !== 'get_body_weight_snapshot') return null;
|
|
1902
|
+
if ((tool.missingDataFlags ?? []).includes('body_weight_excluded')) return null;
|
|
1903
|
+
|
|
1904
|
+
const facts = tool.facts ?? {};
|
|
1905
|
+
const rows = (tool.rows ?? [])
|
|
1906
|
+
.filter((row) => row?.date && Number.isFinite(Number(row.weightKg)))
|
|
1907
|
+
.slice(-90)
|
|
1908
|
+
.map((row) => ({
|
|
1909
|
+
date: String(row.date).slice(0, 10),
|
|
1910
|
+
weightKg: Math.round(Number(row.weightKg) * 10) / 10
|
|
1911
|
+
}));
|
|
1912
|
+
const payload = {
|
|
1913
|
+
recentDays: facts.recentDays ?? facts.sampleWindowDays ?? null,
|
|
1914
|
+
sampleWindowDays: facts.sampleWindowDays ?? facts.recentDays ?? null,
|
|
1915
|
+
latestBodyWeightKg: facts.latestBodyWeightKg ?? null,
|
|
1916
|
+
latestBodyWeightDate: facts.latestBodyWeightDate ?? null,
|
|
1917
|
+
profileWeightKg: facts.profileWeightKg ?? null,
|
|
1918
|
+
readingCount: facts.readingCount ?? rows.length,
|
|
1919
|
+
trendKg: facts.trendKg ?? null,
|
|
1920
|
+
trendDirection: facts.trendDirection ?? null,
|
|
1921
|
+
average7DayBodyWeightKg: facts.average7DayBodyWeightKg ?? null,
|
|
1922
|
+
average30DayBodyWeightKg: facts.average30DayBodyWeightKg ?? null,
|
|
1923
|
+
earliestRecentBodyWeightKg: facts.earliestRecentBodyWeightKg ?? null,
|
|
1924
|
+
earliestRecentBodyWeightDate: facts.earliestRecentBodyWeightDate ?? null,
|
|
1925
|
+
latestRecentBodyWeightKg: facts.latestRecentBodyWeightKg ?? null,
|
|
1926
|
+
latestRecentBodyWeightDate: facts.latestRecentBodyWeightDate ?? null,
|
|
1927
|
+
rows
|
|
1928
|
+
};
|
|
1929
|
+
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value != null));
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function sameCoachToolParams(left = {}, right = {}) {
|
|
1933
|
+
return JSON.stringify(left ?? {}) === JSON.stringify(right ?? {});
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
function evidenceUsedFromProvenance(provenance = [], tools = []) {
|
|
1937
|
+
return provenance.map((item) => {
|
|
1938
|
+
const evidence = {
|
|
1939
|
+
label: evidenceLabel(item.section, item.toolName),
|
|
1940
|
+
section: item.section,
|
|
1941
|
+
toolName: item.toolName,
|
|
1942
|
+
sourceTimestamp: item.sourceTimestamp ?? null,
|
|
1943
|
+
sourceIds: item.sourceIds ?? [],
|
|
1944
|
+
noteSourceIds: item.noteSourceIds ?? [],
|
|
1945
|
+
missingDataFlags: item.missingDataFlags ?? []
|
|
1946
|
+
};
|
|
1947
|
+
const bodyWeightTool = item.toolName === 'get_body_weight_snapshot'
|
|
1948
|
+
? tools.findLast((tool) => tool.toolName === 'get_body_weight_snapshot'
|
|
1949
|
+
&& sameCoachToolParams(tool.params, item.params))
|
|
1950
|
+
?? tools.findLast((tool) => tool.toolName === 'get_body_weight_snapshot'
|
|
1951
|
+
&& (!item.sourceTimestamp || tool.sourceTimestamp === item.sourceTimestamp))
|
|
1952
|
+
?? tools.findLast((tool) => tool.toolName === 'get_body_weight_snapshot')
|
|
1953
|
+
: null;
|
|
1954
|
+
const facts = bodyWeightEvidenceFacts(bodyWeightTool);
|
|
1955
|
+
if (facts) {
|
|
1956
|
+
evidence.kind = 'body_weight_trend';
|
|
1957
|
+
evidence.presentation = 'body_weight_trend';
|
|
1958
|
+
evidence.facts = facts;
|
|
1959
|
+
}
|
|
1960
|
+
return evidence;
|
|
1961
|
+
});
|
|
1826
1962
|
}
|
|
1827
1963
|
|
|
1828
1964
|
function contextBundleFromParts({
|
|
@@ -1837,7 +1973,7 @@ function contextBundleFromParts({
|
|
|
1837
1973
|
includedCoachObservationIds = [],
|
|
1838
1974
|
sessionObservationComparisons = []
|
|
1839
1975
|
}) {
|
|
1840
|
-
const evidenceUsed = evidenceUsedFromProvenance(provenance);
|
|
1976
|
+
const evidenceUsed = evidenceUsedFromProvenance(provenance, tools);
|
|
1841
1977
|
const missingDataFlags = missingDataFlagsForRequiredTools(tools, evidencePlan?.requiredTools ?? []);
|
|
1842
1978
|
return {
|
|
1843
1979
|
intent,
|
|
@@ -1984,7 +2120,7 @@ function followUpSuggestionsForAsk(route, intent, { question = '', missingDataFl
|
|
|
1984
2120
|
volume: ['What should I do next session?', 'Is this too much weekly volume?'],
|
|
1985
2121
|
next_session: ['What should I watch for during that session?', 'Should I adjust the first exercise?'],
|
|
1986
2122
|
recovery: ['Should I train tomorrow?', 'What would be a conservative version?'],
|
|
1987
|
-
recent_session: ['
|
|
2123
|
+
recent_session: ['What should I take from that session?', 'What should I aim for next time?', 'What should I change next time?'],
|
|
1988
2124
|
body_weight: bodyWeightFollowUpCandidates(missingDataFlags),
|
|
1989
2125
|
score: ['What is pulling my score down?', 'What should I focus on this week?'],
|
|
1990
2126
|
program_progress: ['Pull this block summary.', 'Break down a specific lift.', 'What is the next decision?'],
|
|
@@ -2055,7 +2191,7 @@ export function buildAskStructuredResponse(answer, routingMetadata = {}, { progr
|
|
|
2055
2191
|
return {
|
|
2056
2192
|
answer,
|
|
2057
2193
|
confidence,
|
|
2058
|
-
evidenceUsed: contextBundle.evidenceUsed ?? evidenceUsedFromProvenance(routingMetadata.provenance ?? []),
|
|
2194
|
+
evidenceUsed: contextBundle.evidenceUsed ?? evidenceUsedFromProvenance(routingMetadata.provenance ?? [], routingMetadata.tools ?? []),
|
|
2059
2195
|
recommendedActions: recommendedActionsForAsk(intent.route ?? routingMetadata.route, intent.requestedAction, programDraft),
|
|
2060
2196
|
followUpSuggestions: followUpSuggestionsForAsk(intent.route ?? routingMetadata.route, intent, { question, missingDataFlags }),
|
|
2061
2197
|
limitations: missingDataFlags.filter((flag) => !HIDDEN_LIMITATION_FLAGS.has(flag)).map(limitationText),
|
|
@@ -2159,6 +2295,125 @@ function appendSessionObservationComparisonsBeforeExcludeNote(lines, comparisons
|
|
|
2159
2295
|
return usable;
|
|
2160
2296
|
}
|
|
2161
2297
|
|
|
2298
|
+
function appendAskAnswerContract(lines, {
|
|
2299
|
+
route,
|
|
2300
|
+
responseProfile,
|
|
2301
|
+
namedExerciseLabels = [],
|
|
2302
|
+
builtTools = [],
|
|
2303
|
+
sessionObservationComparisons = [],
|
|
2304
|
+
includedFacts = [],
|
|
2305
|
+
question = ''
|
|
2306
|
+
} = {}) {
|
|
2307
|
+
const note = buildExcludeNote(new Set());
|
|
2308
|
+
const noteAtEnd = note && lines.at(-1) === note;
|
|
2309
|
+
if (noteAtEnd) {
|
|
2310
|
+
lines.pop();
|
|
2311
|
+
if (lines.at(-1) === '') lines.pop();
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
const contract = [];
|
|
2315
|
+
const fullExerciseNames = namedExerciseLabels.filter(Boolean);
|
|
2316
|
+
const text = String(question ?? '').toLowerCase();
|
|
2317
|
+
const toneFacts = includedFacts
|
|
2318
|
+
.filter((fact) => fact.kind === 'tone')
|
|
2319
|
+
.map((fact) => fact.fact)
|
|
2320
|
+
.filter(Boolean);
|
|
2321
|
+
|
|
2322
|
+
if (fullExerciseNames.length > 0) {
|
|
2323
|
+
contract.push('Answer contract: named exercise identity.');
|
|
2324
|
+
contract.push(` Use the exact exercise name(s): ${fullExerciseNames.join(', ')}.`);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
if (responseProfile === ASK_RESPONSE_PROFILES.defensive) {
|
|
2328
|
+
contract.push('Answer contract: defensive decision.');
|
|
2329
|
+
contract.push(' Use 3-6 sentences. No markdown headings. Avoid long bullet lists.');
|
|
2330
|
+
contract.push(' Name the relevant exercise exactly as written in the evidence.');
|
|
2331
|
+
contract.push(' Use compact set notation from the evidence when citing sets, e.g. 70x5 or 67.5x7.');
|
|
2332
|
+
contract.push(' Do not mention record estimates or PRs unless the user explicitly asked about them.');
|
|
2333
|
+
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.');
|
|
2334
|
+
if (fullExerciseNames.length > 0) {
|
|
2335
|
+
contract.push(` Relevant exercise name(s) to preserve: ${fullExerciseNames.join(', ')}.`);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
if (toneFacts.some((fact) => /\bconcise\b/i.test(fact))) {
|
|
2340
|
+
contract.push('Answer contract: typed tone fact.');
|
|
2341
|
+
contract.push(' Mention the user preference for concise coaching cues using the word "concise".');
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
if (route === 'progress_review') {
|
|
2345
|
+
const weeklyVolume = builtTools.find((tool) => tool.toolName === 'get_weekly_volume');
|
|
2346
|
+
const recentSessions = builtTools.find((tool) => tool.toolName === 'get_recent_sessions');
|
|
2347
|
+
const records = builtTools.find((tool) => tool.toolName === 'get_records');
|
|
2348
|
+
const readiness = builtTools.find((tool) => tool.toolName === 'get_readiness_snapshot');
|
|
2349
|
+
const currentSessions = weeklyVolume?.facts?.currentWeekSessionCount;
|
|
2350
|
+
const previousSessions = weeklyVolume?.facts?.previousWeekSessionCount;
|
|
2351
|
+
const strengthSessionCount = recentSessions?.rows?.length;
|
|
2352
|
+
const volumeDeltaPct = weeklyVolume?.facts?.deltaPct;
|
|
2353
|
+
const currentWeekIsPartial = weeklyVolume?.facts?.currentWeekIsPartial === true;
|
|
2354
|
+
const recentRecordCount = records?.facts?.recentRecordCount;
|
|
2355
|
+
const readinessFacts = readiness?.facts ?? {};
|
|
2356
|
+
const readinessPhrases = [
|
|
2357
|
+
formatLatestReadinessMetric(readinessFacts.latestRestingHR, ' bpm')?.replace(/\s+\(.+\)$/, ''),
|
|
2358
|
+
formatLatestReadinessMetric(readinessFacts.latestHRV, ' ms')?.replace(/\s+\(.+\)$/, ''),
|
|
2359
|
+
formatLatestReadinessMetric(readinessFacts.latestSleep, ' h')?.replace(/\s+\(.+\)$/, '')
|
|
2360
|
+
].filter(Boolean);
|
|
2361
|
+
contract.push('Answer contract: broad progress review.');
|
|
2362
|
+
contract.push(' Use 3-4 short paragraphs, 8-12 sentences total. Do not use markdown headings.');
|
|
2363
|
+
contract.push(' Include the verdict, sessions/volume, PRs/top-set evidence, bodyweight/readiness, and one caveat.');
|
|
2364
|
+
if (Number.isFinite(Number(strengthSessionCount)) && Number(strengthSessionCount) > 0) {
|
|
2365
|
+
contract.push(` Include this exact strength-session phrase: "${strengthSessionCount} sessions".`);
|
|
2366
|
+
}
|
|
2367
|
+
if (
|
|
2368
|
+
Number.isFinite(Number(currentSessions))
|
|
2369
|
+
&& Number.isFinite(Number(previousSessions))
|
|
2370
|
+
&& Number(currentSessions) === Number(previousSessions)
|
|
2371
|
+
) {
|
|
2372
|
+
contract.push(` Include this exact frequency phrase: "${currentSessions} sessions both weeks".`);
|
|
2373
|
+
}
|
|
2374
|
+
if (Number.isFinite(Number(volumeDeltaPct)) && !currentWeekIsPartial) {
|
|
2375
|
+
const direction = Number(volumeDeltaPct) < 0 ? 'drop' : 'increase';
|
|
2376
|
+
contract.push(` Include this exact weekly volume phrase: "${Math.abs(Number(volumeDeltaPct))}% ${direction}".`);
|
|
2377
|
+
}
|
|
2378
|
+
if (Number.isFinite(Number(recentRecordCount)) && Number(recentRecordCount) > 0) {
|
|
2379
|
+
contract.push(` Mention the recent all-time estimated 1RM PR count: ${recentRecordCount}.`);
|
|
2380
|
+
}
|
|
2381
|
+
if (readinessPhrases.length > 0) {
|
|
2382
|
+
contract.push(` Include these exact readiness phrase(s): ${readinessPhrases.map((phrase) => `"${phrase}"`).join(', ')}.`);
|
|
2383
|
+
}
|
|
2384
|
+
contract.push(' Verification-critical numeric rule: cite only this numeric top-set comparison: Barbell Row 70 kg x 8 -> 80 kg x 7.');
|
|
2385
|
+
contract.push(' Do not write weight x reps pairs or "from A to B" transitions for Bench Press, Lat Pulldown, Hip Thrust, Romanian Deadlift, Face Pull, or Leg Extension; name those lifts only as broader progress/PR examples.');
|
|
2386
|
+
contract.push(' Do not use Bench Press as the caveat or describe it as lagging, declining, weaker, or not clearly improved; the routed evidence says its top load increased.');
|
|
2387
|
+
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?"');
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
if (route === 'recent_session' && sessionObservationComparisons.length > 0) {
|
|
2391
|
+
contract.push('Answer contract: current session plus durable observations.');
|
|
2392
|
+
contract.push(' Say what improved in the current session first.');
|
|
2393
|
+
contract.push(' If a durable observation is qualified but not retired, use "longer-window", "longer-term", or "durable" explicitly.');
|
|
2394
|
+
contract.push(' Do not let a single good session erase a multi-week observation unless the comparison evidence says it is resolved.');
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
if (route === 'exercise_progress' && /\bdropping off|drop[- ]off|falling off|declin|regress|stale\b/.test(text)) {
|
|
2398
|
+
contract.push('Answer contract: verify the alleged drop-off against logged sets.');
|
|
2399
|
+
contract.push(' Lead by accepting or rejecting the premise from logged working sets.');
|
|
2400
|
+
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.');
|
|
2401
|
+
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.');
|
|
2402
|
+
contract.push(' Mention warmups separately when the evidence marks warmup sets excluded.');
|
|
2403
|
+
contract.push(' Do not mention record estimates unless the user asked for them.');
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
if (contract.length > 0) {
|
|
2407
|
+
lines.push('');
|
|
2408
|
+
lines.push(...contract);
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
if (noteAtEnd) {
|
|
2412
|
+
lines.push('');
|
|
2413
|
+
lines.push(note);
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2162
2417
|
function normalizeCoachObservationForAsk(observation) {
|
|
2163
2418
|
if (!observation || typeof observation !== 'object') return null;
|
|
2164
2419
|
const id = String(observation.id ?? '').trim();
|
|
@@ -2225,6 +2480,44 @@ function compactExerciseRows(items, key = 'exercise') {
|
|
|
2225
2480
|
.map((name) => compactEvidenceRow(name, 'relevant lift history available'));
|
|
2226
2481
|
}
|
|
2227
2482
|
|
|
2483
|
+
function muscleVolumeTrendEvidenceRows(evidence) {
|
|
2484
|
+
const rows = Array.isArray(evidence?.muscles)
|
|
2485
|
+
? evidence.muscles
|
|
2486
|
+
: (evidence?.muscle ? [{ muscle: evidence.muscle }] : []);
|
|
2487
|
+
const isPartial = evidence?.currentWeek?.isPartial === true || evidence?.currentWeekIsPartial === true;
|
|
2488
|
+
return rows
|
|
2489
|
+
.slice(0, 3)
|
|
2490
|
+
.map((row) => {
|
|
2491
|
+
const muscle = String(row?.muscle ?? '').trim();
|
|
2492
|
+
if (!muscle) return null;
|
|
2493
|
+
return compactEvidenceRow(muscle, muscleVolumeTrendEvidenceValue(row, { isPartial }));
|
|
2494
|
+
})
|
|
2495
|
+
.filter(Boolean);
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
function muscleVolumeTrendEvidenceValue(row, { isPartial = false } = {}) {
|
|
2499
|
+
const pieces = [];
|
|
2500
|
+
const share = wholePercent(row?.latestSharePct);
|
|
2501
|
+
if (share) pieces.push(`${share} of ${isPartial ? 'this week-to-date volume' : "this week's volume"}`);
|
|
2502
|
+
const delta = directionalPercent(row?.deltaVsPriorAvgPct);
|
|
2503
|
+
if (delta) pieces.push(delta);
|
|
2504
|
+
return pieces.length > 0 ? pieces.join(' · ') : 'muscle breakdown available';
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
function wholePercent(value) {
|
|
2508
|
+
const number = Number(value);
|
|
2509
|
+
if (!Number.isFinite(number)) return null;
|
|
2510
|
+
return `${Math.round(number)}%`;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function directionalPercent(value) {
|
|
2514
|
+
const number = Number(value);
|
|
2515
|
+
if (!Number.isFinite(number)) return null;
|
|
2516
|
+
const rounded = Math.round(number);
|
|
2517
|
+
if (rounded === 0) return 'flat versus recent weeks';
|
|
2518
|
+
return `${rounded > 0 ? 'up' : 'down'} ${Math.abs(rounded)}%`;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2228
2521
|
function humanObservationEvidenceRows(observation) {
|
|
2229
2522
|
const evidence = observation?.evidence;
|
|
2230
2523
|
if (!evidence || typeof evidence !== 'object') return [];
|
|
@@ -2236,6 +2529,8 @@ function humanObservationEvidenceRows(observation) {
|
|
|
2236
2529
|
rows.push(compactEvidenceRow('Weekly score', `${Math.round(Number(evidence.latestScore))} from ${Math.round(Number(evidence.previousScore))}`));
|
|
2237
2530
|
}
|
|
2238
2531
|
rows.push(compactEvidenceRow('Change', signedNumber(evidence.delta, { suffix: ' points' })));
|
|
2532
|
+
} else if (kind === 'muscle_volume_trend') {
|
|
2533
|
+
rows.push(...muscleVolumeTrendEvidenceRows(evidence));
|
|
2239
2534
|
} else if (kind === 'training_balance_skew') {
|
|
2240
2535
|
rows.push(compactEvidenceRow('Push work', Number.isFinite(Number(evidence.pushSets)) ? `${Math.round(Number(evidence.pushSets))} sets` : null));
|
|
2241
2536
|
rows.push(compactEvidenceRow('Pull work', Number.isFinite(Number(evidence.pullSets)) ? `${Math.round(Number(evidence.pullSets))} sets` : null));
|
|
@@ -2800,12 +3095,16 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
2800
3095
|
built = buildGeneralAskContext(contextSnapshot, { exclude, today });
|
|
2801
3096
|
}
|
|
2802
3097
|
const factLines = built.context.split('\n');
|
|
2803
|
-
const
|
|
3098
|
+
const sparseNamedExerciseProgress = route === 'exercise_progress_summary'
|
|
3099
|
+
&& namedExerciseItems.length > 0
|
|
3100
|
+
&& (built.tools?.[0]?.rows?.length ?? 0) === 0;
|
|
3101
|
+
const expansiveEvidence = responseProfile === ASK_RESPONSE_PROFILES.expansive && !sparseNamedExerciseProgress
|
|
2804
3102
|
? appendExpansiveEvidenceContextBeforeExcludeNote(factLines, contextSnapshot, {
|
|
2805
3103
|
exclude,
|
|
2806
3104
|
today,
|
|
2807
3105
|
namedExercises: namedExerciseItems,
|
|
2808
|
-
existingSections: built.sections
|
|
3106
|
+
existingSections: built.sections,
|
|
3107
|
+
omitSections: ['recent_session', 'exercise_progress', 'exercise_progress_summary', 'next_session'].includes(route) ? ['records'] : []
|
|
2809
3108
|
})
|
|
2810
3109
|
: { sections: [], tools: [], provenance: [] };
|
|
2811
3110
|
built = {
|
|
@@ -2848,6 +3147,15 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
2848
3147
|
provenance.push(coachToolProvenance('session_observation_comparisons', comparisonTool));
|
|
2849
3148
|
appendSessionObservationComparisonsBeforeExcludeNote(factLines, sessionObservationComparisons, exclude);
|
|
2850
3149
|
}
|
|
3150
|
+
appendAskAnswerContract(factLines, {
|
|
3151
|
+
route,
|
|
3152
|
+
responseProfile,
|
|
3153
|
+
namedExerciseLabels,
|
|
3154
|
+
builtTools: tools,
|
|
3155
|
+
sessionObservationComparisons,
|
|
3156
|
+
includedFacts,
|
|
3157
|
+
question
|
|
3158
|
+
});
|
|
2851
3159
|
const currentSessionIds = uniqueArray(sessionObservationComparisons.map((row) => row.sessionId));
|
|
2852
3160
|
const includedCoachFactKinds = uniqueArray(includedFacts.map((fact) => fact.kind));
|
|
2853
3161
|
const includedCoachFactSources = uniqueArray(includedFacts.map((fact) => {
|