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:
@@ -21,6 +21,7 @@ import type {
|
||||
PolicyOcrBatchDetail,
|
||||
PolicyOcrConfirmDocument,
|
||||
PolicyOcrCoverage,
|
||||
PolicyOcrCustomerSuggestion,
|
||||
PolicyOcrDocument,
|
||||
PolicyOcrReviewInput,
|
||||
} from "@/lib/types";
|
||||
@@ -351,6 +352,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
const locked = doc.status === "POSTED" || doc.status === "REJECTED";
|
||||
const matchedExisting = !!doc.matchedPolicy;
|
||||
const candidates = doc.matchCandidates ?? [];
|
||||
const suggestions: PolicyOcrCustomerSuggestion[] = doc.customerSuggestions ?? [];
|
||||
|
||||
return (
|
||||
<article className="card" style={{ padding: 16 }}>
|
||||
@@ -670,6 +672,34 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* Name suggestions, never a preselection. The office writes
|
||||
* customers surname-first and carriers print them given-name-first,
|
||||
* so without this the reviewer retypes a name the parser already
|
||||
* read. One click fills the picker above; nothing is chosen until
|
||||
* they click. Kept outside the <Field> label — a label must not
|
||||
* wrap other interactive controls.
|
||||
*/}
|
||||
{!policyId && !customerId && !locked && canReview && suggestions.length > 0 && (
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||
<span className="page-sub">Sugerencias por nombre:</span>
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.customerId}
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
setCustomerId(s.customerId);
|
||||
setCustomerName(s.customerName);
|
||||
}}
|
||||
>
|
||||
{s.customerName}
|
||||
{s.tier === "PARTIAL" && <span className="page-sub"> · parcial</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
Reference in New Issue
Block a user