incremnt 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/package.json +25 -4
- package/src/anonymize.js +12 -0
- package/src/coach-bakeoff.js +300 -0
- package/src/coach-facts.js +100 -0
- package/src/coach-prompt-variants.js +106 -0
- package/src/contract.js +56 -1
- package/src/exercise-aliases.js +163 -0
- package/src/format.js +64 -1
- package/src/increment-score-replay-data.js +486 -0
- package/src/increment-score-replay.js +822 -0
- package/src/lib.js +14 -2
- package/src/local.js +3 -3
- package/src/openrouter.js +1033 -179
- package/src/program-phase-resolver.js +206 -0
- package/src/prompt-security.js +13 -0
- package/src/promptfoo-domain-assert.cjs +4 -0
- package/src/promptfoo-evals.js +166 -0
- package/src/promptfoo-langfuse-scores.js +354 -0
- package/src/promptfoo-provider.cjs +14 -0
- package/src/promptfoo-tests.cjs +4 -0
- package/src/queries.js +2307 -164
- package/src/remote.js +144 -1
- package/src/state.js +9 -2
- package/src/stored-summary-eval-report.js +171 -0
- package/src/summary-evals.js +1445 -0
- package/src/sync-service.js +1557 -158
- package/src/workout-prompt-variants.js +52 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { anonymizeAccountId } from './anonymize.js';
|
|
8
|
+
import { exerciseAliasMapping } from './exercise-aliases.js';
|
|
9
|
+
import { canonicalExerciseName, normalizeExerciseName } from './queries.js';
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
export const incrementScoreReplayDataVersion = 'increment-score-replay-input.v0';
|
|
14
|
+
export const incrementScoreProdAdapterVersion = 'increment-score-prod-data-adapter.v0';
|
|
15
|
+
export const defaultIncrementScoreFormulaVersion = 'increment-score-formula.v0-pending';
|
|
16
|
+
|
|
17
|
+
const knownCanonicalExercises = new Set(Object.keys(exerciseAliasMapping).map((name) => normalizeExerciseName(name)));
|
|
18
|
+
const safeAliasPattern = /^[a-z0-9][a-z0-9_-]{1,63}$/;
|
|
19
|
+
|
|
20
|
+
function adapterError(message, code) {
|
|
21
|
+
const error = new Error(message);
|
|
22
|
+
error.code = code;
|
|
23
|
+
return error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isoNow(now = new Date()) {
|
|
27
|
+
return now.toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function dateOnly(value) {
|
|
31
|
+
if (typeof value !== 'string' || !value) return null;
|
|
32
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
|
33
|
+
const parsed = new Date(value);
|
|
34
|
+
if (Number.isNaN(parsed.getTime())) return null;
|
|
35
|
+
return parsed.toISOString().slice(0, 10);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function completionDateForSession(session) {
|
|
39
|
+
return session?.completedAt ?? session?.summary?.date ?? session?.date ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function numberOrNull(value) {
|
|
43
|
+
const numeric = Number(value);
|
|
44
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stringOrNull(value) {
|
|
48
|
+
if (typeof value !== 'string') return null;
|
|
49
|
+
const trimmed = value.trim();
|
|
50
|
+
return trimmed ? trimmed : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stableHash(parts, length = 16) {
|
|
54
|
+
return createHash('sha256')
|
|
55
|
+
.update(parts.map((part) => String(part ?? '')).join(':'))
|
|
56
|
+
.digest('hex')
|
|
57
|
+
.slice(0, length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hashedId(prefix, accountId, value) {
|
|
61
|
+
if (value == null || value === '') return null;
|
|
62
|
+
return `${prefix}:${stableHash([prefix, accountId, value])}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function dateRange(items, getDate) {
|
|
66
|
+
const dates = (items ?? [])
|
|
67
|
+
.map(getDate)
|
|
68
|
+
.map(dateOnly)
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.sort();
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
count: dates.length,
|
|
74
|
+
firstDate: dates[0] ?? null,
|
|
75
|
+
lastDate: dates.at(-1) ?? null
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeMetricEntries(entries, valueMapper) {
|
|
80
|
+
return (Array.isArray(entries) ? entries : [])
|
|
81
|
+
.map((entry) => valueMapper(entry))
|
|
82
|
+
.filter((entry) => entry.date)
|
|
83
|
+
.sort((lhs, rhs) => lhs.date.localeCompare(rhs.date));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeExerciseNameForReplay(name, accountId) {
|
|
87
|
+
const normalized = normalizeExerciseName(name);
|
|
88
|
+
const canonical = canonicalExerciseName(name);
|
|
89
|
+
|
|
90
|
+
if (knownCanonicalExercises.has(canonical)) {
|
|
91
|
+
return {
|
|
92
|
+
canonicalName: canonical,
|
|
93
|
+
customExerciseHash: null,
|
|
94
|
+
customExercise: false
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
canonicalName: `custom:${stableHash(['exercise', accountId, normalized], 12)}`,
|
|
100
|
+
customExerciseHash: stableHash(['exercise-name', accountId, normalized], 16),
|
|
101
|
+
customExercise: true
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeSet(set) {
|
|
106
|
+
return {
|
|
107
|
+
weightKg: numberOrNull(set?.weight),
|
|
108
|
+
reps: numberOrNull(set?.reps),
|
|
109
|
+
rir: numberOrNull(set?.rir ?? set?.repsInReserve),
|
|
110
|
+
isComplete: set?.isComplete !== false
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeExercise(exercise, accountId) {
|
|
115
|
+
const name = normalizeExerciseNameForReplay(exercise?.name ?? exercise?.exerciseName, accountId);
|
|
116
|
+
return {
|
|
117
|
+
...name,
|
|
118
|
+
muscleGroup: stringOrNull(exercise?.muscleGroup),
|
|
119
|
+
sets: (Array.isArray(exercise?.sets) ? exercise.sets : [])
|
|
120
|
+
.map(normalizeSet)
|
|
121
|
+
.filter((set) => set.weightKg != null || set.reps != null)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizePlannedExercise(exercise, accountId) {
|
|
126
|
+
const name = normalizeExerciseNameForReplay(exercise?.exerciseName ?? exercise?.name, accountId);
|
|
127
|
+
const sourceSets = Array.isArray(exercise?.targetSets)
|
|
128
|
+
? exercise.targetSets
|
|
129
|
+
: (Array.isArray(exercise?.sets) ? exercise.sets : []);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
...name,
|
|
133
|
+
targetReps: numberOrNull(exercise?.targetReps),
|
|
134
|
+
targetWeightKg: numberOrNull(exercise?.targetWeight),
|
|
135
|
+
plannedSets: sourceSets.length
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeSession(session, accountId) {
|
|
140
|
+
const completedAt = completionDateForSession(session);
|
|
141
|
+
const prescriptionExercises = Array.isArray(session?.prescriptionSnapshot?.exercises)
|
|
142
|
+
? session.prescriptionSnapshot.exercises
|
|
143
|
+
: [];
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
id: hashedId('session', accountId, session?.id),
|
|
147
|
+
date: dateOnly(completedAt),
|
|
148
|
+
completedAt: stringOrNull(completedAt),
|
|
149
|
+
programId: hashedId('program', accountId, session?.programId),
|
|
150
|
+
programDayIndex: numberOrNull(session?.programDayIndex),
|
|
151
|
+
totalVolume: numberOrNull(session?.summary?.totalVolume ?? session?.volume),
|
|
152
|
+
durationSeconds: numberOrNull(session?.summary?.durationSeconds),
|
|
153
|
+
effortScore: numberOrNull(session?.summary?.effortScore),
|
|
154
|
+
readiness: session?.readinessBandSnapshot
|
|
155
|
+
? {
|
|
156
|
+
band: stringOrNull(session.readinessBandSnapshot.band),
|
|
157
|
+
dominantSignal: stringOrNull(session.readinessBandSnapshot.dominantSignal),
|
|
158
|
+
adaptationApplied: stringOrNull(session.readinessBandSnapshot.adaptationApplied)
|
|
159
|
+
}
|
|
160
|
+
: null,
|
|
161
|
+
exercises: (Array.isArray(session?.exercises) ? session.exercises : [])
|
|
162
|
+
.map((exercise) => normalizeExercise(exercise, accountId)),
|
|
163
|
+
prescription: prescriptionExercises.length > 0
|
|
164
|
+
? {
|
|
165
|
+
plannedExercises: prescriptionExercises.map((exercise) => normalizePlannedExercise(exercise, accountId))
|
|
166
|
+
}
|
|
167
|
+
: null
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizeProgram(program, accountId) {
|
|
172
|
+
return {
|
|
173
|
+
id: hashedId('program', accountId, program?.id),
|
|
174
|
+
active: false,
|
|
175
|
+
completedCyclesCount: numberOrNull(program?.completedCyclesCount),
|
|
176
|
+
currentDayIndex: numberOrNull(program?.currentDayIndex),
|
|
177
|
+
days: (Array.isArray(program?.days) ? program.days : []).map((day, index) => ({
|
|
178
|
+
index,
|
|
179
|
+
plannedExercises: (Array.isArray(day?.exercises) ? day.exercises : [])
|
|
180
|
+
.map((exercise) => normalizePlannedExercise(exercise, accountId))
|
|
181
|
+
}))
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeHealthMetrics(metrics) {
|
|
186
|
+
const source = metrics ?? {};
|
|
187
|
+
return {
|
|
188
|
+
restingHR: normalizeMetricEntries(source.restingHR, (entry) => ({
|
|
189
|
+
date: dateOnly(entry?.date),
|
|
190
|
+
value: numberOrNull(entry?.value)
|
|
191
|
+
})),
|
|
192
|
+
hrv: normalizeMetricEntries(source.hrv, (entry) => ({
|
|
193
|
+
date: dateOnly(entry?.date),
|
|
194
|
+
value: numberOrNull(entry?.value)
|
|
195
|
+
})),
|
|
196
|
+
sleep: normalizeMetricEntries(source.sleep, (entry) => ({
|
|
197
|
+
date: dateOnly(entry?.date),
|
|
198
|
+
durationMins: numberOrNull(entry?.durationMins)
|
|
199
|
+
})),
|
|
200
|
+
bodyWeight: normalizeMetricEntries(source.bodyWeight, (entry) => ({
|
|
201
|
+
date: dateOnly(entry?.date),
|
|
202
|
+
value: numberOrNull(entry?.value)
|
|
203
|
+
})),
|
|
204
|
+
vo2Max: normalizeMetricEntries(source.vo2Max, (entry) => ({
|
|
205
|
+
date: dateOnly(entry?.date),
|
|
206
|
+
value: numberOrNull(entry?.value)
|
|
207
|
+
})),
|
|
208
|
+
otherWorkouts: normalizeMetricEntries(source.otherWorkouts, (entry) => ({
|
|
209
|
+
date: dateOnly(entry?.date),
|
|
210
|
+
workoutType: stringOrNull(entry?.workoutType),
|
|
211
|
+
durationSecs: numberOrNull(entry?.durationSecs),
|
|
212
|
+
distanceKm: numberOrNull(entry?.distanceKm),
|
|
213
|
+
avgHR: numberOrNull(entry?.avgHR),
|
|
214
|
+
calories: numberOrNull(entry?.calories)
|
|
215
|
+
}))
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function availabilityForMetric(entries) {
|
|
220
|
+
const range = dateRange(entries, (entry) => entry?.date);
|
|
221
|
+
return {
|
|
222
|
+
available: range.count > 0,
|
|
223
|
+
...range
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildDataAvailability(snapshot, normalized) {
|
|
228
|
+
const sessionRange = dateRange(normalized.sessions, (session) => session.date);
|
|
229
|
+
const healthSignals = Object.fromEntries(
|
|
230
|
+
Object.entries(normalized.healthMetrics).map(([key, entries]) => [key, availabilityForMetric(entries)])
|
|
231
|
+
);
|
|
232
|
+
const availableHealthSignalCount = Object.values(healthSignals).filter((signal) => signal.available).length;
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
sessions: {
|
|
236
|
+
available: normalized.sessions.length > 0,
|
|
237
|
+
...sessionRange
|
|
238
|
+
},
|
|
239
|
+
programs: {
|
|
240
|
+
available: normalized.programs.length > 0,
|
|
241
|
+
count: normalized.programs.length
|
|
242
|
+
},
|
|
243
|
+
strengthPlans: {
|
|
244
|
+
available: Array.isArray(snapshot.strengthPlans) && snapshot.strengthPlans.length > 0,
|
|
245
|
+
count: Array.isArray(snapshot.strengthPlans) ? snapshot.strengthPlans.length : 0
|
|
246
|
+
},
|
|
247
|
+
cycleSummaries: {
|
|
248
|
+
available: Array.isArray(snapshot.cycleSummaries) && snapshot.cycleSummaries.length > 0,
|
|
249
|
+
count: Array.isArray(snapshot.cycleSummaries) ? snapshot.cycleSummaries.length : 0
|
|
250
|
+
},
|
|
251
|
+
exerciseRecommendations: {
|
|
252
|
+
available: Boolean(snapshot.exerciseRecommendations && Object.keys(snapshot.exerciseRecommendations).length > 0),
|
|
253
|
+
count: snapshot.exerciseRecommendations ? Object.keys(snapshot.exerciseRecommendations).length : 0
|
|
254
|
+
},
|
|
255
|
+
health: {
|
|
256
|
+
available: availableHealthSignalCount > 0,
|
|
257
|
+
signalCount: availableHealthSignalCount,
|
|
258
|
+
signals: healthSignals
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function parseReplayAllowlist(raw) {
|
|
264
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
265
|
+
throw adapterError(
|
|
266
|
+
'Missing Increment Score replay allowlist. Set INCREMENT_SCORE_REPLAY_ALLOWLIST or pass --allowlist alias=account-id[,alias=account-id].',
|
|
267
|
+
'INCREMENT_SCORE_REPLAY_ALLOWLIST_REQUIRED'
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const allowlist = new Map();
|
|
272
|
+
for (const token of raw.split(',').map((entry) => entry.trim()).filter(Boolean)) {
|
|
273
|
+
const separator = token.indexOf('=');
|
|
274
|
+
if (separator <= 0 || separator === token.length - 1) {
|
|
275
|
+
throw adapterError(`Invalid allowlist entry "${token}". Expected alias=account-id.`, 'INCREMENT_SCORE_REPLAY_ALLOWLIST_INVALID');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const alias = token.slice(0, separator).trim();
|
|
279
|
+
const accountId = token.slice(separator + 1).trim();
|
|
280
|
+
if (!safeAliasPattern.test(alias)) {
|
|
281
|
+
throw adapterError(`Invalid replay alias "${alias}". Use lowercase letters, numbers, "_" or "-".`, 'INCREMENT_SCORE_REPLAY_ALIAS_INVALID');
|
|
282
|
+
}
|
|
283
|
+
if (!accountId) {
|
|
284
|
+
throw adapterError(`Missing account id for replay alias "${alias}".`, 'INCREMENT_SCORE_REPLAY_ACCOUNT_INVALID');
|
|
285
|
+
}
|
|
286
|
+
if (allowlist.has(alias)) {
|
|
287
|
+
throw adapterError(`Duplicate replay alias "${alias}".`, 'INCREMENT_SCORE_REPLAY_ALLOWLIST_DUPLICATE');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
allowlist.set(alias, accountId);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (allowlist.size === 0) {
|
|
294
|
+
throw adapterError('Increment Score replay allowlist is empty.', 'INCREMENT_SCORE_REPLAY_ALLOWLIST_EMPTY');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return allowlist;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function resolveReplaySelection({ requestedAliases = [], allowlist }) {
|
|
301
|
+
const selectedAliases = requestedAliases.length > 0 ? requestedAliases : [...allowlist.keys()];
|
|
302
|
+
const selected = [];
|
|
303
|
+
|
|
304
|
+
for (const alias of selectedAliases) {
|
|
305
|
+
if (!allowlist.has(alias)) {
|
|
306
|
+
throw adapterError(`Replay alias "${alias}" is not allowlisted.`, 'INCREMENT_SCORE_REPLAY_ALIAS_NOT_ALLOWLISTED');
|
|
307
|
+
}
|
|
308
|
+
selected.push({ alias, accountId: allowlist.get(alias) });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return selected;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function buildHostedSnapshotSelect(accountId) {
|
|
315
|
+
const literal = `'${String(accountId).replaceAll('\'', '\'\'')}'`;
|
|
316
|
+
return [
|
|
317
|
+
'SELECT data::text',
|
|
318
|
+
'FROM snapshots',
|
|
319
|
+
`WHERE user_id = ${literal}`,
|
|
320
|
+
'ORDER BY updated_at DESC',
|
|
321
|
+
'LIMIT 1;'
|
|
322
|
+
].join(' ');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function fetchHostedSnapshotJson(databaseId, accountId, {
|
|
326
|
+
env = process.env,
|
|
327
|
+
execFileAsyncImpl = execFileAsync
|
|
328
|
+
} = {}) {
|
|
329
|
+
if (!databaseId) {
|
|
330
|
+
throw adapterError('Missing Increment Score replay database id. Pass --database or set INCREMENT_SCORE_REPLAY_DATABASE.', 'INCREMENT_SCORE_REPLAY_DATABASE_REQUIRED');
|
|
331
|
+
}
|
|
332
|
+
if (!env.RENDER_API_KEY) {
|
|
333
|
+
throw adapterError('Missing RENDER_API_KEY. Increment Score replay adapter refuses to access hosted snapshots without credentials.', 'INCREMENT_SCORE_REPLAY_CREDENTIALS_REQUIRED');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const query = buildHostedSnapshotSelect(accountId);
|
|
337
|
+
if (!query.trim().toUpperCase().startsWith('SELECT ')) {
|
|
338
|
+
throw adapterError('Hosted snapshot query must be read-only.', 'INCREMENT_SCORE_REPLAY_QUERY_NOT_READ_ONLY');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let stdout;
|
|
342
|
+
try {
|
|
343
|
+
({ stdout } = await execFileAsyncImpl('render', [
|
|
344
|
+
'psql',
|
|
345
|
+
databaseId,
|
|
346
|
+
'--command',
|
|
347
|
+
query,
|
|
348
|
+
'--output',
|
|
349
|
+
'text',
|
|
350
|
+
'--',
|
|
351
|
+
'-A',
|
|
352
|
+
'-q',
|
|
353
|
+
'-t'
|
|
354
|
+
], {
|
|
355
|
+
env: {
|
|
356
|
+
...env,
|
|
357
|
+
CI: 'true'
|
|
358
|
+
},
|
|
359
|
+
maxBuffer: 20 * 1024 * 1024
|
|
360
|
+
}));
|
|
361
|
+
} catch (error) {
|
|
362
|
+
const wrapped = adapterError(
|
|
363
|
+
`Hosted snapshot export failed for account ${accountId}.`,
|
|
364
|
+
'INCREMENT_SCORE_REPLAY_EXPORT_FAILED'
|
|
365
|
+
);
|
|
366
|
+
wrapped.cause = error;
|
|
367
|
+
throw wrapped;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const trimmed = stdout.trimEnd();
|
|
371
|
+
if (!trimmed.startsWith('{')) {
|
|
372
|
+
throw adapterError(`Hosted snapshot export for allowlisted replay source did not return JSON.`, 'INCREMENT_SCORE_REPLAY_SNAPSHOT_NOT_FOUND');
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
return JSON.parse(trimmed);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
const wrapped = adapterError(
|
|
378
|
+
`Hosted snapshot export returned invalid JSON for account ${accountId}.`,
|
|
379
|
+
'INCREMENT_SCORE_REPLAY_SNAPSHOT_INVALID_JSON'
|
|
380
|
+
);
|
|
381
|
+
wrapped.cause = error;
|
|
382
|
+
throw wrapped;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function normalizeSnapshotForIncrementScoreReplay(snapshot, {
|
|
387
|
+
accountId,
|
|
388
|
+
alias,
|
|
389
|
+
databaseId = null,
|
|
390
|
+
extractedAt = isoNow(),
|
|
391
|
+
formulaVersion = defaultIncrementScoreFormulaVersion
|
|
392
|
+
} = {}) {
|
|
393
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
|
394
|
+
throw adapterError('Hosted snapshot payload must be a JSON object.', 'INCREMENT_SCORE_REPLAY_SNAPSHOT_INVALID');
|
|
395
|
+
}
|
|
396
|
+
if (!accountId) {
|
|
397
|
+
throw adapterError('accountId is required to normalize replay snapshot identifiers.', 'INCREMENT_SCORE_REPLAY_ACCOUNT_REQUIRED');
|
|
398
|
+
}
|
|
399
|
+
if (!alias || !safeAliasPattern.test(alias)) {
|
|
400
|
+
throw adapterError('A safe source alias is required to normalize replay snapshot data.', 'INCREMENT_SCORE_REPLAY_ALIAS_REQUIRED');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const sessions = (Array.isArray(snapshot.sessions) ? snapshot.sessions : [])
|
|
404
|
+
.map((session) => normalizeSession(session, accountId))
|
|
405
|
+
.sort((lhs, rhs) => String(lhs.completedAt ?? lhs.date ?? '').localeCompare(String(rhs.completedAt ?? rhs.date ?? '')));
|
|
406
|
+
const programs = (Array.isArray(snapshot.programs) ? snapshot.programs : [])
|
|
407
|
+
.map((program) => normalizeProgram(program, accountId));
|
|
408
|
+
const activeProgramHash = hashedId('program', accountId, snapshot.activeProgramId);
|
|
409
|
+
const normalizedPrograms = programs.map((program) => ({
|
|
410
|
+
...program,
|
|
411
|
+
active: activeProgramHash != null && program.id === activeProgramHash
|
|
412
|
+
}));
|
|
413
|
+
const healthMetrics = normalizeHealthMetrics(snapshot.healthMetrics);
|
|
414
|
+
const normalized = { sessions, programs: normalizedPrograms, healthMetrics };
|
|
415
|
+
const dataAvailability = buildDataAvailability(snapshot, normalized);
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
kind: 'incrementScoreReplayInput',
|
|
419
|
+
version: incrementScoreReplayDataVersion,
|
|
420
|
+
adapterVersion: incrementScoreProdAdapterVersion,
|
|
421
|
+
formulaVersion,
|
|
422
|
+
extractedAt,
|
|
423
|
+
source: {
|
|
424
|
+
type: 'hostedSnapshot',
|
|
425
|
+
accountAlias: alias,
|
|
426
|
+
accountHash: anonymizeAccountId(accountId),
|
|
427
|
+
databaseHash: databaseId ? `db:${stableHash(['database', databaseId], 12)}` : null,
|
|
428
|
+
snapshotExportedAt: stringOrNull(snapshot.exportedAt),
|
|
429
|
+
snapshotSchemaVersion: snapshot.schemaVersion ?? null,
|
|
430
|
+
appVersion: stringOrNull(snapshot.appVersion),
|
|
431
|
+
rawSnapshotCommitted: false
|
|
432
|
+
},
|
|
433
|
+
dataAvailability,
|
|
434
|
+
replayWindow: {
|
|
435
|
+
startDate: dataAvailability.sessions.firstDate,
|
|
436
|
+
endDate: dataAvailability.sessions.lastDate
|
|
437
|
+
},
|
|
438
|
+
sessions,
|
|
439
|
+
programs: normalizedPrograms,
|
|
440
|
+
healthMetrics
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function loadAllowlistedHostedReplayInput({
|
|
445
|
+
databaseId,
|
|
446
|
+
alias,
|
|
447
|
+
allowlist,
|
|
448
|
+
fetchSnapshot = fetchHostedSnapshotJson,
|
|
449
|
+
extractedAt = isoNow(),
|
|
450
|
+
formulaVersion = defaultIncrementScoreFormulaVersion
|
|
451
|
+
} = {}) {
|
|
452
|
+
if (!(allowlist instanceof Map)) {
|
|
453
|
+
throw adapterError('allowlist must be a Map returned by parseReplayAllowlist.', 'INCREMENT_SCORE_REPLAY_ALLOWLIST_REQUIRED');
|
|
454
|
+
}
|
|
455
|
+
if (!allowlist.has(alias)) {
|
|
456
|
+
throw adapterError(`Replay alias "${alias}" is not allowlisted.`, 'INCREMENT_SCORE_REPLAY_ALIAS_NOT_ALLOWLISTED');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const accountId = allowlist.get(alias);
|
|
460
|
+
const snapshot = await fetchSnapshot(databaseId, accountId);
|
|
461
|
+
return normalizeSnapshotForIncrementScoreReplay(snapshot, {
|
|
462
|
+
accountId,
|
|
463
|
+
alias,
|
|
464
|
+
databaseId,
|
|
465
|
+
extractedAt,
|
|
466
|
+
formulaVersion
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export async function writeReplayInputFile(replayInput, outDir) {
|
|
471
|
+
const alias = replayInput?.source?.accountAlias;
|
|
472
|
+
if (!alias || !safeAliasPattern.test(alias)) {
|
|
473
|
+
throw adapterError('Replay input missing valid source account alias.', 'INCREMENT_SCORE_REPLAY_INPUT_INVALID');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const baseDir = path.resolve(outDir);
|
|
477
|
+
await mkdir(baseDir, { recursive: true });
|
|
478
|
+
const outputPath = path.resolve(baseDir, `${alias}.increment-score-replay.json`);
|
|
479
|
+
const boundaryPrefix = baseDir.endsWith(path.sep) ? baseDir : `${baseDir}${path.sep}`;
|
|
480
|
+
if (!outputPath.startsWith(boundaryPrefix)) {
|
|
481
|
+
throw adapterError('Replay output path escaped configured output directory.', 'INCREMENT_SCORE_REPLAY_OUTPUT_PATH_INVALID');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
await writeFile(outputPath, `${JSON.stringify(replayInput, null, 2)}\n`, 'utf8');
|
|
485
|
+
return outputPath;
|
|
486
|
+
}
|