diff --git a/apps/Backend/src/ai/cdt-lookup.ts b/apps/Backend/src/ai/cdt-lookup.ts new file mode 100644 index 00000000..9486e1ed --- /dev/null +++ b/apps/Backend/src/ai/cdt-lookup.ts @@ -0,0 +1,168 @@ +/** + * CDT code lookup — pure keyword search against the fee schedule JSON. + * No LLM needed: descriptions are already in plain English. + * + * Input: free-text procedure names from the chat ("perio exam", "adult cleaning", "D0120") + * Output: matched CDT code + description + */ +import path from "path"; +import { readFileSync } from "fs"; + +interface ProcedureCode { + "Procedure Code": string; + Description: string; +} + +interface CdtMatch { + code: string; + description: string; + input: string; +} + +// Load once at module init +const DATA_PATH = path.join(__dirname, "../data/procedureCodes.json"); +const ALL_CODES: ProcedureCode[] = JSON.parse(readFileSync(DATA_PATH, "utf8")); + +// Pre-build token sets for every code so scoring is O(1) per token +const CODE_TOKENS: { code: string; description: string; tokens: Set }[] = + ALL_CODES.map((row) => ({ + code: row["Procedure Code"], + description: row.Description, + tokens: new Set( + row.Description.toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter(Boolean) + ), + })); + +// Common aliases that don't appear verbatim in descriptions +const ALIAS_MAP: Record = { + "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", +}; + +/** + * 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 + * so "prophylaxis - adult" beats "prophylaxis - child, up to 14 years of age". + */ +function score(queryTokens: string[], entry: { tokens: Set; description: string }): number { + let hits = 0; + for (const t of queryTokens) { + if (entry.tokens.has(t)) hits++; + } + if (hits === 0) return 0; + // Tie-break: prefer shorter descriptions (more specific match) + return hits - entry.description.length * 0.0001; +} + +/** + * Map a single free-text procedure name to the best CDT code. + * Returns null if no reasonable match is found. + */ +function matchOne(input: string): CdtMatch | null { + const cleaned = input.trim().toLowerCase(); + + // Direct CDT code pass-through (e.g. "D0120") + if (/^d\d{4}$/i.test(cleaned)) { + const row = ALL_CODES.find( + (r) => r["Procedure Code"].toLowerCase() === cleaned.toLowerCase() + ); + return row + ? { code: row["Procedure Code"], description: row.Description, input } + : null; + } + + // Apply alias before tokenizing + const normalized = ALIAS_MAP[cleaned] ?? cleaned; + const queryTokens = normalized + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter(Boolean); + + if (queryTokens.length === 0) return null; + + let bestScore = 0; + let bestEntry: { code: string; description: string } | null = null; + + for (const entry of CODE_TOKENS) { + const s = score(queryTokens, entry); + if (s > bestScore) { + bestScore = s; + bestEntry = { code: entry.code, description: entry.description }; + } + } + + // Require at least one full token match + if (!bestEntry || bestScore < 0.9) return null; + return { code: bestEntry.code, description: bestEntry.description, input }; +} + +/** + * Map an array of free-text procedure names to CDT codes. + * customAliases (from DB) are checked before the hardcoded ALIAS_MAP. + * Unmatched names are returned with code = null and a note in description. + */ +export function lookupCdtCodes( + procedureNames: string[], + customAliases: { phrase: string; cdtCode: string }[] = [] +): (CdtMatch | { code: null; description: string; input: string })[] { + // Build a lookup map from custom aliases (phrase → cdtCode), lowercased + const customMap: Record = {}; + for (const { phrase, cdtCode } of customAliases) { + if (phrase && cdtCode) customMap[phrase.toLowerCase().trim()] = cdtCode.toUpperCase().trim(); + } + + return procedureNames.map((name) => { + const cleaned = name.trim().toLowerCase(); + + // 1. Custom alias exact match (highest priority) + 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, + }; + } + + // 2. Hardcoded alias + keyword search + const match = matchOne(name); + if (match) return match; + + return { + code: null, + description: `No CDT code found for "${name}"`, + input: name, + }; + }); +} diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index 82f6c903..e25b9867 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -1,39 +1,70 @@ import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; +// ─── Intent types ───────────────────────────────────────────────────────────── + export type InternalChatIntent = - | "check_eligibility" - | "find_patient" + | "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 | "navigate_claims" | "navigate_schedule" | "general"; export interface ChatClassification { intent: InternalChatIntent; - patientName?: string; + // --- patient resolution (one of name OR id+dob) --- + patientName?: string; // for check_eligibility / find_patient + 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"] fallbackReply: string; } -const BASE_SYSTEM_PROMPT = `You are an internal assistant for a dental office management system. -Staff members type natural language commands and you classify what they want. +// ─── System prompt ──────────────────────────────────────────────────────────── -Respond ONLY with valid JSON (no markdown, no code fences) in this exact format: +const BASE_SYSTEM_PROMPT = `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. + +Respond ONLY with valid JSON (no markdown fences): { - "intent": "", - "patientName": "", - "fallbackReply": "" + "intent": "", + "patientName": "", + "memberId": "", + "dob": "", + "insuranceHint": "", + "procedureNames": ["", ...], + "fallbackReply": "<1-2 sentence reply to show the user>" } +Omit any field that is not present in the message. + Intents: -- check_eligibility: user wants to check insurance eligibility for a patient (e.g. "check MARIA", "verify insurance for John Smith", "eligibility GONZALES") -- find_patient: user wants to look up a patient record only (e.g. "find patient John", "look up Smith", "show me GONZALES record") -- navigate_claims: user wants to open the claims page -- navigate_schedule: user wants to open the appointments/schedule page -- general: anything else — answer helpfully based on dental office context +- 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" +- navigate_claims : open the claims page +- navigate_schedule : open the appointments/schedule page +- general : anything else Rules: -- Extract the full patient name as-is from the message for check_eligibility and find_patient -- Keep fallbackReply to 1-2 sentences max -- For navigate intents, fallbackReply should say "Opening the [page] page..."`; +- For check_and_claim, procedureNames should be the RAW user text + (e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes +- 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..."`; + +// ─── Classifier ─────────────────────────────────────────────────────────────── export async function classifyInternalChat( message: string, @@ -42,7 +73,8 @@ export async function classifyInternalChat( ): Promise { const fallback: ChatClassification = { intent: "general", - fallbackReply: "I can help you search for a patient, check eligibility, or navigate to claims or appointments.", + fallbackReply: + "I can search for a patient, check eligibility, run check & claim, or navigate to claims or appointments.", }; if (!apiKey) return fallback; diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts new file mode 100644 index 00000000..9a351b1a --- /dev/null +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -0,0 +1,385 @@ +/** + * Internal chat workflow — deterministic orchestration after LLM classification. + * + * Steps (no LLM involved here): + * 1. Resolve patient from DB (by name or memberId) + * 2. Determine insurance siteKey from patient record OR message hint + * 3. Map procedure names → CDT codes via keyword lookup (check_and_claim only) + * 4. Return a typed ChatResponse the route handler sends to the frontend + */ +import { ChatClassification } from "./internal-chat-graph"; +import { lookupCdtCodes } from "./cdt-lookup"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ResolvedPatient { + id: number; + firstName: string | null; + lastName: string | null; + insuranceId: string | null; + insuranceProvider: string | null; + dateOfBirth: string | null; +} + +export interface CdtResult { + code: string | null; + description: string; + input: string; +} + +export interface ChatResponse { + reply: string; + action?: + | "navigate" + | "show_patient" + | "check_eligibility_prefill" + | "eligibility_id_ready" + | "check_and_claim_ready" + | "need_insurance_clarification"; + actionData?: Record; +} + +// ─── insuranceProvider → siteKey mapping (mirrors claims.ts batchColumnDeriveSiteKey) ─ + +export function deriveSiteKey(provider: string): string { + const p = (provider ?? "").toLowerCase().trim(); + if (!p || p.includes("masshealth") || p === "mh" || p === "mass health") return "MH"; + if (p.includes("commonwealth care alliance") || p === "cca") return "CCA"; + if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA"; + if (p.includes("delta dental ins") || p.includes("delta ins")) return "DELTA_INS"; + if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TUFTS_SCO"; + if ((p.includes("united") && p.includes("sco")) || p.includes("dentalhub") || p === "united_sco") return "UNITED_SCO"; + if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS_MA"; + return "MH"; +} + +// siteKey → autoCheck value used by the insurance-status page prefill +export function siteKeyToAutoCheck(siteKey: string): string { + switch (siteKey) { + case "CCA": return "cca"; + case "DDMA": return "ddma"; + case "DELTA_INS": return "delta-ins"; + case "TUFTS_SCO": return "tufts-sco"; + case "UNITED_SCO": return "united-sco"; + default: return "mh"; // MH (caller may downgrade to "cmsp" by age) + } +} + +// ─── Storage interface (duck-typed, matches the real storage object) ────────── + +interface StorageLike { + searchPatients(opts: { + filters: any; + limit: number; + offset: number; + }): Promise; + getPatientByInsuranceId(id: string): Promise; +} + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +function patientToResult(p: any): ResolvedPatient { + return { + id: p.id, + firstName: p.firstName ?? null, + lastName: p.lastName ?? null, + insuranceId: p.insuranceId ?? null, + insuranceProvider: p.insuranceProvider ?? null, + dateOfBirth: p.dateOfBirth + ? (p.dateOfBirth instanceof Date + ? p.dateOfBirth.toISOString().split("T")[0] + : String(p.dateOfBirth).split("T")[0]) + : null, + }; +} + +async function findPatientByName( + name: string, + storage: StorageLike +): Promise { + const patients = await storage.searchPatients({ + filters: { + OR: [ + { firstName: { contains: name, mode: "insensitive" } }, + { lastName: { contains: name, mode: "insensitive" } }, + { + AND: name.split(/\s+/).map((part: string) => ({ + OR: [ + { firstName: { contains: part, mode: "insensitive" } }, + { lastName: { contains: part, mode: "insensitive" } }, + ], + })), + }, + ], + }, + limit: 5, + offset: 0, + }); + return patients?.[0] ?? null; +} + +// ─── Workflow entry point ───────────────────────────────────────────────────── + +export async function runInternalChatWorkflow( + classification: ChatClassification, + _userId: number, + storage: StorageLike, + customAliases: { phrase: string; cdtCode: string }[] = [] +): Promise { + const { intent } = classification; + + // ── Navigation ────────────────────────────────────────────────────────────── + + if (intent === "navigate_claims") { + return { + reply: classification.fallbackReply, + action: "navigate", + actionData: { url: "/claims" }, + }; + } + if (intent === "navigate_schedule") { + return { + reply: classification.fallbackReply, + action: "navigate", + actionData: { url: "/appointments" }, + }; + } + + // ── Find patient (record only, no eligibility) ────────────────────────────── + + if (intent === "find_patient") { + const name = classification.patientName?.trim(); + if (!name) { + return { reply: "Please include the patient's name in your message." }; + } + const raw = await findPatientByName(name, storage); + if (!raw) { + return { + reply: `No patient found matching "${name}". Try a different spelling or search on the Patients page.`, + }; + } + const patient = patientToResult(raw); + const ins = patient.insuranceProvider ? ` · ${patient.insuranceProvider}` : ""; + const id = patient.insuranceId ? ` (ID: ${patient.insuranceId})` : ""; + return { + reply: `Found: ${patient.firstName ?? ""} ${patient.lastName ?? ""}${ins}${id}`.trim(), + action: "show_patient", + actionData: { patient }, + }; + } + + // ── Check eligibility by patient name ────────────────────────────────────── + + if (intent === "check_eligibility") { + const name = classification.patientName?.trim(); + if (!name) { + return { reply: "Please include the patient's name in your message." }; + } + const raw = await findPatientByName(name, storage); + if (!raw) { + return { + reply: `No patient found matching "${name}". Try a different spelling or search on the Patients page.`, + }; + } + const patient = patientToResult(raw); + const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); + + if (!patient.insuranceId) { + return { + reply: `Found ${fullName} but no Member ID is on file. Please add their insurance info first.`, + action: "show_patient", + actionData: { patient }, + }; + } + return { + reply: `Found ${fullName}. Ready to check eligibility.`, + action: "check_eligibility_prefill", + actionData: { patient }, + }; + } + + // ── Eligibility by explicit member ID + DOB ──────────────────────────────── + + if (intent === "eligibility_by_id") { + return await handleEligibilityById(classification, storage); + } + + // ── Check eligibility + claim procedures ────────────────────────────────── + + if (intent === "check_and_claim") { + return await handleCheckAndClaim(classification, storage, customAliases); + } + + // ── General ──────────────────────────────────────────────────────────────── + return { reply: classification.fallbackReply }; +} + +// ─── eligibility_by_id ──────────────────────────────────────────────────────── + +async function handleEligibilityById( + c: ChatClassification, + storage: StorageLike +): Promise { + const memberId = c.memberId?.trim(); + const dob = c.dob?.trim(); + + if (!memberId || !dob) { + return { + reply: "Please provide both a Member ID and Date of Birth (MM/DD/YYYY).", + }; + } + + // Try to resolve existing patient for name display + insurance + const existingPatient = await storage.getPatientByInsuranceId(memberId); + const patient: ResolvedPatient | null = existingPatient + ? patientToResult(existingPatient) + : null; + + // Determine siteKey + const siteKey = resolveSiteKey( + patient?.insuranceProvider ?? null, + c.insuranceHint ?? null + ); + + if (!siteKey) { + const name = patient + ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() + : `Member ID ${memberId}`; + return { + reply: `Found ${name} but couldn't determine the insurance type. Which insurance should I use?`, + action: "need_insurance_clarification", + actionData: { + memberId, + dob, + patient, + options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United/DentalHub"], + }, + }; + } + + const label = patient + ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() + : `Member ID ${memberId}`; + + return { + reply: `Ready to check eligibility for ${label}.`, + action: "eligibility_id_ready", + actionData: { + patient, + memberId, + dob, + siteKey, + autoCheck: siteKeyToAutoCheck(siteKey), + }, + }; +} + +// ─── check_and_claim ───────────────────────────────────────────────────────── + +async function handleCheckAndClaim( + c: ChatClassification, + storage: StorageLike, + customAliases: { phrase: string; cdtCode: string }[] = [] +): Promise { + // 1. Resolve patient + let patient: ResolvedPatient | null = null; + let memberId = c.memberId?.trim() ?? null; + const dob = c.dob?.trim() ?? null; + + if (memberId) { + const existing = await storage.getPatientByInsuranceId(memberId); + if (existing) patient = patientToResult(existing); + } else if (c.patientName?.trim()) { + const raw = await findPatientByName(c.patientName.trim(), storage); + if (raw) { + patient = patientToResult(raw); + memberId = patient.insuranceId; + } + } + + if (!memberId) { + return { + reply: "Please include either a Member ID or a patient name so I can look up their record.", + }; + } + if (!dob) { + return { + reply: `I have the Member ID (${memberId}) but need a Date of Birth (MM/DD/YYYY) to run the eligibility check.`, + }; + } + + // 2. Determine siteKey + const siteKey = resolveSiteKey( + patient?.insuranceProvider ?? null, + c.insuranceHint ?? null + ); + + if (!siteKey) { + const label = patient + ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() + : `Member ID ${memberId}`; + return { + reply: `Found ${label} but couldn't determine the insurance type. Which insurance should I use?`, + action: "need_insurance_clarification", + actionData: { + memberId, + dob, + patient, + procedureNames: c.procedureNames ?? [], + options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United/DentalHub"], + }, + }; + } + + // 3. Map procedure names → CDT codes (custom aliases take priority) + const procedureNames = c.procedureNames ?? []; + const cdtResults: CdtResult[] = procedureNames.length > 0 + ? lookupCdtCodes(procedureNames, customAliases) + : []; + + const matched = cdtResults.filter((r) => r.code !== null); + const unmatched = cdtResults.filter((r) => r.code === null); + + const label = patient + ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() + : `Member ID ${memberId}`; + + let 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", + actionData: { + patient, + memberId, + dob, + siteKey, + autoCheck: siteKeyToAutoCheck(siteKey), + cdtResults, + matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description })), + }, + }; +} + +// ─── Insurance resolution helper ────────────────────────────────────────────── + +/** + * Determine siteKey from: + * 1. Patient's stored insuranceProvider (most authoritative) + * 2. Insurance hint from the chat message + * 3. null → caller must ask for clarification + */ +function resolveSiteKey( + storedProvider: string | null, + hint: string | null +): string | null { + if (storedProvider) return deriveSiteKey(storedProvider); + if (hint) return deriveSiteKey(hint); + return null; +} diff --git a/apps/Backend/src/data/procedureCodes.json b/apps/Backend/src/data/procedureCodes.json new file mode 100755 index 00000000..ec281470 --- /dev/null +++ b/apps/Backend/src/data/procedureCodes.json @@ -0,0 +1,1191 @@ +[ + { + "Procedure Code": "D0120", + "Description": "Periodic oral evaluation - established patient", + "PriceLTEQ21": "31", + "PriceGT21": "24" + }, + { + "Procedure Code": "D0140", + "Description": "Limited oral evaluation - problem focused", + "PriceLTEQ21": "49", + "PriceGT21": "43" + }, + { + "Procedure Code": "D0145", + "Description": "Oral evaluation for a patient under three years of age and counseling with primary caregiver", + "PriceLTEQ21": "27", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D0150", + "Description": "Comprehensive oral evaluation - new or established patient", + "PriceLTEQ21": "62", + "PriceGT21": "41" + }, + { + "Procedure Code": "D0180", + "Description": "Comprehensive periodontal evaluation - new or established patient", + "PriceLTEQ21": "58", + "PriceGT21": "37" + }, + { + "Procedure Code": "D0190", + "Description": "Screening of a patient (PHDH only)", + "PriceLTEQ21": "29", + "PriceGT21": "20" + }, + { + "Procedure Code": "D0191", + "Description": "Assessment of a patient (PHDH only)", + "PriceLTEQ21": "29", + "PriceGT21": "20" + }, + { + "Procedure Code": "D0210", + "Description": "Intraoral - complete series of radiographic images", + "PriceLTEQ21": "94", + "PriceGT21": "76" + }, + { + "Procedure Code": "D0220", + "Description": "Intraoral - periapical, first radiographic image", + "PriceLTEQ21": "21", + "PriceGT21": "15" + }, + { + "Procedure Code": "D0230", + "Description": "Intraoral - periapical, each additional radiographic image", + "PriceLTEQ21": "17", + "PriceGT21": "13" + }, + { + "Procedure Code": "D0240", + "Description": "Intraoral - occlusal radiographic image", + "PriceLTEQ21": "26", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D0270", + "Description": "Bitewing - single radiographic image", + "PriceLTEQ21": "17", + "PriceGT21": "14" + }, + { + "Procedure Code": "D0272", + "Description": "Bitewings - two radiographic images", + "PriceLTEQ21": "32", + "PriceGT21": "25" + }, + { + "Procedure Code": "D0273", + "Description": "Bitewings - three radiographic images", + "PriceLTEQ21": "35", + "PriceGT21": "27" + }, + { + "Procedure Code": "D0274", + "Description": "Bitewings - four radiographic images", + "PriceLTEQ21": "46", + "PriceGT21": "36" + }, + { + "Procedure Code": "D0330", + "Description": "Panoramic radiographic image", + "PriceLTEQ21": "94", + "PriceGT21": "69" + }, + { + "Procedure Code": "D0340", + "Description": "Cephalometric radiograph image (Oral surgeon only)", + "PriceLTEQ21": "85", + "PriceGT21": "74" + }, + { + "Procedure Code": "D0364", + "Description": "Less than one jaw", + "Price": "350" + }, + { + "Procedure Code": "D0365", + "Description": "Mand", + "Price": "350" + }, + { + "Procedure Code": "D0366", + "Description": "Max", + "Price": "350" + }, + { + "Procedure Code": "D0367", + "Description": "", + "Price": "400" + }, + { + "Procedure Code": "D0368", + "Description": "include TMJ", + "Price": "375" + }, + { + "Procedure Code": "D0380", + "Description": "Less than one jaw", + "Price": "300" + }, + { + "Procedure Code": "D0381", + "Description": "Mand", + "Price": "300" + }, + { + "Procedure Code": "D0382", + "Description": "Max", + "Price": "300" + }, + { + "Procedure Code": "D0383", + "Description": "", + "Price": "350" + }, + { + "Procedure Code": "D1110", + "Description": "Prophylaxis – adult, 14 yo or older", + "PriceLTEQ21": "75", + "PriceGT21": "60" + }, + { + "Procedure Code": "D1120", + "Description": "Prophylaxis – child, 0-13 yo", + "PriceLTEQ21": "55", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D1206", + "Description": "Topical application of fluoride varnish", + "PriceLTEQ21": "28", + "PriceGT21": "26" + }, + { + "Procedure Code": "D1208", + "Description": "Topical application of fluoride – excluding varnish", + "PriceLTEQ21": "31", + "PriceGT21": "29" + }, + { + "Procedure Code": "D1351", + "Description": "Sealant – per tooth", + "PriceLTEQ21": "44", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D1354", + "Description": "Application of caries arresting medicament - per tooth", + "PriceLTEQ21": "15", + "PriceGT21": "15" + }, + { + "Procedure Code": "D1510", + "Description": "Space maintainer – fixed,unilateral – per quadrant", + "PriceLTEQ21": "229", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D1516", + "Description": "Space maintainer- fixed- bilateral, maxillary", + "PriceLTEQ21": "345", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D1517", + "Description": "Space maintainer- fixed- bilateral, mandibular", + "PriceLTEQ21": "345", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D1520", + "Description": "Space maintainer – removable- unilateral- per quadrant", + "PriceLTEQ21": "244", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D1526", + "Description": "Space maintainer- removable- bilateral, maxillary", + "PriceLTEQ21": "368", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D1527", + "Description": "Space maintainer- removable- bilateral, mandibular", + "PriceLTEQ21": "368", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D1575", + "Description": "Distal shoe space maintainer - fixed- unilateral- Per Quadrant I.C", + "PriceLTEQ21": "NC", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D1701", + "Description": "Pfizer-BioNTech Covid-19 vaccine administration – first dose SARSCOV2 COVID-19 VAC mRNA 30mcg/0.3mL IM DOSE 1", + "PriceLTEQ21": "45.87", + "PriceGT21": "45.87" + }, + { + "Procedure Code": "D1702", + "Description": "Pfizer-BioNTech Covid-19 vaccine administration – second dose SARSCOV2 COVID-19 VAC mRNA 30mcg/0.3mL IM DOSE 2", + "PriceLTEQ21": "45.87", + "PriceGT21": "45.87" + }, + { + "Procedure Code": "D1707", + "Description": "Janssen Covid-19 vaccine administration SARSCOV2 COVID-19 VAC Ad26 5x1010 VP/.5mL IM SINGLE DOSE", + "PriceLTEQ21": "45.87", + "PriceGT21": "45.87" + }, + { + "Procedure Code": "D1708", + "Description": "Pfizer-BioNTech Covid-19 vaccine administration – third dose", + "PriceLTEQ21": "45.87", + "PriceGT21": "45.87" + }, + { + "Procedure Code": "D1709", + "Description": "Pfizer-BioNTech Covid-19 vaccine administration – booster dose", + "PriceLTEQ21": "45.87", + "PriceGT21": "45.87" + }, + { + "Procedure Code": "D1712", + "Description": "Janssen Covid-19 vaccine administration - booster dose", + "PriceLTEQ21": "45.87", + "PriceGT21": "45.87" + }, + { + "Procedure Code": "D1713", + "Description": "Pfizer-BioNTech Covid-19 vaccine administration tris-sucrose pediatric – first dose", + "PriceLTEQ21": "45.87", + "PriceGT21": "45.87" + }, + { + "Procedure Code": "D1714", + "Description": "Pfizer-BioNTech Covid-19 vaccine administration tris-sucrose pediatric – second dose", + "PriceLTEQ21": "45.87", + "PriceGT21": "45.87" + }, + { + "Procedure Code": "D1999", + "Description": "", + "Price": "50" + }, + { + "Procedure Code": "D2140", + "Description": "Amalgam-one surface, primary or permanent", + "PriceLTEQ21": "77", + "PriceGT21": "62" + }, + { + "Procedure Code": "D2150", + "Description": "Amalgam-two surfaces, primary or permanent", + "PriceLTEQ21": "95", + "PriceGT21": "77" + }, + { + "Procedure Code": "D2955", + "Description": "post renoval", + "Price": "350" + }, + { + "Procedure Code": "D4910", + "Description": "perio maintains", + "Price": "250" + }, + { + "Procedure Code": "D5510", + "Description": "Repair broken complete denture base (QUAD)", + "Price": "400" + }, + { + "Procedure Code": "D6056", + "Description": "pre fab abut", + "Price": "750" + }, + { + "Procedure Code": "D6057", + "Description": "custom abut", + "Price": "800" + }, + { + "Procedure Code": "D6058", + "Description": "porcelain, implant crown, ceramic crown", + "Price": "1400" + }, + { + "Procedure Code": "D6059", + "Description": "", + "Price": "1400" + }, + { + "Procedure Code": "D6100", + "Description": "", + "Price": "320" + }, + { + "Procedure Code": "D6110", + "Description": "implant", + "Price": "1600" + }, + { + "Procedure Code": "D6242", + "Description": "noble metal. For united", + "Price": "1400" + }, + { + "Procedure Code": "D6245", + "Description": "porcelain, not for united", + "Price": "1400" + }, + { + "Procedure Code": "D7910", + "Description": "suture, small wound up to 5 mm", + "Price": "400" + }, + { + "Procedure Code": "D7950", + "Description": "max", + "Price": "800" + }, + { + "Procedure Code": "D2160", + "Description": "Amalgam-three surfaces, primary or permanent", + "PriceLTEQ21": "110", + "PriceGT21": "92" + }, + { + "Procedure Code": "D2161", + "Description": "Amalgam-four or more surfaces, primary or permanent", + "PriceLTEQ21": "137", + "PriceGT21": "116" + }, + { + "Procedure Code": "D2330", + "Description": "Resin-based composite – one surface, anterior", + "PriceLTEQ21": "98", + "PriceGT21": "72" + }, + { + "Procedure Code": "D2331", + "Description": "Resin-based composite – two surfaces, anterior", + "PriceLTEQ21": "118", + "PriceGT21": "92" + }, + { + "Procedure Code": "D2332", + "Description": "Resin-based composite – three surfaces, anterior", + "PriceLTEQ21": "147", + "PriceGT21": "116" + }, + { + "Procedure Code": "D2335", + "Description": "Resin-based composite – four or more surfaces or involving incisal angle (anterior)", + "PriceLTEQ21": "188", + "PriceGT21": "146" + }, + { + "Procedure Code": "D2390", + "Description": "Resin-based composite crown, anterior", + "PriceLTEQ21": "133", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D2391", + "Description": "Resin-based composite – one surface, posterior", + "PriceLTEQ21": "99", + "PriceGT21": "62" + }, + { + "Procedure Code": "D2392", + "Description": "Resin-based composite – two surfaces, posterior", + "PriceLTEQ21": "123", + "PriceGT21": "77" + }, + { + "Procedure Code": "D2393", + "Description": "Resin-based composite – three surfaces, posterior", + "PriceLTEQ21": "133", + "PriceGT21": "92" + }, + { + "Procedure Code": "D2394", + "Description": "Resin-based composite – four or more surfaces, posterior", + "PriceLTEQ21": "182", + "PriceGT21": "116" + }, + { + "Procedure Code": "D2710", + "Description": "Crown – resin-based composite (indirect)", + "PriceLTEQ21": "244", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D2740", + "Description": "Crown – porcelain/ceramic", + "PriceLTEQ21": "853", + "PriceGT21": "729" + }, + { + "Procedure Code": "D2750", + "Description": "Crown – porcelain fused to high noble metal", + "PriceLTEQ21": "800", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D2751", + "Description": "Crown – porcelain fused to predominantly base metal", + "PriceLTEQ21": "727", + "PriceGT21": "613" + }, + { + "Procedure Code": "D2752", + "Description": "Crown – porcelain fused to noble metal", + "PriceLTEQ21": "735", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D2790", + "Description": "Crown – full cast high noble metal", + "PriceLTEQ21": "808", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D2910", + "Description": "Re-cement or re-bond inlay, onlay or partial coverage restoration", + "PriceLTEQ21": "69", + "PriceGT21": "57" + }, + { + "Procedure Code": "D2920", + "Description": "Re-cement or re-bond crown", + "PriceLTEQ21": "68", + "PriceGT21": "57" + }, + { + "Procedure Code": "D2929", + "Description": "Prefabricated porcelain/ceramic crown – primary tooth", + "PriceLTEQ21": "224", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D2930", + "Description": "Prefabricated stainless steel crown – primary tooth", + "PriceLTEQ21": "205", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D2931", + "Description": "Prefabricated stainless steel crown – permanent tooth", + "PriceLTEQ21": "199", + "PriceGT21": "171" + }, + { + "Procedure Code": "D2932", + "Description": "Prefabricated resin crown", + "PriceLTEQ21": "224", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D2934", + "Description": "Prefabricated esthetic coated stainless steel crown – primary tooth", + "PriceLTEQ21": "184", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D2950", + "Description": "Core buildup, including any pins when required", + "PriceLTEQ21": "197", + "PriceGT21": "164" + }, + { + "Procedure Code": "D2951", + "Description": "Pin retention – per tooth, in addition to restoration", + "PriceLTEQ21": "31", + "PriceGT21": "27" + }, + { + "Procedure Code": "D2954", + "Description": "Prefabricated post and core in addition to crown", + "PriceLTEQ21": "229", + "PriceGT21": "191" + }, + { + "Procedure Code": "D2980", + "Description": "Crown repair necessitated by restorative material failure", + "PriceLTEQ21": "137", + "PriceGT21": "115" + }, + { + "Procedure Code": "D2999", + "Description": "Unspecified restorative procedure, by report", + "PriceLTEQ21": "IC", + "PriceGT21": "IC" + }, + { + "Procedure Code": "D3120", + "Description": "Pulp cap – indirect (excluding final restoration)", + "PriceLTEQ21": "40", + "PriceGT21": "34" + }, + { + "Procedure Code": "D3220", + "Description": "Therapeutic pulpotomy (excluding final restoration) – removal of pulp coronal to the dentinocemental junction and application of medicament", + "PriceLTEQ21": "106", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D3310", + "Description": "Endodontic therapy, anterior (excluding final restoration)", + "PriceLTEQ21": "544", + "PriceGT21": "544" + }, + { + "Procedure Code": "D3320", + "Description": "Endodontic therapy, premolar tooth (excluding final restoration)", + "PriceLTEQ21": "639", + "PriceGT21": "639" + }, + { + "Procedure Code": "D3330", + "Description": "Endodontic therapy, molar tooth (excluding final restoration)", + "PriceLTEQ21": "829", + "PriceGT21": "829" + }, + { + "Procedure Code": "D3346", + "Description": "Retreatment of previous root canal therapy – anterior", + "PriceLTEQ21": "545", + "PriceGT21": "456" + }, + { + "Procedure Code": "D3347", + "Description": "Retreatment of previous root canal therapy – premolar", + "PriceLTEQ21": "641", + "PriceGT21": "538" + }, + { + "Procedure Code": "D3348", + "Description": "Retreatment of previous root canal therapy – molar", + "PriceLTEQ21": "789", + "PriceGT21": "613" + }, + { + "Procedure Code": "D3410", + "Description": "Apicoectomy – anterior", + "PriceLTEQ21": "471", + "PriceGT21": "407" + }, + { + "Procedure Code": "D3421", + "Description": "Apicoectomy – premolar (first root)", + "PriceLTEQ21": "550", + "PriceGT21": "460" + }, + { + "Procedure Code": "D3425", + "Description": "Apicoectomy – molar (first root)", + "PriceLTEQ21": "639", + "PriceGT21": "598" + }, + { + "Procedure Code": "D3426", + "Description": "Apicoectomy (each additional root)", + "PriceLTEQ21": "264", + "PriceGT21": "230" + }, + { + "Procedure Code": "D4210", + "Description": "Gingivectomy or gingivoplasty - Four or more contiguous teeth or bounded teeth spaces per quadrant", + "PriceLTEQ21": "343", + "PriceGT21": "307" + }, + { + "Procedure Code": "D4211", + "Description": "Gingivectomy or gingivoplasty - one to three contiguous teeth or bounded teeth spaces per quadrant", + "PriceLTEQ21": "133", + "PriceGT21": "111" + }, + { + "Procedure Code": "D4341", + "Description": "Periodontal scaling and root planing - four or more teeth per quadrant", + "PriceLTEQ21": "160", + "PriceGT21": "134" + }, + { + "Procedure Code": "D4342", + "Description": "Periodontal scaling and root planing - one to three teeth, per quadrant", + "PriceLTEQ21": "107", + "PriceGT21": "90" + }, + { + "Procedure Code": "D4346", + "Description": "Scaling in presence of generalized moderate or severe gingival inflammation – full mouth, after oral evaluation", + "PriceLTEQ21": "75", + "PriceGT21": "60" + }, + { + "Procedure Code": "D5110", + "Description": "Complete denture – maxillary", + "PriceLTEQ21": "858", + "PriceGT21": "730" + }, + { + "Procedure Code": "D5120", + "Description": "Complete denture – mandibular", + "PriceLTEQ21": "852", + "PriceGT21": "730" + }, + { + "Procedure Code": "D5130", + "Description": "Immediate denture – maxillary", + "PriceLTEQ21": "935", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D5140", + "Description": "Immediate denture - mandibular", + "PriceLTEQ21": "934", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D5211", + "Description": "Maxillary partial denture - resin base (including retentive/clasping materials, rests and teeth)", + "PriceLTEQ21": "650", + "PriceGT21": "556" + }, + { + "Procedure Code": "D5212", + "Description": "Mandibular partial denture - resin base (including retentive/clasping materials, rests and teeth)", + "PriceLTEQ21": "691", + "PriceGT21": "595" + }, + { + "Procedure Code": "D5213", + "Description": "Maxillary partial denture- cast metal framework with resin denture bases (including retentive/clasping materials, rests and teeth)", + "PriceLTEQ21": "974", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D5214", + "Description": "Mandibular partial denture - cast metal framework with resin denture bases (including retentive/clasping materials, rests and teeth)", + "PriceLTEQ21": "986", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D5225", + "Description": "Maxillary partial denture- flexible base", + "PriceLTEQ21": "974", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D5226", + "Description": "Mandibular partial denture- flexible base", + "PriceLTEQ21": "986", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D5511", + "Description": "Repair broken complete denture base, mandibular", + "PriceLTEQ21": "109", + "PriceGT21": "85" + }, + { + "Procedure Code": "D5512", + "Description": "Repair broken complete denture base, maxillary", + "PriceLTEQ21": "109", + "PriceGT21": "85" + }, + { + "Procedure Code": "D5520", + "Description": "Replace missing or broken teeth - complete denture (each tooth)", + "PriceLTEQ21": "89", + "PriceGT21": "77" + }, + { + "Procedure Code": "D5611", + "Description": "Repair broken resin partial denture base, mandibular", + "PriceLTEQ21": "93", + "PriceGT21": "77" + }, + { + "Procedure Code": "D5612", + "Description": "Repair broken resin partial denture base, maxillary", + "PriceLTEQ21": "93", + "PriceGT21": "77" + }, + { + "Procedure Code": "D5621", + "Description": "Repair broken cast partial denture base, mandibular", + "PriceLTEQ21": "121", + "PriceGT21": "104" + }, + { + "Procedure Code": "D5622", + "Description": "Repair broken cast partial denture base, maxillary", + "PriceLTEQ21": "121", + "PriceGT21": "104" + }, + { + "Procedure Code": "D5630", + "Description": "Repair or replace broken retentive/clasping materials – per tooth", + "PriceLTEQ21": "107", + "PriceGT21": "99" + }, + { + "Procedure Code": "D5640", + "Description": "Replace broken teeth - per tooth", + "PriceLTEQ21": "91", + "PriceGT21": "77" + }, + { + "Procedure Code": "D5650", + "Description": "Add tooth to existing partial denture", + "PriceLTEQ21": "110", + "PriceGT21": "92" + }, + { + "Procedure Code": "D5660", + "Description": "Add clasp to existing partial denture per tooth", + "PriceLTEQ21": "125", + "PriceGT21": "98" + }, + { + "Procedure Code": "D5730", + "Description": "Reline complete maxillary denture (direct)", + "PriceLTEQ21": "188", + "PriceGT21": "158" + }, + { + "Procedure Code": "D5731", + "Description": "Reline lower complete mandibular denture (direct)", + "PriceLTEQ21": "184", + "PriceGT21": "173" + }, + { + "Procedure Code": "D5740", + "Description": "Reline maxillary partial denture(chairside)", + "PriceLTEQ21": "169", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D5741", + "Description": "Reline mandibular partial denture(chairside)", + "PriceLTEQ21": "160", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D5750", + "Description": "Reline complete maxillary denture (indirect)", + "PriceLTEQ21": "255", + "PriceGT21": "214" + }, + { + "Procedure Code": "D5751", + "Description": "Reline complete mandibular denture (indirect)", + "PriceLTEQ21": "256", + "PriceGT21": "215" + }, + { + "Procedure Code": "D5760", + "Description": "Reline maxillary partial denture (laboratory)", + "PriceLTEQ21": "252", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D5761", + "Description": "Reline mandibular partial denture (laboratory)", + "PriceLTEQ21": "252", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D6241", + "Description": "Pontic-porcelain fused metal", + "PriceLTEQ21": "691", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D6751", + "Description": "Retainer crown-porcelain fused to metal", + "PriceLTEQ21": "691", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D6930", + "Description": "Re-cement or re-bond fixed partial denture", + "PriceLTEQ21": "87", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D6980", + "Description": "Fixed partial denture repair", + "PriceLTEQ21": "155", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D6999", + "Description": "Fixed prosthodontic procedure", + "PriceLTEQ21": "IC", + "PriceGT21": "IC" + }, + { + "Procedure Code": "D7111", + "Description": "Extraction, coronal remnants - primary tooth", + "PriceLTEQ21": "80", + "PriceGT21": "75" + }, + { + "Procedure Code": "D7140", + "Description": "Extraction, erupted tooth or exposed root (elevation and/or forceps removal)", + "PriceLTEQ21": "107", + "PriceGT21": "77" + }, + { + "Procedure Code": "D7210", + "Description": "Extraction, erupted tooth requiring removal of bone and/or sectioning of tooth, and including elevation of mucoperiosteal flap if indicated", + "PriceLTEQ21": "179", + "PriceGT21": "149" + }, + { + "Procedure Code": "D7220", + "Description": "Removal of impacted tooth - soft tissue", + "PriceLTEQ21": "223", + "PriceGT21": "191" + }, + { + "Procedure Code": "D7230", + "Description": "Removal of impacted tooth - partially bony", + "PriceLTEQ21": "286", + "PriceGT21": "249" + }, + { + "Procedure Code": "D7240", + "Description": "Removal of impacted tooth - completely bony", + "PriceLTEQ21": "378", + "PriceGT21": "295" + }, + { + "Procedure Code": "D7250", + "Description": "Surgical removal of residual tooth roots (cutting procedure)", + "PriceLTEQ21": "173", + "PriceGT21": "144" + }, + { + "Procedure Code": "D7251", + "Description": "Coronectomy- intentional partial tooth removal, impacted teeth only", + "PriceLTEQ21": "173", + "PriceGT21": "134" + }, + { + "Procedure Code": "D7270", + "Description": "Tooth reimplantation and/or stabilization of accidentally evulsed or displaced tooth", + "PriceLTEQ21": "145", + "PriceGT21": "106" + }, + { + "Procedure Code": "D7280", + "Description": "Surgical access of an unerupted tooth", + "PriceLTEQ21": "452", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D7283", + "Description": "Placement of device to facilitate eruption of impacted tooth", + "PriceLTEQ21": "84", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D7310", + "Description": "Alveoloplasty in conjunction with extractions-four or more teeth or tooth spaces, per quadrant", + "PriceLTEQ21": "163", + "PriceGT21": "142" + }, + { + "Procedure Code": "D7311", + "Description": "Alveoloplasty in conjunction with extractions - one to three teeth or tooth spaces, per quadrant", + "PriceLTEQ21": "146", + "PriceGT21": "128" + }, + { + "Procedure Code": "D7320", + "Description": "Alveoloplasty not in conjunction with extractions- four or more teeth or tooth spaces, per quadrant", + "PriceLTEQ21": "202", + "PriceGT21": "187" + }, + { + "Procedure Code": "D7321", + "Description": "Alveoloplasty not in conjunction with extractions - one to three teeth or tooth spaces, per quadrant", + "PriceLTEQ21": "162", + "PriceGT21": "149" + }, + { + "Procedure Code": "D7340", + "Description": "Vestibuloplasty - ridge extension (second epithelialization)", + "PriceLTEQ21": "796", + "PriceGT21": "747" + }, + { + "Procedure Code": "D7350", + "Description": "Vestibuloplasty - ridge extension (Oral surgeon only)", + "PriceLTEQ21": "1236", + "PriceGT21": "943" + }, + { + "Procedure Code": "D7410", + "Description": "Radical excision - lesion diameter up to 1.25cm", + "PriceLTEQ21": "124", + "PriceGT21": "115" + }, + { + "Procedure Code": "D7411", + "Description": "Excision of benign lesion greater than 1.25 cm", + "PriceLTEQ21": "254", + "PriceGT21": "208" + }, + { + "Procedure Code": "D7450", + "Description": "Removal of benign odontogenic cyst or tumor - lesion diameter up to 1.25 cm", + "PriceLTEQ21": "252", + "PriceGT21": "248" + }, + { + "Procedure Code": "D7451", + "Description": "Removal of benign odontogenic cyst or tumor - lesion diameter greater than 1.25 cm", + "PriceLTEQ21": "343", + "PriceGT21": "288" + }, + { + "Procedure Code": "D7460", + "Description": "Removal of benign nonodontogenic cyst or tumor - lesion diameter up to 1.25 cm", + "PriceLTEQ21": "142", + "PriceGT21": "121" + }, + { + "Procedure Code": "D7461", + "Description": "Removal of benign nonodontogenic cyst or tumor - lesion diameter greater than 1.25 cm", + "PriceLTEQ21": "194", + "PriceGT21": "143" + }, + { + "Procedure Code": "D7471", + "Description": "Removal of lateral exostosis (maxilla or mandible) (Oral surgeon only)", + "PriceLTEQ21": "194", + "PriceGT21": "143" + }, + { + "Procedure Code": "D7472", + "Description": "Removal of torus palatinus (Oral surgeon only)", + "PriceLTEQ21": "194", + "PriceGT21": "143" + }, + { + "Procedure Code": "D7473", + "Description": "Removal of torus mandibularis (Oral surgeon only)", + "PriceLTEQ21": "194", + "PriceGT21": "143" + }, + { + "Procedure Code": "D7961", + "Description": "Buccal/labial frenectomy (frenulectomy)", + "PriceLTEQ21": "353", + "PriceGT21": "107" + }, + { + "Procedure Code": "D7962", + "Description": "Lingual frenectomy (frenulectomy)", + "PriceLTEQ21": "353", + "PriceGT21": "107" + }, + { + "Procedure Code": "D7963", + "Description": "Frenuloplasty", + "PriceLTEQ21": "480", + "PriceGT21": "416" + }, + { + "Procedure Code": "D7970", + "Description": "Excision of hyperplastic tissue - per arch", + "PriceLTEQ21": "334", + "PriceGT21": "246" + }, + { + "Procedure Code": "D7999", + "Description": "Unspecified oral surgery procedure, by report", + "PriceLTEQ21": "IC", + "PriceGT21": "IC" + }, + { + "Procedure Code": "D8010", + "Description": "Limited orthodontic treamtnent of the primary transition (Orthodontist only)", + "PriceLTEQ21": "250", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D8020", + "Description": "Limited orthodontic treatment of the transitional dentition (Orthodontist only)", + "PriceLTEQ21": "250", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D8030", + "Description": "Limited orthodontic treatment of the adolescent dentition (Orthodontist only)", + "PriceLTEQ21": "250", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D8040", + "Description": "Limited orthodontic treatment of the adult dentition (Orthodontist only)", + "PriceLTEQ21": "250", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D8070", + "Description": "Comprehensive orthodontic treatment of the transitional dentition (Orthodontist only)", + "PriceLTEQ21": "1302", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D8080", + "Description": "Comprehensive orthodontic treatment of the adolescent dentition (Orthodontist only)", + "PriceLTEQ21": "1302", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D8090", + "Description": "Comprehensive orthodontic treatment of the adult dentition (Orthodontist only)", + "PriceLTEQ21": "1302", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D8660", + "Description": "Pre-orthodontic treatment examination to monitor growth and development (records fee) (Orthodontist only)", + "PriceLTEQ21": "136", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D8670", + "Description": "Periodic orthodontic treatment visit (Orthodontist only)", + "PriceLTEQ21": "288", + "PriceGT21": "215" + }, + { + "Procedure Code": "D8680", + "Description": "Orthodontic retention (removal of appliances, construction and placement of retainer(s)) (Orthodontist only)", + "PriceLTEQ21": "102", + "PriceGT21": "85" + }, + { + "Procedure Code": "D8703", + "Description": "Replacement of lost or broken retainer- maxillary (Orthodontist only)", + "PriceLTEQ21": "95", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D8704", + "Description": "Replacement of lost or broken retainer- mandibular (Orthodontist only)", + "PriceLTEQ21": "95", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D8999", + "Description": "Unspecified orthodontic procedure, by report (Orthodontist only) I.C I.C** Y Y**", + "PriceLTEQ21": "NC", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D9110", + "Description": "Palliative treatment of dental pain – per visit", + "PriceLTEQ21": "75", + "PriceGT21": "36" + }, + { + "Procedure Code": "D9222", + "Description": "Deep sedation/general anesthesia – first 15 minutes", + "PriceLTEQ21": "109", + "PriceGT21": "90" + }, + { + "Procedure Code": "D9223", + "Description": "Deep sedation/general anesthesia – each additional 15- minute increment", + "PriceLTEQ21": "109", + "PriceGT21": "90" + }, + { + "Procedure Code": "D9230", + "Description": "Analgesia, anxiolysis, inhalation of nitrous oxide", + "PriceLTEQ21": "22", + "PriceGT21": "15" + }, + { + "Procedure Code": "D9248", + "Description": "Nonintravenous conscious sedation", + "PriceLTEQ21": "45", + "PriceGT21": "45" + }, + { + "Procedure Code": "D9310", + "Description": "Consultation- Diagnostic service provided by dentist or physician other than requesting dentist or physician (Specialist only)", + "PriceLTEQ21": "54", + "PriceGT21": "63" + }, + { + "Procedure Code": "D9410", + "Description": "House/extended care facility call, once per facility per day", + "PriceLTEQ21": "36", + "PriceGT21": "39" + }, + { + "Procedure Code": "D9450", + "Description": "Rural add-on encounter payment", + "PriceLTEQ21": "31", + "PriceGT21": "31" + }, + { + "Procedure Code": "D9920", + "Description": "Behavior management, by report", + "PriceLTEQ21": "86", + "PriceGT21": "86" + }, + { + "Procedure Code": "D9930", + "Description": "Treatment of complications (postsurgical) - unusual circumstances, by report", + "PriceLTEQ21": "66", + "PriceGT21": "30" + }, + { + "Procedure Code": "D9941", + "Description": "Fabrication of athletic mouthguard", + "PriceLTEQ21": "85", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D9944", + "Description": "Occlusal guard - hard appliance, full arch", + "PriceLTEQ21": "308", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D9945", + "Description": "Occlusal guard - soft appliance, full arch", + "PriceLTEQ21": "308", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D9946", + "Description": "Occlusal guard - hard appliance, partial arch", + "PriceLTEQ21": "308", + "PriceGT21": "NC" + }, + { + "Procedure Code": "D9999", + "Description": "Unspecified adjunctive procedure, by report", + "PriceLTEQ21": "IC", + "PriceGT21": "IC" + } +] diff --git a/apps/Backend/src/routes/ai-settings.ts b/apps/Backend/src/routes/ai-settings.ts index 8187fa67..30c1d042 100644 --- a/apps/Backend/src/routes/ai-settings.ts +++ b/apps/Backend/src/routes/ai-settings.ts @@ -1,6 +1,7 @@ import express, { Request, Response } from "express"; import { storage } from "../storage"; import { classifyInternalChat } from "../ai/internal-chat-graph"; +import { runInternalChatWorkflow } from "../ai/internal-chat-workflow"; const router = express.Router(); @@ -124,6 +125,40 @@ router.put("/internal-chat-settings", async (req: Request, res: Response): Promi } }); +// GET /api/ai/cdt-aliases +router.get("/cdt-aliases", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const aliases = await storage.getCdtAliases(userId); + return res.status(200).json(aliases); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch CDT aliases", details: String(err) }); + } +}); + +// PUT /api/ai/cdt-aliases +router.put("/cdt-aliases", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const aliases = req.body; + if (!Array.isArray(aliases)) { + return res.status(400).json({ message: "Body must be an array of { phrase, cdtCode }" }); + } + const cleaned = aliases + .filter((a: any) => typeof a?.phrase === "string" && typeof a?.cdtCode === "string") + .map((a: any) => ({ + phrase: a.phrase.trim().toLowerCase(), + cdtCode: a.cdtCode.trim().toUpperCase(), + })); + await storage.saveCdtAliases(userId, cleaned); + return res.status(200).json(cleaned); + } catch (err) { + return res.status(500).json({ error: "Failed to save CDT aliases", details: String(err) }); + } +}); + // POST /api/ai/internal-chat router.post("/internal-chat", async (req: Request, res: Response): Promise => { try { @@ -140,107 +175,19 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise }); } - const extraSystemPrompt = await storage.getInternalChatSystemPrompt(userId); - const classification = await classifyInternalChat(message.trim(), aiSettings.apiKey, extraSystemPrompt || undefined); + const [extraSystemPrompt, customAliases] = await Promise.all([ + storage.getInternalChatSystemPrompt(userId), + storage.getCdtAliases(userId), + ]); - // Handle navigation intents immediately - if (classification.intent === "navigate_claims") { - return res.status(200).json({ reply: classification.fallbackReply, action: "navigate", actionData: { url: "/claims" } }); - } - if (classification.intent === "navigate_schedule") { - return res.status(200).json({ reply: classification.fallbackReply, action: "navigate", actionData: { url: "/appointments" } }); - } + const classification = await classifyInternalChat( + message.trim(), + aiSettings.apiKey, + extraSystemPrompt || undefined + ); - // Handle patient intents — search DB - if (classification.intent === "check_eligibility" || classification.intent === "find_patient") { - const name = classification.patientName?.trim(); - if (!name) { - return res.status(200).json({ reply: "Please include the patient's name in your message." }); - } - - const patients = await storage.searchPatients({ - filters: { - OR: [ - { firstName: { contains: name, mode: "insensitive" } }, - { lastName: { contains: name, mode: "insensitive" } }, - { - AND: name.split(/\s+/).map((part: string) => ({ - OR: [ - { firstName: { contains: part, mode: "insensitive" } }, - { lastName: { contains: part, mode: "insensitive" } }, - ], - })), - }, - ], - }, - limit: 5, - offset: 0, - }); - - if (!patients || patients.length === 0) { - return res.status(200).json({ - reply: `No patient found matching "${name}". Try a different spelling or search on the Patients page.`, - }); - } - - const patient = patients[0]!; - const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); - - if (classification.intent === "find_patient") { - const ins = patient.insuranceProvider ? ` · ${patient.insuranceProvider}` : ""; - const id = patient.insuranceId ? ` (ID: ${patient.insuranceId})` : ""; - return res.status(200).json({ - reply: `Found: ${fullName}${ins}${id}`, - action: "show_patient", - actionData: { - patient: { - id: patient.id, - firstName: patient.firstName, - lastName: patient.lastName, - insuranceId: patient.insuranceId ?? null, - insuranceProvider: patient.insuranceProvider ?? null, - dateOfBirth: patient.dateOfBirth ? patient.dateOfBirth.toISOString().split("T")[0] : null, - }, - }, - }); - } - - // check_eligibility - if (!patient.insuranceId) { - return res.status(200).json({ - reply: `Found ${fullName} but no Member ID is on file. Please add their insurance info first.`, - action: "show_patient", - actionData: { - patient: { - id: patient.id, - firstName: patient.firstName, - lastName: patient.lastName, - insuranceId: null, - insuranceProvider: patient.insuranceProvider ?? null, - dateOfBirth: patient.dateOfBirth ? patient.dateOfBirth.toISOString().split("T")[0] : null, - }, - }, - }); - } - - return res.status(200).json({ - reply: `Found ${fullName}. Ready to check eligibility.`, - action: "check_eligibility_prefill", - actionData: { - patient: { - id: patient.id, - firstName: patient.firstName, - lastName: patient.lastName, - insuranceId: patient.insuranceId, - insuranceProvider: patient.insuranceProvider ?? null, - dateOfBirth: patient.dateOfBirth ? patient.dateOfBirth.toISOString().split("T")[0] : null, - }, - }, - }); - } - - // General intent — return Gemini's reply - return res.status(200).json({ reply: classification.fallbackReply }); + const response = await runInternalChatWorkflow(classification, userId, storage, customAliases); + return res.status(200).json(response); } catch (err) { return res.status(500).json({ error: "Internal chat error", details: String(err) }); } diff --git a/apps/Backend/src/storage/twilio-storage.ts b/apps/Backend/src/storage/twilio-storage.ts index 41391908..96683067 100644 --- a/apps/Backend/src/storage/twilio-storage.ts +++ b/apps/Backend/src/storage/twilio-storage.ts @@ -137,6 +137,27 @@ export const twilioStorage = { }); }, + async getCdtAliases(userId: number): Promise<{ phrase: string; cdtCode: string }[]> { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + const all = (settings?.templates as Record) || {}; + const raw = all["_cdt_aliases"]; + if (!Array.isArray(raw)) return []; + return raw.filter( + (r: any) => typeof r?.phrase === "string" && typeof r?.cdtCode === "string" + ); + }, + + async saveCdtAliases(userId: number, aliases: { phrase: string; cdtCode: string }[]): Promise { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + const existing = (settings?.templates as Record) || {}; + const updated = { ...existing, "_cdt_aliases": aliases }; + await db.twilioSettings.upsert({ + where: { userId }, + update: { templates: updated }, + create: { userId, accountSid: "", authToken: "", phoneNumber: "", templates: updated }, + }); + }, + async getRecentCommunicationsByUser(userId: number, limit = 20) { return db.communication.findMany({ where: { patient: { userId } }, diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index b1e3eab6..f854255c 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -21,7 +21,10 @@ type Step = | "eligibility-input" | "eligibility-confirm" | "ai-loading" - | "patient-found"; + | "patient-found" + | "eligibility-id-ready" + | "check-and-claim-ready" + | "need-insurance-clarification"; interface Message { id: number; @@ -45,6 +48,15 @@ interface EligibilityData { dobISO: string; } +interface CheckAndClaimData { + patient: PatientResult | null; + memberId: string; + dob: string; // ISO YYYY-MM-DD + siteKey: string; + autoCheck: string; + matchedCodes: { code: string; description: string }[]; +} + let msgCounter = 0; function makeMsg(role: "bot" | "user", text: string, isLoading = false): Message { return { id: ++msgCounter, role, text, isLoading }; @@ -97,6 +109,9 @@ export function ChatbotButton() { const [eligibilityData, setEligibilityData] = useState(null); const [freeTextInput, setFreeTextInput] = useState(""); const [patientResult, setPatientResult] = useState(null); + const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null } | null>(null); + const [checkAndClaimData, setCheckAndClaimData] = useState(null); + const [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null); const [, setLocation] = useLocation(); const messagesEndRef = useRef(null); const pasteRef = useRef(null); @@ -134,6 +149,9 @@ export function ChatbotButton() { setEligibilityData(null); setFreeTextInput(""); setPatientResult(null); + setEligibilityIdData(null); + setCheckAndClaimData(null); + setClarificationData(null); }; const handleClose = () => { @@ -199,6 +217,37 @@ export function ChatbotButton() { setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600); }; + const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => { + sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck })); + window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill")); + setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600); + }; + + const handleEligibilityIdRun = () => { + if (!eligibilityIdData) return; + addMsg("user", "Check eligibility now"); + addMsg("bot", "Opening the eligibility check page..."); + prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck); + }; + + const handleCheckAndClaimRun = () => { + if (!checkAndClaimData) return; + addMsg("user", "Run check & claim"); + addMsg("bot", "Opening the eligibility check page..."); + // Store claim codes so the eligibility page can offer auto-claim after ACTIVE result + sessionStorage.setItem( + "chatbot_claim_codes", + JSON.stringify({ + codes: checkAndClaimData.matchedCodes, + siteKey: checkAndClaimData.siteKey, + patientId: checkAndClaimData.patient?.id ?? null, + memberId: checkAndClaimData.memberId, + dob: checkAndClaimData.dob, + }) + ); + prefillAndNavigate(checkAndClaimData.memberId, checkAndClaimData.dob, checkAndClaimData.autoCheck); + }; + const handleFreeTextSubmit = async () => { const text = freeTextInput.trim(); if (!text || step === "ai-loading") return; @@ -227,6 +276,43 @@ export function ChatbotButton() { return; } + if (data.action === "eligibility_id_ready" && data.actionData) { + setEligibilityIdData({ + memberId: data.actionData.memberId, + dob: data.actionData.dob, + siteKey: data.actionData.siteKey, + autoCheck: data.actionData.autoCheck, + patient: data.actionData.patient ?? null, + }); + setStep("eligibility-id-ready"); + return; + } + + if (data.action === "check_and_claim_ready" && data.actionData) { + setCheckAndClaimData({ + patient: data.actionData.patient ?? null, + memberId: data.actionData.memberId, + dob: data.actionData.dob, + siteKey: data.actionData.siteKey, + autoCheck: data.actionData.autoCheck, + matchedCodes: data.actionData.matchedCodes ?? [], + }); + setStep("check-and-claim-ready"); + return; + } + + if (data.action === "need_insurance_clarification" && data.actionData) { + setClarificationData({ + memberId: data.actionData.memberId, + dob: data.actionData.dob, + patient: data.actionData.patient ?? null, + procedureNames: data.actionData.procedureNames ?? [], + options: data.actionData.options ?? [], + }); + setStep("need-insurance-clarification"); + return; + } + setStep("menu"); } catch { replaceLastMsg("Sorry, something went wrong. Please try again."); @@ -241,7 +327,13 @@ export function ChatbotButton() { } }; - const showFreeTextInput = step === "menu" || step === "ai-loading"; + const showFreeTextInput = + step === "menu" || + step === "ai-loading" || + step === "patient-found" || + step === "eligibility-id-ready" || + step === "check-and-claim-ready" || + step === "need-insurance-clarification"; return ( <> @@ -416,6 +508,129 @@ export function ChatbotButton() { )} + {/* Eligibility by ID ready */} + {step === "eligibility-id-ready" && eligibilityIdData && ( +
+ {eligibilityIdData.patient && ( +

+ {eligibilityIdData.patient.firstName} {eligibilityIdData.patient.lastName} +

+ )} +

