feat(policy-ocr): suggest the customer from the printed insured name
The office books customers surname-first ("WAGONER, PAMELA") and carriers
print them given-name-first ("PAMELA DENISE WAGONER"), so the review screen
made staff retype a name the parser had already read. Comparing normalized
token sets makes the two orderings the same thing.
Only on the zero-hit path, where the policy number found nothing and a human
has to pick a customer anyway. The suggestions are written to a new
`customerSuggestions` column rather than `matchCandidates`, which the review
screen reads as policy-number hits, and they never set `matchedCustomerId` or
`confident` — matching on `Policy.policyNumber` is unchanged.
Two tiers, drawn where the real book has cliffs: EXACT (identical token sets)
and PARTIAL (containment, >=2 shared tokens, surname present). Of 1536
customers, 1487 have a distinct token set, so EXACT cross-person collisions
are ~0; loosen to surname + first given name and 131 (8.5%) collide, and 185
surnames are shared by 524 customers, which is why one token is never enough
and the surname must be printed explicitly. Replaying every book row as a
carrier would print it: 97.9% top-ranked correct, 1.2% a different row, all
but two of those the same human on a duplicate or variant row.
Normalization folds accents (OCR's MUNOZ reaches the book's MUÑOZ), drops
initials, Spanish particles, JR/S.A. DE C.V., and any token with a digit —
ANA prints the phone hard against the name as `Ph.3102001538`. Names over 8
tokens or 80 characters are refused outright, because GMX's especificación
has no field labels and the parser has handed its whole first page over as
`insuredName`.
Not used for utility statements: there the registrant genuinely is not the
customer, so the same trick would be wrong rather than noisy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d854dff091
commit
81938877ed
10 files changed
+663
-4
No files matched your search
@@ -1,6 +1,12 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { ParsedPolicy } from "./parsers/policy-parser";
|
||||
import {
|
||||
suggestCustomersByName,
|
||||
suggestionNote,
|
||||
type CustomerNameRow,
|
||||
type CustomerNameSuggestion,
|
||||
} from "./name-matcher";
|
||||
|
||||
export interface MatchResult {
|
||||
policyId: string | null;
|
||||
@@ -14,8 +20,24 @@ export interface MatchResult {
|
||||
* the policy number is shared across customers and a human must pick.
|
||||
*/
|
||||
candidates: { policyId: string; customerId: string; customerName: string; policyNumber: string }[];
|
||||
/**
|
||||
* Customers whose name resembles the printed insured name. Populated only
|
||||
* when the policy number resolved to nothing, and never used to set
|
||||
* `customerId` or `confident` — see the class comment.
|
||||
*/
|
||||
customerSuggestions: CustomerNameSuggestion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the customer book is reused across documents in a batch.
|
||||
*
|
||||
* A twenty-page batch would otherwise read all 1536 rows twenty times. The
|
||||
* only cost of the staleness is that a customer created in the last minute
|
||||
* is not suggested — the picker still finds them, so nothing is lost that a
|
||||
* reviewer cannot do in one click.
|
||||
*/
|
||||
const BOOK_TTL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Resolves a parsed policy page to an existing Policy (and its customer) the
|
||||
* office already holds.
|
||||
@@ -33,14 +55,29 @@ export interface MatchResult {
|
||||
* policy numbers across customers do occur (same group policy bound by two
|
||||
* related parties), and picking one arbitrarily would silently book the
|
||||
* wrong coverage.
|
||||
*
|
||||
* On that zero-hit path only, the printed name is used to *rank the picker*
|
||||
* — see `name-matcher.ts`. That is not a walk-back of the rule above: the
|
||||
* suggestion never reaches `customerId` or `confident`, a human still picks,
|
||||
* and the ranking exists because the office writes names surname-first
|
||||
* ("WAGONER, PAMELA") while carriers print them given-name-first ("PAMELA
|
||||
* DENISE WAGONER"), so the reviewer is retyping a name the machine could
|
||||
* have offered.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PolicyMatcherService {
|
||||
private book: { rows: CustomerNameRow[]; loadedAt: number } | null = null;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async match(parsed: ParsedPolicy): Promise<MatchResult> {
|
||||
if (!parsed.policyNumber) {
|
||||
return this.unmatched("no se pudo leer el número de póliza");
|
||||
// No number to search on, so the page goes to review with a picker —
|
||||
// the same place the name suggestions help.
|
||||
return this.unmatched(
|
||||
"no se pudo leer el número de póliza",
|
||||
await this.suggestByName(parsed.insuredName),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await this.prisma.policy.findMany({
|
||||
@@ -61,22 +98,33 @@ export class PolicyMatcherService {
|
||||
}));
|
||||
|
||||
if (rows.length === 0) {
|
||||
const suggestions = await this.suggestByName(parsed.insuredName);
|
||||
const hint = suggestionNote(suggestions);
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note: `no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
|
||||
note: [
|
||||
`no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
|
||||
hint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; "),
|
||||
confident: false,
|
||||
candidates: [],
|
||||
customerSuggestions: suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
if (rows.length > 1) {
|
||||
// The policy number did find rows; the reviewer picks among those, and
|
||||
// adding name guesses on top would only add noise.
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`,
|
||||
confident: false,
|
||||
candidates,
|
||||
customerSuggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,16 +134,47 @@ export class PolicyMatcherService {
|
||||
note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`,
|
||||
confident: true,
|
||||
candidates,
|
||||
customerSuggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
private unmatched(note: string): MatchResult {
|
||||
private async suggestByName(
|
||||
insuredName: string | null | undefined,
|
||||
): Promise<CustomerNameSuggestion[]> {
|
||||
if (!insuredName) return [];
|
||||
return suggestCustomersByName(insuredName, await this.customerBook());
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole customer book, held briefly. 1536 rows of `{id, name}` is a
|
||||
* few hundred kilobytes and the comparison is pure token-set work, so
|
||||
* scanning it beats any SQL approximation — and a `LIKE` search would in
|
||||
* any case have to guess which token is the surname, which is the one
|
||||
* thing the office's own data does not agree on.
|
||||
*/
|
||||
private async customerBook(): Promise<CustomerNameRow[]> {
|
||||
if (this.book && Date.now() - this.book.loadedAt < BOOK_TTL_MS) {
|
||||
return this.book.rows;
|
||||
}
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
this.book = { rows, loadedAt: Date.now() };
|
||||
return rows;
|
||||
}
|
||||
|
||||
private unmatched(
|
||||
note: string,
|
||||
customerSuggestions: CustomerNameSuggestion[] = [],
|
||||
): MatchResult {
|
||||
const hint = suggestionNote(customerSuggestions);
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note,
|
||||
note: [note, hint].filter(Boolean).join("; "),
|
||||
confident: false,
|
||||
candidates: [],
|
||||
customerSuggestions,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user