feat(workouts): add the activity-insight evidence projection and prompt

The evidence block is a numbers-only server projection: zone seconds, the
workout-window heart-rate shape, route climb and a same-sport 90-day own-history
median. Free-text metadata never enters it, and the sport string is narrowed
against the closed enum first, so the payload carries no text the user did not
generate through a measured field.

The prompt composes the shared assessment base and adds the session contract:
device-attributed, own-history-comparative, effort acknowledged where the
numbers show it, and no training prescriptions of any kind. Registered in the
tone harness beside the assessment cards.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 13:50:54 +02:00
1 parent 0dba15e902
commit e53a8ce8a0
7 files changed
+515

No files matched your search

+11
View File
@@ -405,6 +405,17 @@ async function main() {
),
);
// ───── v1.31.0 WorkoutInsight."paragraphEncrypted" (Bytes) ─────
// The per-workout Activity Insight paragraph. Not nullable — a row exists
// only where a paragraph was generated — so every scanned row rotates.
results.push(
await rotateBytesColumn(
"WorkoutInsight",
"paragraphEncrypted",
prisma.workoutInsight,
),
);
// ───── v1.18.1 clinical-spine notes (Bytes columns) ─────
// "noteEncrypted" (LabResult / IllnessEpisode / IllnessDayLog) +
// "contextEncrypted" (Biomarker). Mirror the CoachFact.factEncrypted block.
+14
View File
@@ -105,6 +105,20 @@ export const AI_BUDGETS = {
*/
statusArchetype: { temperature: 0.45, maxTokens: 700 },
/**
* Per-workout Activity Insight — the same `{ "summary": "..." }` contract,
* asked for 3-4 sentences about one recorded session. 0.45 for the same
* reason the archetype cards take it: the facts are pinned by a fully
* deterministic evidence block, so the sampling entropy only varies cadence.
*
* 260 output tokens is the whole ceiling this surface gets, and it is sized
* to the copy contract rather than rounded up: 3-4 sentences do not reach
* it, and a model that ignores the length instruction is cut off rather than
* indulged. The daily cap on the surface is four, so the worst case a single
* account can drive here is bounded before the ledger is even consulted.
*/
workoutInsight: { temperature: 0.45, maxTokens: 260 },
/**
* Batched per-metric status assessment (v1.18.7 HIGH-1) — ONE call that
* returns a `{ perMetric: { bp, weight, pulse, bmi, mood, compliance,
+92
View File
@@ -0,0 +1,92 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPromptBody } from "./base-system";
import { instructionLocale, withOutputLanguage } from "./output-language";
/**
* Per-workout Activity Insight prompt.
*
* The one surface in the assessment family that describes a SINGLE EVENT
* rather than a metric's trajectory, so it composes the shared base body — the
* opening shape, the earned-warmth tone contract, the non-diagnostic framing,
* the forbidden-filler list, the JSON output clause — and then overrides the
* data shape the same way the biomarker card does.
*
* Three things this prompt has to hold that no sibling needs:
*
* 1. **Device attribution.** The figures came off a watch or a strap, not
* off a lab bench. Saying so is honest about the precision on offer and
* is what makes the paragraph read as a record of the session rather than
* as a measurement of the person.
* 2. **No prescriptions.** Not a plan, not a target zone, not a next
* session. This is the fitness-scope-creep line, and it is the single
* easiest contract for a workout prompt to drift across — a model asked
* about a training session will volunteer training advice unless told
* plainly not to. HealthLog describes the record; it does not coach a
* training plan.
* 3. **Own history only.** The comparison is to this person's own median for
* this sport. There is no population norm in the evidence block and there
* must be none in the prose.
*/
/** Bumped whenever the instruction text changes; rides the input hash. */
export const WORKOUT_INSIGHT_PROMPT_VERSION = "1.0.0" as const;
const WORKOUT_SECTION_EN = `METRIC — ONE RECORDED SESSION:
- DIFFERENT DATA SHAPE: this snapshot carries NO signal block. The base instruction's fields that reference signal do not apply here. Instead: sportType, durationSec, distanceM, climbM, activeEnergyKcal, avgHr/maxHr/minHr, zoneSeconds (seconds per effort zone, ascending), hr (the session's heart-rate shape: firstHalfMeanBpm, secondHalfMeanBpm, driftBpm, peaks, medianSettleSec) and history (this person's own median for this sport over the lookback window).
- You are describing ONE session that has already happened, not a metric's trend. Everything is already computed; STATE it, do NOT recompute it.
- ATTRIBUTE THE FIGURES TO THE DEVICE that recorded them ("your watch put most of it in…", "the strap recorded…"). These are device readings of a session, not measurements of the person.
- COMPARE ONLY TO history — this person's own recent median for this sport. When history is absent or its sampleSize is small, say there is not much to compare against yet rather than reaching for a general standard. There is no population norm in this snapshot and none may appear in the reply.
- ACKNOWLEDGE EFFORT WHERE THE NUMBERS SHOW IT: a long session, a climb, time held in the upper zones, a faster settle after peaks than the person's usual. Earned recognition is the point of this surface. Unearned praise is not — a short easy session is described as a short easy session, which is a perfectly good thing to have done.
- NO TRAINING PRESCRIPTIONS. Do not suggest a next session, a weekly plan, a target zone, a pace, a duration, a recovery window or an intensity. Do not say what the person "should" train. This surface describes the session that happened and stops there.
- Absent fields are absent, not zero: no route means nothing about terrain, no hr block means nothing about the shape of the effort.
- Non-diagnostic: no fitness verdict, no health claim, no attribution of cause.`;
const WORKOUT_SECTION_DE = `METRIK — EINE AUFGEZEICHNETE EINHEIT:
- ABWEICHENDE DATENFORM: Dieser Snapshot trägt KEINEN signal-Block. Die Felder der Basisanweisung, die auf signal verweisen, gelten hier nicht. Stattdessen: sportType, durationSec, distanceM, climbM, activeEnergyKcal, avgHr/maxHr/minHr, zoneSeconds (Sekunden je Belastungszone, aufsteigend), hr (der Herzfrequenzverlauf der Einheit: firstHalfMeanBpm, secondHalfMeanBpm, driftBpm, peaks, medianSettleSec) und history (der eigene Median dieser Person für diese Sportart im Rückblickfenster).
- Du beschreibst EINE bereits stattgefundene Einheit, keinen Metrik-Verlauf. Alles ist bereits berechnet; benenne es, rechne es NICHT neu.
- SCHREIBE DIE ZAHLEN DEM GERÄT ZU, das sie aufgezeichnet hat („deine Uhr hat den größten Teil davon in … gelegt", „der Gurt hat … aufgezeichnet"). Das sind Gerätemesswerte einer Einheit, keine Messungen der Person.
- VERGLEICHE AUSSCHLIESSLICH mit history — dem eigenen Median dieser Person für diese Sportart. Fehlt history oder ist sampleSize klein, sage, dass es noch wenig zum Vergleichen gibt, statt auf einen allgemeinen Maßstab auszuweichen. In diesem Snapshot steht keine Bevölkerungsnorm, und in der Antwort darf keine auftauchen.
- ERKENNE ANSTRENGUNG AN, WO DIE ZAHLEN SIE ZEIGEN: eine lange Einheit, Höhenmeter, Zeit in den oberen Zonen, ein schnelleres Zurückkommen nach Spitzen als sonst bei dieser Person. Verdiente Anerkennung ist der Sinn dieser Fläche. Unverdientes Lob nicht — eine kurze lockere Einheit wird als kurze lockere Einheit beschrieben, und das ist völlig in Ordnung.
- KEINE TRAININGSVORGABEN. Schlage keine nächste Einheit vor, keinen Wochenplan, keine Zielzone, kein Tempo, keine Dauer, kein Regenerationsfenster, keine Intensität. Sage nicht, was die Person trainieren „sollte". Diese Fläche beschreibt die stattgefundene Einheit und hört dort auf.
- Fehlende Felder fehlen, sie sind nicht null: keine Route heißt nichts über das Gelände, kein hr-Block heißt nichts über den Verlauf der Anstrengung.
- Nicht diagnostisch: kein Fitnessurteil, keine Gesundheitsaussage, keine Ursachenzuschreibung.`;
export function getWorkoutInsightSystemPrompt(locale: Locale): string {
// fr/es/it/pl compose the ENGLISH body (the base prompt names their language
// and `withOutputLanguage` appends their own directive last); only de takes
// the German one. Never the `locale === "en" ? "en" : "de"` binary that sent
// four locales a German prompt.
const section =
instructionLocale(locale) === "en" ? WORKOUT_SECTION_EN : WORKOUT_SECTION_DE;
return withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`,
locale,
);
}
export function getWorkoutInsightUserPrompt(
evidenceJson: string,
todayKey: string,
locale: Locale,
/** Rotating opener-archetype hint; see metric-archetypes.ts. */
openerHint?: string,
): string {
const openerLine =
openerHint && openerHint.trim().length > 0
? `\nOPENER HINT: ${openerHint}`
: "";
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey}${openerLine}
Write 3 to 4 sentences about this one recorded session. Open with what the session WAS in plain words — the character of it, not the number (e.g. "a steady, aerobic-leaning ride", "a short sharp one with two hard efforts in it") — then bring in ONE concrete figure from the snapshot right after as support. Attribute the figures to the device that recorded them. Place the session against this person's own recent median for the sport and against nothing else. Acknowledge the effort where the numbers show it, plainly and without exclamation. Do not prescribe any training — no next session, no plan, no target zone, no pace.
${evidenceJson}`;
}
return `Datum: ${todayKey}${openerLine}
Schreibe 3 bis 4 Sätze über diese eine aufgezeichnete Einheit. Beginne mit dem CHARAKTER der Einheit in klaren Worten — wie sie war, nicht der Zahl (z. B. „eine ruhige, aerob geprägte Ausfahrt", „eine kurze knackige mit zwei harten Abschnitten") — und bring danach EINE konkrete Zahl aus dem Snapshot als Beleg. Schreibe die Zahlen dem Gerät zu, das sie aufgezeichnet hat. Ordne die Einheit gegen den eigenen Median dieser Person für die Sportart ein und gegen nichts sonst. Erkenne die Anstrengung an, wo die Zahlen sie zeigen, sachlich und ohne Ausrufezeichen. Gib keinerlei Trainingsvorgaben — keine nächste Einheit, keinen Plan, keine Zielzone, kein Tempo.
${evidenceJson}`;
}
+5
View File
@@ -177,6 +177,11 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// install's rows rotate as a clean no-op.
{ model: "ArrivalReaction", field: "lineEncrypted", kind: "bytes" },
// ───── v1.31.0 per-workout Activity Insight (Bytes column) ─────
// NOT nullable: a row exists only once a paragraph was actually generated,
// so every row here carries ciphertext and every one must rotate.
{ model: "WorkoutInsight", field: "paragraphEncrypted", kind: "bytes" },
// ───── v1.18.1 clinical-spine notes (Bytes columns) ─────
{ model: "LabResult", field: "noteEncrypted", kind: "bytes" },
{ model: "Biomarker", field: "contextEncrypted", kind: "bytes" },
+12
View File
@@ -45,6 +45,10 @@ import {
getWeightSystemPrompt,
getWeightUserPrompt,
} from "@/lib/ai/prompts/weight";
import {
getWorkoutInsightSystemPrompt,
getWorkoutInsightUserPrompt,
} from "@/lib/ai/prompts/workout-insight";
import type { MetricStatusMeta } from "@/lib/insights/metric-status-registry";
/** Inert snapshot placeholder — the rules never read it. */
@@ -170,4 +174,12 @@ export const ASSESSMENT_SURFACES: readonly AssessmentSurface[] = [
system: (l) => getBiomarkerSystemPrompt("Marker", l),
user: (l) => getBiomarkerUserPrompt(SNAPSHOT, TODAY, l, OPENER_HINT),
},
{
// Describes one recorded session rather than a metric's trajectory, but it
// composes the same base body and owes the same opening contract — so it
// is graded here rather than given its own dialect.
name: "workout-insight",
system: getWorkoutInsightSystemPrompt,
user: (l) => getWorkoutInsightUserPrompt(SNAPSHOT, TODAY, l, OPENER_HINT),
},
];
+310
View File
@@ -0,0 +1,310 @@
/**
* The deterministic evidence block behind the per-workout Activity Insight.
*
* Everything the model is ever told about a session is built here, and the
* shape of this module is the security boundary of the whole feature: it is a
* NUMBERS-ONLY projection over closed fields. `Workout.metadata` is a JSON blob
* carrying device bundle ids, HKWorkoutEvent markers and per-vendor extras —
* free text the user never wrote and we never reviewed. None of it reaches a
* prompt. The one non-numeric field that survives, `sportType`, is narrowed
* against the closed `workoutSportTypeEnum` first, so even a hand-inserted row
* with a crafted sport string projects as `"other"`.
*
* The consequence worth stating plainly: there is no injection surface here to
* defend, because there is no attacker-controlled text in the payload at all.
* That is a property of this file, and it is why the workout coach launch does
* not need the fenced endpoint the document surfaces use.
*
* Determinism is the second contract. The same session always projects to the
* same numbers, which is what makes the input hash a real no-op gate: a WHOOP
* re-sync or an iOS `stats:` overwrite that changes nothing the paragraph
* narrates hashes identically and never reaches a provider.
*/
import { userDayKey } from "@/lib/tz/format";
import { workoutSportTypeEnum } from "@/lib/validations/workout";
import type { WorkoutHrSeries } from "./hr-series";
import type { WorkoutZones } from "./zones";
/** Lookback for the own-history comparison, in days. */
export const OWN_HISTORY_LOOKBACK_DAYS = 90;
/** Cap on the history rows the median reads — bounded work on a heavy account. */
export const OWN_HISTORY_MAX_ROWS = 400;
/**
* The shape of the session's heart rate, reduced to four figures.
*
* Derived from the SAME series the detail route renders (`buildWorkoutHrSeries`),
* deliberately not from `loadIntradayPulse`: that read is day-scoped, so it
* would answer a question about the day rather than about the session, and the
* workout-window series is the richer of the two anyway.
*/
export interface WorkoutHrShape {
source: WorkoutHrSeries["source"];
bucketSec: number;
buckets: number;
/** Mean bpm across the first half of the session's buckets. */
firstHalfMeanBpm: number;
/** Mean bpm across the second half. */
secondHalfMeanBpm: number;
/** secondHalf − firstHalf. Positive = drifting up as the session went on. */
driftBpm: number;
/** Buckets that stand out as a local high point (see `countPeaks`). */
peaks: number;
/**
* Median seconds from a peak back down to the session's mean. Null when no
* peak ever came back down inside the session.
*/
medianSettleSec: number | null;
}
/** The user's own recent baseline for this sport. Medians, never means. */
export interface WorkoutOwnHistory {
lookbackDays: number;
/** Comparable sessions found, excluding this one. */
sampleSize: number;
medianDurationSec: number;
medianAvgHr: number | null;
medianDistanceM: number | null;
medianEnergyKcal: number | null;
}
export interface WorkoutInsightEvidence {
/** Narrowed against the closed enum — never the raw column. */
sportType: string;
/** User-profile-timezone day the session started. */
localDate: string;
durationSec: number;
distanceM: number | null;
/** Metres climbed, from route altitudes when present, else the column. */
climbM: number | null;
activeEnergyKcal: number | null;
avgHr: number | null;
maxHr: number | null;
minHr: number | null;
/** Seconds in each %HRmax (or device) zone, ascending by zone. */
zoneSeconds: { zone: number; seconds: number }[] | null;
hr: WorkoutHrShape | null;
history: WorkoutOwnHistory | null;
}
/**
* Narrow the stored sport string to the closed vocabulary.
*
* The DB column is free text by design (a new sport is one Zod refinement
* away), so this is the whitelist that keeps an unreviewed value out of the
* prompt. Anything unrecognised projects as `"other"` — the same bucket the
* ingest validator uses.
*/
export function narrowSportType(raw: string): string {
const parsed = workoutSportTypeEnum.safeParse(raw);
return parsed.success ? parsed.data : "other";
}
function median(values: number[]): number | null {
const sorted = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
if (sorted.length === 0) return null;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1
? sorted[mid]
: Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 10) / 10;
}
/**
* Count the session's peaks.
*
* A peak is a bucket that is both a strict local maximum against its
* neighbours AND at or above the 90th percentile of the session's bucket
* means. Requiring both is what keeps a steady ride from reporting fifty
* "peaks" out of ordinary sampling noise, and what keeps an interval session
* from reporting one.
*/
function countPeaks(means: number[], threshold: number): number[] {
const indices: number[] = [];
for (let i = 1; i < means.length - 1; i++) {
if (
means[i] >= threshold &&
means[i] > means[i - 1] &&
means[i] >= means[i + 1]
) {
indices.push(i);
}
}
return indices;
}
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const idx = Math.min(
sorted.length - 1,
Math.max(0, Math.round((sorted.length - 1) * p)),
);
return sorted[idx];
}
/**
* Reduce the HR curve to the four figures the paragraph can honestly use.
*
* Returns null below three buckets: two points cannot carry a front/back-half
* comparison, and inventing one from them is exactly the overclaiming the copy
* contract forbids.
*/
export function summariseHrShape(
series: WorkoutHrSeries | null,
): WorkoutHrShape | null {
if (!series || series.points.length < 3) return null;
const means = series.points.map((p) => p.mean);
const half = Math.floor(means.length / 2);
const avg = (xs: number[]) =>
Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 10) / 10;
const firstHalfMeanBpm = avg(means.slice(0, half));
const secondHalfMeanBpm = avg(means.slice(means.length - half));
const sessionMean = avg(means);
const sorted = [...means].sort((a, b) => a - b);
const peakIndices = countPeaks(means, percentile(sorted, 0.9));
// Settle time: from each peak, how long until the curve first comes back to
// the session's own mean. A peak that never settles inside the session
// contributes nothing rather than a censored figure pretending to be one.
const settles: number[] = [];
for (const idx of peakIndices) {
for (let j = idx + 1; j < means.length; j++) {
if (means[j] <= sessionMean) {
settles.push((j - idx) * series.bucketSec);
break;
}
}
}
return {
source: series.source,
bucketSec: series.bucketSec,
buckets: series.points.length,
firstHalfMeanBpm,
secondHalfMeanBpm,
driftBpm: Math.round((secondHalfMeanBpm - firstHalfMeanBpm) * 10) / 10,
peaks: peakIndices.length,
medianSettleSec: settles.length > 0 ? median(settles) : null,
};
}
/**
* Total metres climbed, summed from a GeoJSON LineString's altitude channel.
*
* Only positive deltas count — this is climb, not net elevation change, which
* is the figure a rider recognises. Returns null when the geometry carries no
* altitude channel at all (Withings ships static GPX without one), so the
* caller can fall back to the denormalised column instead of reporting a
* confident zero.
*/
export function routeClimbM(geometry: unknown): number | null {
if (!geometry || typeof geometry !== "object") return null;
const coords = (geometry as { coordinates?: unknown }).coordinates;
if (!Array.isArray(coords)) return null;
let climb = 0;
let previous: number | null = null;
let seen = 0;
for (const point of coords) {
if (!Array.isArray(point) || point.length < 3) continue;
const alt = point[2];
if (typeof alt !== "number" || !Number.isFinite(alt)) continue;
seen++;
if (previous !== null && alt > previous) climb += alt - previous;
previous = alt;
}
return seen >= 2 ? Math.round(climb) : null;
}
/** Slim row shape the own-history median reads. */
export interface OwnHistoryRow {
durationSec: number;
avgHeartRate: number | null;
totalDistanceM: number | null;
totalEnergyKcal: number | null;
}
/**
* The user's own median for this sport over the lookback window.
*
* Medians rather than means because a single six-hour outing would drag a mean
* far enough that the paragraph's comparison would be wrong in the ordinary
* case. Returns null below three comparable sessions: two sessions do not make
* a baseline, and the copy contract says so plainly instead.
*/
export function summariseOwnHistory(
rows: readonly OwnHistoryRow[],
): WorkoutOwnHistory | null {
if (rows.length < 3) return null;
const durations = rows.map((r) => r.durationSec);
const medianDurationSec = median(durations);
if (medianDurationSec === null) return null;
const numeric = (pick: (r: OwnHistoryRow) => number | null): number[] =>
rows
.map(pick)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
return {
lookbackDays: OWN_HISTORY_LOOKBACK_DAYS,
sampleSize: rows.length,
medianDurationSec: Math.round(medianDurationSec),
medianAvgHr: median(numeric((r) => r.avgHeartRate)),
medianDistanceM: median(numeric((r) => r.totalDistanceM)),
medianEnergyKcal: median(numeric((r) => r.totalEnergyKcal)),
};
}
/** The already-loaded workout row the projection reads. */
export interface WorkoutEvidenceRow {
sportType: string;
startedAt: Date;
durationSec: number;
totalDistanceM: number | null;
totalEnergyKcal: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
minHeartRate: number | null;
elevationM: number | null;
}
export interface BuildEvidenceInput {
row: WorkoutEvidenceRow;
tz: string;
/** The detail route's HR curve for this workout, or null. */
hrSeries: WorkoutHrSeries | null;
/** Zones as the detail route computes them, or null. */
zones: WorkoutZones | null;
/** `WorkoutRoute.geometry`, or null when the source shipped no route. */
routeGeometry: unknown;
/** Same-sport sessions inside the lookback, excluding this workout. */
history: readonly OwnHistoryRow[];
}
/**
* Compose the evidence block. Pure — every read has already happened.
*/
export function buildWorkoutInsightEvidence(
input: BuildEvidenceInput,
): WorkoutInsightEvidence {
const { row } = input;
return {
sportType: narrowSportType(row.sportType),
localDate: userDayKey(row.startedAt, input.tz),
durationSec: row.durationSec,
distanceM: row.totalDistanceM,
climbM: routeClimbM(input.routeGeometry) ?? row.elevationM,
activeEnergyKcal: row.totalEnergyKcal,
avgHr: row.avgHeartRate,
maxHr: row.maxHeartRate,
minHr: row.minHeartRate,
zoneSeconds:
input.zones?.zones.map((z) => ({ zone: z.zone, seconds: z.seconds })) ??
null,
hr: summariseHrShape(input.hrSeries),
history: summariseOwnHistory(input.history),
};
}
+71
View File
@@ -0,0 +1,71 @@
/**
* The pure half of the Activity Insight gate stack.
*
* Five gates stand between a workout landing and a provider call, and they run
* in this order for a reason — each one is cheaper than the one after it, so
* the common refusals cost nothing:
*
* 1. modules — `workouts` AND `insights` both on (one cached map lookup)
* 2. duration — at least `MIN_DURATION_SEC` (a field already in hand)
* 3. daily cap — at most `MAX_INSIGHTS_PER_DAY` generated today (one COUNT)
* 4. input hash — the evidence is unchanged since the last paragraph (one
* indexed read, no provider resolution)
* 5. budget — the token ledger's reserve/reconcile, inside
* `runStatusCompletion`
*
* Gates 2 and 4 are pure and live here so each can be tested on its own. The
* other three need the database and live in the worker beside their query.
*
* Every one of them is a SEPARATE claim. The daily cap does not depend on the
* hash holding, and the hash does not depend on the singleton queue key
* holding: a device that double-posts the same session must be stopped by the
* unique row and by the hash independently, because a queue key is a
* best-effort de-duplication and a lost singleton race is a normal event.
*/
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import type { WorkoutInsightEvidence } from "./insight-evidence";
/**
* The duration floor.
*
* Ten minutes. Below it there is nothing a paragraph can honestly say that the
* stats row does not already say better — a four-minute walk has no zone
* distribution, no front/back-half story and no meaningful comparison to a
* ninety-day median. Silence is the correct output, not a padded sentence.
*/
export const MIN_DURATION_SEC = 600;
/**
* The hard per-user daily ceiling on generated paragraphs.
*
* This is a COUNT of rows written today, not a rate limit: it holds even if
* the queue keys, the unique constraint and the hash all fail at once, which
* is exactly what a hard cap is for. Four is above any realistic training day
* and two orders of magnitude below a device stuck in a re-post loop.
*/
export const MAX_INSIGHTS_PER_DAY = 4;
/** True when the session is long enough to describe. */
export function meetsDurationFloor(durationSec: number): boolean {
return Number.isFinite(durationSec) && durationSec >= MIN_DURATION_SEC;
}
/**
* Fingerprint the evidence together with the prompt version.
*
* `hashInsightSnapshot` canonicalises key order and drops volatile keys, so
* two projections of the same session hash identically regardless of how the
* object was assembled. Including the prompt version means a deliberate prompt
* change re-opens generation exactly once per workout, while a re-sync of
* unchanged data stays free forever.
*
* What is deliberately NOT in here: anything that moves on its own. No
* timestamps, no row ids, no provider name. A hash that drifted with the clock
* would turn the cheapest gate in the stack into a no-op.
*/
export function workoutInsightInputHash(
evidence: WorkoutInsightEvidence,
promptVersion: string,
): string {
return hashInsightSnapshot({ evidence, promptVersion });
}