feat: Users AI Chat multi-step workflows with CDT lookup and alias management

- 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 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-06-03 17:44:19 -04:00
parent 4274cd61dc
commit ba2882957a
8 changed files with 2357 additions and 133 deletions

View File

@@ -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<string> }[] =
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<string, string> = {
"perio exam": "periodic oral evaluation",
"periodic exam": "periodic oral evaluation",
"recall exam": "periodic oral evaluation",
"adult cleaning": "prophylaxis adult",
"adult prophy": "prophylaxis adult",
"adult prophylaxis": "prophylaxis adult",
"child cleaning": "prophylaxis child",
"child prophy": "prophylaxis child",
"pedo cleaning": "prophylaxis child",
"full mouth xray": "intraoral complete series",
"fmx": "intraoral complete series",
"pano": "panoramic",
"panorex": "panoramic",
"bitewing": "bitewing",
"bw": "bitewing two",
"comp exam": "comprehensive oral evaluation",
"comprehensive exam":"comprehensive oral evaluation",
"new patient exam": "comprehensive oral evaluation",
"limited exam": "limited oral evaluation",
"emergency exam": "limited oral evaluation",
"sealant": "sealant",
"fluoride": "fluoride",
"scaling root planing": "scaling root planing",
"srp": "scaling root planing",
"perio maintenance": "periodontal maintenance",
"crown": "crown",
"extraction": "extraction",
"root canal": "root canal",
"filling": "resin",
};
/**
* 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<string>; 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<string, string> = {};
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,
};
});
}

View File

@@ -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": "<one of the intents below>",
"patientName": "<full name if patient is mentioned, otherwise omit>",
"fallbackReply": "<a short, helpful reply to show the user>"
"intent": "<intent>",
"patientName": "<full name if mentioned by name>",
"memberId": "<member/insurance ID if given explicitly>",
"dob": "<date of birth in MM/DD/YYYY if given>",
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
"procedureNames": ["<raw procedure name>", ...],
"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<ChatClassification> {
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;

View File

@@ -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<string, any>;
}
// ─── 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<any[] | null>;
getPatientByInsuranceId(id: string): Promise<any | null>;
}
// ─── 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<any | null> {
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<ChatResponse> {
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<ChatResponse> {
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<ChatResponse> {
// 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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<any> => {
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<any> => {
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<any> => {
try {
@@ -140,107 +175,19 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
});
}
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) });
}

View File

@@ -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<string, any>) || {};
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<void> {
const settings = await db.twilioSettings.findUnique({ where: { userId } });
const existing = (settings?.templates as Record<string, any>) || {};
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 } },

View File

@@ -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<EligibilityData | null>(null);
const [freeTextInput, setFreeTextInput] = useState("");
const [patientResult, setPatientResult] = useState<PatientResult | null>(null);
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null } | null>(null);
const [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | null>(null);
const [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null);
const [, setLocation] = useLocation();
const messagesEndRef = useRef<HTMLDivElement>(null);
const pasteRef = useRef<HTMLTextAreaElement>(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() {
</div>
)}
{/* Eligibility by ID ready */}
{step === "eligibility-id-ready" && eligibilityIdData && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 space-y-2">
{eligibilityIdData.patient && (
<p className="text-xs font-semibold text-blue-800">
{eligibilityIdData.patient.firstName} {eligibilityIdData.patient.lastName}
</p>
)}
<p className="text-xs text-blue-600">ID: {eligibilityIdData.memberId}</p>
<p className="text-xs text-gray-500">DOB: {eligibilityIdData.dob}</p>
{eligibilityIdData.patient?.insuranceProvider && (
<p className="text-xs text-gray-500">{eligibilityIdData.patient.insuranceProvider}</p>
)}
<div className="flex gap-2 pt-1">
<Button
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90"
onClick={handleEligibilityIdRun}
>
<Stethoscope className="h-3 w-3 mr-1" />
Check Eligibility
</Button>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
Cancel
</Button>
</div>
</div>
)}
{/* Check & Claim ready */}
{step === "check-and-claim-ready" && checkAndClaimData && (
<div className="bg-teal-50 border border-teal-200 rounded-xl p-3 space-y-2">
{checkAndClaimData.patient && (
<p className="text-xs font-semibold text-teal-800">
{checkAndClaimData.patient.firstName} {checkAndClaimData.patient.lastName}
</p>
)}
<p className="text-xs text-teal-600">ID: {checkAndClaimData.memberId} · DOB: {checkAndClaimData.dob}</p>
{checkAndClaimData.matchedCodes.length > 0 && (
<div className="space-y-0.5">
<p className="text-xs font-medium text-teal-700">Claim after ACTIVE:</p>
{checkAndClaimData.matchedCodes.map((c) => (
<p key={c.code} className="text-xs text-gray-600 pl-2">
{c.code} {c.description}
</p>
))}
</div>
)}
<div className="flex gap-2 pt-1">
<Button
size="sm"
className="flex-1 h-8 text-xs bg-teal-600 hover:bg-teal-700 text-white"
onClick={handleCheckAndClaimRun}
>
<Stethoscope className="h-3 w-3 mr-1" />
Check &amp; Claim
</Button>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
Cancel
</Button>
</div>
</div>
)}
{/* Need insurance clarification */}
{step === "need-insurance-clarification" && clarificationData && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 space-y-2">
<p className="text-xs font-semibold text-amber-800">Which insurance?</p>
<p className="text-xs text-gray-500">ID: {clarificationData.memberId}</p>
<div className="flex flex-col gap-1.5 pt-1">
{clarificationData.options.map((opt) => (
<button
key={opt}
className="text-left text-xs px-3 py-1.5 rounded-lg border border-amber-300 hover:bg-amber-100 transition-colors"
onClick={() => {
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}
</button>
))}
</div>
<Button size="sm" variant="ghost" className="h-7 text-xs w-full" onClick={reset}>
Cancel
</Button>
</div>
)}
<div ref={messagesEndRef} />
</div>

