feat(imageright): two-pass metadata + content-inspection audit model

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.
This commit is contained in:
lorentz 2026-07-08 14:36:23 +00:00
parent fad9e18662
commit 30fe43fbf6
13 changed files with 1465 additions and 188 deletions

448
ondeck/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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
}
@ -393,10 +399,28 @@ model TaskAudit {
taskId String? @map("task_id")
specItemKey String @map("spec_item_key")
targetDate DateTime @map("target_date")
status TaskAuditStatus
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")

View file

@ -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<string, string> = {
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<string, string> = {
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}` : ''}
</p>
)}
{item.errorMessage && item.status !== 'COMPLETE' && (
{item.msgSubject && (
<p className="text-xs text-muted-foreground">Email subject: {item.msgSubject}</p>
)}
{!!item.attachmentFilenames?.length && (
<p className="text-xs text-muted-foreground">Attachments: {item.attachmentFilenames.join(', ')}</p>
)}
{item.contentEvidenceSummary && (
<p className="text-xs text-muted-foreground italic">{item.contentEvidenceSummary}</p>
)}
{item.errorMessage && item.classification !== 'PASS' && (
<p className="text-xs text-muted-foreground italic">{item.errorMessage}</p>
)}
</div>
<Badge className={STATUS_STYLES[item.status]}>{STATUS_LABELS[item.status]}</Badge>
<Badge className={STATUS_STYLES[item.classification]}>{STATUS_LABELS[item.classification]}</Badge>
</div>
))}
</div>

View file

@ -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')
})
})

View file

@ -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', () => {

View file

@ -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)
})
})

View file

@ -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' })
})
})

View file

@ -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,
fileId: string | number,
parentId: string | number | undefined
): Promise<unknown[]> {
if (parentId !== undefined) {
return client.findDocuments(fileId, parentId)
}
const folders = (await client.getSortedFolders(fileId)) as ImageRightFolder[]
const seen = new Map<string, unknown>()
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<string, any> | 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,
content: InspectedContent
): Promise<{ score: number; signals: ScoreSignal[] }> {
const own = scoreContent(spec, content)
const signals = [...own.signals]
let score = own.score
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 { 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<TaskAuditItemResult, 'linkedTaskId'>
}
export async function auditSpecItem(
client: ImageRightClient,
files: ImageRightFile[],
spec: ShapeAuditSpecItem,
windowStart: Date | null,
windowEnd: Date | null,
targetDate: Date,
renewalYear: number
): Promise<{ doc: unknown | null; folderChecked: string }> {
let lastLabel = spec.folderPath.join(' > ') || 'ALL_TIME (whole file)'
): Promise<AuditCheckResult> {
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)
lastLabel = label
folderChecked = 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 docs = await findDocumentsSafe(client, file.id, parentId)
for (const doc of docs) {
const description = docNameOf(doc)
const docType = docTypeOf(doc)
const docDate = docDateOf(doc)
if (windowStart && windowEnd) {
if (!docDate || docDate < windowStart || docDate > windowEnd) continue
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
}
return { doc, folderChecked: label }
}
}
return { doc: null, folderChecked: lastLabel }
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,

View file

@ -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<string, string[]> = {
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<string>()
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

View file

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

View file

@ -164,4 +164,21 @@ export class ImageRightClient {
const result = await this.getJson<unknown>(`/api/documents/${documentId}/pages`)
return unwrapValues<unknown[]>(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<Buffer> {
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())
}
}

View file

@ -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<InspectedContent> {
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<InspectedContent> {
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 }
}