From ba2882957a1091343fcde9e826e0accf39106842 Mon Sep 17 00:00:00 2001
From: Gitead
Date: Wed, 3 Jun 2026 17:44:19 -0400
Subject: [PATCH] feat: Users AI Chat multi-step workflows with CDT lookup and
alias management
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add eligibility_by_id and check_and_claim intents to internal chat
- New cdt-lookup.ts: keyword search against fee schedule JSON (no LLM)
- New internal-chat-workflow.ts: deterministic orchestration — patient
resolution, insurance siteKey derivation, CDT code mapping
- Custom CDT aliases stored per-user in DB (TwilioSettings JSON blob)
with GET/PUT /api/ai/cdt-aliases endpoints
- Chatbot UI: new steps for eligibility-id-ready, check-and-claim-ready,
and need-insurance-clarification with insurance picker
- Settings UI: CDT Aliases CRUD table with built-in alias reference
Co-Authored-By: Claude Sonnet 4.6
---
apps/Backend/src/ai/cdt-lookup.ts | 168 +++
apps/Backend/src/ai/internal-chat-graph.ts | 68 +-
apps/Backend/src/ai/internal-chat-workflow.ts | 385 ++++++
apps/Backend/src/data/procedureCodes.json | 1191 +++++++++++++++++
apps/Backend/src/routes/ai-settings.ts | 145 +-
apps/Backend/src/storage/twilio-storage.ts | 21 +
.../src/components/layout/chatbot.tsx | 219 ++-
.../settings/ai-chat-settings-card.tsx | 293 +++-
8 files changed, 2357 insertions(+), 133 deletions(-)
create mode 100644 apps/Backend/src/ai/cdt-lookup.ts
create mode 100644 apps/Backend/src/ai/internal-chat-workflow.ts
create mode 100755 apps/Backend/src/data/procedureCodes.json
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 Eligibility
+
+
+ Cancel
+
+
+
+ )}
+
+ {/* 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}
+
+ ))}
+
+ )}
+
+
+
+ Check & Claim
+
+
+ Cancel
+
+
+
+ )}
+
+ {/* Need insurance clarification */}
+ {step === "need-insurance-clarification" && clarificationData && (
+
+
Which insurance?
+
ID: {clarificationData.memberId}
+
+ {clarificationData.options.map((opt) => (
+ {
+ addMsg("user", opt);
+ addMsg("bot", "Thinking...", true);
+ 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(", ") : ""}`,
+ })
+ .then((r) => r.json())
+ .then((data) => {
+ replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
+ 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");
+ } else 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");
+ } else {
+ setStep("menu");
+ }
+ })
+ .catch(() => {
+ replaceLastMsg("Sorry, something went wrong.");
+ setStep("menu");
+ });
+ }}
+ >
+ {opt}
+
+ ))}
+
+
+ Cancel
+
+
+ )}
+
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.
+ ) : (
+
+
+
+
+ Phrase (what staff type)
+ CDT Code
+
+
+
+
+ {aliases.map((a, i) => (
+
+
+ {a.phrase}
+
+ {a.cdtCode}
+
+ handleDelete(i)}
+ disabled={saveMutation.isPending}
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {/* 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(); }}
+ />
+
+
+
+ Add
+
+
+
+ {/* Built-in aliases reference (collapsible) */}
+
+
setShowBuiltin((v) => !v)}
+ >
+ View built-in aliases ({BUILTIN_ALIASES.length})
+ {showBuiltin ? : }
+
+ {showBuiltin && (
+
+
+ {BUILTIN_ALIASES.map((a) => (
+
+
+ {a.phrase}
+
+ {a.cdtCode}
+ built-in
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
+
// ─── Main component ───────────────────────────────────────────────────────────
export function AiChatSettingsCard() {