mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The outbound safety screen ran on the Coach only. A dose-change imperative or a fabricated clinical risk score written onto a per-metric, biomarker or derived-score status card persisted as that day's cached assessment, while the identical sentence in the Coach was caught and replaced -- the only transform between provider and persistence was whitespace normalisation. Move the pattern banks into a shared screen that takes the reader's locale. The old signature took no locale, so the dose-refusal and risk-score contracts had no enforcement for fr/es/it/pl even though the ground rules ship in all six contract files. Banks now cover all six locales and each screen runs the reader's bank plus English, since a provider often answers in English regardless of the language directive. Status cards withhold on a trip: serve the deterministic stub and persist no model text. Nobody waits on a background-generated card, and persisting a refusal would pin it to the card for the day.
124 lines
4.8 KiB
TypeScript
124 lines
4.8 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { annotate } from "@/lib/logging/context";
|
|
|
|
/**
|
|
* Shared timeout / provider-error fallback for the InsightStatus routes.
|
|
*
|
|
* When the upstream provider call exceeds `STATUS_PROVIDER_TIMEOUT_MS`
|
|
* (or rejects outright) the generator still has to return a deterministic
|
|
* envelope. It used to persist a sentinel row carrying the generic
|
|
* fallback under the same `text` field a real assessment uses; the
|
|
* cache-read served it as valid and it stuck until midnight — hiding the
|
|
* real, data-driven assessment for the rest of the day on any account that
|
|
* hit a single transient stall. v1.4.28 removed that persist entirely.
|
|
*
|
|
* v1.8.3 — re-introduce a *negative* cache, but a strictly bounded one. The
|
|
* status route is now read-only: a cache miss enqueues an out-of-band
|
|
* generation. If the provider stalls inside the worker, nothing stops the
|
|
* next navigation from enqueuing again, and again — a stalled provider
|
|
* turns into a re-enqueue storm. So the timeout path writes a short-TTL
|
|
* negative stub (`{ timeout:true, model:"timeout-stub", retryAt }`). The
|
|
* read-only resolver honours it: while the stub is fresh it returns
|
|
* `preparing` WITHOUT re-enqueuing; once `retryAt` passes the stub is stale
|
|
* and the next visit re-attempts. The stub is NEVER served as assessment
|
|
* text — `readFreshStatusText` still rejects every timeout stub by marker,
|
|
* so a transient stall can't hide the real assessment for the day.
|
|
*/
|
|
|
|
/**
|
|
* Why the deterministic fallback is being served instead of model prose.
|
|
*
|
|
* `screened` is the outbound-safety case: the model produced a usable string
|
|
* but it tripped a safety contract (see `@/lib/ai/safety/outbound-screen`).
|
|
* It rides the same envelope as a stall on purpose — the surface policy for a
|
|
* background-generated cached card is WITHHOLD, and this path already means
|
|
* exactly "serve the deterministic line, persist no model text". The 5-minute
|
|
* negative stub gives the next generation a fresh attempt rather than blanking
|
|
* the card until midnight.
|
|
*/
|
|
export type StatusFallbackReason = "timeout" | "error" | "screened";
|
|
|
|
/** Short retry window after a provider stall before a re-enqueue is allowed. */
|
|
export const TIMEOUT_NEGATIVE_CACHE_MS = 5 * 60 * 1000;
|
|
|
|
/**
|
|
* Persist a short-TTL negative-cache stub for `(userId, cacheAction)`.
|
|
* Best-effort: a failure here just means the read-only resolver may
|
|
* re-enqueue sooner than the window intended.
|
|
*/
|
|
export async function persistTimeoutNegativeStub(args: {
|
|
userId: string;
|
|
cacheAction: string;
|
|
todayKey: string;
|
|
reason: StatusFallbackReason;
|
|
}): Promise<void> {
|
|
try {
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
userId: args.userId,
|
|
action: args.cacheAction,
|
|
details: JSON.stringify({
|
|
dateKey: args.todayKey,
|
|
timeout: true,
|
|
model: "timeout-stub",
|
|
reason: args.reason,
|
|
retryAt: new Date(
|
|
Date.now() + TIMEOUT_NEGATIVE_CACHE_MS,
|
|
).toISOString(),
|
|
}),
|
|
},
|
|
});
|
|
} catch {
|
|
// Negative cache is best-effort — never throw out of the timeout path.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the deterministic fallback envelope for a timeout / error. The
|
|
* current render still gets the fallback text, marked `cached:true` so the
|
|
* UI does not mislabel it as a fresh assessment and `updatedAt:null` to
|
|
* signal "no persisted assessment". When `userId` + `todayKey` are present
|
|
* (the worker-side generation path) a short-TTL negative stub is persisted
|
|
* so a stalled provider doesn't trigger a re-enqueue storm.
|
|
*
|
|
* v1.21.0 (coach C1 HIGH-1) — the envelope now reports `hasProvider:false`.
|
|
* The `stubText` is a DETERMINISTIC (signal-grounded or generic) line, not a
|
|
* fresh provider assessment; reporting `hasProvider:true` let the UI render
|
|
* it in the primary assessment slot indistinguishable from real AI prose on
|
|
* any slow/errored call. `false` lets the UI surface it honestly as the
|
|
* computed/offline summary it is. This is independent of whether the user
|
|
* has a provider configured at all — it describes THIS text's authorship.
|
|
*/
|
|
export function returnTimeoutFallback(input: {
|
|
cacheAction: string;
|
|
reason: StatusFallbackReason;
|
|
stubText: string;
|
|
userId?: string;
|
|
todayKey?: string;
|
|
}): {
|
|
hasProvider: false;
|
|
text: string;
|
|
cached: true;
|
|
updatedAt: null;
|
|
} {
|
|
annotate({
|
|
action: { name: "insights.status.fallback_served" },
|
|
meta: { cacheAction: input.cacheAction, reason: input.reason },
|
|
});
|
|
if (input.userId && input.todayKey) {
|
|
// Fire-and-forget — the caller's render must not wait on the write.
|
|
void persistTimeoutNegativeStub({
|
|
userId: input.userId,
|
|
cacheAction: input.cacheAction,
|
|
todayKey: input.todayKey,
|
|
reason: input.reason,
|
|
});
|
|
}
|
|
return {
|
|
hasProvider: false,
|
|
text: input.stubText,
|
|
cached: true,
|
|
updatedAt: null,
|
|
};
|
|
}
|