Spaces:
Sleeping
Sleeping
File size: 10,634 Bytes
5724894 28820a6 5724894 28820a6 5724894 28820a6 5724894 28820a6 5724894 28820a6 5724894 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | 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)
|