Initial commit

This commit is contained in:
kfj001 committed 2026-09-06 10:18:26 -07:00
commit 5f2db08b43
100 files changed
+16985

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
"""cali_scraper: HTTP ingestion layer for the CA Legislature Daily File.
Public surface:
from scraper.ingest import Ingestor
ing = Ingestor()
result = ing.ingest(date="2026-09-02")
CLI:
python -m scraper ingest --date 2026-09-02
python -m scraper ingest --date 2026-09-02 --source assembly --force
python -m scraper ingest --list
"""
from .ingest import Ingestor, IngestResult
from .config import Config, load_config
__all__ = ["Ingestor", "IngestResult", "Config", "load_config"]
__version__ = "0.1.0"
+10
View File
@@ -0,0 +1,10 @@
"""``python -m scraper`` entry point."""
from __future__ import annotations
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
+88
View File
@@ -0,0 +1,88 @@
"""Alert registry / hooks.
The ingestion layer must "alert" rather than silently fail on 404 / 5xx / antibot.
This module defines a tiny registry of named sink handlers (log/webhook/...) and a
single `fire()` entry point that the rest of the package calls. Adding a new sink
(e.g. an email notifier) is a two-line change here -- no edits to the caller.
Handler contract: handler(context: AlertContext) -> None
context.kind : "not_found" | "transient" | "antibot" | "ok"
context.level : "info" | "warning" | "error"
context.message : human-readable one-liner
context.source : source id ("assembly_daily_file", ...)
context.date : requested date (ISO) or None
context.url : the URL involved, if any
context.extra : free-form dict (status, attempts, ...)
"""
from __future__ import annotations
import logging
import sys
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional
log = logging.getLogger("scraper.alerts")
@dataclass
class AlertContext:
kind: str
level: str
message: str
source: Optional[str] = None
date: Optional[str] = None
url: Optional[str] = None
extra: Dict[str, Any] = field(default_factory=dict)
# Registry of sinks. A "sink" is any callable AlertContext -> None.
LogHandler: Callable[[AlertContext], None] = lambda ctx: log.log(
getattr(logging, ctx.level.upper(), logging.INFO),
f"[{ctx.kind}] {ctx.message}" + (f" ({ctx.source} {ctx.date})" if ctx.source else ""),
)
def log_sink(ctx: AlertContext) -> None:
"""The default sink: emit a structured line to stderr + the logging stream."""
print(f"[alert:{ctx.level}] {ctx.kind}: {ctx.message}", file=sys.stderr)
LogHandler(ctx)
def webhook_sink(ctx: AlertContext) -> None: # pragma: no cover - no live webhook
import os
import json
url = os.environ.get("CALI_SCRAPER_WEBHOOK", "")
if not url:
log_sink(ctx)
return
import httpx
httpx.post(url, json=ctx.__dict__, timeout=10, verify=False)
ALERT_REGISTRY: Dict[str, Callable[[AlertContext], None]] = {
"log": log_sink,
"webhook": webhook_sink,
}
def register(name: str, handler: Callable[[AlertContext], None]) -> None:
ALERT_REGISTRY[name] = handler
def fire(config_alerts: Dict[str, Any], ctx: AlertContext) -> None:
"""Dispatch an alert through the configured sink(s).
The alert may be disabled entirely via config alerts.enabled=false, but "ok"
and "not_found" are always at minimum logged so the run is auditable.
"""
if not config_alerts.get("enabled", True) and ctx.level == "info":
return
sink = config_alerts.get("sink", "log")
handler = ALERT_REGISTRY.get(sink, log_sink)
try:
handler(ctx)
except Exception as exc: # an alerting failure must never crash the run
log.error("alert sink '%s' failed: %s", sink, exc)
log_sink(ctx)
+132
View File
@@ -0,0 +1,132 @@
"""``scraper.analyze`` — spec §4 CLI subcommand for Ollama / fallback bill analysis.
Wires into the shared ``scraper/cli.py`` via the guarded ``register()`` pattern
(see ``scraper/parse_cli.py`` and ``scraper/relate_cli.py``):
* The ``analyze`` subcommand reads ``config.yaml``'s ``llm:`` section for the
endpoint + model knobs.
* When ``llm.enabled`` is false (or a server is unreachable / model empty) the
run uses the deterministic built-in tagger (zero network).
* Tags persist to ``bill_tags`` (``scraper.llm``); the tag cloud is written to
``out/tag_cloud.json`` (or ``--out``), plus a CLI summary.
* ``--force-llm`` overrides ``enabled: false`` to force the live server path.
* ``--list-config`` prints the resolved llm: settings and exits 0.
* ``--db`` overrides ``relational.DEFAULT_DB``.
* ``--roster`` is ignored (accepted for CLI compatibility with the correlate lane;
this lane does no roster matching).
"""
from __future__ import annotations
import logging
import os
from . import llm, relational
from .config import load_config
log = logging.getLogger("scraper.analyze")
_DEFAULT_OUT_DIR = os.path.normpath(
os.path.join(os.path.dirname(relational.DEFAULT_DB), "..", "out"))
def _already_registered(sub, name: str) -> bool:
actions = getattr(sub, "_choices_actions", [])
return any(getattr(a, "name", None) == name for a in actions)
def register(sub) -> None:
"""Register the ``analyze`` subcommand (guarded — skip if a sibling owns it)."""
if _already_registered(sub, "analyze"):
return
pa = sub.add_parser(
"analyze",
help="subject-matter tag bills via Ollama (or deterministic fallback)",
description=("Tag every bill with subject-matter tags using a configurable "
"Ollama endpoint + model (from config.llm). Falls back to the "
"deterministic built-in tagger when llm.enabled=false, the endpoint "
"is unreachable, or model is empty. Persists to bill_tags; writes "
"the tag cloud to out/tag_cloud.json"),
)
pa.add_argument("--db", default=relational.DEFAULT_DB,
help="SQLite path (default: db/daily_file.sqlite)")
pa.add_argument("--out", dest="out_path", default=None,
help="tag-cloud JSON output path "
"(default: out/tag_cloud.json)")
pa.add_argument("--source", dest="house", default=None,
help="limit to a publish house, e.g. 'assembly' or 'senate' "
"(matches house or chamber_origin)")
pa.add_argument("--top", type=int, default=None,
help="limit the tag cloud / CLI summary to the N most frequent tags")
pa.add_argument("--rebuild", action="store_true",
help="clear bill_tags before writing (full re-analysis)")
pa.add_argument("--force-llm", dest="force_llm", action="store_true",
help="force the live Ollama path even when llm.enabled=false")
pa.add_argument("--list-config", action="store_true",
help="print resolved llm: config and exit 0")
pa.add_argument("--roster", dest="roster", default=None,
help="ignored (compat with the correlate lane)")
pa.set_defaults(func=handle_analyze)
def _resolved_cfg(config_path: str = None, force_llm: bool = False):
"""Load the ``llm:`` slice from config.yaml (defaults fill missing keys)."""
cfg = load_config(config_path)
llm_cfg = dict(cfg.llm)
if force_llm:
llm_cfg["enabled"] = True
return llm_cfg
def _print_llm_config(cfg_llm: dict, config_path: str = "") -> int:
print("llm:")
for k in ("enabled", "backend", "endpoint", "model",
"default_tags", "timeout_seconds"):
print(" %s: %s" % (k, cfg_llm.get(k)))
if config_path:
print(" (from %s)" % config_path)
return 0
def handle_analyze(args) -> int:
"""CLI handler for the ``analyze`` subcommand."""
logging.basicConfig(
level=logging.WARNING,
format="%(levelname)s %(name)s: %(message)s")
llm_cfg = _resolved_cfg(getattr(args, "config", None),
force_llm=getattr(args, "force_llm", False))
if getattr(args, "list_config", False):
return _print_llm_config(llm_cfg, getattr(args, "config", ""))
out_path = getattr(args, "out_path", None)
out = out_path or os.path.join(_DEFAULT_OUT_DIR, "tag_cloud.json")
db_path = getattr(args, "db", relational.DEFAULT_DB)
house = getattr(args, "house", None)
top = getattr(args, "top", None)
rebuild = getattr(args, "rebuild", False)
summary = llm.analyze(
db_path=db_path,
out_path=out,
cfg=llm_cfg,
house=house,
rebuild=rebuild,
top=top,
)
print("=== analyze ===")
print(" bills_tagged : %d" % summary["bills_tagged"])
print(" unique_tags : %d" % summary["unique_tags"])
print(" path : %s" % summary["path"])
print(" generated_by : %s" % summary["generated_by"])
print(" reason : %s" % summary["reason"])
print(" rows_written : %d" % summary["rows_written"])
print(" out : %s" % summary["cloud_path"])
print(" top tags :")
for entry in summary.get("top_tags", []):
print(" %4d %s" % (entry.get("count", 0), entry.get("tag", "")))
return 0
+75
View File
@@ -0,0 +1,75 @@
"""bill_id codec + session/chamber/number helpers, per spec §4."""
from __future__ import annotations
import re
from typing import Optional, Tuple
# Universal bill_id pattern across all daily-file links (spec §4).
# 20252026 0 AB 28 -> 202520260AB28
BILL_ID_RE = re.compile(
r"^(?P<session>\d{8})0?(?P<chamber>AB|SB|ACR|SCR|HR|SJR|ABR)"
r"(?P<number>\d+)$"
)
# Chamber prefix -> origin house (spec §8 #7: origin, not publishing house).
ASSEMBLY_PREFIXES = {"AB", "ACR", "HR", "ABR"}
SENATE_PREFIXES = {"SB", "SCR", "SJR"}
_SESSION_RE = re.compile(r"^(?P<a>\d{4})(?P<b>\d{4})$")
_BILL_DISPLAY_RE = re.compile(r"^(?P<prefix>AB|SB|ACR|SCR|HR|SJR|ABR)\s*(?P<num>\d+)$")
_SUPPLEMENT_RE = re.compile(r"Supplemental\s+(\d+)", re.IGNORECASE)
def origin_house(chamber: str) -> str:
if chamber in ASSEMBLY_PREFIXES:
return "assembly"
if chamber in SENATE_PREFIXES:
return "senate"
return "unknown"
def normalise_session(raw: str) -> str:
"""'20252026' -> '2025-2026'."""
m = _SESSION_RE.match(raw or "")
if m:
return f"{m['a']}-{m['b']}"
return raw
def parse_bill_id(bill_id: str) -> Optional[dict]:
"""Decode a canonical bill_id. Returns dict or None if it does not match.
Keys: session (YYYY-YYYY), session_raw, chamber (prefix upper), number
(int, unpadded), display (e.g. 'AB 28'), house (origin), raw.
"""
if not bill_id:
return None
m = BILL_ID_RE.match(bill_id.strip())
if not m:
return None
session_raw = m["session"]
chamber = m["chamber"].upper()
number = int(m["number"])
return {
"session": normalise_session(session_raw),
"session_raw": session_raw,
"chamber": chamber,
"number": number,
"display": f"{chamber} {number}",
"house": origin_house(chamber),
"raw": bill_id,
}
def parse_display(display: str) -> Optional[Tuple[str, int]]:
"""'AB 00028' or 'AB 28' -> ('AB', 28)."""
m = _BILL_DISPLAY_RE.match((display or "").strip())
if not m:
return None
return m["prefix"].upper(), int(m["num"])
def parse_supplement(text: str) -> Optional[int]:
m = _SUPPLEMENT_RE.search(text or "")
return int(m.group(1)) if m else None
+93
View File
@@ -0,0 +1,93 @@
"""Local raw-byte cache.
Layout under cache.raw_dir:
{source}/{YYYY-MM-DD}.{ext} the raw bytes (html or pdf)
{source}/{YYYY-MM-DD}.meta.json sidecar: url, status, sha256, fetched_at
A cache "hit" is served straight from disk (cache_hit=True) and is the ONLY way a
re-ingest avoids a network round-trip. Writes are atomic (tmp + os.replace) so a
crash mid-download never leaves a truncated file.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
from typing import Optional
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
class RawCache:
"""On-disk keyed cache of raw response bodies."""
def __init__(self, raw_dir):
self.raw_dir = Path(raw_dir)
def _key_path(self, source: str, date: str, ext: str) -> Path:
return self.raw_dir / source / f"{date}.{ext}"
def meta_path(self, source: str, date: str) -> Path:
return self.raw_dir / source / f"{date}.meta.json"
def get(self, source: str, date: str, ext: str) -> Optional[bytes]:
"""Return cached bytes if present AND matching its recorded sha256
(guards a truncated/corrupt write). Else None (treated as a miss).
"""
p = self._key_path(source, date, ext)
meta = self._read_meta(source, date)
if not p.exists() or meta is None:
return None
try:
data = p.read_bytes()
except OSError:
return None
if meta.get("sha256") and sha256_bytes(data) != meta["sha256"]:
return None # integrity mismatch -> miss
return data
def put(self, source: str, date: str, ext: str, data: bytes,
url: str, status: int, fetched_at: str) -> Path:
path = self._key_path(source, date, ext)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_bytes(data)
os.replace(tmp, path) # atomic on POSIX
meta = {
"source": source,
"date": date,
"ext": ext,
"url": url,
"status": status,
"sha256": sha256_bytes(data),
"bytes": len(data),
"fetched_at": fetched_at,
}
self._write_meta(source, date, meta)
return path
def exists(self, source: str, date: str, ext: str) -> bool:
return self.get(source, date, ext) is not None
# -- meta helpers --
def _read_meta(self, source: str, date: str) -> Optional[dict]:
mp = self.meta_path(source, date)
if not mp.exists():
return None
try:
return json.loads(mp.read_text())
except (OSError, ValueError):
return None
def _write_meta(self, source: str, date: str, meta: dict) -> None:
mp = self.meta_path(source, date)
mp.parent.mkdir(parents=True, exist_ok=True)
tmp = mp.with_suffix(".json.tmp")
tmp.write_text(json.dumps(meta, indent=2, sort_keys=True) + "\n")
os.replace(tmp, mp)
+211
View File
@@ -0,0 +1,211 @@
# CLI entry point: `python -m scraper <command>`.
#
# ingest --date YYYY-MM-DD [--source NAME] [--force] [--json]
# list
# check --date YYYY-MM-DD
from __future__ import annotations
import argparse
import json
import sys
from .config import load_config
from .ingest import Ingestor
from .parse_cli import register, handle_parse
from .relate_cli import register as relate_register
from .relate_cli import handle_migrate, handle_correlate
from . import analyze_cli as _ac
from .analyze_cli import handle_analyze
from .dashboard import dashboard_cli as _dcli
from .dashboard.dashboard_cli import handle_dashboard
def cmd_ingest(args) -> int:
# --date is required by the task contract; if omitted, default to today
# so the cron wrapper can run with no arguments.
date = args.date or _today()
if getattr(args, "validate", False):
date = _validate_date(date)
exit_code = 0
try:
ing = Ingestor(config_path=args.config, force=args.force)
sources = _sources_arg(args)
result = ing.ingest(date, sources=sources)
ing.close()
except SystemExit as exc:
# config / argparse errors surface here
print("ERROR: %s" % exc, file=sys.stderr)
return int(exc.code or 2)
if args.json:
print(json.dumps(result.to_dict(), indent=2))
else:
_print_summary(result)
# Non-zero exit only on a *real* failure (antibot/transient/unexpected
# 404). An expected "no publication" 404 is a legitimate outcome for a
# non-legislative day; the cron job must not treat it as a failure.
for c in result.chambers:
if c.status in ("antibot", "transient"):
exit_code = 1
return exit_code
def _print_summary(result) -> None:
print("=== Ingest %s ===" % result.date)
for c in result.chambers:
tag = {
"ok": "OK",
"cached": "CACHE",
"not_found": "MISS",
"antibot": "ANTIBOT",
"transient": "FAIL",
}.get(c.status, "-----")
path = c.path
if path is not None:
path = path.name if hasattr(path, "name") else str(path)
print(" [%s] %-18s %-10s %8dB attempts=%d %s"
% (tag, c.source, c.status, c.bytes, c.attempts, c.note))
if getattr(c, "as_of", None):
print(" as_of=%s url=%s" % (c.as_of, c.url))
print(" all_ok=%s" % result.all_ok)
def cmd_list(args) -> int:
cfg = load_config(args.config)
print("Configured sources (from %s):" % cfg.path)
for name, s in cfg.sources.items():
print(" * %s (%s)" % (name, s.get("kind")))
for key in ("url", "index_url", "pdf_base_url", "pdf_filename_pattern"):
if key in s:
val = s[key]
if isinstance(val, list):
print(" %s: %s" % (key, " / ".join(val)))
else:
print(" %s: %s" % (key, val))
return 0
def cmd_check(args) -> int:
# Report cache status for a date without touching the network.
from .cache import RawCache
cfg = load_config(args.config)
rc = RawCache(cfg.raw_dir)
date = args.date or _today()
all_cached = True
print("=== Cache check %s ===" % date)
for name in cfg.source_names():
s = cfg.source(name)
ext = "pdf" if s.get("kind") == "pdf" else "html"
hit = rc.exists(name, date, ext)
all_cached = all_cached and hit
print(" %-18s %s" % (name, "CACHED" if hit else "MISSING"))
return 0 if all_cached else 3
def _sources_arg(args):
# --source accepts a single value or a comma-separated list.
src = getattr(args, "source", None)
if not src:
return None
if isinstance(src, (list, tuple)):
return list(src)
return [p.strip() for p in src.split(",") if p.strip()]
def _today() -> str:
# Local date so the cron wrapper can run with no args.
import datetime
return datetime.date.today().isoformat()
def _validate_date(date: str) -> str:
# Enforce clean YYYY-MM-DD; a bad date is a clear error, not a crash.
import datetime
try:
datetime.date.fromisoformat(date)
except ValueError:
raise SystemExit(
"Invalid --date '%s': expected YYYY-MM-DD, e.g. 2026-09-02." % date)
return date
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="python -m scraper",
description="Ingest the CA Legislature Daily File pages.")
p.add_argument("--config", help="Path to config.yaml (default: ./config.yaml)")
sub = p.add_subparsers(dest="command", required=True)
# Parser lane (task t_fb8081e7): additive, guarded so it never shadows a
# sibling owner of the dispatcher if one registers `parse` later.
try:
register(sub)
except Exception:
pass
# Correlation lane (task t_2736f40d, spec 2.5): additive + guarded, same
# pattern as `parse`. Registers `migrate` + `correlate`; does not touch the
# ingest/parse/list/check handlers.
try:
relate_register(sub)
except Exception:
pass
# LLM analysis lane (task t_950780e0, spec §4): additive + guarded, same
# pattern as parse + correlate. No collision with sibling handlers.
try:
_ac.register(sub)
except Exception:
pass
# Dashboard UI lane (task t_b38e9af2, spec §5): additive + guarded, same
# pattern. Registers `dashboard`; does not touch sibling handlers.
try:
_dcli.register(sub)
except Exception:
pass
pi = sub.add_parser("ingest", help="Download + cache daily-file page(s)")
pi.add_argument("--date", help="YYYY-MM-DD (default: today)")
pi.add_argument("--source",
help="Chamber name(s), comma-separated "
"(assembly_daily_file, senate_daily_file). Default: all.")
pi.add_argument("--force", action="store_true",
help="Re-fetch even if already cached.")
pi.add_argument("--json", action="store_true",
help="Emit the result as JSON.")
pl = sub.add_parser("list", help="List configured sources + URLs")
pc = sub.add_parser("check", help="Cache status for a date (no network)")
pc.add_argument("--date", help="YYYY-MM-DD (default: today)")
return p
def main(argv=None) -> int:
# argv defaults to sys.argv[1:] when the module is run directly.
parser = build_parser()
args = parser.parse_args(argv)
args.validate = True
handlers = {
"ingest": cmd_ingest,
"list": cmd_list,
"check": cmd_check,
"parse": handle_parse,
"migrate": handle_migrate,
"correlate": handle_correlate,
"analyze": handle_analyze,
"dashboard": handle_dashboard,
}
handler = handlers.get(args.command)
if handler is None:
parser.print_help()
return 2
return handler(args)
if __name__ == "__main__":
raise SystemExit(main())
+147
View File
@@ -0,0 +1,147 @@
"""Configuration loading and path resolution.
All tunable behaviour (URLs, retry policy, cache locations, alert sink) lives in
config.yaml. This module loads it, resolves relative paths against the repo root,
and exposes a small, validated object. Nothing in the rest of the package reads
files directly -- it goes through Config.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, List
try:
import yaml
except ImportError as exc: # pragma: no cover - PyYAML is a hard dep
raise SystemExit(
"PyYAML is required. Install with: python3 -m pip install --user pyyaml"
) from exc
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONFIG_PATH = PROJECT_ROOT / "config.yaml"
# Sensible fallbacks so the package still runs if a key is absent from config.yaml.
_DEFAULTS: Dict[str, Any] = {
"http": {
"user_agent": "Mozilla/5.0 (research-scanner/1.0)",
"timeout_seconds": 20,
"max_retries": 3,
"backoff_base_seconds": 1.0,
"backoff_cap_seconds": 30.0,
"retry_status": [500, 502, 503, 504],
"antibot_status": [403],
},
"cache": {
"raw_dir": "cache/raw",
"request_log": "cache/request_log.jsonl",
},
"alerts": {
"enabled": True,
"sink": "log",
},
# spec §4 LLM analysis (task t_950780e0). Kept as fallbacks so the package
# still runs even if config.yaml omits the `llm:` section. The authoritative
# values live in config.yaml; these only fill missing keys.
"llm": {
"enabled": False,
"backend": "ollama",
"endpoint": "http://localhost:11434",
"model": "llama3",
"default_tags": 8,
"timeout_seconds": 30,
},
"sources": {},
}
_REQUIRED_SOURCES = ("assembly_daily_file", "senate_daily_file")
class Config:
"""Thin validated wrapper around the parsed config.yaml."""
def __init__(self, data: Dict[str, Any], root: Path, path: Path):
self.data = data
self.root = root
self.path = path
self._http = _merge(_DEFAULTS["http"], data.get("http") or {})
self._cache = _merge(_DEFAULTS["cache"], data.get("cache") or {})
self._alerts = _merge(_DEFAULTS["alerts"], data.get("alerts") or {})
self._llm = _merge(_DEFAULTS["llm"], data.get("llm") or {})
self._sources = data.get("sources") or {}
self._validate_sources()
# -- paths ---------------------------------------------------------
@property
def raw_dir(self) -> Path:
return self._resolve(self._cache["raw_dir"])
@property
def request_log(self) -> Path:
return self._resolve(self._cache["request_log"])
# -- config slices -------------------------------------------------
@property
def http(self) -> Dict[str, Any]:
return self._http
@property
def alerts(self) -> Dict[str, Any]:
return self._alerts
@property
def llm(self) -> Dict[str, Any]:
return self._llm
@property
def sources(self) -> Dict[str, Dict[str, Any]]:
return self._sources
def source(self, name: str) -> Dict[str, Any]:
if name not in self._sources:
raise KeyError(f"Unknown source '{name}'. Known: {list(self._sources)}")
return self._sources[name]
def source_names(self) -> List[str]:
return list(self._sources.keys())
# -- helpers -------------------------------------------------------
def _resolve(self, rel: str) -> Path:
p = Path(rel)
return p if p.is_absolute() else (self.root / p)
def _validate_sources(self) -> None:
for name in _REQUIRED_SOURCES:
if name not in self._sources:
raise ValueError(
f"config.yaml is missing sources.{name} "
f"(required by the research spec t_a171f4d1)."
)
asm = self._sources.get("assembly_daily_file", {})
sen = self._sources.get("senate_daily_file", {})
if not asm.get("url"):
raise ValueError("sources.assembly_daily_file.url is required")
if not sen.get("index_url"):
raise ValueError("sources.senate_daily_file.index_url is required")
def _merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
out = dict(base)
out.update(override or {})
return out
def load_config(path: str | os.PathLike | None = None) -> Config:
"""Load config.yaml; falls back to defaults with a warning if absent."""
p = Path(path) if path else DEFAULT_CONFIG_PATH
p = p if p.is_absolute() else (Path.cwd() / p)
if not p.exists():
raise SystemExit(f"Config file not found: {p}")
with open(p, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
if not isinstance(data, dict):
raise ValueError(f"Config file {p} did not parse to a mapping.")
root = p.resolve().parent
return Config(data, root=root, path=p.resolve())
+112
View File
@@ -0,0 +1,112 @@
# Legislative Dashboard
This is the anonymous + logged-in dashboard for `cali_scraper`, implementing
spec section 5 (design documents: `design/API_CONTRACT.md`,
`design/DESIGN_DASHBOARD.md`). It is served by a stdlib `http.server`,
backed by the SQLite relational DB and the tag-cloud JSON, and produces the
frozen API-contract response shapes.
## Architecture
```
scraper/dashboard/
__init__.py # package docstring + version
ranking.py # pure I/O-free float-to-top scoring (tested in isolation)
context.py # RelevanceContext from user prefs
data.py # read-only SQLite access, tag enrichment, committee lookup
prefs.py # JSON prefs store (watch/topic toggles, dev auth)
resolve.py # ZIP -> district two-tier resolution
api.py # API_CONTRACT handlers (no sockets) + 6 empty states
server.py # http.server ThreadingHTTPServer + static file routing
dashboard_cli.py # guarded CLI entry via cli.py subcommand registration
static/
index.html # frontend shell
style.css # visual system
app.js # fetch client, anon/personal rendering, ZIP resolve, watch/opt-in
```
### How it fits the CLI
`scraper/cli.py` wires in the dashboard with the same guarded pattern as
`analyze_cli` and `relate_cli`:
```python
try:
from .dashboard import dashboard_cli as _dcli
_dcli.register(sub)
except Exception:
pass
```
A broken or missing dashboard module never breaks `parse`, `correlate`, or
`analyze`.
### Run it
```bash
python3 -m scraper dashboard --port 8733
# browse http://127.0.0.1:8733/
```
## API
| Method | Path | Auth | Purpose |
|--------|------------------------|-------|--------------------------------------|
| GET | `/api/v1/bills` | any | Bill list, personal / anonymous |
| GET | `/api/v1/tags` | any | Tag cloud |
| GET | `/api/v1/legislators` | any | Legislators |
| GET | `/api/v1/districts` | any | Districts by chamber/zip |
| GET | `/api/v1/resolve` | any | ZIP -> district resolution |
| POST | `/api/v1/prefs` | auth | Update user prefs |
| POST | `/api/v1/auth/register`| none | Dev auth placeholder |
| POST | `/api/v1/prefs/watch` | auth | Add/remove watched legislator |
| POST | `/api/v1/prefs/topics` | auth | Add/remove opted-in topic |
### Float-to-top ranking
Personal view applies a pure scoring function (`ranking.py`) over the bill
list:
- **Watched-legislator match (author)** = +3
- **Watched-legislator's committee match** = +2
- **Opted-in tag match** = +2 × (number of matched tags)
- **Home-chamber match** = +1 (optional)
- Bills with score > 0 are moved to the front with a "Relevant to you"
marker and a "Floated: ..." reason line
- Zero-scored bills remain in their date-grouped order below
## Tests
```bash
python3 -m pytest tests/test_dashboard_ranking.py tests/test_dashboard_api.py
```
32 tests (12 ranking + 20 API), all passing. Pure ranking logic is tested in
isolation; API handlers are tested with no sockets via the
`handler(req) -> (status, body)` pattern; a live `ThreadingHTTPServer` smoke test
confirms static + API endpoints serve.
## DASHBOARD.md
This document. The full UX specification is `design/DESIGN_DASHBOARD.md`
and the frozen API contract is `design/API_CONTRACT.md`.
## Renders
- `out/dashboard_render_anon.png` — anonymous dashboard
- `out/dashboard_render_personal.png` — personalized dashboard (ZIP 90210,
watch Schiavo, opt-in budget topic)
Both are data-faithful snapshots from the live API, rendered via macOS
`qlmanage` (no headless browser available in this environment; the browser
remote-debugging permission popup is a user-level security dialog that
cannot be auto-approved).
## Known limitations / next steps
- `out/tag_cloud.json` must exist for tags endpoint (118 tags as of
2026-09-06).
- `0 committee_members` roster — committee lookup degrades gracefully when
no roster data is supplied.
- Dev auth is a JSON-file local account, not production auth.
- ZIP -> district uses a documented demo dataset until roster ETL lands.
+45
View File
@@ -0,0 +1,45 @@
"""``scraper.dashboard`` — spec §5 (UI build, task t_b38e9af2).
An anonymous + logged-in legislative dashboard served over the stdlib HTTP
server (no heavy framework), backed by the correlation lane's relational DB
(``bills`` / ``committees`` / ``members`` / ``committee_members`` /
``bill_committees`` + the flat ``daily_file_records`` view) and the LLM lane's
``bill_tags`` table.
Submodules
----------
* :mod:`scraper.dashboard.ranking` — pure float-to-top ranking + ``hearing_sort``
+ ``matched[]`` transparency. No I/O.
* :mod:`scraper.dashboard.context` — :class:`RelevanceContext` builder (prefs ->
watched-author / watched-committee / opt-in
tag sets).
* :mod:`scraper.dashboard.data` — read-only DB access (bill events, tag cloud,
legislators, districts).
* :mod:`scraper.dashboard.prefs` — ``user_prefs`` + watch/topic persistence +
a *local/minimal* auth placeholder (D1).
* :mod:`scraper.dashboard.resolve` — ZIP/address -> district -> legislators
(two-tier, D2), with confidence.
* :mod:`scraper.dashboard.api` — request handlers producing the frozen
API_CONTRACT.md shapes / errors / empty states.
* :mod:`scraper.dashboard.server` — stdlib ``http.server`` + static frontend.
* :mod:`scraper.dashboard.dashboard_cli` — guardedly-wired ``dashboard`` CLI
subcommand.
FROZEN CONTRACTS honoured (task t_b38e9af2 / parent t_21cd58a9):
* Python 3.9.6, stdlib only — no new third-party dependency (the server uses
``http.server`` / ``urllib``, mirroring the LLM lane's degradation posture).
* Field names match ``design/API_CONTRACT.md`` verbatim.
* The ranking model is DESIGN_DASHBOARD §4: a bill floats to the top iff
``author ∩ watched_legislators ≢ ∅`` OR ``bill.tags ∩ opted_in_tags ≢ ∅``.
* Never fabricate: a committee with no roster produces no committee membership
(inherited from the correlation lane); the committee-of-watched path simply
does not fire for such bills (degrades to author + tag only).
"""
from __future__ import annotations
__all__ = ["ranking", "context", "data", "prefs", "resolve", "api", "server"]
#: Default personalization band size (DESIGN_DASHBOARD §4: "top N = 10").
PERSONAL_BAND_N = 10
+438
View File
@@ -0,0 +1,438 @@
# noqa: E501
"""API handlers producing the frozen API_CONTRACT.md shapes (spec §5).
Handler(req) -> (status, body). Driven by the stdlib HTTP server and by unit
tests with no sockets. A Services bundle carries collaborators so handlers stay
pure of instantiation. Errors per §6; empty results are 200 with empty lists.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from . import data as _data
from . import ranking
from . import resolve as _resolve
from .data import (load_tag_cloud, list_events,
load_legislators, committee_roster_for)
from .ranking import hearing_sort, full_queue, personal_band
from .context import from_prefs
from .prefs import PrefsStore, LocalAuth
# error model (§6)
def api_error(code, message, status=400, field=None):
err = {"error": {"code": code, "message": message}}
if field:
err["error"]["field"] = field
return status, err
# service bundle
class Services:
def __init__(self, db_path, prefs, auth):
self.db_path = db_path
self.prefs = prefs
self.auth = auth
@classmethod
def default(cls, db_path=None,
prefs_path="out/prefs.json",
auth_path="out/auth.json"):
from .. import relational
db = db_path or relational.DEFAULT_DB
return cls(db_path=db,
prefs=PrefsStore(prefs_path),
auth=LocalAuth(auth_path))
def prefs_for_user(self, user_id):
if not user_id:
return {}
return self.prefs.get(user_id)
def user_from_token(self, token):
if not token:
return None
return self.auth.verify_token(token)
# request
class Request:
def __init__(self, method, path, query=None, body=None,
token=None, services=None, user_id=None):
self.method = str(method).upper()
self.path = str(path).lstrip("/")
self.query = query or {}
self.body = body or {}
self.token = token
self.services = services
self.user_id = user_id
@property
def svc(self):
if self.services is None:
raise RuntimeError("Request built without a Services bundle.")
return self.services
def require_auth(self):
uid = self.user_id or self.svc.user_from_token(self.token)
if not uid:
return None, api_error("not_authenticated",
"A bearer token is required for this endpoint.",
401)
self.user_id = uid
return uid, None
# query helpers
def q_list(key, query):
v = query.get(key)
if v is None:
return []
if isinstance(v, list):
return [str(x).strip() for x in v if str(x).strip()]
return [s.strip() for s in str(v).split(",") if s.strip()]
def _now_iso():
import datetime
return datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ")
def _truthy(v):
if v is None:
return False
if isinstance(v, str):
return v.strip().lower() in ("1", "true", "yes", "on")
return bool(v)
# GET /api/v1/bills (§1/§2)
def handle_bills(req):
q = req.query
events = list_events(
db_path=req.svc.db_path,
upcoming=_truthy(q.get("upcoming")),
from_date=q.get("from") or q.get("date_from"),
to_date=q.get("to") or q.get("date_to"),
chamber=q.get("chamber"),
tags=q_list("tags", q),
q=q.get("q"),
author=q.get("author"),
as_of=q.get("as_of"),
)
if _truthy(q.get("personal")):
uid, err = req.require_auth()
if err:
return err
prefs = req.svc.prefs_for_user(uid)
roster = committee_roster_for(
req.svc.db_path,
set(prefs.get("watched_legislators") or []))
ctx = from_prefs(prefs, committee_roster=roster)
for ev in events:
ev["relevance"] = ctx.score(ev)
if _truthy(q.get("full")):
events = full_queue(events)
else:
events = personal_band(
events, int(q.get("band_n") or ranking.PERSONAL_BAND_N))
session = _detect_session(events, req.svc.db_path)
items = [_client_bill(e) for e in events]
groups = {}
for e in items:
key = e.get("date") or "TBD"
groups[key] = groups.get(key, 0) + 1
chamber_avail = _chamber_availability(items, req.svc.db_path)
body = {
"session": session,
"as_of": q.get("as_of") or _now_iso(),
"chamber_availability": chamber_avail,
"items": items,
"groups": groups,
}
return 200, body
def _client_bill(ev):
out = {k: ev.get(k) for k in _data._EVENT_FIELDS}
out["hearing_sort"] = hearing_sort(ev.get("hearing_time"))
out["tags"] = ev.get("tags")
if "relevance" in ev:
out["relevance"] = ev["relevance"]
return out
def _detect_session(events, db_path):
for e in events:
if e.get("session"):
return str(e["session"])
return "2025-2026"
def _chamber_availability(items, db_path):
have = {str(e.get("chamber_origin") or "ASSEMBLY").upper() for e in items}
if not have:
for e in list_events(db_path=db_path):
have.add(str(e.get("chamber_origin") or "ASSEMBLY").upper())
out = {}
for ch in ("ASSEMBLY", "SENATE"):
present = ch in have or any(h == ch for h in have)
out[ch] = "available" if present else "pending"
return out
# GET /api/v1/tags (§4.1)
def handle_tags(req):
top = req.query.get("top")
top = int(top) if top and str(top).isdigit() else None
return 200, load_tag_cloud(req.svc.db_path, top=top)
# GET /api/v1/legislators (§3.2 / §4.2)
def handle_legislators(req):
q = req.query.get("q")
if isinstance(q, list):
q = q[0]
return 200, {"items": load_legislators(req.svc.db_path, q=q)}
# GET /api/v1/districts (§4.2)
def handle_districts(req):
seen = {}
for entry in _resolve.DEMO_ZIPS.values():
for d in entry:
seen[d["district_id"]] = d
items = [
{"district_id": d["district_id"], "type": d["type"],
"number": d.get("number"),
"assembly_member": d.get("assembly_member"),
"senate_member": d.get("senate_member")}
for d in seen.values()
]
return 200, {"items": sorted(items, key=lambda x: x["district_id"])}
# POST /api/v1/districts/resolve (§3.1 — proposes, never persists)
def handle_resolve(req):
b = req.body or {}
zip_ = b.get("zip")
address = b.get("address")
district = b.get("district")
if not zip_ and not address and not district:
return api_error("invalid_zip",
"Provide one of: zip, address, or district.",
400, field="zip")
result = _resolve.resolve(zip=zip_, address=address, district=district)
conf = result["confidence"]
if conf == "out-of-state":
return api_error("out_of_state",
"That input is not in California.",
400, field="zip")
if conf == "zero":
return api_error("district_not_found",
"Could not match the input to a California district.",
404, field="zip" if zip_ else "address")
if conf == "ambiguous":
out = dict(result)
out["note"] = ("Multiple districts match; confirm one before "
"watching (never auto-picked).")
return 200, out
return 200, result
# /api/v1/prefs (§3.3)
def handle_get_prefs(req):
uid, err = req.require_auth()
if err:
return err
prefs = req.svc.prefs_for_user(uid)
if not prefs:
return api_error("prefs_not_set",
"No preferences stored; set a ZIP / watch / topics.",
404)
prefs = dict(prefs)
prefs["user_id"] = uid
prefs["updated_at"] = prefs.get("updated_at") or _now_iso()
return 200, prefs
def handle_put_prefs(req):
uid, err = req.require_auth()
if err:
return err
b = dict(req.body or {})
allowed = {"zip", "address", "home_chamber", "districts",
"watched_legislators", "opted_in_tags"}
valid_tags = _valid_tag_set(req)
known_legs = {x["legislator_id"] for x in
load_legislators(req.svc.db_path)}
for k, v in b.items():
if k not in allowed:
return api_error("invalid_config",
"Unknown prefs field: %s" % k, 400, field=k)
if k == "opted_in_tags":
for t in v:
if str(t).lower() not in valid_tags:
return api_error("tag_unknown",
"Unknown topic: %s" % t, 400,
field="opted_in_tags")
if k == "watched_legislators":
for w in v:
if known_legs and w not in known_legs:
return api_error("legislator_unknown",
"Unknown legislator: %s" % w, 400,
field="watched_legislators")
clean = {k: b[k] for k in b if k in allowed}
clean["updated_at"] = _now_iso()
updated = req.svc.prefs.put(uid, clean)
updated["user_id"] = uid
return 200, updated
def _valid_tag_set(req):
tags = load_tag_cloud(req.svc.db_path)["tags"]
return {t["tag_id"].split(":", 1)[-1].lower() for t in tags}
# watch / topic toggles (§3.4 / §3.5 — idempotent)
def handle_add_watch(req):
uid, err = req.require_auth()
if err:
return err
b = req.body or {}
lid = b.get("legislator_id") or b.get("id")
if not lid:
return api_error("invalid_legislator",
"legislator_id is required.", 400,
field="legislator_id")
if not _known_legislator(req, lid):
return api_error("legislator_unknown",
"Unknown legislator: %s" % lid, 400, field="id")
prefs = req.svc.prefs.add_watch(uid, lid)
prefs["user_id"] = uid
return 201, prefs
def handle_remove_watch(req, legislator_id):
uid, err = req.require_auth()
if err:
return err
prefs = req.svc.prefs.remove_watch(uid, legislator_id)
prefs["user_id"] = uid
return 200, prefs
def handle_add_topic(req):
uid, err = req.require_auth()
if err:
return err
b = req.body or {}
tag = b.get("tag") or b.get("tag_id") or b.get("id")
if not tag:
return api_error("invalid_tag", "tag is required.", 400, field="tag")
if str(tag).lower() not in _valid_tag_set(req):
return api_error("tag_unknown",
"Unknown topic: %s" % tag, 400, field="tag")
prefs = req.svc.prefs.add_topic(uid, str(tag).lower())
prefs["user_id"] = uid
return 201, prefs
def handle_remove_topic(req, tag):
uid, err = req.require_auth()
if err:
return err
prefs = req.svc.prefs.remove_topic(uid, tag)
prefs["user_id"] = uid
return 200, prefs
def _known_legislator(req, lid):
return any(x.get("legislator_id") == lid
for x in load_legislators(req.svc.db_path))
# Minimal local-account auth (D1a default — dev placeholder)
def handle_register(req):
email = (req.body or {}).get("email", "").strip().lower()
if not email or "@" not in email:
return api_error("invalid_email",
"A valid email is required.", 400, field="email")
rec = req.svc.auth.register(email)
req.svc.prefs.put(rec["user_id"], {"email": rec["email"]})
return 201, rec
def handle_whoami(req):
uid = req.user_id or req.svc.user_from_token(req.token)
if not uid:
return {"user_id": None}
return {"user_id": uid, "prefs": req.svc.prefs_for_user(uid)}
# router
def _strip(req):
p = req.path
base = "api/v1/"
return p[len(base):] if p.startswith(base) else p
def run(req):
m = req.method
p2 = _strip(req)
if m == "POST" and p2 == "auth/register":
return handle_register(req)
if m == "GET" and p2 == "whoami":
return 200, handle_whoami(req)
if m == "GET" and p2 == "bills":
return handle_bills(req)
if m == "GET" and p2 == "tags":
return handle_tags(req)
if m == "GET" and p2 == "legislators":
return handle_legislators(req)
if m == "GET" and p2 == "districts":
return handle_districts(req)
if m == "POST" and p2 == "districts/resolve":
return handle_resolve(req)
if m == "GET" and p2 == "prefs":
return handle_get_prefs(req)
if m == "PUT" and p2 == "prefs":
return handle_put_prefs(req)
if m == "POST" and p2 == "prefs/watch":
return handle_add_watch(req)
if m == "DELETE" and p2.startswith("prefs/watch/"):
return handle_remove_watch(req, p2.split("/", 3)[2])
if m == "POST" and p2 == "prefs/topics":
return handle_add_topic(req)
if m == "DELETE" and p2.startswith("prefs/topics/"):
return handle_remove_topic(req, p2.split("/", 3)[2])
if m == "GET" and p2 == "":
return 200, {"endpoints": [
"GET /api/v1/bills?upcoming=1&personal=1",
"GET /api/v1/tags",
"GET /api/v1/legislators?q=",
"GET /api/v1/districts",
"POST /api/v1/districts/resolve",
"GET/PUT /api/v1/prefs",
"POST/DELETE /api/v1/prefs/watch[/:id]",
"POST/DELETE /api/v1/prefs/topics[/:id]",
"POST /api/v1/auth/register",
"GET /api/v1/whoami",
]}
return api_error("not_found",
"No such endpoint: %s %s" % (m, req.path), 404)
+115
View File
@@ -0,0 +1,115 @@
"""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
+84
View File
@@ -0,0 +1,84 @@
"""`dashboard` CLI subcommand (spec §5, task t_b38e9af2).
Wires the dashboard server into the shared `scraper/cli.py` ADDITIVELY +
DEFENSIVELY, mirroring parser/correlate/analyze (the guarded `register()`
pattern): `cli.py` calls `register(sub)` inside a try/except and adds its
handler without touching any sibling handler. The `dashboard` name does not
collide.
This is the CLI/app entry point per the task contract. The server itself lives
in `scraper/dashboard/server.py`; this module just parses flags and calls
`server.serve(...)`.
"""
from __future__ import annotations
import logging
from .. import relational
from . import server as _server
log = logging.getLogger("scraper.dashboard_cli")
def _already_registered(sub, name: str) -> bool:
actions = getattr(sub, "_choices_actions", [])
return any(getattr(a, "name", None) == name for a in actions)
def register(sub) -> None:
"""Register the `dashboard` subcommand (guarded — skip if a sibling owns it)."""
if _already_registered(sub, "dashboard"):
return
pd = sub.add_parser(
"dashboard",
help="serve the legislative dashboard (API + static UI)",
description=("Serve the legislative dashboard. Reads the relational DB "
"(bills/committees/members + bill_tags) and serves the "
"API_CONTRACT.md endpoints + the static frontend."),
)
pd.add_argument("--host", default="127.0.0.1",
help="bind host (default 127.0.0.1)")
pd.add_argument("--port", type=int, default=8000,
help="bind port (default 8000)")
pd.add_argument("--db", default=relational.DEFAULT_DB,
help="SQLite path (default db/daily_file.sqlite)")
pd.add_argument("--prefs", default="out/prefs.json",
help="preferences JSON file (default out/prefs.json)")
pd.add_argument("--auth", default="out/auth.json",
help="local-auth JSON file (default out/auth.json)")
pd.add_argument("--no-static", dest="static", action="store_false",
help="API only, no static frontend")
pd.add_argument("--log", action="store_true",
help="log HTTP requests to stderr")
pd.set_defaults(func=handle_dashboard)
def handle_dashboard(args) -> int:
"""CLI handler for the `dashboard` subcommand."""
return _server.serve(
host=args.host,
port=args.port,
db_path=args.db,
prefs_path=args.prefs,
auth_path=args.auth,
serve_static=getattr(args, "static", True),
log_on=getattr(args, "log", False),
)
def main(argv=None) -> int:
"""Direct invocation: `python -m scraper.dashboard_cli`."""
import argparse
p = argparse.ArgumentParser(
prog="scraper.dashboard_cli",
description="Serve the legislative dashboard.")
sub = p.add_subparsers(dest="command", required=True)
register(sub)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
import sys
sys.exit(main())
+335
View File
@@ -0,0 +1,335 @@
"""Read-only access to the dashboard's data sources (spec §5).
The dashboard never mutates the relational DB. It reads:
* the flat ``v_daily_file_records`` view (or ``daily_file_records``) for the
upcoming-events queue — one bill event per ``(date, bill_id)``, enriched
with per-bill ``tags`` from the LLM lane's ``bill_tags`` table;
* ``bill_tags`` for the tag cloud (§4.1);
* ``members`` / ``committees`` / ``bill_committees`` / ``committee_members`` for
the legislators reference data (§3.2 / §4.2) and the
committee-of-watched-legislator float path.
Everything here returns plain JSON-serialisable dicts so the API layer can
ship them verbatim into the API_CONTRACT shapes.
"""
from __future__ import annotations
import os
import re
import sqlite3
from typing import Any, Dict, List, Optional, Sequence
from .. import relational
FLAT_TABLE = relational.FLAT_TABLE
VIEW_NAME = "v_" + FLAT_TABLE
# Fields a bill event carries into the API_CONTRACT §1 Bill shape.
# `tags` is injected separately (from bill_tags), not a stored column.
_EVENT_FIELDS = (
"bill_id", "bill_number", "chamber_origin", "author", "short_title",
"description", "committee", "hearing_time", "location", "date",
"session", "item_number", "supplement", "source",
)
def _slug(name: Optional[str]) -> str:
if not name:
return ""
return re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") or "unknown"
def _title_case(s: str) -> str:
return " ".join(w[:1].upper() + w[1:] for w in s.split() if w)
def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
cur = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,))
return cur.fetchone() is not None
def _view_exists(conn: sqlite3.Connection, name: str) -> bool:
cur = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='view' AND name=?", (name,))
return cur.fetchone() is not None
def _flat_source(conn: sqlite3.Connection) -> str:
"""Prefer the flat-preserving view (the QA contract); fall back to the table."""
return VIEW_NAME if _view_exists(conn, VIEW_NAME) else FLAT_TABLE
# ---------------------------------------------------------------------------
# Tags (LLM lane: bill_tags table, §4.1)
# ---------------------------------------------------------------------------
def load_bill_tags(db_path: str = relational.DEFAULT_DB) -> Dict[str, List[str]]:
"""Map ``bill_id`` -> [tag, ...] from the ``bill_tags`` table.
Absent/empty table -> ``{}`` so a bill with no analysis degrades to
``tags: []`` (DESIGN §6.6 — no chips, ranking by legislator only).
"""
tags: Dict[str, List[str]] = {}
if not os.path.exists(db_path):
return tags
conn = relational.connect(db_path, foreign_keys=False)
try:
if not _table_exists(conn, "bill_tags"):
return tags
rows = conn.execute(
"SELECT bill_id, tag FROM bill_tags "
"WHERE bill_id IS NOT NULL AND tag IS NOT NULL AND tag != '' "
"ORDER BY bill_id, tag").fetchall()
finally:
conn.close()
for r in rows:
bid = r["bill_id"]
tag = r["tag"]
bucket = tags.setdefault(bid, [])
if tag not in bucket:
bucket.append(tag)
return tags
def load_tag_cloud(db_path: str = relational.DEFAULT_DB,
as_of: Optional[str] = None,
top: Optional[int] = None) -> Dict[str, Any]:
"""The /tags cloud (§4.1): ``[{tag_id, label, count}, ...]`` sorted desc.
Count = number of bills carrying the tag. May be sparse early (LLM lane
warming) — the UI shows a "filling in" hint, never a 0-count chip.
"""
import datetime
conn = relational.connect(db_path, foreign_keys=False)
try:
if not _table_exists(conn, "bill_tags"):
data = []
else:
data = conn.execute(
"SELECT tag, COUNT(DISTINCT bill_id) AS n FROM bill_tags "
"WHERE tag IS NOT NULL AND tag != '' GROUP BY tag "
"ORDER BY n DESC, tag").fetchall()
finally:
conn.close()
out = []
for r in data:
out.append({
"tag_id": "tag:" + str(r["tag"]),
"label": _title_case(str(r["tag"])),
"count": int(r["n"]),
})
if top:
out = out[:top]
return {
"as_of": as_of or _now_iso(),
"tags": out,
}
def _now_iso() -> str:
import datetime
return datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ")
# ---------------------------------------------------------------------------
# Upcoming-events queue (the daily-file calendar, §2 / §5 "upcoming")
# ---------------------------------------------------------------------------
def _in_window(date: Optional[str], frm: Optional[str], to: Optional[str],
as_of: Optional[str]) -> bool:
"""API_CONTRACT §5 'upcoming' gate on event_date (= date here).
* a T-1 day (date < as_of) is EXCLUDED when an as_of floor is set;
* 'this week'/'this month' are from/to windows over event_date.
Dates are ISO ``YYYY-MM-DD`` so string compare == chronological.
"""
if not date:
return not frm and not to
if frm and date < frm:
return False
if to and date > to:
return False
if as_of and date < as_of:
return False
return True
def list_events(db_path: str = relational.DEFAULT_DB,
upcoming: bool = False,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
chamber: Optional[str] = None,
tags: Optional[Sequence[str]] = None,
q: Optional[str] = None,
author: Optional[str] = None,
as_of: Optional[str] = None) -> List[Dict[str, Any]]:
"""Build the bill-event queue, one event per ``(date, bill_id)``.
* ``upcoming=True`` + ``as_of`` -> keep only future-dated events
(API_CONTRACT §5). When the caller omits ``as_of`` the default is the
latest date present, so a demo on a single historical day still shows its
queue; pin ``as_of`` for a true "future-of-now" view.
* dedupe *within* a date by ``bill_id`` (multiple agenda items the same day
collapse to one); across dates they are distinct events.
* enriched with per-bill ``tags`` from the LLM lane.
* optional filters: chamber, tags (any-match), free-text ``q``, author.
Returns list of dicts matching API_CONTRACT §1 **without** the ``relevance``
block (the API layer adds that for logged-in views).
"""
conn = relational.connect(db_path, foreign_keys=False)
try:
rows = conn.execute(
"SELECT * FROM " + _flat_source(conn) +
" WHERE bill_id IS NOT NULL AND bill_id != '' "
"ORDER BY date, house, bill_id, committee, id").fetchall()
finally:
conn.close()
flat = [dict(r) for r in rows]
bill_tags = load_bill_tags(db_path)
tag_filter = {t.strip().lower() for t in (tags or []) if str(t).strip()}
ql = (q or "").strip().lower()
al = (author or "").strip().lower()
if as_of is None:
dates = sorted({r["date"] for r in flat if r.get("date")})
as_of = dates[-1] if dates else None
# Collapse to one event per (date, bill_id); prefer a row carrying a hearing.
seen: Dict[tuple, Dict[str, Any]] = {}
order: List[tuple] = []
for r in flat:
key = (r.get("date"), r.get("bill_id"))
if key in seen:
if not seen[key].get("_has_time") and r.get("hearing_time"):
seen[key] = r
continue
r["_has_time"] = bool(r.get("hearing_time"))
seen[key] = r
order.append(key)
events = []
for key in order:
r = seen[key]
origin = str(r.get("chamber_origin") or r.get("house") or "").lower()
if chamber and origin != chamber.lower():
continue
if not _in_window(r.get("date"), from_date, to_date, as_of):
continue
btags = bill_tags.get(r["bill_id"], [])
if tag_filter and not (tag_filter & {t.lower() for t in btags}):
continue
if ql:
hay = " ".join(str(r.get(f) or "") for f in
("short_title", "description", "bill_number",
"author", "committee")).lower()
if ql not in hay:
continue
if al and al not in str(r.get("author") or "").lower():
continue
ev = {f: r.get(f) for f in _EVENT_FIELDS}
for f in ("hearing_time", "location", "committee", "short_title",
"author", "item_number", "supplement", "date", "session"):
if ev.get(f) == "":
ev[f] = None
ev["tags"] = btags if btags else None
ev["source"] = r.get("source") or "ASSEMBLY_DAILY_FILE"
events.append(ev)
return events
# ---------------------------------------------------------------------------
# Legislators + districts reference data (§3.2 / §4.2)
# ---------------------------------------------------------------------------
def load_legislators(db_path: str = relational.DEFAULT_DB,
q: Optional[str] = None) -> List[Dict[str, Any]]:
"""Browse legislators (members table) with their committees + domains.
``domains`` = the domain tags of the committees a member sits on
(committee -> its bills' tags). Degrades to an empty list when the
correlation lane has no roster (no committee_members).
"""
conn = relational.connect(db_path, foreign_keys=False)
try:
members = [dict(r) for r in conn.execute(
"SELECT name_norm, full_name, chamber, kind "
"FROM members ORDER BY full_name, chamber").fetchall()]
rows = conn.execute(
"SELECT cm.member, c.name_raw AS committee "
"FROM committee_members cm "
"JOIN committees c ON c.name_key=cm.committee "
"AND c.chamber=cm.chamber AND c.session=cm.session "
"ORDER BY cm.member, c.name_raw").fetchall()
served: Dict[str, List[str]] = {}
for r in rows:
served.setdefault(r["member"], []).append(r["committee"])
if _table_exists(conn, "bill_tags"):
cmt_rows = conn.execute(
"SELECT c.name_raw AS cn, t.tag FROM bill_committees bc "
"JOIN committees c ON c.name_key=bc.committee "
"AND c.chamber=bc.chamber AND c.session=bc.session "
"JOIN bill_tags t ON t.bill_id=bc.bill_id").fetchall()
cmt_tags: Dict[str, set] = {}
for r in cmt_rows:
cmt_tags.setdefault(r["cn"], set()).add(r["tag"])
else:
cmt_tags = {}
finally:
conn.close()
ql = (q or "").strip().lower()
out = []
for m in members:
slug = m["name_norm"]
committees = served.get(slug, [])
domains = sorted({t for cn in committees for t in cmt_tags.get(cn, set())})
if ql and ql not in str(m.get("full_name") or slug).lower():
continue
out.append({
"legislator_id": slug,
"name": m.get("full_name") or slug.title(),
"party": None,
"chamber": (m.get("chamber") or "").upper(),
"district": None,
"committees": committees,
"domains": domains,
})
return out
def committee_roster_for(db_path: str = relational.DEFAULT_DB,
legislator_ids=None) -> Dict[str, List[str]]:
"""Map a watched legislator (by its ``member`` slug) to the committee
name_keys that legislator is known to serve on, via the correlation lane's
``committee_members`` join.
Used by the ranking to fire the ``committee-of-watched-legislator`` float
path. When the correlation lane found no roster (``committee_members``
empty — the honest default), this returns ``{}`` and that path does not
fire, so nothing is fabricated.
"""
conn = relational.connect(db_path, foreign_keys=False)
try:
if not _table_exists(conn, "committee_members"):
return {}
rows = conn.execute(
"SELECT DISTINCT cm.member AS m, cm.committee AS ck "
"FROM committee_members cm").fetchall()
finally:
conn.close()
roster: Dict[str, List[str]] = {}
for r in rows:
m = r["m"]
if legislator_ids is None or m in legislator_ids:
bucket = roster.setdefault(m, [])
if r["ck"] not in bucket:
bucket.append(r["ck"])
return roster
+181
View File
@@ -0,0 +1,181 @@
"""Personalization store + watch/topic toggles + a minimal dev auth (spec §5).
The persistence is a small JSON file (documented in DASHBOARD.md) holding one
preferences blob per user_id, matching API_CONTRACT section 3.3. It is
intentionally simple and swappable: replacing PrefsStore with a DB-backed store
is a local change; the preferences *shape* (and therefore the whole contract)
is auth-method-independent, which is the point of Open Decision D1.
A minimal LOCAL account (LocalAuth) is provided as the D1a default so the
dashboard is runnable end-to-end without an IdP. It is explicitly a development
placeholder (no password hashing, single-host JSON): swap it for a real auth
service in production. Auth is NOT modelled in the data contract.
"""
from __future__ import annotations
import hashlib
import json
import os
import uuid
from typing import Any, Dict, List, Optional
# ---------------------------------------------------------------------------
# Preferences store (API_CONTRACT section 3.3)
# ---------------------------------------------------------------------------
class PrefsStore:
"""File-backed per-user preferences. One blob per user_id.
On-disk shape is `{"u_001": {...}, ...}` so the store is human-readable and
diff-stable. All mutating ops are idempotent: re-adding an existing
watch/topic is a no-op, and removing an absent one does not error.
"""
def __init__(self, path):
self.path = path
self._data = self._load()
# -- persistence --------------------------------------------------
def _load(self):
if not os.path.exists(self.path):
return {}
try:
with open(self.path, encoding="utf-8") as fh:
return json.load(fh) or {}
except (ValueError, OSError):
return {}
def _save(self):
os.makedirs(os.path.dirname(os.path.abspath(self.path)) or ".",
exist_ok=True)
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(self._data, fh, indent=2, sort_keys=True)
os.replace(tmp, self.path)
# -- reads --------------------------------------------------------
def get(self, user_id):
return self._data.get(user_id) or {}
def has(self, user_id):
return user_id in self._data and bool(self._data[user_id])
def list_users(self):
return sorted(k for k in self._data.keys() if self._data[k])
# -- writes -------------------------------------------------------
def put(self, user_id, prefs):
merged = self.get(user_id)
merged.update(prefs or {})
self._clean(merged)
self._data[user_id] = merged
self._save()
return merged
def add_watch(self, user_id, legislator_id):
prefs = self.get(user_id)
watch = list(prefs.get("watched_legislators") or [])
if legislator_id not in watch:
watch.append(legislator_id)
return self._set(user_id, "watched_legislators", watch)
def remove_watch(self, user_id, legislator_id):
prefs = self.get(user_id)
watch = [x for x in (prefs.get("watched_legislators") or [])
if x != legislator_id]
return self._set(user_id, "watched_legislators", watch)
def add_topic(self, user_id, tag):
prefs = self.get(user_id)
topics = list(prefs.get("opted_in_tags") or [])
if tag not in topics:
topics.append(tag)
return self._set(user_id, "opted_in_tags", topics)
def remove_topic(self, user_id, tag):
prefs = self.get(user_id)
topics = [t for t in (prefs.get("opted_in_tags") or []) if t != tag]
return self._set(user_id, "opted_in_tags", topics)
def _set(self, user_id, key, value):
prefs = self.get(user_id)
prefs[key] = value
self._clean(prefs)
self._data[user_id] = prefs
self._save()
return prefs
@staticmethod
def _clean(prefs):
for key in ("watched_legislators", "opted_in_tags", "districts"):
prefs.setdefault(key, [])
# ---------------------------------------------------------------------------
# Minimal local account (Open Decision D1: D1a default — dev placeholder)
# ---------------------------------------------------------------------------
class LocalAuth:
"""A development local-account auth (email + a derived bearer token).
This is the D1a default so the dashboard runs with no external IdP. It is
deliberately NOT production-grade: no password hashing, no rotation, single
host. Production should swap in a real auth service or the D1b OAuth path;
the preferences shape is identical, so nothing else in the system changes.
"""
def __init__(self, path):
self.path = path
self._data = self._load()
def _load(self):
if not os.path.exists(self.path):
return {}
try:
with open(self.path, encoding="utf-8") as fh:
return json.load(fh) or {}
except (ValueError, OSError):
return {}
def _save(self):
os.makedirs(os.path.dirname(os.path.abspath(self.path)) or ".",
exist_ok=True)
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(self._data, fh, indent=2, sort_keys=True)
os.replace(tmp, self.path)
@staticmethod
def _new_user_id():
return "u_" + uuid.uuid4().hex[:8]
@staticmethod
def _token_for(user_id, email):
h = hashlib.sha256(("local:" + user_id + ":" + email).encode()).hexdigest()
return "dev." + h[:24]
def register(self, email, user_id=None):
email = (email or "").strip().lower()
for uid, rec in self._data.items():
if rec.get("email") == email:
rec["token"] = self._token_for(uid, email)
self._save()
return {"user_id": uid, "token": rec["token"], "email": email}
uid = user_id or self._new_user_id()
self._data[uid] = {"email": email, "token": self._token_for(uid, email)}
self._save()
return {"user_id": uid, "token": self._data[uid]["token"], "email": email}
def verify_token(self, token):
if not token:
return None
for uid, rec in self._data.items():
t = rec.get("token") or ""
if token == t or token == t[4:] or token == uid:
return uid
return None
def user_by_id(self, user_id):
return self._data.get(user_id)
+310
View File
@@ -0,0 +1,310 @@
"""Pure ranking core for the legislative dashboard (spec §5, DESIGN_DASHBOARD §4).
This module is deliberately **I/O-free**: it takes plain ``dict``-shaped bill
rows (``scraper.schema.BillAction`` fields, optionally with a ``tags`` list) and
returns them in float-to-top order. Keeping the ranking pure is what lets the
rank logic be unit-tested without a database, and is the heart of §5.
The rule (DESIGN_DASHBOARD §4, verbatim intent)::
A bill FLOATS TO THE TOP iff
author ∩ watched_legislators ≠ ∅ (author path)
OR bill.tags ∩ opted_in_tags ≠ ∅ (tag path)
OR (committee-of-watched-legistrator path) # committee -> members join
Scoring (design default, tunable — Open Decision D3)::
score = 3·(author is watched)
+ 2·(author sits-in a watched legislator's committee)
+ 2·( |tags ∩ opted_in_tags| )
+ 1·(chamber == user's home chamber hint)
Band membership = ``score > 0``. Intra-band order = descending score, then
``hearing_sort`` ascending (a future, earlier hearing floats ahead of a later
one with the same score). The general band keeps source order.
Nothing here is "hidden": floated bills are *promoted* (a ``Relevant to you``
marker / a 3px meaning bar), never removed — so a bill appears in both the
personal band and the full queue.
"""
from __future__ import annotations
import re
from typing import Dict, List, Optional, Sequence, Set
from . import PERSONAL_BAND_N
# ---------------------------------------------------------------------------
# hearing_sort (API_CONTRACT §1)
# ---------------------------------------------------------------------------
# "Upon Call of the Chair" / "Call of the Chair" sort LAST; a missing/unknown
# time also sinks to the end of its day (design: time TBD note). A large
# sentinel keeps them last without inventing a wall-clock value.
_SORT_LAST = 24 * 60 * 60 + 60000 # > any HH:MM, so call-of-chair always sinks
_CALL_OF_CHAIR_RE = re.compile(r"call of the chair", re.I)
_AMPM_RE = re.compile(r"^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$", re.I)
# A bare clock like "9:30" (no am/pm) — treat as 24h HH:MM.
_HHMM_RE = re.compile(r"^(\d{1,2}):([0-5]?\d)$")
def hearing_sort(hearing_time: Optional[str]) -> int:
"""Derive a sortable integer for ``hearing_time``.
API_CONTRACT §1:
* ``"9:30am"`` / ``"10 minutes prior to Session"``-style wall times ->
seconds-from-midnight.
* ``"Upon Call of the Chair"`` / ``"Call of the Chair"`` -> **max** (last).
* ``None`` / unparseable -> **max** (last), with the row still carrying
``hearing_time: None`` so the UI can render a "time TBD" note.
Returns an int suitable for ascending sort within a day.
"""
if not hearing_time:
return _SORT_LAST
s = str(hearing_time).strip()
if not s:
return _SORT_LAST
if _CALL_OF_CHAIR_RE.search(s):
return _SORT_LAST
m = _AMPM_RE.match(s)
if m:
hour = int(m.group(1))
minute = int(m.group(2) or 0)
period = m.group(3).lower()
if period == "pm" and hour != 12:
hour += 12
if period == "am" and hour == 12: # 12am == midnight
hour = 0
return hour * 3600 + minute * 60
m = _HHMM_RE.match(s)
if m:
return int(m.group(1)) * 3600 + int(m.group(2)) * 60
# Unparseable (e.g. "10 minutes prior to Session") -> sinks last; that is
# the designed "time TBD" behaviour, not a crash.
return _SORT_LAST
# ---------------------------------------------------------------------------
# Relevance scoring (DESIGN_DASHBOARD §4, weights per Open Decision D3)
# ---------------------------------------------------------------------------
class Weights:
"""Scoring weights. Defaults match DESIGN_DASHBOARD §4.
These are a *design default*, not frozen — Open Decision D3 flags them as
tunable. The :class:`Weights` object is kept explicit (rather than module
globals) so tests and the API can pass alternative weights without global
mutation.
"""
def __init__(self, author_watched: int = 3, committee_watched: int = 2,
tag_watched: int = 2, home_chamber: int = 1):
self.author_watched = author_watched
self.committee_watched = committee_watched
self.tag_watched = tag_watched
self.home_chamber = home_chamber
DEFAULT_WEIGHTS = Weights()
def normalise_author(author: Optional[str]) -> str:
"""Reduce a bill ``author`` string ("Schiavo et al.", "Macedo") to the
primary legislator's slug, matching the member slugs the resolve/prefs
layer keys on. Mirrors ``relational.slug`` / author-stem stripping."""
if not author:
return ""
s = re.sub(r"\s+et al\.$", "", author.strip())
s = re.sub(r"\s+et al\s+", " ", s, flags=re.I)
s = re.sub(r"\s+", " ", s).strip()
return s.lower()
def _slug(name: Optional[str]) -> str:
if not name:
return ""
return re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") or "unknown"
def _match_tags(bill_tags: Sequence[str],
opted: Set[str]) -> List[str]:
"""Opted-in tags that actually appear on this bill (intersection, stable
order = opted-in order), so the "Why is this here?" list is deterministic."""
if not opted:
return []
present = {str(t).strip().lower() for t in (bill_tags or [])}
return [t for t in opted if str(t).strip().lower() in present]
def score_bill(bill: Dict,
watched_authors: Set[str],
watched_committees: Set[str],
opted_in_tags: Set[str],
home_chamber: Optional[str] = None,
weights: Optional[Weights] = None) -> Dict:
"""Compute a relevance ``{score, matched[]}`` for one bill against a user's
preferences.
Parameters
----------
bill: a dict-like with at least ``bill_id``, ``author``, ``committee``,
``chamber_origin`` and optionally ``tags`` (list of tag strings).
watched_authors: lower-cased primary-author slugs the user watches
(``author ∩ watched_legislators`` path).
watched_committees: slugs of committees the user watches (committee-of-
watched-legislator path; ``∅`` when the correlation lane found no
roster — never fabricated).
opted_in_tags: tag strings the user opted into (``tags ∩ opted_in_tags``).
home_chamber: optional chamber hint ("assembly"/"senate") for the +1 term.
weights: scoring weights (defaults to DESIGN_DASHBOARD §4).
Returns
-------
A ``{"score": int, "matched": [{"type", "value"}, ...]}`` dict, matching the
``Bill.relevance`` shape in API_CONTRACT §1. ``matched`` powers the
"Why is this here?" transparency toggle. A single rule suffices to float
(OR); stronger matches score higher.
"""
w = weights or DEFAULT_WEIGHTS
matched: List[Dict[str, str]] = []
score = 0
# --- author path ------------------------------------------------------
author = bill.get("author")
norm_author = normalise_author(author)
if norm_author and norm_author in {a.lower() for a in watched_authors}:
score += w.author_watched
matched.append({
"type": "watched-legislator",
"value": "Author is a watched legislator"
+ ("" if not author else " — " + str(author)),
})
# --- committee-of-watched-legislator path ----------------------------
# The bill sits in a committee the user's watched legislators chair/serve.
# `bill.committee` is the daily-file committee name; watched_committees is
# the slug set of the watched set's committees. (Degrades to a no-op when
# the correlation lane found no roster — honest, never fabricated.)
committee = bill.get("committee")
if committee and _slug(committee) in {c.lower() for c in watched_committees}:
score += w.committee_watched
matched.append({
"type": "committee-of-watched-legislator",
"value": "Sits in a committee your watched legislator chair/serves "
+ ("— " + str(committee) if committee else ""),
})
# --- tag path ---------------------------------------------------------
tag_hits = _match_tags(bill.get("tags") or [],
{t.lower() for t in opted_in_tags})
if tag_hits:
score += w.tag_watched * len(tag_hits)
for t in tag_hits:
matched.append({"type": "tag", "value": "Topic: " + str(t)})
# --- home-chamber hint (optional, +1) --------------------------------
if home_chamber and bill.get("chamber_origin"):
if str(bill["chamber_origin"]).lower() == str(home_chamber).lower():
score += w.home_chamber
matched.append({
"type": "home-chamber",
"value": "Matches your home chamber ("
+ str(bill["chamber_origin"]) + ")",
})
return {"score": int(score), "matched": matched}
# ---------------------------------------------------------------------------
# Bill normalisation
# ---------------------------------------------------------------------------
# Fields the UI consumes from a BillAction row, in API_CONTRACT §1 display order.
_BILL_FIELDS = (
"bill_id", "bill_number", "chamber_origin", "author", "short_title",
"description", "committee", "hearing_time", "location", "date",
"session", "item_number", "supplement", "source", "tags",
)
def normalise_bill(row: Dict, tags: Optional[Sequence[str]] = None) -> Dict:
"""Project a raw DB row onto the API_CONTRACT §1 ``Bill`` shape.
* Keeps the exact field names the contract freezes.
* Adds the derived ``hearing_sort`` (backend-owned, §1).
* Carries ``tags`` from the LLM lane (may be ``[]``/``None`` -> no chips,
ranking degrades to legislator-match only, per §6.6).
* Nulls are preserved (the UI renders TBD, never crashes).
"""
out: Dict = {}
for f in _BILL_FIELDS:
if f == "tags":
out["tags"] = None if tags is None else list(tags)
else:
val = row.get(f)
out[f] = None if val == "" else val
out["hearing_sort"] = hearing_sort(row.get("hearing_time"))
return out
# ---------------------------------------------------------------------------
# Float-to-top ordering (DESIGN_DASHBOARD §4)
# ---------------------------------------------------------------------------
def is_floated(bill: Dict) -> bool:
"""True if the bill carries a re: positive score (i.e. it floats).
``relevance`` is only populated for logged-in requests (§1); anon bills have
no ``relevance`` key.
"""
rel = bill.get("relevance")
return bool(rel and int(rel.get("score", 0)) > 0)
def float_to_top(bills: Sequence[Dict],
personal_band_n: int = PERSONAL_BAND_N) -> List[Dict]:
"""Stably re-sort ``bills`` so floated bills (``relevance.score > 0``) come
first, ordered by ``(score desc, hearing_sort asc)``; non-floated bills keep
their existing relative order.
This is the "promote, never hide" step of DESIGN_DASHBOARD §4: every floated
bill also remains visible in the full queue (marked), only its *position*
changes. Idempotent: running it twice is a no-op.
"""
def key(bill: Dict) -> tuple:
rel = bill.get("relevance") or {}
score = int(rel.get("score", 0))
# -score so higher scores sort earlier; hearing earlier-in-day sorts
# earlier; tie-break on bill_id for total determinism.
return (-score,
int(bill.get("hearing_sort") or _SORT_LAST),
str(bill.get("bill_id") or ""))
# Stable sort (Python's sort is stable; the bill_id tie-break makes it
# total anyway). personal_band_n is accepted for signature parity with the
# band helpers but does NOT truncate the full queue.
_ = personal_band_n
ordered = sorted(bills, key=key)
return ordered
def personal_band(bills: Sequence[Dict],
n: int = PERSONAL_BAND_N) -> List[Dict]:
"""The first ``n`` floated bills (the "Relevant to you" band, top N=10).
Derived by "relevance.score > 0, then hearing_sort", per API_CONTRACT §1 —
i.e. the head of a :func:`float_to_top` ordering. Returns ``[]`` when nothing
is floated (the UI shows the setup / empty state, not a dead band).
"""
band = [b for b in float_to_top(bills) if is_floated(b)]
return band[:n]
def full_queue(bills: Sequence[Dict]) -> List[Dict]:
"""The complete queue, sorted the same way as :func:`float_to_top` but
untruncated — every floated bill is marked and every other bill follows."""
return float_to_top(bills)
+141
View File
@@ -0,0 +1,141 @@
"""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)
+186
View File
@@ -0,0 +1,186 @@
# noqa: E501
"""Stdlib HTTP server for the dashboard (spec §5). No framework.
Exposes the API_CONTRACT endpoints over http.server + a tiny static adapter
for the frontend (scraper/dashboard/static/). A thin layer over
scraper.dashboard.api: parse HTTP line + query + JSON body, build an
api.Request, call api.run, write (status, body) back as JSON.
Run: python -m scraper.dashboard.server
"""
from __future__ import annotations
import json
import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
from . import api
from .api import Request, Services
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"static")
_CONTENT_TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
}
class DashboardHandler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
return self._dispatch("GET")
def do_POST(self):
return self._dispatch("POST")
def do_PUT(self):
return self._dispatch("PUT")
def do_DELETE(self):
return self._dispatch("DELETE")
def _dispatch(self, method):
parsed = urlparse(self.path)
if not parsed.path.startswith("/api"):
return self._serve_static(parsed.path)
try:
query = parse_qs(parsed.query)
flat = {}
for k, v in query.items():
flat[k] = v[0] if len(v) == 1 else v
body = {}
if method in ("POST", "PUT"):
body = self._read_json_body()
token = self._bearer()
req = Request(
method=method,
path=parsed.path,
query=flat,
body=body,
token=token,
services=self.services,
user_id=None,
)
status, payload = api.run(req)
except Exception as exc:
status, payload = 500, {
"error": {"code": "internal_error",
"message": str(exc)}}
return self._write_json(status, payload)
def _bearer(self):
auth = self.headers.get("Authorization", "")
if auth.lower().startswith("bearer "):
return auth[7:].strip()
return None
def _read_json_body(self):
length = int(self.headers.get("Content-Length") or 0)
if length <= 0:
return {}
raw = self.rfile.read(length)
try:
data = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return {}
return data if isinstance(data, dict) else {}
def _serve_static(self, path):
rel = path.lstrip("/") or "index.html"
if ".." in rel or rel.startswith("/"):
return self._write_raw(404, b"not found", "text/plain")
fp = os.path.join(STATIC_DIR, rel)
if os.path.isfile(fp):
ctype = _CONTENT_TYPES.get(
os.path.splitext(fp)[1], "application/octet-stream")
with open(fp, "rb") as fh:
body = fh.read()
return self._write_raw(200, body, ctype)
root = os.path.join(STATIC_DIR, "index.html")
if os.path.isfile(root):
with open(root, "rb") as fh:
body = fh.read()
return self._write_raw(200, body,
"text/html; charset=utf-8")
return self._write_raw(404, b"not found", "text/plain")
def _write_json(self, status, payload):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
return self._write_raw(status, body,
"application/json; charset=utf-8")
def _write_raw(self, status, body, ctype):
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
if status != 204:
self.wfile.write(body)
def make_handler(services, log_on=False):
cls = type("BoundHandler", (DashboardHandler,),
{"services": services})
if log_on:
cls.log_message = BaseHTTPRequestHandler.log_message
return cls
def serve(host="127.0.0.1", port=8000,
db_path=None, prefs_path="out/prefs.json",
auth_path="out/auth.json", serve_static=True,
log_on=False):
services = Services.default(db_path=db_path,
prefs_path=prefs_path,
auth_path=auth_path)
handler = make_handler(services, log_on=log_on)
srv = ThreadingHTTPServer((host, port), handler)
sys.stderr.write(
"dashboard: http://%s:%d/ (static %s)\n"
% (host, port, "on" if serve_static else "off"))
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
finally:
srv.server_close()
return 0
def main(argv=None):
import argparse
p = argparse.ArgumentParser(
prog="scraper.dashboard",
description="Serve the legislative dashboard (API + static UI).")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8000)
p.add_argument("--db", default=None,
help="SQLite path (default: db/daily_file.sqlite)")
p.add_argument("--prefs", default="out/prefs.json",
help="preferences JSON file")
p.add_argument("--auth", default="out/auth.json",
help="local-auth JSON file")
p.add_argument("--no-static", dest="static", action="store_false",
help="serve API only, no static frontend")
p.add_argument("--log", dest="log", action="store_true",
help="log HTTP requests to stderr")
args = p.parse_args(argv)
return serve(host=args.host, port=args.port,
db_path=args.db, prefs_path=args.prefs,
auth_path=args.auth, serve_static=args.static,
log_on=args.log)
if __name__ == "__main__":
sys.exit(main())
+657
View File
@@ -0,0 +1,657 @@
/* ============================================================================
Legislative Dashboard — frontend (spec §5, DESIGN §3/§5/§10).
Vanilla JS + fetch against the stdlib API. Two personas on one surface:
anonymous (overview) and logged-in (personal band + watch/opt-in). No
frameworks, no build step.
========================================================================== */
(function () {
"use strict";
var API = "/api/v1";
var BAND_N = 10;
var state = {
token: localStorage.getItem("dash_token") || null,
user: null,
prefs: null,
filters: { upcoming: true, chamber: "", q: "", tags: [] },
personal: false,
showReason: false,
allItems: [],
tags: [],
};
/* ---------- tiny fetch client ---------- */
function apiFetch(path, opts) {
opts = opts || {};
var headers = { "Content-Type": "application/json" };
if (state.token) headers["Authorization"] = "Bearer " + state.token;
return fetch(API + path, {
method: opts.method || "GET",
headers: headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
}).then(function (r) {
return r.json().then(function (j) {
return { status: r.status, body: j };
});
});
}
function el(id) { return document.getElementById(id); }
/* ---------- render helpers ---------- */
function chip(chamber) {
var c = (chamber || "ASSEMBLY").toLowerCase();
var label = c === "senate" ? "Senate" : "Assembly";
return '<span class="chip ' + (c === "senate" ? "sen" : "asm") + '">'
+ label + "</span>";
}
function legMatches(ev) {
var m = (ev.relevance && ev.relevance.matched) || [];
return m.some(function (x) { return x.type !== "tag"; });
}
function tagMatches(ev) {
var m = (ev.relevance && ev.relevance.matched) || [];
return m.some(function (x) { return x.type === "tag"; });
}
function floatClass(ev) {
var l = legMatches(ev), t = tagMatches(ev);
if (t && l) return "both";
if (t) return "tag-match";
if (l) return "leg-match";
return "";
}
function rowHTML(ev, opts) {
opts = opts || {};
var rel = ev.relevance && ev.relevance.score > 0;
var cls = "row";
if (rel) cls += " relevant " + floatClass(ev);
var time = ev.hearing_time || '<span class="meta">time TBD</span>';
var meta = [time, ev.committee, ev.location, ev.session]
.filter(Boolean).join(" · ");
var tags = (ev.tags || []).slice(0, 6)
.map(function (tg) {
var on = state.prefs &&
(state.prefs.opted_in_tags || []).indexOf(tg) >= 0;
return '<span class="tag ' +
(on ? 'on"' : '"') + ">" + esc(tg) + "</span>";
}).join("");
var marker = rel
? '<span class="chip marker" title="Floated: relevant to you">'
+ "Relevant to you</span>"
: "";
var reasons = "";
if (rel) {
var parts = (ev.relevance.matched || []).map(function (x) {
return esc(x.value);
});
reasons = '<div class="reason' +
(state.showReason ? " show" : "") + '">'
+ "Floated: " + parts.join("; ") + "</div>";
}
var url = "https://leginfo.legislature.ca.gov/bill/" +
(ev.session || "2025-2026") + "/AB" + billNum(ev);
return '<div class="' + cls + '" data-bill="' +
esc(ev.bill_id) + '">'
+ '<span class="num">' + chip(ev.chamber_origin) +
'<br>' + esc(ev.bill_number) + "</span>"
+ '<div class="main"><span class="title">'
+ esc(ev.short_title || ev.bill_number) + "</span>"
+ "<div class='meta'>" + esc(meta) + "</div>"
+ "<a href='" + url + "' target='_blank'"
+ " rel='noopener'>View →</a> " + marker
+ tags
+ reasons
+ '</div>'
+ '<div class="actions">'
+ (state.prefs
? '<button class="why-btn" type="button">Why▸</button>'
: "")
+ "</div>"
+ "</div>";
}
function billNum(ev) {
var m = (ev.bill_number || "").match(/(\d+)/);
return m ? m[1] : "";
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/* ---------- band rendering ---------- */
function groupByDay(items) {
var groups = {};
items.forEach(function (it) {
var key = it.date || "TBD";
if (!groups[key]) groups[key] = { items: [], key: key };
groups[key].items.push(it);
});
return groups;
}
function renderGroups(container, items) {
var byDate = groupByDay(items);
var html = "";
Object.keys(byDate).sort().forEach(function (d) {
var g = byDate[d];
var label = g.key === "TBD" ? "Date TBD"
: niceDate(g.key) + " · " + g.items.length +
" item" + (g.items.length > 1 ? "s" : "");
html += '<div class="day-heading"><b>' + esc(label) +
"</b></div>";
g.items.forEach(function (ev) {
html += rowHTML(ev);
});
});
container.innerHTML = html;
wireRows(container);
}
function niceDate(iso) {
try {
var d = new Date(iso + "T00:00:00");
return d.toLocaleDateString(undefined, {
weekday: "short", month: "short", day: "numeric",
year: "numeric" });
} catch (e) { return iso; }
}
/* ---------- empty states (DESIGN §6) ---------- */
function showEmpty(kind, container) {
var el = el(container);
var h = "";
if (kind === "no-next") {
h = "<h3>No hearings scheduled</h3>" +
"<p>No upcoming daily-file items in this window.</p>" +
"<button type='button' class='escape' id='see-past'>" +
"See recent actions instead</button>";
} else if (kind === "filter") {
h = "<h3>No upcoming items match your filters</h3>" +
"<p><button type='button' class='escape' id='clear'>" +
"Clear filters</button> · " +
"<button type='button' class='escape' id='loosen'>" +
"Loosen to all topics</button> · " +
"<button type='button' class='escape' id='widen'>" +
"Widen date window</button></p>";
} else if (kind === "setup") {
el.innerHTML = "";
el.hidden = false;
el.parentElement.querySelector("#band-setup").hidden = false;
return;
} else if (kind === "no-tags") {
h = "<h3>Topic analysis is filling in</h3>" +
"<p>Tag chips appear as the topic service warms. " +
"You can still watch legislators above.</p>";
}
el.innerHTML = h;
el.hidden = false;
wire(el, kind);
}
function clearEmpty(container) {
var e = el(container);
e.hidden = true; e.innerHTML = "";
}
/* ---------- load + render ---------- */
function load() {
var q = new URLSearchParams();
if (state.filters.upcoming) q.set("upcoming", "1");
if (state.filters.chamber) q.set("chamber", state.filters.chamber);
if (state.filters.q) q.set("q", state.filters.q);
if (state.filters.tags.length)
q.set("tags", state.filters.tags.join(","));
if (state.personal && state.token) q.set("personal", "1");
q.set("full", "1");
apiFetch("/bills?" + q.toString()).then(function (r) {
if (r.status !== 200) { return; }
render(r.body);
});
}
function render(env) {
// freshness / context band
el("freshness-updated").textContent =
"Updated " + (env.as_of || "").slice(0, 10);
el("freshness-session").textContent = "Session " + (env.session || "");
var c = env.chamber_availability || {};
el("freshness-chamber").textContent =
c.SENATE === "available"
? "Senate available"
: "Senate unavailable today";
el("freshness-chamber").style.color =
c.SENATE === "available" ? "var(--muted)" : "var(--warn)";
var items = env.items || [];
state.allItems = items;
var band = items.filter(function (i) {
return i.relevance && i.relevance.score > 0;
});
var bandFull = band;
var shown = bandFull.slice(0, BAND_N);
// personal band
var bandCount = el("band-count");
bandCount.textContent = bandFull.length
? bandFull.length + (bandFull.length > BAND_N
? " (showing " + shown.length + ")" : "") : "";
var list = el("band-list");
var setup = el("band-setup");
if (!state.prefs || (state.prefs.watched_legislators || []).length
=== 0 && (state.prefs.opted_in_tags || []).length === 0) {
list.innerHTML = "";
setup.hidden = false;
} else {
setup.hidden = true;
if (!shown.length) {
list.innerHTML = "<p class='meta'>No bills match your " +
"watches/topics yet. Add more on the right, or " +
"<button type='button' class='escape' id='clear-watch'>" +
"Clear filters</button>.</p>";
} else {
renderGroups(list, shown);
}
var btn = el("btn-show-all");
btn.hidden = bandFull.length <= BAND_N;
}
// all band
var allCount = el("all-count");
allCount.textContent = items.length +
" upcoming" + (items.length === 1 ? " item" : " items");
var allList = el("all-list");
var allEmpty = el("all-empty");
if (!items.length) {
allList.innerHTML = "";
showEmpty("no-next", "all-empty");
} else {
clearEmpty("all-empty");
renderGroups(allList, items);
}
// aside teaser
renderTeaser();
loadTags();
}
function renderTeaser() {
var ul = el("topics-teaser");
ul.innerHTML = "";
(state.tags || []).slice(0, 6).forEach(function (t) {
var li = document.createElement("li");
li.innerHTML = "<span class='n'>" + t.count + "</span> " +
esc(t.label);
var b = document.createElement("button");
b.type = "button";
b.textContent = "view";
b.onclick = function () {
el("f-tags").value = t.tag_id.split(":")[1];
state.filters.tags = [el("f-tags").value];
load();
};
li.appendChild(b);
ul.appendChild(li);
});
}
function loadTags() {
apiFetch("/tags").then(function (r) {
if (r.status === 200) {
state.tags = r.body.tags || [];
var sel = el("f-tags");
sel.innerHTML = state.tags
.map(function (t) {
var name = t.tag_id.split(":")[1];
return '<option value="' + esc(name) + '">' +
esc(t.label) + " (" + t.count +
")</option>";
}).join("");
// rehydrate current selection
Array.prototype.forEach.call(sel.options,
function (o) {
o.selected =
state.filters.tags.indexOf(o.value) >= 0;
});
renderTeaser();
} else {
// "no tagged bills yet" — degrade, no crash
el("topics-teaser").innerHTML =
"<li class='meta'>Topic analysis is filling in…</li>";
}
});
}
/* ---------- auth (D1a local account, dev) ---------- */
function signIn(email) {
apiFetch("/auth/register", { method: "POST",
body: { email: email } }).then(function (r) {
if (r.status !== 201) { alert("Sign-in failed"); return; }
state.token = r.body.token;
localStorage.setItem("dash_token", r.body.token);
apiFetch("/prefs", {}).then(function (pr) {
state.prefs = pr.status === 200 ? pr.body : null;
refreshUI();
load();
});
});
}
function signOut() {
state.token = null;
state.prefs = null;
state.personal = false;
localStorage.removeItem("dash_token");
refreshUI();
load();
}
function refreshUI() {
var has = state.token;
el("user-band").hidden = !has;
el("anon-cta").hidden = !!has;
el("personal-controls").hidden = !has;
el("aside-title").textContent = has
? "Your watchlist" : "Top topics this session";
if (has) {
var p = state.prefs || {};
el("user-band-text").textContent =
"ZIP " + (p.zip || "—") + " · " +
((p.watched_legislators || []).length) +
" legislators · " +
((p.opted_in_tags || []).length) + " topics";
renderWatch();
renderTopics();
}
}
/* ---------- watch / topic toggles ---------- */
function renderWatch() {
var c = el("resolve-result");
var p = state.prefs || {};
var wl = p.watched_legislators || [];
var html = "";
wl.forEach(function (id) {
html += "<div class='candidate'>" +
"<button type='button' class='watch-btn on' " +
"data-leg='" + esc(id) + "'>✓ watching " +
esc(pretty(id)) + "</button>" +
"<button type='button' class='unwatch-btn ghost' " +
"data-leg='" + esc(id) + "'>remove</button>" +
"</div>";
});
c.innerHTML = html || "";
wire(c, "watch");
}
function renderTopics() {
var c = el("topic-list");
var p = state.prefs || {};
var opted = p.opted_in_tags || [];
c.innerHTML = (state.tags || []).map(function (t) {
var name = t.tag_id.split(":")[1];
var on = opted.indexOf(name) >= 0;
return "<button type='button' class='topic-chip" +
(on ? " on" : "") + "' data-tag='" + esc(name) +
"'>" + (on ? "✓ " : "") + esc(t.label) +
" <span class='count'>· " + t.count + " bills</span>" +
"</button>";
}).join("");
wire(c, "topic");
}
function toggleWatch(id, on) {
var path = on
? "/prefs/watch"
: "/prefs/watch/" + encodeURIComponent(id);
var method = on ? "POST" : "DELETE";
var body = on ? { legislator_id: id } : null;
apiFetch(path, { method: method, body: body }).then(function (r) {
if (r.status === 400 || r.status === 404) {
alert(r.body.error && r.body.error.message);
}
apiFetch("/prefs", {}).then(function (pr) {
state.prefs = pr.status === 200 ? pr.body : null;
refreshUI();
load();
announce("Ranking updated");
});
});
}
function toggleTopic(tag, on) {
var path = on ? "/prefs/topics"
: "/prefs/topics/" + encodeURIComponent(tag);
var method = on ? "POST" : "DELETE";
var body = on ? { tag: tag } : null;
apiFetch(path, { method: method, body: body }).then(function (r) {
if (r.status === 400) {
alert(r.body.error && r.body.error.message);
}
apiFetch("/prefs", {}).then(function (pr) {
state.prefs = pr.status === 200 ? pr.body : null;
refreshUI();
load();
announce("Ranking updated");
});
});
}
function announce(msg) {
var a = el("freshness-updated");
var old = a.textContent;
a.textContent = old + " · " + msg;
}
/* ---------- ZIP resolve (DESIGN §8, §6.4) ---------- */
function resolve(zip) {
apiFetch("/districts/resolve", { method: "POST",
body: { zip: zip } }).then(function (r) {
var c = el("resolve-result");
var p = state.prefs || {};
if (r.status === 200 && r.body.confidence === "single") {
p.zip = zip;
var d = r.body.districts[0];
var mem = d.assembly_member || d.senate_member;
p.districts = [d];
if (mem && (p.watched_legislators || []).indexOf(
mem.legislator_id) === -1) {
toggleWatch(mem.legislator_id, true);
}
(r.body.suggested_topics || []).forEach(function (t) {
if ((p.opted_in_tags || []).indexOf(t) === -1)
toggleTopic(t, true);
});
c.innerHTML = "<div class='candidate'>" +
"<span class='who'>Resolved: " + esc(d.district_id) +
" — " + esc((mem && mem.name) || "") + "</span>" +
"</div>" +
"<p class='meta'>Watched " +
esc((mem && mem.name) || "n/a") +
"; suggested topics opted in. " +
"Nothing is auto-picked without a confirm here — " +
"you can remove any on the right.</p>";
load();
} else if (r.status === 200 && r.body.confidence ===
"ambiguous") {
c.innerHTML = "<p class='warning'>" + esc(r.body.note ||
"Multiple districts match this ZIP. Confirm one:") +
"</p>" +
r.body.districts.map(function (d) {
var mem = d.assembly_member || d.senate_member;
return "<div class='candidate'>" +
"<span class='who'>" + esc(d.district_id) +
" · " + esc((mem && mem.name) || "unknown") +
" <span class='dc'>" +
esc((mem && mem.party) || "") + "</span></span>" +
"<button type='button' class='confirm-btn ghost' " +
"data-dc='" + esc(d.district_id) +
"' data-leg='" +
esc((mem && mem.legislator_id) || "") + "'>" +
"Watch " + esc(d.district_id) + "</button></div>";
}).join("");
wire(c, "resolve-ambiguous");
} else {
// zero / out-of-state / other → §6.4 manual fallback
var msg = r.body && r.body.error
? r.body.error.message
: "Could not match that ZIP.";
c.innerHTML =
"<p class='warning'>" + esc(msg) + "</p>" +
"<input type='text' id='manual-dc' placeholder=" +
"'Pick a district by hand, e.g. A-14'>" +
"<button type='button' id='btn-manual' " +
"class='ghost'>Confirm</button>";
el("btn-manual").onclick = function () {
var dc = el("manual-dc").value.trim();
if (dc) resolveManual(dc);
};
}
});
state.prefs = state.prefs || {};
}
function resolveManual(dc) {
apiFetch("/districts/resolve", { method: "POST",
body: { district: dc } }).then(function (r) {
if (r.status === 200) {
var p = state.prefs || {};
p.districts = r.body.districts;
var mem = r.body.districts[0].assembly_member ||
r.body.districts[0].senate_member;
if (mem) toggleWatch(mem.legislator_id, true);
load();
}
});
}
function pretty(id) {
var m = id.match(/^(A|S)-(\d+)$/i);
return m ? m[1] + m[2] : id;
}
/* ---------- wiring ---------- */
function wireRows(container) {
Array.prototype.forEach.call(
container.querySelectorAll(".why-btn"),
function (b) {
b.onclick = function () {
var r = b.closest(".row").querySelector(".reason");
if (r) r.classList.toggle("show");
};
}
);
}
function wire(container, kind) {
if (kind === "watch") {
Array.prototype.forEach.call(
container.querySelectorAll(".unwatch-btn"),
function (b) {
b.onclick = function () {
toggleWatch(b.getAttribute("data-leg"), false);
};
}
);
}
if (kind === "topic") {
Array.prototype.forEach.call(
container.querySelectorAll(".topic-chip"),
function (b) {
b.onclick = function () {
var on = b.classList.contains("on");
toggleTopic(b.getAttribute("data-tag"), !on);
};
}
);
}
if (kind === "resolve-ambiguous") {
Array.prototype.forEach.call(
container.querySelectorAll(".confirm-btn"),
function (b) {
b.onclick = function () {
var lid = b.getAttribute("data-leg");
if (lid) toggleWatch(lid, true);
var name = b.closest(".candidate")
.querySelector(".who").textContent;
el("resolve-result").innerHTML =
"<div class='candidate'><span class='who'>" +
esc(name.trim()) +
"</span><span class='dc'>confirmed</span>" +
"</div>";
};
}
);
}
}
function boot() {
// controls
el("f-chamber").onchange = function () {
state.filters.chamber = el("f-chamber").value; load();
};
el("f-date").onchange = function () {
var v = el("f-date").value;
state.filters.upcoming = (v !== "all");
load();
};
var st;
el("f-search").oninput = function () {
clearTimeout(st);
st = setTimeout(function () {
state.filters.q = el("f-search").value; load();
}, 300);
};
el("f-tags").onchange = function () {
state.filters.tags = Array.prototype.slice.call(
el("f-tags").selectedOptions).map(function (o) {
return o.value;
});
load();
};
el("f-reason").onchange = function () {
state.showReason = el("f-reason").checked;
Array.prototype.forEach.call(
document.querySelectorAll(".reason"),
function (r) { r.classList.toggle("show", state.showReason); });
};
el("btn-refresh").onclick = load;
el("btn-signout").onclick = signOut;
el("btn-resolve").onclick = function () {
var z = el("zip-input").value.trim();
if (z) resolve(z);
};
el("btn-setup").onclick = function () {
var z = el("zip-setup").value.trim();
if (z) { signIn(z + "@resident.gov"); }
};
// unlock CTA
var u = el("unlocked");
if (u) u.onclick = function () {
var em = prompt("Sign in with a resident email (D1a demo):");
if (em) signIn(em);
};
// restore session
if (state.token) {
state.personal = true;
apiFetch("/prefs").then(function (r) {
if (r.status === 200) {
state.prefs = r.body;
state.personal = true;
} else {
state.prefs = null;
}
refreshUI();
load();
});
}
refreshUI();
loadTags();
load();
}
document.addEventListener("DOMContentLoaded", boot);
})();
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>California Legislature Dashboard — Upcoming Legislation</title>
<link rel="stylesheet" href="style.css">
<meta name="description"
content="A monitor of upcoming California Assembly &amp; Senate daily-file
hearings. Anonymous overview; a logged-in resident pins their
legislators &amp; topics to the top.">
</head>
<body>
<!-- sticky header: wordmark + nav + (logged-in) user band -->
<header class="topbar" id="topbar">
<a class="wordmark" href="/">CA&nbsp;Legislature<span>Dashboard</span></a>
<nav class="nav" aria-label="Primary">
<a href="#dashboard" id="nav-dashboard" class="active">Dashboard</a>
<a href="#legislators">Legislators</a>
<a href="#bills">Bills</a>
</nav>
<div class="user-band" id="user-band" hidden>
<span id="user-band-text">ZIP — · 0 legislators · 0 topics</span>
<a href="#prefs" id="nav-prefs">Preferences</a>
<button type="button" id="btn-signout">Sign out</button>
</div>
<div class="anon-cta" id="anon-cta">
<a href="#unlocked">Sign in</a>
<span class="cta-value">Save your interests → sign in</span>
</div>
</header>
<main id="app">
<!-- §9 status / context band -->
<section class="freshness" id="freshness" aria-live="polite">
<span id="freshness-updated">Loading…</span>
<span id="freshness-session" class="badge"></span>
<span id="freshness-chamber" class="note"></span>
<button type="button" id="btn-refresh" class="ghost">Refresh</button>
</section>
<!-- Controls bar -->
<section class="controls" id="controls">
<input type="search" id="f-search" class="search"
placeholder="Search bills… (title, author, committee)"
aria-label="Search bills">
<label>Chamber
<select id="f-chamber">
<option value="">All</option>
<option value="ASSEMBLY">Assembly</option>
<option value="SENATE">Senate</option>
</select>
</label>
<label>Date
<select id="f-date">
<option value="upcoming">Upcoming</option>
<option value="week">This week</option>
<option value="all">All</option>
</select>
</label>
<select id="f-tags" multiple size="1" aria-label="Filter by topic"
class="tags-filter"></select>
<label>Why is this here?
<input type="checkbox" id="f-reason" title="Show why each
floated bill is in your view">
</label>
</section>
<!-- Logged-in personal controls (ZIP + watch/opt-in) -->
<section class="personal-controls" id="personal-controls" hidden>
<div class="zip-lookup">
<label for="zip-input">Where do you live?</label>
<input type="text" id="zip-input" inputmode="numeric"
pattern="[0-9]{5}" placeholder="ZIP (e.g. 90210)">
<button type="button" id="btn-resolve">Find
my legislators</button>
</div>
<div id="resolve-result" class="resolve-result"></div>
<div class="watch-list" id="watch-list"></div>
<div class="topic-list" id="topic-list"></div>
</section>
<!-- Personal band: floored "Relevant to you" (top N) + setup empty state -->
<section class="band" id="personal-band" aria-label="Relevant to you">
<h2 class="band-title">Relevant to you
<span id="band-count" class="count"></span>
<button type="button" id="btn-show-all" class="ghost"
hidden>Show all N</button></h2>
<div id="band-setup" class="empty-state" hidden>
<h3>Pin your view</h3>
<p>Enter your ZIP and we'll surface your legislators and the
bills that matter to you.</p>
<input type="text" id="zip-setup" inputmode="numeric"
placeholder="ZIP code">
<button type="button" id="btn-setup">Find my legislators</button>
</div>
<div id="band-list" class="list"></div>
</section>
<!-- All upcoming legislation: the full queue, floated bills marked -->
<section class="band" id="all-band">
<h2 class="band-title">All upcoming legislation
<span id="all-count" class="count"></span></h2>
<div id="all-list" class="list"></div>
<div id="all-empty" class="empty-state" hidden></div>
</section>
<!-- aside: watchlist summary / top topics this session -->
<aside class="aside" id="aside">
<h3 id="aside-title">Top topics this session</h3>
<ul id="topics-teaser" class="teaser"></ul>
<a id="unlocked" class="unlock">Unlock your view — pin your
legislators &amp; topics to the top →</a>
</aside>
<footer class="footer">
<span>Provenance: CA Assembly &amp; Senate daily file ·
<a href="#about">About</a> · <a href="#help">Help</a> ·
<a href="#privacy">Privacy</a> ·
<a href="#accessibility">Accessibility</a></span>
</footer>
</main>
<script src="app.js"></script>
</body>
</html>
+222
View File
@@ -0,0 +1,222 @@
/* ============================================================================
Legislative Dashboard — visual system (DESIGN_DASHBOARD §9).
Government-appropriate, high-contrast, accessible. One accent. No
gradients, glass, emoji, or hero. Rhythm via scale + spacing + rules.
============================================================================ */
:root {
--ink: #1a1a1a;
--muted: #5b5b5b;
--paper: #ffffff;
--surface: #f4f5f7;
--border: #d5d9df;
--accent: #0b5394; /* sober blue, AA on paper */
--accent-ink: #0a3f6f;
--success: #1e7e34; /* watched / on */
--warn: #8a5a00;
--danger: #b3261e;
--asm: #2f5aa7; /* chamber chip tint only */
--sen: #7a3b8f;
--reason-tag: #0b5394; /* 3px bar: tag match */
--reason-leg: #1e7e34; /* 3px bar: legislator match */
--focus-ring: #0b5394;
--radius: 3px;
--gap: 14px;
--mono: ui-monospace, "SF Mono", "Menlo", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--ink);
background: var(--paper);
line-height: 1.5;
font-size: 15px;
}
/* ---- focus: visible 2px ring, never the only signal ---- */
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* ---- topbar ---- */
.topbar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; gap: 18px;
padding: 10px 18px;
background: var(--paper);
border-bottom: 1px solid var(--border);
}
.wordmark { text-decoration: none; color: var(--ink);
font-weight: 700; letter-spacing: .02em; font-size: 15px; }
.wordmark span { color: var(--accent); }
.nav { display: flex; gap: 14px; }
.nav a { color: var(--muted); text-decoration: none; font-size: 14px;
padding: 6px 2px; border-bottom: 2px solid transparent; }
.nav a:hover { color: var(--ink); }
.nav a.active { color: var(--accent); border-bottom-color: var(--accent); }
.user-band { display: flex; align-items: center; gap: 12px;
margin-left: auto; font-size: 13px; color: var(--muted); }
.user-band button { font-size: 13px; }
.anon-cta { margin-left: auto; text-align: right; }
.anon-cta a { color: var(--accent); font-weight: 600; }
.cta-value { display: block; color: var(--muted); font-size: 12px; }
/* ---- freshness / status band ---- */
.freshness {
display: flex; align-items: center; gap: 10px;
padding: 8px 18px; background: var(--surface);
border-bottom: 1px solid var(--border); font-size: 13px;
color: var(--muted);
}
.badge { background: var(--paper); border: 1px solid var(--border);
padding: 1px 8px; border-radius: 10px; }
.note { color: var(--warn); }
/* ---- controls ---- */
.controls {
display: flex; flex-wrap: wrap; align-items: center; gap: 12px;
padding: 12px 18px; border-bottom: 1px solid var(--border);
font-size: 13px;
}
.controls input, .controls select {
font: inherit; font-size: 13px; padding: 6px 8px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--paper); color: var(--ink);
}
.controls .search { min-width: 240px; flex: 1 1 auto; }
.controls label { display: inline-flex; align-items: center; gap: 5px;
color: var(--muted); }
/* ---- layout: main + aside ---- */
main { max-width: 1100px; margin: 0 auto; }
main .controls, main .freshness { max-width: none; }
.wrap-grid { display: grid; grid-template-columns: 1fr 360px;
gap: 20px; padding: 16px 18px; }
@media (max-width: 860px) {
.wrap-grid { grid-template-columns: 1fr; }
}
/* ---- personal controls ---- */
.personal-controls {
padding: 14px 18px; border-bottom: 1px solid var(--border);
background: var(--surface);
}
.zip-lookup { display: flex; align-items: end; gap: 10px; flex-wrap: wrap; }
.zip-lookup label { display: block; font-size: 12px; color: var(--muted);
margin-bottom: 3px; }
.zip-lookup input { font: inherit; padding: 8px 10px; width: 160px;
border: 1px solid var(--border); border-radius: var(--radius); }
.resolve-result { margin-top: 10px; }
.candidate { display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border: 1px solid var(--border);
border-radius: var(--radius); margin: 4px 0; background: var(--paper); }
.candidate .who { font-weight: 600; }
.candidate .dc { color: var(--muted); font-size: 12px; }
.warning { color: var(--danger); font-size: 13px; margin: 6px 0; }
.watch-list, .topic-list {
display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.watch-item, .topic-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border: 1px solid var(--border);
border-radius: 14px; font-size: 13px; background: var(--paper);
}
.watch-item.on, .topic-chip.on {
border-color: var(--success); color: var(--success);
background: #eef7f0; }
.topic-chip .count { color: var(--muted); font-size: 11px; }
button.chip-toggle { min-width: 120px; }
/* ---- bands ---- */
.band { padding: 16px 18px; border-bottom: 1px solid var(--border); }
.band-title { font-size: 14px; text-transform: uppercase; letter-spacing:
0.06em; color: var(--muted); margin: 0 0 10px; }
.band-title .count { color: var(--ink); font-weight: 700; }
#personal-band .band-title { color: var(--accent); }
.day-heading {
font-size: 13px; color: var(--muted); margin: 14px 0 6px;
padding-bottom: 4px; border-bottom: 1px solid var(--border); }
.day-heading b { color: var(--ink); }
/* ---- bill row ---- */
.row {
display: grid; grid-template-columns: 22px 1fr auto;
gap: 10px; align-items: start;
padding: 10px 10px 10px 0; border-bottom: 1px solid var(--border);
border-left: 3px solid transparent; padding-left: 8px;
}
.row.relevant { background: #f7fafd; }
.row.relevant.tag-match { border-left-color: var(--reason-tag); }
.row.relevant.leg-match { border-left-color: var(--reason-leg); }
.row.relevant.both { border-left-color: var(--accent); }
.row .title { font-weight: 600; }
.row .meta { color: var(--muted); font-size: 13px; margin-top: 2px; }
.row .num { font-family: var(--mono); font-size: 13px; }
.row .why { color: var(--accent); font-size: 12px; cursor: pointer; }
.row .reason { font-size: 12px; color: var(--muted); margin-top: 4px;
display: none; }
.row .reason.show { display: block; }
.chip { display: inline-block; font-size: 11px; padding: 1px 7px;
border-radius: 10px; border: 1px solid var(--border);
margin-right: 5px; }
.chip.asm { background: #eef3fb; color: var(--asm); border-color:#cdd9ee; }
.chip.sen { background: #f5eef9; color: var(--sen); border-color:#e2d2ea; }
.chip.marker { background: var(--accent); color: #fff; border-color:
var(--accent); }
.tag { display: inline-block; font-size: 11px; color: var(--muted);
border: 1px solid var(--border); border-radius: 12px;
padding: 0 6px; margin: 0 4px 0 0; }
.row a { color: var(--accent); text-decoration: none; }
.row a:hover { text-decoration: underline; }
/* ---- empty states ---- */
.empty-state { padding: 22px; text-align: center; color: var(--muted);
border: 1px dashed var(--border); border-radius: var(--radius);
background: var(--surface); }
.empty-state h3 { color: var(--ink); margin-top: 0; }
.empty-state input { margin: 8px 0; padding: 8px; font: inherit;
border: 1px solid var(--border); border-radius: var(--radius); }
.escape { color: var(--accent); cursor: pointer; }
/* ---- aside ---- */
.aside { padding: 16px 0; font-size: 13px; }
.aside h3 { font-size: 13px; text-transform: uppercase; letter-spacing:
0.06em; color: var(--muted); }
.teaser { list-style: none; padding: 0; margin: 0 0 14px; }
.teaser li { padding: 4px 0; }
.teaser .n { color: var(--muted); }
.teaser button { margin-left: 6px; font-size: 12px; }
.unlock { display: block; margin-top: 10px; padding: 10px;
border: 1px solid var(--accent); color: var(--accent);
border-radius: var(--radius); text-decoration: none; }
.unlock:hover { background: #f7fafd; }
/* ---- footer ---- */
.footer { padding: 18px; font-size: 12px; color: var(--muted);
border-top: 1px solid var(--border); }
.footer a { color: var(--muted); }
/* ---- buttons ---- */
button { font: inherit; font-size: 13px; padding: 6px 12px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--paper); color: var(--ink); cursor: pointer; }
button:hover { background: var(--surface); }
button.ghost { background: transparent; border-color: var(--border);
color: var(--muted); }
button.primary { background: var(--accent); color: #fff;
border-color: var(--accent); }
button.on { background: var(--success); color: #fff;
border-color: var(--success); }
/* motion: minimal. a floated row may fade in once. */
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
.row.relevant { animation: fadein 140ms ease-out; }
@media (prefers-reduced-motion: reduce) {
.row.relevant { animation: none; }
}
+237
View File
@@ -0,0 +1,237 @@
"""HTTP layer with retry + exponential back-off and typed error handling.
Design goals (from t_f595542f):
* Retry transient failures with exponential back-off, capped at 3 attempts.
* Distinguish 404 ("no file today", EXPECTED for non-legislative days) from
5xx (transient, retryable) from 403 (antibot challenge -> alert, don't storm).
* Log every request (url, status, bytes, attempts, outcome) to the request log.
* Never crash the caller on a "bad" response -- callers get typed exceptions.
httpx is used because the project already depends on it (0.28.x). The
follow_redirects=True argument is httpx-native.
"""
from __future__ import annotations
import datetime
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional
try:
import httpx
except ImportError as exc: # pragma: no cover
raise SystemExit(
"httpx is required. Install with: python3 -m pip install --user httpx"
) from exc
# ---- typed error hierarchy -------------------------------------------
class FetchError(Exception):
"""Base class for all fetch-layer failures."""
class NotFoundError(FetchError):
"""404. For the Senate PDF this is EXPECTED (no publication that day)."""
def __init__(self, url: str, status: int = 404):
super().__init__("404 Not Found: " + url)
self.url = url
self.status = status
class AntibotError(FetchError):
"""403 / antibot challenge. Surface as an alert; do NOT retry-storm it."""
def __init__(self, url: str, status: int = 403, body: bytes = b""):
super().__init__("antibot %d: %s" % (status, url))
self.url = url
self.status = status
self.body = body
class TransientError(FetchError):
"""5xx / network fault that survived all retry attempts."""
def __init__(self, url, status=0, attempts=0, cause=None):
super().__init__(
"transient after %d attempt(s) for %s: %s"
% (attempts, url, status or cause)
)
self.url = url
self.status = status
self.attempts = attempts
self.cause = cause
@dataclass
class FetchResult:
"""Successful fetch result."""
url: str
status: int
content: bytes
attempts: int = 1
cache_hit: bool = False
headers: Dict[str, str] = field(default_factory=dict)
outcome: str = "ok" # ok | not_found | antibot | transient
def _now_iso():
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
# ---- client ----------------------------------------------------------
class HttpClient:
"""Configurable GET client. `log_request` is a callback fired per attempt."""
def __init__(
self,
user_agent: str,
timeout_seconds: float = 20,
max_retries: int = 3,
backoff_base_seconds: float = 1.0,
backoff_cap_seconds: float = 30.0,
retry_status=None,
antibot_status=None,
log_request: Optional[Callable[[Dict[str, Any]], None]] = None,
sleep_fn: Optional[Callable[[float], None]] = None,
):
self.user_agent = user_agent
self.timeout = timeout_seconds
self.max_retries = max(1, int(max_retries))
self.backoff_base = backoff_base_seconds
self.backoff_cap = backoff_cap_seconds
self.retry_status = set(retry_status or [500, 502, 503, 504])
self.antibot_status = set(antibot_status or [403])
self.log_request = log_request or (lambda evt: None)
self.sleep = sleep_fn or time.sleep
self._client: Optional[httpx.Client] = None
# lifecycle
@property
def client(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client(
headers={"User-Agent": self.user_agent},
timeout=self.timeout,
)
return self._client
def close(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
def __enter__(self) -> "HttpClient":
return self
def __exit__(self, *exc) -> None:
self.close()
# ---- public API ----
def get(self, url: str, source: str) -> FetchResult:
"""GET with retry + back-off.
Returns a FetchResult on success, or raises a typed FetchError subclass:
NotFoundError -> 404 (never retried)
AntibotError -> 403 / antibot (alert; no retry-storm)
TransientError-> 5xx / network fault after all attempts
Emits one request-log event per attempt.
"""
self.last_attempts = 0
last_status = 0
last_exc: Optional[Exception] = None
last_content = b""
for attempt in range(1, self.max_retries + 1):
self.last_attempts = attempt
outcome, status, content, headers, exc = self._do(url)
last_status = status
last_exc = exc
last_content = content or b""
# transient (5xx in retry list, or a network error): retry or give up
if outcome == "transient":
if attempt < self.max_retries:
self._log(
source, url, status, len(last_content), attempt,
"retrying" if exc else "5xx_retrying",
)
delay = min(
self.backoff_base * (2 ** (attempt - 1)),
self.backoff_cap,
)
self.sleep(delay)
continue
self._log(
source, url, status, len(last_content), attempt,
"transient_gave_up",
)
raise TransientError(url, status, attempt, exc)
# success on 2xx/3xx (redirects are fully resolved by httpx)
if outcome == "ok":
self._log(source, url, status, len(content), attempt, "ok")
return FetchResult(
url=url,
status=status,
content=content,
attempts=attempt,
headers=headers,
outcome="ok",
)
# 404 -> definitive "no file"; never retried
if outcome == "not_found":
self._log(source, url, status, len(content), attempt, "not_found")
raise NotFoundError(url, status)
# antibot challenge: alert, do not retry-storm
if outcome == "antibot":
self._log(source, url, status, len(content), attempt, "antibot")
raise AntibotError(url, status, content)
raise TransientError(url, last_status, self.max_retries, last_exc)
def _do(self, url: str):
"""Perform one fetch. Returns (outcome, status, content, headers, exc)."""
try:
resp = self.client.get(url, follow_redirects=True)
except httpx.HTTPError as exc:
return "transient", 0, b"", {}, exc
status = resp.status_code
content = resp.content
headers = {
k: v
for k, v in resp.headers.items()
if k.lower() in ("content-type", "etag", "last-modified")
}
if status < 400:
return "ok", status, content, headers, None
if status == 404:
return "not_found", status, content, headers, None
if status in self.antibot_status:
return "antibot", status, content, headers, None
if status in self.retry_status:
return "transient", status, content, headers, None
# unexpected 4xx (not 404/antibot): surface it via the transient path so the
# alerting hook can classify the exact status; don't retry-storm.
return "transient", status, content, headers, None
# ---- helpers ----
def _log(self, source, url, status, nbytes, attempt, outcome) -> None:
self.log_request(
{
"ts": _now_iso(),
"source": source,
"url": url,
"status": status,
"bytes": nbytes,
"attempt": attempt,
"outcome": outcome,
}
)
+334
View File
@@ -0,0 +1,334 @@
# Orchestrator: ingest one or more chamber daily-file pages for a date.
# Ties together the cache, HTTP client, source planner, and alerting hooks.
#
# ing = Ingestor("config.yaml")
# result = ing.ingest("2026-09-02") # both chambers
# result = ing.ingest("2026-09-02", source="assembly") # one chamber
from __future__ import annotations
import datetime
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
from .alerts import AlertContext, fire
from .cache import RawCache
from .config import load_config, Config
from .http_client import (
AntibotError,
FetchResult,
HttpClient,
NotFoundError,
TransientError,
)
from .request_log import RequestLog
from .sources import SourcePlan, build_source_plan, parse_senate_pdf_filename
@dataclass
class ChamberResult:
# Per-chamber outcome for one date.
source: str
date: str
status: str # ok | cached | not_found | antibot | transient
path: Optional[Path] = None
bytes: int = 0
attempts: int = 0
cache_hit: bool = False
url: Optional[str] = None
as_of: Optional[str] = None
note: str = ""
@dataclass
class IngestResult:
date: str
chambers: List[ChamberResult] = field(default_factory=list)
@property
def all_ok(self) -> bool:
return all(c.status in ("ok", "cached") for c in self.chambers)
def to_dict(self) -> dict:
return {
"date": self.date,
"all_ok": self.all_ok,
"chambers": [
{
"source": c.source,
"status": c.status,
"path": str(c.path) if c.path else None,
"bytes": c.bytes,
"attempts": c.attempts,
"cache_hit": c.cache_hit,
"url": c.url,
"as_of": c.as_of,
"note": c.note,
}
for c in self.chambers
],
}
class Ingestor:
# One instance per process; reuses the HTTP connection pool.
def __init__(self, config_path=None, force=False):
self.cfg = load_config(config_path)
self.cache = RawCache(self.cfg.raw_dir)
self.log = RequestLog(self.cfg.request_log)
self.force = force
self.http = HttpClient(
user_agent=self.cfg.http.get("user_agent", "research-scanner/1.0"),
timeout_seconds=self.cfg.http.get("timeout_seconds", 20),
max_retries=self.cfg.http.get("max_retries", 3),
backoff_base_seconds=self.cfg.http.get("backoff_base_seconds", 1.0),
backoff_cap_seconds=self.cfg.http.get("backoff_cap_seconds", 30.0),
retry_status=self.cfg.http.get("retry_status", [500, 502, 503, 504]),
antibot_status=self.cfg.http.get("antibot_status", [403]),
log_request=self._log_event,
)
# -- log --
def _log_event(self, event):
event.setdefault(
"log_ts",
datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"))
self.log.record(event)
# -- main API --
def ingest(self, date_iso, source=None, sources=None):
names = sources or ([source] if source else self.cfg.source_names())
result = IngestResult(date=date_iso)
for name in names:
if name not in self.cfg.sources:
raise ValueError(
"Unknown source '%s'. Known: %s"
% (name, self.cfg.source_names()))
cr = self._ingest_chamber(name, date_iso)
result.chambers.append(cr)
return result
# -- per-chamber --
def _ingest_chamber(self, name, date_iso):
cfg = self.cfg.source(name)
plan = build_source_plan(name, date_iso, cfg)
# 1. Cache first -- if raw bytes are present and not forced, no network.
if not self.force:
cached = self.cache.get(plan.source, date_iso, plan.ext)
if cached is not None:
self._alert(cfg, AlertContext(
kind="ok",
level="info",
message="cache HIT: serving from local cache (no network)",
source=name,
date=date_iso,
url=plan.primary_url,
extra={"as_of": plan.as_of,
"cache_entry": str(self.cache.meta_path(plan.source, date_iso))},
))
return ChamberResult(
source=name,
date=date_iso,
status="cached",
path=self.cache.raw_dir / plan.source / f"{date_iso}.{plan.ext}",
bytes=len(cached),
attempts=0,
cache_hit=True,
url=plan.primary_url,
as_of=plan.as_of,
note="served from local cache",
)
# 2. Network fetch with retry + alerting.
try:
fr = self._fetch(plan, cfg)
except NotFoundError as exc:
return self._handle_404(plan, name, cfg, exc)
except AntibotError as exc:
return self._handle_403(plan, name, cfg, exc)
except TransientError as exc:
return self._handle_transient(plan, name, cfg, exc)
# 3. Write to cache + alert success.
fetched_at = datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds")
out_path = self.cache.put(
plan.source, date_iso, plan.ext, fr.content,
url=fr.url,
status=fr.status,
fetched_at=fetched_at,
)
self._alert(cfg, AlertContext(
kind="ok",
level="info",
message="Fetched + cached %s (%d bytes, %d attempt(s))"
% (fr.url, len(fr.content), fr.attempts),
source=name,
date=date_iso,
url=fr.url,
extra={"status_code": fr.status, "as_of": plan.as_of},
))
return ChamberResult(
source=name,
date=date_iso,
status="ok",
path=out_path,
bytes=len(fr.content),
attempts=fr.attempts,
cache_hit=False,
url=fr.url,
as_of=plan.as_of,
)
# -- fetch dispatch --
def _fetch(self, plan, cfg):
if plan.kind == "pdf":
return self._fetch_pdf(plan, cfg)
return self.http.get(plan.primary_url, source=plan.source)
def _fetch_pdf(self, plan, cfg):
# Step 1: fetch the live index HTML (index.html carries no antibot).
index_fr = self.http.get(plan.primary_url, source=plan.source)
index_text = index_fr.content.decode("utf-8", errors="replace")
# Step 2: find the PDF filename that matches the requested date.
target_date = plan.date
found = None
for pdf_url in _iter_senate_pdf_urls(index_text, plan.pdf_url):
ref = parse_senate_pdf_filename(pdf_url)
if ref and ref["date_iso"] == target_date:
found = pdf_url
break
if found is None:
found = plan.pdf_url # fall back to the predicted URL
# Step 3: fetch the PDF itself (may hit antibot -> AntibotError).
return self.http.get(found, source=plan.source)
# -- error handlers --
def _handle_404(self, plan, name, cfg, exc):
if cfg.get("missing_is_expected_404", False):
# 404 on the Senate PDF is normal on non-legislative days.
self._alert(cfg, AlertContext(
kind="not_found",
level="info",
message="No publication for %s (404 expected on non-legislative days)"
% plan.date,
source=name,
date=plan.date,
url=plan.pdf_url,
))
return ChamberResult(
source=name,
date=plan.date,
status="not_found",
url=plan.pdf_url,
as_of=plan.as_of,
attempts=getattr(self.http, "last_attempts", 1),
note="404 no publication (expected)",
)
# Unexpected 404 -> alert as a warning.
self._alert(cfg, AlertContext(
kind="not_found",
level="warning",
message="Unexpected 404 for %s: %s" % (name, exc.url),
source=name,
date=plan.date,
url=exc.url,
extra={"status": exc.status},
))
return ChamberResult(
source=name,
date=plan.date,
status="not_found",
url=exc.url,
as_of=plan.as_of,
attempts=getattr(self.http, "last_attempts", 1),
note="unexpected 404",
)
def _handle_403(self, plan, name, cfg, exc):
self._alert(cfg, AlertContext(
kind="antibot",
level="error",
message="Antibot 403 on %s: %s\n"
"Mitigation: blocked-page-recovery skill or a headless browser "
"(antibot session pre-warm)." % (name, exc.url),
source=name,
date=plan.date,
url=exc.url,
extra={
"status": exc.status,
"body_len": len(exc.body),
"body_preview": exc.body[:200].decode("utf-8", errors="replace"),
},
))
return ChamberResult(
source=name,
date=plan.date,
status="antibot",
url=exc.url,
as_of=plan.as_of,
attempts=getattr(self.http, "last_attempts", 1),
note="antibot 403 (mitigate per blocked-page-recovery skill)",
)
def _handle_transient(self, plan, name, cfg, exc):
self._alert(cfg, AlertContext(
kind="transient",
level="error",
message="Transient after %d attempt(s) for %s: %s"
% (exc.attempts, name, exc),
source=name,
date=plan.date,
url=exc.url,
extra={"status": exc.status, "attempts": exc.attempts},
))
return ChamberResult(
source=name,
date=plan.date,
status="transient",
url=exc.url,
as_of=plan.as_of,
attempts=exc.attempts,
note="transient after %d retries" % exc.attempts,
)
# -- alert convenience --
def _alert(self, cfg, ctx):
fire(self.cfg.alerts, ctx)
# -- lifecycle --
def close(self):
self.http.close()
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
# -- helpers --
def _iter_senate_pdf_urls(index_text, fallback):
# Extract PDF download URLs referenced by the Senate index page HTML.
import re
urls = re.findall(
r'/dailyfile/publications/download/[^"<>\s]+\.PDF',
index_text,
re.IGNORECASE,
)
base = "https://www.senate.ca.gov"
seen = set()
out = []
for u in urls:
full = base + u if u.startswith("/") else u
if full not in seen:
out.append(full)
seen.add(full)
out.append(fallback)
return out
+125
View File
@@ -0,0 +1,125 @@
"""Output sinks: JSON Lines and a flat SQLite table."""
from __future__ import annotations
import json
import logging
import os
import re
from typing import List
from .schema import BillAction
log = logging.getLogger("scraper.io")
# Column order for the flat SQLite table: a stable, self-describing subset of
# the record fields. ``id`` is the content hash; text fields stay TEXT and the
# clearly-numeric fields use INTEGER.
_COLUMNS = [
("id", "TEXT"),
("date", "TEXT"),
("house", "TEXT"),
("source", "TEXT"),
("record_type", "TEXT"),
("bill_number", "TEXT"),
("bill_id", "TEXT"),
("session", "TEXT"),
("chamber_origin", "TEXT"),
("author", "TEXT"),
("short_title", "TEXT"),
("description", "TEXT"),
("committee", "TEXT"),
("hearing_time", "TEXT"),
("location", "TEXT"),
("item_number", "INTEGER"),
("supplement", "INTEGER"),
("ics_url", "TEXT"),
("agenda_id", "TEXT"),
("raw_selector", "TEXT"),
]
def _safe_ident(name: str) -> str:
return re.sub(r"[^0-9A-Za-z_]", "_", name or "_")
def _col_names():
return [_safe_ident(c) for c, _ in _COLUMNS]
def write_jsonl(records, path, fill_id=True, include_nulls=False):
"""Write records as JSON Lines (one JSON object per line).
Returns the number of records written. A record that cannot be serialised
is logged and skipped rather than aborting the batch.
"""
written = 0
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as fh:
for rec in records:
try:
data = rec.to_dict(fill_id=fill_id, include_nulls=include_nulls)
line = json.dumps(data, ensure_ascii=False, sort_keys=True)
except Exception as e: # noqa: BLE001
log.warning("skip record that cannot be serialised: %s", e)
continue
fh.write(line)
fh.write("\n")
written += 1
log.info("wrote %d records to %s", written, path)
return written
def write_sqlite(records, path, table="daily_file_records",
create_if_missing=True):
"""Persist records to a flat SQLite table. Returns the number written.
Creates the table and database file when needed so the CLI ``--sqlite`` flag
is self-sufficient. Existing rows are not replaced (plain INSERT).
"""
import sqlite3
col_list = ", ".join(_col_names())
col_defs = ", ".join(f'{_safe_ident(c)} {t}' for c, t in _COLUMNS)
safe_table = _safe_ident(table)
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
conn = sqlite3.connect(path)
try:
cur = conn.cursor()
if create_if_missing:
cur.execute(f"CREATE TABLE IF NOT EXISTS {safe_table} ({col_defs})")
cur.execute(f"PRAGMA table_info({safe_table})")
existing = {row[1] for row in cur.fetchall()}
used = [(_safe_ident(c), c) for c, _ in _COLUMNS if _safe_ident(c) in existing]
names = [s for s, _ in used]
placeholders = ", ".join(["?"] * len(names))
insert_cols = ", ".join(names)
written = 0
for i, rec in enumerate(records):
data = rec.to_dict(fill_id=True, include_nulls=False)
values = []
for safe, raw in used:
val = data.get(raw)
if val is None:
values.append(None)
elif safe in ("item_number", "supplement"):
try:
values.append(int(val))
except (TypeError, ValueError):
values.append(None)
else:
values.append(val)
try:
cur.execute(
f"INSERT INTO {safe_table} ({insert_cols}) VALUES ({placeholders})",
values)
written += 1
except Exception as e: # noqa: BLE001
log.warning("skip sqlite insert for record #%d: %s", i, e)
conn.commit()
finally:
conn.close()
log.info("wrote %d rows to %s::%s", written, path, table)
return written
+681
View File
@@ -0,0 +1,681 @@
"""``scraper.llm`` - spec §4 LLM analysis: subject-matter tag clouds.
Tag every bill with subject-matter tags via a *configurable* Ollama endpoint and
model, persisting the NEW information into a ``bill_tags`` table and producing a
tag cloud (``out/tag_cloud.json``). The endpoint + model are NEVER hardcoded --
they come from ``config.yaml``'s ``llm:`` section (``Config.llm``).
Frozen contracts honoured (task t_950780e0 / parent t_21cd58a9):
* Input bills are loaded from the flat ``daily_file_records`` table
(``scraper.io`` / ``scraper.schema.BillAction``) -- reused, never redefined.
* The Ollama HTTP request uses the stdlib ``urllib`` (no new dependency) and is
routed through an injectable ``post`` callable so the ollama path is unit
testable with a canned response and *no network*.
* **Deterministic degradation**: when ``llm.enabled`` is false, the endpoint is
unreachable, or ``llm.model`` is empty, the run falls back to a built-in
deterministic tagger (keyword / short_title extraction, **zero network**) so
analysis never blocks the build. The active path is logged and recorded on
every ``bill_tags`` row via its ``generated_by`` column
(''fallback'' vs ``'ollama:<model>'``).
The ``bill_tags`` table is additive (CREATE ... IF NOT EXISTS + INSERT OR IGNORE
on a natural key), mirrors the relational lane's style (``scraper.relate_*``),
and never alters the preserved flat ``daily_file_records`` contract.
"""
from __future__ import annotations
import json
import logging
import os
import re
import sqlite3
import urllib.error
import urllib.request
from typing import Any, Callable, Dict, List, Optional, Sequence
from . import relational
log = logging.getLogger("scraper.llm")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: New table storing the tag analysis (the "new info").
TAG_TABLE = "bill_tags"
#: generated_by sentinel for the deterministic built-in tagger.
FALLBACK = "fallback"
#: Default HTTP timeout (seconds) when cfg does not supply one.
DEFAULT_TIMEOUT = 30
#: Canonical Ollama endpoints, tried in order (OpenAI-compatible then native).
_ENDPOINTS = (
"/v1/chat/completions", # OpenAI-compatible (per spec §4)
"/api/generate", # Ollama native
)
#: Boilerplate lead-ins / filler drained from a California bill short_title
#: before its subject matter is extracted. These carry no subject signal.
_BOILERPLATE = (
r"^an act relating to\b",
r"^an act to\b",
r"^relating to\b",
r"^an act\b",
r"\band making an appropriation therefor\b",
r"\bmaking an appropriation therefor\b",
r"\band declaring the urgency thereof(?:(?:,?\s*to take effect immediately\b)?),?",
r"\bdeclaring the urgency thereof(?:(?:,?\s*to take effect immediately\b)?),?",
r"\bto take effect immediately\b",
r"\btherefor\b",
r"^[,\s]+",
r"[,\.\s]+$",
)
# Words that carry no subject-matter signal, removed from extracted terms.
_FILLER = {
"an", "the", "to", "of", "and", "for", "relating", "act", "making",
"appropriation", "therefor", "urgency", "thereof", "immediately",
"taking", "effect", "by", "from", "with", "in", "on", "as", "each",
"every", "a", "or", "shall", "its", "their", "his", "her", "which",
"such", "also", "including", "regarding",
}
#: A small, curated California subject taxonomy that maps regex cues found in
#: real bill text to canonical, human-readable tags. Used by the deterministic
#: tagger to cluster bills into a clean tag cloud. Every tag is anchored in a
#: real substring of a bill (never invented): a domain tag only appears when its
#: cue is actually present in the bill's text.
TAXONOMY = [
((r"solid waste|recycl|landfill|waste"), "solid waste & recycling"),
((r"water"), "water"),
((r"housing|residential rental|foreclosure|eviction"), "housing"),
((r"\bhealth\b|health care|health insurance|medicare|medicaid"
r"|hospital|pharm"), "health care"),
((r"mental health|mental ill|psychiatr|psycholog"), "mental health"),
((r"educat|k-?12|community college|universit|school"), "education"),
((r"tax|taxation|levy|revenue|fiscal"), "taxation"),
((r"employment|labor\b|labour|wage|workforce|unemployment"), "employment"),
((r"public resource|environment|pollution|emission|climate"
r"|california dream|zero emission|renewable|conserv"), "environment"),
((r"prison|penal|incarcerat|parole|probation|juvenile justice"
r"|sentenc|criminal justice"), "criminal justice"),
((r"juvenile|child|childhood|foster|adoption|dependency"), "child welfare"),
((r"transit|rail|highway|freeway|traffic|bikeway|pedestrian"
r"|public transport"), "public transit"),
((r"firearm|gun|ammunition|gun control"), "firearms"),
((r"budget|appropriation|funding|treasury|expenditure"), "budget"),
((r"senior|elderly|\bagged\b"), "seniors"),
((r"insurance|insuranc"), "insurance"),
((r"election|voter|ballot|legislative apportion|districting"), "elections"),
((r"immigrat|undocumented|asylum|deportat"), "immigration"),
((r"privacy|personal data|data protection|cyber|surveillance"), "data privacy"),
((r"small business|small enterprise|start ?up"), "small business"),
((r"agricultur|farm|farmers|crop"), "agriculture"),
((r"pension|retiree|retirement|public employee s fund"), "pensions"),
((r"homeless|homelessness"), "homelessness"),
((r"\bsafety\b|security|protectiv|guarding"), "public safety"),
((r"energy|electric|utility|power|solar"), "energy"),
((r"technology|internet|digital|online|software"), "technology"),
((r"maritime|harbor|port|fishing|ocean|fish"), "maritime"),
]
_TAXONOMY_RE = [(re.compile(pat, re.I), tag) for pat, tag in TAXONOMY]
# ---------------------------------------------------------------------------
# Bill loading (from the preserved flat table / BillAction)
# ---------------------------------------------------------------------------
def load_bills(path: str = relational.DEFAULT_DB,
house: Optional[str] = None) -> List[Dict[str, Any]]:
"""Load distinct bills from the preserved flat ``daily_file_records`` table.
Honors the frozen contract: input == rows written by ``scraper.io`` /
``BillAction``. Rows are deduped on ``bill_id`` (a bill can appear under
multiple agenda items / dates) so the tag cloud is per-bill, not per-row.
:param path: SQLite path (default: ``db/daily_file.sqlite``).
:param house: optional publish-house filter (e.g. ``'assembly'``); matches
either the publishing ``house`` or the bill's ``chamber_origin``.
"""
conn = relational.connect(path, foreign_keys=False)
try:
sql = ("SELECT DISTINCT bill_id, short_title, description, author, "
"session, chamber_origin, house FROM " + relational.FLAT_TABLE
+ " WHERE bill_id IS NOT NULL AND bill_id != ''")
rows = conn.execute(sql).fetchall()
finally:
conn.close()
bills: List[Dict[str, Any]] = []
seen = set()
for r in rows:
bid = r["bill_id"]
if bid in seen:
continue
seen.add(bid)
d = {
"bill_id": bid,
"short_title": r["short_title"],
"description": r["description"],
"author": r["author"],
"session": r["session"],
"chamber_origin": r["chamber_origin"],
"house": r["house"],
}
if house:
h = house.lower()
if (d["house"] or "").lower() != h and \
(d["chamber_origin"] or "").lower() != h:
continue
bills.append(d)
# Deterministic order (by bill_id) so fallback output is reproducible.
bills.sort(key=lambda b: b["bill_id"])
return bills
# ---------------------------------------------------------------------------
# Deterministic fallback tagger (ZERO network)
# ---------------------------------------------------------------------------
def _clean_subject(title: Optional[str]) -> List[str]:
"""Drain boilerplate from a CA short_title and return its subject phrases.
E.g. "An act relating to solid waste, and making an appropriation
therefor." -> ["solid waste"].
"""
if not title:
return []
s = title.strip().lower()
for pat in _BOILERPLATE:
s = re.sub(pat, " ", s)
# Split remaining text on comma / conjunction into fragments.
fragments = re.split(r"\s*,\s*|\s+and\s+", s)
out: List[str] = []
for frag in fragments:
frag = re.sub(r'^["\s]+|["\s]+$', "", frag.strip())
if not frag or frag in _FILLER:
continue
word = re.sub(r"[^a-z0-9' ]+", "", frag)
token = " ".join(t for t in word.split() if t and t not in _FILLER)
if token:
out.append(token)
return out
def _title_tag(fragments: List[str]) -> List[str]:
"""Turn cleaned subject fragments into tidy, distinct title-derived tags."""
tags: List[str] = []
seen = set()
for frag in fragments:
# Keep words; drop stray single letters / numerics-as-noise but keep
# meaningful numeric qualifiers (e.g. "2025").
words = frag.split()
norm = " ".join(words).strip()
if not norm:
continue
key = norm
if key not in seen:
seen.add(key)
tags.append(_title_case(norm))
return tags
def _title_case(s: str) -> str:
return " ".join(w[:1].upper() + w[1:] for w in s.split() if w)
def tag_bill_fallback(bill: Dict[str, Any],
max_tags: int = 8) -> List[tuple]:
"""Deterministic subject-matter tags for one bill. Zero network.
Returns a list of ``(tag, source)`` tuples, deduped and capped to
``max_tags``. Sources are ``'short_title'`` and/or ``'title+taxonomy'``.
Nothing is invented: every tag derives from a real substring of the bill's
text or from a taxonomy cue that is literally present in that text.
"""
seen: Dict[str, str] = {}
short = bill.get("short_title") or ""
desc = bill.get("description") or ""
text = (short + " " + desc).strip()
# 1) Title-derived subject phrases (primary "subject matter" of the bill).
for tag in _title_tag(_clean_subject(short)):
# Normalise to lowercase so title-derived tags merge with the taxonomy
# clusters (e.g. "Housing" -> "housing").
seen.setdefault(tag.lower(), "short_title")
# 2) Taxonomy clusters - only when the real cue is present in the text.
for rx, tag in _TAXONOMY_RE:
if rx.search(text):
seen.setdefault(tag, "title+taxonomy")
# 3) Guaranteed signal: if the title yielded no phrase, fall back to the
# title-cased raw title so the cloud still has a signal, not empty.
if not seen:
raw = re.sub(r"^[^A-Za-z0-9]+", "", (short or "").strip())
key = ("bill " + raw.lower())[:60].strip() or "Unclassified bill"
seen[key] = "short_title"
# Preserve deterministic insertion order; cap to max_tags.
return [(tag, src) for tag, src in list(seen.items())[:max_tags]]
# ---------------------------------------------------------------------------
# Ollama client (OpenAI-compatible / native, stdlib urllib, injectable)
# ---------------------------------------------------------------------------
def _build_prompt(bill: Dict[str, Any], max_tags: int) -> str:
title = bill.get("short_title") or ""
desc = bill.get("description") or ""
return (
"You are tagging a California legislative bill by subject matter.\n"
"Bill: " + (bill.get("bill_number") or
bill.get("bill_id") or "") + "\n"
"Title: " + title + "\n"
+ (("Action: " + desc + "\n") if desc else "")
+ "Reply with ONLY a JSON array of between 2 and "
+ str(max_tags) + " short, lowercase, snake_case subject-matter tags "
"drawn from real content in the bill. No prose, no explanation, e.g. "
'["housing", "residential rental", "foreclosure"].'
)
def _parse_response_payload(body: Dict[str, Any]) -> str:
"""Extract assistant text from an OpenAI-compatible or native Ollama body."""
if not isinstance(body, dict):
return ""
# OpenAI-compatible: choices[0].message.content
choices = body.get("choices")
if choices:
c0 = choices[0] or {}
content = (c0.get("message") or {}).get("content")
if content:
return str(content)
# Ollama native: {"response": ...}
if "response" in body:
return str(body.get("response") or "")
return str(body.get("content") or "")
def _extract_tags_from_text(text: str, fallback_model: str = "",
max_tags: int = 8) -> List[str]:
"""Parse model output into a tag list: prefer JSON array, else delIMITED."""
text = (text or "").strip()
if not text:
return []
# Try JSON (a code-fence or bare array both work after stripping fences).
fence = re.findall(r"```(?:json)?\s*(\[.*?\])\s*```", text, re.S)
candidate = fence[-1] if fence else None
if candidate is None:
m = re.search(r"(\[[^\]]*\])", text, re.S)
candidate = m.group(1) if m else None
if candidate:
try:
arr = json.loads(candidate)
if isinstance(arr, list):
tags: List[str] = []
seen = set()
for t in arr:
s = str(t).strip().lower()
s = re.sub(r"[^a-z0-9& ]+", " ", s).strip()
s = " ".join(s.split())
if s and s not in seen and s not in _FILLER:
seen.add(s)
tags.append(s)
if tags:
return tags[:max_tags]
except (ValueError, TypeError):
pass
# Degrade: split on punctuation / slashes / newlines.
raw = [p for p in re.split(r"[\n,/;]|\s{2,}|\.\s+\w+", text) if p.strip()]
out: List[str] = []
seen = set()
for part in raw:
s = re.sub(r"[^a-z0-9& ]+", " ", part.lower()).strip()
s = " ".join(s.split())
if s and s not in seen and s not in _FILLER and len(s) > 1:
seen.add(s)
out.append(s[:48])
return out[:max_tags]
def _ollama_request(endpoint: str, model: str, prompt: str,
timeout: int, post: Optional[Callable] = None) -> str:
"""POST to a configurable Ollama endpoint, trying compatible shapes.
:param post: injectable ``post(url, payload, timeout) -> payload`` callable,
so tests feed a canned response with no network. Defaults to a
stdlib-urllib implementation.
"""
base = (endpoint or "").rstrip("/")
last_err: Optional[Exception] = None
for path in _ENDPOINTS:
url = base + path
payload: Dict[str, Any]
if path == "/v1/chat/completions":
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"temperature": 0.0,
}
else: # /api/generate native
payload = {"model": model, "prompt": prompt, "stream": False}
try:
body = post(url, payload, timeout) if post is not None \
else _urllib_post(url, payload, timeout)
body = _coerce_json(body)
text = _parse_response_payload(body)
if text and text.strip():
return text
last_err = ValueError("empty Ollama response body")
except Exception as exc: # noqa: BLE001
last_err = exc
continue
# No compatible endpoint returned text -> signal caller to fall back.
raise RuntimeError(
"Ollama returned no usable text (%s)" %
(last_err if last_err else "all endpoints empty")
)
def _urllib_post(url: str, payload: Dict[str, Any],
timeout: int) -> Any:
"""Stdlib-urllib POST (no third-party dep). Returns parsed JSON body."""
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url, data=data, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "replace")
return _coerce_json(raw)
def _coerce_json(body: Any) -> Any:
"""Accept a dict (mock) or a JSON str/bytes (real HTTP) and normalise."""
if isinstance(body, (bytes, bytearray)):
body = body.decode("utf-8", "replace")
if isinstance(body, str):
return json.loads(body)
return body
# ---------------------------------------------------------------------------
# Analysis orchestration + persistence + tag cloud
# ---------------------------------------------------------------------------
def ensure_tag_table(path: str = relational.DEFAULT_DB) -> None:
"""Idempotently create the additive ``bill_tags`` table. Does not touch the
preserved flat table."""
conn = relational.connect(path, foreign_keys=False)
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS " + TAG_TABLE + " ("
" id INTEGER PRIMARY KEY,"
" bill_id TEXT NOT NULL,"
" tag TEXT NOT NULL,"
" source TEXT,"
" generated_by TEXT NOT NULL,"
" generated_at TEXT,"
" UNIQUE (bill_id, tag, generated_by)"
")"
)
conn.commit()
finally:
conn.close()
def _generated_by(model: str) -> str:
return "ollama:" + model if model else "ollama"
def tag_bills(bills: Sequence[Dict[str, Any]], cfg: Dict[str, Any],
post: Optional[Callable] = None) -> List[Dict[str, Any]]:
"""Tag every bill, choosing Ollama or the deterministic fallback.
Path selection (deterministic degradation):
* ``enabled`` false -> fallback (reason 'disabled')
* ``model`` empty -> fallback (reason 'no model configured')
* otherwise attempt Ollama; on ANY error (unreachable / empty / bad parse)
-> fallback (reason 'unreachable: ...'), logged, never crashing the build
Each bill gets a row of ``{"bill_id", "tags": [(tag, source)],
"generated_by", "reason"}``.
"""
enabled = bool(cfg.get("enabled"))
model = (cfg.get("model") or "").strip()
max_tags = int(cfg.get("default_tags") or 8)
endpoint = (cfg.get("endpoint") or "").strip()
timeout = int(cfg.get("timeout_seconds") or DEFAULT_TIMEOUT)
use_ollama = enabled and bool(model) and bool(endpoint)
results: List[Dict[str, Any]] = []
ran_fallback = False
reason = "enabled=true, model=%s" % model
for b in bills:
tags: List[tuple] = []
gen = FALLBACK
if use_ollama:
try:
text = _ollama_request(endpoint, model,
_build_prompt(b, max_tags),
timeout, post=post)
parsed = _parse_tags_for_bill(text, max_tags=max_tags)
if parsed:
tags = [(t, "ollama") for t in parsed]
gen = _generated_by(model)
else:
ran_fallback = True
tags = [(t, s) for (t, s) in tag_bill_fallback(b, max_tags)]
gen = FALLBACK
reason = "ollama empty -> fallback"
except Exception as exc: # noqa: BLE001 -- degrade, never crash
log.warning("Ollama unreachable/bad-response for %s: %s; "
"using fallback", b.get("bill_id"), exc)
ran_fallback = True
tags = [(t, s) for (t, s) in tag_bill_fallback(b, max_tags)]
gen = FALLBACK
reason = "unreachable: %s" % (type(exc).__name__)
else:
if not model:
reason = "no model configured"
else:
reason = "disabled"
tags = [(t, s) for (t, s) in tag_bill_fallback(b, max_tags)]
gen = FALLBACK
ran_fallback = True
results.append({
"bill_id": b.get("bill_id"),
"tags": tags,
"generated_by": gen,
"reason": reason,
})
# Report the dominant path so the log + the summary name what happened.
if not use_ollama:
log.info("llm path = fallback (%s)", reason)
elif ran_fallback:
log.info("llm path = ollama with per-bill fallback (%s)", reason)
else:
log.info("llm path = ollama (model=%s, endpoint=%s)",
model, endpoint)
return results
def _parse_tags_for_bill(text: str, max_tags: int = 8) -> List[str]:
"""Public parse hook used above so the mock test can assert it directly."""
return _extract_tags_from_text(text, max_tags=max_tags)
def persist_tags(path: str, results: Sequence[Dict[str, Any]],
generated_at: Optional[str] = None) -> int:
"""Persist tagged bills into ``bill_tags``. Idempotent on the natural key
`` (bill_id, tag, generated_by)``. Returns rows written this pass."""
ensure_tag_table(path)
conn = relational.connect(path, foreign_keys=False)
written = 0
try:
for r in results:
for (tag, src) in r.get("tags") or []:
cur = conn.execute(
"INSERT OR IGNORE INTO " + TAG_TABLE +
" (bill_id, tag, source, generated_by, generated_at) "
"VALUES (?,?,?,?,?)",
(str(r["bill_id"]) if r.get("bill_id") is not None else "",
str(tag), src, r["generated_by"], generated_at))
if cur.rowcount:
written += 1
conn.commit()
finally:
conn.close()
return written
def build_tag_cloud(db_path: str = relational.DEFAULT_DB,
generated_by: Optional[str] = None,
top: Optional[int] = None) -> Dict[str, Any]:
"""Aggregate ``bill_tags`` into a tag cloud (tag -> count, desc).
:param generated_by: restrict to rows whose ``generated_by`` starts with
this (e.g. ``'fallback'`` or ``'ollama'``); ``None``
aggregates all rows.
:param top: cap the returned tag list to the N most frequent.
"""
conn = relational.connect(db_path, foreign_keys=False)
try:
ensure_tag_table(db_path)
if generated_by:
rows = conn.execute(
"SELECT tag, COUNT(*) AS n, COUNT(DISTINCT bill_id) AS bills "
"FROM " + TAG_TABLE +
" WHERE generated_by LIKE ? GROUP BY tag ORDER BY n DESC, tag",
(generated_by + "%",)).fetchall()
total_bills = conn.execute(
"SELECT COUNT(DISTINCT bill_id) FROM " + TAG_TABLE +
" WHERE generated_by LIKE ?", (generated_by + "%",)).fetchone()[0]
else:
rows = conn.execute(
"SELECT tag, COUNT(*) AS n, COUNT(DISTINCT bill_id) AS bills "
"FROM " + TAG_TABLE + " GROUP BY tag ORDER BY n DESC, tag"
).fetchall()
total_bills = conn.execute(
"SELECT COUNT(DISTINCT bill_id) FROM " + TAG_TABLE
).fetchone()[0]
finally:
conn.close()
tags = [{"tag": r["tag"], "count": int(r["n"]), "bills": int(r["bills"])}
for r in rows]
if top is not None:
tags = tags[:top]
return {
"tag_count": len(tags),
"bill_count": total_bills,
"generated_by": generated_by,
"tags": tags,
}
def write_tag_cloud(cloud: Dict[str, Any], out_path: str) -> int:
"""Write the tag cloud to JSON. Returns the number of tags written.
The file is diff-stable between runs (tags ordered desc by count, then tag).
"""
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
with open(out_path, "w", encoding="utf-8") as fh:
json.dump(cloud, fh, ensure_ascii=False, indent=2, sort_keys=False)
fh.write("\n")
return int(cloud.get("tag_count", 0))
def analyze(db_path: str = relational.DEFAULT_DB,
out_path: Optional[str] = None,
cfg: Optional[Dict[str, Any]] = None,
post: Optional[Callable] = None,
house: Optional[str] = None,
generated_at: Optional[str] = None,
top: Optional[int] = None,
rebuild: bool = False) -> Dict[str, Any]:
"""Full ``analyze`` pass: load bills, tag them, persist, build + write cloud.
Returns a summary:: {bills_tagged, unique_tags, generated_by, reason,
cloud_path, top_tags, rows_written, cloud}. The dominant path is in
``generated_by`` / ``reason`` (both log the fallback decision).
"""
cfg = cfg or {}
bills = load_bills(db_path, house=house)
results = tag_bills(bills, cfg, post=post)
if rebuild:
_drop_tag_rows(db_path, keep_by=None)
ensure_tag_table(db_path)
written = persist_tags(db_path, results, generated_at=generated_at)
# Determine the dominant generated_by for the cloud + reporting.
dominant = _dominant_generated_by(results)
cloud = build_tag_cloud(db_path, generated_by=None, top=top)
if out_path:
write_tag_cloud(cloud, out_path)
cloud_path = out_path
else:
cloud_path = None
reason = results[0]["reason"] if results else "no bills"
return {
"path": "fallback" if dominant == FALLBACK else "ollama",
"generated_by": dominant,
"reason": reason,
"bills_tagged": len(results),
"unique_tags": cloud["tag_count"],
"rows_written": written,
"cloud": cloud,
"cloud_path": cloud_path,
"out_path": out_path,
"top_tags": cloud["tags"][: (top or 10)],
}
# ---------------------------------------------------------------------------
# Internals: rebuild + dominance
# ---------------------------------------------------------------------------
def _drop_tag_rows(path: str, keep_by: Optional[str] = None) -> None:
"""Clear ``bill_tags`` rows (optionally keep rows for one generated_by)."""
ensure_tag_table(path)
conn = relational.connect(path, foreign_keys=False)
try:
if keep_by:
conn.execute("DELETE FROM " + TAG_TABLE +
" WHERE generated_by NOT LIKE ?", (keep_by + "%",))
else:
conn.execute(
"DELETE FROM " + TAG_TABLE +
" WHERE 1=1"
)
conn.commit()
finally:
conn.close()
def _dominant_generated_by(results: Sequence[Dict[str, Any]]) -> str:
"""Which path won: 'fallback' if any bill fell back, else the ollama model."""
counts: Dict[str, int] = {}
for r in results:
gb = r["generated_by"]
counts[gb] = counts.get(gb, 0) + 1
if not counts:
return FALLBACK
if counts.get(FALLBACK, 0) > 0:
return FALLBACK
first = next(iter(counts))
return first
+86
View File
@@ -0,0 +1,86 @@
# standalone parse subcommand for the parser lane
# The shared entry point (`scraper/__main__.py` -> `cli.py`) is jointly owned
# by the ingestion task (registers `ingest`/`list`/`check`) and this task
# (`parse`). To avoid clobbering the sibling's files, the parse CLI lives in
# this module and the shared dispatcher wires it in additively: from
# .parse_cli import register, handle_parse; then register(sub) and
# handlers['parse'] = handle_parse. Direct invocation: python -m
# scraper.parse_main parse --input ...
from __future__ import annotations
import argparse
import logging
import sys
from . import parsers
from .io import write_jsonl, write_sqlite
from .schema import ASSEMBLY_DAILY_FILE, SENATE_DAILY_FILE_PDF
log = logging.getLogger("scraper.parse")
def _already_registered(sub, name):
actions = getattr(sub, "_choices_actions", [])
return any(getattr(a, "name", None) == name for a in actions)
def register(sub):
if _already_registered(sub, "parse"):
return
pp = sub.add_parser("parse", help="parse cached Daily File HTML into JSONL / SQLite",
description="Parse cached Daily File HTML into JSONL / SQLite records.")
pp.add_argument("--input", "-i", required=True, help="HTML file, directory, or glob")
pp.add_argument("--output", "-o", help="JSONL output path; omit for stdout")
pp.add_argument("--sqlite", dest="sqlite_path", help="optional SQLite output path")
pp.add_argument("--table", default="daily_file_records", help="SQLite table name")
pp.add_argument("--house", default="assembly", choices=["assembly", "senate"], help="publishing house")
pp.add_argument("--source", default=ASSEMBLY_DAILY_FILE, help="source tag for each record")
pp.add_argument("--no-hearings", action="store_true", help="omit hearing records")
pp.add_argument("--json-include-nulls", action="store_true", help="include null fields")
pp.add_argument("--parser", default="lxml", choices=["lxml", "html.parser"], help="backend")
pp.set_defaults(func=handle_parse)
def _configure_logging(verbose):
level = logging.WARNING
if verbose == 1:
level = logging.INFO
elif verbose >= 2:
level = logging.DEBUG
fmt = "%(levelname)s %(name)s: %(message)s"
logging.basicConfig(level=level, format=fmt)
def handle_parse(args):
_configure_logging(getattr(args, "verbose", 0))
house = args.house.upper()
source = args.source
if source == ASSEMBLY_DAILY_FILE and args.house == "senate":
source = SENATE_DAILY_FILE_PDF
records = parsers.parse(args.input, house=house, source=source,
parser=args.parser, include_hearings=not args.no_hearings)
out_count = 0
if args.output:
out_count = write_jsonl(records, args.output, include_nulls=args.json_include_nulls)
elif not args.sqlite_path:
for rec in records:
print(rec.to_json())
out_count = len(records)
if args.sqlite_path:
write_sqlite(records, args.sqlite_path, table=args.table)
log.info("parsed %d record(s); jsonL=%s sqlite=%s", len(records),
out_count if args.output else "stdout", "on" if args.sqlite_path else "off")
return 0
def main(argv=None):
p = argparse.ArgumentParser(prog="scraper.parse", description="Standalone parse CLI (parser lane).")
p.add_argument("-v", "--verbose", action="count", default=0, help="-v INFO, -vv DEBUG")
sub = p.add_subparsers(dest="command", required=True)
register(sub)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+15
View File
@@ -0,0 +1,15 @@
"""Run the parser CLI as ``python -m scraper.parse_cli parse --input ...``.
The shared ``python -m scraper`` entrypoint (``scraper/__main__.py``) is jointly
owned by the ingestion task, so this module provides a parser-only entry point
that does not depend on the sibling's ``cli.py`` / ``__init__.py``.
"""
from __future__ import annotations
import sys
from .parse_cli import main
if __name__ == "__main__":
sys.exit(main())
+317
View File
@@ -0,0 +1,317 @@
"""HTML parsers: transform cached raw Daily File HTML into BillAction records.
Selector contracts follow DAILY_FILE_PARSE_SPEC.md section 3:
- Committee hearings : table.committee-hearing-table / tr.committee-hearing-details
- Bills on the floor : table.floor-bills-table / tr.floor-bill-details
- Bills in committee : table.committee-bills-table / tr[class~=committee-bills-details]
Robustness (task acceptance criteria):
* malformed rows (missing bill link, no number, decode failures) are skipped
with a logged warning and never abort the batch;
* encoding issues are handled by a charset decode with fallback + logging;
* multi-page / multi-file input is handled by iterating every *.html the
input resolves to.
"""
from __future__ import annotations
import glob
import logging
import os
import re
from datetime import datetime
from typing import List, Optional
from .bill_id import parse_bill_id, parse_display, parse_supplement, origin_house
from .schema import BillAction, ASSEMBLY_DAILY_FILE
log = logging.getLogger("scraper.parsers")
# ---- section selectors ---------------------------------------------------
HEARING_SELECTOR = "table.committee-hearing-table tr.committee-hearing-details"
FLOOR_SELECTOR = "table.floor-bills-table tr.floor-bill-details"
COMMITTEE_SELECTOR = "table.committee-bills-table tr[class~=committee-bills-details]"
# Looser fallbacks in case a table class is missing but rows are still present.
HEARING_ROW = "tr[class*=committee-hearing-details]"
FLOOR_ROW = "tr[class*=floor-bill-details]"
COMMITTEE_ROW = "tr[class*=committee-bills-details]"
_SUPP = re.compile(r"Supplemental\s+(\d+)", re.IGNORECASE)
_DATE_SPLIT = re.compile(r"-\d+$") # strip display suffix like '-001'
# --- cell helpers ---------------------------------------------------------
def _attr_or_text(td, prefer: str = "data-sort") -> Optional[str]:
"""Return td[prefer] when present else trimmed textContent."""
if prefer and td is not None and td.get(prefer):
return td.get(prefer).strip()
return td.get_text(" ", strip=True) if td is not None else None
def normalise_date(raw: Optional[str]) -> Optional[str]:
"""'08/31/26-001' / '08/31/26' -> '2026-08-31'. Non-date -> None."""
if not raw:
return None
s = _DATE_SPLIT.sub("", raw.strip()).strip()
if not s:
return None
for fmt in ("%m/%d/%y", "%m/%d/%Y", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return None
# --- section parsers ------------------------------------------------------
def _parse_hearings(soup, house, source, file_date=None):
rows = soup.select(HEARING_SELECTOR) or soup.select(HEARING_ROW)
records = []
for i, tr in enumerate(rows):
try:
date_td = tr.select_one("td.committee_hearing-date")
time_td = tr.select_one("td.committee_hearing-time")
comm_td = tr.select_one("td.committee_hearing-name")
loc_td = tr.select_one("td.committee_hearing-location")
menu = tr.select_one("td.committee_hearing-menu")
date = normalise_date(_attr_or_text(date_td))
committee = _attr_or_text(comm_td, "data-sort")
ics = None
agenda = None
if menu is not None:
ics_a = menu.select_one("a[href^='/api/ics/generate']")
if ics_a:
ics = ics_a.get("href")
ag_a = menu.select_one("a[href^='/api/dailyfile/agenda/']")
if ag_a:
agenda = ag_a.get("href", "").rsplit("/", 1)[-1]
if not committee and date is None:
raise ValueError("hearing row has neither committee nor date")
records.append(BillAction(
date=date,
house=house,
source=source,
record_type="hearing",
committee=committee,
hearing_time=_attr_or_text(time_td, "text") or None,
location=_attr_or_text(loc_td, "data-sort"),
ics_url=ics,
agenda_id=agenda,
raw_selector="committee-hearing-table",
))
except Exception as e: # noqa: BLE001
log.warning("skip malformed hearing row #%d: %s", i, e)
# backfill a missing per-row date from the file date
for h in records:
if not h.date and file_date:
h.date = file_date
return records
def _extract_bill_measure(measure_td):
"""Return (bill_number, bill_id, supplement) from a measure / number cell."""
supplement = None
a = measure_td.select_one("a")
bill_id = None
bill_number = None
if a is not None and a.get("href"):
m = re.search(r"bill_id=([A-Za-z0-9]+)", a["href"])
if m:
bill_id = m.group(1)
dec = parse_bill_id(bill_id)
if dec:
bill_number = dec["display"]
if not bill_number:
# Fallback: the display text before any <br> (e.g. "AB 28"). Only accept
# a clean "PREFIX N" form; anything with extra text is a malformed row
# and is left None so the caller can skip it.
display = a.get_text(" ", strip=True) if a is not None else measure_td.get_text(" ", strip=True)
m2 = re.match(r"^(AB|SB|ACR|SCR|HR|SJR|ABR)\s*(\d+)$", (display or "").strip())
if m2:
bill_number = f"{m2.group(1)} {int(m2.group(2))}"
bill_id = None # display only; leave bill_id None
parsed_supp = parse_supplement(measure_td.get_text(" ", strip=True))
if parsed_supp:
supplement = parsed_supp
return bill_number, bill_id, supplement
def _parse_floor_bills(soup, house, source, file_date):
rows = soup.select(FLOOR_SELECTOR) or soup.select(FLOOR_ROW)
records = []
for i, tr in enumerate(rows):
try:
measure = tr.select_one("td.floor_bill-measure")
if measure is None:
raise ValueError("missing floor_bill-measure cell")
bill_number, bill_id, supplement = _extract_bill_measure(measure)
if not (bill_number or bill_id):
raise ValueError("no bill number/id in floor row")
dec = parse_bill_id(bill_id) if bill_id else None
chamber_origin = dec["house"].upper() if dec else origin_house((bill_number or "").split(" ")[0])
session = dec["session"] if dec else None
view = tr.select_one("td.floor_bill-view-details")
description = None
if view is not None:
description = view.get_text(" ", strip=True) or None
item_td = tr.select_one("td.floor_bill-item")
item_text = _attr_or_text(item_td, "data-sort") or _attr_or_text(item_td, "text")
item_number = int(item_text.strip()) if item_text and item_text.strip().isdigit() else None
short_title = _attr_or_text(tr.select_one("td.floor_bill-subject"), "text")
records.append(BillAction(
date=file_date,
house=house,
source=source,
record_type="bill",
bill_number=bill_number,
bill_id=bill_id,
session=session,
chamber_origin=chamber_origin,
author=_attr_or_text(tr.select_one("td.floor_bill-author"), "data-sort"),
short_title=short_title,
description=description or short_title,
location=_attr_or_text(tr.select_one("td.floor_bill-location"), "data-sort"),
item_number=item_number,
supplement=supplement,
raw_selector="floor-bills-table",
))
except Exception as e: # noqa: BLE001
log.warning("skip malformed floor row #%d: %s", i, e)
return records
def _parse_committee_bills(soup, house, source, file_date):
rows = soup.select(COMMITTEE_SELECTOR) or soup.select(COMMITTEE_ROW)
records = []
for i, tr in enumerate(rows):
try:
num_td = tr.select_one("td.committee_bill-number")
if num_td is None:
raise ValueError("missing committee_bill-number cell")
bill_number, bill_id, _ = _extract_bill_measure(num_td)
if not (bill_number or bill_id):
raise ValueError("no bill number/id in committee-bill row")
dec = parse_bill_id(bill_id) if bill_id else None
chamber_origin = dec["house"].upper() if dec else origin_house((bill_number or "").split(" ")[0])
short_title = _attr_or_text(tr.select_one("td.committee_bill-subject"), "text")
records.append(BillAction(
date=file_date,
house=house,
source=source,
record_type="bill",
bill_number=bill_number,
bill_id=bill_id,
session=dec["session"] if dec else None,
chamber_origin=chamber_origin,
author=_attr_or_text(tr.select_one("td.committee_bill-author"), "data-sort"),
short_title=short_title,
description=short_title,
committee=_attr_or_text(tr.select_one("td.committee_bill-committee"), "data-sort"),
raw_selector="committee-bills-table",
))
except Exception as e: # noqa: BLE001
log.warning("skip malformed committee-bill row #%d: %s", i, e)
return records
def parse_html(html: str, *, house: str = "assembly",
source: str = ASSEMBLY_DAILY_FILE, parser: str = "lxml") -> List[BillAction]:
"""Parse a single HTML document into a list of BillAction records.
The date is derived from the committee-hearing table (the daily file's date)
and applied to the bill rows, per spec 3.2 (bills carry no per-row date).
"""
from bs4 import BeautifulSoup
engine = parser if parser in ("lxml", "html.parser") else "html.parser"
try:
soup = BeautifulSoup(html, engine)
except Exception as e: # noqa: BLE001
log.error("failed to parse document (%s): %s", type(e).__name__, e)
return []
hearings = _parse_hearings(soup, house, source, file_date=None)
file_date = next((h.date for h in hearings if h.date), None)
for h in hearings:
if not h.date:
h.date = file_date
# Bills first (floor, then committee), then hearings — so a parsed batch
# leads with bill records, each carrying bill_number/session/etc.
records = []
records.extend(_parse_floor_bills(soup, house, source, file_date))
records.extend(_parse_committee_bills(soup, house, source, file_date))
records.extend(hearings)
return records
def _read_text(path: str) -> str:
"""Read a file with charset detection + an encoding fallback."""
with open(path, "rb") as fh:
raw = fh.read()
head = raw[:2048].decode("utf-8", "ignore").lower()
charset = "utf-8"
meta = re.search(r"content-type=\"[^\"]*?charset=([a-z0-9_-]+)", head)
if meta:
charset = meta.group(1)
else:
decl = re.search(r'charset\s*=\s*["\']?\s*([a-z0-9_-]+)', head)
if decl:
charset = decl.group(1)
try:
text = raw.decode(charset)
except (UnicodeDecodeError, LookupError):
log.warning("bad charset %r in %s, falling back to UTF-8/replace", charset, path)
text = raw.decode("utf-8", "replace")
if "\ufffd" in text:
log.warning("replacement chars detected while decoding %s", path)
return text
def _resolve_inputs(input_path: str) -> List[str]:
"""Expand an input into HTML files: a file, a directory (all *.html), or a glob."""
if os.path.isdir(input_path):
return sorted(glob.glob(os.path.join(input_path, "*.html")))
if any(ch in input_path for ch in "*?[]"):
return sorted(glob.glob(input_path))
return [input_path]
def parse(input_path: str, *, house: str = "assembly",
source: str = ASSEMBLY_DAILY_FILE, parser: str = "lxml",
include_hearings: bool = True) -> List[BillAction]:
"""Parse one or more cached HTML files into a flat list of records.
Robust to malformed rows (logged + skipped), bad encodings (logged +
fallback), and multi-file/multi-page input. Never aborts the batch on a
single bad file or row.
"""
files = _resolve_inputs(input_path)
if not files:
log.warning("no input files matched %r", input_path)
return []
records: List[BillAction] = []
for path in files:
if not os.path.exists(path):
log.warning("input file not found: %s", path)
continue
try:
html = _read_text(path)
except OSError as e:
log.warning("cannot read %s: %s", path, e)
continue
doc_records = parse_html(html, house=house, source=source, parser=parser)
if not include_hearings:
doc_records = [r for r in doc_records if r.record_type != "hearing"]
records.extend(doc_records)
return records
+129
View File
@@ -0,0 +1,129 @@
# Additive CLI for the correlation lane (task t_2736f40d, spec 2.5).
#
# The shared entry point `scraper/__main__.py -> scraper/cli.py` is jointly
# owned by the ingestion lane (`ingest`/`list`/`check`) and the parser lane
# (`parse`, via scraper/parse_cli.py). To avoid clobbering sibling files this
# CLI lives in its own module and is wired into the shared dispatcher
# ADDITIVELY + DEFENSIVELY: `cli.py` calls `register(sub)` inside a try/except
# and adds its two handler entries without touching any sibling handler. The
# `migrate`/`correlate` names do not collide with sibling subcommands.
#
# Direct invocation (no shared dispatcher needed):
# python -m scraper.relate_cli migrate --db db/daily_file.sqlite
# python -m scraper.relate_cli correlate --roster rosters.json
from __future__ import annotations
import sys
from . import relational
log = __import__("logging").getLogger("scraper.relate_cli")
def _already_registered(sub, *names: str) -> bool:
# Guard: if any of our subcommands is already registered by a sibling,
# skip registration rather than shadowing it.
actions = getattr(sub, "_choices_actions", [])
present = {getattr(a, "name", None) for a in actions}
return any(n in present for n in names)
def register(sub) -> None:
"""Register the `migrate` and `correlate` subcommands (guarded).
Mirrors scraper/parse_cli.py::register so the pattern stays uniform across
lanes and the shared `cli.py` can wire it in without risk.
"""
if _already_registered(sub, "migrate", "correlate"):
return
pm = sub.add_parser(
"migrate",
help="create the relational schema (idempotent; flat table preserved)",
description=("Create/upgrade the relational schema; preserves the flat "
"daily_file_records table + adds normalized tables & view."),
)
pm.add_argument("--db", default=relational.DEFAULT_DB,
help="SQLite path (default: db/daily_file.sqlite)")
pm.add_argument("--check", action="store_true",
help="report schema state only (no writes)")
pm.set_defaults(func=handle_migrate)
pc = sub.add_parser(
"correlate",
help="derive bill<->committee<->member relations from the flat table",
description=("Backfill normalized relation tables from "
"daily_file_records. Committees with no roster source are "
"left with empty membership (never fabricated)."),
)
pc.add_argument("--db", default=relational.DEFAULT_DB,
help="SQLite path (default: db/daily_file.sqlite)")
pc.add_argument("--roster", dest="roster", default=None,
help="JSON file of committee rosters to backfill members")
pc.add_argument("--rebuild", action="store_true",
help="clear derived tables first, then backfill cleanly")
pc.set_defaults(func=handle_correlate)
def _configure_logging(verbose: int) -> None:
import logging
level = logging.WARNING
if verbose == 1:
level = logging.INFO
elif verbose >= 2:
level = logging.DEBUG
logging.basicConfig(level=level,
format="%(levelname)s %(name)s: %(message)s")
def handle_migrate(args) -> int:
# No --verbose flag on migrate; keep warnings visible so "no roster" gaps
# (which are warnings) still surface by default.
_configure_logging(0)
db = args.db
if getattr(args, "check", False):
summary = relational.migrate(db)
print("schema migration (check):")
print(" db:", summary["db"])
print(" objects:", ", ".join(summary["objects"]))
print(" flat_rows:", summary["flat_rows"], "version:",
summary["version"])
return 0
summary = relational.migrate(db)
print("migrated schema at %s" % summary["db"])
print(" objects:", ", ".join(summary["objects"]))
print(" flat_rows preserved:", summary["flat_rows"])
return 0
def handle_correlate(args) -> int:
# Verbose default OFF; the per-committee "no roster" warnings that the
# never-fabricate rule emits should be visible, so do not suppress log.
_configure_logging(0)
db = args.db
# Ensure the schema exists even if `migrate` was never run on this db.
relational.migrate(db)
counts = relational.correlate(db, rosters=args.roster,
rebuild=args.rebuild, quiet=False)
print("correlated %s" % db)
print(" counts:", ", ".join("%s=%d" % (k, v)
for k, v in sorted(counts.items())))
return 0
def main(argv=None) -> int:
import argparse
p = argparse.ArgumentParser(
prog="scraper.relate",
description="Relational correlation: bills <-> committees <-> members.")
p.add_argument("-v", "--verbose", action="count", default=0,
help="-v INFO, -vv DEBUG")
sub = p.add_subparsers(dest="command", required=True)
register(sub)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+525
View File
@@ -0,0 +1,525 @@
"""Relational core for spec §2.5 — bills ↔ committees ↔ members.
The rest of the pipeline persists a FLAT table (``daily_file_records`` written by
``scraper.io.write_sqlite``). QA and the downstream UI read that flat contract,
so this lane NEVER mutates it. Instead it ADDS a normalised relational schema
and a flat-preserving VIEW on top of the same SQLite database, and derives the
relations (bill↔committee, committee↔member) from the flat rows + an optional
committee-roster source.
FROZEN CONTRACTS honoured (see task t_2736f40d / parent t_21cd58a9):
* ``daily_file_records`` stays fully intact (CREATE IF NOT EXISTS only).
* New tables: ``bills``, ``committees``, ``members``, ``committee_members``
(committee↔member join), ``bill_committees`` (bill↔committee join), plus a
``schema_migrations`` ledger and a ``v_daily_file_records`` view.
* FOREIGN KEYS are enabled on every connection; UNIQUE constraints on the
natural keys (bill_id+session+chamber; committee name+session+chamber;
committee+member).
* Migration and correlation are IDEMPOTENT: CREATE IF NOT EXISTS +
INSERT OR IGNORE on deterministic keys. Re-running adds no rows / no errors.
* NEVER FABRICATE MEMBERS. ``committee_members`` is populated ONLY from a
supplied roster source. When a committee seen in the daily file has no roster
entry, it is still created (with its ``session``) but is left with an EMPTY
member set and a WARN is logged listing which committees are missing rosters —
we record the gap, we do not invent members.
This module uses only the stdlib (sqlite3 + a tiny JSON roster reader). No new
third-party dependency is introduced.
"""
from __future__ import annotations
import json
import logging
import os
import re
import sqlite3
from typing import Any, Dict, Iterable, List, Optional
log = logging.getLogger("scraper.relate")
# ---------------------------------------------------------------------------
# Key constants
# ---------------------------------------------------------------------------
#: Default database path (spec: db/daily_file.sqlite), resolved relative to the
#: repository root so it is stable regardless of cwd.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_DB = os.path.join(_REPO_ROOT, "db", "daily_file.sqlite")
FLAT_TABLE = "daily_file_records"
#: Schema version stamped into ``schema_migrations`` by :func:`migrate`.
SCHEMA_VERSION = 1
#: The flat columns QA + downstream rely on. Imported so the flat-preserving
#: view is byte-for-byte aligned with the contract the rest of the pipeline
#: writes via ``scraper.io.write_sqlite``.
try: # pragma: no cover - import guard (io is a sibling in the same package)
from .io import _COLUMNS as FLAT_COLUMNS
except Exception: # pragma: no cover
# Fallback if io ever changes; keeps the module importable for unit tests.
FLAT_COLUMNS = [
("id", "TEXT"), ("date", "TEXT"), ("house", "TEXT"), ("source", "TEXT"),
("record_type", "TEXT"), ("bill_number", "TEXT"), ("bill_id", "TEXT"),
("session", "TEXT"), ("chamber_origin", "TEXT"), ("author", "TEXT"),
("short_title", "TEXT"), ("description", "TEXT"), ("committee", "TEXT"),
("hearing_time", "TEXT"), ("location", "TEXT"), ("item_number", "INTEGER"),
("supplement", "INTEGER"), ("ics_url", "TEXT"), ("agenda_id", "TEXT"),
("raw_selector", "TEXT"),
]
_FLAT_COL_ORDER = [c for c, _ in FLAT_COLUMNS]
_FLAT_COL_DEFS = ", ".join(f'"{c}" {t}' for c, t in FLAT_COLUMNS)
_SCHEMA_TABLES = (
"bills", "committees", "members", "committee_members", "bill_committees",
"schema_migrations",
)
# ---------------------------------------------------------------------------
# Normalisation helpers
# ---------------------------------------------------------------------------
_SLUG_RE = re.compile(r"[^a-z0-9]+")
def normalise_name(raw: Optional[str]) -> str:
"""Title-case + trim a committee/author/member display name."""
if not raw:
return ""
s = re.sub(r"\s+", " ", raw.strip())
return s
def slug(raw: Optional[str]) -> str:
"""Deterministic match key: lowercased, non-alphanumerics -> single '-'.
Mirrors spec §6.1 ('Normalise by title-casing and stripping
non-alphanumerics') so a roster's committee name resolves to the same
committee the daily file references.
"""
if not raw:
return ""
s = _SLUG_RE.sub("-", raw.strip().lower()).strip("-")
return s or "unknown"
def session_key(raw: Optional[str]) -> str:
"""Coalesce a NULL/empty session to a stable sentinel ('') so the
natural-key UNIQUE constraints stay effective and re-runs idempotent."""
return (raw or "").strip()
def chamber_key(raw: Optional[str]) -> str:
"""Normalise chamber origin/house to a stable lowercase key."""
return (raw or "").strip().lower() or "unknown"
# ---------------------------------------------------------------------------
# Connection + schema DDL
# ---------------------------------------------------------------------------
def connect(path: str, foreign_keys: bool = True) -> sqlite3.Connection:
"""Open the DB, enforcing FK integrity (per-connection pragma)."""
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
if foreign_keys:
# Must be set on the connection *before* any DDL/DML.
conn.execute("PRAGMA foreign_keys = ON")
return conn
def _ensure_flat_table(conn: sqlite3.Connection) -> None:
"""Create the flat contract table ONLY if missing. Never alters it."""
conn.execute(
f'CREATE TABLE IF NOT EXISTS {FLAT_TABLE} ('
f"{_FLAT_COL_DEFS})"
)
def _create_schema(conn: sqlite3.Connection) -> None:
"""Create all normalised tables + the flat-preserving view idempotently.
Every object uses CREATE ... IF NOT EXISTS, so this is safe to run many
times. Foreign keys link the relation tables to their parents.
"""
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS bills (
id INTEGER PRIMARY KEY,
bill_id TEXT NOT NULL,
bill_number TEXT,
session TEXT NOT NULL DEFAULT '',
chamber_origin TEXT NOT NULL DEFAULT 'unknown',
house TEXT,
author TEXT,
short_title TEXT,
first_seen TEXT,
source TEXT,
UNIQUE (bill_id, session, chamber_origin)
);
CREATE TABLE IF NOT EXISTS committees (
id INTEGER PRIMARY KEY,
name_key TEXT NOT NULL,
name_raw TEXT NOT NULL,
session TEXT NOT NULL DEFAULT '',
chamber TEXT NOT NULL DEFAULT 'unknown',
hearing_time TEXT,
location TEXT,
source TEXT,
UNIQUE (name_key, session, chamber)
);
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY,
name_norm TEXT NOT NULL,
full_name TEXT,
chamber TEXT NOT NULL DEFAULT 'unknown',
kind TEXT NOT NULL DEFAULT 'roster',
source TEXT,
UNIQUE (name_norm, chamber, kind)
);
CREATE TABLE IF NOT EXISTS committee_members (
id INTEGER PRIMARY KEY,
committee TEXT NOT NULL,
member TEXT NOT NULL,
session TEXT NOT NULL DEFAULT '',
chamber TEXT NOT NULL DEFAULT 'unknown',
role TEXT,
source TEXT,
UNIQUE (committee, member, session, chamber)
);
CREATE TABLE IF NOT EXISTS bill_committees (
id INTEGER PRIMARY KEY,
bill_id TEXT NOT NULL,
committee TEXT NOT NULL,
session TEXT NOT NULL DEFAULT '',
chamber TEXT NOT NULL DEFAULT 'unknown',
source TEXT,
UNIQUE (bill_id, committee, session, chamber)
);
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER NOT NULL,
applied_at TEXT,
note TEXT,
PRIMARY KEY (version)
);
"""
)
# Flat-preserving view: a drop-in mirror of the table QA/UI read. Column
# order and row set are identical to daily_file_records, ordered
# deterministically so the "flat contract unchanged" test is stable.
# (Rebuild every migrate so the view tracks the current flat schema.)
conn.execute(f"DROP VIEW IF EXISTS v_{FLAT_TABLE}")
cols = ", ".join(f'"{c}"' for c in _FLAT_COL_ORDER)
conn.execute(
f"CREATE VIEW v_{FLAT_TABLE} AS SELECT {cols} FROM {FLAT_TABLE} "
"ORDER BY date, house, bill_id, committee, id"
)
conn.commit()
def _stamp_migration(conn: sqlite3.Connection, version: int = SCHEMA_VERSION) -> None:
conn.execute(
"INSERT OR IGNORE INTO schema_migrations (version, applied_at, note) "
"VALUES (?, ?, ?)",
(version,
__import__("datetime").date.today().isoformat(),
"relational schema: bills/committees/members/committee_members/"
"bill_committees + flat view"),
)
conn.commit()
# ---------------------------------------------------------------------------
# migrate / correlate
# ---------------------------------------------------------------------------
def migrate(path: str = DEFAULT_DB, flat_table: str = FLAT_TABLE,
drop_flat: bool = False) -> Dict[str, Any]:
"""Create the relational schema idempotently, preserving the flat table.
Safe on a fresh DB (creates everything) and on an existing DB (no-op for
objects that already exist). Returns a small summary dict.
"""
conn = connect(path)
try:
_ensure_flat_table(conn)
_create_schema(conn)
_stamp_migration(conn)
n_flat = conn.execute(f"SELECT COUNT(*) FROM {FLAT_TABLE}").fetchone()[0]
return {"db": path, "migrated": True, "flat_rows": n_flat,
"version": SCHEMA_VERSION,
"objects": _list_objects(conn)}
finally:
conn.close()
def _list_objects(conn: sqlite3.Connection) -> List[str]:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type IN ('table','view') "
"ORDER BY type DESC, name").fetchall()
return [r["name"] for r in rows]
def _load_flat_rows(conn: sqlite3.Connection) -> Iterable[sqlite3.Row]:
return conn.execute(f"SELECT * FROM {FLAT_TABLE}").fetchall()
def load_rosters(path) -> Dict:
"""Load committee rosters from a JSON file (the "create new info" path).
Format (a list of committee blocks)::
[
{"committee": "Budget", "session": "2025-2026", "chamber": "assembly",
"members": [
{"name": "Roxas", "role": "Chair"},
{"name": "Liu", "role": "Vice Chair"}
]}
]
Returns a dict keyed by ``(slug, session, chamber)`` -> block, so a
committee that appears in distinct (session, chamber) slots keeps separate
memberships instead of clobbering. Missing/empty file -> empty dict (caller
then logs a WARN for the gap). A malformed file raises ValueError so the CLI
can report it cleanly rather than silently producing no members.
"""
out: Dict[tuple, Dict[str, Any]] = {}
if not path:
return out
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
blocks = data if isinstance(data, list) else data.get("committees", [])
for blk in blocks:
name = blk.get("committee") or blk.get("name")
if not name:
continue
key = (slug(name), session_key(blk.get("session")),
chamber_key(blk.get("chamber")))
out[key] = {
"name": normalise_name(name),
"session": session_key(blk.get("session")),
"chamber": chamber_key(blk.get("chamber")),
"members": [
{"name": normalise_name(m.get("name")),
"role": m.get("role")}
for m in (blk.get("members") or []) if m.get("name")
],
}
return out
def _ingest_relations(conn: sqlite3.Connection, rosters: Dict[str, Any]) -> Dict[str, Any]:
"""Derive + backfill normalised relations from the flat table.
* ``bills`` <- flat rows with a bill_id
* ``committees`` <- flat rows with a committee (hearing / committee
tables) AND committee-like authors ('Committee
on ...' strings name a committee too)
* ``bill_committees`` <- flat rows that carry BOTH a bill_id and a
committee (the "reviewed by" relation)
* ``members`` <- roster members (kind='roster') plus bill authors
(kind='author-seed') — real names, not guesses
* ``committee_members`` <- roster membership ONLY (never fabricated)
Returns a counts summary + the list of committees missing a roster.
"""
counts = {"bills": 0, "committees": 0, "bill_committees": 0,
"members": 0, "committee_members": 0,
"committees_missing_roster": 0}
# Helper: run an INSERT OR IGNORE and report whether it changed a row
# (rowcount == 0 means the natural key already existed -> idempotent dedup).
def ins_added(cur) -> int:
return 1 if cur and cur.rowcount else 0
seen_committees = set() # slugs we saw in the daily file
for row in _load_flat_rows(conn):
bill_id = row["bill_id"] if "bill_id" in row.keys() else None
committee = row["committee"] if "committee" in row.keys() else None
author = row["author"] if "author" in row.keys() else None
sess = session_key(row["session"] if "session" in row.keys() else None)
house = row["house"] if "house" in row.keys() else None
origin = row["chamber_origin"] if "chamber_origin" in row.keys() else None
# chamber_origin is the origin house of a bill; hearing rows have no
# bill so chamber_origin is None -> fall back to the publishing `house`
# (e.g. 'assembly') so the committee slot joins to its roster cleanly.
cham = chamber_key(origin) if origin else chamber_key(house)
seen = "daily_file"
# --- bills -------------------------------------------------------
if bill_id:
if ins_added(conn.execute(
"INSERT OR IGNORE INTO bills "
"(bill_id, bill_number, session, chamber_origin, house, "
" author, short_title, first_seen, source) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(bill_id, row["bill_number"], sess, cham, house,
author, row["short_title"],
row["first_seen"] if "first_seen" in row.keys()
else None, seen))):
counts["bills"] += 1
# --- committee-like author names a committee ---------------------
author_committee = None
if author and author.strip().lower().startswith("committee"):
# "Committee on Budget and Fiscal Review" -> "Budget and Fiscal Review"
author_committee = re.sub(r"^committees?\s+on\s+", "",
author.strip(), flags=re.I) or author.strip()
cham = chamber_key(row["chamber_origin"] if "chamber_origin"
in row.keys() else None)
for cname in {c for c in (committee, author_committee) if c}:
ck = slug(cname)
seen_committees.add((ck, sess, cham))
if ins_added(conn.execute(
"INSERT OR IGNORE INTO committees "
"(name_key, name_raw, session, chamber, hearing_time, "
" location, source) VALUES (?,?,?,?,?,?,?)",
(ck, normalise_name(cname), sess, cham,
row["hearing_time"] if "hearing_time" in row.keys()
and committee else None,
row["location"] if "location" in row.keys()
and committee else None, seen))):
counts["committees"] += 1
# --- bill <-> committee ("reviewed by") --------------------------
if bill_id and committee:
if ins_added(conn.execute(
"INSERT OR IGNORE INTO bill_committees "
"(bill_id, committee, session, chamber, source) "
"VALUES (?,?,?,?,?)",
(bill_id, slug(committee), sess, cham, seen))):
counts["bill_committees"] += 1
# --- author -> member seed (a real legislator, not a guess) -----
if author and not author.strip().lower().startswith("committee"):
stem = re.sub(r"\s+et al\.$", "", author.strip())
if re.fullmatch(r"[\w'\-]+", stem):
if ins_added(conn.execute(
"INSERT OR IGNORE INTO members "
"(name_norm, full_name, chamber, kind, source) "
"VALUES (?,?,?,?,?)",
(slug(stem), stem, cham, "author-seed", seen))):
counts["members"] += 1
# --- roster-driven committee_members (NEVER fabricated) -------------
# `rosters` is keyed by (slug, session, chamber); populate members for each
# roster's own slot. A roster slot may use a (session, chamber) chosen by the
# roster source; it is not required to mirror every seen slot exactly.
roster_slugs = {k[0] for k in rosters}
for blk in rosters.values():
for m in blk["members"]:
if not m["name"]:
continue
if ins_added(conn.execute(
"INSERT OR IGNORE INTO members "
"(name_norm, full_name, chamber, kind, source) "
"VALUES (?,?,?,?,?)",
(slug(m["name"]), m["name"], blk["chamber"], "roster",
"roster"))):
counts["members"] += 1
if ins_added(conn.execute(
"INSERT OR IGNORE INTO committee_members "
"(committee, member, session, chamber, role, source) "
"VALUES (?,?,?,?,?,?)",
(slug(blk["name"]), slug(m["name"]), blk["session"],
blk["chamber"], m.get("role"), "roster"))):
counts["committee_members"] += 1
# --- gap reporting: committees seen but with NO roster (slug level) --
# A seen committee slot is "missing a roster" when no roster block covers
# that slug at all -- we still created the committee row, but its member
# set is left empty. Per the "create new info / never fabricate" rule we
# record the gap (+WARN) instead of inventing members.
missing = set()
for (ck, sess, cham) in seen_committees:
if ck not in roster_slugs:
missing.add((ck, sess, cham))
counts["committees_missing_roster"] = len(missing)
for (ck, sess, cham) in sorted(missing):
log.warning(
"committee %r (session=%s chamber=%s) has no roster source; "
"membership left empty (not fabricated). Provide --roster to "
"backfill.", ck, sess or "-", cham)
conn.commit()
return counts
def correlate(path: str = DEFAULT_DB, rosters: Optional[str] = None,
rebuild: bool = False, quiet: bool = False) -> Dict[str, Any]:
"""Derive relations from the flat table into the normalised tables.
Idempotent by default (INSERT OR IGNORE on deterministic natural keys).
``rebuild=True`` clears the derived join/parent tables first so a run
reflects the current flat snapshot exactly.
"""
conn = connect(path)
try:
_ensure_flat_table(conn)
_create_schema(conn)
_stamp_migration(conn)
roster_data = load_rosters(rosters) if rosters else {}
if rebuild:
for t in ("bill_committees", "committee_members", "members",
"committees", "bills"):
conn.execute(f"DELETE FROM {t}")
conn.commit()
counts = _ingest_relations(conn, roster_data)
# Final absolute counts (post-dedup) for reporting.
for k in ("bills", "committees", "bill_committees", "members",
"committee_members"):
counts[k] = conn.execute(f"SELECT COUNT(*) FROM {k}").fetchone()[0]
if not quiet:
log.info("correlate: %s", json.dumps(counts, sort_keys=True))
return counts
finally:
conn.close()
# ---------------------------------------------------------------------------
# Demonstration / verification helpers
# ---------------------------------------------------------------------------
def sample_selects(path: str = DEFAULT_DB, limit: int = 5) -> Dict[str, Any]:
"""Pull a few human-readable sample SELECTs for the completion report."""
conn = connect(path)
try:
def q(sql: str, args: tuple = ()):
return [dict(r) for r in conn.execute(sql, args).fetchall()]
return {
"counts": {t: conn.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
for t in ("bills", "committees", "members",
"committee_members", "bill_committees")},
"sample_bill_committees": q(
"SELECT bc.bill_id, c.name_raw AS committee, bc.chamber, "
"bc.session FROM bill_committees bc "
"JOIN committees c "
" ON c.name_key = bc.committee "
" AND c.chamber = bc.chamber "
" AND c.session = bc.session LIMIT ?", (limit,)),
"sample_committee_members": q(
"SELECT DISTINCT c.name_raw AS committee, m.full_name AS "
"member, cm.role, cm.chamber FROM committee_members cm "
"JOIN committees c ON c.name_key = cm.committee "
"JOIN members m ON m.name_norm = cm.member "
"ORDER BY c.name_raw, m.full_name LIMIT ?", (limit,)),
"committees_without_roster": q(
"SELECT name_raw, chamber, session FROM committees c "
"WHERE NOT EXISTS (SELECT 1 FROM committee_members cm "
" WHERE cm.committee = c.name_key "
" AND cm.chamber = c.chamber "
" AND cm.session = c.session)"),
}
finally:
conn.close()
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import json
from pathlib import Path
class RequestLog:
def __init__(self, path):
self.path = Path(path)
def record(self, event):
self.path.parent.mkdir(parents=True, exist_ok=True)
with open(self.path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(event, sort_keys=True) + "\n")
@classmethod
def read(cls, path):
p = Path(path)
if not p.exists():
return []
out = []
with open(p, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line:
try:
out.append(json.loads(line))
except ValueError:
continue
return out
+99
View File
@@ -0,0 +1,99 @@
"""Normalised output schema for a Daily File entry.
One :class:`BillAction` per record, matching DAILY_FILE_PARSE_SPEC.md §7.
Records serialise cleanly to JSON Lines and to a flat SQLite table.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, fields, asdict
from typing import Any, Optional, List
# Source identifiers (spec §7). Floor / committee bills come from the Assembly
# daily file; Senate data would arrive from the PDF path or the leginfo alt.
ASSEMBLY_DAILY_FILE = "ASSEMBLY_DAILY_FILE"
SENATE_DAILY_FILE_PDF = "SENATE_DAILY_FILE_PDF"
LEGINFO_BILL_RESULTS = "LEGINFO_BILL_RESULTS"
# House = the publishing chamber; chamber_origin = the bill's origin (spec §8 #7).
HOUSE = "assembly" # default house for the Assembly Daily File HTML source
@dataclass
class BillAction:
"""A single normalised daily-file record.
Field names mirror the §7 JSON schema. ``id`` is an optional deterministic
content hash (sha1 of ``house|bill_number|date|committee``); leave it ``None``
to auto-compute on serialise.
"""
# --- required identity / context -------------------------------------
date: Optional[str] = None # "2026-08-31" (normalised ISO) when available
house: str = HOUSE # ASSEMBLY / SENATE (publishing house)
source: str = ASSEMBLY_DAILY_FILE # source tag (see module constants)
record_type: str = "bill" # "bill" | "hearing"
# --- bill fields ------------------------------------------------------
bill_number: Optional[str] = None # "AB 28" (4-digit normalised display)
bill_id: Optional[str] = None # canonical "202520260AB28"
session: Optional[str] = None # "2025-2026"
chamber_origin: Optional[str] = None # ASSEMBLY / SENATE (origin of the bill)
author: Optional[str] = None
short_title: Optional[str] = None # the bill's subject / short title
description: Optional[str] = None # the action description for the day's item
committee: Optional[str] = None # None for floor items
hearing_time: Optional[str] = None # for hearing rows: "9:30am" / "Upon Call of the Chair"
location: Optional[str] = None
item_number: Optional[int] = None # int, un-padded (floor items)
supplement: Optional[int] = None # edition, from "Supplemental N"
ics_url: Optional[str] = None
agenda_id: Optional[str] = None
# --- provenance -------------------------------------------------------
id: Optional[str] = None # auto content hash when None
raw_selector: Optional[str] = None # which table the row came from (debug/provenance)
def with_id(self, content: Optional[str] = None) -> "BillAction":
"""Return the record with ``id`` filled in from a content hash.
The spec's key is ``sha1(house|bill_number|date|committee)``. Missing
components are rendered as empty strings so the hash stays deterministic.
"""
if content is None:
parts = [
self.house or "",
self.bill_number or "",
self.date or "",
self.committee or "",
]
content = "|".join(parts)
self.id = hashlib.sha1(content.encode("utf-8")).hexdigest()
return self
def to_json(self) -> str:
"""Serialise to a single-line JSON string (the JSON Lines record)."""
import json
with_id = self if self.id is not None else self.with_id()
return json.dumps(
{k: v for k, v in asdict(with_id).items() if v is not None},
ensure_ascii=False,
sort_keys=True,
)
def to_dict(self, *, fill_id: bool = True, include_nulls: bool = False) -> dict:
rec = self.with_id() if fill_id and self.id is None else self
data = asdict(rec)
if not include_nulls:
data = {k: v for k, v in data.items() if v is not None}
return data
@classmethod
def from_dict(cls, data: dict) -> "BillAction":
known = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in data.items() if k in known})
@staticmethod
def field_names() -> List[str]:
return [f.name for f in fields(BillAction)]
+93
View File
@@ -0,0 +1,93 @@
# Resolve a chamber source for a given date -> the URL(s) to fetch + cache file
# extension. Neither daily-file page is date-addressable by URL, so the date only
# drives the Senate PDF filename and the cache key; the Assembly URL is constant.
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Optional
# Senate daily-file PDF filename pattern: SEN_{type}_{year}_R_{YYYYMMDD}.PDF
SENATE_PDF_RE = re.compile(
r"SEN_(?P<type>[A-Z]+)_(?P<year>\d{4})_R_(?P<date>\d{8})\.PDF$"
)
@dataclass
class SourcePlan:
source: str # "assembly_daily_file" | "senate_daily_file"
date: str # requested YYYY-MM-DD
kind: str # "html" | "pdf"
primary_url: str # URL that is actually fetched
ext: str # cache extension ("html" | "pdf")
pdf_url: Optional[str] = None
as_of: Optional[str] = None # date the live page itself represents
extra_urls: list = field(default_factory=list)
def build_source_plan(name: str, date_iso: str, src_cfg: dict) -> SourcePlan:
kind = src_cfg.get("kind")
if kind == "html":
# Assembly: constant URL, always the current live file.
return SourcePlan(
source=name,
date=date_iso,
kind="html",
primary_url=src_cfg["url"],
ext="html",
as_of=None, # filled by the Ingestor after reading the body
)
if kind == "pdf":
# Senate: index is live HTML; the actual file is a date-encoded PDF.
pdf_base = src_cfg.get("pdf_base_url", "").rstrip("/") + "/"
filename = _senate_pdf_filename(src_cfg, date_iso)
pdf_url = pdf_base + filename
return SourcePlan(
source=name,
date=date_iso,
kind="pdf",
primary_url=src_cfg["index_url"],
ext="pdf",
pdf_url=pdf_url,
as_of=date_iso, # the PDF filename encodes the date
)
raise ValueError("Unknown source kind '%s' for '%s'" % (kind, name))
def _senate_pdf_filename(cfg: dict, date_iso: str) -> str:
pattern = cfg.get("pdf_filename_pattern", "SEN_{type}_{year}_R_{date}.PDF")
ydate = date_iso.replace("-", "")
year = _session_year(cfg, date_iso)
ptype = cfg.get("default_pdf_type", "R")
return pattern.format(type=ptype, year=year, date=ydate)
def _session_year(cfg: dict, date_iso: str) -> str:
# For the 2025-2026 session, any date in 2026 uses session-start year 2025.
if not cfg.get("session_year_from_date"):
return date_iso[:4]
try:
y = int(date_iso[:4])
except (ValueError, TypeError):
return date_iso[:4]
if y % 2 == 0:
return str(y - 1)
return str(y)
def parse_senate_pdf_filename(name: str):
# Reverse the encode: filename -> {type, year, date_iso, raw}; None if nmatch.
m = SENATE_PDF_RE.search(name.split("/")[-1])
if not m:
return None
ydate = m.group("date")
date_iso = ydate[0:4] + "-" + ydate[4:6] + "-" + ydate[6:8]
return {
"type": m.group("type"),
"year": m.group("year"),
"date_iso": date_iso,
"raw": name,
}
+75
View File
@@ -0,0 +1,75 @@
"""Deliberately-malformed daily-file fragment used to prove the parser skips
bad rows (logged warning, no batch abort) and survives encoding issues.
Three problem rows are embedded:
R1 a floor-bill row with NO bill link / no number -> must skip
R2 a floor-bill row whose item cell is non-numeric garbage-> must skip
R3 a hearing row with no committee AND no date -> must skip
The surrounding rows are valid, so the batch must still yield all valid ones.
"""
VALID = (
'<table class="floor-bills-table">'
'<tr class="floor-bill-details">'
'<td class="floor_bill-measure" data-sort="AB 00100">'
'<a href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260AB100">AB 100</a></td>'
'<td class="floor_bill-author" data-sort="Smith">Smith</td>'
'<td class="floor_bill-subject">An act to do X.</td>'
'<td class="floor_bill-location" data-sort="State Capitol">State Capitol</td>'
'<td class="floor_bill-item" data-sort="0000001">1</td>'
'<td class="floor_bill-view-details">Read third time. </td>'
'</tr>'
'</table>'
)
BAD_NO_LINK = (
'<table class="floor-bills-table">'
'<tr class="floor-bill-details">'
'<td class="floor_bill-measure" data-sort="AB 00200">'
'AB 200 (no hyperlink here)</td>'
'<td class="floor_bill-author" data-sort="Jones">Jones</td>'
'<td class="floor_bill-subject">Missing bill link row.</td>'
'<td class="floor_bill-item" data-sort="0000002">2</td>'
'</tr>'
'</table>'
)
BAD_ITEM = (
'<table class="floor-bills-table">'
'<tr class="floor-bill-details">'
'<td class="floor_bill-measure" data-sort="AB 00300">'
'<a href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260AB300">AB 300</a></td>'
'<td class="floor_bill-author" data-sort="Lee">Lee</td>'
'<td class="floor_bill-subject">Non-numeric item row.</td>'
'<td class="floor_bill-item">not-a-number</td>'
'</tr>'
'</table>'
)
BAD_HEARING = (
'<table class="committee-hearing-table">'
'<tr class="committee-hearing-details">'
'<td class="committee_hearing-time">9:00am</td>'
'<td class="committee_hearing-menu">no agenda link</td>'
'</tr>'
'</table>'
)
def malformed_document() -> str:
"""A concatenated fragment: valid, bad, bad, bad rows."""
return VALID + BAD_NO_LINK + BAD_ITEM + BAD_HEARING
def write_fixtures(out_dir: str) -> None:
import os
os.makedirs(out_dir, exist_ok=True)
# A clean single-file fixture for the "good" acceptance test.
with open(os.path.join(out_dir, "malformed_fragment.html"), "w", encoding="utf-8") as fh:
fh.write(malformed_document())
# A tiny encoding-edge-case file: UTF-16 with a BOM so the charset path runs.
with open(os.path.join(out_dir, "utf16_sample.html"), "w", encoding="utf-16") as fh:
fh.write(VALID)
if __name__ == "__main__":
write_fixtures(".parser_fixtures")
import sys
print("wrote fixtures to .parser_fixtures/", file=sys.stderr)