fix: AI claims/preauths keep all codes past 10; reuse CCA/DentalHub Chrome

- Claim form: AI claim/preauth prefill adds rows beyond the initial 10
  instead of dropping codes 11+
- CDT lookup: "D6010 #5, 9, 11, 12" expands to one line per tooth, and
  the chat graph rebuilds such lists from the message if the LLM drops items
- Eligibility: match an existing patient by name+DOB when the insurance ID
  changed (e.g. MassHealth → CCA) instead of creating a duplicate patient
- CCA PreAuth: services grid scrolls to reach rows 11+
- CCA and Tufts SCO PreAuth: tentative service date = today + 3 days
- CCA (eligibility/claim/preauth) and DentalHub (claim/preauth): keep Chrome
  open, blank and minimized, after a job so the next job reuses it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 01:27:28 -04:00
co-authored by Claude Opus 5.5
parent 8937a3390c
commit 143319909f
13 changed files with 218 additions and 63 deletions
+29 -1
View File
@@ -630,6 +630,27 @@ function expandCompositeLists(procedureNames: string[]): string[] {
return out;
}
// Explicit code + tooth list with no surfaces, e.g. "D6010 #5, 9 , 11, 12" or "D6057 #5 #9 and 11"
const CODE_TOOTH_LIST_RE =
/\b(D\d{4})\s*#\s*(\d{1,2}(?:\s*(?:,|&|\band\b)?\s*#?\s*\d{1,2})*)\b(?!\s*[OMDBLFIV]{1,5}\b)/gi;
/**
* Split explicit CDT codes followed by a tooth list into one entry per tooth.
* Returns null when the text has no code with more than one tooth.
* e.g. "D6010 #5, 9, 11, 12 D6057 #5, 9" → ["D6010 #5", "D6010 #9", "D6010 #11", "D6010 #12", "D6057 #5", "D6057 #9"]
*/
export function expandCodeToothLists(text: string): string[] | null {
const out: string[] = [];
let multi = false;
for (const m of text.matchAll(CODE_TOOTH_LIST_RE)) {
const code = m[1]!.toUpperCase();
const teeth = m[2]!.match(/\d{1,2}/g) ?? [];
if (teeth.length > 1) multi = true;
for (const t of teeth) out.push(`${code} #${t}`);
}
return multi ? out : null;
}
/**
* Map an array of free-text procedure names to CDT codes.
* customAliases (from DB) are checked before the hardcoded ALIAS_MAP.
@@ -648,7 +669,14 @@ export function lookupCdtCodes(
// Pontics with no stated material — re-coded below to match the crowns
const inferredPontics: CdtMatch[] = [];
const results = expandCompositeLists(procedureNames).flatMap((name): (CdtMatch | { code: null; description: string; input: string })[] => {
// Only split an entry that is nothing but code + tooth lists, so no other text is lost
const expandedNames = procedureNames.flatMap((n) => {
const split = expandCodeToothLists(n);
const rest = n.replace(CODE_TOOTH_LIST_RE, "").replace(/[\s,;&]|\band\b/gi, "");
return split && rest === "" ? split : [n];
});
const results = expandCompositeLists(expandedNames).flatMap((name): (CdtMatch | { code: null; description: string; input: string })[] => {
const cleaned = name.trim().toLowerCase();
// If the LLM emitted a bare tooth-number token like "# 32" or "#32" with no procedure,
@@ -1,4 +1,5 @@
import { getLlm, type AiProvider } from "./llm-factory";
import { expandCodeToothLists } from "./cdt-lookup";
// ─── Intent types ─────────────────────────────────────────────────────────────
@@ -254,6 +255,24 @@ function restoreBarePaCount(message: string, names: string[]): string[] {
return kept;
}
// "D6010 #5, 9, 11, 12 D6057 #5, 9, 11, 12" — the LLM can drop or merge items when
// splitting long tooth lists, so rebuild those entries from the message itself.
export function restoreCodeToothLists(message: string, names: string[]): string[] {
const expanded = expandCodeToothLists(message);
if (!expanded) return names;
const codes = new Set(expanded.map((e) => e.split(" ")[0]!));
const isCovered = (n: string) => {
const s = n.trim();
if (/^#?\s*\d{1,2}$/.test(s)) return true; // stray tooth number from the split
const m = s.match(/^(D\d{4})\b/i);
return !!m && codes.has(m[1]!.toUpperCase());
};
const first = names.findIndex(isCovered);
const kept = names.filter((n) => !isCovered(n));
kept.splice(first === -1 ? kept.length : first, 0, ...expanded);
return kept;
}
function parseBareIdDob(message: string): { memberId: string; dob: string } | null {
const text = message.trim();
const dobPattern = /(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{4})/;
@@ -347,6 +366,7 @@ export async function classifyInternalChat(
const parsed = JSON.parse(jsonStr) as ChatClassification;
if (!parsed.intent || !parsed.fallbackReply) return fallback;
if (parsed.procedureNames) parsed.procedureNames = restoreBarePaCount(message, parsed.procedureNames);
if (parsed.procedureNames) parsed.procedureNames = restoreCodeToothLists(message, parsed.procedureNames);
return parsed;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -125,6 +125,15 @@ export async function createOrUpdatePatientByInsuranceId(options: {
console.log(`[createOrUpdatePatient] id-only fallback (no DOB on record): found id=${patient.id}`);
}
}
if (!patient && incomingFirst && incomingLast) {
// Same person whose insurance changed (e.g. MassHealth → CCA): match by
// name+DOB and move them to the new insuranceId instead of duplicating.
const byNameDob = await storage.getPatientByNameAndDob(incomingFirst, incomingLast, dobDate);
if (byNameDob) {
patient = byNameDob;
console.log(`[createOrUpdatePatient] name+DOB fallback: found id=${patient.id} (insuranceId "${patient.insuranceId}" → "${normalizedId}")`);
}
}
} else {
// No DOB supplied — fall back to insuranceId-only (legacy / non-family plans)
patient = await storage.getPatientByInsuranceId(normalizedId);
@@ -138,6 +147,7 @@ export async function createOrUpdatePatientByInsuranceId(options: {
if (incomingLast && String(patient.lastName ?? "").trim() !== incomingLast)
updates.lastName = incomingLast;
if (dobDate && !patient.dateOfBirth) updates.dateOfBirth = dobDate;
if (String(patient.insuranceId ?? "") !== normalizedId) updates.insuranceId = normalizedId;
if (Object.keys(updates).length > 0) {
console.log(`[createOrUpdatePatient] updating patient id=${patient.id} with`, updates);
await storage.updatePatient(patient.id, updates);
@@ -12,6 +12,7 @@ export interface IStorage {
getPatientByInsuranceId(insuranceId: string): Promise<Patient | null>;
getPatientByInsuranceIdAndDob(insuranceId: string, dob: Date): Promise<Patient | null>;
getPatientByInsuranceIdNoDob(insuranceId: string): Promise<Patient | null>;
getPatientByNameAndDob(firstName: string, lastName: string, dob: Date): Promise<Patient | null>;
getAllPatients(): Promise<Patient[]>;
getRecentPatients(limit: number, offset: number): Promise<Patient[]>;
getPatientsByIds(ids: number[]): Promise<Patient[]>;
@@ -81,6 +82,21 @@ export const patientsStorage: IStorage = {
});
},
async getPatientByNameAndDob(firstName: string, lastName: string, dob: Date): Promise<Patient | null> {
const startOfDay = new Date(dob);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(dob);
endOfDay.setHours(23, 59, 59, 999);
return db.patient.findFirst({
where: {
firstName: { equals: firstName, mode: "insensitive" },
lastName: { equals: lastName, mode: "insensitive" },
dateOfBirth: { gte: startOfDay, lte: endOfDay },
},
orderBy: { updatedAt: "desc" },
});
},
async getRecentPatients(limit: number, offset: number): Promise<Patient[]> {
return db.patient.findMany({
skip: offset,