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

- Add eligibility_by_id and check_and_claim intents to internal chat
- New cdt-lookup.ts: keyword search against fee schedule JSON (no LLM)
- New internal-chat-workflow.ts: deterministic orchestration — patient
  resolution, insurance siteKey derivation, CDT code mapping
- Custom CDT aliases stored per-user in DB (TwilioSettings JSON blob)
  with GET/PUT /api/ai/cdt-aliases endpoints
- Chatbot UI: new steps for eligibility-id-ready, check-and-claim-ready,
  and need-insurance-clarification with insurance picker
- Settings UI: CDT Aliases CRUD table with built-in alias reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-06-03 17:44:19 -04:00
parent 4274cd61dc
commit ba2882957a
8 changed files with 2357 additions and 133 deletions

View File

@@ -0,0 +1,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;
}