mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(ai): screen numberless halving and dose changes, and hold continuations to the plan
The outbound screen let a halving with no number through in every
language but Dutch ("Reduce your dose by half.", "Reduza a dose pela
metade.", "Tome meia dose."), let a bare change imperative through
("Zmniejsz dawkę.", "복용량을 늘리세요.") and did not know the Polish
spelling of the generic stems, so "Odstaw ramipryl." passed. Each
language's bank now carries a halving and fraction shape and a bare
change shape on a medication noun, and the stems include the Polish
-pryl, -statyn and -glutyd.
The continuation exemption trusted "as before" on any surface that
passed no schedule, so "Take 40 mg as before." went out to people who
had never taken 40 mg. The screen now takes the dose plan (each active
medication with its dose) instead of a bare list of numbers, and
exempts a stated amount only when it is the planned dose of the
medication the sentence names. Without a plan no stated amount is
exempt.
This commit is contained in:
1 parent
47ac2c6889
commit
3354631a85
31 files changed
+526
-123
No files matched your search
@@ -543,7 +543,7 @@ export const modules = {
|
||||
}),
|
||||
aboutMe: () => ({ getSelfContextTextForUser: async () => null }),
|
||||
snapshot: () => ({ buildCoachSnapshot: async () => snapshotFor() }),
|
||||
scheduledDoses: () => ({ getScheduledDoseValues: async () => [] }),
|
||||
scheduledDoses: () => ({ getScheduledDosePlan: async () => [] }),
|
||||
workoutEvidence: () => ({
|
||||
buildWorkoutEvidenceSection: async () => null,
|
||||
}),
|
||||
|
||||
@@ -135,7 +135,7 @@ export const m = {
|
||||
getCoachSystemPrompt: vi.fn(),
|
||||
getSelfContextTextForUser: vi.fn(),
|
||||
buildCoachSnapshot: vi.fn(),
|
||||
getScheduledDoseValues: vi.fn(),
|
||||
getScheduledDosePlan: vi.fn(),
|
||||
buildWorkoutEvidenceSection: vi.fn(),
|
||||
buildCoachDataInventory: vi.fn(),
|
||||
renderDataInventory: vi.fn(),
|
||||
@@ -278,7 +278,7 @@ export function resetGolden(): void {
|
||||
m.getCoachSystemPrompt.mockReturnValue("SYSTEM");
|
||||
m.getSelfContextTextForUser.mockResolvedValue(null);
|
||||
m.buildCoachSnapshot.mockImplementation(async () => defaultSnapshot());
|
||||
m.getScheduledDoseValues.mockResolvedValue([]);
|
||||
m.getScheduledDosePlan.mockResolvedValue([]);
|
||||
m.buildWorkoutEvidenceSection.mockResolvedValue({
|
||||
sportType: "running",
|
||||
durationSec: 2400,
|
||||
|
||||
@@ -122,7 +122,7 @@ vi.mock("@/lib/ai/coach/snapshot", () => ({
|
||||
buildCoachSnapshot: h.m.buildCoachSnapshot,
|
||||
}));
|
||||
vi.mock("@/lib/medications/scheduled-doses", () => ({
|
||||
getScheduledDoseValues: h.m.getScheduledDoseValues,
|
||||
getScheduledDosePlan: h.m.getScheduledDosePlan,
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/workout-evidence-builder", () => ({
|
||||
buildWorkoutEvidenceSection: h.m.buildWorkoutEvidenceSection,
|
||||
|
||||
@@ -128,7 +128,7 @@ vi.mock("@/lib/ai/coach/facts", () => ({
|
||||
// Guard II — the schedule read. Empty here; the schedule-gated dose rule is
|
||||
// unit-tested in the outbound-screen suite.
|
||||
vi.mock("@/lib/medications/scheduled-doses", () => ({
|
||||
getScheduledDoseValues: vi.fn(async () => []),
|
||||
getScheduledDosePlan: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
|
||||
|
||||
@@ -121,7 +121,7 @@ vi.mock("@/lib/ai/coach/facts", () => ({
|
||||
// Guard II — the schedule read. Empty here; the schedule-gated dose rule is
|
||||
// unit-tested in the outbound-screen suite.
|
||||
vi.mock("@/lib/medications/scheduled-doses", () => ({
|
||||
getScheduledDoseValues: vi.fn(async () => []),
|
||||
getScheduledDosePlan: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
|
||||
|
||||
@@ -130,7 +130,7 @@ describe("screenCoachReply — a drug-named dose change", () => {
|
||||
const d = screenCoachReply(
|
||||
"Increase your metformin to 2000 mg daily.",
|
||||
"en",
|
||||
[1000],
|
||||
[{ name: "Metformin", dose: 1000 }],
|
||||
);
|
||||
expect(d.block).toBe(true);
|
||||
expect(d.reason).toBe("dose_prescription");
|
||||
@@ -140,7 +140,7 @@ describe("screenCoachReply — a drug-named dose change", () => {
|
||||
const d = screenCoachReply(
|
||||
"Your log shows you take 1000 mg twice daily.",
|
||||
"en",
|
||||
[1000],
|
||||
[{ name: "Metformin", dose: 1000 }],
|
||||
);
|
||||
expect(d.block).toBe(false);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { buildGroundingLedger } from "@/lib/ai/coach/grounding-ledger";
|
||||
import { findUnverifiedCoachNumbersInLedger } from "@/lib/ai/coach/coach-prose-grounding";
|
||||
|
||||
import { ACTIVITY_TEXT_MAX_CHARS, ACTIVITY_TITLE_MAX_CHARS } from "./contract";
|
||||
import type { ScheduledDose } from "@/lib/medications/scheduled-doses";
|
||||
|
||||
export interface ActivityScreenContext {
|
||||
locale: Locale;
|
||||
@@ -34,8 +35,12 @@ export interface ActivityScreenContext {
|
||||
figures: () => readonly unknown[];
|
||||
/** The person's own message: figures they stated may be repeated. */
|
||||
userMessage?: string;
|
||||
/** Scheduled doses, which the dose screen lets through. */
|
||||
scheduleDoses?: readonly number[];
|
||||
/**
|
||||
* The active medications with their doses: the dose screen trusts a
|
||||
* continuation only at a planned dose, and the ledger counts the doses as
|
||||
* grounded figures.
|
||||
*/
|
||||
dosePlan?: readonly ScheduledDose[];
|
||||
/** The schedule's medication names, which count as medication nouns. */
|
||||
medicationNames?: readonly string[];
|
||||
}
|
||||
@@ -61,7 +66,7 @@ function cut(text: string, max: number): string {
|
||||
function passes(text: string, ctx: ActivityScreenContext): boolean {
|
||||
try {
|
||||
if (
|
||||
screenCoachReply(text, ctx.locale, ctx.scheduleDoses, ctx.medicationNames)
|
||||
screenCoachReply(text, ctx.locale, ctx.dosePlan, ctx.medicationNames)
|
||||
.block
|
||||
) {
|
||||
return false;
|
||||
@@ -73,7 +78,7 @@ function passes(text: string, ctx: ActivityScreenContext): boolean {
|
||||
const ledger = buildGroundingLedger({
|
||||
toolPayloads: ctx.figures(),
|
||||
priorUserMessages: ctx.userMessage ? [ctx.userMessage] : [],
|
||||
scheduleDoses: ctx.scheduleDoses,
|
||||
scheduleDoses: ctx.dosePlan?.map((entry) => entry.dose),
|
||||
});
|
||||
const unverified = findUnverifiedCoachNumbersInLedger(
|
||||
text,
|
||||
|
||||
@@ -13,11 +13,11 @@ vi.mock("../../bytes-codec", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
const doses = vi.hoisted(() => ({
|
||||
values: [] as number[],
|
||||
values: [] as { name: string; dose: number }[],
|
||||
names: [] as string[],
|
||||
}));
|
||||
vi.mock("@/lib/medications/scheduled-doses", () => ({
|
||||
getScheduledDoseValues: vi.fn(async () => doses.values),
|
||||
getScheduledDosePlan: vi.fn(async () => doses.values),
|
||||
}));
|
||||
vi.mock("@/lib/medications/medication-names", () => ({
|
||||
getMedicationNames: vi.fn(async () => doses.names),
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
CoachMemoryNote,
|
||||
CoachPlanProposal,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import type { ScheduledDose } from "@/lib/medications/scheduled-doses";
|
||||
|
||||
export * from "./shared";
|
||||
|
||||
@@ -149,10 +150,10 @@ export interface ProposePlanArgs {
|
||||
/** The person's locale, which the outbound screen reads the plan text in. */
|
||||
locale: Locale;
|
||||
/**
|
||||
* The turn's scheduled doses and medication names, which the screen reads
|
||||
* The turn's dose plan and medication names, which the screen reads
|
||||
* the plan text against; loaded for the person when absent.
|
||||
*/
|
||||
scheduleDoses?: readonly number[];
|
||||
dosePlan?: readonly ScheduledDose[];
|
||||
medicationNames?: readonly string[];
|
||||
call: ProposePlanCall;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getMedicationNames } from "@/lib/medications/medication-names";
|
||||
import { getScheduledDoseValues } from "@/lib/medications/scheduled-doses";
|
||||
import {
|
||||
getScheduledDosePlan,
|
||||
type ScheduledDose,
|
||||
} from "@/lib/medications/scheduled-doses";
|
||||
|
||||
import { screenCoachReply } from "../outbound-guard";
|
||||
import { detectRefusal } from "../refusal";
|
||||
@@ -87,19 +90,15 @@ export function planTextPasses(
|
||||
texts: readonly string[],
|
||||
locale: Locale,
|
||||
screen: {
|
||||
scheduleDoses: readonly number[];
|
||||
dosePlan: readonly ScheduledDose[];
|
||||
medicationNames: readonly string[];
|
||||
},
|
||||
): boolean {
|
||||
try {
|
||||
for (const text of [...texts, texts.join(" ")]) {
|
||||
if (
|
||||
screenCoachReply(
|
||||
text,
|
||||
locale,
|
||||
screen.scheduleDoses,
|
||||
screen.medicationNames,
|
||||
).block
|
||||
screenCoachReply(text, locale, screen.dosePlan, screen.medicationNames)
|
||||
.block
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -154,9 +153,9 @@ async function runPropose(args: ProposePlanArgs): Promise<ProposePlanOutcome> {
|
||||
) {
|
||||
return { kind: "declined", reason: "invalid" };
|
||||
}
|
||||
const [scheduleDoses, medicationNames] = await Promise.all([
|
||||
args.scheduleDoses ??
|
||||
getScheduledDoseValues(userId).catch((): number[] => []),
|
||||
const [dosePlan, medicationNames] = await Promise.all([
|
||||
args.dosePlan ??
|
||||
getScheduledDosePlan(userId).catch((): ScheduledDose[] => []),
|
||||
args.medicationNames ??
|
||||
getMedicationNames(userId).catch((): string[] => []),
|
||||
]);
|
||||
@@ -164,7 +163,7 @@ async function runPropose(args: ProposePlanArgs): Promise<ProposePlanOutcome> {
|
||||
!planTextPasses(
|
||||
[ifCue, thenAction, ...(target ? [target] : [])],
|
||||
args.locale,
|
||||
{ scheduleDoses, medicationNames },
|
||||
{ dosePlan, medicationNames },
|
||||
)
|
||||
) {
|
||||
return { kind: "declined", reason: "unsafe" };
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
type OutboundReason,
|
||||
} from "@/lib/ai/safety/outbound-screen";
|
||||
import type { ScheduledDose } from "@/lib/medications/scheduled-doses";
|
||||
|
||||
/**
|
||||
* Why the outbound reply was blocked, for the Wide-Event annotation. The Coach
|
||||
@@ -53,11 +54,10 @@ export interface CoachOutboundDecision {
|
||||
* Screen an assembled assistant reply against the conversational contracts in
|
||||
* the reader's locale.
|
||||
*
|
||||
* v1.32.9 (Coach Guard II / G3) — `scheduleDoses` optionally gates the dose
|
||||
* continuation exemption on the user's actual schedule: a "keep taking your
|
||||
* N mg" is trusted only when N is a dose the user is on. Omitted by the
|
||||
* non-Coach callers (nudge / reaction-line), which keep the phrase-anchored
|
||||
* Guard I behaviour.
|
||||
* `dosePlan` gates the dose continuation exemption on the user's actual
|
||||
* plan: a "keep taking your N mg" is trusted only when N is the dose of the
|
||||
* medication the sentence names. Without it (the nudge and reaction-line
|
||||
* callers) no stated amount is exempt.
|
||||
*
|
||||
* `medicationNames` optionally makes the person's own medication names count
|
||||
* as medication nouns in the dose-change class, so "skip the Eliquis" or
|
||||
@@ -68,11 +68,11 @@ export interface CoachOutboundDecision {
|
||||
export function screenCoachReply(
|
||||
reply: string,
|
||||
locale: Locale,
|
||||
scheduleDoses?: readonly number[],
|
||||
dosePlan?: readonly ScheduledDose[],
|
||||
medicationNames?: readonly string[],
|
||||
): CoachOutboundDecision {
|
||||
const decision = screenModelOutput(reply, locale, CONVERSATIONAL_CONTRACTS, {
|
||||
scheduleDoses,
|
||||
dosePlan,
|
||||
medicationNames,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
loadClarifyRecordChoices,
|
||||
} from "@/lib/ai/coach/clarify";
|
||||
import type { InventoryEntry } from "./inventory";
|
||||
import type { ScheduledDose } from "@/lib/medications/scheduled-doses";
|
||||
|
||||
export const ASK_CLARIFICATION_TOOL_NAME = "ask_clarification";
|
||||
export const REMEMBER_FACT_TOOL_NAME = "remember_fact";
|
||||
@@ -210,7 +211,7 @@ export interface DialogToolContext {
|
||||
* The person's scheduled doses and medication names, which the outbound
|
||||
* screen reads a proposed plan's text against, as it reads the answer.
|
||||
*/
|
||||
scheduleDoses?: readonly number[];
|
||||
dosePlan?: readonly ScheduledDose[];
|
||||
medicationNames?: readonly string[];
|
||||
/**
|
||||
* A window was set for this conversation (the header pill, a deep link):
|
||||
@@ -351,7 +352,7 @@ export async function runDialogTool(args: {
|
||||
userId: ctx.userId,
|
||||
conversationId: ctx.conversationId,
|
||||
locale: ctx.locale,
|
||||
...(ctx.scheduleDoses ? { scheduleDoses: ctx.scheduleDoses } : {}),
|
||||
...(ctx.dosePlan ? { dosePlan: ctx.dosePlan } : {}),
|
||||
...(ctx.medicationNames
|
||||
? { medicationNames: ctx.medicationNames }
|
||||
: {}),
|
||||
|
||||
@@ -33,7 +33,10 @@ import {
|
||||
type CoachTurnContext,
|
||||
} from "@/lib/ai/coach/chat-request-builder";
|
||||
import { buildWorkoutEvidenceSection } from "@/lib/ai/coach/workout-evidence-builder";
|
||||
import { getScheduledDoseValues } from "@/lib/medications/scheduled-doses";
|
||||
import {
|
||||
getScheduledDosePlan,
|
||||
type ScheduledDose,
|
||||
} from "@/lib/medications/scheduled-doses";
|
||||
import { getMedicationNames } from "@/lib/medications/medication-names";
|
||||
import { buildRememberAddendum } from "@/lib/ai/coach/reminders";
|
||||
import { buildSuggestActionAddendum } from "@/lib/ai/coach/suggest-action";
|
||||
@@ -65,7 +68,10 @@ export interface TurnContext {
|
||||
effectiveScope: CoachScope | undefined;
|
||||
snapshot: CoachSnapshotResult;
|
||||
aboutMe: string | null;
|
||||
scheduleDoses: Awaited<ReturnType<typeof getScheduledDoseValues>>;
|
||||
/** The active doses' magnitudes, the Grounding Ledger's `schedule` source. */
|
||||
scheduleDoses: number[];
|
||||
/** The active medications with their doses, which the dose screen reads. */
|
||||
dosePlan: ScheduledDose[];
|
||||
/** The schedule's medication names, which the outbound dose screen reads. */
|
||||
medicationNames: string[];
|
||||
turnContext: CoachTurnContext;
|
||||
@@ -137,9 +143,12 @@ export async function assembleTurnContext(args: {
|
||||
const aboutMe = await getSelfContextTextForUser(userId, locale);
|
||||
// v1.32.9 (Coach Guard II / G3) — the user's active medication doses, for the
|
||||
// Grounding Ledger's `schedule` source AND the schedule-gated dose
|
||||
// continuation exemption. Fail-open: a read failure yields an empty set, which
|
||||
// keeps the Guard I phrase-anchored dose behaviour.
|
||||
const scheduleDoses = await getScheduledDoseValues(userId).catch(() => []);
|
||||
// continuation exemption. A read failure yields an empty plan, under which
|
||||
// the screen trusts no stated amount (fail closed).
|
||||
const dosePlan = await getScheduledDosePlan(userId).catch(
|
||||
(): ScheduledDose[] => [],
|
||||
);
|
||||
const scheduleDoses = [...new Set(dosePlan.map((entry) => entry.dose))];
|
||||
const medicationNames = await getMedicationNames(userId).catch(() => []);
|
||||
// v1.22 — the canonical system-prompt module is owned elsewhere, so
|
||||
// the two memory/action clauses are appended at assembly time here: one
|
||||
@@ -242,6 +251,7 @@ export async function assembleTurnContext(args: {
|
||||
snapshot,
|
||||
aboutMe,
|
||||
scheduleDoses,
|
||||
dosePlan,
|
||||
medicationNames,
|
||||
turnContext,
|
||||
workoutEvidence,
|
||||
|
||||
@@ -307,7 +307,7 @@ export async function runTurnModel(args: {
|
||||
locale,
|
||||
figures: () => figures,
|
||||
userMessage: args.message,
|
||||
scheduleDoses: ctx.scheduleDoses,
|
||||
dosePlan: ctx.dosePlan,
|
||||
medicationNames: ctx.medicationNames,
|
||||
},
|
||||
});
|
||||
@@ -466,7 +466,7 @@ export async function runTurnModel(args: {
|
||||
...(args.userMessageId ? { userMessageId: args.userMessageId } : {}),
|
||||
inventory: inventory.entries,
|
||||
conversationWindowSet: args.conversationWindowSet,
|
||||
scheduleDoses: ctx.scheduleDoses,
|
||||
dosePlan: ctx.dosePlan,
|
||||
medicationNames: ctx.medicationNames,
|
||||
},
|
||||
checkpointScreen: { userMessage: args.message },
|
||||
|
||||
@@ -204,7 +204,7 @@ export async function guardReply(args: {
|
||||
const outbound = screenCoachReply(
|
||||
replyText,
|
||||
locale,
|
||||
ctx.scheduleDoses,
|
||||
ctx.dosePlan,
|
||||
ctx.medicationNames,
|
||||
);
|
||||
if (outbound.block && outbound.reason) {
|
||||
|
||||
@@ -42,6 +42,9 @@ const BANKS: Record<Locale, LocaleBank> = {
|
||||
|
||||
const SENTENCES: Record<Locale, readonly string[]> = {
|
||||
en: [
|
||||
"Reduce your dose by half.",
|
||||
"Take half your usual dose.",
|
||||
"Reduce your dose.",
|
||||
"Increase your metformin to 2000 mg daily.",
|
||||
"You might want to consider increasing your insulin to 20 units.",
|
||||
"Take 2000 mg daily.",
|
||||
@@ -81,6 +84,10 @@ const SENTENCES: Record<Locale, readonly string[]> = {
|
||||
"Keep taking your 7.5 mg as prescribed.",
|
||||
],
|
||||
de: [
|
||||
"Reduzieren Sie die Dosis um die Hälfte.",
|
||||
"Nehmen Sie nur die halbe Dosis.",
|
||||
"Teilen Sie die Tablette in zwei Hälften.",
|
||||
"Reduzieren Sie die Dosis.",
|
||||
"Erhöhe dein Metformin auf 2000 mg.",
|
||||
"Nimm 2 Tabletten am Abend.",
|
||||
"Pausiere dein Insulin.",
|
||||
@@ -120,6 +127,9 @@ const SENTENCES: Record<Locale, readonly string[]> = {
|
||||
"Nimm dein Metformin weiter wie verordnet.",
|
||||
],
|
||||
fr: [
|
||||
"Réduisez la dose de moitié.",
|
||||
"Prenez une demi-dose.",
|
||||
"Diminuez votre dose.",
|
||||
"Augmentez votre metformine à 2000 mg.",
|
||||
"Prenez 2 comprimés le soir.",
|
||||
"Sautez votre dose du soir.",
|
||||
@@ -151,6 +161,9 @@ const SENTENCES: Record<Locale, readonly string[]> = {
|
||||
"Continuez à prendre votre metformine.",
|
||||
],
|
||||
es: [
|
||||
"Reduzca la dosis a la mitad.",
|
||||
"Tome media dosis.",
|
||||
"Reduzca la dosis.",
|
||||
"Aumente su metformina a 2000 mg.",
|
||||
"Tome 2 comprimidos por la noche.",
|
||||
"Omita la dosis de la noche.",
|
||||
@@ -181,6 +194,9 @@ const SENTENCES: Record<Locale, readonly string[]> = {
|
||||
"Siga tomando su metformina.",
|
||||
],
|
||||
it: [
|
||||
"Riduca la dose della metà.",
|
||||
"Prenda mezza dose.",
|
||||
"Riduca la dose.",
|
||||
"Aumenti la metformina a 2000 mg.",
|
||||
"Prenda 2 compresse la sera.",
|
||||
"Salti la dose serale.",
|
||||
@@ -210,6 +226,9 @@ const SENTENCES: Record<Locale, readonly string[]> = {
|
||||
"Continui a prendere la metformina.",
|
||||
],
|
||||
pl: [
|
||||
"Zmniejsz dawkę o połowę.",
|
||||
"Weź połowę dawki.",
|
||||
"Zmniejsz dawkę.",
|
||||
"Zwiększ metforminę do 2000 mg.",
|
||||
"Proszę przyjąć 2 tabletki wieczorem.",
|
||||
"Pomiń wieczorną dawkę.",
|
||||
@@ -239,6 +258,9 @@ const SENTENCES: Record<Locale, readonly string[]> = {
|
||||
"Kontynuuj przyjmowanie leku.",
|
||||
],
|
||||
nl: [
|
||||
"Verlaag je dosis met de helft.",
|
||||
"Neem een halve dosis.",
|
||||
"Verlaag je dosis.",
|
||||
"Verhoog je metformine naar 2000 mg per dag.",
|
||||
"Bouw je dosis af naar 5 mg.",
|
||||
"Zet je insuline op 20 eenheden.",
|
||||
@@ -282,6 +304,7 @@ const SENTENCES: Record<Locale, readonly string[]> = {
|
||||
"Blijf je metformine nemen.",
|
||||
],
|
||||
ko: [
|
||||
"복용량을 늘리세요.",
|
||||
"메트포르민을 2000mg으로 증량하세요.",
|
||||
"저녁 인슐린 용량을 건너뛰세요.",
|
||||
"메트포르민 복용을 중단하세요.",
|
||||
@@ -313,6 +336,9 @@ const SENTENCES: Record<Locale, readonly string[]> = {
|
||||
"처방대로 복용하세요.",
|
||||
],
|
||||
"pt-BR": [
|
||||
"Reduza a dose pela metade.",
|
||||
"Tome meia dose.",
|
||||
"Reduza a dose.",
|
||||
"Aumente sua metformina para 2000 mg por dia.",
|
||||
"Tome 2 comprimidos à noite.",
|
||||
"Pule a dose da noite.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* v1.32.9 (Coach Guard II / G3) — the schedule-gated dose continuation
|
||||
* exemption (M6/D7 end state) and the fr/es/it/pl risk-bank parity.
|
||||
* The plan-gated dose continuation exemption and the fr/es/it/pl risk-bank
|
||||
* parity.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
@@ -9,56 +9,138 @@ import {
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
} from "@/lib/ai/safety/outbound-screen";
|
||||
|
||||
describe("screenModelOutput — dose exemption gated on the schedule", () => {
|
||||
it("passes a continuation dose that MATCHES the user's schedule", () => {
|
||||
describe("screenModelOutput — dose exemption gated on the plan", () => {
|
||||
const PLAN = [
|
||||
{ name: "Semaglutide", dose: 7.5 },
|
||||
{ name: "Ramipril", dose: 5 },
|
||||
{ name: "Atorvastatin", dose: 40 },
|
||||
];
|
||||
|
||||
it("passes a continuation dose that MATCHES the user's plan", () => {
|
||||
const d = screenModelOutput(
|
||||
"You should keep taking your prescribed 7.5 mg exactly as directed.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ scheduleDoses: [7.5] },
|
||||
{ dosePlan: PLAN },
|
||||
);
|
||||
expect(d.block).toBe(false);
|
||||
});
|
||||
|
||||
it("BLOCKS a continuation dose that does NOT match the schedule (closes the M6 window)", () => {
|
||||
// The schedule says 7.5; "keep taking your 15 mg" is a wrong maintenance
|
||||
it("BLOCKS a continuation dose that does NOT match the plan (closes the M6 window)", () => {
|
||||
// The plan says 7.5; "keep taking your 15 mg" is a wrong maintenance
|
||||
// dose and must not ride the continuation exemption.
|
||||
const d = screenModelOutput(
|
||||
"You should keep taking your 15 mg as prescribed.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ scheduleDoses: [7.5] },
|
||||
{ dosePlan: PLAN },
|
||||
);
|
||||
expect(d.block).toBe(true);
|
||||
expect(d.reason).toBe("dose_prescription");
|
||||
});
|
||||
|
||||
it("keeps the Guard I phrase-anchored exemption when NO schedule is supplied", () => {
|
||||
const d = screenModelOutput(
|
||||
"You should keep taking your 7.5 mg as prescribed.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
);
|
||||
expect(d.block).toBe(false);
|
||||
it("BLOCKS a stated continuation dose when no plan is supplied", () => {
|
||||
// "As before" is the model's claim. Without the plan nothing confirms it.
|
||||
for (const [text, locale] of [
|
||||
["You should keep taking your 7.5 mg as prescribed.", "en"],
|
||||
["Take 40 mg as before.", "en"],
|
||||
["Tome 40 mg como antes.", "pt-BR"],
|
||||
["Nimm 40 mg wie bisher.", "de"],
|
||||
["Neem 40 mg zoals eerder.", "nl"],
|
||||
] as const) {
|
||||
expect(
|
||||
screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS).block,
|
||||
text,
|
||||
).toBe(true);
|
||||
expect(
|
||||
screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS, {
|
||||
dosePlan: [],
|
||||
}).block,
|
||||
text,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("still blocks a titration even when the change lands on a scheduled value", () => {
|
||||
// A change stem voids the exemption regardless of schedule membership.
|
||||
it("passes the same sentences when the plan holds that dose", () => {
|
||||
for (const [text, locale] of [
|
||||
["Take 40 mg as before.", "en"],
|
||||
["Tome 40 mg como antes.", "pt-BR"],
|
||||
["Nimm 40 mg wie bisher.", "de"],
|
||||
["Neem 40 mg zoals eerder.", "nl"],
|
||||
] as const) {
|
||||
expect(
|
||||
screenModelOutput(text, locale, CONVERSATIONAL_CONTRACTS, {
|
||||
dosePlan: PLAN,
|
||||
}).block,
|
||||
text,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds a named medication to its own dose, not to any dose on the plan", () => {
|
||||
// 40 mg is on the plan, but for atorvastatin, not ramipril.
|
||||
expect(
|
||||
screenModelOutput(
|
||||
"Take 40 mg of ramipril as before.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ dosePlan: PLAN },
|
||||
).block,
|
||||
).toBe(true);
|
||||
expect(
|
||||
screenModelOutput(
|
||||
"Tome 40 mg de ramipril como antes.",
|
||||
"pt-BR",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ dosePlan: PLAN },
|
||||
).block,
|
||||
).toBe(true);
|
||||
expect(
|
||||
screenModelOutput(
|
||||
"Take 40 mg of atorvastatin as before.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ dosePlan: PLAN },
|
||||
).block,
|
||||
).toBe(false);
|
||||
expect(
|
||||
screenModelOutput(
|
||||
"Take 5 mg of ramipril as before.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ dosePlan: PLAN },
|
||||
).block,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never trusts a continuation for a drug outside the plan", () => {
|
||||
expect(
|
||||
screenModelOutput(
|
||||
"Take 40 mg of simvastatin as before.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ dosePlan: PLAN },
|
||||
).block,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still blocks a titration even when the change lands on a planned value", () => {
|
||||
// A change stem voids the exemption regardless of plan membership.
|
||||
const d = screenModelOutput(
|
||||
"You should continue tapering to 7.5 mg next week.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ scheduleDoses: [7.5] },
|
||||
{ dosePlan: PLAN },
|
||||
);
|
||||
expect(d.block).toBe(true);
|
||||
});
|
||||
|
||||
it("matches a comma-decimal scheduled dose (DE reader)", () => {
|
||||
it("matches a comma-decimal planned dose (DE reader)", () => {
|
||||
const d = screenModelOutput(
|
||||
"Nimm weiterhin deine 7,5 mg wie verordnet.",
|
||||
"de",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ scheduleDoses: [7.5] },
|
||||
{ dosePlan: PLAN },
|
||||
);
|
||||
expect(d.block).toBe(false);
|
||||
});
|
||||
|
||||
@@ -318,13 +318,14 @@ describe("screenModelOutput — a brand name needs the schedule", () => {
|
||||
});
|
||||
|
||||
describe("screenCoachReply — passes the schedule's names through", () => {
|
||||
const PLAN = [{ name: "Eliquis", dose: 5 }];
|
||||
it("blocks a named-drug skip when the names are supplied", () => {
|
||||
expect(
|
||||
screenCoachReply("Skip the Eliquis tomorrow.", "en", [5], ["Eliquis"])
|
||||
screenCoachReply("Skip the Eliquis tomorrow.", "en", PLAN, ["Eliquis"])
|
||||
.block,
|
||||
).toBe(true);
|
||||
expect(
|
||||
screenCoachReply("Skip the Eliquis tomorrow.", "en", [5]).block,
|
||||
screenCoachReply("Skip the Eliquis tomorrow.", "en", PLAN).block,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -286,14 +286,26 @@ describe("screenModelOutput — risk narrowing (D1/D4/M4)", () => {
|
||||
// ── D7 — dose continuation-exclusion ─────────────────────────────────────
|
||||
|
||||
describe("screenModelOutput — dose continuation-exclusion (D7)", () => {
|
||||
it("PASSES an adherence-supporting continuation of the current dose", () => {
|
||||
const PLAN = [{ name: "Semaglutide", dose: 7.5 }];
|
||||
|
||||
it("PASSES an adherence-supporting continuation of the planned dose", () => {
|
||||
const d = screenModelOutput(
|
||||
"You should keep taking your prescribed 7.5 mg exactly as directed.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ dosePlan: PLAN },
|
||||
);
|
||||
expect(d.block).toBe(false);
|
||||
expect(d.reason).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks the same continuation where no plan can confirm the amount", () => {
|
||||
const d = screenModelOutput(
|
||||
"You should keep taking your prescribed 7.5 mg exactly as directed.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
);
|
||||
expect(d.block).toBe(false);
|
||||
expect(d.reason).toBeNull();
|
||||
expect(d.block).toBe(true);
|
||||
});
|
||||
|
||||
it("still blocks 'keep in mind that trying 5 mg' — no maintenance anchor", () => {
|
||||
@@ -334,6 +346,7 @@ describe("screenModelOutput — dose continuation-exclusion (D7)", () => {
|
||||
"Du solltest weiterhin deine 7,5 mg wie verordnet einnehmen.",
|
||||
"de",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ dosePlan: PLAN },
|
||||
);
|
||||
expect(d.block).toBe(false);
|
||||
});
|
||||
@@ -470,6 +483,13 @@ describe("screenModelOutput — empty input", () => {
|
||||
*/
|
||||
const DOSE_CLASS_VIOLATIONS: Record<Locale, readonly string[]> = {
|
||||
en: [
|
||||
"Reduce your dose by half.",
|
||||
"Cut your dose in half.",
|
||||
"Take half a dose.",
|
||||
"Take half your usual dose.",
|
||||
"Reduce your dose.",
|
||||
"Increase your insulin.",
|
||||
"Lower your ramipril a little.",
|
||||
"Increase your metformin to 2000 mg daily.",
|
||||
"Raise your insulin by 4 units tonight.",
|
||||
"Up your metformin to 1500 mg.",
|
||||
@@ -490,6 +510,13 @@ const DOSE_CLASS_VIOLATIONS: Record<Locale, readonly string[]> = {
|
||||
"You should stop taking your lisinopril.",
|
||||
],
|
||||
de: [
|
||||
"Reduzieren Sie die Dosis um die Hälfte.",
|
||||
"Reduziere deine Dosis um die Hälfte.",
|
||||
"Nehmen Sie nur die halbe Dosis.",
|
||||
"Teilen Sie die Tablette in zwei Hälften.",
|
||||
"Reduzieren Sie die Dosis.",
|
||||
"Erhöhe dein Insulin.",
|
||||
"Senken Sie die Dosis etwas.",
|
||||
"Erhöhe dein Metformin auf 2000 mg täglich.",
|
||||
"Reduzieren Sie Ihr Insulin um 4 Einheiten.",
|
||||
"Nimm 2 Tabletten am Abend.",
|
||||
@@ -499,6 +526,12 @@ const DOSE_CLASS_VIOLATIONS: Record<Locale, readonly string[]> = {
|
||||
"Du solltest das Metformin absetzen.",
|
||||
],
|
||||
fr: [
|
||||
"Réduisez la dose de moitié.",
|
||||
"Prenez une demi-dose.",
|
||||
"Prenez la moitié de votre dose.",
|
||||
"Coupez le comprimé en deux.",
|
||||
"Diminuez votre dose.",
|
||||
"Augmentez votre insuline.",
|
||||
"Augmentez votre metformine à 2000 mg.",
|
||||
"Réduisez votre insuline de 4 unités.",
|
||||
"Prenez 2 comprimés le soir.",
|
||||
@@ -506,6 +539,12 @@ const DOSE_CLASS_VIOLATIONS: Record<Locale, readonly string[]> = {
|
||||
"Arrêtez de prendre la metformine.",
|
||||
],
|
||||
es: [
|
||||
"Reduzca la dosis a la mitad.",
|
||||
"Tome media dosis.",
|
||||
"Tome la mitad de la dosis.",
|
||||
"Parta la pastilla por la mitad.",
|
||||
"Reduzca la dosis.",
|
||||
"Aumente la insulina.",
|
||||
"Aumente su metformina a 2000 mg.",
|
||||
"Reduzca la insulina en 4 unidades.",
|
||||
"Tome 2 pastillas por la noche.",
|
||||
@@ -513,6 +552,12 @@ const DOSE_CLASS_VIOLATIONS: Record<Locale, readonly string[]> = {
|
||||
"Deje de tomar la metformina.",
|
||||
],
|
||||
it: [
|
||||
"Riduca la dose della metà.",
|
||||
"Riduca la dose a metà.",
|
||||
"Prenda mezza dose.",
|
||||
"Prenda metà della dose.",
|
||||
"Riduca la dose.",
|
||||
"Aumenti l'insulina.",
|
||||
"Aumenti la metformina a 2000 mg.",
|
||||
"Riduca l'insulina di 4 unità.",
|
||||
"Prenda 2 compresse la sera.",
|
||||
@@ -520,6 +565,14 @@ const DOSE_CLASS_VIOLATIONS: Record<Locale, readonly string[]> = {
|
||||
"Smetta di prendere la metformina.",
|
||||
],
|
||||
pl: [
|
||||
"Zmniejsz dawkę o połowę.",
|
||||
"Weź pół tabletki.",
|
||||
"Weź połowę dawki.",
|
||||
"Przyjmij pół dawki.",
|
||||
"Zmniejsz dawkę.",
|
||||
"Zwiększ dawkę insuliny.",
|
||||
"Odstaw ramipryl.",
|
||||
"Odstaw enalapryl.",
|
||||
"Zwiększ metforminę do 2000 mg.",
|
||||
"Zmniejsz insulinę o 4 jednostki.",
|
||||
"Proszę przyjąć 2 tabletki wieczorem.",
|
||||
@@ -527,6 +580,10 @@ const DOSE_CLASS_VIOLATIONS: Record<Locale, readonly string[]> = {
|
||||
"Przestań brać metforminę.",
|
||||
],
|
||||
nl: [
|
||||
"Verlaag je dosis met de helft.",
|
||||
"Neem een halve dosis.",
|
||||
"Verlaag je dosis.",
|
||||
"Verhoog je insuline.",
|
||||
"Verhoog je metformine naar 2000 mg per dag.",
|
||||
"Verlaag je insuline met 4 eenheden.",
|
||||
"Neem 's avonds 2 tabletten.",
|
||||
@@ -537,6 +594,13 @@ const DOSE_CLASS_VIOLATIONS: Record<Locale, readonly string[]> = {
|
||||
"Bouw je dosis af naar 5 mg.",
|
||||
],
|
||||
"pt-BR": [
|
||||
"Reduza a dose pela metade.",
|
||||
"Tome meia dose.",
|
||||
"Tome metade da dose.",
|
||||
"Corte o comprimido ao meio.",
|
||||
"Diminua a dose.",
|
||||
"Reduza a dose.",
|
||||
"Aumente a insulina.",
|
||||
"Aumente sua metformina para 2000 mg por dia.",
|
||||
"Reduza sua insulina em 4 unidades.",
|
||||
"Tome 2 comprimidos à noite.",
|
||||
@@ -549,6 +613,11 @@ const DOSE_CLASS_VIOLATIONS: Record<Locale, readonly string[]> = {
|
||||
"Aplique 12 UI de insulina antes do jantar.",
|
||||
],
|
||||
ko: [
|
||||
"복용량을 늘리세요.",
|
||||
"복용량을 줄이세요.",
|
||||
"인슐린 용량을 낮추세요.",
|
||||
"반 알만 드세요.",
|
||||
"용량을 늘려 보세요.",
|
||||
"메트포르민을 2000mg으로 증량하세요.",
|
||||
"하루 2정 드세요.",
|
||||
"저녁 인슐린 용량을 건너뛰세요.",
|
||||
@@ -565,6 +634,16 @@ const DOSE_CLASS_VIOLATIONS: Record<Locale, readonly string[]> = {
|
||||
*/
|
||||
const DOSE_CLASS_CLEAN: Record<Locale, readonly string[]> = {
|
||||
en: [
|
||||
"Reduce your sugar intake by half.",
|
||||
"Cut your screen time in half.",
|
||||
"Take half an hour to walk.",
|
||||
"Reduce your salt.",
|
||||
"Ask your doctor whether to reduce your dose.",
|
||||
"Your dose was reduced by half in May.",
|
||||
"Don't reduce your dose.",
|
||||
"Increase your activity.",
|
||||
"Lower your blood pressure with walks.",
|
||||
"Take your medication as prescribed.",
|
||||
"You take 1000 mg twice daily.",
|
||||
"Your log shows 2000 mg.",
|
||||
"Your log shows you take 1000 mg twice daily, and you haven't missed a dose.",
|
||||
@@ -583,6 +662,12 @@ const DOSE_CLASS_CLEAN: Record<Locale, readonly string[]> = {
|
||||
"Aim to bring your LDL down to 100 mg/dL with your doctor.",
|
||||
],
|
||||
de: [
|
||||
"Reduzieren Sie den Zucker um die Hälfte.",
|
||||
"Reduziere deinen Salzkonsum.",
|
||||
"Deine Dosis wurde im Mai halbiert.",
|
||||
"Reduziere die Dosis nicht.",
|
||||
"Nimm dir eine halbe Stunde Zeit.",
|
||||
"Teilen Sie Ihre Medikamentenliste mit der Ärztin.",
|
||||
"Du nimmst 1000 mg zweimal täglich.",
|
||||
"Dein Protokoll zeigt 2000 mg.",
|
||||
"Setz dein Metformin nicht ab.",
|
||||
@@ -590,23 +675,42 @@ const DOSE_CLASS_CLEAN: Record<Locale, readonly string[]> = {
|
||||
"Frag deine Ärztin, ob du die Dosis erhöhen sollst.",
|
||||
],
|
||||
fr: [
|
||||
"Réduisez le sel de moitié.",
|
||||
"Prenez une demi-heure pour marcher.",
|
||||
"Ne réduisez pas votre dose.",
|
||||
"Augmentez votre activité.",
|
||||
"Vous prenez 1000 mg deux fois par jour.",
|
||||
"Ne sautez pas votre dose du soir.",
|
||||
"N'arrêtez pas de prendre la metformine.",
|
||||
],
|
||||
es: [
|
||||
"Reduzca el azúcar a la mitad.",
|
||||
"Tome media hora para caminar.",
|
||||
"No reduzca la dosis.",
|
||||
"Aumente su actividad.",
|
||||
"Usted toma 1000 mg dos veces al día.",
|
||||
"No deje de tomar la metformina.",
|
||||
],
|
||||
it: [
|
||||
"Riduca lo zucchero della metà.",
|
||||
"Prenda mezz'ora per camminare.",
|
||||
"Non riduca la dose.",
|
||||
"Aumenti l'attività.",
|
||||
"Lei prende 1000 mg due volte al giorno.",
|
||||
"Non smetta di prendere la metformina.",
|
||||
],
|
||||
pl: [
|
||||
"Zmniejsz spożycie soli o połowę.",
|
||||
"Nie zmniejszaj dawki.",
|
||||
"Odstaw telefon przed snem.",
|
||||
"Zwiększ aktywność.",
|
||||
"Przyjmuje Pan 1000 mg dwa razy dziennie.",
|
||||
"Nie przestawaj brać leków.",
|
||||
],
|
||||
nl: [
|
||||
"Verlaag je suiker met de helft.",
|
||||
"Verlaag je dosis niet.",
|
||||
"Neem een half uur de tijd.",
|
||||
"Je neemt 1000 mg twee keer per dag.",
|
||||
"Je logboek laat 2000 mg zien.",
|
||||
"Sla je avonddosis niet over.",
|
||||
@@ -618,6 +722,11 @@ const DOSE_CLASS_CLEAN: Record<Locale, readonly string[]> = {
|
||||
"Vraag je arts voordat je stopt met metformine.",
|
||||
],
|
||||
"pt-BR": [
|
||||
"Reduza o açúcar pela metade.",
|
||||
"Tome meia hora para caminhar.",
|
||||
"Corte o pão ao meio.",
|
||||
"Não reduza a dose.",
|
||||
"Aumente sua atividade.",
|
||||
"Você toma 1000 mg duas vezes ao dia.",
|
||||
"Seu registro mostra 2000 mg.",
|
||||
"Não pule a dose da noite.",
|
||||
@@ -632,6 +741,10 @@ const DOSE_CLASS_CLEAN: Record<Locale, readonly string[]> = {
|
||||
"Converse com seu médico antes de parar de tomar a metformina.",
|
||||
],
|
||||
ko: [
|
||||
"걸음 수를 늘리세요.",
|
||||
"용량을 줄이지 마세요.",
|
||||
"약 2주 동안 걷는 시간을 늘리세요.",
|
||||
"물 섭취를 늘리세요.",
|
||||
"하루 두 번 1000mg을 복용하고 계세요.",
|
||||
"저녁 용량을 건너뛰지 마세요.",
|
||||
"용량을 늘릴지 의사와 상담하세요.",
|
||||
@@ -686,7 +799,7 @@ describe("screenModelOutput — the dose-change class, every locale", () => {
|
||||
"Take your 1000 mg as usual.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ scheduleDoses: [1000] },
|
||||
{ dosePlan: [{ name: "Metformin", dose: 1000 }] },
|
||||
).block,
|
||||
).toBe(false);
|
||||
// Off schedule: the same phrasing names a dose the user is not on.
|
||||
@@ -695,7 +808,7 @@ describe("screenModelOutput — the dose-change class, every locale", () => {
|
||||
"Take your 2000 mg as usual.",
|
||||
"en",
|
||||
CONVERSATIONAL_CONTRACTS,
|
||||
{ scheduleDoses: [1000] },
|
||||
{ dosePlan: [{ name: "Metformin", dose: 1000 }] },
|
||||
).block,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -53,10 +53,12 @@ import { bank as ko } from "./outbound-screen/locales/ko";
|
||||
import { bank as ptBR } from "./outbound-screen/locales/pt-br";
|
||||
import {
|
||||
DOSE_UNIT,
|
||||
INN_STEM,
|
||||
LETTER,
|
||||
UNIT_END,
|
||||
type LocaleBank,
|
||||
} from "./outbound-screen/shared";
|
||||
import type { ScheduledDose } from "@/lib/medications/scheduled-doses";
|
||||
|
||||
/** Which contract a caller asks the screen to enforce. */
|
||||
export type OutboundContract = "dose" | "risk" | "causal";
|
||||
@@ -312,13 +314,20 @@ export const INSIGHTS_CONTRACTS: readonly OutboundContract[] = [
|
||||
/**
|
||||
* Continuation-exclusion for the dose bank (v1.32.7 — D7). A sentence that
|
||||
* matches a dose pattern is EXEMPT only when it is anchored to a maintenance
|
||||
* object ("keep taking … as prescribed") AND carries no change stem. So
|
||||
* "you should keep taking your prescribed 7.5 mg" passes, while
|
||||
* object ("keep taking … as prescribed"), carries no change stem, AND every
|
||||
* amount it states is one the person is on for the medication it names. So
|
||||
* "keep taking your prescribed 7.5 mg" passes for someone on 7.5 mg, while
|
||||
* "you should keep in mind that trying 5 mg" (no maintenance anchor) and
|
||||
* "you should continue tapering to 2.4 mg" (a change stem present) stay
|
||||
* blocked. The direction is exclusion-of-continuation, never
|
||||
* requirement-of-change — "you should try 5 mg" must keep blocking.
|
||||
*
|
||||
* The plan check is not optional. The phrase alone used to be enough on every
|
||||
* surface that passed no schedule, so "Take 40 mg as before." went out from
|
||||
* a nudge or a briefing to someone who had never taken 40 mg of anything: "as
|
||||
* before" is the model's claim, and only the plan can confirm it. A surface
|
||||
* without the plan cannot exempt a stated amount at all.
|
||||
*
|
||||
* Accepted residual FP, failing safe: "I'd recommend discussing the 2.4 mg
|
||||
* step with your doctor" stays blocked ("step" is a change stem).
|
||||
*/
|
||||
@@ -347,31 +356,41 @@ function sentenceDoseValues(sentence: string): number[] {
|
||||
return values;
|
||||
}
|
||||
|
||||
/** True when a dose value matches a scheduled dose (exact / ±2%). */
|
||||
function matchesSchedule(
|
||||
values: readonly number[],
|
||||
schedule: readonly number[],
|
||||
/** True when a dose value equals a planned one (exact / ±2%). */
|
||||
function sameDose(value: number, planned: number): boolean {
|
||||
return Math.abs(value - planned) <= Math.max(0.01, Math.abs(planned) * 0.02);
|
||||
}
|
||||
|
||||
const INN_STEM_RE = new RegExp(INN_STEM, "i");
|
||||
|
||||
/**
|
||||
* True when every amount a continuation sentence states is on the person's
|
||||
* plan for the medication it names. A sentence naming one of their
|
||||
* medications is held to that medication's doses; one naming a drug outside
|
||||
* the plan is never trusted; one naming none is held to the whole plan.
|
||||
*/
|
||||
function continuationMatchesPlan(
|
||||
sentence: string,
|
||||
plan: readonly ScheduledDose[] | undefined,
|
||||
): boolean {
|
||||
return values.some((v) =>
|
||||
schedule.some((s) => Math.abs(v - s) <= Math.max(0.01, Math.abs(s) * 0.02)),
|
||||
const values = sentenceDoseValues(sentence);
|
||||
if (values.length === 0 || !plan || plan.length === 0) return false;
|
||||
const named = plan.filter((entry) => {
|
||||
const alternation = medicationNameAlternation([entry.name]);
|
||||
return alternation !== null && new RegExp(alternation, "i").test(sentence);
|
||||
});
|
||||
if (named.length === 0 && INN_STEM_RE.test(sentence)) return false;
|
||||
const pool = named.length > 0 ? named : plan;
|
||||
return values.every((value) =>
|
||||
pool.some((entry) => sameDose(value, entry.dose)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a dose-change imperative trips, honouring the continuation rule.
|
||||
*
|
||||
* v1.32.9 (Coach Guard II / G3 — M6/D7 end state): when `scheduleDoses` is
|
||||
* supplied (the Coach passes the user's active doses), the continuation
|
||||
* exemption ALSO requires the sentence's dose to match one the user is actually
|
||||
* on. So "keep taking your 7.5 mg" passes only when 7.5 is a scheduled dose;
|
||||
* "keep taking your 15 mg" when the schedule says 7.5 is a wrong maintenance
|
||||
* dose and stays blocked. Without a schedule (every non-Coach surface) the
|
||||
* Guard I phrase-anchored exemption stands unchanged.
|
||||
*/
|
||||
/** True when a dose-change imperative trips, honouring the continuation rule. */
|
||||
function doseTrips(
|
||||
subject: string,
|
||||
locale: Locale,
|
||||
scheduleDoses?: readonly number[],
|
||||
dosePlan?: readonly ScheduledDose[],
|
||||
medicationNames?: readonly string[],
|
||||
): boolean {
|
||||
const bank = BANKS.dose;
|
||||
@@ -386,18 +405,12 @@ function doseTrips(
|
||||
locale === "en"
|
||||
? [DOSE_CHANGE_STEM.en]
|
||||
: [DOSE_CHANGE_STEM[locale], DOSE_CHANGE_STEM.en];
|
||||
const gateOnSchedule =
|
||||
scheduleDoses !== undefined && scheduleDoses.length > 0;
|
||||
for (const sentence of splitSentences(subject)) {
|
||||
if (!patterns.some((p) => p.test(sentence))) continue;
|
||||
let exempt =
|
||||
const exempt =
|
||||
continuation.some((p) => p.test(sentence)) &&
|
||||
!changeStem.some((p) => p.test(sentence));
|
||||
if (exempt && gateOnSchedule) {
|
||||
// The continuation phrasing is only trusted when the dose it names is one
|
||||
// the user is actually on. An off-schedule maintenance dose is caught.
|
||||
exempt = matchesSchedule(sentenceDoseValues(sentence), scheduleDoses);
|
||||
}
|
||||
!changeStem.some((p) => p.test(sentence)) &&
|
||||
continuationMatchesPlan(sentence, dosePlan);
|
||||
if (!exempt) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -413,12 +426,12 @@ function doseTrips(
|
||||
*/
|
||||
export interface ScreenOptions {
|
||||
/**
|
||||
* v1.32.9 — the user's active medication doses (numeric magnitudes). When
|
||||
* present, the dose continuation exemption is additionally gated on a match:
|
||||
* a "keep taking your N mg" is trusted only when N is a scheduled dose. Only
|
||||
* the Coach passes this; every other surface keeps the phrase-anchored rule.
|
||||
* The person's active medications with their dose magnitudes. A "keep
|
||||
* taking your N mg" is trusted only when N is on this plan for the
|
||||
* medication the sentence names; without the plan, a stated amount is never
|
||||
* exempt.
|
||||
*/
|
||||
scheduleDoses?: readonly number[];
|
||||
dosePlan?: readonly ScheduledDose[];
|
||||
/**
|
||||
* The person's own medication names, as typed into their schedule. Each one
|
||||
* counts as a medication noun in the dose-change class, so "skip the
|
||||
@@ -441,9 +454,7 @@ export function screenModelOutput(
|
||||
if (contract === "dose") {
|
||||
// Dose is sentence-scoped so the continuation exemption cannot be
|
||||
// voided by a change stem in an unrelated sentence.
|
||||
if (
|
||||
doseTrips(subject, locale, opts?.scheduleDoses, opts?.medicationNames)
|
||||
) {
|
||||
if (doseTrips(subject, locale, opts?.dosePlan, opts?.medicationNames)) {
|
||||
return { block: true, reason: REASON_FOR_CONTRACT.dose };
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -105,6 +105,29 @@ export const bank: LocaleBank = {
|
||||
`\\bverzichte(?:n\\s+sie)?\\s+${DE_SHORT}auf\\s+${DE_SHORT}${med}`,
|
||||
"i",
|
||||
),
|
||||
// Halving or a fraction with no number: "reduzieren Sie die Dosis um die
|
||||
// Hälfte", "nehmen Sie nur die halbe Dosis", "teilen Sie die Tablette in
|
||||
// zwei Hälften". "Teilen" alone also means "share", so the split needs
|
||||
// its "in Hälften".
|
||||
new RegExp(
|
||||
`\\b(?:reduzier(?:e|en)?|verringer(?:e|n)?|senk(?:e|en)?|kürz(?:e|en)?)(?:\\s+sie)?\\s+${DE_SLOT}${med}\\s+um\\s+(?:die\\s+)?hälfte`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`\\b(?:nimm|nehmen\\s+sie|spritz|spritzen\\s+sie|injizier(?:e)?|injizieren\\s+sie)\\s+(?:nur\\s+)?(?:die\\s+|eine\\s+)?(?:halbe|hälfte\\s+(?:der|deiner|ihrer))\\s+${DE_SHORT}${med}`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`\\bteil(?:e|en)?(?:\\s+sie)?\\s+${DE_SLOT}${med}\\s+(?:in\\s+(?:zwei\\s+)?hälften|in\\s+der\\s+mitte|durch\\s+zwei)`,
|
||||
"i",
|
||||
),
|
||||
// A bare change imperative on a medication, no target: "reduzieren Sie
|
||||
// die Dosis.", "erhöhe dein Insulin etwas". The noun closes the clause or
|
||||
// meets a time or degree word; "reduziere die Dosis nicht" does neither.
|
||||
new RegExp(
|
||||
`\\b(?:erhöh(?:e|en)?|steiger(?:e|n)?|reduzier(?:e|en)?|senk(?:e|en)?|verringer(?:e|n)?)(?:\\s+sie)?\\s+${DE_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:jetzt|heute|morgen|etwas|leicht|langsam|schrittweise|ab\\s+sofort|für|bis)\\b)`,
|
||||
"i",
|
||||
),
|
||||
],
|
||||
dose: [
|
||||
new RegExp(
|
||||
|
||||
@@ -115,6 +115,24 @@ export const bank: LocaleBank = {
|
||||
`${EN_CUE}(?:(?!\\b(?:not|never|before|without|until|unless|ask|asking|whether|if)\\b)[^.?!]){0,20}?\\b(?:(?:stopping|omitting|leaving\\s+out)\\s+${EN_SHORT}${med}|taking\\s+(?:another|(?:(?:an?|one|two|\\d+)\\s+)?(?:extra|additional|more))\\s+${EN_SHORT}${med})`,
|
||||
"i",
|
||||
),
|
||||
// Halving or a fraction with no number: "reduce your dose by half",
|
||||
// "take half a dose". A half hour or half your sugar names no medication.
|
||||
new RegExp(
|
||||
`${EN_LEAD}(?:reduce|cut|lower|decrease|drop|split|break|divide)\\s+${EN_SHORT}${med}\\s+(?:by|in)\\s+(?:a\\s+)?half\\b`,
|
||||
"im",
|
||||
),
|
||||
new RegExp(
|
||||
`${EN_LEAD}(?:take|use|inject|give\\s+yourself)\\s+(?:only\\s+|just\\s+)?half\\s+(?:of\\s+)?(?:(?:a|an|the|your)\\s+)?${EN_SHORT}${med}`,
|
||||
"im",
|
||||
),
|
||||
// A bare change imperative on a medication, no target: "reduce your
|
||||
// dose.", "increase your insulin slightly". The medication noun has to
|
||||
// close the clause or meet a time or degree word, so "reduce your dose of
|
||||
// stress" and "cut your medication costs" are not read as one.
|
||||
new RegExp(
|
||||
`${EN_LEAD}(?:increase|raise|up|boost|bump|reduce|lower|decrease|cut|drop)\\s+${EN_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:now|today|tonight|tomorrow|a\\s+(?:bit|little)|slightly|gradually|somewhat|this\\s+week|for|until|from\\s+now)\\b)`,
|
||||
"im",
|
||||
),
|
||||
],
|
||||
dose: [
|
||||
// step/move/increase/raise/bump/titrate/go up to|by N unit
|
||||
|
||||
@@ -65,6 +65,22 @@ export const bank: LocaleBank = {
|
||||
`(?<!\\b(?:no|nunca)\\s)\\b(?:deje|pare|abandone)\\s+${ES_SHORT}${med}`,
|
||||
"i",
|
||||
),
|
||||
// Halving or a fraction with no number: "reduzca la dosis a la mitad",
|
||||
// "tome media dosis", "tome la mitad de la dosis".
|
||||
new RegExp(
|
||||
`(?<!\\b(?:no|nunca)\\s)\\b(?:reduzca|reduce|baje|disminuya|corte|parta|divida)\\s+${ES_SLOT}${med}\\s+(?:a|en|por)\\s+la\\s+mitad`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?<!\\b(?:no|nunca)\\s)\\b(?:tome|tómese|inyecte|inyéctese|use)\\s+(?:s[óo]lo\\s+|solamente\\s+)?(?:media\\s+|la\\s+mitad\\s+de\\s+)${ES_SHORT}${med}`,
|
||||
"i",
|
||||
),
|
||||
// A bare change imperative on a medication, no target: "reduzca la
|
||||
// dosis.", "aumente la insulina un poco".
|
||||
new RegExp(
|
||||
`(?<!\\b(?:no|nunca)\\s)\\b(?:aumente|suba|incremente|reduzca|baje|disminuya)\\s+${ES_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:ahora|hoy|mañana|esta\\s+noche|un\\s+poco|ligeramente|gradualmente|durante|desde)(?![\\w\\u00C0-\\u024F]))`,
|
||||
"i",
|
||||
),
|
||||
],
|
||||
dose: [
|
||||
// aumente / suba / pase a|en N unit
|
||||
|
||||
@@ -69,6 +69,22 @@ export const bank: LocaleBank = {
|
||||
`(?<!\\bne\\s)(?<!\\b(?:vous|nous|je|tu|il|elle|on)\\s)\\b(?:prenez|prends|injectez|ajoutez)\\s+${FR_SHORT}(?:(?:encore\\s+)?(?:un|une|deux|trois|\\d+)\\s+${FR_SHORT}${med}[^.?!]{0,30}?(?:\\ben\\s+plus\\b|\\bde\\s+plus\\b|\\bsuppl[ée]mentaires?)|(?:un|une)\\s+autres?\\s+${FR_SHORT}${med}|encore\\s+(?:un|une)\\s+${FR_SHORT}${med})`,
|
||||
"i",
|
||||
),
|
||||
// Halving or a fraction with no number: "réduisez la dose de moitié",
|
||||
// "prenez une demi-dose", "coupez le comprimé en deux".
|
||||
new RegExp(
|
||||
`(?<!\\b(?:vous|nous|je|tu|il|elle|on)\\s)(?<!n['’])\\b(?:r[ée]duisez|r[ée]duis|diminuez|diminue|baissez|baisse|coupez|coupe|divisez|divise)\\s+${FR_SLOT}${med}\\s+(?:de\\s+moiti[ée]|par\\s+deux|en\\s+deux)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?<!\\b(?:vous|nous|je|tu|il|elle|on)\\s)\\b(?:prenez|prends|injectez|injecte)\\s+(?:seulement\\s+|uniquement\\s+)?(?:une\\s+demi-?\\s?|la\\s+moiti[ée]\\s+d(?:e\\s+|u\\s+|es\\s+|['’]))${FR_SHORT}${med}`,
|
||||
"i",
|
||||
),
|
||||
// A bare change imperative on a medication, no target: "diminuez votre
|
||||
// dose.", "augmentez votre insuline un peu".
|
||||
new RegExp(
|
||||
`(?<!\\b(?:vous|nous|je|tu|il|elle|on)\\s)(?<!n['’])\\b(?:augmentez|augmente|diminuez|diminue|r[ée]duisez|r[ée]duis|baissez|baisse)\\s+${FR_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:maintenant|aujourd|demain|ce\\s+soir|un\\s+peu|progressivement|pendant)(?![\\w\\u00C0-\\u024F]))`,
|
||||
"i",
|
||||
),
|
||||
],
|
||||
dose: [
|
||||
// augmentez / montez / passez / portez à|de N unit
|
||||
|
||||
@@ -61,6 +61,22 @@ export const bank: LocaleBank = {
|
||||
`(?<!\\b(?:non|mai)\\s)\\b(?:prenda|assuma|inietti|aggiunga)\\s+${IT_SHORT}(?:(?:un|una|uno|due|tre|\\d+)\\s+${IT_SHORT}${med}[^.?!]{0,30}?(?:\\bin\\s+più|\\bextra\\b|\\baggiuntiv[aoei]\\b|\\bin\\s+aggiunta\\b)|(?:un['’]altra|un\\s+altro|altr[eio])\\s*${IT_SHORT}${med})`,
|
||||
"i",
|
||||
),
|
||||
// Halving or a fraction with no number: "riduca la dose della metà",
|
||||
// "prenda mezza dose", "prenda metà della dose".
|
||||
new RegExp(
|
||||
`(?<!\\b(?:non|mai)\\s)\\b(?:riduca|ridurre|diminuisca|abbassi|tagli|divida)\\s+${IT_SLOT}${med}\\s+(?:della|alla|a|per)\\s+met[àa]`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?<!\\b(?:non|mai)\\s)\\b(?:prenda|assuma|inietti)\\s+(?:solo\\s+|soltanto\\s+)?(?:mezz[ao]\\s+|(?:la\\s+)?met[àa]\\s+(?:della|del|dell['’]|di)\\s*)${IT_SHORT}${med}`,
|
||||
"i",
|
||||
),
|
||||
// A bare change imperative on a medication, no target: "riduca la
|
||||
// dose.", "aumenti l'insulina un po'".
|
||||
new RegExp(
|
||||
`(?<!\\b(?:non|mai)\\s)\\b(?:aumenti|incrementi|riduca|abbassi|diminuisca)\\s+${IT_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:ora|oggi|domani|stasera|un\\s+po|leggermente|gradualmente|per|da\\s+subito)(?![\\w\\u00C0-\\u024F]))`,
|
||||
"i",
|
||||
),
|
||||
],
|
||||
dose: [
|
||||
// aumenti / salga / passi a|di N unit
|
||||
|
||||
@@ -70,6 +70,13 @@ export const bank: LocaleBank = {
|
||||
new RegExp(
|
||||
`${med}[^.?!]{0,12}(?:(?:(?:한|두|세|\\d+)\\s*(?:알|정|캡슐|번|단위)?\\s*)?더|추가로)\\s*(?:드세요|드십시오|복용하세요|복용하십시오|투여하세요|맞으세요|맞으십시오|주사하세요)`,
|
||||
),
|
||||
// A medication object raised or lowered by imperative with no amount:
|
||||
// "복용량을 늘리세요", "인슐린 용량을 낮추세요". No digit may sit between,
|
||||
// so "약 2주 동안 걷는 시간을 늘리세요" ("약" = about) passes, and the
|
||||
// prohibitive "줄이지 마세요" never meets an imperative ending.
|
||||
new RegExp(
|
||||
`${med}[^.?!\\d]{0,8}?(?:(?:늘리|올리|높이|줄이|내리|낮추|증량하|감량하)(?:세요|십시오)|(?:늘려|올려|높여|줄여|내려|낮춰)\\s*(?:보세요|주세요|보십시오))`,
|
||||
),
|
||||
],
|
||||
// Korean is verb-final: the dose comes first and the change verb closes the
|
||||
// clause ("2.4mg으로 늘려 보세요"), the mirror image of the Latin banks. A
|
||||
|
||||
@@ -111,6 +111,22 @@ export const bank: LocaleBank = {
|
||||
`(?<!\\b(?:niet|nooit)\\s)\\b(?:neem|slik|spuit|injecteer|voeg)\\s+${NL_SHORT}(?:(?:(?:een|één|twee|\\d+)\\s+)?(?:extra|aanvullende?|tweede)[-\\s]+${NL_SHORT}${med}|nog\\s+(?:een|één|twee|\\d+)\\s+${NL_SHORT}${med}|(?:een|één|twee|drie|\\d+)\\s+${NL_SHORT}${med}\\s+(?:meer|extra)\\b)`,
|
||||
"i",
|
||||
),
|
||||
// Halving or a fraction with no number: "verlaag je dosis met de helft",
|
||||
// "neem een halve dosis", "snijd de tablet doormidden".
|
||||
new RegExp(
|
||||
`(?<!\\b(?:niet|nooit)\\s)\\b(?:verlaag|verminder|deel|breek|snijd)\\s+${NL_SLOT}${med}\\s+(?:met\\s+de\\s+helft|door\\s+(?:twee|2)|doormidden|in\\s+twee)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?<!\\b(?:niet|nooit)\\s)\\b(?:neem|slik|spuit|injecteer)\\s+(?:alleen\\s+|maar\\s+)?(?:een\\s+halve|de\\s+helft\\s+van)\\s+${NL_SHORT}${med}`,
|
||||
"i",
|
||||
),
|
||||
// A bare change imperative on a medication, no target: "verlaag je
|
||||
// dosis.", "verhoog je insuline wat". "Verlaag je dosis niet" is a warning.
|
||||
new RegExp(
|
||||
`(?<!\\b(?:niet|nooit)\\s)\\b(?:verhoog|verlaag|verminder)\\s+${NL_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:nu|vandaag|morgen|vanavond|iets|wat|een\\s+beetje|geleidelijk|vanaf)\\b)`,
|
||||
"i",
|
||||
),
|
||||
],
|
||||
dose: [
|
||||
// verhoog / ga omhoog / bouw op naar|tot|met N unit
|
||||
|
||||
@@ -59,6 +59,23 @@ export const bank: LocaleBank = {
|
||||
`(?:(?<!\\bnie\\s)\\b(?:we[źz]|bierz|przyjmij|wstrzyknij|dodaj)|\\b(?:proszę|należy|warto)\\s+(?:wzi[ąa][ćc]|przyj[ąa][ćc]|wstrzykn[ąa][ćc]|doda[ćc]))(?=\\s)\\s+${PL_SHORT}(?:(?:dodatkow\\S*|kolejn\\S*|jeszcze\\s+jedn\\S*)\\s+${PL_SHORT}${med}|(?:jedn\\S*|dwie|dwa|trzy|\\d+)\\s+${PL_SHORT}${med}\\s+więcej(?=[\\s.,;:!?]|$))`,
|
||||
"i",
|
||||
),
|
||||
// Halving or a fraction with no number: "zmniejsz dawkę o połowę",
|
||||
// "weź połowę dawki", "przyjmij pół dawki".
|
||||
new RegExp(
|
||||
`(?<!\\bnie\\s)\\b(?:zmniejsz|obni[żz]|zredukuj|podziel|przetnij|przełam)(?:cie)?\\s+${PL_SLOT}${med}\\s+(?:o\\s+połowę|na\\s+pół|na\\s+połowę|na\\s+dwie\\s+części)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:(?<!\\bnie\\s)\\b(?:we[źz]|bierz|przyjmij|przyjmuj|wstrzyknij)|\\b(?:proszę|należy)\\s+(?:wzi[ąa][ćc]|przyj[ąa][ćc]|przyjmowa[ćc]))\\s+(?:tylko\\s+)?(?:pół|połowę|połowy)\\s+${PL_SHORT}${med}`,
|
||||
"i",
|
||||
),
|
||||
// A bare change imperative on a medication, no target: "zmniejsz
|
||||
// dawkę.", "zwiększ dawkę insuliny.". The noun closes the clause, or one
|
||||
// genitive word does ("dawkę insuliny"), or a time or degree word follows.
|
||||
new RegExp(
|
||||
`(?<!\\bnie\\s)\\b(?:zwi[ęe]ksz|zmniejsz|obni[żz]|podnie[śs]|podwy[żz]sz|zredukuj)(?:cie)?\\s+${PL_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+[^\\s\\d.?!,;:]+\\s*(?:[.!?;:)]|$)|\\s+(?:teraz|dziś|dzisiaj|jutro|trochę|nieco|stopniowo|od\\s+jutra|przez)(?=[\\s.,;:!?]|$))`,
|
||||
"i",
|
||||
),
|
||||
],
|
||||
dose: [
|
||||
// zwiększ / podnieś / przejdź do|o N unit
|
||||
|
||||
@@ -92,6 +92,22 @@ export const bank: LocaleBank = {
|
||||
`(?<!\\b(?:não|nao|nunca|jamais)\\s)\\b(?:tome|use|aplique|injete|acrescente|adicione)\\s+${PT_SHORT}(?:(?:um|uma|dois|duas|três|\\d+)\\s+${PT_SHORT}${med}[^.?!]{0,20}?(?:\\ba\\s+mais\\b|\\bextras?\\b|\\badiciona(?:l|is)\\b)|(?:outr[oa]s?|mais\\s+(?:um|uma))\\s+${PT_SHORT}${med})`,
|
||||
"i",
|
||||
),
|
||||
// Halving or a fraction with no number: "reduza a dose pela metade",
|
||||
// "tome meia dose", "corte o comprimido ao meio".
|
||||
new RegExp(
|
||||
`(?<!\\b(?:não|nao|nunca|jamais)\\s)\\b(?:reduza|diminua|baixe|corte|parta|divida)\\s+${PT_SLOT}${med}\\s+(?:pela\\s+metade|ao\\s+meio|em\\s+duas\\s+partes|[àa]\\s+metade)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?<!\\b(?:não|nao|nunca|jamais)\\s)\\b(?:tome|use|aplique|injete)\\s+(?:s[óo]\\s+|apenas\\s+|somente\\s+)?(?:meia\\s+|(?:a\\s+)?metade\\s+d[aoe]s?\\s+)${PT_SHORT}${med}`,
|
||||
"i",
|
||||
),
|
||||
// A bare change imperative on a medication, no target: "reduza a
|
||||
// dose.", "aumente a insulina um pouco".
|
||||
new RegExp(
|
||||
`(?<!\\b(?:não|nao|nunca|jamais)\\s)\\b(?:aumente|suba|eleve|reduza|diminua|baixe|abaixe)\\s+(?:(?:a|o|as|os)\\s+)?${PT_SHORT}${med}(?=\\s*(?:[.!?;:)]|$)|\\s+(?:agora|hoje|amanh[ãa]|um\\s+pouco|aos\\s+poucos|gradualmente|a\\s+partir)(?![\\w\\u00C0-\\u024F]))`,
|
||||
"i",
|
||||
),
|
||||
],
|
||||
dose: [
|
||||
// aumente / suba / passe a dose para|a|até|em N unit
|
||||
|
||||
@@ -84,12 +84,13 @@ export const LETTER = "a-zà-ÿąćęłńóśźż";
|
||||
/**
|
||||
* Common generic-name stems (INN): ramipril, losartan, bisoprolol,
|
||||
* atorvastatin, amlodipine, metformin, semaglutide, apixaban, omeprazole,
|
||||
* furosemide. A surface that passes no schedule (a nudge, the document chat)
|
||||
* furosemide, with the Polish spellings ("ramipryl", "atorwastatyna",
|
||||
* "semaglutyd") beside the Latin ones. A surface that passes no schedule (a nudge, the document chat)
|
||||
* still recognises "skip the amlodipine". Two letters must precede the stem,
|
||||
* so "April" is not a drug; the stems are chosen so no everyday word ends in
|
||||
* one ("comparing" is why heparin is spelled out rather than stemmed).
|
||||
*/
|
||||
export const INN_STEM = `(?<![${LETTER}])[${LETTER}]{2,}(?:pril|sartan|olol|statin|dipin|dypin|formin|glutid|gliflozin|gliptin|xaban|ksaban|prazol|semid)[${LETTER}]{0,4}(?![${LETTER}])|(?<![${LETTER}])(?:enoxa|dalte)?heparin[${LETTER}]{0,4}`;
|
||||
export const INN_STEM = `(?<![${LETTER}])[${LETTER}]{2,}(?:pril|pryl|sartan|olol|statin|statyn|dipin|dypin|formin|glutid|glutyd|gliflozin|gliptin|xaban|ksaban|prazol|semid)[${LETTER}]{0,4}(?![${LETTER}])|(?<![${LETTER}])(?:enoxa|dalte)?heparin[${LETTER}]{0,4}`;
|
||||
|
||||
export const ENGINE = "(?:framingham|ascvd|score2|qrisk)";
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* v1.32.9 (Coach Guard II / G3) — the numeric doses on a user's active
|
||||
* medication schedule, for the Grounding Ledger and the schedule-gated dose
|
||||
* medication schedule, for the Grounding Ledger and the plan-gated dose
|
||||
* continuation exemption.
|
||||
*
|
||||
* `Medication.dose` is free text as printed on the container ("7.5 mg",
|
||||
* "1 pieces", "0,25 mg"). It is deliberately NOT parsed into number + unit at
|
||||
* write time (see the schema note), so this helper extracts the leading numeric
|
||||
* magnitude the same way the display formatter does. The output is a de-duped
|
||||
* set of magnitudes: the guard asks "is 7.5 a dose this user is actually on?",
|
||||
* not "which medication", so the unit is irrelevant to the match.
|
||||
* magnitude the same way the display formatter does. The unit is irrelevant to
|
||||
* the match: the screen asks "is 7.5 the dose of the medication this sentence
|
||||
* names?", and the ledger asks only for the magnitudes.
|
||||
*
|
||||
* Server-only; a cheap indexed read over the user's active medications.
|
||||
*/
|
||||
@@ -26,21 +26,29 @@ function parseDoseValue(raw: string | null | undefined): number | null {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** One active medication and the leading magnitude of its dose. */
|
||||
export interface ScheduledDose {
|
||||
name: string;
|
||||
dose: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The de-duped set of numeric doses across the user's ACTIVE medications. Empty
|
||||
* The user's ACTIVE medications that carry a parseable dose, each with its
|
||||
* name. The outbound screen holds a "keep taking your N mg" to the dose of
|
||||
* the medication the sentence names, not to any dose on the schedule. Empty
|
||||
* when the user has no active medications or none carries a parseable dose.
|
||||
*/
|
||||
export async function getScheduledDoseValues(
|
||||
export async function getScheduledDosePlan(
|
||||
userId: string,
|
||||
): Promise<number[]> {
|
||||
): Promise<ScheduledDose[]> {
|
||||
const rows = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
select: { dose: true },
|
||||
select: { name: true, dose: true },
|
||||
});
|
||||
const values = new Set<number>();
|
||||
const plan: ScheduledDose[] = [];
|
||||
for (const row of rows) {
|
||||
const value = parseDoseValue(row.dose);
|
||||
if (value !== null) values.add(value);
|
||||
const dose = parseDoseValue(row.dose);
|
||||
if (dose !== null) plan.push({ name: row.name.trim(), dose });
|
||||
}
|
||||
return [...values];
|
||||
return plan;
|
||||
}
|
||||
Reference in new issue
Block a user