mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The evidence block is a numbers-only server projection: zone seconds, the workout-window heart-rate shape, route climb and a same-sport 90-day own-history median. Free-text metadata never enters it, and the sport string is narrowed against the closed enum first, so the payload carries no text the user did not generate through a measured field. The prompt composes the shared assessment base and adds the session contract: device-attributed, own-history-comparative, effort acknowledged where the numbers show it, and no training prescriptions of any kind. Registered in the tone harness beside the assessment cards.
72 lines
3.1 KiB
TypeScript
72 lines
3.1 KiB
TypeScript
/**
|
|
* The pure half of the Activity Insight gate stack.
|
|
*
|
|
* Five gates stand between a workout landing and a provider call, and they run
|
|
* in this order for a reason — each one is cheaper than the one after it, so
|
|
* the common refusals cost nothing:
|
|
*
|
|
* 1. modules — `workouts` AND `insights` both on (one cached map lookup)
|
|
* 2. duration — at least `MIN_DURATION_SEC` (a field already in hand)
|
|
* 3. daily cap — at most `MAX_INSIGHTS_PER_DAY` generated today (one COUNT)
|
|
* 4. input hash — the evidence is unchanged since the last paragraph (one
|
|
* indexed read, no provider resolution)
|
|
* 5. budget — the token ledger's reserve/reconcile, inside
|
|
* `runStatusCompletion`
|
|
*
|
|
* Gates 2 and 4 are pure and live here so each can be tested on its own. The
|
|
* other three need the database and live in the worker beside their query.
|
|
*
|
|
* Every one of them is a SEPARATE claim. The daily cap does not depend on the
|
|
* hash holding, and the hash does not depend on the singleton queue key
|
|
* holding: a device that double-posts the same session must be stopped by the
|
|
* unique row and by the hash independently, because a queue key is a
|
|
* best-effort de-duplication and a lost singleton race is a normal event.
|
|
*/
|
|
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
|
import type { WorkoutInsightEvidence } from "./insight-evidence";
|
|
|
|
/**
|
|
* The duration floor.
|
|
*
|
|
* Ten minutes. Below it there is nothing a paragraph can honestly say that the
|
|
* stats row does not already say better — a four-minute walk has no zone
|
|
* distribution, no front/back-half story and no meaningful comparison to a
|
|
* ninety-day median. Silence is the correct output, not a padded sentence.
|
|
*/
|
|
export const MIN_DURATION_SEC = 600;
|
|
|
|
/**
|
|
* The hard per-user daily ceiling on generated paragraphs.
|
|
*
|
|
* This is a COUNT of rows written today, not a rate limit: it holds even if
|
|
* the queue keys, the unique constraint and the hash all fail at once, which
|
|
* is exactly what a hard cap is for. Four is above any realistic training day
|
|
* and two orders of magnitude below a device stuck in a re-post loop.
|
|
*/
|
|
export const MAX_INSIGHTS_PER_DAY = 4;
|
|
|
|
/** True when the session is long enough to describe. */
|
|
export function meetsDurationFloor(durationSec: number): boolean {
|
|
return Number.isFinite(durationSec) && durationSec >= MIN_DURATION_SEC;
|
|
}
|
|
|
|
/**
|
|
* Fingerprint the evidence together with the prompt version.
|
|
*
|
|
* `hashInsightSnapshot` canonicalises key order and drops volatile keys, so
|
|
* two projections of the same session hash identically regardless of how the
|
|
* object was assembled. Including the prompt version means a deliberate prompt
|
|
* change re-opens generation exactly once per workout, while a re-sync of
|
|
* unchanged data stays free forever.
|
|
*
|
|
* What is deliberately NOT in here: anything that moves on its own. No
|
|
* timestamps, no row ids, no provider name. A hash that drifted with the clock
|
|
* would turn the cheapest gate in the stack into a no-op.
|
|
*/
|
|
export function workoutInsightInputHash(
|
|
evidence: WorkoutInsightEvidence,
|
|
promptVersion: string,
|
|
): string {
|
|
return hashInsightSnapshot({ evidence, promptVersion });
|
|
}
|