"""ZIP/address -> district -> legislators resolution (spec §5, DESIGN §8, D2). Open Decision D2 (inherited from the UX lane) asks which ZIP-to-district data source the dashboard owns. The authoritative source is the Legislature's full-address tool / a county voter file or data.ca.gov boundary datasets; that ETL is the correlation lane's job. This module provides a runnable demonstration resolver so the ZIP->legislator flow is real and testable: * a small, documented ZIP sample map (see DEMO_ZIPS) covering a few districts of the current 2025-2026 session; * a ZIP fast path (0 / 1 / several districts) with the confidence semantics the design freezes (single / ambiguous / zero / out-of-state); * a manual fallback (pick a district by hand) for the edge cases the design requires (P.O. boxes, cross-border ZIPs, post-redistricting gaps). Real-world fact the design grounds this in: a ZIP alone does NOT reliably resolve a CA district (a single ZIP can straddle two districts). Fast-path ambiguity therefore proposes candidates; the user commits (never auto-watched). A real deployment swaps DEMO_ZIPS for the data.ca.gov boundary join, which is documented in DASHBOARD.md. Resolve never persists: it proposes (API_CONTRACT section 3.1). Persisting a watch is the user's explicit act via /prefs/watch. """ from __future__ import annotations from typing import Any, Dict, List, Optional, Sequence from . import data # DEMO sample: ZIP -> the (current) district(s) it maps to, each with its # assembly/senate member. Explicitly a placeholder for the authoritative # boundary join (D2). Legislators reference the session's real member slugs. DEMO_ZIPS = { "90210": [{"district_id": "A-14", "type": "ASSEMBLY", "number": 14, "assembly_member": {"legislator_id": "schiavo", "name": "M. Schiavo", "party": "D"}}], "94107": [{"district_id": "A-11", "type": "ASSEMBLY", "number": 11, "assembly_member": {"legislator_id": "macedo", "name": "K. Macedo", "party": "D"}}], "90012": [ # A ZIP straddling two districts -> AMBIGUOUS (the design's example). {"district_id": "S-08", "type": "SENATE", "number": 8, "senate_member": {"legislator_id": "chen", "name": "W. Chen", "party": "R"}}, {"district_id": "A-09", "type": "ASSEMBLY", "number": 9, "assembly_member": {"legislator_id": "ortega", "name": "A. Ortega", "party": "D"}}, ], "92101": [{"district_id": "A-32", "type": "ASSEMBLY", "number": 32, "assembly_member": {"legislator_id": "bryan", "name": "B. Bryan", "party": "D"}}], "99501": [{"district_id": "A-55", "type": "ASSEMBLY", "number": 55, "assembly_member": {"legislator_id": "jackson", "name": "H. Jackson", "party": "R"}}], } # 5-digit ZIP prefixes that are obviously not California, so the out-of-state # confidence branch is demonstrable. _OUT_OF_STATE_PREFIX = ("990", "999") def _to_resolution(mode: str, districts: Sequence[Dict[str, Any]]) -> Dict[str, Any]: suggested_watch = [] for d in districts: for member in (d.get("assembly_member"), d.get("senate_member")): if member and member.get("legislator_id"): suggested_watch.append(member["legislator_id"]) return { "resolved_from": mode, "confidence": "single" if len(districts) == 1 else ("ambiguous" if districts else "zero"), "districts": [dict(d) for d in districts], "suggested_topics": _suggested_topics(districts), "suggested_watch": suggested_watch, } def _manual_district(district: str) -> Dict[str, Any]: d = district.strip() typ = d[0].upper() if d else "A" num = d[1:] type_ = "SENATE" if typ == "S" else "ASSEMBLY" out: Dict[str, Any] = { "district_id": d, "type": type_, "number": int(num) if num.isdigit() else None, } member_key = "senate_member" if type_ == "SENATE" else "assembly_member" out[member_key] = {} for entry in DEMO_ZIPS.values(): for cand in entry: if cand.get("district_id") == d and cand.get(member_key): out[member_key] = dict(cand[member_key]) return out def _suggested_topics(districts: Sequence[Dict[str, Any]]) -> List[str]: topics: List[str] = [] seen = set() for d in districts: for member in (d.get("assembly_member"), d.get("senate_member")): if not member: continue lid = member.get("legislator_id") for lg in data.load_legislators(): if lg.get("legislator_id") == lid: for x in lg.get("domains") or []: if x not in seen: seen.add(x) topics.append(x) return topics def resolve(zip: Optional[str] = None, address: Optional[str] = None, district: Optional[str] = None) -> Dict[str, Any]: # Manual pick: no ZIP ambiguity at all. if district and not zip and not address: d = _manual_district(district) return _to_resolution("manual", [d]) key = None mode = "zip" if address: a = address.strip() key = a[-5:] if len(a) >= 5 else a mode = "address" elif zip is not None: key = str(zip).strip() else: return {"resolved_from": "zip", "confidence": "zero", "districts": [], "suggested_topics": [], "suggested_watch": []} entry = DEMO_ZIPS.get(key) if entry is None: confidence = "out-of-state" if key[:3] in _OUT_OF_STATE_PREFIX \ else "zero" return {"resolved_from": mode, "confidence": confidence, "districts": [], "suggested_topics": [], "suggested_watch": []} return _to_resolution(mode, entry)