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
co-authored by Claude Sonnet 4.6
parent 4274cd61dc
commit ba2882957a
8 changed files with 2357 additions and 133 deletions
@@ -2,11 +2,12 @@ import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal } from "lucide-react";
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -673,23 +674,86 @@ function InternalChatSettingsCard() {
to navigate directly to a page.
</p>
{/* Capability summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
{[
{ icon: "🔍", label: "Patient search", desc: 'e.g. "find GONZALES"' },
{ icon: "🏥", label: "Eligibility prefill", desc: 'e.g. "check MARIA DE LA CRUZ"' },
{ icon: "🗺️", label: "Navigation", desc: 'e.g. "open claims", "schedule"' },
].map((c) => (
<div key={c.label} className="flex items-start gap-2 rounded-lg border bg-muted/30 p-3">
<span className="text-base leading-none">{c.icon}</span>
<div>
<p className="font-medium text-foreground">{c.label}</p>
<p className="text-muted-foreground mt-0.5">{c.desc}</p>
{/* Built-in workflows */}
<div className="space-y-2">
<p className="text-xs font-semibold text-foreground uppercase tracking-wide">Built-in Workflows</p>
<div className="divide-y rounded-lg border overflow-hidden text-xs">
{/* Eligibility by name */}
<div className="flex items-start gap-3 p-3 bg-background">
<span className="mt-0.5 shrink-0 text-base">🏥</span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Eligibility by patient name</p>
<p className="text-muted-foreground">
Looks up the patient in the database, resolves their insurance, and opens the eligibility page pre-filled.
</p>
<div className="flex flex-wrap gap-1 pt-1">
{["check Maria Jesus", "verify insurance for John Smith"].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
))}
</div>
</div>
</div>
))}
{/* Eligibility by member ID */}
<div className="flex items-start gap-3 p-3 bg-muted/20">
<span className="mt-0.5 shrink-0 text-base">🔢</span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Eligibility by Member ID + DOB</p>
<p className="text-muted-foreground">
Provide a member ID and date of birth. Insurance is resolved from the patient record, or from what you state in the message.
</p>
<div className="flex flex-wrap gap-1 pt-1">
{["check masshealth for 100xxxx, 10/10/1988"].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
))}
</div>
</div>
</div>
{/* Check & Claim */}
<div className="flex items-start gap-3 p-3 bg-background">
<span className="mt-0.5 shrink-0 text-base">⚡</span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Check eligibility + claim procedures</p>
<p className="text-muted-foreground">
Resolves the patient, maps procedure names to CDT codes using the fee schedule, and opens the eligibility page ready to check and claim. Procedure names or CDT codes both work.
</p>
<div className="flex flex-wrap gap-1 pt-1">
{[
"check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning",
"check Maria Jesus and claim D0120 D1110",
].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono block">{ex}</code>
))}
</div>
<p className="text-muted-foreground pt-1">
CDT codes are looked up from the fee schedule — no AI translation needed.
If insurance is unknown the assistant will ask which one to use.
</p>
</div>
</div>
{/* Navigation */}
<div className="flex items-start gap-3 p-3 bg-muted/20">
<span className="mt-0.5 shrink-0 text-base">🗺️</span>
<div className="space-y-0.5">
<p className="font-medium text-foreground">Navigation</p>
<p className="text-muted-foreground">Opens any page in the app.</p>
<div className="flex flex-wrap gap-1 pt-1">
{["open claims", "go to schedule", "find patient GONZALES"].map((ex) => (
<code key={ex} className="bg-muted px-1.5 py-0.5 rounded font-mono">{ex}</code>
))}
</div>
</div>
</div>
</div>
</div>
{/* CDT Aliases */}
<CdtAliasesCard />
{/* Additional context / system prompt */}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
@@ -736,6 +800,207 @@ function InternalChatSettingsCard() {
);
}
// ─── CDT Aliases card ────────────────────────────────────────────────────────
const BUILTIN_ALIASES = [
{ phrase: "perio exam", cdtCode: "D0120" },
{ phrase: "periodic exam", cdtCode: "D0120" },
{ phrase: "adult cleaning", cdtCode: "D1110" },
{ phrase: "adult prophy", cdtCode: "D1110" },
{ phrase: "child cleaning", cdtCode: "D1120" },
{ phrase: "full mouth xray", cdtCode: "D0210" },
{ phrase: "fmx", cdtCode: "D0210" },
{ phrase: "pano", cdtCode: "D0330" },
{ phrase: "comp exam", cdtCode: "D0150" },
{ phrase: "limited exam", cdtCode: "D0140" },
{ phrase: "scaling root planing", cdtCode: "D4341" },
{ phrase: "srp", cdtCode: "D4341" },
{ phrase: "perio maintenance", cdtCode: "D4910" },
];
type CdtAlias = { phrase: string; cdtCode: string };
function CdtAliasesCard() {
const { toast } = useToast();
const [aliases, setAliases] = useState<CdtAlias[]>([]);
const [newPhrase, setNewPhrase] = useState("");
const [newCode, setNewCode] = useState("");
const [showBuiltin, setShowBuiltin] = useState(false);
const initialized = useRef(false);
const { data, isLoading } = useQuery<CdtAlias[]>({
queryKey: ["/api/ai/cdt-aliases"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/cdt-aliases");
if (!res.ok) return [];
return res.json();
},
staleTime: Infinity,
refetchOnWindowFocus: false,
});
useEffect(() => {
if (data && !initialized.current) {
initialized.current = true;
setAliases(data);
}
}, [data]);
const saveMutation = useMutation({
mutationFn: async (list: CdtAlias[]) => {
const res = await apiRequest("PUT", "/api/ai/cdt-aliases", list);
if (!res.ok) throw new Error("Failed to save");
return res.json();
},
onSuccess: (saved: CdtAlias[]) => {
setAliases(saved);
queryClient.invalidateQueries({ queryKey: ["/api/ai/cdt-aliases"] });
toast({ title: "CDT aliases saved" });
},
onError: () => {
toast({ title: "Error", description: "Failed to save aliases.", variant: "destructive" });
},
});
const handleAdd = () => {
const phrase = newPhrase.trim().toLowerCase();
const code = newCode.trim().toUpperCase();
if (!phrase || !code) return;
if (aliases.some((a) => a.phrase === phrase)) {
toast({ title: "Duplicate", description: `"${phrase}" already exists.`, variant: "destructive" });
return;
}
const updated = [...aliases, { phrase, cdtCode: code }];
setAliases(updated);
saveMutation.mutate(updated);
setNewPhrase("");
setNewCode("");
};
const handleDelete = (idx: number) => {
const updated = aliases.filter((_, i) => i !== idx);
setAliases(updated);
saveMutation.mutate(updated);
};
return (
<Card>
<CardContent className="py-6 space-y-4">
<div className="flex items-center gap-2">
<BookMarked className="h-5 w-5 text-primary" />
<h3 className="text-base font-semibold">CDT Aliases</h3>
</div>
<p className="text-xs text-muted-foreground">
Map your own shorthand phrases to CDT codes. These override the built-in aliases when
staff type procedure names in the chat (e.g.{" "}
<code className="bg-muted px-1 py-0.5 rounded font-mono">"check &amp; claim perio exam"</code>).
Phrases are matched case-insensitively.
</p>
{/* Custom alias list */}
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : aliases.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No custom aliases yet — add one below.</p>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Phrase (what staff type)</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">CDT Code</th>
<th className="w-8" />
</tr>
</thead>
<tbody className="divide-y">
{aliases.map((a, i) => (
<tr key={i} className="bg-background">
<td className="px-3 py-2">
<code className="font-mono">{a.phrase}</code>
</td>
<td className="px-3 py-2 font-mono font-semibold text-primary">{a.cdtCode}</td>
<td className="px-2 py-1">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(i)}
disabled={saveMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Add row */}
<div className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<p className="text-xs text-muted-foreground">Phrase</p>
<Input
value={newPhrase}
onChange={(e) => setNewPhrase(e.target.value)}
placeholder='e.g. "cleaning adult"'
className="h-8 text-xs"
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
/>
</div>
<div className="w-28 space-y-1">
<p className="text-xs text-muted-foreground">CDT Code</p>
<Input
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
placeholder="D1110"
className="h-8 text-xs font-mono"
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
/>
</div>
<Button
size="sm"
className="h-8 gap-1.5 shrink-0"
onClick={handleAdd}
disabled={!newPhrase.trim() || !newCode.trim() || saveMutation.isPending}
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
{/* Built-in aliases reference (collapsible) */}
<div className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted/40 transition-colors"
onClick={() => setShowBuiltin((v) => !v)}
>
<span>View built-in aliases ({BUILTIN_ALIASES.length})</span>
{showBuiltin ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
{showBuiltin && (
<table className="w-full text-xs border-t">
<tbody className="divide-y">
{BUILTIN_ALIASES.map((a) => (
<tr key={a.phrase} className="bg-muted/20">
<td className="px-3 py-1.5">
<code className="font-mono text-muted-foreground">{a.phrase}</code>
</td>
<td className="px-3 py-1.5 font-mono font-semibold text-muted-foreground">{a.cdtCode}</td>
<td className="px-3 py-1.5 text-muted-foreground/60 italic text-[10px]">built-in</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</CardContent>
</Card>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export function AiChatSettingsCard() {