An inbound service request arrives as a two-line email from an office manager: "Can you add the new box truck to our auto policy, effective Monday." No account number. No policy number. The sender's domain does not match the named insured, because the named insured is a holding company and the email came from the operating entity.
Everything downstream depends on getting that message onto the right account. Draft the activity on the wrong client and you have put one insured's business in another insured's file. That is a privacy problem before it is an accuracy problem.
This tutorial covers the matching step: from a raw inbound message to an AMS account and policy, with a confidence score and an honest abstain path. The code is Python and the pattern is AMS-agnostic; the account and policy tables come from Applied Epic, HawkSoft, or EZLynx, whichever you pull from.
Do not start with the model
The instinct is to hand the whole email to an LLM with a list of accounts and ask which one it is. That fails in two directions. Your book will not fit in a prompt once it passes a few thousand accounts, and the model will confidently pick the closest-looking name when the right answer is "none of these."
Use a three-stage pipeline instead:
- Extract the identifiers present in the message: sender address and domain, names, DBAs, policy numbers, FEIN, phone, address.
- Retrieve a small candidate set from the AMS book, deterministically.
- Score and decide: pick a winner, or abstain and route to a human.
Stages 1 and 3 can use a model. Stage 2 should not.
Extract the identifiers
Policy numbers are the strongest signal you will get and they are cheap to find. Carrier formats vary, but most are matchable with a handful of patterns plus a normalized comparison against the policy numbers you already hold.
import re
POLICY_HINT = re.compile(r"\b[A-Z]{1,5}[-\s]?\d{6,12}(?:[-\s]?\d{1,3})?\b")
FEIN = re.compile(r"\b\d{2}-\d{7}\b")
def normalize_policy(value: str) -> str:
return re.sub(r"[^A-Z0-9]", "", value.upper())
def candidate_identifiers(subject: str, body: str, sender: str):
text = f"{subject}\n{body}"
return {
"sender": sender.lower(),
"domain": sender.split("@")[-1].lower(),
"policy_numbers": {normalize_policy(m) for m in POLICY_HINT.findall(text)},
"feins": set(FEIN.findall(text)),
}
The signature block is worth parsing separately from the body. Signatures carry the entity name, phone, and address that the message body leaves out, and they are stable across an entire thread. Strip quoted history before you extract, or you will match on the account discussed three replies ago.
Company names need normalization before comparison. Case-fold, drop punctuation, strip entity suffixes, and expand the abbreviations agency staff type by habit.
SUFFIXES = {"inc", "llc", "llp", "lp", "ltd", "co", "corp", "company", "pllc", "pc"}
ABBREV = {"bros": "brothers", "mfg": "manufacturing", "assoc": "associates",
"constr": "construction", "svcs": "services", "&": "and"}
def normalize_name(raw: str) -> str:
tokens = re.sub(r"[^a-z0-9& ]", " ", raw.lower()).split()
tokens = [ABBREV.get(t, t) for t in tokens]
tokens = [t for t in tokens if t not in SUFFIXES]
return " ".join(tokens)
"Rivera Bros. Construction, LLC" and "RIVERA BROTHERS CONSTRUCTION" now collapse to the same string. That is most of what a name match needs.
Retrieve candidates deterministically
Pull the book once a night into a local table you control: account id, named insured, DBAs, additional named insureds, email domains seen on the account, phone numbers, service addresses, and every active policy number with its line of business and term dates. Refresh it on a schedule; do not query the AMS live inside the matching loop.
Then generate candidates with cheap blocking keys rather than scanning the whole book:
def candidates(idents, index):
hits = set()
for policy in idents["policy_numbers"]:
hits |= index.by_policy.get(policy, set())
hits |= index.by_domain.get(idents["domain"], set())
hits |= index.by_email.get(idents["sender"], set())
for fein in idents["feins"]:
hits |= index.by_fein.get(fein, set())
if not hits:
hits |= index.trigram_search(idents["name_guess"], limit=25)
return hits
Two rules keep this honest. Free email domains (gmail.com, outlook.com, yahoo.com and friends) must be excluded from the domain index; one shared gmail domain will otherwise link 300 unrelated accounts. And an exact policy-number hit should short-circuit the rest: if the message names a policy you hold, that is the account.
Score, then decide
Score each candidate with explicit signals, not a single similarity number. You need to be able to explain the match in an activity note later.
from rapidfuzz import fuzz
WEIGHTS = {
"policy_exact": 0.60,
"fein_exact": 0.25,
"email_known": 0.20,
"domain_known": 0.15,
"name_similar": 0.25,
"phone_match": 0.10,
}
def score(candidate, idents):
signals = {}
if idents["policy_numbers"] & candidate.policy_numbers:
signals["policy_exact"] = 1.0
if idents["feins"] & candidate.feins:
signals["fein_exact"] = 1.0
if idents["sender"] in candidate.known_emails:
signals["email_known"] = 1.0
elif idents["domain"] in candidate.known_domains:
signals["domain_known"] = 1.0
name_ratio = max(
(fuzz.token_sort_ratio(idents["name_guess"], n) for n in candidate.all_names),
default=0,
) / 100
if name_ratio >= 0.85:
signals["name_similar"] = name_ratio
total = sum(WEIGHTS[k] * v for k, v in signals.items())
return min(total, 1.0), signals
The decision rule matters more than the weights:
def decide(scored):
scored.sort(key=lambda s: s[1], reverse=True)
if not scored:
return {"decision": "no_match"}
top_id, top, signals = scored[0]
runner_up = scored[1][1] if len(scored) > 1 else 0.0
if top >= 0.75 and (top - runner_up) >= 0.20:
return {"decision": "match", "account_id": top_id, "confidence": top, "signals": signals}
return {"decision": "review", "shortlist": scored[:3]}
Note the margin test. A high score on two candidates is worse than a low score on one: it usually means a parent and a subsidiary, or two locations of the same insured, and picking either without a human is guessing. Abstaining is a decision, and it should be recorded as one.
Pick the policy, not just the account
Account matching is half the job. "Add the box truck" needs the commercial auto policy, in force on the requested effective date, not the GL policy or last year's expired term.
Filter the account's policies by line of business inferred from the request, then by term dates covering the effective date. If exactly one survives, attach it. If more than one survives, or none does, keep the account match and leave the policy field empty for the account manager. A drafted activity with the right account and a blank policy is useful. A drafted activity with a confidently wrong policy is not.
Where the model earns its place
Use an LLM for two narrow jobs, both with the candidate set already in hand:
- Reading the message: pull the entity name, requested action, effective date, and any vehicle, location, or holder details into a fixed schema. Constrain the output; the model should return a name it can point at in the text, never one it inferred.
- Tie-breaking a shortlist: give it the message and three candidate accounts with their named insureds, DBAs, and addresses, and require it to answer with one candidate or the string
none. Log the answer as a signal, not as the decision.
Neither call should be able to invent an account id. The id comes from your index; the model only chooses among ids you handed it.
Log the match like you will be asked about it
Every matched request should write an activity into the AMS with the reasoning visible: the signals that fired, the confidence, the runner-up, and the message id it came from. Applied Epic activities, HawkSoft log notes, and EZLynx tasks all carry enough text for this. When an account manager sees "matched on policy number and sender domain, confidence 0.91, runner-up 0.32," they can correct it in five seconds.
Then use those corrections. Every reroute is a labeled example: store the message identifiers with the account the human actually chose, and feed the sender address and domain back into the index. A book that has been running this for a month matches noticeably better than one on day one, without touching the weights.
What this will not do
- It will not resolve a request that names no entity and comes from a personal email address with no history. That message needs a human, and no amount of scoring changes that.
- It will not untangle related entities that share a domain, an address, and half a name. Holding companies, DBAs, and multi-location insureds are exactly where the margin test should abstain.
- It will not send anything. The output is an account match and a drafted activity; the account manager confirms both before a reply leaves the agency.
Set the thresholds by measuring, not by taste. Pull 200 closed service requests where you know the account that ended up handling them, run the matcher, and look at three numbers: correct matches, wrong matches, and abstentions. Wrong matches are the only expensive category. Raise the threshold until that number is near zero, then work on shrinking the abstentions.
This matching layer sits under our Service-Request Agent and COI Agent work, and it runs against the agency's own book before anything goes live. If you are building one and want a senior engineer on it, contact us.