import { getLlm, type AiProvider } from "./llm-factory"; // ─── Intent types ───────────────────────────────────────────────────────────── export type InternalChatIntent = | "check_eligibility" // by patient name → look up in DB | "eligibility_by_id" // by explicit memberId + dob (no name) | "check_and_claim" // eligibility + claim procedures | "find_patient" // look up patient record only | "schedule_appointment" // add patient to today's (or specified) schedule | "claim_only" // submit claim for procedures (no eligibility check) | "navigate_claims" | "navigate_schedule" | "general"; export interface ChatClassification { intent: InternalChatIntent; // --- patient resolution (one of name OR id+dob) --- patientName?: string; // for check_eligibility / find_patient / schedule_appointment memberId?: string; // for eligibility_by_id / check_and_claim dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY) // --- insurance hint (only if explicitly stated in the message) --- insuranceHint?: string; // raw text, e.g. "masshealth", "BCBS", "CCA" // --- procedures (raw text, NOT CDT codes — CDT lookup is done in workflow) --- procedureNames?: string[]; // for check_and_claim, e.g. ["perio exam", "adult cleaning"] // --- scheduling --- appointmentDate?: string; // for schedule_appointment, YYYY-MM-DD (omit = today) appointmentTime?: string; // for schedule_appointment, HH:MM 24h (omit = 09:00) fallbackReply: string; } // ─── System prompt ──────────────────────────────────────────────────────────── 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": "", "patientName": "", "memberId": "", "dob": "", "insuranceHint": "", "procedureNames": ["", ...], "appointmentDate": "", "appointmentTime": "", "fallbackReply": "<1-2 sentence reply to show the user>" } 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 e.g. "check Maria Jesus", "verify insurance for John Smith" - eligibility_by_id : user provides a member ID and date of birth (no patient name) e.g. "check masshealth for 100xxxx, 10/10/1988" - check_and_claim : user wants to check eligibility AND submit procedures as claims e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning" e.g. "check Maria Jesus and claim D0120 D1110" - find_patient : look up a patient record only, no eligibility e.g. "find patient John", "look up Smith" - schedule_appointment : add a patient to the schedule (today or a specified date/time) e.g. "put John Smith in today's schedule" e.g. "schedule Maria at 2pm tomorrow" 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 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 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..." - 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, 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 ─────────────────────────────────────────────────────────────── export async function classifyInternalChat( message: string, apiKey: string, extraSystemPrompt?: string, history: { role: "user" | "assistant"; text: string }[] = [], provider: AiProvider = "google", model?: string, clientDate?: string // YYYY-MM-DD from the browser's local clock ): Promise { const fallback: ChatClassification = { intent: "general", fallbackReply: "I can search for a patient, check eligibility, run check & claim, schedule appointments, or navigate to claims or appointments.", }; if (!apiKey) return fallback; // 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); // 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; }); const historyMessages = trimmedHistory.map((h) => ({ role: h.role, content: h.text, })); const response = await llm.invoke([ { role: "system", content: systemPrompt }, ...historyMessages, { role: "user", content: message }, ]); 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 (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; } }