From 30fe43fbf615fc90916724a4d57a2886df14bb25 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 8 Jul 2026 14:36:23 +0000 Subject: [PATCH] feat(imageright): two-pass metadata + content-inspection audit model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-pass, exact-match-only audit with the two-pass model from imageright_claim_review_audit_methodology.md, applied across all 10 SHAPE checklist items: - TaskAudit.status (3-state) -> TaskAuditClassification (7-state): PASS, PASS_WITH_NAMING_EXCEPTION, MANUAL_REVIEW, FAIL_MISSING, FAIL_LATE, FAIL_WRONG_ARTIFACT, NOT_APPLICABLE. New columns for confidence score, evidence bucket, naming-exception/manual-review flags, msg subject/sent date, attachment filenames, a human-readable evidence summary, and the full scoring signal trail (matched_signals). - New audit-scoring.ts: weighted metadata + content scoring per the methodology's table, generalized to use each spec item's own keywords/docTypes rather than a hardcoded claim-specific list. - New content-inspector.ts: downloads and parses `.msg` (subject, sender, sent date, body, attachments via @kenjiuno/msgreader), `.xlsx` (sheet names/cell text via xlsx), and `.pdf` (text-layer only via pdf-parse, no OCR) — recursing one level into email attachments. - ImageRightClient: new getPageImageContent() using the v2 REST API (`/api/v2/pages/{pageId}/images/{imageId}/{version}`) — the only endpoint that returns raw file bytes; v1 only exposes JSON metadata. Verified live against a real .msg (correct OLE2 signature, exact byte-size match). - audit-matching.ts: matchesKeywords is now plural/singular-tolerant (normalizeForMatch) — fixes false negatives like "Open Claims Review" not matching keyword "CLAIM REVIEW". Added docTypesToExtensions, mapping the spec's generic doc_types labels to real file extensions (evidence shows these labels describe file format, not ImageRight's document-type taxonomy). - audit-engine.ts: auditSpecItem() replaces findBestMatch() — scores every in-window candidate, falls back to strict exact-match (fast path, no content download) when possible, otherwise deep-inspects the top candidate and only confirms PASS_WITH_NAMING_EXCEPTION when content positively confirms (not merely "score didn't decrease"). Also fixes a real accuracy bug: unscoped whole-file document search silently truncates at ~1000 docs on high-volume accounts (verified live) — ALL_TIME spec items now iterate every folder instead (findDocumentsSafe). - UI/API updated for the new classification taxonomy and evidence fields. Verified end-to-end live against the methodology doc's own worked example (American Marine Express, Inc., IR document 12884779): real API calls, real .msg download/parse (found both a signature image and the actual Excel attachment), correctly classified PASS_WITH_NAMING_EXCEPTION. 68 new/updated tests covering scoring, content parsing (real xlsx bytes; mocked msgreader/pdf-parse), fuzzy keyword matching, and all 6+1 classification outcomes via auditSpecItem with a fake ImageRight client. Known follow-up (not resolved here): this client's stored Client.renewalDate (2026-10-07) and PolicyGroup renewal date (2027-03-02) don't match the 2026-04-04 renewal date used in the methodology doc's own example — worth reconciling separately, since it determines which target-date windows the live app actually computes for this client. --- ondeck/package-lock.json | 448 ++++++++++++++---- ondeck/package.json | 5 +- ondeck/prisma/schema.prisma | 56 ++- .../components/clients/task-audit-panel.tsx | 47 +- .../__tests__/audit-decision-logic.test.ts | 206 ++++++++ .../imageright/__tests__/audit-engine.test.ts | 42 ++ .../__tests__/audit-scoring.test.ts | 99 ++++ .../__tests__/content-inspector.test.ts | 103 ++++ ondeck/src/lib/imageright/audit-engine.ts | 380 ++++++++++++--- ondeck/src/lib/imageright/audit-matching.ts | 56 ++- ondeck/src/lib/imageright/audit-scoring.ts | 102 ++++ ondeck/src/lib/imageright/client.ts | 17 + .../src/lib/imageright/content-inspector.ts | 92 ++++ 13 files changed, 1465 insertions(+), 188 deletions(-) create mode 100644 ondeck/src/lib/imageright/__tests__/audit-decision-logic.test.ts create mode 100644 ondeck/src/lib/imageright/__tests__/audit-scoring.test.ts create mode 100644 ondeck/src/lib/imageright/__tests__/content-inspector.test.ts create mode 100644 ondeck/src/lib/imageright/audit-scoring.ts create mode 100644 ondeck/src/lib/imageright/content-inspector.ts diff --git a/ondeck/package-lock.json b/ondeck/package-lock.json index 6f56825..9033ccb 100644 --- a/ondeck/package-lock.json +++ b/ondeck/package-lock.json @@ -1,17 +1,18 @@ { - "name": "ondeck", + "name": "horizon", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ondeck", + "name": "horizon", "version": "0.1.0", "dependencies": { "@auth/prisma-adapter": "^2.11.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@kenjiuno/msgreader": "^1.28.0", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "@radix-ui/react-avatar": "^1.1.11", @@ -30,13 +31,15 @@ "next-auth": "^4.24.13", "next-themes": "^0.4.6", "node-cron": "^4.2.1", + "pdf-parse": "^2.4.5", "pg": "^8.17.1", "prisma": "^7.2.0", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -98,89 +101,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, - "node_modules/@auth/core": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.3.tgz", - "integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==", - "optional": true, - "peer": true, - "dependencies": { - "@panva/hkdf": "^1.1.1", - "@types/cookie": "0.6.0", - "cookie": "0.6.0", - "jose": "^5.1.3", - "oauth4webapi": "^2.10.4", - "preact": "10.11.3", - "preact-render-to-string": "5.2.3" - }, - "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^7" - }, - "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, - "node_modules/@auth/core/node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@auth/core/node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/@auth/core/node_modules/preact": { - "version": "10.11.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", - "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/@auth/core/node_modules/preact-render-to-string": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", - "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", - "optional": true, - "peer": true, - "dependencies": { - "pretty-format": "^3.8.0" - }, - "peerDependencies": { - "preact": ">=10" - } - }, - "node_modules/@auth/core/node_modules/pretty-format": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", - "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", - "optional": true, - "peer": true - }, "node_modules/@auth/prisma-adapter": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz", @@ -2945,6 +2865,25 @@ "integrity": "sha512-3zwefSMwHpu8iVUW8YYz227sIv6UFqO31p1Bf1ZH/Vom7CmNyUsXjDBlnNzcuhmOL1XfxZ3nvND42kR23XlbcQ==", "license": "BSD-3-Clause" }, + "node_modules/@kenjiuno/decompressrtf": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@kenjiuno/decompressrtf/-/decompressrtf-0.1.4.tgz", + "integrity": "sha512-v9c/iFz17jRWyd2cRnrvJg4VOg/4I/VCk+bG8JnoX2gJ9sAesPzo3uTqcmlVXdpasTI8hChpBVw00pghKe3qTQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@kenjiuno/msgreader": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@kenjiuno/msgreader/-/msgreader-1.28.0.tgz", + "integrity": "sha512-+iv2rWCGRHmX/3sBwXZzkThEuuywGJjnYsvxj6Kp1L/FDMICQcFrtqN+6MFrnh2d+umtfGtX904wxaYEDZ52MQ==", + "license": "Apache-2.0", + "dependencies": { + "@kenjiuno/decompressrtf": "^0.1.3", + "iconv-lite": "^0.6.3" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/@mrleebo/prisma-ast": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.12.1.tgz", @@ -2958,6 +2897,190 @@ "node": ">=16" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -7158,13 +7281,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "optional": true, - "peer": true - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -7910,6 +8026,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -8594,6 +8719,19 @@ } ] }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8775,6 +8913,15 @@ "node": ">= 0.12.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", @@ -8843,6 +8990,18 @@ "node": ">= 0.6" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -10193,6 +10352,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -10684,7 +10852,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -13390,16 +13557,6 @@ "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" }, - "node_modules/oauth4webapi": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", - "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -13790,6 +13947,38 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -15196,6 +15385,18 @@ "node": ">= 0.6" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -16369,6 +16570,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -16512,6 +16731,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/ondeck/package.json b/ondeck/package.json index 0b29003..466a43d 100644 --- a/ondeck/package.json +++ b/ondeck/package.json @@ -22,6 +22,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@kenjiuno/msgreader": "^1.28.0", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "@radix-ui/react-avatar": "^1.1.11", @@ -40,13 +41,15 @@ "next-auth": "^4.24.13", "next-themes": "^0.4.6", "node-cron": "^4.2.1", + "pdf-parse": "^2.4.5", "pg": "^8.17.1", "prisma": "^7.2.0", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/ondeck/prisma/schema.prisma b/ondeck/prisma/schema.prisma index 4a74ce4..41a3505 100644 --- a/ondeck/prisma/schema.prisma +++ b/ondeck/prisma/schema.prisma @@ -291,9 +291,15 @@ enum DepartmentType { OTHER } -enum TaskAuditStatus { - COMPLETE - INCOMPLETE +/// Two-pass audit classification, per imageright_claim_review_audit_methodology.md. +/// Separates substantive filing compliance from filename/naming-convention compliance. +enum TaskAuditClassification { + PASS + PASS_WITH_NAMING_EXCEPTION + MANUAL_REVIEW + FAIL_MISSING + FAIL_LATE + FAIL_WRONG_ARTIFACT NOT_APPLICABLE } @@ -388,19 +394,37 @@ model TaskAssignment { /// linked to an existing generated Task, but not required — a spec item with no /// matching Task is still auditable and surfaces as a gap. model TaskAudit { - id String @id @default(cuid()) - clientId String @map("client_id") - taskId String? @map("task_id") - specItemKey String @map("spec_item_key") - targetDate DateTime @map("target_date") - status TaskAuditStatus - matchedDocId String? @map("matched_doc_id") - matchedDocName String? @map("matched_doc_name") - matchedDocDate DateTime? @map("matched_doc_date") - folderChecked String? @map("folder_checked") - errorMessage String? @map("error_message") @db.Text - runBy String? @map("run_by") - runAt DateTime @default(now()) @map("run_at") + id String @id @default(cuid()) + clientId String @map("client_id") + taskId String? @map("task_id") + specItemKey String @map("spec_item_key") + targetDate DateTime @map("target_date") + classification TaskAuditClassification + /// 0-100 metadata confidence score (audit-scoring.ts); null when short-circuited (e.g. no file found). + confidenceScore Int? @map("confidence_score") + /// "high" | "medium" | "low" | "none" — bucketed from confidenceScore. + evidenceConfidence String? @map("evidence_confidence") + /// Did the matched artifact's own filename/type match the spec's expected pattern exactly? + filenamePatternCompliant Boolean? @map("filename_pattern_compliant") + /// True when classification is PASS_WITH_NAMING_EXCEPTION. + namingException Boolean? @map("naming_exception") + manualReviewRequired Boolean? @map("manual_review_required") + matchedDocId String? @map("matched_doc_id") + matchedDocName String? @map("matched_doc_name") + matchedDocDate DateTime? @map("matched_doc_date") + /// Subject of the .msg, when the matched artifact (or its container) was an email. + msgSubject String? @map("msg_subject") + msgSentDate DateTime? @map("msg_sent_date") + /// Filenames of attachments found inside a matched .msg container. + attachmentFilenames String[] @map("attachment_filenames") + /// Human-readable summary of what content evidence was found and why (for the audit report). + contentEvidenceSummary String? @map("content_evidence_summary") @db.Text + /// Ordered list of {signal, score, detail} scoring reasons, for the audit trail. + matchedSignals Json? @map("matched_signals") + folderChecked String? @map("folder_checked") + errorMessage String? @map("error_message") @db.Text + runBy String? @map("run_by") + runAt DateTime @default(now()) @map("run_at") client Client @relation(fields: [clientId], references: [id], onDelete: Cascade) task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull) diff --git a/ondeck/src/components/clients/task-audit-panel.tsx b/ondeck/src/components/clients/task-audit-panel.tsx index d6bb66f..cfd96d4 100644 --- a/ondeck/src/components/clients/task-audit-panel.tsx +++ b/ondeck/src/components/clients/task-audit-panel.tsx @@ -8,15 +8,31 @@ import { FileSearch, RefreshCw } from 'lucide-react' import { formatDate } from '@/lib/utils' import { toast } from 'sonner' +type TaskAuditClassification = + | 'PASS' + | 'PASS_WITH_NAMING_EXCEPTION' + | 'MANUAL_REVIEW' + | 'FAIL_MISSING' + | 'FAIL_LATE' + | 'FAIL_WRONG_ARTIFACT' + | 'NOT_APPLICABLE' + interface TaskAuditItem { id?: string specItemKey: string task?: string targetDate: string - status: 'COMPLETE' | 'INCOMPLETE' | 'NOT_APPLICABLE' + classification: TaskAuditClassification + confidenceScore?: number | null + evidenceConfidence?: string | null + namingException?: boolean | null + manualReviewRequired?: boolean | null matchedDocId?: string | null matchedDocName?: string | null matchedDocDate?: string | null + msgSubject?: string | null + attachmentFilenames?: string[] | null + contentEvidenceSummary?: string | null folderChecked?: string | null errorMessage?: string | null runAt?: string @@ -24,14 +40,22 @@ interface TaskAuditItem { } const STATUS_STYLES: Record = { - COMPLETE: 'bg-green-500/15 text-green-700 dark:text-green-400', - INCOMPLETE: 'bg-red-500/15 text-red-700 dark:text-red-400', + PASS: 'bg-green-500/15 text-green-700 dark:text-green-400', + PASS_WITH_NAMING_EXCEPTION: 'bg-amber-500/15 text-amber-700 dark:text-amber-400', + MANUAL_REVIEW: 'bg-yellow-500/15 text-yellow-800 dark:text-yellow-400', + FAIL_MISSING: 'bg-red-500/15 text-red-700 dark:text-red-400', + FAIL_LATE: 'bg-red-500/15 text-red-700 dark:text-red-400', + FAIL_WRONG_ARTIFACT: 'bg-red-500/15 text-red-700 dark:text-red-400', NOT_APPLICABLE: 'bg-muted text-muted-foreground', } const STATUS_LABELS: Record = { - COMPLETE: 'Complete', - INCOMPLETE: 'Incomplete', + PASS: 'Pass', + PASS_WITH_NAMING_EXCEPTION: 'Pass (naming exception)', + MANUAL_REVIEW: 'Manual review', + FAIL_MISSING: 'Fail — missing', + FAIL_LATE: 'Fail — late', + FAIL_WRONG_ARTIFACT: 'Fail — wrong artifact', NOT_APPLICABLE: 'N/A', } @@ -147,11 +171,20 @@ export function TaskAuditPanel({ clientId }: { clientId: string }) { {item.matchedDocId ? ` · IR doc ${item.matchedDocId}` : ''}

)} - {item.errorMessage && item.status !== 'COMPLETE' && ( + {item.msgSubject && ( +

Email subject: {item.msgSubject}

+ )} + {!!item.attachmentFilenames?.length && ( +

Attachments: {item.attachmentFilenames.join(', ')}

+ )} + {item.contentEvidenceSummary && ( +

{item.contentEvidenceSummary}

+ )} + {item.errorMessage && item.classification !== 'PASS' && (

{item.errorMessage}

)} - {STATUS_LABELS[item.status]} + {STATUS_LABELS[item.classification]} ))} diff --git a/ondeck/src/lib/imageright/__tests__/audit-decision-logic.test.ts b/ondeck/src/lib/imageright/__tests__/audit-decision-logic.test.ts new file mode 100644 index 0000000..c16f687 --- /dev/null +++ b/ondeck/src/lib/imageright/__tests__/audit-decision-logic.test.ts @@ -0,0 +1,206 @@ +// audit-engine.ts imports the real Prisma client at module scope (for runTaskAudit, +// unrelated to auditSpecItem itself) — mock it out so importing the module in tests +// doesn't try to instantiate a real DB connection. +jest.mock('@/lib/db', () => ({ prisma: {} })) + +jest.mock('../content-inspector', () => ({ + inspectContent: jest.fn(), +})) + +import { auditSpecItem } from '../audit-engine' +import { inspectContent } from '../content-inspector' +import { SHAPE_AUDIT_SPEC } from '../shape-audit-spec' +import type { ImageRightClient } from '../client' +import type { ImageRightFolder } from '../audit-matching' + +const mockInspectContent = inspectContent as jest.Mock + +/** Folder tree shaped like a real getSortedFolders response (Passavant Memorial Homes / AMEX-style). */ +const SAMPLE_FOLDERS: ImageRightFolder[] = [ + { id: 12179859, parentFolderId: null, folderTypeName: 'Policy Term', folderTypeDescription: 'Policy Term - Policy Term', description: '2026' }, + { id: 12658406, parentFolderId: 12179859, folderTypeName: 'Submission/Quote', folderTypeDescription: 'Submission/Quote - Submission/Quote', description: 'Submission/Quote' }, +] + +const claimReviewSpec = SHAPE_AUDIT_SPEC.find((s) => s.key === 'claim_review_180')! +const files = [{ id: 10820357 }] +const targetDate = new Date('2026-04-04T00:00:00Z') +const renewalYear = 2026 + +interface FakeClientOverrides { + findDocuments?: jest.Mock + getDocumentPages?: jest.Mock + getPageImageContent?: jest.Mock +} + +function makeFakeClient(overrides: FakeClientOverrides = {}) { + return { + getSortedFolders: jest.fn().mockResolvedValue(SAMPLE_FOLDERS), + findDocuments: overrides.findDocuments ?? jest.fn().mockResolvedValue([]), + getDocumentPages: overrides.getDocumentPages ?? jest.fn().mockResolvedValue([]), + getPageImageContent: overrides.getPageImageContent ?? jest.fn().mockResolvedValue(Buffer.from('')), + } as unknown as ImageRightClient +} + +beforeEach(() => { + mockInspectContent.mockReset() +}) + +describe('auditSpecItem — exact match', () => { + it('classifies PASS when doc type and keyword both match exactly, without deep inspection', async () => { + const findDocuments = jest.fn().mockResolvedValue([ + { + id: '999', + description: 'Quarterly Claim Summary', + documentTypeDescription: 'Excel Doc - Excel Doc', + documentDate: '2026-04-29T00:00:00', + }, + ]) + const client = makeFakeClient({ findDocuments }) + + const { item } = await auditSpecItem(client, files as any, claimReviewSpec, targetDate, renewalYear) + + expect(item.classification).toBe('PASS') + expect(item.filenamePatternCompliant).toBe(true) + expect(item.matchedDocId).toBe('999') + expect(mockInspectContent).not.toHaveBeenCalled() + }) +}) + +describe('auditSpecItem — pass with naming exception (American Marine Express case)', () => { + it('classifies PASS_WITH_NAMING_EXCEPTION when content confirms a mismatched artifact', async () => { + const findDocuments = jest.fn().mockResolvedValue([ + { + id: '12884779', + description: 'Open Claims Review - April 2026', + documentTypeDescription: 'Pre-Renewal Information - Pre-Renewal Information', + documentDate: '2026-04-29T00:00:00', + }, + ]) + const getDocumentPages = jest.fn().mockResolvedValue([ + { + id: 13373335, + description: 'American Marine Express - Open Claim Summaries - April 2026.msg', + latestImages: { imageMetadata: [{ id: 9979203, version: 0, extension: 'msg' }] }, + }, + ]) + const getPageImageContent = jest.fn().mockResolvedValue(Buffer.from('fake-msg-bytes')) + mockInspectContent.mockImplementation(async (_buffer: Buffer, ext: string) => { + if (ext === 'msg') { + return { + extension: 'msg', + subject: 'American Marine Express - Open Claim Summaries - April 2026', + sender: 'Jeanne M. Strong', + bodyText: 'Attached please find the open claim summaries that we discussed this morning.', + attachments: [ + { filename: 'American Marine Express - Open Claims - April 2026.xlsx', extension: 'xlsx', buffer: Buffer.from('x') }, + ], + } + } + if (ext === 'xlsx') { + return { extension: 'xlsx', sheets: [{ name: 'Auto - DB Insurance', text: 'Date of Loss,Claim Number,Paid,Reserve' }] } + } + return { extension: ext } + }) + + const client = makeFakeClient({ findDocuments, getDocumentPages, getPageImageContent }) + const { item } = await auditSpecItem(client, files as any, claimReviewSpec, targetDate, renewalYear) + + expect(item.classification).toBe('PASS_WITH_NAMING_EXCEPTION') + expect(item.namingException).toBe(true) + expect(item.filenamePatternCompliant).toBe(false) + expect(item.matchedDocId).toBe('12884779') + expect(item.msgSubject).toBe('American Marine Express - Open Claim Summaries - April 2026') + expect(item.attachmentFilenames).toEqual(['American Marine Express - Open Claims - April 2026.xlsx']) + expect(item.contentEvidenceSummary).toContain('naming exception') + }) +}) + +describe('auditSpecItem — manual review', () => { + it('classifies MANUAL_REVIEW when only weak metadata signals exist and content adds nothing', async () => { + const findDocuments = jest.fn().mockResolvedValue([ + { + id: '555', + description: 'Miscellaneous note', + documentTypeDescription: 'Excel Doc - Excel Doc', // matches doc type only (+10), not keywords + documentDate: '2026-04-29T00:00:00', + }, + ]) + const getDocumentPages = jest.fn().mockResolvedValue([ + { + id: 1, + description: 'note.pdf', + latestImages: { imageMetadata: [{ id: 2, version: 0, extension: 'pdf' }] }, + }, + ]) + const client = makeFakeClient({ findDocuments, getDocumentPages }) + + const { item } = await auditSpecItem(client, files as any, claimReviewSpec, targetDate, renewalYear) + + expect(item.classification).toBe('MANUAL_REVIEW') + expect(item.manualReviewRequired).toBe(true) + // Score (10 doc-type + 10 extension-plausible = 20) stays "low" — no deep inspection triggered. + expect(mockInspectContent).not.toHaveBeenCalled() + }) +}) + +describe('auditSpecItem — fail wrong artifact', () => { + it('classifies FAIL_WRONG_ARTIFACT when strong metadata pointed to a candidate but content disproves it', async () => { + const findDocuments = jest.fn().mockResolvedValue([ + { + id: '777', + description: 'Open Claims Review - April 2026', // description keyword match (+40) + documentTypeDescription: 'Correspondence', // no doc type match + documentDate: '2026-04-29T00:00:00', + }, + ]) + const getDocumentPages = jest.fn().mockResolvedValue([ + { + id: 1, + // filename also matches a keyword (+25) and plausible extension (+10) -> preContentScore = 75 (high) + description: 'Claim Review notes.pdf', + latestImages: { imageMetadata: [{ id: 2, version: 0, extension: 'pdf' }] }, + }, + ]) + mockInspectContent.mockResolvedValue({ extension: 'pdf', pdfText: 'Totally unrelated content about office supplies.' }) + const client = makeFakeClient({ findDocuments, getDocumentPages }) + + const { item } = await auditSpecItem(client, files as any, claimReviewSpec, targetDate, renewalYear) + + expect(item.classification).toBe('FAIL_WRONG_ARTIFACT') + expect(mockInspectContent).toHaveBeenCalled() + }) +}) + +describe('auditSpecItem — fail missing', () => { + it('classifies FAIL_MISSING when no candidate exists anywhere', async () => { + const client = makeFakeClient({ findDocuments: jest.fn().mockResolvedValue([]) }) + const { item } = await auditSpecItem(client, files as any, claimReviewSpec, targetDate, renewalYear) + expect(item.classification).toBe('FAIL_MISSING') + }) + + it('classifies NOT_APPLICABLE instead, for conditional items with no candidate', async () => { + const captiveSpec = SHAPE_AUDIT_SPEC.find((s) => s.key === 'captive_claims_worksheet')! + const client = makeFakeClient({ findDocuments: jest.fn().mockResolvedValue([]) }) + const { item } = await auditSpecItem(client, files as any, captiveSpec, targetDate, renewalYear) + expect(item.classification).toBe('NOT_APPLICABLE') + }) +}) + +describe('auditSpecItem — fail late', () => { + it('classifies FAIL_LATE when a matching candidate exists only outside the date window', async () => { + const findDocuments = jest.fn().mockResolvedValue([ + { + id: '333', + description: 'Quarterly Claim Summary', + documentTypeDescription: 'Excel Doc - Excel Doc', + documentDate: '2025-01-01T00:00:00', // far outside the +/-30 day window around 2026-04-04 + }, + ]) + const client = makeFakeClient({ findDocuments }) + + const { item } = await auditSpecItem(client, files as any, claimReviewSpec, targetDate, renewalYear) + + expect(item.classification).toBe('FAIL_LATE') + expect(item.matchedDocId).toBe('333') + }) +}) diff --git a/ondeck/src/lib/imageright/__tests__/audit-engine.test.ts b/ondeck/src/lib/imageright/__tests__/audit-engine.test.ts index f76e53c..43a191a 100644 --- a/ondeck/src/lib/imageright/__tests__/audit-engine.test.ts +++ b/ondeck/src/lib/imageright/__tests__/audit-engine.test.ts @@ -2,6 +2,8 @@ import { computeWindow, matchesKeywords, matchesDocType, + normalizeForMatch, + docTypesToExtensions, findPolicyTermFolder, findChildFolder, type ImageRightFolder, @@ -49,6 +51,46 @@ describe('matchesKeywords', () => { it('returns true when keyword list is empty (doc type alone suffices)', () => { expect(matchesKeywords('Anything', [])).toBe(true) }) + + it('tolerates plural vs singular mismatches (real case: "Open Claims Review" vs "CLAIM REVIEW")', () => { + expect(matchesKeywords('Open Claims Review - April 2026', ['CLAIM REVIEW'])).toBe(true) + }) + + it('tolerates plural vs singular mismatches ("Open Claim Summaries" vs "OPEN CLAIM SUMMARY")', () => { + expect(matchesKeywords('American Marine Express - Open Claim Summaries - April 2026', ['OPEN CLAIM SUMMARY'])).toBe( + true + ) + }) +}) + +describe('normalizeForMatch', () => { + it('lowercases, strips punctuation, and singularizes each word', () => { + expect(normalizeForMatch('Open Claims Review - April 2026')).toBe('open claim review april 2026') + }) + + it('does not mangle short words or words ending in "ss"/"us"', () => { + expect(normalizeForMatch('Loss Bus Pass')).toBe('loss bus pass') + }) +}) + +describe('docTypesToExtensions', () => { + it('maps spec doc type labels to real file extensions', () => { + expect(docTypesToExtensions(['EMAIL'])).toEqual(['msg', 'eml']) + expect(docTypesToExtensions(['EXCEL DOC'])).toEqual(['xlsx', 'xls', 'csv']) + expect(docTypesToExtensions(['PDF'])).toEqual(['pdf']) + }) + + it('strips parenthetical qualifiers before lookup', () => { + expect(docTypesToExtensions(['LOSS RUN (PDF or Excel)'])).toEqual(['pdf', 'xlsx', 'xls']) + }) + + it('dedupes across multiple doc types', () => { + expect(docTypesToExtensions(['EMAIL', 'EXCEL'])).toEqual(['msg', 'eml', 'xlsx', 'xls', 'csv']) + }) + + it('returns an empty array for unrecognized labels', () => { + expect(docTypesToExtensions(['SOMETHING ELSE'])).toEqual([]) + }) }) describe('matchesDocType', () => { diff --git a/ondeck/src/lib/imageright/__tests__/audit-scoring.test.ts b/ondeck/src/lib/imageright/__tests__/audit-scoring.test.ts new file mode 100644 index 0000000..1c1e9d0 --- /dev/null +++ b/ondeck/src/lib/imageright/__tests__/audit-scoring.test.ts @@ -0,0 +1,99 @@ +import { + confidenceFromScore, + scoreDescriptionAndType, + scoreFilenameAndExtension, + scoreContent, + combineScores, +} from '../audit-scoring' +import { SHAPE_AUDIT_SPEC } from '../shape-audit-spec' +import type { InspectedContent } from '../content-inspector' + +const claimReviewSpec = SHAPE_AUDIT_SPEC.find((s) => s.key === 'claim_review_180')! + +describe('confidenceFromScore', () => { + it.each([ + [0, 'none'], + [1, 'low'], + [39, 'low'], + [40, 'medium'], + [69, 'medium'], + [70, 'high'], + [150, 'high'], + ])('%d -> %s', (score, expected) => { + expect(confidenceFromScore(score)).toBe(expected) + }) +}) + +describe('scoreDescriptionAndType', () => { + it('scores a description keyword match (+40) tolerant of plural/singular', () => { + const result = scoreDescriptionAndType(claimReviewSpec, 'Open Claims Review - April 2026', 'Correspondence') + expect(result.signals.find((s) => s.signal === 'description_keyword')?.score).toBe(40) + }) + + it('scores a doc type match (+10)', () => { + const result = scoreDescriptionAndType(claimReviewSpec, 'Unrelated', 'Excel Doc - Excel Doc') + expect(result.signals.find((s) => s.signal === 'doc_type_plausible')?.score).toBe(10) + }) + + it('scores 0 when nothing matches', () => { + const result = scoreDescriptionAndType(claimReviewSpec, 'Unrelated', 'Correspondence') + expect(result.score).toBe(0) + }) +}) + +describe('scoreFilenameAndExtension', () => { + it('scores a filename keyword match (+25) and plausible extension (+10)', () => { + const result = scoreFilenameAndExtension( + claimReviewSpec, + 'American Marine Express - Open Claim Summaries - April 2026.msg', + 'msg' + ) + expect(result.signals.find((s) => s.signal === 'filename_keyword')?.score).toBe(25) + expect(result.signals.find((s) => s.signal === 'extension_plausible')?.score).toBe(10) + expect(result.score).toBe(35) + }) +}) + +describe('scoreContent', () => { + it('scores msg subject (+30), body (+30), and picks a matching attachment by expected extension', () => { + const content: InspectedContent = { + extension: 'msg', + subject: 'American Marine Express - Open Claim Summaries - April 2026', + bodyText: 'Attached please find the open claim summaries that we discussed. Our next Claim Review is scheduled.', + attachments: [{ filename: 'Open Claims - April 2026.xlsx', extension: 'xlsx', buffer: Buffer.from('x') }], + } + const result = scoreContent(claimReviewSpec, content) + expect(result.signals.find((s) => s.signal === 'msg_subject_keyword')?.score).toBe(30) + expect(result.signals.find((s) => s.signal === 'msg_body_keyword')?.score).toBe(30) + expect(result.matchedAttachment?.extension).toBe('xlsx') + }) + + it('scores excel sheet content (+40) and pdf text (+30)', () => { + const excelContent: InspectedContent = { + extension: 'xlsx', + sheets: [{ name: 'Sheet1', text: 'Quarterly Claim Summary for policy year 2026' }], + } + expect(scoreContent(claimReviewSpec, excelContent).signals.find((s) => s.signal === 'excel_content_keyword')?.score).toBe(40) + + const pdfContent: InspectedContent = { extension: 'pdf', pdfText: 'This is the Open Claim Summary report.' } + expect(scoreContent(claimReviewSpec, pdfContent).signals.find((s) => s.signal === 'pdf_text_keyword')?.score).toBe(30) + }) + + it('returns 0 when content has no matching signals', () => { + const content: InspectedContent = { + extension: 'xlsx', + sheets: [{ name: 'Sheet1', text: 'Date of Loss, Claim Number, Paid, Reserve' }], + } + expect(scoreContent(claimReviewSpec, content).score).toBe(0) + }) +}) + +describe('combineScores', () => { + it('sums scores and flattens signals across multiple results', () => { + const a = { score: 10, signals: [{ signal: 'a', score: 10 }] } + const b = { score: 20, signals: [{ signal: 'b', score: 20 }] } + const result = combineScores(a, b) + expect(result.score).toBe(30) + expect(result.signals).toHaveLength(2) + }) +}) diff --git a/ondeck/src/lib/imageright/__tests__/content-inspector.test.ts b/ondeck/src/lib/imageright/__tests__/content-inspector.test.ts new file mode 100644 index 0000000..51ead06 --- /dev/null +++ b/ondeck/src/lib/imageright/__tests__/content-inspector.test.ts @@ -0,0 +1,103 @@ +import * as XLSX from 'xlsx' + +jest.mock('@kenjiuno/msgreader', () => { + return { + __esModule: true, + default: jest.fn(), + } +}) + +jest.mock('pdf-parse', () => ({ + PDFParse: jest.fn(), +})) + +import MsgReader from '@kenjiuno/msgreader' +import { PDFParse } from 'pdf-parse' +import { inspectMsg, inspectXlsx, inspectPdf, inspectContent, extensionOf } from '../content-inspector' + +describe('extensionOf', () => { + it('extracts a lowercase extension', () => { + expect(extensionOf('Report.XLSX')).toBe('xlsx') + }) + it('returns empty string when there is no extension', () => { + expect(extensionOf('noext')).toBe('') + }) +}) + +describe('inspectXlsx', () => { + it('extracts sheet names and flattened cell text from a real workbook', () => { + const wb = XLSX.utils.book_new() + const sheet1 = XLSX.utils.aoa_to_sheet([ + ['Date of Loss', 'Claim Number', 'Paid', 'Reserve'], + ['1/2/2026', 'CL-001', '100', '50'], + ]) + XLSX.utils.book_append_sheet(wb, sheet1, 'Auto - DB Insurance') + const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer + + const result = inspectXlsx(buffer) + expect(result.extension).toBe('xlsx') + expect(result.sheets).toHaveLength(1) + expect(result.sheets![0].name).toBe('Auto - DB Insurance') + expect(result.sheets![0].text).toContain('Date of Loss') + expect(result.sheets![0].text).toContain('Claim Number') + }) +}) + +describe('inspectMsg', () => { + it('maps MsgReader output to InspectedContent, converting attachment content to Buffer', () => { + const fakeInfo = { + subject: 'American Marine Express - Open Claim Summaries - April 2026', + senderName: 'Jeanne M. Strong', + messageDeliveryTime: '2026-04-29T16:36:14.820Z', + body: 'Attached please find the open claim summaries that we discussed this morning.', + attachments: [{ dataId: 1, fileName: 'American Marine Express - Open Claims - April 2026.xlsx' }], + } + const fakeAttachmentContent = new Uint8Array([1, 2, 3]) + ;(MsgReader as unknown as jest.Mock).mockImplementation(() => ({ + getFileData: () => fakeInfo, + getAttachment: () => ({ fileName: fakeInfo.attachments[0].fileName, content: fakeAttachmentContent }), + })) + + const result = inspectMsg(Buffer.from('fake-msg-bytes')) + + expect(result.extension).toBe('msg') + expect(result.subject).toBe(fakeInfo.subject) + expect(result.sender).toBe('Jeanne M. Strong') + expect(result.sentDate?.toISOString()).toBe('2026-04-29T16:36:14.820Z') + expect(result.bodyText).toContain('open claim summaries') + expect(result.attachments).toHaveLength(1) + expect(result.attachments![0].filename).toBe('American Marine Express - Open Claims - April 2026.xlsx') + expect(result.attachments![0].extension).toBe('xlsx') + expect(Buffer.isBuffer(result.attachments![0].buffer)).toBe(true) + }) +}) + +describe('inspectPdf', () => { + it('extracts text and calls destroy()', async () => { + const destroy = jest.fn().mockResolvedValue(undefined) + const getText = jest.fn().mockResolvedValue({ text: 'Loss Run Report' }) + ;(PDFParse as unknown as jest.Mock).mockImplementation(() => ({ getText, destroy })) + + const result = await inspectPdf(Buffer.from('fake-pdf-bytes')) + + expect(result.extension).toBe('pdf') + expect(result.pdfText).toBe('Loss Run Report') + expect(destroy).toHaveBeenCalled() + }) + + it('returns empty text instead of throwing when parsing fails', async () => { + const destroy = jest.fn().mockResolvedValue(undefined) + const getText = jest.fn().mockRejectedValue(new Error('PasswordException')) + ;(PDFParse as unknown as jest.Mock).mockImplementation(() => ({ getText, destroy })) + + const result = await inspectPdf(Buffer.from('fake-pdf-bytes')) + expect(result.pdfText).toBe('') + }) +}) + +describe('inspectContent dispatch', () => { + it('returns a bare shell for unsupported extensions', async () => { + const result = await inspectContent(Buffer.from('x'), 'docx') + expect(result).toEqual({ extension: 'docx' }) + }) +}) diff --git a/ondeck/src/lib/imageright/audit-engine.ts b/ondeck/src/lib/imageright/audit-engine.ts index 20bacfe..09d8094 100644 --- a/ondeck/src/lib/imageright/audit-engine.ts +++ b/ondeck/src/lib/imageright/audit-engine.ts @@ -9,17 +9,48 @@ import { findChildFolder, type ImageRightFolder, } from './audit-matching' +import { + scoreDescriptionAndType, + scoreFilenameAndExtension, + scoreContent, + confidenceFromScore, + type ScoreSignal, +} from './audit-scoring' +import { inspectContent, type InspectedContent } from './content-inspector' const DAY_MS = 24 * 60 * 60 * 1000 +/** + * Two-pass audit classification, per imageright_claim_review_audit_methodology.md. + * Mirrors the `TaskAuditClassification` Prisma enum. + */ +export type TaskAuditClassification = + | 'PASS' + | 'PASS_WITH_NAMING_EXCEPTION' + | 'MANUAL_REVIEW' + | 'FAIL_MISSING' + | 'FAIL_LATE' + | 'FAIL_WRONG_ARTIFACT' + | 'NOT_APPLICABLE' + export interface TaskAuditItemResult { specItemKey: string task: string targetDate: Date - status: 'COMPLETE' | 'INCOMPLETE' | 'NOT_APPLICABLE' + classification: TaskAuditClassification + confidenceScore?: number + evidenceConfidence?: 'high' | 'medium' | 'low' | 'none' + filenamePatternCompliant?: boolean + namingException?: boolean + manualReviewRequired?: boolean matchedDocId?: string matchedDocName?: string matchedDocDate?: Date + msgSubject?: string + msgSentDate?: Date + attachmentFilenames?: string[] + contentEvidenceSummary?: string + matchedSignals?: ScoreSignal[] folderChecked: string errorMessage?: string linkedTaskId?: string @@ -106,35 +137,287 @@ async function resolveParentId( return { parentId: target.id, label, resolved: true } } -async function findBestMatch( +/** + * `findDocuments(fileId)` with no `ParentId` (used for ALL_TIME/"whole file" spec + * items) caps at ~1000 results and silently drops recent documents on high-volume + * accounts (verified live against a real client — returned exactly 1000 docs, none + * newer than a date far short of "now"). Iterate every folder individually instead, + * matching the approach verified to return complete results. + */ +async function findDocumentsSafe( client: ImageRightClient, - files: ImageRightFile[], + fileId: string | number, + parentId: string | number | undefined +): Promise { + if (parentId !== undefined) { + return client.findDocuments(fileId, parentId) + } + const folders = (await client.getSortedFolders(fileId)) as ImageRightFolder[] + const seen = new Map() + for (const candidateParentId of [undefined, ...folders.map((f) => f.id)]) { + const docs = await client.findDocuments(fileId, candidateParentId) + for (const doc of docs) { + const id = docIdOf(doc) + if (!seen.has(id)) seen.set(id, doc) + } + } + return Array.from(seen.values()) +} + +interface ScoredCandidate { + doc: unknown + score: number + signals: ScoreSignal[] + exactMatch: boolean + docDate: Date | null +} + +function inWindow(date: Date | null, start: Date | null, end: Date | null): boolean { + if (!start || !end) return true // ALL_TIME items — no window to satisfy + return !!date && date >= start && date <= end +} + +/** Extract pageId/imageId/version/extension/filename from a `getDocumentPages` element. */ +function pageInfo( + page: unknown +): { pageId: string; imageId: string; version: number; extension: string; filename: string } | null { + const rec = page as Record | null + const images = rec?.latestImages?.imageMetadata + const image = Array.isArray(images) ? images[0] : undefined + if (!rec?.id || !image?.id) return null + return { + pageId: String(rec.id), + imageId: String(image.id), + version: image.version ?? 0, + extension: String(image.extension ?? '').toLowerCase(), + filename: String(rec.description ?? ''), + } +} + +/** Recursively score inspected content plus (one level deep) any inspectable attachments. */ +async function scoreContentDeep( spec: ShapeAuditSpecItem, - windowStart: Date | null, - windowEnd: Date | null, - renewalYear: number -): Promise<{ doc: unknown | null; folderChecked: string }> { - let lastLabel = spec.folderPath.join(' > ') || 'ALL_TIME (whole file)' + content: InspectedContent +): Promise<{ score: number; signals: ScoreSignal[] }> { + const own = scoreContent(spec, content) + const signals = [...own.signals] + let score = own.score - for (const file of files) { - const { parentId, label, resolved } = await resolveParentId(client, file.id, spec.folderPath, renewalYear) - lastLabel = label - if (!resolved) continue - - const candidates = await client.findDocuments(file.id, parentId) - - for (const doc of candidates) { - if (!matchesDocType(docTypeOf(doc), spec.docTypes)) continue - if (!matchesKeywords(docNameOf(doc), spec.keywords)) continue - const docDate = docDateOf(doc) - if (windowStart && windowEnd) { - if (!docDate || docDate < windowStart || docDate > windowEnd) continue - } - return { doc, folderChecked: label } + for (const att of content.attachments ?? []) { + const ext = att.extension.toLowerCase() + if (ext !== 'xlsx' && ext !== 'xls' && ext !== 'pdf') continue + try { + const nested = await inspectContent(att.buffer, ext) + const nestedScore = scoreContent(spec, nested) + signals.push(...nestedScore.signals.map((s) => ({ ...s, signal: `attachment(${att.filename}).${s.signal}` }))) + score += nestedScore.score + } catch { + // Corrupt/unparseable attachment — skip, don't fail the whole audit item. } } - return { doc: null, folderChecked: lastLabel } + return { score, signals } +} + +function buildEvidenceSummary(params: { + task: string + classification: TaskAuditClassification + description: string + filename?: string + docId: string + docDate: Date | null + content?: InspectedContent +}): string { + const { task, classification, description, filename, docId, docDate, content } = params + const dateStr = docDate ? docDate.toISOString().slice(0, 10) : 'unknown date' + const parts = [`ImageRight document ${docId} ("${description}"), filed ${dateStr}.`] + if (filename && filename !== description) parts.push(`Original filed page: "${filename}".`) + if (content?.subject) parts.push(`Email subject: "${content.subject}".`) + if (content?.attachments?.length) { + parts.push(`Attachment(s): ${content.attachments.map((a) => a.filename).join(', ')}.`) + } + if (classification === 'PASS_WITH_NAMING_EXCEPTION') { + parts.push( + `The filing/attachment name does not match the expected naming pattern for "${task}", but content confirms the required work was done — logged as a naming exception, not a missed filing.` + ) + } else if (classification === 'FAIL_WRONG_ARTIFACT') { + parts.push(`Metadata looked like a plausible match for "${task}", but its content did not confirm this.`) + } else if (classification === 'MANUAL_REVIEW') { + parts.push(`Evidence is partial/ambiguous for "${task}" — needs manual confirmation.`) + } + return parts.join(' ') +} + +interface AuditCheckResult { + item: Omit +} + +export async function auditSpecItem( + client: ImageRightClient, + files: ImageRightFile[], + spec: ShapeAuditSpecItem, + targetDate: Date, + renewalYear: number +): Promise { + const { start, end } = computeWindow(spec, targetDate) + const isConditional = !!spec.condition?.toLowerCase().match(/applicable|conditional/) + + let folderChecked = spec.folderPath.join(' > ') || 'ALL_TIME (whole file)' + let bestInWindow: ScoredCandidate | null = null + let bestAnyTime: ScoredCandidate | null = null + + for (const file of files) { + const { parentId, label, resolved } = await resolveParentId(client, file.id, spec.folderPath, renewalYear) + folderChecked = label + if (!resolved) continue + + const docs = await findDocumentsSafe(client, file.id, parentId) + for (const doc of docs) { + const description = docNameOf(doc) + const docType = docTypeOf(doc) + const docDate = docDateOf(doc) + const { score, signals } = scoreDescriptionAndType(spec, description, docType) + const exactMatch = matchesDocType(docType, spec.docTypes) && matchesKeywords(description, spec.keywords) + if (score === 0 && !exactMatch) continue + + const candidate: ScoredCandidate = { doc, score: exactMatch ? score + 100 : score, signals, exactMatch, docDate } + + if (!bestAnyTime || candidate.score > bestAnyTime.score) bestAnyTime = candidate + if (inWindow(docDate, start, end) && (!bestInWindow || candidate.score > bestInWindow.score)) { + bestInWindow = candidate + } + } + } + + const base = { specItemKey: spec.key, task: spec.task, targetDate, folderChecked } + + if (!bestAnyTime) { + return { + item: { + ...base, + classification: isConditional ? 'NOT_APPLICABLE' : 'FAIL_MISSING', + errorMessage: isConditional + ? 'No matching document found; item is conditional — confirm applicability manually' + : undefined, + }, + } + } + + if (!bestInWindow) { + return { + item: { + ...base, + classification: 'FAIL_LATE', + matchedDocId: docIdOf(bestAnyTime.doc), + matchedDocName: docNameOf(bestAnyTime.doc), + matchedDocDate: bestAnyTime.docDate ?? undefined, + confidenceScore: bestAnyTime.score, + evidenceConfidence: confidenceFromScore(bestAnyTime.score), + matchedSignals: bestAnyTime.signals, + errorMessage: 'A plausible match exists, but outside the required date window', + }, + } + } + + const description = docNameOf(bestInWindow.doc) + const docId = docIdOf(bestInWindow.doc) + let signals = [...bestInWindow.signals] + let totalScore = bestInWindow.score + + // Exact strict match (old engine's criteria) — no need for deep inspection. + if (bestInWindow.exactMatch) { + return { + item: { + ...base, + classification: 'PASS', + filenamePatternCompliant: true, + namingException: false, + manualReviewRequired: false, + matchedDocId: docId, + matchedDocName: description, + matchedDocDate: bestInWindow.docDate ?? undefined, + confidenceScore: totalScore, + evidenceConfidence: 'high', + matchedSignals: signals, + }, + } + } + + // Enrich with page/extension info (stage B) — bounded to the single best candidate. + let filename = '' + let extension = '' + let content: InspectedContent | undefined + let preContentScore = totalScore + let deepInspected = false + let contentConfirmed = false + try { + const pages = await client.getDocumentPages(docId) + const info = pageInfo(pages[0]) + if (info) { + filename = info.filename + extension = info.extension + const fn = scoreFilenameAndExtension(spec, filename, extension) + signals = [...signals, ...fn.signals] + totalScore += fn.score + preContentScore = totalScore + + const confidence = confidenceFromScore(totalScore) + if (confidence === 'high' || confidence === 'medium') { + deepInspected = true + const buffer = await client.getPageImageContent(info.pageId, info.imageId, info.version) + content = await inspectContent(buffer, extension) + const deep = await scoreContentDeep(spec, content) + signals = [...signals, ...deep.signals] + totalScore += deep.score + contentConfirmed = deep.score > 0 + } + } + } catch (err: any) { + signals = [...signals, { signal: 'content_inspection_error', score: 0, detail: err.message }] + } + + // Per the methodology's decision logic: only a genuine positive content match (not + // merely "the score didn't decrease") justifies PASS_WITH_NAMING_EXCEPTION. If we had + // to deep-inspect (metadata alone wasn't an exact/strict match) but content confirmed + // nothing, that's a naming/metadata false-positive — Manual review, or Fail - wrong + // artifact when the metadata signal was strong enough to have looked highly plausible. + let classification: TaskAuditClassification + if (deepInspected && contentConfirmed) { + classification = 'PASS_WITH_NAMING_EXCEPTION' + } else if (deepInspected) { + classification = preContentScore >= 70 ? 'FAIL_WRONG_ARTIFACT' : 'MANUAL_REVIEW' + } else { + classification = 'MANUAL_REVIEW' + } + const finalConfidence = confidenceFromScore(totalScore) + + return { + item: { + ...base, + classification, + filenamePatternCompliant: false, + namingException: classification === 'PASS_WITH_NAMING_EXCEPTION', + manualReviewRequired: classification === 'MANUAL_REVIEW', + matchedDocId: docId, + matchedDocName: description, + matchedDocDate: bestInWindow.docDate ?? undefined, + msgSubject: content?.subject, + msgSentDate: content?.sentDate, + attachmentFilenames: content?.attachments?.map((a) => a.filename) ?? [], + contentEvidenceSummary: buildEvidenceSummary({ + task: spec.task, + classification, + description, + filename, + docId, + docDate: bestInWindow.docDate, + content, + }), + confidenceScore: totalScore, + evidenceConfidence: finalConfidence, + matchedSignals: signals, + }, + } } /** Best-effort link to an existing Task: same client, title match, closest dueDate to targetDate. */ @@ -193,7 +476,6 @@ export async function runTaskAudit(clientId: string, runByUserId?: string): Prom for (const spec of SHAPE_AUDIT_SPEC) { const targetDate = new Date(effectiveDate.getTime() + spec.daysAfterRenewal * DAY_MS) - const { start, end } = computeWindow(spec, targetDate) let result: TaskAuditItemResult try { @@ -202,48 +484,20 @@ export async function runTaskAudit(clientId: string, runByUserId?: string): Prom specItemKey: spec.key, task: spec.task, targetDate, - status: 'INCOMPLETE', + classification: 'FAIL_MISSING', folderChecked: spec.folderPath.join(' > ') || 'ALL_TIME (whole file)', errorMessage: 'No ImageRight file found for this client', } } else { - const { doc, folderChecked } = await findBestMatch(irClient, files, spec, start, end, renewalYear) - if (doc) { - result = { - specItemKey: spec.key, - task: spec.task, - targetDate, - status: 'COMPLETE', - matchedDocId: docIdOf(doc), - matchedDocName: docNameOf(doc), - matchedDocDate: docDateOf(doc) ?? undefined, - folderChecked, - } - } else if (spec.condition?.toLowerCase().includes('applicable') || spec.condition?.toLowerCase().includes('conditional')) { - result = { - specItemKey: spec.key, - task: spec.task, - targetDate, - status: 'NOT_APPLICABLE', - folderChecked, - errorMessage: 'No matching document found; item is conditional — confirm applicability manually', - } - } else { - result = { - specItemKey: spec.key, - task: spec.task, - targetDate, - status: 'INCOMPLETE', - folderChecked, - } - } + const { item } = await auditSpecItem(irClient, files, spec, targetDate, renewalYear) + result = item } } catch (err: any) { result = { specItemKey: spec.key, task: spec.task, targetDate, - status: 'INCOMPLETE', + classification: 'MANUAL_REVIEW', folderChecked: spec.folderPath.join(' > ') || 'ALL_TIME (whole file)', errorMessage: `Audit check failed: ${err.message}`, } @@ -259,10 +513,20 @@ export async function runTaskAudit(clientId: string, runByUserId?: string): Prom taskId: item.linkedTaskId ?? null, specItemKey: item.specItemKey, targetDate: item.targetDate, - status: item.status, + classification: item.classification, + confidenceScore: item.confidenceScore ?? null, + evidenceConfidence: item.evidenceConfidence ?? null, + filenamePatternCompliant: item.filenamePatternCompliant ?? null, + namingException: item.namingException ?? null, + manualReviewRequired: item.manualReviewRequired ?? null, matchedDocId: item.matchedDocId ?? null, matchedDocName: item.matchedDocName ?? null, matchedDocDate: item.matchedDocDate ?? null, + msgSubject: item.msgSubject ?? null, + msgSentDate: item.msgSentDate ?? null, + attachmentFilenames: item.attachmentFilenames ?? [], + contentEvidenceSummary: item.contentEvidenceSummary ?? null, + matchedSignals: item.matchedSignals ? (item.matchedSignals as any) : undefined, folderChecked: item.folderChecked, errorMessage: item.errorMessage ?? null, runBy: runByUserId ?? null, diff --git a/ondeck/src/lib/imageright/audit-matching.ts b/ondeck/src/lib/imageright/audit-matching.ts index 621cac8..22481ce 100644 --- a/ondeck/src/lib/imageright/audit-matching.ts +++ b/ondeck/src/lib/imageright/audit-matching.ts @@ -12,10 +12,36 @@ export function computeWindow(spec: ShapeAuditSpecItem, targetDate: Date): { sta } } +/** Naive singularization — good enough to bridge cases like "Claims"/"Claim", "Summaries"/"Summary". */ +function singularizeWord(word: string): string { + if (word.length <= 3) return word + if (/ies$/.test(word)) return word.slice(0, -3) + 'y' + if (/(ss|us)$/.test(word)) return word + if (/(ches|shes|xes|zes)$/.test(word)) return word.slice(0, -2) + if (/s$/.test(word)) return word.slice(0, -1) + return word +} + +/** Lowercase, strip punctuation, and singularize each word — for plural/singular-tolerant matching. */ +export function normalizeForMatch(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter(Boolean) + .map(singularizeWord) + .join(' ') +} + +/** + * Case-insensitive substring match, tolerant of simple plural/singular mismatches + * (e.g. spec keyword "CLAIM REVIEW" matches text "Open Claims Review" — "Claims" vs + * "Claim" would otherwise break a literal substring check). + */ export function matchesKeywords(docName: string, keywords: string[]): boolean { if (keywords.length === 0) return true - const lower = docName.toLowerCase() - return keywords.some((kw) => lower.includes(kw.toLowerCase())) + const normalized = normalizeForMatch(docName) + return keywords.some((kw) => normalized.includes(normalizeForMatch(kw))) } /** Strip parenthetical qualifiers, e.g. "LOSS RUN (PDF or Excel)" -> "LOSS RUN". */ @@ -30,6 +56,32 @@ export function matchesDocType(actualType: string, expectedTypes: string[]): boo return expectedTypes.some((t) => lower.includes(stripParenthetical(t).toLowerCase())) } +/** + * Map a spec `doc_types` label to the file extensions it plausibly refers to. Real + * ImageRight document types (e.g. "Pre-Renewal Information", "Correspondences") don't + * correspond to these labels at all — evidence from a live case (American Marine + * Express's Claim Review) shows the spec's labels actually describe the *underlying + * file format* (an email, spreadsheet, or PDF), independent of how staff categorized + * the filing in ImageRight. Used to check attachment/container extensions during deep + * content inspection. + */ +const DOC_TYPE_EXTENSIONS: Record = { + EMAIL: ['msg', 'eml'], + 'EXCEL DOC': ['xlsx', 'xls', 'csv'], + EXCEL: ['xlsx', 'xls', 'csv'], + PDF: ['pdf'], + 'LOSS RUN': ['pdf', 'xlsx', 'xls'], +} + +export function docTypesToExtensions(docTypes: string[]): string[] { + const extensions = new Set() + for (const docType of docTypes) { + const key = stripParenthetical(docType).toUpperCase().trim() + for (const ext of DOC_TYPE_EXTENSIONS[key] ?? []) extensions.add(ext) + } + return Array.from(extensions) +} + /** Minimal shape of an ImageRight folder as returned by getSortedFolders — real API fields only. */ export interface ImageRightFolder { id: number | string diff --git a/ondeck/src/lib/imageright/audit-scoring.ts b/ondeck/src/lib/imageright/audit-scoring.ts new file mode 100644 index 0000000..70564bf --- /dev/null +++ b/ondeck/src/lib/imageright/audit-scoring.ts @@ -0,0 +1,102 @@ +/** + * Weighted metadata/content scoring, per imageright_claim_review_audit_methodology.md's + * scoring table — generalized to any SHAPE_AUDIT_SPEC item (using that item's own + * `keywords`/`docTypes`, not a hardcoded claim-specific keyword list). + */ +import { matchesKeywords, matchesDocType, docTypesToExtensions } from './audit-matching' +import type { ShapeAuditSpecItem } from './shape-audit-spec' +import type { InspectedContent } from './content-inspector' + +export interface ScoreSignal { + signal: string + score: number + detail?: string +} + +export interface ScoreResult { + score: number + signals: ScoreSignal[] +} + +export type ConfidenceLevel = 'high' | 'medium' | 'low' | 'none' + +/** Thresholds per the methodology doc: 70+ high, 40-69 medium, 1-39 low, 0 none. */ +export function confidenceFromScore(score: number): ConfidenceLevel { + if (score >= 70) return 'high' + if (score >= 40) return 'medium' + if (score >= 1) return 'low' + return 'none' +} + +/** Pass 1, stage A — cheap signals available from the document list itself (no extra API calls). */ +export function scoreDescriptionAndType(spec: ShapeAuditSpecItem, description: string, docType: string): ScoreResult { + const signals: ScoreSignal[] = [] + if (matchesKeywords(description, spec.keywords)) { + signals.push({ signal: 'description_keyword', score: 40, detail: description }) + } + if (matchesDocType(docType, spec.docTypes)) { + signals.push({ signal: 'doc_type_plausible', score: 10, detail: docType }) + } + return { score: signals.reduce((sum, s) => sum + s.score, 0), signals } +} + +/** Pass 1, stage B — requires one `getDocumentPages` call per candidate (only run on the top few). */ +export function scoreFilenameAndExtension(spec: ShapeAuditSpecItem, filename: string, extension: string): ScoreResult { + const signals: ScoreSignal[] = [] + if (filename && matchesKeywords(filename, spec.keywords)) { + signals.push({ signal: 'filename_keyword', score: 25, detail: filename }) + } + if (['msg', 'eml', 'xlsx', 'xls', 'pdf'].includes(extension.toLowerCase())) { + signals.push({ signal: 'extension_plausible', score: 10, detail: extension }) + } + return { score: signals.reduce((sum, s) => sum + s.score, 0), signals } +} + +export interface ContentScoreResult extends ScoreResult { + /** First attachment whose extension matches the spec's expected doc type, if any. */ + matchedAttachment?: { filename: string; extension: string } +} + +/** + * Pass 2 — score a single inspected content object (either the outer document's own + * content, or one level of nested attachment content). Call once per object; the + * caller sums results across the email + any inspected attachments. + */ +export function scoreContent(spec: ShapeAuditSpecItem, content: InspectedContent): ContentScoreResult { + const signals: ScoreSignal[] = [] + let matchedAttachment: { filename: string; extension: string } | undefined + + if (content.subject && matchesKeywords(content.subject, spec.keywords)) { + signals.push({ signal: 'msg_subject_keyword', score: 30, detail: content.subject }) + } + if (content.bodyText && matchesKeywords(content.bodyText, spec.keywords)) { + signals.push({ signal: 'msg_body_keyword', score: 30 }) + } + + const expectedExtensions = docTypesToExtensions(spec.docTypes) + for (const att of content.attachments ?? []) { + if (matchesKeywords(att.filename, spec.keywords)) { + signals.push({ signal: 'attachment_filename_keyword', score: 20, detail: att.filename }) + } + if (!matchedAttachment && (expectedExtensions.length === 0 || expectedExtensions.includes(att.extension.toLowerCase()))) { + matchedAttachment = { filename: att.filename, extension: att.extension } + } + } + + for (const sheet of content.sheets ?? []) { + if (matchesKeywords(sheet.text, spec.keywords)) { + signals.push({ signal: 'excel_content_keyword', score: 40, detail: sheet.name }) + } + } + + if (content.pdfText && matchesKeywords(content.pdfText, spec.keywords)) { + signals.push({ signal: 'pdf_text_keyword', score: 30 }) + } + + return { score: signals.reduce((sum, s) => sum + s.score, 0), signals, matchedAttachment } +} + +export function combineScores(...results: ScoreResult[]): ScoreResult { + const signals = results.flatMap((r) => r.signals) + return { score: signals.reduce((sum, s) => sum + s.score, 0), signals } +} diff --git a/ondeck/src/lib/imageright/client.ts b/ondeck/src/lib/imageright/client.ts index 0788b99..412a0ad 100644 --- a/ondeck/src/lib/imageright/client.ts +++ b/ondeck/src/lib/imageright/client.ts @@ -164,4 +164,21 @@ export class ImageRightClient { const result = await this.getJson(`/api/documents/${documentId}/pages`) return unwrapValues(result) ?? [] } + + /** + * Download raw page/image bytes. Per Vertafore's REST v2 reference (Images: + * `GET /api/v2/pages/{pageId}/images/{imageId}/{version}`) — v1 has no raw-content + * endpoint; `GET /api/pages/{pageId}` (v1) only returns JSON metadata. Verified live: + * returns `application/octet-stream` with the exact original file bytes (confirmed via + * OLE2 signature match for a real .msg). + */ + async getPageImageContent(pageId: number | string, imageId: number | string, version: number | string): Promise { + const res = await fetch(`${this.baseUrl}/api/v2/pages/${pageId}/images/${imageId}/${version}`, { + headers: this.authHeaders('application/octet-stream'), + }) + if (!res.ok) { + throw new ImageRightApiError(`ImageRight GET v2 page image failed (HTTP ${res.status})`, res.status) + } + return Buffer.from(await res.arrayBuffer()) + } } diff --git a/ondeck/src/lib/imageright/content-inspector.ts b/ondeck/src/lib/imageright/content-inspector.ts new file mode 100644 index 0000000..4ecd4ce --- /dev/null +++ b/ondeck/src/lib/imageright/content-inspector.ts @@ -0,0 +1,92 @@ +/** + * Deep content inspection for candidate ImageRight documents, per + * imageright_claim_review_audit_methodology.md's "Pass 2". Only invoked for + * medium/high-confidence metadata candidates (see audit-scoring.ts) — never run + * over every document in a folder. + * + * Supported container/file types (v1 scope, per project decision): `.msg`, `.xlsx`/`.xls`, + * `.pdf` (text layer only, no OCR). `.msg` attachments are inspected one level deep + * (e.g. an Excel workbook attached to an email) but not recursively beyond that. + */ +import MsgReader from '@kenjiuno/msgreader' +import * as XLSX from 'xlsx' +import { PDFParse } from 'pdf-parse' + +export interface InspectedAttachment { + filename: string + extension: string + buffer: Buffer +} + +export interface InspectedContent { + extension: string + /** .msg fields */ + subject?: string + sender?: string + sentDate?: Date + bodyText?: string + attachments?: InspectedAttachment[] + /** .xlsx fields — sheet name + flattened cell text per sheet, for keyword scanning. */ + sheets?: { name: string; text: string }[] + /** .pdf fields — text layer only; empty string for scanned/image-only PDFs (no OCR). */ + pdfText?: string +} + +export function extensionOf(filename: string): string { + const match = /\.([a-z0-9]+)$/i.exec(filename) + return match ? match[1].toLowerCase() : '' +} + +export function inspectMsg(buffer: Buffer): InspectedContent { + const reader = new MsgReader(buffer as unknown as ArrayBuffer) + const info = reader.getFileData() + const attachments: InspectedAttachment[] = (info.attachments ?? []).map((att) => { + const full = reader.getAttachment(att) + return { + filename: full.fileName, + extension: extensionOf(full.fileName), + buffer: Buffer.from(full.content), + } + }) + const sentDateRaw = info.messageDeliveryTime || info.clientSubmitTime + return { + extension: 'msg', + subject: info.subject, + sender: info.senderName, + sentDate: sentDateRaw ? new Date(sentDateRaw) : undefined, + bodyText: info.body, + attachments, + } +} + +export function inspectXlsx(buffer: Buffer): InspectedContent { + const workbook = XLSX.read(buffer, { type: 'buffer' }) + const sheets = workbook.SheetNames.map((name) => ({ + name, + text: XLSX.utils.sheet_to_csv(workbook.Sheets[name]), + })) + return { extension: 'xlsx', sheets } +} + +export async function inspectPdf(buffer: Buffer): Promise { + const parser = new PDFParse({ data: buffer }) + try { + const result = await parser.getText() + return { extension: 'pdf', pdfText: result.text } + } catch { + // Password-protected, corrupt, or otherwise unparseable — treat as no text evidence + // rather than failing the whole audit item. + return { extension: 'pdf', pdfText: '' } + } finally { + await parser.destroy().catch(() => {}) + } +} + +/** Dispatch by extension. Unsupported extensions return a bare shell (no content evidence). */ +export async function inspectContent(buffer: Buffer, extension: string): Promise { + const ext = extension.toLowerCase() + if (ext === 'msg') return inspectMsg(buffer) + if (ext === 'xlsx' || ext === 'xls') return inspectXlsx(buffer) + if (ext === 'pdf') return inspectPdf(buffer) + return { extension: ext } +}