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)