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