mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
e53a8ce8a0
commit
1a469301b3
9 files changed
+556
-29
No files matched your search
+22
-5
@@ -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 +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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user