ID: {eligibilityIdData.memberId}

+

DOB: {eligibilityIdData.dob}

+ {eligibilityIdData.patient?.insuranceProvider && ( +

{eligibilityIdData.patient.insuranceProvider}

+ )} +
+ + +
+
+ )} + + {/* Check & Claim ready */} + {step === "check-and-claim-ready" && checkAndClaimData && ( +
+ {checkAndClaimData.patient && ( +

+ {checkAndClaimData.patient.firstName} {checkAndClaimData.patient.lastName} +

+ )} +

ID: {checkAndClaimData.memberId} · DOB: {checkAndClaimData.dob}

+ {checkAndClaimData.matchedCodes.length > 0 && ( +
+

Claim after ACTIVE:

+ {checkAndClaimData.matchedCodes.map((c) => ( +

+ {c.code} — {c.description} +

+ ))} +
+ )} +
+ + +
+
+ )} + + {/* Need insurance clarification */} + {step === "need-insurance-clarification" && clarificationData && ( +
+

Which insurance?

+

ID: {clarificationData.memberId}

+
+ {clarificationData.options.map((opt) => ( + + ))} +
+ +
+ )} +
diff --git a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx index f89c97c4..6505456a 100644 --- a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx +++ b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx @@ -2,11 +2,12 @@ import { useState, useEffect, useRef } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient } from "@/lib/queryClient"; -import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal } from "lucide-react"; +import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp } from "lucide-react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -673,23 +674,86 @@ function InternalChatSettingsCard() { to navigate directly to a page.