View File

@@ -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.
</p>
{/* Capability summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
{[
{ 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) => (
<div key={c.label} className="flex items-start gap-2 rounded-lg border bg-muted/30 p-3">
<span className="text-base leading-none">{c.icon}</span>
<div>
<p className="font-medium text-foreground">{c.label}</p>
<p className="text-muted-foreground mt-0.5">{c.desc}</p>
{/* Built-in workflows */}
<div className="space-y-2">
<p className="text-xs font-semibold text-foreground uppercase tracking-wide">Built-in Workflows</p>
<div className="divide-y rounded-lg border overflow-hidden text-xs">
{/* Eligibility by name */}
<div className="flex items-start gap-3 p-3 bg-background">
<span className="mt-0.5 shrink-0 text-base">🏥</span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Eligibility by patient name</p>
<p className="text-muted-foreground">
Looks up the patient in the database, resolves their insurance, and opens the eligibility page pre-filled.
</p>
<div className="flex flex-wrap gap-1 pt-1">
{["check Maria Jesus", "verify insurance for John Smith"].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
))}
</div>
</div>
</div>
))}
{/* Eligibility by member ID */}
<div className="flex items-start gap-3 p-3 bg-muted/20">
<span className="mt-0.5 shrink-0 text-base">🔢</span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Eligibility by Member ID + DOB</p>
<p className="text-muted-foreground">
Provide a member ID and date of birth. Insurance is resolved from the patient record, or from what you state in the message.
</p>
<div className="flex flex-wrap gap-1 pt-1">
{["check masshealth for 100xxxx, 10/10/1988"].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
))}
</div>
</div>
</div>
{/* Check & Claim */}
<div className="flex items-start gap-3 p-3 bg-background">
<span className="mt-0.5 shrink-0 text-base"></span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Check eligibility + claim procedures</p>
<p className="text-muted-foreground">
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.
</p>
<div className="flex flex-wrap gap-1 pt-1">
{[
"check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning",
"check Maria Jesus and claim D0120 D1110",
].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono block">{ex}</code>
))}
</div>
<p className="text-muted-foreground pt-1">
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.
</p>
</div>
</div>
{/* Navigation */}
<div className="flex items-start gap-3 p-3 bg-muted/20">
<span className="mt-0.5 shrink-0 text-base">🗺</span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Navigation</p>
<p className="text-muted-foreground">Opens any page in the app.</p>
<div className="flex flex-wrap gap-1 pt-1">
{["open claims", "go to schedule", "find patient GONZALES"].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
))}
</div>
</div>
</div>
</div>
</div>
{/* CDT Aliases */}
<CdtAliasesCard />
{/* Additional context / system prompt */}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
@@ -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<CdtAlias[]>([]);
const [newPhrase, setNewPhrase] = useState("");
const [newCode, setNewCode] = useState("");
const [showBuiltin, setShowBuiltin] = useState(false);
const initialized = useRef(false);
const { data, isLoading } = useQuery<CdtAlias[]>({
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 (
<Card>
<CardContent className="py-6 space-y-4">
<div className="flex items-center gap-2">
<BookMarked className="h-5 w-5 text-primary" />
<h3 className="text-base font-semibold">CDT Aliases</h3>
</div>
<p className="text-xs text-muted-foreground">
Map your own shorthand phrases to CDT codes. These override the built-in aliases when
staff type procedure names in the chat (e.g.{" "}
<code className="bg-muted px-1 py-0.5 rounded font-mono">"check &amp; claim perio exam"</code>).
Phrases are matched case-insensitively.
</p>
{/* Custom alias list */}
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : aliases.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No custom aliases yet add one below.</p>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Phrase (what staff type)</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">CDT Code</th>
<th className="w-8" />
</tr>
</thead>
<tbody className="divide-y">
{aliases.map((a, i) => (
<tr key={i} className="bg-background">
<td className="px-3 py-2">
<code className="font-mono">{a.phrase}</code>
</td>
<td className="px-3 py-2 font-mono font-semibold text-primary">{a.cdtCode}</td>
<td className="px-2 py-1">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(i)}
disabled={saveMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Add row */}
<div className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<p className="text-xs text-muted-foreground">Phrase</p>
<Input
value={newPhrase}
onChange={(e) => setNewPhrase(e.target.value)}
placeholder='e.g. "cleaning adult"'
className="h-8 text-xs"
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
/>
</div>
<div className="w-28 space-y-1">
<p className="text-xs text-muted-foreground">CDT Code</p>
<Input
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
placeholder="D1110"
className="h-8 text-xs font-mono"
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
/>
</div>
<Button
size="sm"
className="h-8 gap-1.5 shrink-0"
onClick={handleAdd}
disabled={!newPhrase.trim() || !newCode.trim() || saveMutation.isPending}
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
{/* Built-in aliases reference (collapsible) */}
<div className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted/40 transition-colors"
onClick={() => setShowBuiltin((v) => !v)}
>
<span>View built-in aliases ({BUILTIN_ALIASES.length})</span>
{showBuiltin ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
{showBuiltin && (
<table className="w-full text-xs border-t">
<tbody className="divide-y">
{BUILTIN_ALIASES.map((a) => (
<tr key={a.phrase} className="bg-muted/20">
<td className="px-3 py-1.5">
<code className="font-mono text-muted-foreground">{a.phrase}</code>
</td>
<td className="px-3 py-1.5 font-mono font-semibold text-muted-foreground">{a.cdtCode}</td>
<td className="px-3 py-1.5 text-muted-foreground/60 italic text-[10px]">built-in</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</CardContent>
</Card>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export function AiChatSettingsCard() {