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:
168
apps/Backend/src/ai/cdt-lookup.ts
Normal file
168
apps/Backend/src/ai/cdt-lookup.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,39 +1,70 @@
|
|||||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||||
|
|
||||||
|
// ─── Intent types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type InternalChatIntent =
|
export type InternalChatIntent =
|
||||||
| "check_eligibility"
|
| "check_eligibility" // by patient name → look up in DB
|
||||||
| "find_patient"
|
| "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_claims"
|
||||||
| "navigate_schedule"
|
| "navigate_schedule"
|
||||||
| "general";
|
| "general";
|
||||||
|
|
||||||
export interface ChatClassification {
|
export interface ChatClassification {
|
||||||
intent: InternalChatIntent;
|
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;
|
fallbackReply: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_SYSTEM_PROMPT = `You are an internal assistant for a dental office management system.
|
// ─── System prompt ────────────────────────────────────────────────────────────
|
||||||
Staff members type natural language commands and you classify what they want.
|
|
||||||
|
|
||||||
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>",
|
"intent": "<intent>",
|
||||||
"patientName": "<full name if patient is mentioned, otherwise omit>",
|
"patientName": "<full name if mentioned by name>",
|
||||||
"fallbackReply": "<a short, helpful reply to show the user>"
|
"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:
|
Intents:
|
||||||
- check_eligibility: user wants to check insurance eligibility for a patient (e.g. "check MARIA", "verify insurance for John Smith", "eligibility GONZALES")
|
- check_eligibility : user wants to check insurance for a patient identified by NAME only
|
||||||
- find_patient: user wants to look up a patient record only (e.g. "find patient John", "look up Smith", "show me GONZALES record")
|
e.g. "check Maria Jesus", "verify insurance for John Smith"
|
||||||
- navigate_claims: user wants to open the claims page
|
- eligibility_by_id : user provides a member ID and date of birth (no patient name)
|
||||||
- navigate_schedule: user wants to open the appointments/schedule page
|
e.g. "check masshealth for 100xxxx, 10/10/1988"
|
||||||
- general: anything else — answer helpfully based on dental office context
|
- 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:
|
Rules:
|
||||||
- Extract the full patient name as-is from the message for check_eligibility and find_patient
|
- For check_and_claim, procedureNames should be the RAW user text
|
||||||
- Keep fallbackReply to 1-2 sentences max
|
(e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes
|
||||||
- For navigate intents, fallbackReply should say "Opening the [page] page..."`;
|
- 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(
|
export async function classifyInternalChat(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -42,7 +73,8 @@ export async function classifyInternalChat(
|
|||||||
): Promise<ChatClassification> {
|
): Promise<ChatClassification> {
|
||||||
const fallback: ChatClassification = {
|
const fallback: ChatClassification = {
|
||||||
intent: "general",
|
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;
|
if (!apiKey) return fallback;
|
||||||
|
|||||||
385
apps/Backend/src/ai/internal-chat-workflow.ts
Normal file
385
apps/Backend/src/ai/internal-chat-workflow.ts
Normal 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;
|
||||||
|
}
|
||||||
1191
apps/Backend/src/data/procedureCodes.json
Executable file
1191
apps/Backend/src/data/procedureCodes.json
Executable file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import express, { Request, Response } from "express";
|
import express, { Request, Response } from "express";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { classifyInternalChat } from "../ai/internal-chat-graph";
|
import { classifyInternalChat } from "../ai/internal-chat-graph";
|
||||||
|
import { runInternalChatWorkflow } from "../ai/internal-chat-workflow";
|
||||||
|
|
||||||
const router = express.Router();
|
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
|
// POST /api/ai/internal-chat
|
||||||
router.post("/internal-chat", async (req: Request, res: Response): Promise<any> => {
|
router.post("/internal-chat", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
@@ -140,107 +175,19 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const extraSystemPrompt = await storage.getInternalChatSystemPrompt(userId);
|
const [extraSystemPrompt, customAliases] = await Promise.all([
|
||||||
const classification = await classifyInternalChat(message.trim(), aiSettings.apiKey, extraSystemPrompt || undefined);
|
storage.getInternalChatSystemPrompt(userId),
|
||||||
|
storage.getCdtAliases(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
// Handle navigation intents immediately
|
const classification = await classifyInternalChat(
|
||||||
if (classification.intent === "navigate_claims") {
|
message.trim(),
|
||||||
return res.status(200).json({ reply: classification.fallbackReply, action: "navigate", actionData: { url: "/claims" } });
|
aiSettings.apiKey,
|
||||||
}
|
extraSystemPrompt || undefined
|
||||||
if (classification.intent === "navigate_schedule") {
|
);
|
||||||
return res.status(200).json({ reply: classification.fallbackReply, action: "navigate", actionData: { url: "/appointments" } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle patient intents — search DB
|
const response = await runInternalChatWorkflow(classification, userId, storage, customAliases);
|
||||||
if (classification.intent === "check_eligibility" || classification.intent === "find_patient") {
|
return res.status(200).json(response);
|
||||||
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 });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(500).json({ error: "Internal chat error", details: String(err) });
|
return res.status(500).json({ error: "Internal chat error", details: String(err) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
async getRecentCommunicationsByUser(userId: number, limit = 20) {
|
||||||
return db.communication.findMany({
|
return db.communication.findMany({
|
||||||
where: { patient: { userId } },
|
where: { patient: { userId } },
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ type Step =
|
|||||||
| "eligibility-input"
|
| "eligibility-input"
|
||||||
| "eligibility-confirm"
|
| "eligibility-confirm"
|
||||||
| "ai-loading"
|
| "ai-loading"
|
||||||
| "patient-found";
|
| "patient-found"
|
||||||
|
| "eligibility-id-ready"
|
||||||
|
| "check-and-claim-ready"
|
||||||
|
| "need-insurance-clarification";
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -45,6 +48,15 @@ interface EligibilityData {
|
|||||||
dobISO: string;
|
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;
|
let msgCounter = 0;
|
||||||
function makeMsg(role: "bot" | "user", text: string, isLoading = false): Message {
|
function makeMsg(role: "bot" | "user", text: string, isLoading = false): Message {
|
||||||
return { id: ++msgCounter, role, text, isLoading };
|
return { id: ++msgCounter, role, text, isLoading };
|
||||||
@@ -97,6 +109,9 @@ export function ChatbotButton() {
|
|||||||
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
|
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
|
||||||
const [freeTextInput, setFreeTextInput] = useState("");
|
const [freeTextInput, setFreeTextInput] = useState("");
|
||||||
const [patientResult, setPatientResult] = useState<PatientResult | null>(null);
|
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 [, setLocation] = useLocation();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -134,6 +149,9 @@ export function ChatbotButton() {
|
|||||||
setEligibilityData(null);
|
setEligibilityData(null);
|
||||||
setFreeTextInput("");
|
setFreeTextInput("");
|
||||||
setPatientResult(null);
|
setPatientResult(null);
|
||||||
|
setEligibilityIdData(null);
|
||||||
|
setCheckAndClaimData(null);
|
||||||
|
setClarificationData(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -199,6 +217,37 @@ export function ChatbotButton() {
|
|||||||
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
|
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 handleFreeTextSubmit = async () => {
|
||||||
const text = freeTextInput.trim();
|
const text = freeTextInput.trim();
|
||||||
if (!text || step === "ai-loading") return;
|
if (!text || step === "ai-loading") return;
|
||||||
@@ -227,6 +276,43 @@ export function ChatbotButton() {
|
|||||||
return;
|
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");
|
setStep("menu");
|
||||||
} catch {
|
} catch {
|
||||||
replaceLastMsg("Sorry, something went wrong. Please try again.");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -416,6 +508,129 @@ export function ChatbotButton() {
|
|||||||
</div>
|
</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 & 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 ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
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";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -673,23 +674,86 @@ function InternalChatSettingsCard() {
|
|||||||
to navigate directly to a page.
|
to navigate directly to a page.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Capability summary */}
|
{/* Built-in workflows */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
<div className="space-y-2">
|
||||||
{[
|
<p className="text-xs font-semibold text-foreground uppercase tracking-wide">Built-in Workflows</p>
|
||||||
{ icon: "🔍", label: "Patient search", desc: 'e.g. "find GONZALES"' },
|
<div className="divide-y rounded-lg border overflow-hidden text-xs">
|
||||||
{ icon: "🏥", label: "Eligibility prefill", desc: 'e.g. "check MARIA DE LA CRUZ"' },
|
|
||||||
{ icon: "🗺️", label: "Navigation", desc: 'e.g. "open claims", "schedule"' },
|
{/* Eligibility by name */}
|
||||||
].map((c) => (
|
<div className="flex items-start gap-3 p-3 bg-background">
|
||||||
<div key={c.label} className="flex items-start gap-2 rounded-lg border bg-muted/30 p-3">
|
<span className="mt-0.5 shrink-0 text-base">🏥</span>
|
||||||
<span className="text-base leading-none">{c.icon}</span>
|
<div className="space-y-0.5">
|
||||||
<div>
|
<p className="font-medium text-foreground">Eligibility by patient name</p>
|
||||||
<p className="font-medium text-foreground">{c.label}</p>
|
<p className="text-muted-foreground">
|
||||||
<p className="text-muted-foreground mt-0.5">{c.desc}</p>
|
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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
|
{/* CDT Aliases */}
|
||||||
|
<CdtAliasesCard />
|
||||||
|
|
||||||
{/* Additional context / system prompt */}
|
{/* Additional context / system prompt */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<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 & 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 ───────────────────────────────────────────────────────────
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function AiChatSettingsCard() {
|
export function AiChatSettingsCard() {
|
||||||
|
|||||||
Reference in New Issue
Block a user