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:
parent
fad9e18662
commit
30fe43fbf6
13 changed files with 1465 additions and 188 deletions
448
ondeck/package-lock.json
generated
448
ondeck/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
206
ondeck/src/lib/imageright/__tests__/audit-decision-logic.test.ts
Normal file
206
ondeck/src/lib/imageright/__tests__/audit-decision-logic.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
99
ondeck/src/lib/imageright/__tests__/audit-scoring.test.ts
Normal file
99
ondeck/src/lib/imageright/__tests__/audit-scoring.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
103
ondeck/src/lib/imageright/__tests__/content-inspector.test.ts
Normal file
103
ondeck/src/lib/imageright/__tests__/content-inspector.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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<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,
|
||||
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<TaskAuditItemResult, 'linkedTaskId'>
|
||||
}
|
||||
|
||||
export async function auditSpecItem(
|
||||
client: ImageRightClient,
|
||||
files: ImageRightFile[],
|
||||
spec: ShapeAuditSpecItem,
|
||||
targetDate: Date,
|
||||
renewalYear: number
|
||||
): 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)
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
102
ondeck/src/lib/imageright/audit-scoring.ts
Normal file
102
ondeck/src/lib/imageright/audit-scoring.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
ondeck/src/lib/imageright/content-inspector.ts
Normal file
92
ondeck/src/lib/imageright/content-inspector.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue