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";
|
||||
|
||||
// ─── 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;
|
||||
|
||||
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 { 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) });
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user