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 }
+}