From 1a469301b35a62dff36a1980eec1f0f86c012ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 13:56:50 +0200 Subject: [PATCH] feat(workouts): generate the per-workout insight on arrival, never on view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spine dispatches a generator-free enqueue; the worker resolves the provider. Five gates stand in front of it in ascending cost order — both modules on, ten-minute duration floor, a hard count of four a day in the user's own timezone, an input hash over the deterministic evidence, and the token ledger inside the shared status chokepoint. Each refusal returns a status rather than throwing, because retrying against a ceiling that does not move until midnight is an unbounded loop. The detail route only reads: a workout with no row serves null and renders no card, which is every workout that predates this and every re-synced one. An undecryptable paragraph degrades to no card rather than failing the page. --- docs/api/openapi.yaml | 27 +- src/app/api/workouts/[id]/route.ts | 37 +- src/hooks/use-workouts.ts | 20 +- src/lib/ai/prompts/workout-insight.ts | 4 +- src/lib/jobs/data-arrival.ts | 29 +- src/lib/jobs/reminder/register-status.ts | 19 + .../jobs/workout-insight-generate-shared.ts | 66 ++++ src/lib/jobs/workout-insight-generate.ts | 365 ++++++++++++++++++ src/lib/openapi/routes/workouts.ts | 18 +- 9 files changed, 556 insertions(+), 29 deletions(-) create mode 100644 src/lib/jobs/workout-insight-generate-shared.ts create mode 100644 src/lib/jobs/workout-insight-generate.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b8fc1dc1..abd119f89 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3031,10 +3031,12 @@ paths: summary: Workout detail (v1.4.32) description: Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive - enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`, reserved `aiInsight`) are computed - server-side. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and - `route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32 - contract. Cross-user rows surface as 404 (existence channel sealed). + enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`) are computed server-side. `aiInsight` is a + pure read of a per-workout paragraph written by a background job when the workout landed; it is null for every + workout that predates the feature or was re-synced, and reading this endpoint never generates one. `compact=1` + (sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the + response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 + (existence channel sealed). parameters: - in: path name: id @@ -18341,7 +18343,9 @@ components: - $ref: "#/components/schemas/WorkoutSportContext" - type: "null" aiInsight: - type: "null" + anyOf: + - $ref: "#/components/schemas/WorkoutActivityInsight" + - type: "null" canonicalId: type: string required: @@ -18540,6 +18544,19 @@ components: - avgDistanceM - avgAvgHr additionalProperties: false + WorkoutActivityInsight: + type: object + properties: + paragraph: + type: string + generatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - paragraph + - generatedAt + additionalProperties: false BatchWorkoutsResponse: type: object properties: diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 4cf93eae1..cda43de06 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -31,6 +31,7 @@ import { annotate } from "@/lib/logging/context"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series"; import { computeZones, @@ -79,6 +80,9 @@ export const GET = apiHandler( sampleCount: true, }, }, + insight: { + select: { paragraphEncrypted: true, generatedAt: true }, + }, }, }); @@ -209,6 +213,26 @@ export const GET = apiHandler( } : null; + // Decrypt the stored paragraph. Fail-SOFT here and only here: `decrypt` is + // fail-closed by design, so a rotated-away key would otherwise turn the + // whole workout-detail page into a 500 over a garnish field. An + // undecryptable paragraph degrades to no card, exactly like a workout that + // never had one. + let aiInsight: { paragraph: string; generatedAt: string } | null = null; + if (row.insight) { + try { + aiInsight = { + paragraph: decryptFromBytes(row.insight.paragraphEncrypted), + generatedAt: row.insight.generatedAt.toISOString(), + }; + } catch { + annotate({ + action: { name: "workouts.detail.insight_undecryptable" }, + meta: { workoutId: row.id }, + }); + } + } + return apiSuccess({ id: row.id, sportType: row.sportType, @@ -233,12 +257,13 @@ export const GET = apiHandler( zones, splits, sportContext, - // Reserved Activity-Insight seam (strategic-concept Wave C, bet 5): - // always null today. The Phase-2 pg-boss job writes a cached - // paragraph onto the workout row and maps it here — the detail page - // already composes `{aiInsight ? : null}`, so the card - // mounts with zero layout rework. - aiInsight: null, + // The per-workout Activity Insight. A pure READ of a row the + // `workout-insight-generate` worker wrote when this workout landed — + // this route never generates, never enqueues, and never falls back to + // a provider. A workout with no row (every historical one, every + // re-synced one, every one on a provider-less install) serves null and + // the page's `{aiInsight ? : null}` renders nothing. + aiInsight, // v1.4.32 — when the requested id is a non-canonical twin the // caller can redirect to `canonicalId` to land on the cluster // winner. `canonicalId === id` when the requested row already diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index f33cbece8..150631149 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -167,13 +167,21 @@ export interface WorkoutSportContextDto { } /** - * Reserved Activity-Insight payload (strategic-concept Wave C, bet 5). - * The wire field is `null` today; the Phase-2 job will populate it and - * the detail page's seam mounts a card with zero rework. Declared here - * so the type is stable in advance — widen the union (never change the - * seam shape) when the job ships. + * The per-workout Activity Insight, or `null`. + * + * The seam shape is unchanged from when it was reserved — the union widened, + * nothing else. `null` remains the overwhelmingly common case and is not an + * error state: a paragraph exists only for a workout that LANDED while the + * feature was live, in a session over ten minutes, under the day's cap, with a + * provider reachable. Every historical workout, every re-synced one, and every + * workout on a provider-less install reads `null` and renders no card. Nothing + * on this read path can ever trigger a generation. */ -export type WorkoutActivityInsight = null; +export type WorkoutActivityInsight = { + /** Plain text. Rendered as React text children — there is no markdown here. */ + paragraph: string; + generatedAt: string; +} | null; export interface WorkoutDetailPayload extends WorkoutListEntry { minHr: number | null; diff --git a/src/lib/ai/prompts/workout-insight.ts b/src/lib/ai/prompts/workout-insight.ts index dd94f7ef4..f1706ef83 100644 --- a/src/lib/ai/prompts/workout-insight.ts +++ b/src/lib/ai/prompts/workout-insight.ts @@ -57,7 +57,9 @@ export function getWorkoutInsightSystemPrompt(locale: Locale): string { // 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; + instructionLocale(locale) === "en" + ? WORKOUT_SECTION_EN + : WORKOUT_SECTION_DE; return withOutputLanguage( `${getBaseSystemPromptBody(locale)} diff --git a/src/lib/jobs/data-arrival.ts b/src/lib/jobs/data-arrival.ts index 67582c091..07f65256d 100644 --- a/src/lib/jobs/data-arrival.ts +++ b/src/lib/jobs/data-arrival.ts @@ -37,6 +37,10 @@ import { withBackgroundEvent } from "@/lib/logging/background"; import { DATA_ARRIVAL_QUEUE } from "@/lib/arrivals/emit-shared"; import { isArrivalKind, type DataArrival } from "@/lib/arrivals/types"; +// Generator-free by construction — see that module's header. Importing the +// worker instead would make its provider clients reachable from this file and +// break the spine's structural zero-spend guard. +import { enqueueWorkoutInsight } from "@/lib/jobs/workout-insight-generate-shared"; import { getWorkerPrisma, workerLog } from "./reminder/shared"; @@ -124,13 +128,24 @@ export async function runDataArrival( break; case "workout": - // DISPATCH POINT — the per-workout Activity Insight consumes this. - // It is deliberately NOT gated on `claimed`: two workouts in one day are - // two events and both deserve a paragraph, whereas the day-scoped marker - // can only be claimed once. Its own once-per-workout key, duration - // floor, hard daily cap, input hash and token-ledger reservation bound - // the spend — none of which belong in the spine. - actions.push("workout_pending_insight"); + // The per-workout Activity Insight. Deliberately NOT gated on `claimed`: + // two workouts in one day are two events and both deserve a paragraph, + // whereas the day-scoped marker can only be claimed once. Its own + // once-per-workout key, duration floor, hard daily cap, input hash and + // token-ledger reservation bound the spend — none of which belong in the + // spine, which is why only the generator-free enqueue is imported here. + if (arrival.refId) { + await enqueueWorkoutInsight({ + userId: arrival.userId, + workoutId: arrival.refId, + }); + actions.push("workout_insight_enqueued"); + } else { + // A workout arrival with no referent cannot address a paragraph. The + // seams always carry one; annotating rather than guessing keeps a + // future seam that forgets it visible. + actions.push("workout_no_ref"); + } break; case "weight": diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index c8cab6683..8d102aaf5 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -30,6 +30,12 @@ import { type MorningDigestRefreshPayload, } from "@/lib/jobs/morning-digest-refresh"; import { DATA_ARRIVAL_QUEUE, handleDataArrival } from "@/lib/jobs/data-arrival"; +import { + handleWorkoutInsightGenerate, + WORKOUT_INSIGHT_GENERATE_CONCURRENCY, + WORKOUT_INSIGHT_GENERATE_QUEUE, + type WorkoutInsightGeneratePayload, +} from "@/lib/jobs/workout-insight-generate"; import type { DataArrival } from "@/lib/arrivals/types"; import { DAILY_BRIEFING_QUEUE, @@ -179,6 +185,10 @@ const allQueues = [ // this entry pg-boss never provisions the queue and every emit silently // drops (the v1.4.37 dead-queue class). DATA_ARRIVAL_QUEUE, + // v1.31.0 — the per-workout Activity Insight, dispatched by the spine when a + // workout lands. No cron schedule — nothing warms it and no read path + // triggers it, so this entry is the only thing that provisions the queue. + WORKOUT_INSIGHT_GENERATE_QUEUE, // v1.10.0 — computed scores (WX-C / WX-E). Nightly Recovery / Stress / // Strain compute + store. The queue MUST be registered here or pg-boss // never provisions it and the nightly schedule silently never fires. @@ -532,6 +542,15 @@ export async function registerStatusQueues( { localConcurrency: 1 }, handleDataArrival, ); + // v1.31.0 — the per-workout Activity Insight. Serial: this IS the provider + // path the spine deliberately does not walk, so it stays off the request pool + // and cannot fan out concurrent completions when several workouts land in one + // sync. + await boss.work( + WORKOUT_INSIGHT_GENERATE_QUEUE, + { localConcurrency: WORKOUT_INSIGHT_GENERATE_CONCURRENCY }, + handleWorkoutInsightGenerate, + ); // S5 — daily-briefing fallback slot. Single-flight; the `push_attempts` // frequency cap makes an overlapping tick a no-op (a second same-day attempt // is suppressed), and the tick only loads a digest for opted-in users at diff --git a/src/lib/jobs/workout-insight-generate-shared.ts b/src/lib/jobs/workout-insight-generate-shared.ts new file mode 100644 index 000000000..d12c10390 --- /dev/null +++ b/src/lib/jobs/workout-insight-generate-shared.ts @@ -0,0 +1,66 @@ +/** + * Generator-free enqueue contract for the per-workout Activity Insight. + * + * The split matters structurally, not stylistically. The data-arrival spine's + * central cost claim — that the worker cannot spend a token — is enforced by a + * module-graph test that BFS-walks the static imports of + * `@/lib/jobs/data-arrival`. The spine dispatches THIS surface, so if the + * dispatch pulled in the generator, the generator's provider clients would + * become reachable from the spine and that guard would go red, correctly. + * + * So the spine imports only this module: a queue name, a payload, and a + * `boss.send`. The worker that resolves a provider lives in + * `workout-insight-generate.ts` and is imported solely by the worker registrar. + */ +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { annotate } from "@/lib/logging/context"; + +export const WORKOUT_INSIGHT_GENERATE_QUEUE = "workout-insight-generate"; + +/** Serial — this is a provider path, kept off the request pool entirely. */ +export const WORKOUT_INSIGHT_GENERATE_CONCURRENCY = 1; + +export interface WorkoutInsightGeneratePayload { + userId: string; + workoutId: string; +} + +/** + * Enqueue one workout's paragraph generation. + * + * The singleton key is the workout id, so a device that posts the same session + * three times during a sync retry produces one job. That is a best-effort + * collapse and is treated as such: the worker does not rely on it. The unique + * `WorkoutInsight.workoutId` row and the input-hash gate each independently + * refuse a second generation, so a lost singleton race costs one cheap read, + * never a second provider call. + * + * Fire-and-forget in the strictest sense: no boss, or a failed send, is a + * no-op. A workout with no paragraph renders no card, which is the surface's + * honest empty state rather than an error condition. + */ +export async function enqueueWorkoutInsight( + payload: WorkoutInsightGeneratePayload, +): Promise<{ enqueued: boolean }> { + const boss = getGlobalBoss(); + if (!boss) return { enqueued: false }; + try { + await boss.send(WORKOUT_INSIGHT_GENERATE_QUEUE, payload, { + singletonKey: `workout-insight:${payload.workoutId}`, + // The LLM-bound queues' shared policy: a provider hiccup or a pool + // exhaustion earns three backed-off retries. Every BUSINESS refusal in + // the worker returns a status instead of throwing, so a retry here only + // ever re-runs a genuinely transient fault. + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + }); + return { enqueued: true }; + } catch { + annotate({ + action: { name: "workouts.insight.enqueue_failed" }, + meta: { workoutId: payload.workoutId }, + }); + return { enqueued: false }; + } +} diff --git a/src/lib/jobs/workout-insight-generate.ts b/src/lib/jobs/workout-insight-generate.ts new file mode 100644 index 000000000..05739c48c --- /dev/null +++ b/src/lib/jobs/workout-insight-generate.ts @@ -0,0 +1,365 @@ +/** + * The per-workout Activity Insight worker. + * + * Dispatched by the data-arrival spine when a workout genuinely LANDS. There is + * no other trigger and there is deliberately no regenerate button: a per-workout + * provider button is a saturation surface, and a read path that could generate + * would turn opening the workout list on a fresh install into a bill. + * + * The consequence is worth stating because it looks like a bug and is not: every + * workout that predates this feature, and every workout re-synced from a + * provider backfill, renders NO card, forever. That is the design. The spine's + * recency classifier drops historical samples before anything is enqueued, the + * upsert seams only emit for rows they actually created, and nothing on the read + * side ever writes here. + * + * Five gates stand in front of the provider, in ascending cost order, and each + * is a claim on its own rather than a layer of one claim: + * + * 1. modules — `workouts` AND `insights` on + * 2. duration — at least ten minutes of session + * 3. daily cap — at most four paragraphs generated today + * 4. inputHash — the evidence is unchanged since the last paragraph + * 5. budget — the token ledger, inside `runStatusCompletion` + * + * Gate 3 and gate 4 both independently stop a device double-post, which is why + * they are both here: the singleton queue key that would normally collapse a + * burst is best-effort, and a lost singleton race is an ordinary event. + * + * Every business refusal RETURNS a status. It does not throw. pg-boss retries a + * failed job, and retrying against a daily cap that does not move until midnight + * is an unbounded loop. Only a genuine transient fault escapes to earn its + * backed-off retries. + */ +import type { Job } from "pg-boss"; + +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; +import { + getWorkoutInsightSystemPrompt, + getWorkoutInsightUserPrompt, + WORKOUT_INSIGHT_PROMPT_VERSION, +} from "@/lib/ai/prompts/workout-insight"; +import { prisma } from "@/lib/db"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { finalizeStatusSummary } from "@/lib/insights/status-shared"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { annotate } from "@/lib/logging/context"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series"; +import { + buildWorkoutInsightEvidence, + OWN_HISTORY_LOOKBACK_DAYS, + OWN_HISTORY_MAX_ROWS, +} from "@/lib/workouts/insight-evidence"; +import { + MAX_INSIGHTS_PER_DAY, + meetsDurationFloor, + workoutInsightInputHash, +} from "@/lib/workouts/insight-gates"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { + computeZones, + hrMaxFromAge, + parseWhoopZoneDurations, +} from "@/lib/workouts/zones"; + +import { + WORKOUT_INSIGHT_GENERATE_CONCURRENCY, + WORKOUT_INSIGHT_GENERATE_QUEUE, + type WorkoutInsightGeneratePayload, +} from "./workout-insight-generate-shared"; + +export { WORKOUT_INSIGHT_GENERATE_CONCURRENCY, WORKOUT_INSIGHT_GENERATE_QUEUE }; +export type { WorkoutInsightGeneratePayload }; + +const MS_PER_DAY = 86_400_000; + +/** + * What one run did. `skipped` is a SUCCESS — the distinction is observability. + */ +export type WorkoutInsightOutcome = + | { status: "skipped"; reason: string } + | { status: "generated"; providerType: string; length: number }; + +/** + * Generate one workout's paragraph. Owner-scoped throughout: `userId` comes + * from the queue payload the spine wrote from an authenticated ingest, and it + * is in the `where` of every read and every write below. There is no path here + * that resolves a workout by id alone. + */ +export async function runWorkoutInsightGenerate( + payload: WorkoutInsightGeneratePayload, + now: Date = new Date(), +): Promise { + const { userId, workoutId } = payload; + if (!userId || !workoutId) + return { status: "skipped", reason: "bad_payload" }; + + // ── Gate 1: modules ────────────────────────────────────────────────────── + // Both, not either. `workouts` is the domain the surface lives in and + // `insights` is the AI surface family it belongs to; a user who turned either + // off has said no to this card. + const modules = await resolveModuleMap(userId); + if (modules.workouts === false || modules.insights === false) { + return { status: "skipped", reason: "module_off" }; + } + + const row = await prisma.workout.findFirst({ + where: { id: workoutId, userId }, + select: { + id: true, + sportType: true, + startedAt: true, + endedAt: true, + durationSec: true, + totalDistanceM: true, + totalEnergyKcal: true, + avgHeartRate: true, + maxHeartRate: true, + minHeartRate: true, + elevationM: true, + metadata: true, + route: { select: { geometry: true } }, + samples: { select: { samples: true } }, + }, + }); + // Gone between the emit and the run (a hard delete), or never ours. + if (!row) return { status: "skipped", reason: "not_found" }; + + // ── Gate 2: duration floor ─────────────────────────────────────────────── + // A four-minute walk has no zone distribution, no front/back-half story and + // nothing to compare. Silence is the honest output. + if (!meetsDurationFloor(row.durationSec)) { + annotate({ + action: { name: "workouts.insight.skipped" }, + meta: { workoutId, reason: "too_short", durationSec: row.durationSec }, + }); + return { status: "skipped", reason: "too_short" }; + } + + const tz = await resolveUserTimezone(userId); + + // ── Gate 3: hard daily cap ─────────────────────────────────────────────── + // A COUNT, not a rate limit. It holds even if the queue key, the unique row + // and the hash all fail at once — which is exactly what a hard cap is for. + // The window is the USER's local day, so "four a day" means what a user + // would mean by it, not what UTC would. + const dayStart = startOfUserDay(now, tz); + const generatedToday = await prisma.workoutInsight.count({ + where: { userId, generatedAt: { gte: dayStart } }, + }); + if (generatedToday >= MAX_INSIGHTS_PER_DAY) { + annotate({ + action: { name: "workouts.insight.skipped" }, + meta: { workoutId, reason: "daily_cap", generatedToday }, + }); + return { status: "skipped", reason: "daily_cap" }; + } + + // ── Evidence: deterministic, numbers-only, no free text ────────────────── + const profile = await prisma.user.findUnique({ + where: { id: userId }, + select: { dateOfBirth: true, locale: true }, + }); + + // The SAME builder the detail route serves `hrSeries` from — not the + // day-scoped intraday read, which would answer a question about the day + // rather than about the session. + const hrSeries = await buildWorkoutHrSeries({ + userId, + startedAt: row.startedAt, + endedAt: row.endedAt, + durationSec: row.durationSec, + storedSamples: row.samples?.samples ?? null, + now, + }); + + const zones = computeZones({ + hrMax: hrMaxFromAge(getAgeFromDateOfBirth(profile?.dateOfBirth ?? null)), + series: hrSeries?.points ?? [], + bucketSec: hrSeries?.bucketSec ?? 0, + whoopZoneDurations: parseWhoopZoneDurations(row.metadata), + }); + + const history = await prisma.workout.findMany({ + where: { + userId, + sportType: row.sportType, + id: { not: row.id }, + startedAt: { + gte: new Date( + row.startedAt.getTime() - OWN_HISTORY_LOOKBACK_DAYS * MS_PER_DAY, + ), + lt: row.startedAt, + }, + }, + orderBy: { startedAt: "desc" }, + take: OWN_HISTORY_MAX_ROWS, + select: { + durationSec: true, + avgHeartRate: true, + totalDistanceM: true, + totalEnergyKcal: true, + }, + }); + + const evidence = buildWorkoutInsightEvidence({ + row, + tz, + hrSeries, + zones, + // `metadata` is NOT passed. The projection reads closed numeric fields + // only; device bundle ids and event markers never reach a prompt. + routeGeometry: row.route?.geometry ?? null, + history, + }); + + // ── Gate 4: input hash ─────────────────────────────────────────────────── + // The cheapest real gate and the one that makes a re-sync free. It runs + // BEFORE any provider is resolved, so an unchanged session costs one indexed + // read and nothing else. + const inputHash = workoutInsightInputHash( + evidence, + WORKOUT_INSIGHT_PROMPT_VERSION, + ); + const existing = await prisma.workoutInsight.findFirst({ + where: { workoutId, userId }, + select: { id: true, inputHash: true }, + }); + if (existing && existing.inputHash === inputHash) { + annotate({ + action: { name: "workouts.insight.skipped" }, + meta: { workoutId, reason: "unchanged" }, + }); + return { status: "skipped", reason: "unchanged" }; + } + + // ── Generation ─────────────────────────────────────────────────────────── + // The reader's stored preference is the source: this job has no request. The + // paragraph is written once, in that language, and a later language switch + // does NOT regenerate — a read path may never trigger a provider call. + const locale: Locale = (locales as readonly string[]).includes( + profile?.locale ?? "", + ) + ? (profile?.locale as Locale) + : defaultLocale; + + // ── Gate 5: the token ledger ───────────────────────────────────────────── + // `runStatusCompletion` is the chokepoint that owns reserve → provider → + // reconcile for the whole status family, including the refund on a timeout + // or an error. Reserving separately here would double-charge the day. + const outcome = await runStatusCompletion({ + userId, + cacheAction: "insights.workout-insight", + consentSurface: "insights", + systemPrompt: getWorkoutInsightSystemPrompt(locale), + userPrompt: getWorkoutInsightUserPrompt( + JSON.stringify(evidence), + userDayKey(now, tz), + locale, + openerArchetypeHint(`${userId}:workout:${workoutId}`, locale), + ), + temperature: AI_BUDGETS.workoutInsight.temperature, + maxTokens: AI_BUDGETS.workoutInsight.maxTokens, + }); + + if (outcome.kind !== "ok") { + // No provider, no consent, budget spent, timeout, provider error — all the + // same to this surface: no row, no card, no retry loop. The stats the page + // already renders are the floor and they never needed AI. + annotate({ + action: { name: "workouts.insight.skipped" }, + meta: { workoutId, reason: outcome.kind }, + }); + return { status: "skipped", reason: outcome.kind }; + } + + // The outbound safety screen every generated assessment passes through. + // WITHHOLD: a paragraph that tripped it is never shown as if it were fine. + const screened = finalizeStatusSummary(outcome.content, locale); + if (!screened.ok || !screened.text) { + annotate({ + action: { name: "workouts.insight.outbound_blocked" }, + meta: { workoutId, reason: screened.ok ? "empty" : screened.reason }, + }); + return { status: "skipped", reason: "screened" }; + } + + const data = { + paragraphEncrypted: encryptToBytes(screened.text), + inputHash, + promptVersion: WORKOUT_INSIGHT_PROMPT_VERSION, + providerType: outcome.providerType, + locale, + generatedAt: now, + }; + + // Upsert on the unique workout id. The constraint is the durable half of the + // double-post defence: if two workers did somehow both reach here, one write + // wins and the other updates it in place rather than creating a twin. + await prisma.workoutInsight.upsert({ + where: { workoutId }, + create: { userId, workoutId, ...data }, + update: data, + }); + + annotate({ + action: { name: "workouts.insight.generated" }, + meta: { + workoutId, + providerType: outcome.providerType, + sportType: evidence.sportType, + length: screened.text.length, + }, + }); + return { + status: "generated", + providerType: outcome.providerType, + length: screened.text.length, + }; +} + +/** Midnight in the user's profile timezone, as an absolute instant. */ +function startOfUserDay(now: Date, tz: string): Date { + const key = userDayKey(now, tz); + // Walk back from `now` to the first instant whose day key differs. Bounded to + // 48 hourly steps, which covers every real offset including the half-hour and + // 45-minute zones plus a DST shift. + let cursor = now.getTime(); + for (let i = 0; i < 48; i++) { + const next = cursor - 3_600_000; + if (userDayKey(new Date(next), tz) !== key) { + // Refine to the minute so a cap window is not up to an hour wide. + for (let m = 0; m < 60; m++) { + const candidate = cursor - m * 60_000; + if (userDayKey(new Date(candidate), tz) !== key) { + return new Date(candidate + 60_000); + } + } + return new Date(cursor); + } + cursor = next; + } + return new Date(now.getTime() - MS_PER_DAY); +} + +export async function handleWorkoutInsightGenerate( + jobs: Job[], +): Promise { + await withBackgroundEvent("job.workout_insight_generate", async (evt) => { + for (const job of jobs) { + const outcome = await runWorkoutInsightGenerate(job.data); + evt.addMeta( + "workout_insight", + outcome.status === "skipped" + ? `skipped:${outcome.reason}` + : `generated:${outcome.providerType}`, + ); + } + }); +} diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index a07818fd7..51fdc4c45 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -152,6 +152,14 @@ const workoutSportContext = z }) .meta({ id: "WorkoutSportContext" }); +const workoutActivityInsight = z + .object({ + /** Plain text. No markup of any kind — clients render it as text. */ + paragraph: z.string(), + generatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ id: "WorkoutActivityInsight" }); + const workoutDetailResponse = z .object({ id: z.string(), @@ -178,9 +186,11 @@ const workoutDetailResponse = z zones: workoutZones.nullable(), splits: z.array(workoutSplit).nullable(), sportContext: workoutSportContext.nullable(), - // Reserved Activity-Insight seam — always null until the Phase-2 job - // populates it. Typed here so the wire contract is stable in advance. - aiInsight: z.null(), + // The per-workout Activity Insight. Additive-nullable and overwhelmingly + // null: a paragraph exists only for a workout that landed while the + // feature was live, over the duration floor, under the day's cap and with + // a provider reachable. Reading this endpoint never generates one. + aiInsight: workoutActivityInsight.nullable(), canonicalId: z.string(), }) .meta({ id: "WorkoutDetailResponse" }); @@ -220,7 +230,7 @@ export const workoutPaths: NonNullable = { tags: ["Measurements"], summary: "Workout detail (v1.4.32)", description: - "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`, reserved `aiInsight`) are computed server-side. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 (existence channel sealed).", + "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`) are computed server-side. `aiInsight` is a pure read of a per-workout paragraph written by a background job when the workout landed; it is null for every workout that predates the feature or was re-synced, and reading this endpoint never generates one. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 (existence channel sealed).", requestParams: { path: z.object({ id: z.string() }), query: z.object({