fix: AI claim queue restart from first patient; recognize D7210 # 32 in notes

- appointments-page: save full queue from current patient on confirm so that
  cancelling the claim form resumes at the same patient instead of skipping it
- claims-page: advance ai_claim_queue only after successful submission (CCA,
  MH selenium PDF download, or direct non-draft submit) via new advanceAiClaimQueue()
- cdt-lookup: fix extractToothSurface regex to allow space between # and digit
  (e.g. "# 32") so tooth number is correctly captured
- cdt-lookup: silently skip bare tooth-number tokens ("# 32", "#32") that the
  LLM may emit as standalone items, preventing false "unrecognized procedure" errors
- internal-chat-graph: add explicit rule that a CDT code followed by # NN
  (e.g. "D7210 # 32") must stay as one entry and not spread to other procedures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 10:31:03 -04:00
parent 029a0e9d53
commit 91adac89e5
4 changed files with 37 additions and 7 deletions

View File

@@ -227,8 +227,8 @@ const DIRECT_CODE_MAP: Record<string, string> = {
* e.g. "extraction #14" → { toothNumber: "14" }
*/
function extractToothSurface(input: string): { toothNumber?: string; toothSurface?: string } {
// Numeric tooth #1-32 (e.g. "#29 OB")
const m = input.match(/#(\d{1,2})(?:\s+([A-Za-z]{1,5}))?/);
// Numeric tooth #1-32 (e.g. "#29 OB", "# 32")
const m = input.match(/#\s*(\d{1,2})(?:\s+([A-Za-z]{1,5}))?/);
if (m) {
const toothNum = parseInt(m[1]!, 10);
if (toothNum >= 1 && toothNum <= 32) {
@@ -418,8 +418,13 @@ export function lookupCdtCodes(
if (phrase && cdtCode) customMap[phrase.toLowerCase().trim()] = cdtCode.toUpperCase().trim();
}
return procedureNames.map((name) => {
return procedureNames.flatMap((name) => {
const cleaned = name.trim().toLowerCase();
// If the LLM emitted a bare tooth-number token like "# 32" or "#32" with no procedure,
// silently skip it — the tooth info was already captured on the preceding CDT code entry.
if (/^#\s*\d{1,2}$/.test(cleaned)) return [];
// Extract tooth# from "#NN" notation — applies to any procedure
const toothInfo = extractToothSurface(name);
// Strip tooth notation for alias matching:

View File

@@ -141,6 +141,10 @@ Rules:
e.g. "#14 rct, buildup, crown" → ["#14 rct", "#14 buildup", "#14 crown"]
- For RCT/root canal with a tooth number, preserve the tooth# in the entry:
e.g. "rct #29", "#14 root canal", "rct #6", "#20 rct" — keep the #number with the procedure so the correct code can be selected
- For a CDT code (D####) followed by a tooth number (# NN or #NN), keep them together as ONE entry and do NOT spread the tooth# to other procedures:
e.g. "Limited exam, PANO, D7210 # 32" → ["Limited exam", "PANO", "D7210 # 32"]
e.g. "D7210 #32, D0140, pano" → ["D7210 #32", "D0140", "pano"]
The tooth# after a CDT code belongs only to that procedure.
- For SRP with a quadrant abbreviation (UL, UR, LL, LR), keep the code and quadrant together as one entry:
e.g. "D4341 UL", "4341 LR", "D4342 UR" — the quadrant always travels with the SRP code
- For multiple PA X-rays with tooth numbers, expand each PA into its own entry:

View File

@@ -1577,11 +1577,11 @@ export default function AppointmentsPage() {
const handleAiClaimConfirm = () => {
if (!aiClaimCurrentData) return;
const { matchedCodes, siteKey, serviceDate, appointmentId } = aiClaimCurrentData;
const nextIndex = aiClaimCurrentIndex + 1;
const remaining = aiClaimQueue.slice(nextIndex);
if (remaining.length > 0) {
// Include current patient in queue; claims-page advances it only on successful submit.
const fromCurrent = aiClaimQueue.slice(aiClaimCurrentIndex);
if (fromCurrent.length > 0) {
sessionStorage.setItem("ai_claim_queue", JSON.stringify({
appointments: remaining,
appointments: fromCurrent,
date: formattedSelectedDate,
pendingResume: true,
}));

View File

@@ -142,6 +142,7 @@ export default function ClaimsPage() {
// CCA result: pdfFileId is already saved by the processor — open preview directly
if (jobResult.pdfFileId) {
advanceAiClaimQueue();
setPreviewPdfId(jobResult.pdfFileId);
setPreviewFallbackFilename(jobResult.pdfFilename ?? `cca_claim_${jobResult.claimNumber ?? "unknown"}.pdf`);
setPreviewOpen(true);
@@ -332,6 +333,23 @@ export default function ClaimsPage() {
}
};
// Advance the ai_claim_queue by removing the first item (current patient).
// Called only on successful submission so that cancel leaves the queue intact.
const advanceAiClaimQueue = () => {
try {
const raw = sessionStorage.getItem("ai_claim_queue");
if (!raw) return;
const parsed = JSON.parse(raw);
if (!parsed?.pendingResume || !Array.isArray(parsed.appointments)) return;
const [, ...rest] = parsed.appointments;
if (rest.length > 0) {
sessionStorage.setItem("ai_claim_queue", JSON.stringify({ ...parsed, appointments: rest }));
} else {
sessionStorage.removeItem("ai_claim_queue");
}
} catch {}
};
// 3. create or update claim (update when claimId is present)
const handleClaimSubmit = async (claimData: any): Promise<Claim> => {
const { isDraft, claimId, uploadedFiles: _uf, ...cleanData } = claimData;
@@ -352,6 +370,7 @@ export default function ClaimsPage() {
const data = await res.json();
if (!res.ok) throw new Error(data?.message || "Failed to save claim");
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
if (!isDraft) advanceAiClaimQueue();
return data;
};
@@ -865,6 +884,8 @@ export default function ClaimsPage() {
duration: isPreAuth ? 10000 : 5000,
});
if (!isPreAuth) advanceAiClaimQueue();
// Pop up the final PDF so the user doesn't need to navigate to Documents
if (result.pdfFileId) {
setPreviewPdfId(result.pdfFileId);