- {/* Capability summary */} -
- {[ - { icon: "🔍", label: "Patient search", desc: 'e.g. "find GONZALES"' }, - { icon: "🏥", label: "Eligibility prefill", desc: 'e.g. "check MARIA DE LA CRUZ"' }, - { icon: "🗺️", label: "Navigation", desc: 'e.g. "open claims", "schedule"' }, - ].map((c) => ( -
- {c.icon} -
-

{c.label}

-

{c.desc}

+ {/* Built-in workflows */} +
+

Built-in Workflows

+
+ + {/* Eligibility by name */} +
+ 🏥 +
+

Eligibility by patient name

+

+ Looks up the patient in the database, resolves their insurance, and opens the eligibility page pre-filled. +

+
+ {["check Maria Jesus", "verify insurance for John Smith"].map((ex) => ( + {ex} + ))} +
- ))} + + {/* Eligibility by member ID */} +
+ 🔢 +
+

Eligibility by Member ID + DOB

+

+ Provide a member ID and date of birth. Insurance is resolved from the patient record, or from what you state in the message. +

+
+ {["check masshealth for 100xxxx, 10/10/1988"].map((ex) => ( + {ex} + ))} +
+
+
+ + {/* Check & Claim */} +
+ +
+

Check eligibility + claim procedures

