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:
@@ -38,37 +38,220 @@ const CODE_TOKENS: { code: string; description: string; tokens: Set<string> }[]
|
|||||||
|
|
||||||
// Common aliases that don't appear verbatim in descriptions
|
// Common aliases that don't appear verbatim in descriptions
|
||||||
const ALIAS_MAP: Record<string, string> = {
|
const ALIAS_MAP: Record<string, string> = {
|
||||||
"perio exam": "periodic oral evaluation",
|
// Exams
|
||||||
"periodic exam": "periodic oral evaluation",
|
"perio exam": "periodic oral evaluation",
|
||||||
"recall exam": "periodic oral evaluation",
|
"periodic exam": "periodic oral evaluation",
|
||||||
"adult cleaning": "prophylaxis adult",
|
"recall exam": "periodic oral evaluation",
|
||||||
"adult prophy": "prophylaxis adult",
|
"comp exam": "comprehensive oral evaluation",
|
||||||
"adult prophylaxis": "prophylaxis adult",
|
"comprehensive exam": "comprehensive oral evaluation",
|
||||||
"child cleaning": "prophylaxis child",
|
"new patient exam": "comprehensive oral evaluation",
|
||||||
"child prophy": "prophylaxis child",
|
"new patient": "comprehensive oral evaluation",
|
||||||
"pedo cleaning": "prophylaxis child",
|
"limited exam": "limited oral evaluation",
|
||||||
"full mouth xray": "intraoral complete series",
|
"emergency exam": "limited oral evaluation",
|
||||||
"fmx": "intraoral complete series",
|
// Prophylaxis / cleaning
|
||||||
"pano": "panoramic",
|
"adult cleaning": "prophylaxis adult",
|
||||||
"panorex": "panoramic",
|
"adult prophy": "prophylaxis adult",
|
||||||
"bitewing": "bitewing",
|
"adult prophylaxis": "prophylaxis adult",
|
||||||
"bw": "bitewing two",
|
"child cleaning": "prophylaxis child",
|
||||||
"comp exam": "comprehensive oral evaluation",
|
"child prophy": "prophylaxis child",
|
||||||
"comprehensive exam":"comprehensive oral evaluation",
|
"pedo cleaning": "prophylaxis child",
|
||||||
"new patient exam": "comprehensive oral evaluation",
|
"kid prophy": "prophylaxis child",
|
||||||
"limited exam": "limited oral evaluation",
|
// X-rays
|
||||||
"emergency exam": "limited oral evaluation",
|
"full mouth xray": "intraoral complete series",
|
||||||
"sealant": "sealant",
|
"fmx": "intraoral complete series",
|
||||||
"fluoride": "fluoride",
|
"pano": "panoramic",
|
||||||
"scaling root planing": "scaling root planing",
|
"panorex": "panoramic",
|
||||||
"srp": "scaling root planing",
|
"bitewing": "bitewing",
|
||||||
"perio maintenance": "periodontal maintenance",
|
"bw": "bitewing two",
|
||||||
"crown": "crown",
|
"2bw": "bitewing two",
|
||||||
"extraction": "extraction",
|
"4bw": "bitewing four",
|
||||||
"root canal": "root canal",
|
"periapical": "periapical first",
|
||||||
"filling": "resin",
|
// Fluoride / sealant
|
||||||
|
"sealant": "sealant",
|
||||||
|
"fluoride": "fluoride",
|
||||||
|
"fl": "fluoride",
|
||||||
|
"fl varnish": "fluoride varnish",
|
||||||
|
"fluoride treatment": "fluoride",
|
||||||
|
"fluoride application": "fluoride",
|
||||||
|
"topical fluoride": "fluoride",
|
||||||
|
// Perio
|
||||||
|
"scaling root planing": "scaling root planing",
|
||||||
|
"srp": "scaling root planing",
|
||||||
|
"srp 4": "scaling root planing four",
|
||||||
|
"srp 1 3": "scaling root planing one",
|
||||||
|
"perio maintenance": "periodontal maintenance",
|
||||||
|
"perio maintains": "periodontal maintenance",
|
||||||
|
"perio maint": "periodontal maintenance",
|
||||||
|
// Fillings / restorations
|
||||||
|
"filling": "resin",
|
||||||
|
"composite": "resin",
|
||||||
|
"amalgam": "amalgam",
|
||||||
|
"core buildup": "core buildup",
|
||||||
|
"core bu": "core buildup",
|
||||||
|
"buildup": "core buildup",
|
||||||
|
// Crowns
|
||||||
|
"crown": "crown",
|
||||||
|
"porcelain crown": "porcelain ceramic",
|
||||||
|
"pfm": "porcelain fused",
|
||||||
|
"pfc": "porcelain fused",
|
||||||
|
"high noble crown": "porcelain fused high noble",
|
||||||
|
"base metal crown": "porcelain fused base metal",
|
||||||
|
"re cement crown": "re-cement",
|
||||||
|
"recement crown": "re-cement",
|
||||||
|
"recement": "re-cement",
|
||||||
|
"post core": "post and core",
|
||||||
|
"post and core": "post and core",
|
||||||
|
// Root canal (endodontic)
|
||||||
|
"root canal": "endodontic therapy",
|
||||||
|
"rct": "endodontic therapy",
|
||||||
|
"endo": "endodontic therapy",
|
||||||
|
"anterior rct": "endodontic anterior",
|
||||||
|
"rct anterior": "endodontic anterior",
|
||||||
|
"premolar rct": "endodontic premolar",
|
||||||
|
"rct premolar": "endodontic premolar",
|
||||||
|
"bicuspid rct": "endodontic premolar",
|
||||||
|
"molar rct": "endodontic molar",
|
||||||
|
"rct molar": "endodontic molar",
|
||||||
|
// Extractions
|
||||||
|
"extraction": "extraction",
|
||||||
|
"ext": "extraction erupted",
|
||||||
|
"simple extraction": "extraction erupted",
|
||||||
|
"surgical extraction": "extraction bone",
|
||||||
|
"surgical ext": "extraction bone",
|
||||||
|
"baby tooth ext": "extraction primary",
|
||||||
|
"baby tooth extraction": "extraction primary",
|
||||||
|
"primary tooth ext": "extraction primary",
|
||||||
|
"soft tissue impaction": "impacted soft tissue",
|
||||||
|
"partial bony": "impacted partially bony",
|
||||||
|
"partial bony impaction":"impacted partially bony",
|
||||||
|
"complete bony": "impacted completely bony",
|
||||||
|
"complete bony impaction":"impacted completely bony",
|
||||||
|
"root tip": "surgical removal residual",
|
||||||
|
// Dentures
|
||||||
|
"full upper denture": "complete denture maxillary",
|
||||||
|
"complete upper denture":"complete denture maxillary",
|
||||||
|
"full lower denture": "complete denture mandibular",
|
||||||
|
"complete lower denture":"complete denture mandibular",
|
||||||
|
"partial upper denture": "partial denture maxillary",
|
||||||
|
"partial lower denture": "partial denture mandibular",
|
||||||
|
// Implant / abutment
|
||||||
|
"abutment": "abutment",
|
||||||
|
"implant crown": "implant crown ceramic",
|
||||||
|
// Suture
|
||||||
|
"suture": "suture small wound",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Direct phrase → CDT code for terms not in the MH fee schedule JSON
|
||||||
|
// or short abbreviations that need precise mapping (checked before keyword search)
|
||||||
|
const DIRECT_CODE_MAP: Record<string, string> = {
|
||||||
|
// X-rays
|
||||||
|
"2bw": "D0272",
|
||||||
|
"2 bw": "D0272",
|
||||||
|
"4bw": "D0274",
|
||||||
|
"4 bw": "D0274",
|
||||||
|
"pa": "D0220",
|
||||||
|
"first pa": "D0220",
|
||||||
|
"2nd pa": "D0230",
|
||||||
|
"additional pa": "D0230",
|
||||||
|
"cbct": "D0367",
|
||||||
|
"cone beam": "D0367",
|
||||||
|
// Fluoride
|
||||||
|
"fl": "D1208",
|
||||||
|
// Perio
|
||||||
|
"srp 4+": "D4341",
|
||||||
|
"srp 1-3": "D4342",
|
||||||
|
// Core / post
|
||||||
|
"core bu": "D2950",
|
||||||
|
"p/c": "D2954",
|
||||||
|
"post core": "D2954",
|
||||||
|
// Crowns
|
||||||
|
"recement": "D2920",
|
||||||
|
"re cement": "D2920",
|
||||||
|
"porcelain crown": "D2740",
|
||||||
|
"pfm": "D2750",
|
||||||
|
"high noble crown": "D2750",
|
||||||
|
"base metal crown": "D2751",
|
||||||
|
"noble crown": "D2752",
|
||||||
|
// Root canal
|
||||||
|
"rct anterior": "D3310",
|
||||||
|
"anterior rct": "D3310",
|
||||||
|
"rct premolar": "D3320",
|
||||||
|
"premolar rct": "D3320",
|
||||||
|
"bicuspid rct": "D3320",
|
||||||
|
"rct molar": "D3330",
|
||||||
|
"molar rct": "D3330",
|
||||||
|
"primary anterior rct": "D3230",
|
||||||
|
"rct primary anterior": "D3230",
|
||||||
|
"primary molar rct": "D3240",
|
||||||
|
"rct primary molar": "D3240",
|
||||||
|
"baby tooth rct": "D3230",
|
||||||
|
// Extractions
|
||||||
|
"baby tooth ext": "D7111",
|
||||||
|
"baby tooth extraction": "D7111",
|
||||||
|
"primary tooth ext": "D7111",
|
||||||
|
"surgical extraction": "D7210",
|
||||||
|
"surgical ext": "D7210",
|
||||||
|
"soft tissue impaction": "D7220",
|
||||||
|
"partial bony": "D7230",
|
||||||
|
"complete bony": "D7240",
|
||||||
|
"root tip": "D7250",
|
||||||
|
"suture": "D7910",
|
||||||
|
// Bone grafts / sinus (not in MH schedule)
|
||||||
|
"socket preservation": "D7953",
|
||||||
|
"bone graft socket": "D7953",
|
||||||
|
"socket graft": "D7953",
|
||||||
|
"sinus lift": "D7951",
|
||||||
|
"lateral sinus lift": "D7951",
|
||||||
|
"lateral sinus": "D7951",
|
||||||
|
"vertical sinus lift": "D7952",
|
||||||
|
"vertical sinus": "D7952",
|
||||||
|
"sinus augmentation": "D7951",
|
||||||
|
"bone graft": "D4263",
|
||||||
|
// Implant (not in MH schedule)
|
||||||
|
"implant": "D6010",
|
||||||
|
"implant body": "D6010",
|
||||||
|
"custom abutment": "D6057",
|
||||||
|
"abutment": "D6057",
|
||||||
|
"implant crown": "D6058",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Composite filling tooth classification
|
||||||
|
const FRONT_TEETH = new Set([6, 7, 8, 9, 10, 11, 22, 23, 24, 25, 26, 27]);
|
||||||
|
const BACK_TEETH = new Set([1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 28, 29, 30, 31, 32]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse composite filling notation like "#29 OB", "composite #8 MO", "tooth 11 MOD".
|
||||||
|
* Returns the correct D233x/D239x code based on tooth location and surface count.
|
||||||
|
*/
|
||||||
|
function parseCompositeCode(input: string): CdtMatch | null {
|
||||||
|
const s = input.trim();
|
||||||
|
// Match #NN SURFACES or tooth NN SURFACES (surfaces = 1-5 letters, valid dental surface chars)
|
||||||
|
const m = s.match(/#(\d{1,2})\s+([A-Za-z]{1,5})\b/) ?? s.match(/\btooth\s+(\d{1,2})\s+([A-Za-z]{1,5})\b/i);
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
const toothNum = parseInt(m[1]!, 10);
|
||||||
|
const surfaces = m[2]!;
|
||||||
|
|
||||||
|
if (toothNum < 1 || toothNum > 32) return null;
|
||||||
|
// Surfaces must only be dental surface letters (O M D B L F I V)
|
||||||
|
if (!/^[OMDBLFIV]+$/i.test(surfaces)) return null;
|
||||||
|
|
||||||
|
const surfaceCount = surfaces.length;
|
||||||
|
const isFront = FRONT_TEETH.has(toothNum);
|
||||||
|
|
||||||
|
if (!isFront && !BACK_TEETH.has(toothNum)) return null;
|
||||||
|
|
||||||
|
let code: string;
|
||||||
|
if (isFront) {
|
||||||
|
code = surfaceCount === 1 ? "D2330" : surfaceCount === 2 ? "D2331" : surfaceCount === 3 ? "D2332" : "D2335";
|
||||||
|
} else {
|
||||||
|
code = surfaceCount === 1 ? "D2391" : surfaceCount === 2 ? "D2392" : surfaceCount === 3 ? "D2393" : "D2394";
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = ALL_CODES.find((r) => r["Procedure Code"] === code);
|
||||||
|
return { code, description: row?.Description ?? code, input };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Score how well a set of query tokens matches a code's description tokens.
|
* Score how well a set of query tokens matches a code's description tokens.
|
||||||
* Each matched token contributes 1 point; shorter descriptions get a bonus
|
* Each matched token contributes 1 point; shorter descriptions get a bonus
|
||||||
@@ -148,14 +331,21 @@ export function lookupCdtCodes(
|
|||||||
if (customMap[cleaned]) {
|
if (customMap[cleaned]) {
|
||||||
const code = customMap[cleaned]!;
|
const code = customMap[cleaned]!;
|
||||||
const row = ALL_CODES.find((r) => r["Procedure Code"].toUpperCase() === code);
|
const row = ALL_CODES.find((r) => r["Procedure Code"].toUpperCase() === code);
|
||||||
return {
|
return { code, description: row?.Description ?? code, input: name };
|
||||||
code,
|
|
||||||
description: row?.Description ?? code,
|
|
||||||
input: name,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Hardcoded alias + keyword search
|
// 2. Composite filling by tooth# and surfaces (e.g. "#29 OB", "composite #8 MO")
|
||||||
|
const compositeMatch = parseCompositeCode(name);
|
||||||
|
if (compositeMatch) return compositeMatch;
|
||||||
|
|
||||||
|
// 3. Hardcoded direct code map (short abbreviations / codes not in MH schedule)
|
||||||
|
if (DIRECT_CODE_MAP[cleaned]) {
|
||||||
|
const code = DIRECT_CODE_MAP[cleaned]!;
|
||||||
|
const row = ALL_CODES.find((r) => r["Procedure Code"].toUpperCase() === code);
|
||||||
|
return { code, description: row?.Description ?? code, input: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Hardcoded alias + keyword search
|
||||||
const match = matchOne(name);
|
const match = matchOne(name);
|
||||||
if (match) return match;
|
if (match) return match;
|
||||||
|
|
||||||
|
|||||||
@@ -31,24 +31,27 @@ export interface ChatClassification {
|
|||||||
|
|
||||||
// ─── System prompt ────────────────────────────────────────────────────────────
|
// ─── 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
|
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.
|
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):
|
Respond ONLY with valid JSON (no markdown fences):
|
||||||
{
|
{
|
||||||
"intent": "<intent>",
|
"intent": "<intent>",
|
||||||
"patientName": "<full name if mentioned by name>",
|
"patientName": "<full name if mentioned by name>",
|
||||||
"memberId": "<member/insurance ID if given explicitly>",
|
"memberId": "<member/insurance ID if given explicitly or found in history>",
|
||||||
"dob": "<date of birth in MM/DD/YYYY if given>",
|
"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'>",
|
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
|
||||||
"procedureNames": ["<raw procedure name>", ...],
|
"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>",
|
"appointmentTime": "<HH:MM 24h if a specific time is mentioned, omit if not stated>",
|
||||||
"fallbackReply": "<1-2 sentence reply to show the user>"
|
"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:
|
Intents:
|
||||||
- check_eligibility : user wants to check insurance for a patient identified by NAME only
|
- 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"
|
e.g. "add Jane Doe at 10:30"
|
||||||
- claim_only : submit a claim for procedures WITHOUT an eligibility check
|
- claim_only : submit a claim for procedures WITHOUT an eligibility check
|
||||||
e.g. "claim comprehensive exam and Pano for her"
|
e.g. "claim comprehensive exam and Pano for her"
|
||||||
e.g. "claim D0120 and D1110 for John Smith"
|
e.g. "claim D0120 and D1110 for John Smith today"
|
||||||
e.g. "bill adult cleaning for Maria"
|
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
|
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_claims : open the claims page
|
||||||
- navigate_schedule : open the appointments/schedule page
|
- navigate_schedule : open the appointments/schedule page
|
||||||
- general : anything else
|
- general : anything else
|
||||||
@@ -76,15 +81,24 @@ Intents:
|
|||||||
Rules:
|
Rules:
|
||||||
- For check_and_claim and claim_only, procedureNames should be the RAW user text
|
- 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
|
(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
|
- insuranceHint is only set when the user explicitly names an insurance in the message
|
||||||
- Keep fallbackReply to 1-2 sentences
|
- Keep fallbackReply to 1-2 sentences
|
||||||
- For navigate intents, fallbackReply = "Opening the [page] page..."
|
- 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.
|
- 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
|
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.
|
the conversation history to find the patient name, memberId, AND dob that were mentioned most recently.
|
||||||
Always populate patientName (or memberId) from history when a pronoun is used.
|
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.`;
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Classifier ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -94,7 +108,8 @@ export async function classifyInternalChat(
|
|||||||
extraSystemPrompt?: string,
|
extraSystemPrompt?: string,
|
||||||
history: { role: "user" | "assistant"; text: string }[] = [],
|
history: { role: "user" | "assistant"; text: string }[] = [],
|
||||||
provider: AiProvider = "google",
|
provider: AiProvider = "google",
|
||||||
model?: string
|
model?: string,
|
||||||
|
clientDate?: string // YYYY-MM-DD from the browser's local clock
|
||||||
): Promise<ChatClassification> {
|
): Promise<ChatClassification> {
|
||||||
const fallback: ChatClassification = {
|
const fallback: ChatClassification = {
|
||||||
intent: "general",
|
intent: "general",
|
||||||
@@ -104,18 +119,18 @@ export async function classifyInternalChat(
|
|||||||
|
|
||||||
if (!apiKey) return fallback;
|
if (!apiKey) return fallback;
|
||||||
|
|
||||||
const systemPrompt = extraSystemPrompt
|
// Prefer the client's local date (avoids UTC midnight rollover for US timezones)
|
||||||
? `${BASE_SYSTEM_PROMPT}\n\nAdditional office context:\n${extraSystemPrompt}`
|
const today = (clientDate && /^\d{4}-\d{2}-\d{2}$/.test(clientDate))
|
||||||
: BASE_SYSTEM_PROMPT;
|
? 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 {
|
try {
|
||||||
const llm = getLlm(provider, apiKey, model);
|
const llm = getLlm(provider, apiKey, model);
|
||||||
|
|
||||||
// Gemini requires conversation to start with a user turn — drop any leading assistant messages
|
// Drop leading assistant messages (some providers require conversation to start with user turn)
|
||||||
const trimmedHistory = history.slice(
|
const firstUserIdx = history.findIndex((h) => h.role === "user");
|
||||||
history.findIndex((h) => h.role === "user")
|
const trimmedHistory = (firstUserIdx === -1 ? [] : history.slice(firstUserIdx)).filter((_, i, arr) => {
|
||||||
).filter((_, i, arr) => {
|
|
||||||
// Also drop consecutive same-role messages (keep last of each run)
|
|
||||||
if (i === arr.length - 1) return true;
|
if (i === arr.length - 1) return true;
|
||||||
return arr[i]!.role !== arr[i + 1]!.role;
|
return arr[i]!.role !== arr[i + 1]!.role;
|
||||||
});
|
});
|
||||||
@@ -131,11 +146,21 @@ export async function classifyInternalChat(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const raw = String(response.content).trim();
|
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 jsonStr = raw.replace(/^```json\s*/i, "").replace(/```\s*$/, "").trim();
|
||||||
const parsed = JSON.parse(jsonStr) as ChatClassification;
|
const parsed = JSON.parse(jsonStr) as ChatClassification;
|
||||||
if (!parsed.intent || !parsed.fallbackReply) return fallback;
|
if (!parsed.intent || !parsed.fallbackReply) return fallback;
|
||||||
return parsed;
|
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;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export interface ChatResponse {
|
|||||||
| "need_insurance_clarification"
|
| "need_insurance_clarification"
|
||||||
| "appointment_created"
|
| "appointment_created"
|
||||||
| "claim_only_ready"
|
| "claim_only_ready"
|
||||||
| "need_appointment_selection";
|
| "need_appointment_selection"
|
||||||
|
| "need_cdt_clarification";
|
||||||
actionData?: Record<string, any>;
|
actionData?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ interface StorageLike {
|
|||||||
offset: number;
|
offset: number;
|
||||||
}): Promise<any[] | null>;
|
}): Promise<any[] | null>;
|
||||||
getPatientByInsuranceId(id: string): Promise<any | null>;
|
getPatientByInsuranceId(id: string): Promise<any | null>;
|
||||||
|
getPatientByInsuranceIdAndDob(id: string, dob: Date): Promise<any | null>;
|
||||||
createAppointment(appointment: any): Promise<any>;
|
createAppointment(appointment: any): Promise<any>;
|
||||||
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
|
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
|
||||||
getOfficeHours(userId: number): Promise<any | null>;
|
getOfficeHours(userId: number): Promise<any | null>;
|
||||||
@@ -98,6 +100,22 @@ function patientToResult(p: any): ResolvedPatient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Look up by memberId, preferring the insuranceId+DOB combo when DOB is available. */
|
||||||
|
async function findPatientByMemberId(
|
||||||
|
memberId: string,
|
||||||
|
dob: string | null | undefined,
|
||||||
|
storage: StorageLike
|
||||||
|
): Promise<any | null> {
|
||||||
|
const dobDate = dob ? new Date(dob) : null;
|
||||||
|
if (dobDate && !isNaN(dobDate.getTime())) {
|
||||||
|
const byCombo = await storage.getPatientByInsuranceIdAndDob(memberId, dobDate);
|
||||||
|
if (byCombo) return byCombo;
|
||||||
|
// DOB provided but no record with that combo → this is a new family member not yet in DB
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return storage.getPatientByInsuranceId(memberId);
|
||||||
|
}
|
||||||
|
|
||||||
async function findPatientByName(
|
async function findPatientByName(
|
||||||
name: string,
|
name: string,
|
||||||
storage: StorageLike
|
storage: StorageLike
|
||||||
@@ -196,6 +214,28 @@ export async function runInternalChatWorkflow(
|
|||||||
actionData: { patient },
|
actionData: { patient },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If patient has DOB + known insurance, route through eligibility_id_ready so the
|
||||||
|
// correct provider button (DDMA, CCA, etc.) auto-triggers instead of always MH.
|
||||||
|
const resolvedDob = patient.dateOfBirth ?? null;
|
||||||
|
const siteKey = resolveSiteKey(
|
||||||
|
patient.insuranceProvider ?? null,
|
||||||
|
classification.insuranceHint ?? null
|
||||||
|
);
|
||||||
|
if (resolvedDob && siteKey) {
|
||||||
|
return {
|
||||||
|
reply: `Found ${fullName}. Ready to check eligibility.`,
|
||||||
|
action: "eligibility_id_ready",
|
||||||
|
actionData: {
|
||||||
|
patient,
|
||||||
|
memberId: patient.insuranceId,
|
||||||
|
dob: resolvedDob,
|
||||||
|
siteKey,
|
||||||
|
autoCheck: siteKeyToAutoCheck(siteKey),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reply: `Found ${fullName}. Ready to check eligibility.`,
|
reply: `Found ${fullName}. Ready to check eligibility.`,
|
||||||
action: "check_eligibility_prefill",
|
action: "check_eligibility_prefill",
|
||||||
@@ -246,8 +286,8 @@ async function handleEligibilityById(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to resolve existing patient for name display + insurance
|
// Try to resolve existing patient for name display + insurance (use DOB to pick the right family member)
|
||||||
const existingPatient = await storage.getPatientByInsuranceId(memberId);
|
const existingPatient = await findPatientByMemberId(memberId, dob, storage);
|
||||||
const patient: ResolvedPatient | null = existingPatient
|
const patient: ResolvedPatient | null = existingPatient
|
||||||
? patientToResult(existingPatient)
|
? patientToResult(existingPatient)
|
||||||
: null;
|
: null;
|
||||||
@@ -312,7 +352,7 @@ async function handleCheckAndClaim(
|
|||||||
const dob = c.dob?.trim() ?? null;
|
const dob = c.dob?.trim() ?? null;
|
||||||
|
|
||||||
if (memberId) {
|
if (memberId) {
|
||||||
const existing = await storage.getPatientByInsuranceId(memberId);
|
const existing = await findPatientByMemberId(memberId, dob, storage);
|
||||||
if (existing) patient = patientToResult(existing);
|
if (existing) patient = patientToResult(existing);
|
||||||
} else if (c.patientName?.trim()) {
|
} else if (c.patientName?.trim()) {
|
||||||
const raw = await findPatientByName(c.patientName.trim(), storage);
|
const raw = await findPatientByName(c.patientName.trim(), storage);
|
||||||
@@ -368,18 +408,24 @@ async function handleCheckAndClaim(
|
|||||||
const matched = cdtResults.filter((r) => r.code !== null);
|
const matched = cdtResults.filter((r) => r.code !== null);
|
||||||
const unmatched = cdtResults.filter((r) => r.code === null);
|
const unmatched = cdtResults.filter((r) => r.code === null);
|
||||||
|
|
||||||
|
// Block if any term couldn't be mapped — ask instead of proceeding
|
||||||
|
if (unmatched.length > 0) {
|
||||||
|
const phrases = unmatched.map((r) => r.input);
|
||||||
|
return {
|
||||||
|
reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`,
|
||||||
|
action: "need_cdt_clarification",
|
||||||
|
actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const label = patient
|
const label = patient
|
||||||
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
||||||
: `Member ID ${memberId}`;
|
: `Member ID ${memberId}`;
|
||||||
|
|
||||||
let reply = `Ready to check eligibility for ${label} and claim: ${
|
const reply = `Ready to check eligibility for ${label} and claim: ${
|
||||||
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped"
|
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped"
|
||||||
}.`;
|
}.`;
|
||||||
|
|
||||||
if (unmatched.length > 0) {
|
|
||||||
reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — please verify these codes manually.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reply,
|
reply,
|
||||||
action: "check_and_claim_ready",
|
action: "check_and_claim_ready",
|
||||||
@@ -402,11 +448,11 @@ async function handleClaimOnly(
|
|||||||
storage: StorageLike,
|
storage: StorageLike,
|
||||||
customAliases: { phrase: string; cdtCode: string }[]
|
customAliases: { phrase: string; cdtCode: string }[]
|
||||||
): Promise<ChatResponse> {
|
): Promise<ChatResponse> {
|
||||||
// Resolve patient
|
// Resolve patient — use insuranceId+DOB when DOB is available to distinguish family members
|
||||||
let patient: ResolvedPatient | null = null;
|
let patient: ResolvedPatient | null = null;
|
||||||
|
|
||||||
if (c.memberId?.trim()) {
|
if (c.memberId?.trim()) {
|
||||||
const existing = await storage.getPatientByInsuranceId(c.memberId.trim());
|
const existing = await findPatientByMemberId(c.memberId.trim(), c.dob, storage);
|
||||||
if (existing) patient = patientToResult(existing);
|
if (existing) patient = patientToResult(existing);
|
||||||
} else if (c.patientName?.trim()) {
|
} else if (c.patientName?.trim()) {
|
||||||
const raw = await findPatientByName(c.patientName.trim(), storage);
|
const raw = await findPatientByName(c.patientName.trim(), storage);
|
||||||
@@ -431,6 +477,16 @@ async function handleClaimOnly(
|
|||||||
const matched = cdtResults.filter((r) => r.code !== null);
|
const matched = cdtResults.filter((r) => r.code !== null);
|
||||||
const unmatched = cdtResults.filter((r) => r.code === null);
|
const unmatched = cdtResults.filter((r) => r.code === null);
|
||||||
|
|
||||||
|
// Block if any term couldn't be mapped — ask instead of proceeding
|
||||||
|
if (unmatched.length > 0) {
|
||||||
|
const phrases = unmatched.map((r) => r.input);
|
||||||
|
return {
|
||||||
|
reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`,
|
||||||
|
action: "need_cdt_clarification",
|
||||||
|
actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve service date: use explicit date from message, then latest appointment, then ask
|
// Resolve service date: use explicit date from message, then latest appointment, then ask
|
||||||
let serviceDate: string | null = c.appointmentDate ?? null;
|
let serviceDate: string | null = c.appointmentDate ?? null;
|
||||||
let appointmentId: number | null = null;
|
let appointmentId: number | null = null;
|
||||||
@@ -480,11 +536,9 @@ async function handleClaimOnly(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!serviceDate) {
|
if (!serviceDate) {
|
||||||
// No appointment on file — ask for the service date
|
// No appointment on file and no date in message — default to today
|
||||||
const codesPreview = matched.map((r) => `${r.code}`).join(", ") || "the procedures";
|
const now = new Date();
|
||||||
return {
|
serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
reply: `Found ${fullName} but no appointments on file. What was the service date for ${codesPreview}? (e.g. "6/2/2026")`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
|
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
|
||||||
|
|||||||
@@ -102,8 +102,28 @@ export async function createOrUpdatePatientByInsuranceId(options: {
|
|||||||
|
|
||||||
console.log(`[createOrUpdatePatient] insuranceId="${normalizedId}" firstName="${incomingFirst}" lastName="${incomingLast}" userId=${userId}`);
|
console.log(`[createOrUpdatePatient] insuranceId="${normalizedId}" firstName="${incomingFirst}" lastName="${incomingLast}" userId=${userId}`);
|
||||||
|
|
||||||
let patient = await storage.getPatientByInsuranceId(normalizedId);
|
// Family members often share the same insuranceId (e.g. Delta Dental family plans).
|
||||||
console.log(`[createOrUpdatePatient] existing patient lookup: ${patient ? `found id=${patient.id}` : "not found"}`);
|
// When a DOB is provided, look up by insuranceId+DOB so each family member is a
|
||||||
|
// distinct patient record. If the combo doesn't exist → new patient; never
|
||||||
|
// overwrite a different family member who happens to share the same insuranceId.
|
||||||
|
let dobDate: Date | null = null;
|
||||||
|
if (dob) {
|
||||||
|
const parsed = new Date(dob);
|
||||||
|
if (!isNaN(parsed.getTime())) dobDate = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let patient = null;
|
||||||
|
if (dobDate) {
|
||||||
|
// Primary lookup: exact insuranceId + DOB match
|
||||||
|
patient = await storage.getPatientByInsuranceIdAndDob(normalizedId, dobDate);
|
||||||
|
console.log(`[createOrUpdatePatient] id+DOB lookup: ${patient ? `found id=${patient.id}` : "not found"}`);
|
||||||
|
// Do NOT fall back to insuranceId-only lookup — another record with that ID
|
||||||
|
// is a different family member and must not be overwritten.
|
||||||
|
} else {
|
||||||
|
// No DOB supplied — fall back to insuranceId-only (legacy / non-family plans)
|
||||||
|
patient = await storage.getPatientByInsuranceId(normalizedId);
|
||||||
|
console.log(`[createOrUpdatePatient] id-only lookup: ${patient ? `found id=${patient.id}` : "not found"}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (patient && patient.id) {
|
if (patient && patient.id) {
|
||||||
const updates: any = {};
|
const updates: any = {};
|
||||||
@@ -111,14 +131,14 @@ export async function createOrUpdatePatientByInsuranceId(options: {
|
|||||||
updates.firstName = incomingFirst;
|
updates.firstName = incomingFirst;
|
||||||
if (incomingLast && String(patient.lastName ?? "").trim() !== incomingLast)
|
if (incomingLast && String(patient.lastName ?? "").trim() !== incomingLast)
|
||||||
updates.lastName = incomingLast;
|
updates.lastName = incomingLast;
|
||||||
if (dob && !patient.dateOfBirth) {
|
// Store DOB if not already set
|
||||||
const parsed = new Date(dob);
|
if (dobDate && !patient.dateOfBirth) updates.dateOfBirth = dobDate;
|
||||||
if (!isNaN(parsed.getTime())) updates.dateOfBirth = parsed;
|
|
||||||
}
|
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
console.log(`[createOrUpdatePatient] updating patient id=${patient.id} with`, updates);
|
console.log(`[createOrUpdatePatient] updating patient id=${patient.id} with`, updates);
|
||||||
await storage.updatePatient(patient.id, updates);
|
await storage.updatePatient(patient.id, updates);
|
||||||
patient = await storage.getPatientByInsuranceId(normalizedId);
|
patient = dobDate
|
||||||
|
? await storage.getPatientByInsuranceIdAndDob(normalizedId, dobDate)
|
||||||
|
: await storage.getPatientByInsuranceId(normalizedId);
|
||||||
}
|
}
|
||||||
return patient;
|
return patient;
|
||||||
}
|
}
|
||||||
@@ -126,7 +146,7 @@ export async function createOrUpdatePatientByInsuranceId(options: {
|
|||||||
const createPayload: any = {
|
const createPayload: any = {
|
||||||
firstName: incomingFirst,
|
firstName: incomingFirst,
|
||||||
lastName: incomingLast,
|
lastName: incomingLast,
|
||||||
dateOfBirth: dob,
|
dateOfBirth: dobDate ?? dob, // use parsed Date; fallback to raw string if dobDate is null
|
||||||
gender: "",
|
gender: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -251,13 +251,32 @@ router.put("/cdt-aliases", async (req: Request, res: Response): Promise<any> =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/ai/cdt-aliases/add — add/update a single alias without overwriting others
|
||||||
|
router.post("/cdt-aliases/add", async (req: Request, res: Response): Promise<any> => {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
const { phrase, cdtCode } = req.body;
|
||||||
|
if (typeof phrase !== "string" || typeof cdtCode !== "string") {
|
||||||
|
return res.status(400).json({ message: "Body must be { phrase, cdtCode }" });
|
||||||
|
}
|
||||||
|
const existing = await storage.getCdtAliases(userId);
|
||||||
|
const newEntry = { phrase: phrase.trim().toLowerCase(), cdtCode: cdtCode.trim().toUpperCase() };
|
||||||
|
const updated = [...existing.filter((a) => a.phrase !== newEntry.phrase), newEntry];
|
||||||
|
await storage.saveCdtAliases(userId, updated);
|
||||||
|
return res.status(200).json(newEntry);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: "Failed to add CDT alias", details: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/ai/internal-chat
|
// POST /api/ai/internal-chat
|
||||||
router.post("/internal-chat", async (req: Request, res: Response): Promise<any> => {
|
router.post("/internal-chat", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
|
||||||
const { message, history } = req.body;
|
const { message, history, clientDate } = req.body;
|
||||||
if (!message?.trim()) return res.status(400).json({ message: "message is required" });
|
if (!message?.trim()) return res.status(400).json({ message: "message is required" });
|
||||||
|
|
||||||
const aiSettings = await storage.getAiSettings(userId);
|
const aiSettings = await storage.getAiSettings(userId);
|
||||||
@@ -279,7 +298,8 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
|
|||||||
extraSystemPrompt || undefined,
|
extraSystemPrompt || undefined,
|
||||||
Array.isArray(history) ? history : [],
|
Array.isArray(history) ? history : [],
|
||||||
activeAi.provider,
|
activeAi.provider,
|
||||||
activeAi.model
|
activeAi.model,
|
||||||
|
typeof clientDate === "string" ? clientDate : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await runInternalChatWorkflow(classification, userId, storage, customAliases);
|
const response = await runInternalChatWorkflow(classification, userId, storage, customAliases);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -187,7 +187,8 @@ export function CCAEligibilityButton({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
|
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
|
||||||
|
if (autoTriggeredRef.current || isFormIncomplete) return;
|
||||||
autoTriggeredRef.current = true;
|
autoTriggeredRef.current = true;
|
||||||
onAutoTriggered?.();
|
onAutoTriggered?.();
|
||||||
handleStart();
|
handleStart();
|
||||||
|
|||||||
@@ -393,7 +393,8 @@ export function DdmaEligibilityButton({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
|
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
|
||||||
|
if (autoTriggeredRef.current || isFormIncomplete) return;
|
||||||
autoTriggeredRef.current = true;
|
autoTriggeredRef.current = true;
|
||||||
onAutoTriggered?.();
|
onAutoTriggered?.();
|
||||||
handleDdmaStart();
|
handleDdmaStart();
|
||||||
|
|||||||
@@ -327,7 +327,8 @@ export function DeltaInsEligibilityButton({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
|
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
|
||||||
|
if (autoTriggeredRef.current || isFormIncomplete) return;
|
||||||
autoTriggeredRef.current = true;
|
autoTriggeredRef.current = true;
|
||||||
onAutoTriggered?.();
|
onAutoTriggered?.();
|
||||||
handleStart();
|
handleStart();
|
||||||
|
|||||||
@@ -324,7 +324,8 @@ export function TuftsSCOEligibilityButton({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
|
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
|
||||||
|
if (autoTriggeredRef.current || isFormIncomplete) return;
|
||||||
autoTriggeredRef.current = true;
|
autoTriggeredRef.current = true;
|
||||||
onAutoTriggered?.();
|
onAutoTriggered?.();
|
||||||
handleStart();
|
handleStart();
|
||||||
|
|||||||
@@ -324,7 +324,8 @@ export function UnitedSCOEligibilityButton({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
|
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
|
||||||
|
if (autoTriggeredRef.current || isFormIncomplete) return;
|
||||||
autoTriggeredRef.current = true;
|
autoTriggeredRef.current = true;
|
||||||
onAutoTriggered?.();
|
onAutoTriggered?.();
|
||||||
handleStart();
|
handleStart();
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ type Step =
|
|||||||
| "eligibility-id-ready"
|
| "eligibility-id-ready"
|
||||||
| "check-and-claim-ready"
|
| "check-and-claim-ready"
|
||||||
| "need-insurance-clarification"
|
| "need-insurance-clarification"
|
||||||
| "need-appointment-selection";
|
| "need-appointment-selection"
|
||||||
|
| "need-cdt-clarification"
|
||||||
|
| "claim-ready";
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -126,6 +128,18 @@ export function ChatbotButton() {
|
|||||||
matchedCodes: { code: string; description: string }[];
|
matchedCodes: { code: string; description: string }[];
|
||||||
options: { label: string; appointmentId: number; serviceDate: string }[];
|
options: { label: string; appointmentId: number; serviceDate: string }[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [cdtClarificationData, setCdtClarificationData] = useState<{
|
||||||
|
unknownPhrases: string[];
|
||||||
|
codeInputs: Record<string, string>;
|
||||||
|
originalMessage: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [claimReadyData, setClaimReadyData] = useState<{
|
||||||
|
patient: PatientResult | null;
|
||||||
|
matchedCodes: { code: string; description: string }[];
|
||||||
|
siteKey: string;
|
||||||
|
serviceDate: string;
|
||||||
|
appointmentId: number | null;
|
||||||
|
} | null>(null);
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -175,6 +189,8 @@ export function ChatbotButton() {
|
|||||||
setCheckAndClaimData(null);
|
setCheckAndClaimData(null);
|
||||||
setClarificationData(null);
|
setClarificationData(null);
|
||||||
setApptSelectionData(null);
|
setApptSelectionData(null);
|
||||||
|
setCdtClarificationData(null);
|
||||||
|
setClaimReadyData(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Full reset including message history and stored session
|
// Full reset including message history and stored session
|
||||||
@@ -294,7 +310,9 @@ export function ChatbotButton() {
|
|||||||
.filter((m) => !m.isLoading)
|
.filter((m) => !m.isLoading)
|
||||||
.slice(-15)
|
.slice(-15)
|
||||||
.map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
|
.map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
|
||||||
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history });
|
const d = new Date();
|
||||||
|
const clientDate = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
||||||
|
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history, clientDate });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
|
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
|
||||||
@@ -366,22 +384,25 @@ export function ChatbotButton() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.action === "need_cdt_clarification" && data.actionData) {
|
||||||
|
const phrases: string[] = data.actionData.unknownPhrases ?? [];
|
||||||
|
const inputs: Record<string, string> = {};
|
||||||
|
for (const p of phrases) inputs[p] = "";
|
||||||
|
setCdtClarificationData({ unknownPhrases: phrases, codeInputs: inputs, originalMessage: text });
|
||||||
|
setStep("need-cdt-clarification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.action === "claim_only_ready" && data.actionData) {
|
if (data.action === "claim_only_ready" && data.actionData) {
|
||||||
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = data.actionData;
|
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = data.actionData;
|
||||||
if (patient?.id && matchedCodes?.length > 0) {
|
setClaimReadyData({
|
||||||
sessionStorage.setItem(
|
patient: patient ?? null,
|
||||||
"chatbot_claim_prefill",
|
matchedCodes: matchedCodes ?? [],
|
||||||
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true })
|
siteKey,
|
||||||
);
|
serviceDate,
|
||||||
}
|
appointmentId: appointmentId ?? null,
|
||||||
const url = appointmentId
|
});
|
||||||
? `/claims?appointmentId=${appointmentId}`
|
setStep("claim-ready");
|
||||||
: `/claims?newPatient=${patient?.id}`;
|
|
||||||
setTimeout(() => {
|
|
||||||
setLocation(url);
|
|
||||||
setOpen(false);
|
|
||||||
resetStep();
|
|
||||||
}, 600);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,6 +693,7 @@ export function ChatbotButton() {
|
|||||||
setStep("ai-loading");
|
setStep("ai-loading");
|
||||||
apiRequest("POST", "/api/ai/internal-chat", {
|
apiRequest("POST", "/api/ai/internal-chat", {
|
||||||
message: `check ${opt} for ${clarificationData.memberId}, ${clarificationData.dob}${clarificationData.procedureNames.length > 0 ? " and claim " + clarificationData.procedureNames.join(", ") : ""}`,
|
message: `check ${opt} for ${clarificationData.memberId}, ${clarificationData.dob}${clarificationData.procedureNames.length > 0 ? " and claim " + clarificationData.procedureNames.join(", ") : ""}`,
|
||||||
|
clientDate: (() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; })(),
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -753,6 +775,124 @@ export function ChatbotButton() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Claim ready — confirm before submitting */}
|
||||||
|
{step === "claim-ready" && claimReadyData && (() => {
|
||||||
|
const [sy, sm, sd] = (claimReadyData.serviceDate ?? "").split("-");
|
||||||
|
const dateLabel = sy ? `${sm}/${sd}/${sy}` : claimReadyData.serviceDate;
|
||||||
|
return (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-xl p-3 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-green-800">Confirm Claim</p>
|
||||||
|
{claimReadyData.patient && (
|
||||||
|
<p className="text-xs text-green-700 font-medium">
|
||||||
|
{claimReadyData.patient.firstName} {claimReadyData.patient.lastName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500">Service date: {dateLabel}</p>
|
||||||
|
{claimReadyData.matchedCodes.length > 0 && (
|
||||||
|
<div className="space-y-0.5 pt-0.5">
|
||||||
|
{claimReadyData.matchedCodes.map((c) => (
|
||||||
|
<p key={c.code} className="text-xs text-gray-700 pl-1">
|
||||||
|
<span className="font-medium">{c.code}</span> — {c.description}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
onClick={() => {
|
||||||
|
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = claimReadyData;
|
||||||
|
addMsg("user", "Confirm & submit claim");
|
||||||
|
addMsg("bot", "Opening claim...");
|
||||||
|
if (patient?.id && matchedCodes.length > 0) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"chatbot_claim_prefill",
|
||||||
|
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const url = appointmentId
|
||||||
|
? `/claims?appointmentId=${appointmentId}`
|
||||||
|
: `/claims?newPatient=${patient?.id}`;
|
||||||
|
setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText className="h-3 w-3 mr-1" />
|
||||||
|
Confirm & Submit
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* CDT clarification — unknown procedure terms */}
|
||||||
|
{step === "need-cdt-clarification" && cdtClarificationData && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-amber-800">Unknown procedure term{cdtClarificationData.unknownPhrases.length > 1 ? "s" : ""}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{cdtClarificationData.unknownPhrases.map((phrase) => (
|
||||||
|
<div key={phrase} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-700 font-medium shrink-0">"{phrase}"→</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="D0272"
|
||||||
|
value={cdtClarificationData.codeInputs[phrase] ?? ""}
|
||||||
|
onChange={(e) => setCdtClarificationData((prev) => prev ? {
|
||||||
|
...prev,
|
||||||
|
codeInputs: { ...prev.codeInputs, [phrase]: e.target.value.toUpperCase() },
|
||||||
|
} : prev)}
|
||||||
|
className="flex-1 rounded border border-amber-300 bg-white px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs bg-amber-600 hover:bg-amber-700 text-white"
|
||||||
|
disabled={Object.values(cdtClarificationData.codeInputs).some((v) => !v.trim())}
|
||||||
|
onClick={async () => {
|
||||||
|
const entries = Object.entries(cdtClarificationData.codeInputs);
|
||||||
|
for (const [phrase, code] of entries) {
|
||||||
|
await apiRequest("POST", "/api/ai/cdt-aliases/add", { phrase, cdtCode: code.trim() });
|
||||||
|
}
|
||||||
|
const savedPairs = entries.map(([p, c]) => `"${p}" = ${c.trim()}`).join(", ");
|
||||||
|
addMsg("user", entries.map(([p, c]) => `${p} = ${c.trim()}`).join(", "));
|
||||||
|
addMsg("bot", `Got it! Saved ${savedPairs}. Retrying...`, true);
|
||||||
|
setStep("ai-loading");
|
||||||
|
setCdtClarificationData(null);
|
||||||
|
const origMsg = cdtClarificationData.originalMessage;
|
||||||
|
try {
|
||||||
|
const history = messages.filter((m) => !m.isLoading).slice(-15).map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
|
||||||
|
const _d = new Date();
|
||||||
|
const _cd = `${_d.getFullYear()}-${String(_d.getMonth()+1).padStart(2,"0")}-${String(_d.getDate()).padStart(2,"0")}`;
|
||||||
|
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: origMsg, history, clientDate: _cd });
|
||||||
|
const data = await res.json();
|
||||||
|
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
|
||||||
|
if (data.action === "claim_only_ready" && data.actionData) {
|
||||||
|
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = data.actionData;
|
||||||
|
if (patient?.id && matchedCodes?.length > 0) {
|
||||||
|
sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true }));
|
||||||
|
}
|
||||||
|
const url = appointmentId ? `/claims?appointmentId=${appointmentId}` : `/claims?newPatient=${patient?.id}`;
|
||||||
|
setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600);
|
||||||
|
} else {
|
||||||
|
setStep("menu");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
replaceLastMsg("Sorry, something went wrong. Please try again.");
|
||||||
|
setStep("menu");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save & Retry
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -92,10 +92,11 @@ const TUFTSSCO_CODE_MAP: Map<string, CodeRow> = (() => {
|
|||||||
|
|
||||||
/** Return the correct fee-schedule map for the given insurance type. */
|
/** Return the correct fee-schedule map for the given insurance type. */
|
||||||
function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
|
function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
|
||||||
if (insuranceSiteKey === "CCA") return CCA_CODE_MAP;
|
const k = (insuranceSiteKey ?? "").replace(/_/g, "").toLowerCase();
|
||||||
if (insuranceSiteKey === "DDMA") return DDMA_CODE_MAP;
|
if (k === "cca") return CCA_CODE_MAP;
|
||||||
if (insuranceSiteKey === "UNITED_SCO" || insuranceSiteKey === "UnitedSCO" || insuranceSiteKey === "UNITEDDH") return UNITEDDH_CODE_MAP;
|
if (k === "ddma") return DDMA_CODE_MAP;
|
||||||
if (insuranceSiteKey === "TuftsSCO") return TUFTSSCO_CODE_MAP;
|
if (k === "unitedsco" || k === "uniteddh" || k === "dentalhub") return UNITEDDH_CODE_MAP;
|
||||||
|
if (k === "tuftssco" || k === "tufts") return TUFTSSCO_CODE_MAP;
|
||||||
return CODE_MAP; // default: MassHealth
|
return CODE_MAP; // default: MassHealth
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,43 +176,28 @@ export function pickPriceForRowByAge(
|
|||||||
): Decimal {
|
): Decimal {
|
||||||
// Special-case rules (add more codes here if needed)
|
// Special-case rules (add more codes here if needed)
|
||||||
if (normalizedCode) {
|
if (normalizedCode) {
|
||||||
// D1110: only valid for age >=14 (14..21 => PriceLTEQ21, >21 => PriceGT21)
|
// D1110: only valid for age >=14
|
||||||
if (normalizedCode === "D1110") {
|
if (normalizedCode === "D1110") {
|
||||||
if (age < 14) {
|
if (age < 14) return new Decimal(0); // D1110 not for children <14
|
||||||
// D1110 not applicable to children <14 (those belong to D1120)
|
// age >= 14: use age-split if present, then flat Price
|
||||||
return new Decimal(0);
|
if (age <= 21 && !isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
|
||||||
}
|
if (age > 21 && !isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
|
||||||
if (age >= 14 && age <= 21) {
|
if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price);
|
||||||
// use PriceLTEQ21 only if present
|
|
||||||
if (!isBlankPrice(row.PriceLTEQ21))
|
|
||||||
return toDecimalOrZero(row.PriceLTEQ21);
|
|
||||||
return new Decimal(0);
|
|
||||||
}
|
|
||||||
// age > 21
|
|
||||||
if (!isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
|
|
||||||
return new Decimal(0);
|
return new Decimal(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// D1120: child 0-13 => PriceLTEQ21, otherwise no price (NC)
|
// D1120: valid for child 0-13 only
|
||||||
if (normalizedCode === "D1120") {
|
if (normalizedCode === "D1120") {
|
||||||
if (age < 14) {
|
if (age >= 14) return new Decimal(0); // NC for adults
|
||||||
if (!isBlankPrice(row.PriceLTEQ21))
|
if (!isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
|
||||||
return toDecimalOrZero(row.PriceLTEQ21);
|
if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price);
|
||||||
return new Decimal(0);
|
|
||||||
}
|
|
||||||
// age >= 14 => NC / no price
|
|
||||||
return new Decimal(0);
|
return new Decimal(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic/default behavior (unchanged)
|
// Generic/default: age-split first, flat Price as fallback
|
||||||
if (age <= 21) {
|
if (age <= 21 && !isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
|
||||||
if (!isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
|
if (age > 21 && !isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
|
||||||
} else {
|
|
||||||
if (!isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to Price if tiered not available/blank
|
|
||||||
if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price);
|
if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price);
|
||||||
return new Decimal(0);
|
return new Decimal(0);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user