mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Registering `ko` in the `Locale` union left seven locale-keyed tables without an entry, so the build could not type-check and the safety screens, the citation-coverage grader and the reference-range parser read nothing at all for a Korean reader. Three failures were not missing keys but wrong assumptions: - The shared fold decomposes with NFD and strips U+0300-U+036F. Latin accents become plain letters, but Hangul decomposes into jamo, which are not in that range and survived — so folded Korean text was a jamo sequence no precomposed table entry could ever match. It recomposes to NFC now; every Latin, Polish and German fold is byte-identical. - `normaliseLabKey` kept `[a-z0-9]`, which reduced every Korean analyte name to the empty string, so the derived index held none of them and a Korean cholesterol result reached a FHIR export uncoded. - The reference-range parser had the hyphen and the dashes but not the tilde a Korean lab actually prints (`3.5~5.0`). The Korean safety banks carry no `\b` next to a Hangul token — `\b` is an ASCII `\w` boundary and Hangul is not `\w`, so `/\b증량/` matches nothing — and the clause order is the mirror of the Latin banks, since Korean closes with the verb. The dose patterns require a target-marking particle for the same reason the Latin banks require "to"/"by": without it the permitted restatement "7.5mg을 복용 중이고, 체중은 내려가고 있어요" tripped the lowering pattern. The Coach and Insights prompts route `ko` down the reviewed English body plus a Korean reply directive; there is no `safety-contracts.ko.yaml`, so the native builder has no Korean ground rules to compose from.
257 lines
9.0 KiB
TypeScript
257 lines
9.0 KiB
TypeScript
/**
|
|
* Reachability note. This module was written for `generateInsight()`, whose
|
|
* only caller was `runWithFallback()` — and that had no production caller,
|
|
* because every live surface routes through `runRawCompletionWithFallback`.
|
|
* The coverage annotation therefore never executed: an audit surface that
|
|
* implied enforcement it did not perform.
|
|
*
|
|
* v1.30.24 kept the logic and widened the input type to the structural shape
|
|
* both payloads share, so the comprehensive post-parse step could call it and
|
|
* the admin quality dashboard could see real data. The dead wrapper and chain
|
|
* runner around it have since been removed outright, so this module now has
|
|
* exactly one caller and it is a live one.
|
|
*
|
|
* Observational by design: an uncited normative recommendation is an
|
|
* annotation, never a parse failure. It is a METRIC, not a gate — the citation
|
|
* ENFORCEMENT that once sat beside it is gone rather than dormant, so nothing
|
|
* here should be read as withholding output.
|
|
*/
|
|
|
|
/**
|
|
* The minimum recommendation shape the coverage check needs. Both the strict
|
|
* `AIInsightResponse` and the live `InsightResult` satisfy it; the live union
|
|
* also admits a bare string, which carries neither an id nor a citation and is
|
|
* therefore counted as uncited when it makes a normative claim.
|
|
*/
|
|
export interface RecommendationForCoverage {
|
|
id?: string;
|
|
text: string;
|
|
referenceId?: string;
|
|
/**
|
|
* Callers pass their own richer recommendation objects (severity, rationale,
|
|
* metricSource, …). Only the three fields above are read; the index
|
|
* signature keeps a wider literal assignable without a cast at every site.
|
|
*/
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface PayloadForCoverage {
|
|
recommendations: ReadonlyArray<RecommendationForCoverage | string>;
|
|
/** Callers pass the whole payload; only `recommendations` is read. */
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* v1.4.16 phase B5a — citation-coverage post-validation.
|
|
*
|
|
* After the schema parse + cross-citation check pass, the wrapper
|
|
* counts how many recommendations make a normative claim
|
|
* ("target", "should", "normal range", "above", "below") and how
|
|
* many of those carry a `referenceId` pointing into the curated
|
|
* medical-reference bundle. The result lands as a Wide-Event meta
|
|
* annotation so the admin AI quality dashboard can track coverage
|
|
* over time.
|
|
*
|
|
* The check is observational only — a normative rec without a
|
|
* referenceId is logged as a warning, never raised as a parse
|
|
* failure. v1.4.16 phase B5c flips it to required for severity
|
|
* >= "important".
|
|
*
|
|
* ## Why the keyword bank has six arms
|
|
*
|
|
* The recommendations are generated in the READER's language — the prompt is
|
|
* built per locale and `screenInsightPayloadProse` screens the same prose
|
|
* against the same locale. The bank was English plus German, so for a French,
|
|
* Spanish, Italian or Polish reader every recommendation scored
|
|
* non-normative: the annotation reported `normative: 0`, `uncited: 0`, and the
|
|
* admin dashboard read that as perfect coverage rather than as no measurement
|
|
* at all. A check that cannot fail is worse than no check, because it is
|
|
* indistinguishable from a passing one.
|
|
*
|
|
* These words are NOT in `messages/*.json` — they are the model's own prose,
|
|
* not app labels — so the derive-from-the-bundles shape that fits a UI label
|
|
* does not apply here. The bank is hand-written, and the structural test
|
|
* refuses to let a locale ship with an empty arm: adding a seventh language
|
|
* fails the suite instead of silently reporting 0/0 for it.
|
|
*
|
|
* Matching policy: fold both sides through `foldForMatch` (case, accents,
|
|
* hyphens, apostrophes) and require a LEADING word boundary while leaving the
|
|
* tail free, so the LLM's inflections still hit ("targets", "should be",
|
|
* "objectifs") without "sopra" firing inside the everyday Italian
|
|
* "soprattutto".
|
|
*/
|
|
|
|
import { locales, type Locale } from "@/lib/i18n/config";
|
|
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
|
|
|
/**
|
|
* Normative-claim vocabulary per shipped locale, in `foldForMatch` normal form
|
|
* (lower case, no accents, hyphens and apostrophes as spaces). Stems, not whole
|
|
* words: the leading-boundary matcher leaves the tail free.
|
|
*
|
|
* Comparatives are spelled as the phrase the language actually uses — "au
|
|
* dessus", "por encima", "al di sopra" — rather than the bare preposition,
|
|
* which in Italian and Spanish rides inside common unrelated words.
|
|
*
|
|
* Polish "ł" survives the fold (NFD has no decomposition for it), so the
|
|
* entries are written the way a Polish reply spells them.
|
|
*/
|
|
const NORMATIVE_KEYWORDS: Record<Locale, readonly string[]> = {
|
|
en: ["target", "should", "normal range", "above", "below"],
|
|
de: ["ziel", "sollte", "normalbereich", "uber", "unter"],
|
|
fr: [
|
|
"objectif",
|
|
"cible",
|
|
"devrait",
|
|
"devriez",
|
|
"plage normale",
|
|
"au dessus",
|
|
"au dessous",
|
|
"en dessous",
|
|
"superieur",
|
|
"inferieur",
|
|
],
|
|
es: [
|
|
"objetivo",
|
|
"deberia",
|
|
"rango normal",
|
|
"por encima",
|
|
"por debajo",
|
|
"superior",
|
|
"inferior",
|
|
],
|
|
it: [
|
|
"obiettivo",
|
|
"dovrebbe",
|
|
"dovresti",
|
|
"intervallo normale",
|
|
"al di sopra",
|
|
"al di sotto",
|
|
"superiore",
|
|
"inferiore",
|
|
],
|
|
pl: [
|
|
"cel",
|
|
"docelow",
|
|
"powinien",
|
|
"powinna",
|
|
"powinno",
|
|
"powinny",
|
|
"zakres normy",
|
|
"powyzej",
|
|
"ponizej",
|
|
],
|
|
ko: [
|
|
"목표",
|
|
"권장",
|
|
"정상 범위",
|
|
"해야",
|
|
"하세요",
|
|
"이상",
|
|
"이하",
|
|
"초과",
|
|
"미만",
|
|
],
|
|
};
|
|
|
|
/**
|
|
* One compiled matcher per locale — leading word boundary, free tail.
|
|
*
|
|
* An EMPTY bank compiles to a matcher that never fires, not to `(?:)`, which
|
|
* matches the empty string at offset 0 and would therefore grade every
|
|
* recommendation normative. The failure modes of a missing bank point in
|
|
* opposite directions — silently zero before, silently total after — and both
|
|
* are unreadable on the dashboard, so the structural test forbids the empty
|
|
* bank outright and this only keeps the fallback honest.
|
|
*/
|
|
const NEVER_MATCHES = /(?!)/u;
|
|
|
|
const NORMATIVE_MATCHERS: Record<Locale, RegExp> = Object.fromEntries(
|
|
locales.map((locale) => {
|
|
const bank = NORMATIVE_KEYWORDS[locale];
|
|
if (bank.length === 0) return [locale, NEVER_MATCHES];
|
|
const alternation = bank
|
|
.map((kw) => kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
.join("|");
|
|
return [locale, new RegExp(`(?<![\\p{L}\\p{N}])(?:${alternation})`, "u")];
|
|
}),
|
|
) as Record<Locale, RegExp>;
|
|
|
|
/**
|
|
* Test seam: the guard suite asserts every shipped locale carries a non-empty
|
|
* bank, so a new language cannot land silently scoring every recommendation
|
|
* non-normative.
|
|
*/
|
|
export function normativeKeywordBank(): Record<Locale, readonly string[]> {
|
|
return NORMATIVE_KEYWORDS;
|
|
}
|
|
|
|
/**
|
|
* Returns true when the rec text contains a normative-claim keyword.
|
|
*
|
|
* The READER's bank and the English one both run. English is always included
|
|
* because a fallback provider (or a model that ignores the language directive)
|
|
* answers a non-English reader in English — the same dual-bank rule the Coach's
|
|
* reference-sentence exemption uses.
|
|
*/
|
|
export function detectsNormativeClaim(
|
|
text: string,
|
|
locale: Locale = "en",
|
|
): boolean {
|
|
const folded = foldForMatch(text);
|
|
if (folded === "") return false;
|
|
if (NORMATIVE_MATCHERS[locale].test(folded)) return true;
|
|
return locale !== "en" && NORMATIVE_MATCHERS.en.test(folded);
|
|
}
|
|
|
|
export interface CitationCoverage {
|
|
/** Total `recommendations[]` length. */
|
|
totalRecommendations: number;
|
|
/** Subset of recs that make a normative claim. */
|
|
normativeRecommendations: number;
|
|
/** Subset of normative recs that carry a `referenceId`. */
|
|
citedNormativeRecommendations: number;
|
|
/** Recommendation ids that are normative but lack a referenceId. */
|
|
uncitedNormativeRecommendationIds: string[];
|
|
}
|
|
|
|
/**
|
|
* Compute the citation-coverage breakdown for a parsed insight
|
|
* response. Pure function — no side effects, safe to unit test in
|
|
* isolation. The comprehensive generator calls this once on the successful
|
|
* parse and forwards the result via `annotate()`.
|
|
*
|
|
* `locale` is the language the recommendations were GENERATED in, not a
|
|
* display preference: it selects which normative-claim bank grades them.
|
|
*/
|
|
export function computeCitationCoverage(
|
|
parsed: PayloadForCoverage,
|
|
locale: Locale = "en",
|
|
): CitationCoverage {
|
|
const total = parsed.recommendations.length;
|
|
let normative = 0;
|
|
let cited = 0;
|
|
const uncitedIds: string[] = [];
|
|
|
|
for (const [index, raw] of parsed.recommendations.entries()) {
|
|
const rec: RecommendationForCoverage =
|
|
typeof raw === "string" ? { text: raw } : raw;
|
|
if (!detectsNormativeClaim(rec.text, locale)) continue;
|
|
normative += 1;
|
|
if (rec.referenceId) {
|
|
cited += 1;
|
|
} else {
|
|
// A legacy string rec has no id of its own; index it so the dashboard
|
|
// can still point at which entry was uncited.
|
|
uncitedIds.push(rec.id ?? `index:${index}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalRecommendations: total,
|
|
normativeRecommendations: normative,
|
|
citedNormativeRecommendations: cited,
|
|
uncitedNormativeRecommendationIds: uncitedIds,
|
|
};
|
|
}
|