+

+ Resolves the patient, maps procedure names to CDT codes using the fee schedule, and opens the eligibility page ready to check and claim. Procedure names or CDT codes both work. +

+
+ {[ + "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning", + "check Maria Jesus and claim D0120 D1110", + ].map((ex) => ( + {ex} + ))} +
+

+ CDT codes are looked up from the fee schedule — no AI translation needed. + If insurance is unknown the assistant will ask which one to use. +

+
+
+ + {/* Navigation */} +
+ 🗺️ +
+

Navigation

+

Opens any page in the app.

+
+ {["open claims", "go to schedule", "find patient GONZALES"].map((ex) => ( + {ex} + ))} +
+
+
+ +
+ {/* CDT Aliases */} + + {/* Additional context / system prompt */}
@@ -736,6 +800,207 @@ function InternalChatSettingsCard() { ); } +// ─── CDT Aliases card ──────────────────────────────────────────────────────── + +const BUILTIN_ALIASES = [ + { phrase: "perio exam", cdtCode: "D0120" }, + { phrase: "periodic exam", cdtCode: "D0120" }, + { phrase: "adult cleaning", cdtCode: "D1110" }, + { phrase: "adult prophy", cdtCode: "D1110" }, + { phrase: "child cleaning", cdtCode: "D1120" }, + { phrase: "full mouth xray", cdtCode: "D0210" }, + { phrase: "fmx", cdtCode: "D0210" }, + { phrase: "pano", cdtCode: "D0330" }, + { phrase: "comp exam", cdtCode: "D0150" }, + { phrase: "limited exam", cdtCode: "D0140" }, + { phrase: "scaling root planing", cdtCode: "D4341" }, + { phrase: "srp", cdtCode: "D4341" }, + { phrase: "perio maintenance", cdtCode: "D4910" }, +]; + +type CdtAlias = { phrase: string; cdtCode: string }; + +function CdtAliasesCard() { + const { toast } = useToast(); + const [aliases, setAliases] = useState([]); + const [newPhrase, setNewPhrase] = useState(""); + const [newCode, setNewCode] = useState(""); + const [showBuiltin, setShowBuiltin] = useState(false); + const initialized = useRef(false); + + const { data, isLoading } = useQuery({ + queryKey: ["/api/ai/cdt-aliases"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/ai/cdt-aliases"); + if (!res.ok) return []; + return res.json(); + }, + staleTime: Infinity, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (data && !initialized.current) { + initialized.current = true; + setAliases(data); + } + }, [data]); + + const saveMutation = useMutation({ + mutationFn: async (list: CdtAlias[]) => { + const res = await apiRequest("PUT", "/api/ai/cdt-aliases", list); + if (!res.ok) throw new Error("Failed to save"); + return res.json(); + }, + onSuccess: (saved: CdtAlias[]) => { + setAliases(saved); + queryClient.invalidateQueries({ queryKey: ["/api/ai/cdt-aliases"] }); + toast({ title: "CDT aliases saved" }); + }, + onError: () => { + toast({ title: "Error", description: "Failed to save aliases.", variant: "destructive" }); + }, + }); + + const handleAdd = () => { + const phrase = newPhrase.trim().toLowerCase(); + const code = newCode.trim().toUpperCase(); + if (!phrase || !code) return; + if (aliases.some((a) => a.phrase === phrase)) { + toast({ title: "Duplicate", description: `"${phrase}" already exists.`, variant: "destructive" }); + return; + } + const updated = [...aliases, { phrase, cdtCode: code }]; + setAliases(updated); + saveMutation.mutate(updated); + setNewPhrase(""); + setNewCode(""); + }; + + const handleDelete = (idx: number) => { + const updated = aliases.filter((_, i) => i !== idx); + setAliases(updated); + saveMutation.mutate(updated); + }; + + return ( + + +
+ +

