feat: AI chat claim confirmation, CDT alias learning, and eligibility auto-trigger fixes

- Claim flow: show green confirm card (patient, CDT codes, service date) before Selenium starts
- CDT lookup: add DIRECT_CODE_MAP + ALIAS_MAP with 60+ dental abbreviations from office fee schedule
  (2BW→D0272, 4BW→D0274, PA→D0220, FL→D1208, RCT codes, composite tooth#/surface parser, etc.)
- Composite filling parser: auto-map "#29 OB" → D2392 based on tooth# (front/back) and surface count
- Ask-and-learn: unknown CDT terms block claim and ask user; answer saved to DB alias map for future use
- Cancel on confirm card returns to chat (not full reset) so user can correct and retry
- Eligibility auto-trigger fix: reset autoTriggeredRef when autoTrigger resets to false so second
  chatbot eligibility check on same page visit fires correctly (all 5 provider buttons fixed)
- check_eligibility by name now returns eligibility_id_ready with correct siteKey for non-MH patients
- DDMA/CCA/United/Tufts fee schedules updated with office prices (single Price field, no age split)
- getCodeMap case-insensitive matching fix (ddma/cca/etc. now correctly selected)
- Family plan member disambiguation: insuranceId+DOB composite lookup prevents overwriting siblings
- AI chat date fix: send clientDate from browser to avoid UTC midnight rollover (EST→wrong day)
- AI prompt: appointmentDate extracted for claim_only intent when user says "today" or a date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-06 21:11:58 -04:00
co-authored by Claude Sonnet 4.6
parent 4899ab8368
commit 86cf55aa4d
16 changed files with 1405 additions and 4913 deletions
+45 -20
View File
@@ -31,24 +31,27 @@ export interface ChatClassification {
// ─── System prompt ────────────────────────────────────────────────────────────
const BASE_SYSTEM_PROMPT = `You are an internal assistant for a dental office management app.
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>",
"dob": "<date of birth in MM/DD/YYYY if given>",
"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>",
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
"procedureNames": ["<raw procedure name>", ...],
"appointmentDate": "<YYYY-MM-DD if a specific date is mentioned, omit for today>",
"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.
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
@@ -66,9 +69,11 @@ Intents:
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"
e.g. "bill adult cleaning for Maria"
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
- navigate_claims : open the claims page
- navigate_schedule : open the appointments/schedule page
- general : anything else
@@ -76,15 +81,24 @@ Intents:
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
- 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
- insuranceHint is only set when the user explicitly names an insurance in the message
- Keep fallbackReply to 1-2 sentences
- For navigate intents, fallbackReply = "Opening the [page] page..."
- For schedule_appointment, appointmentDate omitted means today; appointmentTime omitted means no preference
- 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 that was mentioned most recently.
Always populate patientName (or memberId) from history when a pronoun is used.
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 ───────────────────────────────────────────────────────────────
@@ -94,7 +108,8 @@ export async function classifyInternalChat(
extraSystemPrompt?: string,
history: { role: "user" | "assistant"; text: string }[] = [],
provider: AiProvider = "google",
model?: string
model?: string,
clientDate?: string // YYYY-MM-DD from the browser's local clock
): Promise<ChatClassification> {
const fallback: ChatClassification = {
intent: "general",
@@ -104,18 +119,18 @@ export async function classifyInternalChat(
if (!apiKey) return fallback;
const systemPrompt = extraSystemPrompt
? `${BASE_SYSTEM_PROMPT}\n\nAdditional office context:\n${extraSystemPrompt}`
: BASE_SYSTEM_PROMPT;
// 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);
// Gemini requires conversation to start with a user turn — drop any leading assistant messages
const trimmedHistory = history.slice(
history.findIndex((h) => h.role === "user")
).filter((_, i, arr) => {
// Also drop consecutive same-role messages (keep last of each run)
// 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;
});
@@ -131,11 +146,21 @@ export async function classifyInternalChat(
]);
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 {
} 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;
}
}