Files
2026-09-06 10:18:26 -07:00

116 lines
4.2 KiB
Python

"""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