CDT Aliases

+
+ +

+ Map your own shorthand phrases to CDT codes. These override the built-in aliases when + staff type procedure names in the chat (e.g.{" "} + "check & claim perio exam"). + Phrases are matched case-insensitively. +

+ + {/* Custom alias list */} + {isLoading ? ( +

Loading...

+ ) : aliases.length === 0 ? ( +

No custom aliases yet — add one below.

+ ) : ( +
+ + + + + + + + + {aliases.map((a, i) => ( + + + + + + ))} + +
Phrase (what staff type)CDT Code +
+ {a.phrase} + {a.cdtCode} + +
+
+ )} + + {/* Add row */} +
+
+

Phrase

+ setNewPhrase(e.target.value)} + placeholder='e.g. "cleaning adult"' + className="h-8 text-xs" + onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }} + /> +
+
+

CDT Code

+ setNewCode(e.target.value)} + placeholder="D1110" + className="h-8 text-xs font-mono" + onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }} + /> +
+ +
+ + {/* Built-in aliases reference (collapsible) */} +
+ + {showBuiltin && ( + + + {BUILTIN_ALIASES.map((a) => ( + + + + + + ))} + +
+ {a.phrase} + {a.cdtCode}built-in
+ )} +
+
+
+ ); +} + // ─── Main component ─────────────────────────────────────────────────────────── export function AiChatSettingsCard() {