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
|
||||
const ALIAS_MAP: Record<string, string> = {
|
||||
"perio exam": "periodic oral evaluation",
|
||||
"periodic exam": "periodic oral evaluation",
|
||||
"recall exam": "periodic oral evaluation",
|
||||
"adult cleaning": "prophylaxis adult",
|
||||
"adult prophy": "prophylaxis adult",
|
||||
"adult prophylaxis": "prophylaxis adult",
|
||||
"child cleaning": "prophylaxis child",
|
||||
"child prophy": "prophylaxis child",
|
||||
"pedo cleaning": "prophylaxis child",
|
||||
"full mouth xray": "intraoral complete series",
|
||||
"fmx": "intraoral complete series",
|
||||
"pano": "panoramic",
|
||||
"panorex": "panoramic",
|
||||
"bitewing": "bitewing",
|
||||
"bw": "bitewing two",
|
||||
"comp exam": "comprehensive oral evaluation",
|
||||
"comprehensive exam":"comprehensive oral evaluation",
|
||||
"new patient exam": "comprehensive oral evaluation",
|
||||
"limited exam": "limited oral evaluation",
|
||||
"emergency exam": "limited oral evaluation",
|
||||
"sealant": "sealant",
|
||||
"fluoride": "fluoride",
|
||||
"scaling root planing": "scaling root planing",
|
||||
"srp": "scaling root planing",
|
||||
"perio maintenance": "periodontal maintenance",
|
||||
"crown": "crown",
|
||||
"extraction": "extraction",
|
||||
"root canal": "root canal",
|
||||
"filling": "resin",
|
||||
// Exams
|
||||
"perio exam": "periodic oral evaluation",
|
||||
"periodic exam": "periodic oral evaluation",
|
||||
"recall exam": "periodic oral evaluation",
|
||||
"comp exam": "comprehensive oral evaluation",
|
||||
"comprehensive exam": "comprehensive oral evaluation",
|
||||
"new patient exam": "comprehensive oral evaluation",
|
||||
"new patient": "comprehensive oral evaluation",
|
||||
"limited exam": "limited oral evaluation",
|
||||
"emergency exam": "limited oral evaluation",
|
||||
// Prophylaxis / cleaning
|
||||
"adult cleaning": "prophylaxis adult",
|
||||
"adult prophy": "prophylaxis adult",
|
||||
"adult prophylaxis": "prophylaxis adult",
|
||||
"child cleaning": "prophylaxis child",
|
||||
"child prophy": "prophylaxis child",
|
||||
"pedo cleaning": "prophylaxis child",
|
||||
"kid prophy": "prophylaxis child",
|
||||
// X-rays
|
||||
"full mouth xray": "intraoral complete series",
|
||||
"fmx": "intraoral complete series",
|
||||
"pano": "panoramic",
|
||||
"panorex": "panoramic",
|
||||
"bitewing": "bitewing",
|
||||
"bw": "bitewing two",
|
||||
"2bw": "bitewing two",
|
||||
"4bw": "bitewing four",
|
||||
"periapical": "periapical first",
|
||||
// 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.
|
||||
* Each matched token contributes 1 point; shorter descriptions get a bonus
|
||||
@@ -148,14 +331,21 @@ export function lookupCdtCodes(
|
||||
if (customMap[cleaned]) {
|
||||
const code = customMap[cleaned]!;
|
||||
const row = ALL_CODES.find((r) => r["Procedure Code"].toUpperCase() === code);
|
||||
return {
|
||||
code,
|
||||
description: row?.Description ?? code,
|
||||
input: name,
|
||||
};
|
||||
return { 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);
|
||||
if (match) return match;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ export interface ChatResponse {
|
||||
| "need_insurance_clarification"
|
||||
| "appointment_created"
|
||||
| "claim_only_ready"
|
||||
| "need_appointment_selection";
|
||||
| "need_appointment_selection"
|
||||
| "need_cdt_clarification";
|
||||
actionData?: Record<string, any>;
|
||||
}
|
||||
|
||||
@@ -75,6 +76,7 @@ interface StorageLike {
|
||||
offset: number;
|
||||
}): Promise<any[] | null>;
|
||||
getPatientByInsuranceId(id: string): Promise<any | null>;
|
||||
getPatientByInsuranceIdAndDob(id: string, dob: Date): Promise<any | null>;
|
||||
createAppointment(appointment: any): Promise<any>;
|
||||
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
|
||||
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(
|
||||
name: string,
|
||||
storage: StorageLike
|
||||
@@ -196,6 +214,28 @@ export async function runInternalChatWorkflow(
|
||||
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 {
|
||||
reply: `Found ${fullName}. Ready to check eligibility.`,
|
||||
action: "check_eligibility_prefill",
|
||||
@@ -246,8 +286,8 @@ async function handleEligibilityById(
|
||||
};
|
||||
}
|
||||
|
||||
// Try to resolve existing patient for name display + insurance
|
||||
const existingPatient = await storage.getPatientByInsuranceId(memberId);
|
||||
// Try to resolve existing patient for name display + insurance (use DOB to pick the right family member)
|
||||
const existingPatient = await findPatientByMemberId(memberId, dob, storage);
|
||||
const patient: ResolvedPatient | null = existingPatient
|
||||
? patientToResult(existingPatient)
|
||||
: null;
|
||||
@@ -312,7 +352,7 @@ async function handleCheckAndClaim(
|
||||
const dob = c.dob?.trim() ?? null;
|
||||
|
||||
if (memberId) {
|
||||
const existing = await storage.getPatientByInsuranceId(memberId);
|
||||
const existing = await findPatientByMemberId(memberId, dob, storage);
|
||||
if (existing) patient = patientToResult(existing);
|
||||
} else if (c.patientName?.trim()) {
|
||||
const raw = await findPatientByName(c.patientName.trim(), storage);
|
||||
@@ -368,18 +408,24 @@ async function handleCheckAndClaim(
|
||||
const matched = 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
|
||||
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
|
||||
: `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"
|
||||
}.`;
|
||||
|
||||
if (unmatched.length > 0) {
|
||||
reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — please verify these codes manually.`;
|
||||
}
|
||||
|
||||
return {
|
||||
reply,
|
||||
action: "check_and_claim_ready",
|
||||
@@ -402,11 +448,11 @@ async function handleClaimOnly(
|
||||
storage: StorageLike,
|
||||
customAliases: { phrase: string; cdtCode: string }[]
|
||||
): Promise<ChatResponse> {
|
||||
// Resolve patient
|
||||
// Resolve patient — use insuranceId+DOB when DOB is available to distinguish family members
|
||||
let patient: ResolvedPatient | null = null;
|
||||
|
||||
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);
|
||||
} else if (c.patientName?.trim()) {
|
||||
const raw = await findPatientByName(c.patientName.trim(), storage);
|
||||
@@ -431,6 +477,16 @@ async function handleClaimOnly(
|
||||
const matched = 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
|
||||
let serviceDate: string | null = c.appointmentDate ?? null;
|
||||
let appointmentId: number | null = null;
|
||||
@@ -480,11 +536,9 @@ async function handleClaimOnly(
|
||||
}
|
||||
|
||||
if (!serviceDate) {
|
||||
// No appointment on file — ask for the service date
|
||||
const codesPreview = matched.map((r) => `${r.code}`).join(", ") || "the procedures";
|
||||
return {
|
||||
reply: `Found ${fullName} but no appointments on file. What was the service date for ${codesPreview}? (e.g. "6/2/2026")`,
|
||||
};
|
||||
// No appointment on file and no date in message — default to today
|
||||
const now = new Date();
|
||||
serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
let patient = await storage.getPatientByInsuranceId(normalizedId);
|
||||
console.log(`[createOrUpdatePatient] existing patient lookup: ${patient ? `found id=${patient.id}` : "not found"}`);
|
||||
// Family members often share the same insuranceId (e.g. Delta Dental family plans).
|
||||
// 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) {
|
||||
const updates: any = {};
|
||||
@@ -111,14 +131,14 @@ export async function createOrUpdatePatientByInsuranceId(options: {
|
||||
updates.firstName = incomingFirst;
|
||||
if (incomingLast && String(patient.lastName ?? "").trim() !== incomingLast)
|
||||
updates.lastName = incomingLast;
|
||||
if (dob && !patient.dateOfBirth) {
|
||||
const parsed = new Date(dob);
|
||||
if (!isNaN(parsed.getTime())) updates.dateOfBirth = parsed;
|
||||
}
|
||||
// Store DOB if not already set
|
||||
if (dobDate && !patient.dateOfBirth) updates.dateOfBirth = dobDate;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
console.log(`[createOrUpdatePatient] updating patient id=${patient.id} with`, 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;
|
||||
}
|
||||
@@ -126,7 +146,7 @@ export async function createOrUpdatePatientByInsuranceId(options: {
|
||||
const createPayload: any = {
|
||||
firstName: incomingFirst,
|
||||
lastName: incomingLast,
|
||||
dateOfBirth: dob,
|
||||
dateOfBirth: dobDate ?? dob, // use parsed Date; fallback to raw string if dobDate is null
|
||||
gender: "",
|
||||
phone: "",
|
||||
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
|
||||
router.post("/internal-chat", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
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" });
|
||||
|
||||
const aiSettings = await storage.getAiSettings(userId);
|
||||
@@ -279,7 +298,8 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
|
||||
extraSystemPrompt || undefined,
|
||||
Array.isArray(history) ? history : [],
|
||||
activeAi.provider,
|
||||
activeAi.model
|
||||
activeAi.model,
|
||||
typeof clientDate === "string" ? clientDate : undefined
|
||||
);
|
||||
|
||||
const response = await runInternalChatWorkflow(classification, userId, storage, customAliases);
|
||||
|
||||
Reference in New Issue
Block a user