116 lines
4.2 KiB
Python
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
|