claim-agent / data.py
harsh-d's picture
Five-factor verification, offer a human before transferring, decline_off_topic tool, gpt-oss-120b
28820a6 verified
Raw History Blame Contribute Delete
10.6 kB
from __future__ import annotations
import json
import re
from pathlib import Path
FIXTURES_DIR = Path(__file__).parent / "fixtures"
def load_fixtures() -> dict:
return {
"policyholders": _load_json("policyholders.json"),
"claims": _load_json("claims.json"),
"claim_schema": _load_json("claim_schema.json"),
"guidelines": _load_json("required_document_guideline.json"),
"representatives": _load_json("representatives.json"),
"consent_scenarios": _load_json("consent_scenarios.json"),
}
def _load_json(filename: str):
with open(FIXTURES_DIR / filename) as f:
return json.load(f)
# ---------------------------------------------------------------------------
# PII matching
# ---------------------------------------------------------------------------
def _normalize_phone(raw: str) -> str:
digits = re.sub(r"[^0-9]", "", raw)
if len(digits) == 11 and digits.startswith("1"):
digits = digits[1:]
return digits
def _normalize_policy(raw: str) -> str:
return re.sub(r"^pol[-\s]?", "", raw.strip(), flags=re.IGNORECASE).strip().upper()
def _normalize_date(raw: str) -> str | None:
raw = raw.strip()
iso_match = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})$", raw)
if iso_match:
return f"{iso_match.group(1)}-{int(iso_match.group(2)):02d}-{int(iso_match.group(3)):02d}"
us_match = re.match(r"^(\d{1,2})/(\d{1,2})/(\d{2,4})$", raw)
if us_match:
year = us_match.group(3)
if len(year) == 2:
century = "20" if int(year) <= 30 else "19"
year = century + year
return f"{year}-{int(us_match.group(1)):02d}-{int(us_match.group(2)):02d}"
# "March 15, 1985", "15 March 1985", "Mar 15 1985", "March 15th, 1985" —
# the model passes dates through in whatever form the caller spoke them.
# "3 december 1989" used to return None, so a caller with correct details
# failed verification until the model happened to re-record it as ISO.
day, month_str, year = None, None, None
verbal = re.match(r"^([a-zA-Z]+)\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})$", raw)
if verbal:
month_str, day, year = verbal.group(1), verbal.group(2), verbal.group(3)
else:
verbal = re.match(r"^(\d{1,2})(?:st|nd|rd|th)?\s+(?:of\s+)?([a-zA-Z]+)\.?,?\s+(\d{4})$", raw)
if verbal:
day, month_str, year = verbal.group(1), verbal.group(2), verbal.group(3)
if month_str:
month = _MONTHS.get(month_str.lower()) or _MONTHS.get(month_str.lower()[:3])
if month:
return f"{year}-{month}-{int(day):02d}"
return None
_MONTHS = {
"january": "01", "february": "02", "march": "03", "april": "04",
"may": "05", "june": "06", "july": "07", "august": "08",
"september": "09", "october": "10", "november": "11", "december": "12",
"jan": "01", "feb": "02", "mar": "03", "apr": "04", "jun": "06", "jul": "07",
"aug": "08", "sep": "09", "sept": "09", "oct": "10", "nov": "11", "dec": "12",
}
# The five identity factors the SOP accepts (SPEC.txt). A policy number is
# recorded because callers volunteer it and it is useful later, but it does not
# count toward the three — it is printed on every piece of mail.
VERIFICATION_FIELDS = ("full_name", "dob", "phone", "email", "id_last4")
PII_FIELDS = VERIFICATION_FIELDS + ("policy_number",)
def verification_field_count(collected_pii: dict[str, str]) -> int:
return sum(1 for k in collected_pii if k in VERIFICATION_FIELDS)
def pii_value_problem(field_name: str, value: str) -> str | None:
"""Why `value` cannot be a `field_name`, or None if it is plausible.
The model sometimes records a field from a message that contains no PII
at all ("I'm calling about my denied claim from January" produced a
record_pii call), and small models pass the caller's phrasing through
verbatim. Junk in collected_pii inflates the count that gates
verify_identity and can shadow a real value given later, so it is
refused at the door with a reason the model can act on.
"""
v = (value or "").strip()
if field_name not in PII_FIELDS:
return f"unknown field; use one of {', '.join(PII_FIELDS)}"
if not v:
return "empty value"
if field_name == "full_name" and not re.search(r"[A-Za-z]", v):
return "not a name"
if field_name == "dob" and _normalize_date(v) is None:
return "not a recognizable date — re-record it as YYYY-MM-DD"
if field_name == "phone" and len(re.sub(r"\D", "", v)) < 7:
return "not a phone number"
if field_name == "email" and "@" not in v:
return "not an email address"
if field_name == "id_last4" and len(re.sub(r"\D", "", v)) != 4:
return "must be exactly the last 4 digits"
if field_name == "policy_number" and not re.search(r"\d", v):
return "not a policy number"
return None
def match_policyholder(
collected_pii: dict[str, str],
policyholders: list[dict],
) -> tuple[dict | None, int]:
best_match = None
best_count = 0
for ph in policyholders:
count = 0
if "full_name" in collected_pii:
names = [ph["name"].lower()]
names.extend(a.lower() for a in ph.get("name_aliases", []))
if collected_pii["full_name"].strip().lower() in names:
count += 1
if "dob" in collected_pii:
normalized = _normalize_date(collected_pii["dob"])
if normalized and normalized == ph.get("dob"):
count += 1
if "phone" in collected_pii:
phones = [_normalize_phone(ph["phone"])]
phones.extend(_normalize_phone(p) for p in ph.get("phone_aliases", []))
if _normalize_phone(collected_pii["phone"]) in phones:
count += 1
if "email" in collected_pii:
emails = [ph["email"].lower()]
emails.extend(e.lower() for e in ph.get("email_aliases", []))
if collected_pii["email"].strip().lower() in emails:
count += 1
if "id_last4" in collected_pii:
if re.sub(r"\D", "", collected_pii["id_last4"]) == ph.get("id_last4", ""):
count += 1
# Deliberately not counted — see VERIFICATION_FIELDS.
if count > best_count:
best_count = count
best_match = ph
if best_count >= 3:
return best_match, best_count
return None, best_count
# ---------------------------------------------------------------------------
# Claim lookups
# ---------------------------------------------------------------------------
def get_claims_for_party(party_id: str, claims: list[dict]) -> list[dict]:
return [c for c in claims if c["party_id"] == party_id]
def get_claim_by_id(case_id: str, claims: list[dict]) -> dict | None:
for c in claims:
if c["case_id"] == case_id:
return c
return None
# ---------------------------------------------------------------------------
# Document and follow-up guidance
# ---------------------------------------------------------------------------
def get_document_guidance(document_name: str, guidelines: dict, case_type: str | None = None) -> str:
doc_guidance = guidelines.get("document_guidance", {})
alt_guidance = guidelines.get("document_alternative_guidance", {})
doc_name_lower = document_name.lower().strip()
matched_key = None
for key in doc_guidance:
if doc_name_lower in key.lower() or key.lower() in doc_name_lower:
matched_key = key
break
parts = []
if matched_key:
parts.append(f"Submission guidance for '{matched_key}':\n{doc_guidance[matched_key].get('en', '')}")
if matched_key in alt_guidance:
parts.append(f"\nIf not available:\n{alt_guidance[matched_key].get('en', '')}")
else:
default = guidelines.get("default_guidance", {}).get("en", "")
parts.append(f"General document submission guidance:\n{default}")
default_alt = alt_guidance.get("default", {}).get("en", "")
if default_alt:
parts.append(f"\nIf not available:\n{default_alt}")
# Only the note for this claim's own type — emitting every case type gave
# healthcare callers auto-claim advice.
case_type_guidance = guidelines.get("case_type_guidance", {})
for ct, g in case_type_guidance.items():
if case_type and ct.lower() != case_type.lower():
continue
parts.append(f"\n{ct.title()} note: {g.get('en', '')}")
return "\n".join(parts)
def match_followup_guidance(
question: str,
intent: str | None,
active_claim: dict | None,
guidelines: dict,
) -> str:
question_lower = question.lower()
settings = guidelines.get("claim_followup_settings", {})
# Preference order:
# 1. keyword hit on an entry whose intent_hints include this intent
# 2. keyword hit on any entry — if the caller literally asked about the
# topic, that beats the classified intent (a denial_question caller
# still deserves submission_method guidance when they ask how to send)
# 3. an intent-matching catch-all (entries with no `match_any`)
# Without this, the catch-all listed first shadows every specific rule.
by_intent_and_keyword = None
by_keyword = None
by_intent = None
for entry in guidelines.get("claim_followup_guidance", []):
if entry.get("requires_documents") and not (active_claim and active_claim.get("documents_needed")):
continue
intent_ok = not intent or intent in entry.get("intent_hints", [])
match_any = entry.get("match_any", [])
if match_any and any(kw.lower() in question_lower for kw in match_any):
if intent_ok:
by_intent_and_keyword = by_intent_and_keyword or entry
else:
by_keyword = by_keyword or entry
elif not match_any and intent_ok:
by_intent = by_intent or entry
entry = by_intent_and_keyword or by_keyword or by_intent
if entry is None:
return guidelines.get("claim_followup_fallback", {}).get("en", "No specific guidance available.")
text = entry["en"]
if active_claim:
text = text.replace("{case_id}", active_claim.get("case_id", ""))
docs = ", ".join(active_claim.get("documents_needed", []))
text = text.replace("{documents}", docs)
avg_time = settings.get("average_processing_time_after_submission", {}).get("en", "")
return text.replace("{average_processing_time_after_submission}", avg_time)