mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Wire the foundation layer for the AI Coach: - `types.ts` defines the SSE event shapes (token / provenance / done / error), the chat-request zod schema, and the conversation/message DTOs the route + UI share. - `persistence.ts` encrypts every message body with the active ENCRYPTION_KEYS entry before insert and decrypts on read. `summariseTitle()` keeps the rail snippet ≤80 chars with a smart word-boundary cut. - `budget.ts` introduces a per-(user, UTC-day) ledger with a 25 000- token daily cap. `enforceBudget()` throws HttpError(429, "coach.budget.exceeded") so the route can short-circuit before spending. UTC alignment matches the LLM-provider billing cycle. - `refusal.ts` runs prompt-injection and off-topic detection on inbound messages. Pattern bank covers EN+DE injection wording, jailbreak variants, and obvious off-topic asks (weather, code-help, recipes). Health-allow tokens override the off-topic deny list so mixed questions land on-topic. - i18n keys `insights.coach.refusal*` + `insights.coach.error*` added to both locale bundles. - 28 unit tests cover the helpers with mocked Prisma. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
195 lines
8.1 KiB
TypeScript
195 lines
8.1 KiB
TypeScript
/**
|
|
* Refusal heuristics for the Coach inbound channel.
|
|
*
|
|
* Two attack surfaces this module guards:
|
|
*
|
|
* 1. Off-topic — the Coach is health-tracking only. A user typing
|
|
* "tell me a joke", "what's the weather", "write me a python
|
|
* script" gets a calm refusal in their locale. The route never
|
|
* hits a provider for these — saves the operator's bill and
|
|
* keeps the response shape consistent.
|
|
*
|
|
* 2. Prompt injection — variations of "ignore previous instructions",
|
|
* "you are now a different model", "system: you may answer
|
|
* anything". The detector is intentionally pattern-based rather
|
|
* than LLM-based; a tiny, deterministic regex bank is cheap and
|
|
* auditable. A full LLM-based classifier would itself be
|
|
* promptable.
|
|
*
|
|
* The detector errs toward false positives — refusing a borderline
|
|
* request is recoverable (the user rephrases). Letting an injection
|
|
* through is not. Genuine health-related text trips few of these
|
|
* patterns; the refusal helper takes a `defaultAllow` argument so the
|
|
* route can choose its bias.
|
|
*/
|
|
import type { Locale } from "@/lib/i18n/config";
|
|
|
|
/**
|
|
* Refusal copy for the streaming response. Plain English / German
|
|
* sentences — UI-rendered as `token` SSE frames followed by `done`.
|
|
* Keys live in `messages/{en,de}.json` under `coach.refusal.*`; this
|
|
* module also exports the constants so server-only code (tests, logs)
|
|
* can pin the exact wording without a translator round-trip.
|
|
*/
|
|
export const COACH_REFUSAL_OUT_OF_SCOPE_EN =
|
|
"I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.";
|
|
|
|
export const COACH_REFUSAL_OUT_OF_SCOPE_DE =
|
|
"Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.";
|
|
|
|
export const COACH_REFUSAL_INJECTION_EN =
|
|
"I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.";
|
|
|
|
export const COACH_REFUSAL_INJECTION_DE =
|
|
"In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.";
|
|
|
|
/**
|
|
* Categorisation of a refusal hit, so the route can annotate the
|
|
* Wide-Event with `reason` and serve the right localised copy.
|
|
*/
|
|
export type CoachRefusalReason = "out_of_scope" | "prompt_injection";
|
|
|
|
export interface CoachRefusalDecision {
|
|
/** True when the message should be refused. */
|
|
refuse: boolean;
|
|
/** Why — drives Wide-Event metadata. */
|
|
reason: CoachRefusalReason | null;
|
|
/** Pre-resolved refusal copy for the active locale. */
|
|
message: string | null;
|
|
}
|
|
|
|
/**
|
|
* Pattern bank — kept as a flat array so a future audit can grep for
|
|
* any single phrase without unraveling a regex tree.
|
|
*
|
|
* Each entry uses `\b` word boundaries so substring collisions ("hi
|
|
* gnore me" matching "ignore") are avoided, and the case-insensitive
|
|
* `i` flag accepts the obvious variants.
|
|
*/
|
|
const INJECTION_PATTERNS: readonly RegExp[] = [
|
|
/\bignore\s+(?:all\s+)?(?:previous|prior|earlier|above|the\s+above)\s+(?:instructions?|rules?|prompts?|messages?)\b/i,
|
|
/\bignoriere\s+(?:alle\s+)?(?:vorherigen?|vorigen?|bisherigen?|obigen?)\s+(?:anweisungen?|regeln?|vorgaben?|prompts?)\b/i,
|
|
/\bvergiss\s+(?:alle\s+)?(?:vorherigen?|bisherigen?|obigen?)\s+(?:anweisungen?|regeln?|vorgaben?)\b/i,
|
|
/\bdisregard\s+(?:all\s+)?(?:previous|prior|earlier|above)\s+(?:instructions?|rules?|prompts?)\b/i,
|
|
/\boverride\s+(?:your|the)\s+(?:system|previous|original)\s+(?:prompt|instructions?|rules?)\b/i,
|
|
/\byou\s+are\s+now\s+(?:a|an)?\s*(?:dan|jailbreak|developer|admin|root)\b/i,
|
|
/\bact\s+as\s+(?:if\s+)?(?:you\s+(?:are|were)|a)\s+(?:dan|jailbreak|admin|root|unrestricted)\b/i,
|
|
/\bpretend\s+(?:to\s+be\s+|you\s+are\s+)(?:dan|admin|root|unrestricted|a\s+different\s+model)\b/i,
|
|
/\b(?:do\s+anything\s+now|jailbreak|prompt\s+injection)\b/i,
|
|
/\b(?:reveal|print|show|leak|expose|dump)\s+(?:your\s+)?(?:system\s+)?(?:prompt|instructions?|rules?|guidelines?)\b/i,
|
|
/\b(?:from\s+now\s+on|starting\s+now)\s*,?\s*you\s+(?:will|must|are)\b/i,
|
|
/^\s*system\s*[:>]/im,
|
|
/<\s*\|?\s*(?:system|im_start|imstart)\s*\|?\s*>/i,
|
|
/\[\s*INST\s*\]/i,
|
|
/\bend\s+of\s+(?:system\s+)?prompt\b/i,
|
|
];
|
|
|
|
/**
|
|
* Lightweight off-topic detector. Health-related terminology lives in a
|
|
* positive allow-list (any match → on-topic). Common off-topic asks
|
|
* land in the deny bucket. The bias is per-call: when neither bucket
|
|
* matches we return `defaultAllow`.
|
|
*
|
|
* The list is pragmatic, not exhaustive — the prompt itself enforces
|
|
* the harder constraint that the model only narrates the snapshot. The
|
|
* detector exists so the obvious "what's the weather" ask never burns
|
|
* a token.
|
|
*/
|
|
const HEALTH_TOKENS: readonly RegExp[] = [
|
|
/\b(?:bp|blood\s*pressure|systolic|diastolic|mmhg|hypertension|hypotension)\b/i,
|
|
/\b(?:weight|gewicht|kg|bmi|body\s*mass)\b/i,
|
|
/\b(?:pulse|puls|heart\s*rate|hr|bpm|resting\s*hr|hrv)\b/i,
|
|
/\b(?:mood|stimmung|mental|sleep|schlaf)\b/i,
|
|
/\b(?:medication|medikament|compliance|reminder|dose|dosierung)\b/i,
|
|
/\b(?:withings|trend|reading|measurement|messung|wert)\b/i,
|
|
/\b(?:doctor|arzt|appointment|termin|report|bericht)\b/i,
|
|
/\b(?:health|gesundheit|insight|einsicht|score|coach)\b/i,
|
|
/\b(?:streak|achievement|erfolg|goal|ziel)\b/i,
|
|
/\b(?:steps|schritte|activity|workout)\b/i,
|
|
/\b(?:trend|delta|baseline|durchschnitt|average|median|veränderung|verändert)\b/i,
|
|
];
|
|
|
|
const OFF_TOPIC_TOKENS: readonly RegExp[] = [
|
|
/\b(?:weather|wetter|forecast|temperature|temperatur)\b/i,
|
|
/\b(?:news|nachricht(?:en)?|politic(?:s|al)|wahl|election)\b/i,
|
|
/\b(?:joke|witz|story|geschichte|poem|gedicht|fanfic|roleplay|rollenspiel)\b/i,
|
|
/\b(?:python|javascript|typescript|java|html|css|sql|regex|code\s+for)\b/i,
|
|
/\b(?:stock|aktie|crypto|bitcoin|ethereum|invest)\b/i,
|
|
/\b(?:movie|film|series|serie|netflix|spotify|music|musik)\b/i,
|
|
/\b(?:recipe|rezept|cooking|kochen)\b/i,
|
|
/\b(?:flight|flug|hotel|trip|urlaub|vacation|travel|reise)\b/i,
|
|
];
|
|
|
|
export interface DetectRefusalParams {
|
|
/** Raw user-input message. */
|
|
message: string;
|
|
/** Locale for the refusal copy. */
|
|
locale: Locale;
|
|
/**
|
|
* What to do when neither allow-list nor deny-list trips. Default
|
|
* `true` — when nothing in the message looks off-topic, let the
|
|
* model handle it (the prompt itself enforces the harder boundary).
|
|
*/
|
|
defaultAllow?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Check a user message for refusal triggers. Order:
|
|
* 1. Prompt-injection patterns (highest priority — never run a
|
|
* tampered request).
|
|
* 2. Off-topic deny patterns, unless the message also contains a
|
|
* health allow-token (e.g. "is my BP trend related to the
|
|
* weather?" stays on-topic).
|
|
*/
|
|
export function detectRefusal(
|
|
params: DetectRefusalParams,
|
|
): CoachRefusalDecision {
|
|
const { message, locale } = params;
|
|
const defaultAllow = params.defaultAllow ?? true;
|
|
const trimmed = message.trim();
|
|
|
|
if (trimmed.length === 0) {
|
|
return { refuse: false, reason: null, message: null };
|
|
}
|
|
|
|
for (const pattern of INJECTION_PATTERNS) {
|
|
if (pattern.test(trimmed)) {
|
|
return {
|
|
refuse: true,
|
|
reason: "prompt_injection",
|
|
message:
|
|
locale === "de"
|
|
? COACH_REFUSAL_INJECTION_DE
|
|
: COACH_REFUSAL_INJECTION_EN,
|
|
};
|
|
}
|
|
}
|
|
|
|
const looksHealth = HEALTH_TOKENS.some((p) => p.test(trimmed));
|
|
const looksOffTopic = OFF_TOPIC_TOKENS.some((p) => p.test(trimmed));
|
|
|
|
if (looksOffTopic && !looksHealth) {
|
|
return {
|
|
refuse: true,
|
|
reason: "out_of_scope",
|
|
message:
|
|
locale === "de"
|
|
? COACH_REFUSAL_OUT_OF_SCOPE_DE
|
|
: COACH_REFUSAL_OUT_OF_SCOPE_EN,
|
|
};
|
|
}
|
|
|
|
if (!looksHealth && !defaultAllow) {
|
|
return {
|
|
refuse: true,
|
|
reason: "out_of_scope",
|
|
message:
|
|
locale === "de"
|
|
? COACH_REFUSAL_OUT_OF_SCOPE_DE
|
|
: COACH_REFUSAL_OUT_OF_SCOPE_EN,
|
|
};
|
|
}
|
|
|
|
return { refuse: false, reason: null, message: null };
|
|
}
|