- Add batch_eligibility, batch_claim, and batch_check_and_claim intents to AI classifier so multiple patients can be processed one by one - Add queue processing on insurance-status and claims pages to auto-start the next patient after each check/claim completes - Make patient schema firstName, lastName, phone optional so patients can be created with just member ID + DOB from eligibility checks - Cancel buttons now preserve chat history instead of clearing it - Patient-found card shows Check Eligibility, Eligibility & Appointment Today, and Cancel buttons - Claim service date asks user to pick between latest appointment and today when they differ - Login page subtitle styled with animated gradient Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
229 lines
15 KiB
TypeScript
229 lines
15 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)
|
|
| "batch_eligibility" // multiple patients by memberId + dob
|
|
| "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)
|
|
| "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)
|
|
// --- 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"]
|
|
// --- 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>",
|
|
"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>", ...],
|
|
"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"
|
|
- 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"
|
|
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)
|
|
- 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_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".
|
|
- 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"
|
|
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
|
|
e.g. "check mh", "check masshealth", "open eligibility", "go to eligibility", "check insurance"
|
|
- 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" — keep the #number and surface letters together as one entry
|
|
- #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"]
|
|
- 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 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 multiple PA X-rays with tooth numbers, expand each PA into its own entry:
|
|
"1 pa, #T" for the first tooth, "2nd pa, #T" for each additional tooth
|
|
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. "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 BOTH schedule_appointment AND claim_only/check_and_claim:
|
|
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")
|
|
omit it only when no date is mentioned at all (the backend will find the last appointment)
|
|
- 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 ───────────────────────────────────────────────────────────────
|
|
|
|
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.",
|
|
};
|
|
|
|
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;
|
|
}
|
|
}
|