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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-06 21:11:58 -04:00
parent 4899ab8368
commit 86cf55aa4d
16 changed files with 1405 additions and 4913 deletions

View File

@@ -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;

View File

@@ -31,24 +31,27 @@ export interface ChatClassification {
// ─── System prompt ────────────────────────────────────────────────────────────
const BASE_SYSTEM_PROMPT = `You are an internal assistant for a dental office management app.
function buildSystemPrompt(today: string, extra?: string | null): string {
const base = `You are an internal assistant for a dental office management app.
Staff type natural language commands. Your ONLY job is to classify the intent and extract
structured parameters. Do NOT map procedure names to CDT codes — return them as plain text.
TODAY'S DATE: ${today}
Respond ONLY with valid JSON (no markdown fences):
{
"intent": "<intent>",
"patientName": "<full name if mentioned by name>",
"memberId": "<member/insurance ID if given explicitly>",
"dob": "<date of birth in MM/DD/YYYY if given>",
"memberId": "<member/insurance ID if given explicitly or found in history>",
"dob": "<date of birth in MM/DD/YYYY if given explicitly or found in history>",
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
"procedureNames": ["<raw procedure name>", ...],
"appointmentDate": "<YYYY-MM-DD if a specific date is mentioned, omit for today>",
"appointmentDate": "<YYYY-MM-DD; use today's date (${today}) if user says 'today'; omit only if no date is mentioned at all>",
"appointmentTime": "<HH:MM 24h if a specific time is mentioned, omit if not stated>",
"fallbackReply": "<1-2 sentence reply to show the user>"
}
Omit any field that is not present in the message.
Omit any field that is not present in the message or history.
Intents:
- check_eligibility : user wants to check insurance for a patient identified by NAME only
@@ -66,9 +69,11 @@ Intents:
e.g. "add Jane Doe at 10:30"
- claim_only : submit a claim for procedures WITHOUT an eligibility check
e.g. "claim comprehensive exam and Pano for her"
e.g. "claim D0120 and D1110 for John Smith"
e.g. "bill adult cleaning for Maria"
e.g. "claim D0120 and D1110 for John Smith today"
e.g. "bill adult cleaning for Maria on 05/15/2026"
e.g. "claim perio exam, 2BW for John Smith"
Use this when no eligibility check is requested — just billing/claiming services
Always extract appointmentDate when a date or "today" is mentioned
- navigate_claims : open the claims page
- navigate_schedule : open the appointments/schedule page
- general : anything else
@@ -76,15 +81,24 @@ Intents:
Rules:
- For check_and_claim and claim_only, procedureNames should be the RAW user text
(e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes
- For composite fillings with a tooth number, preserve the EXACT notation including tooth# and surfaces:
e.g. "composite #29 O", "#8 MO", "composite #11 MOD" — keep the #number and surface letters together as one entry
- insuranceHint is only set when the user explicitly names an insurance in the message
- Keep fallbackReply to 1-2 sentences
- For navigate intents, fallbackReply = "Opening the [page] page..."
- For schedule_appointment, appointmentDate omitted means today; appointmentTime omitted means no preference
- appointmentDate applies to BOTH schedule_appointment AND claim_only/check_and_claim:
always set it to today's date (${today}) when the user says "today", "this visit", or similar
set it to the specified date when the user mentions a date (e.g. "05/15/2026")
omit it only when no date is mentioned at all (the backend will find the last appointment)
- For schedule_appointment, appointmentTime omitted means no preference
- IMPORTANT: Use the conversation history to resolve pronouns and references.
If the user says "her", "him", "them", "the patient", or "same patient", look back through
the conversation history to find the patient name that was mentioned most recently.
Always populate patientName (or memberId) from history when a pronoun is used.
the conversation history to find the patient name, memberId, AND dob that were mentioned most recently.
Always populate patientName (or memberId) AND dob from history when a pronoun is used.
Family members may share the same memberId — always include the dob so the correct family member is identified.
Never return an empty patientName just because the current message uses a pronoun.`;
return extra?.trim() ? `${base}\n\nAdditional office context:\n${extra.trim()}` : base;
}
// ─── Classifier ───────────────────────────────────────────────────────────────
@@ -94,7 +108,8 @@ export async function classifyInternalChat(
extraSystemPrompt?: string,
history: { role: "user" | "assistant"; text: string }[] = [],
provider: AiProvider = "google",
model?: string
model?: string,
clientDate?: string // YYYY-MM-DD from the browser's local clock
): Promise<ChatClassification> {
const fallback: ChatClassification = {
intent: "general",
@@ -104,18 +119,18 @@ export async function classifyInternalChat(
if (!apiKey) return fallback;
const systemPrompt = extraSystemPrompt
? `${BASE_SYSTEM_PROMPT}\n\nAdditional office context:\n${extraSystemPrompt}`
: BASE_SYSTEM_PROMPT;
// Prefer the client's local date (avoids UTC midnight rollover for US timezones)
const today = (clientDate && /^\d{4}-\d{2}-\d{2}$/.test(clientDate))
? clientDate
: (() => { const n = new Date(); return `${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,"0")}-${String(n.getDate()).padStart(2,"0")}`; })();
const systemPrompt = buildSystemPrompt(today, extraSystemPrompt);
try {
const llm = getLlm(provider, apiKey, model);
// Gemini requires conversation to start with a user turn — drop any leading assistant messages
const trimmedHistory = history.slice(
history.findIndex((h) => h.role === "user")
).filter((_, i, arr) => {
// Also drop consecutive same-role messages (keep last of each run)
// Drop leading assistant messages (some providers require conversation to start with user turn)
const firstUserIdx = history.findIndex((h) => h.role === "user");
const trimmedHistory = (firstUserIdx === -1 ? [] : history.slice(firstUserIdx)).filter((_, i, arr) => {
if (i === arr.length - 1) return true;
return arr[i]!.role !== arr[i + 1]!.role;
});
@@ -131,11 +146,21 @@ export async function classifyInternalChat(
]);
const raw = String(response.content).trim();
console.log("[internal-chat] raw LLM response:", raw.slice(0, 400));
const jsonStr = raw.replace(/^```json\s*/i, "").replace(/```\s*$/, "").trim();
const parsed = JSON.parse(jsonStr) as ChatClassification;
if (!parsed.intent || !parsed.fallbackReply) return fallback;
return parsed;
} catch {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[internal-chat] classifyInternalChat error (provider=%s model=%s): %s", provider, model, msg);
// Surface billing/auth errors so the user sees a useful message in the chat
if (/credit balance|billing|quota|insufficient|authentication|api.key|invalid_api_key/i.test(msg)) {
return {
intent: "general",
fallbackReply: `AI provider error: ${msg.slice(0, 200)}`,
};
}
return fallback;
}
}

View File

@@ -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";

View File

@@ -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,

View File

@@ -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);

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

View File

@@ -187,7 +187,8 @@ export function CCAEligibilityButton({
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
if (autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();

View File

@@ -393,7 +393,8 @@ export function DdmaEligibilityButton({
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
if (autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleDdmaStart();

View File

@@ -327,7 +327,8 @@ export function DeltaInsEligibilityButton({
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
if (autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();

View File

@@ -324,7 +324,8 @@ export function TuftsSCOEligibilityButton({
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
if (autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();

View File

@@ -324,7 +324,8 @@ export function UnitedSCOEligibilityButton({
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete) return;
if (!autoTrigger) { autoTriggeredRef.current = false; return; }
if (autoTriggeredRef.current || isFormIncomplete) return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();

View File

@@ -26,7 +26,9 @@ type Step =
| "eligibility-id-ready"
| "check-and-claim-ready"
| "need-insurance-clarification"
| "need-appointment-selection";
| "need-appointment-selection"
| "need-cdt-clarification"
| "claim-ready";
interface Message {
id: number;
@@ -126,6 +128,18 @@ export function ChatbotButton() {
matchedCodes: { code: string; description: string }[];
options: { label: string; appointmentId: number; serviceDate: string }[];
} | 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 messagesEndRef = useRef<HTMLDivElement>(null);
const pasteRef = useRef<HTMLTextAreaElement>(null);
@@ -175,6 +189,8 @@ export function ChatbotButton() {
setCheckAndClaimData(null);
setClarificationData(null);
setApptSelectionData(null);
setCdtClarificationData(null);
setClaimReadyData(null);
};
// Full reset including message history and stored session
@@ -294,7 +310,9 @@ export function ChatbotButton() {
.filter((m) => !m.isLoading)
.slice(-15)
.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();
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
@@ -366,22 +384,25 @@ export function ChatbotButton() {
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) {
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);
setClaimReadyData({
patient: patient ?? null,
matchedCodes: matchedCodes ?? [],
siteKey,
serviceDate,
appointmentId: appointmentId ?? null,
});
setStep("claim-ready");
return;
}
@@ -672,6 +693,7 @@ export function ChatbotButton() {
setStep("ai-loading");
apiRequest("POST", "/api/ai/internal-chat", {
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((data) => {
@@ -753,6 +775,124 @@ export function ChatbotButton() {
</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 &amp; 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 &amp; Retry
</Button>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={resetStep}>Cancel</Button>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>

View File

@@ -92,10 +92,11 @@ const TUFTSSCO_CODE_MAP: Map<string, CodeRow> = (() => {
/** Return the correct fee-schedule map for the given insurance type. */
function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
if (insuranceSiteKey === "CCA") return CCA_CODE_MAP;
if (insuranceSiteKey === "DDMA") return DDMA_CODE_MAP;
if (insuranceSiteKey === "UNITED_SCO" || insuranceSiteKey === "UnitedSCO" || insuranceSiteKey === "UNITEDDH") return UNITEDDH_CODE_MAP;
if (insuranceSiteKey === "TuftsSCO") return TUFTSSCO_CODE_MAP;
const k = (insuranceSiteKey ?? "").replace(/_/g, "").toLowerCase();
if (k === "cca") return CCA_CODE_MAP;
if (k === "ddma") return DDMA_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
}
@@ -175,43 +176,28 @@ export function pickPriceForRowByAge(
): Decimal {
// Special-case rules (add more codes here if needed)
if (normalizedCode) {
// D1110: only valid for age >=14 (14..21 => PriceLTEQ21, >21 => PriceGT21)
// D1110: only valid for age >=14
if (normalizedCode === "D1110") {
if (age < 14) {
// D1110 not applicable to children <14 (those belong to D1120)
return new Decimal(0);
}
if (age >= 14 && age <= 21) {
// 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);
if (age < 14) return new Decimal(0); // D1110 not for children <14
// age >= 14: use age-split if present, then flat Price
if (age <= 21 && !isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
if (age > 21 && !isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price);
return new Decimal(0);
}
// D1120: child 0-13 => PriceLTEQ21, otherwise no price (NC)
// D1120: valid for child 0-13 only
if (normalizedCode === "D1120") {
if (age < 14) {
if (!isBlankPrice(row.PriceLTEQ21))
return toDecimalOrZero(row.PriceLTEQ21);
return new Decimal(0);
}
// age >= 14 => NC / no price
if (age >= 14) return new Decimal(0); // NC for adults
if (!isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price);
return new Decimal(0);
}
}
// Generic/default behavior (unchanged)
if (age <= 21) {
if (!isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
} else {
if (!isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
}
// Fallback to Price if tiered not available/blank
// Generic/default: age-split first, flat Price as fallback
if (age <= 21 && !isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21);
if (age > 21 && !isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21);
if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price);
return new Decimal(0);
}