feat: AI chat file uploads, RCT/PA tooth mapping, check+claim flow, service date column

- Chatbot: add paperclip button to attach X-ray/PDF files to claim submissions;
  files flow through chatbotFileStore into claim-form uploadedFiles for Selenium
- CDT lookup: auto-select D3310/D3320/D3330 by tooth number for RCT; strip #NN
  from procedure names before alias lookup so "1 pa, #3" → D0220 tooth 3;
  add "1 pa" alias for D0220; expand multi-PA notation via AI prompt rule
- AI classifier: add navigate_eligibility intent ("check mh" → /insurance-status);
  fix duplicate intent entry; add RCT and multi-PA prompt rules
- Check+claim flow: pass serviceDate through check_and_claim_ready actionData;
  tryClaimFromChatbot skips PDF preview and navigates straight to claims with
  memberId fallback lookup; wired into all provider onPdfReady callbacks
- Claims table: add Service Date column between Patient Name and Submission Date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-07 23:47:48 -04:00
parent 19bb5c1145
commit 967a53fc6c
8 changed files with 243 additions and 41 deletions

View File

@@ -152,6 +152,7 @@ const DIRECT_CODE_MAP: Record<string, string> = {
"4bw": "D0274",
"4 bw": "D0274",
"pa": "D0220",
"1 pa": "D0220",
"first pa": "D0220",
"2nd pa": "D0230",
"additional pa": "D0230",
@@ -238,6 +239,11 @@ function extractToothSurface(input: string): { toothNumber?: string; toothSurfac
const FRONT_TEETH = new Set([6, 7, 8, 9, 10, 11, 22, 23, 24, 25, 26, 27]);
const BACK_TEETH = new Set([1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 28, 29, 30, 31, 32]);
// RCT tooth classification by tooth type
const RCT_ANTERIOR = new Set([6, 7, 8, 9, 10, 11, 22, 23, 24, 25, 26, 27]); // D3310
const RCT_PREMOLAR = new Set([4, 5, 12, 13, 20, 21, 28, 29]); // D3320
const RCT_MOLAR = new Set([1, 2, 3, 14, 15, 16, 17, 18, 19, 30, 31, 32]); // D3330
/**
* Parse composite filling notation like "#29 OB", "composite #8 MO", "tooth 11 MOD".
* Returns the correct D233x/D239x code based on tooth location and surface count.
@@ -271,6 +277,43 @@ function parseCompositeCode(input: string): CdtMatch | null {
return { code, description: row?.Description ?? code, input, toothNumber: String(toothNum), toothSurface: surfaces.toUpperCase() };
}
/**
* Parse RCT/root-canal notation like "rct #29", "#14 rct", "root canal #6".
* Returns D3310 (anterior), D3320 (premolar), or D3330 (molar) based on tooth number.
*/
function parseRctCode(input: string): CdtMatch | null {
const lower = input.toLowerCase();
const isRct = /\b(rct|root\s*canal)\b/.test(lower);
if (!isRct) return null;
// Extract tooth number from "#NN" or bare number adjacent to "rct"/"root canal"
let toothNum: number | null = null;
const hashMatch = input.match(/#(\d{1,2})/);
if (hashMatch) {
toothNum = parseInt(hashMatch[1]!, 10);
} else {
// Fallback: bare number in input (e.g. "rct 29", "29 rct")
const numMatch = input.match(/\b(\d{1,2})\b/);
if (numMatch) toothNum = parseInt(numMatch[1]!, 10);
}
if (!toothNum || toothNum < 1 || toothNum > 32) return null;
let code: string;
if (RCT_ANTERIOR.has(toothNum)) {
code = "D3310";
} else if (RCT_PREMOLAR.has(toothNum)) {
code = "D3320";
} else if (RCT_MOLAR.has(toothNum)) {
code = "D3330";
} else {
return null;
}
const row = ALL_CODES.find((r) => r["Procedure Code"].toUpperCase() === code);
return { code, description: row?.Description ?? code, input, toothNumber: String(toothNum) };
}
/**
* 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
@@ -347,10 +390,14 @@ export function lookupCdtCodes(
const cleaned = name.trim().toLowerCase();
// Extract tooth# from "#NN" notation — applies to any procedure
const toothInfo = extractToothSurface(name);
// Version of cleaned with tooth notation stripped, for alias matching
// e.g. "1 pa, #3" → "1 pa" so it hits the "1 pa" alias
const strippedCleaned = cleaned.replace(/,?\s*#\d{1,2}\b/g, "").replace(/\s+/g, " ").trim();
// 1. Custom alias exact match (highest priority)
if (customMap[cleaned]) {
const code = customMap[cleaned]!;
const customKey = customMap[cleaned] ? cleaned : customMap[strippedCleaned] ? strippedCleaned : null;
if (customKey) {
const code = customMap[customKey]!;
const row = ALL_CODES.find((r) => r["Procedure Code"].toUpperCase() === code);
return { code, description: row?.Description ?? code, input: name, ...toothInfo };
}
@@ -359,16 +406,21 @@ export function lookupCdtCodes(
const compositeMatch = parseCompositeCode(name);
if (compositeMatch) return compositeMatch; // already carries toothNumber/toothSurface
// 2b. RCT by tooth# — D3310/D3320/D3330 based on tooth position
const rctMatch = parseRctCode(name);
if (rctMatch) return rctMatch;
// 3. Hardcoded direct code map (short abbreviations / codes not in MH schedule)
if (DIRECT_CODE_MAP[cleaned]) {
const code = DIRECT_CODE_MAP[cleaned]!;
const directKey = DIRECT_CODE_MAP[cleaned] ? cleaned : DIRECT_CODE_MAP[strippedCleaned] ? strippedCleaned : null;
if (directKey) {
const code = DIRECT_CODE_MAP[directKey]!;
const row = ALL_CODES.find((r) => r["Procedure Code"].toUpperCase() === code);
return { code, description: row?.Description ?? code, input: name, ...toothInfo };
}
// 4. Hardcoded alias + keyword search
const match = matchOne(name);
if (match) return { ...match, ...toothInfo };
// 4. Hardcoded alias + keyword search (try stripped name so tooth# doesn't break matching)
const match = matchOne(strippedCleaned !== cleaned ? strippedCleaned : name);
if (match) return { ...match, input: name, ...toothInfo };
return {
code: null,

View File

@@ -11,6 +11,7 @@ export type InternalChatIntent =
| "claim_only" // submit claim for procedures (no eligibility check)
| "navigate_claims"
| "navigate_schedule"
| "navigate_eligibility"
| "general";
export interface ChatClassification {
@@ -76,6 +77,8 @@ Intents:
Always extract appointmentDate when a date or "today" is mentioned
- navigate_claims : open the claims page
- navigate_schedule : open the appointments/schedule page
- navigate_eligibility : open the insurance eligibility page
e.g. "check mh", "check masshealth", "open eligibility", "go to eligibility", "check insurance"
- general : anything else
Rules:
@@ -83,9 +86,16 @@ Rules:
(e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes
- For composite fillings with a tooth number, preserve the EXACT notation including tooth# and surfaces:
e.g. "composite #29 O", "#8 MO", "composite #11 MOD" — keep the #number and surface letters together as one entry
- For RCT/root canal with a tooth number, preserve the tooth# in the entry:
e.g. "rct #29", "#14 root canal", "rct #6" — keep the #number with the procedure so the correct code can be selected
- For multiple PA X-rays with tooth numbers, expand each PA into its own entry:
"1 pa, #T" for the first tooth, "2nd pa, #T" for each additional tooth
e.g. "2 PA (#30, 15)" → ["1 pa, #30", "2nd pa, #15"]
e.g. "3 PA (#3, 14, 30)" → ["1 pa, #3", "2nd pa, #14", "2nd pa, #30"]
e.g. "2 pa #3 #14" → ["1 pa, #3", "2nd pa, #14"]
- 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..."
- For navigate intents, fallbackReply = "Opening the [page] page..." (e.g. "Opening the eligibility page...")
- appointmentDate applies to BOTH schedule_appointment AND claim_only/check_and_claim:
always set it to today's date (${today}) when the user says "today", "this visit", or similar
set it to the specified date when the user mentions a date (e.g. "05/15/2026")

View File

@@ -26,6 +26,8 @@ export interface CdtResult {
code: string | null;
description: string;
input: string;
toothNumber?: string;
toothSurface?: string;
}
export interface ChatResponse {
@@ -181,6 +183,13 @@ export async function runInternalChatWorkflow(
actionData: { url: "/appointments" },
};
}
if (intent === "navigate_eligibility") {
return {
reply: classification.fallbackReply ?? "Opening the eligibility page...",
action: "navigate",
actionData: { url: "/insurance-status" },
};
}
// ── Find patient (record only, no eligibility) ──────────────────────────────
@@ -451,6 +460,7 @@ async function handleCheckAndClaim(
autoCheck: siteKeyToAutoCheck(siteKey),
cdtResults,
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface })),
serviceDate: c.appointmentDate ?? null,
},
};
}