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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user