Files
DentalManagement09/apps/Backend/src/ai/internal-chat-graph.ts
T

344 lines
24 KiB
TypeScript

import { getLlm, type AiProvider } from "./llm-factory";
// ─── Intent types ─────────────────────────────────────────────────────────────
export type InternalChatIntent =
| "check_eligibility" // by patient name → look up in DB
| "eligibility_by_id" // by explicit memberId + dob (no name)
| "eligibility_by_name_dob" // by explicit first name + last name + dob (no memberId) — United Ucard name search
| "batch_eligibility" // multiple patients by memberId + dob
| "batch_eligibility_by_name" // multiple patients by name (no memberId)
| "batch_claim" // claim same procedures for multiple patients by name
| "batch_check_and_claim" // eligibility + claim for multiple patients by memberId+dob
| "check_and_claim" // eligibility + claim procedures
| "find_patient" // look up patient record only
| "schedule_appointment" // add patient to today's (or specified) schedule
| "claim_only" // submit claim for procedures (no eligibility check)
| "multi_claim" // different procedures for different patients by name
| "preauth" // submit pre-authorization for procedures
| "navigate_claims"
| "navigate_schedule"
| "navigate_eligibility"
| "general";
export interface ChatClassification {
intent: InternalChatIntent;
// --- patient resolution (one of name OR id+dob) ---
patientName?: string; // for check_eligibility / find_patient / schedule_appointment
memberId?: string; // for eligibility_by_id / check_and_claim
dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY)
firstName?: string; // for eligibility_by_name_dob
lastName?: string; // for eligibility_by_name_dob
// --- batch eligibility (multiple patients) ---
patients?: { memberId: string; dob: string }[]; // for batch_eligibility
// --- batch claim (same procedures for multiple patients by name) ---
patientNames?: string[]; // for batch_claim
// --- insurance hint (only if explicitly stated in the message) ---
insuranceHint?: string; // raw text, e.g. "masshealth", "BCBS", "CCA"
// --- rendering/treating provider (only if explicitly stated, e.g. "with provider Kai Gao") ---
renderingProvider?: string; // raw name, e.g. "Kai Gao", "Dr. Smith"
// --- procedures (raw text, NOT CDT codes — CDT lookup is done in workflow) ---
procedureNames?: string[]; // for check_and_claim, e.g. ["perio exam", "adult cleaning"]
// --- multi_claim (different procedures per patient) ---
claimGroups?: { patientName: string; procedureNames: string[] }[];
// --- scheduling ---
appointmentDate?: string; // for schedule_appointment, YYYY-MM-DD (omit = today)
appointmentTime?: string; // for schedule_appointment, HH:MM 24h (omit = 09:00)
fallbackReply: string;
}
// ─── System prompt ────────────────────────────────────────────────────────────
function buildSystemPrompt(today: string, extra?: string | null): string {
const base = `You are an internal assistant for a dental office management app.
Staff type natural language commands. Your ONLY job is to classify the intent and extract
structured parameters. Do NOT map procedure names to CDT codes — return them as plain text.
TODAY'S DATE: ${today}
Respond ONLY with valid JSON (no markdown fences):
{
"intent": "<intent>",
"patientName": "<full name if mentioned by name>",
"memberId": "<member/insurance ID if given explicitly or found in history>",
"firstName": "<first name, only for eligibility_by_name_dob>",
"lastName": "<last name, only for eligibility_by_name_dob>",
"dob": "<date of birth in MM/DD/YYYY if given explicitly or found in history>",
"patients": [{"memberId": "<id>", "dob": "<MM/DD/YYYY>"}, ...],
"patientNames": ["<name1>", "<name2>", ...],
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
"renderingProvider": "<provider/doctor name only if explicitly stated, e.g. 'Kai Gao', 'Dr. Smith' — omit if not mentioned>",
"procedureNames": ["<raw procedure name>", ...],
"claimGroups": [{"patientName": "<name>", "procedureNames": ["<procedure>", ...]}, ...],
"appointmentDate": "<YYYY-MM-DD; use today's date (${today}) if user says 'today'; omit only if no date is mentioned at all>",
"appointmentTime": "<HH:MM 24h if a specific time is mentioned, omit if not stated>",
"fallbackReply": "<1-2 sentence reply to show the user>"
}
Omit any field that is not present in the message or history.
Intents:
- check_eligibility : user wants to check insurance for a patient identified by NAME only
e.g. "check Maria Jesus", "verify insurance for John Smith"
e.g. "check Qu", "check Li", "check Kim" — a short word after "check" is a patient last name, NOT an insurance abbreviation
Insurance abbreviations are: mh, masshealth, bcbs, cca, dentaquest — anything else is a patient name
- eligibility_by_id : user provides a SINGLE member ID and date of birth (no patient name)
e.g. "check masshealth for 100xxxx, 10/10/1988"
e.g. "100xxxx 10/10/1988" or "100xxxx, 10/10/1988" — a bare member ID + DOB with
NO verb and NO insurance keyword is STILL eligibility_by_id (default to MassHealth,
omit insuranceHint). Do NOT classify this as "general".
ALSO use this when user wants to check eligibility AND schedule/add an appointment on a date
e.g. "check mh for 100xxxx, 10/10/1988 and schedule on 4/10/2026"
e.g. "check mh for 100xxxx, 10/10/1988 and make appointment on 5/1/2026"
In these cases set appointmentDate to the mentioned date (YYYY-MM-DD)
- eligibility_by_name_dob : user provides a first name, last name AND date of birth but NO member ID
e.g. "check ucard Kai Gao 08/30/1997", "Kai Gao, 8/30/1997"
e.g. "canin gomez, 08/30/1997, ucard" → firstName "canin", lastName "gomez" (ALWAYS: first word = first name, all remaining words = last name, e.g. "min ping wang" → firstName "min", lastName "ping wang")
Set firstName, lastName and dob (MM/DD/YYYY). Set insuranceHint if stated (e.g. "ucard").
- batch_eligibility : user provides MULTIPLE member IDs with dates of birth in one message
e.g. "check mh for 100xxxx 10/10/1988 and 200xxxx 5/5/2000"
e.g. "check 100xxxx, 10/10/1988 and 200xxxx, 5/5/2000"
Use this ONLY when TWO OR MORE distinct memberId+dob pairs are given.
Put each pair into the "patients" array. Also set insuranceHint if stated.
- batch_eligibility_by_name : user wants to check eligibility for MULTIPLE patients identified by NAME
e.g. "check Mary and Sinthia", "check eligibility for John, Jane, and Bob"
e.g. "verify insurance for Mary and Sinthia"
Use this when TWO OR MORE patient names are given WITHOUT member IDs.
Put each patient name into the "patientNames" array.
- batch_claim : user wants to claim the SAME procedures for MULTIPLE patients identified by NAME
e.g. "claim perio exam and adult prophy for Jackaline and Keioson"
e.g. "perio exam, adult cleaning for Maria and John"
Use this ONLY when procedures AND two or more patient names are given.
Put each patient name into the "patientNames" array. Put procedure names in "procedureNames".
- multi_claim : user wants to claim DIFFERENT procedures for DIFFERENT patients identified by NAME
e.g. "claim #13 crown for flor and claim d5212 for bian"
e.g. "claim perio exam for Maria and claim adult prophy for John"
e.g. "claim D0120 for Jane and D1110 for Bob"
e.g. "claim post #6 for A and comp #10 DL for B, all on 6/23/26"
→ claimGroups: [{patientName:"A", procedureNames:["post #6"]}, {patientName:"B", procedureNames:["comp #10 DL"]}], appointmentDate: "2026-06-23"
Use this when each patient has their OWN distinct set of procedures.
Put each patient+procedures group into the "claimGroups" array.
A trailing date phrase like "all on <date>" or "on <date>" at the end of the message sets
appointmentDate for EVERY group — do not skip it just because it comes after multiple patients.
Do NOT use batch_claim for this — batch_claim is ONLY for the SAME procedures applied to ALL patients.
- batch_check_and_claim : user provides MULTIPLE member IDs with DOBs AND wants to claim PROCEDURES for all of them
e.g. "check mh for 100xxxx 10/10/1988 and 200xxxx 5/5/2000, and claim perio exam and adult prophy"
e.g. "check 100xxxx, 10/10/1988 and 200xxxx, 5/5/2000 and claim D0120 D1110 for them"
Use this when TWO OR MORE memberId+dob pairs are given WITH procedures.
Put each pair into "patients" array. Put procedure names in "procedureNames".
- check_and_claim : user wants to check eligibility AND submit PROCEDURES/BILLING as claims
e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning"
e.g. "check Maria Jesus and claim D0120 D1110"
Only use this when procedures or CDT codes are mentioned — NOT for scheduling
- find_patient : look up a patient record only, no eligibility
e.g. "find patient John", "look up Smith"
- schedule_appointment : add a patient to the schedule (today or a specified date/time)
e.g. "put John Smith in today's schedule"
e.g. "schedule Maria at 2pm tomorrow"
e.g. "add Jane Doe at 10:30"
- claim_only : submit a claim for procedures WITHOUT an eligibility check
e.g. "claim comprehensive exam and Pano for her"
e.g. "claim D0120 and D1110 for John Smith today"
e.g. "bill adult cleaning for Maria on 05/15/2026"
e.g. "claim perio exam, 2BW for John Smith"
e.g. "claim #8 ml for lisa" → procedureNames: ["#8 ml"], patientName: "lisa" (tooth+surface = composite filling, NOT insurance)
e.g. "claim comp #8 ml for lisa today" → procedureNames: ["comp #8 ml"], patientName: "lisa", appointmentDate: today
"comp" is short for "composite" — when followed by #tooth+surfaces, it is a composite filling
Use this when no eligibility check is requested — just billing/claiming services
Always extract appointmentDate when a date or "today" is mentioned
- preauth : submit a pre-authorization request for procedures
e.g. "preauth rct, post, crown for John Smith"
e.g. "pre auth #20 rct, post, crown for Zhiyuan Chen"
e.g. "pre-auth D3320, D2952, D2740 for Maria"
Use this when the user says "preauth", "pre auth", "pre-auth", or "prior auth"
- navigate_claims : open the claims page
- navigate_schedule : open the appointments/schedule page
- navigate_eligibility : open the insurance eligibility page — ONLY when user says a known insurance keyword with no patient name
e.g. "check mh", "check masshealth", "open eligibility", "go to eligibility", "check insurance"
Do NOT use this for "check [name]" — that is check_eligibility
- general : anything else
Rules:
- For check_and_claim and claim_only, procedureNames should be the RAW user text
(e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes
- IMPORTANT: If the user says "with the x ray", "with x ray", "attach x ray", "the x ray", "with xray",
"with the attachment", "the attachment", "with attachment", "with the file", "with the image",
"with the scan", "with the photo", "with the document", or any variation meaning an uploaded/attached file,
do NOT include it in procedureNames. It refers to a file attachment, not a billable procedure.
Only include actual clinical procedures in procedureNames.
- For composite fillings with a tooth number, preserve the EXACT notation including tooth# and surfaces:
e.g. "composite #29 O", "#8 MO", "composite #11 MOD", "# 10 DL", "# 11 ML", "#8 ml", "comp #8 ml" — keep the #number and surface letters together as one entry
Note: "comp" is short for "composite". "# 10 DL", "composite on # 10 DL", and "comp # 10 DL" are all the same — preserve the space-after-# as-is
IMPORTANT: Any letter(s) immediately after a tooth number are DENTAL SURFACE abbreviations indicating a composite filling:
M = Mesial, D = Distal, L = Lingual, F = Facial, B = Buccal, O = Occlusal, V = Vestibular, I = Incisal
e.g. "#8 ML" → composite on tooth 8, mesial+lingual surfaces (NOT an insurance abbreviation)
e.g. "#14 MOD" → composite on tooth 14, mesial+occlusal+distal surfaces
e.g. "#5 F" → composite on tooth 5, facial surface
Whenever you see #[tooth number] followed by any combination of M/D/L/F/B/O/V/I, it is ALWAYS a composite filling surface notation
- #number always means a TOOTH number (never a case or pre-auth reference). When a single #number appears before a comma-separated list of procedures, apply it to EVERY procedure in the list.
e.g. "#20 rct, post, crown" → ["#20 rct", "#20 post", "#20 crown"]
e.g. "preauth #20 rct, pos, crown" → ["#20 rct", "#20 pos", "#20 crown"]
e.g. "#14 rct, buildup, crown" → ["#14 rct", "#14 buildup", "#14 crown"]
- When a procedure is followed by MULTIPLE tooth numbers (comma-separated, with or without # on each), apply the procedure to EVERY tooth number as separate entries.
e.g. "post/core on #23, 24, 25, 26" → ["post/core #23", "post/core #24", "post/core #25", "post/core #26"]
e.g. "crown #3, #5, #12" → ["crown #3", "crown #5", "crown #12"]
e.g. "extraction #1, 16, 17, 32" → ["extraction #1", "extraction #16", "extraction #17", "extraction #32"]
e.g. "rct #23, 24, 25" → ["rct #23", "rct #24", "rct #25"]
e.g. "recement #5, #3" → ["recement #5", "recement #3"]
"recement" / "re-cement" / "re cement" means re-cementing a crown (D2920) — always spread across each tooth number
"post/core" is the same as "post core" — a combined post and core procedure
- For RCT/root canal with a tooth number, preserve the tooth# in the entry:
e.g. "rct #29", "#14 root canal", "rct #6", "#20 rct" — keep the #number with the procedure so the correct code can be selected
- For a CDT code (D####) followed by a tooth number (# NN or #NN), keep them together as ONE entry and do NOT spread the tooth# to other procedures:
e.g. "Limited exam, PANO, D7210 # 32" → ["Limited exam", "PANO", "D7210 # 32"]
e.g. "D7210 #32, D0140, pano" → ["D7210 #32", "D0140", "pano"]
The tooth# after a CDT code belongs only to that procedure.
- For SRP with a quadrant abbreviation (UL, UR, LL, LR), keep the code and quadrant together as one entry:
e.g. "D4341 UL", "4341 LR", "D4342 UR" — the quadrant always travels with the SRP code
- For PA X-rays with tooth numbers, always use "1 pa, #T" format for the first/only PA and "2nd pa, #T" for every additional:
e.g. "1 PA (#13)" → ["1 pa, #13"]
e.g. "PA #13" → ["1 pa, #13"]
e.g. "2 PA (#30, 15)" → ["1 pa, #30", "2nd pa, #15"]
e.g. "3 PA (#3, 14, 30)" → ["1 pa, #3", "2nd pa, #14", "2nd pa, #30"]
e.g. "4 PA (#3, 14, 19, 30)" → ["1 pa, #3", "2nd pa, #14", "2nd pa, #19", "2nd pa, #30"]
e.g. "2 pa #3 #14" → ["1 pa, #3", "2nd pa, #14"]
- insuranceHint is only set when the user explicitly names an insurance in the message
- renderingProvider is only set when the user explicitly names a treating/rendering provider or doctor
e.g. "with provider Kai Gao", "provider Dr. Smith", "rendered by Kai Gao", "doctor Kai Gao"
Extract just the name (without "Dr." prefix unless it's part of the name), omit if not mentioned
- Keep fallbackReply to 1-2 sentences
- For navigate intents, fallbackReply = "Opening the [page] page..." (e.g. "Opening the eligibility page...")
- appointmentDate applies to ALL claim/scheduling intents — schedule_appointment, claim_only,
check_and_claim, batch_claim, multi_claim, batch_check_and_claim, and preauth:
always set it to today's date (${today}) when the user says "today", "this visit", or similar
set it to the specified date when the user mentions a date (e.g. "05/15/2026")
This applies even when the date is mentioned once at the END of the message and covers
multiple patients/groups (e.g. "...claim X for A and Y for B, all on 6/23/26" → appointmentDate
applies to every group, not just the last one mentioned)
omit it only when no date is mentioned at all (the backend will find the last appointment)
- IMPORTANT: Users type dates in American M/D/YYYY format (month first, then day).
e.g. "6/12/2026" means June 12 2026 (NOT December 6). "1/5/2026" means January 5 2026.
If the year has a typo (e.g. "12026" instead of "2026"), correct it to the intended 4-digit year.
- For schedule_appointment, appointmentTime omitted means no preference
- IMPORTANT: Use the conversation history to resolve pronouns and references.
If the user says "her", "him", "them", "the patient", or "same patient", look back through
the conversation history to find the patient name, memberId, AND dob that were mentioned most recently.
Always populate patientName (or memberId) AND dob from history when a pronoun is used.
Family members may share the same memberId — always include the dob so the correct family member is identified.
Never return an empty patientName just because the current message uses a pronoun.`;
return extra?.trim() ? `${base}\n\nAdditional office context:\n${extra.trim()}` : base;
}
// ─── Classifier ───────────────────────────────────────────────────────────────
// Deterministic fast-path for a bare "memberId DOB" (or "DOB memberId") message with
// nothing else in it — e.g. "100232102531 4/27/1999" or "100232102531, 4/27/1999".
// Bypasses the LLM entirely so this always resolves to eligibility_by_id, since some
// providers hedge/refuse structured output on a bare digit string that looks like an SSN.
function parseBareIdDob(message: string): { memberId: string; dob: string } | null {
const text = message.trim();
const dobPattern = /(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{4})/;
const idThenDob = text.match(new RegExp(`^([A-Za-z0-9]{3,})[\\s,]+${dobPattern.source}$`));
if (idThenDob) return { memberId: idThenDob[1]!, dob: idThenDob[2]!.replace(/-/g, "/") };
const dobThenId = text.match(new RegExp(`^${dobPattern.source}[\\s,]+([A-Za-z0-9]{3,})$`));
if (dobThenId) return { memberId: dobThenId[2]!, dob: dobThenId[1]!.replace(/-/g, "/") };
return null;
}
// Deterministic fast-path for a bare "First Last DOB" (or "DOB First Last") message,
// e.g. "Kai Gao 08/30/1997" or "Kai Gao, 8/30/1997". Two or more alphabetic words: first word = first name, the rest = last name.
function parseBareNameDob(message: string): { firstName: string; lastName: string; dob: string } | null {
const text = message.trim();
const dob = "(\\d{1,2}[\\/\\-]\\d{1,2}[\\/\\-]\\d{4})";
const name = "([A-Za-z][A-Za-z'\\-]*)\\s+([A-Za-z][A-Za-z'\\-]*(?:\\s+[A-Za-z][A-Za-z'\\-]*)*)";
const nameThenDob = text.match(new RegExp(`^${name}[\\s,]+${dob}$`));
if (nameThenDob) return { firstName: nameThenDob[1]!, lastName: nameThenDob[2]!, dob: nameThenDob[3]!.replace(/-/g, "/") };
const dobThenName = text.match(new RegExp(`^${dob}[\\s,]+${name}$`));
if (dobThenName) return { firstName: dobThenName[2]!, lastName: dobThenName[3]!, dob: dobThenName[1]!.replace(/-/g, "/") };
return null;
}
export async function classifyInternalChat(
message: string,
apiKey: string,
extraSystemPrompt?: string,
history: { role: "user" | "assistant"; text: string }[] = [],
provider: AiProvider = "google",
model?: string,
clientDate?: string // YYYY-MM-DD from the browser's local clock
): Promise<ChatClassification> {
const fallback: ChatClassification = {
intent: "general",
fallbackReply:
"I can search for a patient, check eligibility, run check & claim, schedule appointments, or navigate to claims or appointments.",
};
const bare = parseBareIdDob(message);
if (bare) {
return {
intent: "eligibility_by_id",
memberId: bare.memberId,
dob: bare.dob,
fallbackReply: `Checking eligibility for Member ID ${bare.memberId}...`,
};
}
const bareName = parseBareNameDob(message);
if (bareName) {
return {
intent: "eligibility_by_name_dob",
firstName: bareName.firstName,
lastName: bareName.lastName,
dob: bareName.dob,
fallbackReply: `Checking eligibility for ${bareName.firstName} ${bareName.lastName}...`,
};
}
if (!apiKey) return fallback;
// Prefer the client's local date (avoids UTC midnight rollover for US timezones)
const today = (clientDate && /^\d{4}-\d{2}-\d{2}$/.test(clientDate))
? clientDate
: (() => { const n = new Date(); return `${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,"0")}-${String(n.getDate()).padStart(2,"0")}`; })();
const systemPrompt = buildSystemPrompt(today, extraSystemPrompt);
try {
const llm = getLlm(provider, apiKey, model);
// Drop leading assistant messages (some providers require conversation to start with user turn)
const firstUserIdx = history.findIndex((h) => h.role === "user");
const trimmedHistory = (firstUserIdx === -1 ? [] : history.slice(firstUserIdx)).filter((_, i, arr) => {
if (i === arr.length - 1) return true;
return arr[i]!.role !== arr[i + 1]!.role;
});
const historyMessages = trimmedHistory.map((h) => ({
role: h.role,
content: h.text,
}));
const response = await llm.invoke([
{ role: "system", content: systemPrompt },
...historyMessages,
{ role: "user", content: message },
]);
const raw = String(response.content).trim();
console.log("[internal-chat] raw LLM response:", raw.slice(0, 400));
const jsonStr = raw.replace(/^```json\s*/i, "").replace(/```\s*$/, "").trim();
const parsed = JSON.parse(jsonStr) as ChatClassification;
if (!parsed.intent || !parsed.fallbackReply) return fallback;
return parsed;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[internal-chat] classifyInternalChat error (provider=%s model=%s): %s", provider, model, msg);
// Surface billing/auth errors so the user sees a useful message in the chat
if (/credit balance|billing|quota|insufficient|authentication|api.key|invalid_api_key/i.test(msg)) {
return {
intent: "general",
fallbackReply: `AI provider error: ${msg.slice(0, 200)}`,
};
}
return fallback;
}
}