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({