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)