feat(workouts): generate the per-workout insight on arrival, never on view

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 13:56:50 +02:00
1 parent e53a8ce8a0
commit 1a469301b3
9 files changed
+556 -29

No files matched your search

+22 -5
View File
@@ -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:
+31 -6
View File
@@ -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 ? <card/> : 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 ? <card/> : 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
+14 -6
View File
@@ -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;
+3 -1
View File
@@ -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)}
+22 -7
View File
@@ -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":
+19
View File
@@ -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<WorkoutInsightGeneratePayload>(
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
@@ -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 };
}
}
+365
View File
@@ -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<WorkoutInsightOutcome> {
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<WorkoutInsightGeneratePayload>[],
): Promise<void> {
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}`,
);
}
});
}
+14 -4
View File
@@ -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<ZodOpenApiObject["paths"]> = {
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({