"""Build a relevance context from a user's preferences (spec §5, DESIGN §4/§10). The :class:`RelevanceContext` is the pure input to :func:`ranking.score_bill`. It is built from a user's stored preferences plus (optionally) the correlation lane's committee<->member join, so the "committee of a watched legislator" float path fires *only* when a real roster-backed committee exists for the bill (never fabricated). """ from __future__ import annotations import re from dataclasses import dataclass, field from typing import Dict, List, Optional, Set from .ranking import Weights, DEFAULT_WEIGHTS def _slug(name): if not name: return "" return re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") or "unknown" @dataclass class RelevanceContext: """Pure input to the ranking for one logged-in user. Attributes ---------- watched_authors: lower-cased slugs of the primary author of each watched legislator (``author ∩ watched_legislators``). watched_committees: slugs of committees served by a watched legislator (committee-of-watched-legislator path). Built by :func:`from_prefs` when a committee-roster lookup is supplied; otherwise empty. opted_in_tags: lower-cased tag strings (``tags ∩ opted_in_tags``). home_chamber: optional chamber hint for the +1 score term. weights: scoring weights (tunable — Open Decision D3). """ watched_authors: Set[str] = field(default_factory=set) watched_committees: Set[str] = field(default_factory=set) opted_in_tags: Set[str] = field(default_factory=set) home_chamber: Optional[str] = None weights: Weights = field(default_factory=lambda: DEFAULT_WEIGHTS) def score(self, bill: Dict) -> Dict: """Score one bill against this context.""" from .ranking import score_bill return score_bill( bill, self.watched_authors, self.watched_committees, self.opted_in_tags, home_chamber=self.home_chamber, weights=self.weights, ) @property def is_personalised(self) -> bool: """True when the user has set a watch or opt-in (DESIGN §6.3 gate).""" return bool(self.watched_authors or self.watched_committees or self.opted_in_tags) def from_prefs(prefs, committee_roster=None, weights=None) -> RelevanceContext: """Build a :class:`RelevanceContext` from a stored preferences blob. ``prefs`` matches API_CONTRACT §3.3:: { "watched_legislators": ["L-asm-14", ...], "opted_in_tags": ["waste", "health"], "home_chamber": "ASSEMBLY", "districts": [...] } ``committee_roster`` optionally maps a ``legislator_id`` -> list of committee slugs that legislator chairs/serves in, so the committee-of-watched path can be derived. It is looked up against the correlation lane by the caller (:mod:`scraper.dashboard.data`); when absent the committee path is empty (honest degradation — the correlation lane found no roster, and we never fabricate membership). """ prefs = prefs or {} ctx = RelevanceContext(weights=weights or DEFAULT_WEIGHTS) # Key the author-matching set on both the id-as-given and its slug so a # bill whose `author` column is the *name* still matches a name-based watch. for lid in prefs.get("watched_legislators") or []: s = _slug(lid) if s and s != "unknown": ctx.watched_authors.add(s) if isinstance(lid, str) and lid.strip(): ctx.watched_authors.add(lid.strip().lower()) for tag in prefs.get("opted_in_tags") or []: t = str(tag).strip().lower() if t: ctx.opted_in_tags.add(t) hc = prefs.get("home_chamber") if hc: ctx.home_chamber = str(hc).lower() # Resolve committee-of-watched legs from the roster map (never fabricated). if committee_roster: for lid in prefs.get("watched_legislators") or []: committees = committee_roster.get(lid) if committees: for c in committees: cs = _slug(c) if cs and cs != "unknown": ctx.watched_committees.add(cs) return ctx