feat(dashboard): add forward "what's next" signal + fix upcoming floor
The daily-file bill queue is a backward snapshot, so "upcoming" could only resurface the latest past day (the 08-31 snapshot read as today's queue) and there was no "what is scheduled next." leginfo bill status/history are backward-only, so the genuine future-dated signal is the chambers' "upcoming events" calendars. - api.py: floor the "upcoming" view at *today* (not the DB max date) when no as_of is pinned; surface next_legislative_day + scheduled_events (next 7 days) on /bills. Add _today_iso / _next_legislative_day / _scheduled_events, all degrading to a recess marker / [] (never 5xx). - relational.py: schema v2 adds the scheduled_events table + store_scheduled_events (idempotent per-day refresh), load_scheduled_events, next_legislative_day. - parsers.py: parse_events_page + classify_event_kind for the chamber events calendar; skip empty-title rows (decompose the <time> before the fallback). - ingest.py + cli.py: --store-events lane (cache-first, recess-safe) that parses the calendar HTML into scheduled_events. - sources.py + config.yaml: register assembly_events / senate_events (kind: events, empty_is_ok: true). - run_ingest.sh: run the events lane after the daily-file lane as non-fatal (skippable via CALI_SCRAPER_SKIP_EVENTS=1); daily-file exit still gates cron. - frontend: "Next legislative day" line + "What's next · next 7 days" band with honest recess empty states. - DASHBOARD.md: document the forward signal + known limitations (leginfo backward-only gap, recess empty state, 08-31 snapshot is analysis-only). - tests: 5 new API tests + 18 parse-events tests; 170 pass, 92% coverage.
This commit is contained in:
1 parent
1919d43bf0
commit
03eeb6bb62
16 files changed
+941
-70
No files matched your search
+159
-17
@@ -51,7 +51,11 @@ 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
|
||||
# v1: bills/committees/members/committee_members/bill_committees + flat view.
|
||||
# v2: adds scheduled_events (the chamber "upcoming events" forward source — the
|
||||
# only genuine future-dated signal; see design/API_CONTRACT.md §5 + the
|
||||
# "what's next" work). Kept OUT of the frozen flat daily_file_records view.
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
#: 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
|
||||
@@ -211,9 +215,27 @@ def _create_schema(conn: sqlite3.Connection) -> None:
|
||||
applied_at TEXT,
|
||||
note TEXT,
|
||||
PRIMARY KEY (version)
|
||||
);
|
||||
"""
|
||||
)
|
||||
);
|
||||
|
||||
-- v2: the chamber "upcoming events" forward source. This is the ONLY
|
||||
-- genuine *future-dated* signal (the daily file is a backward snapshot;
|
||||
-- leginfo bill status/history is backward-only). It holds the next
|
||||
-- scheduled floor/committee events parsed from the chamber events
|
||||
-- calendar (assembly.ca.gov/events, senate.ca.gov/calendar). It is
|
||||
-- deliberately OUT of the frozen flat daily_file_records view.
|
||||
CREATE TABLE IF NOT EXISTS scheduled_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
date TEXT NOT NULL, -- ISO YYYY-MM-DD (event day)
|
||||
chamber TEXT NOT NULL, -- ASSEMBLY | SENATE
|
||||
kind TEXT, -- FLOOR | COMMITTEE | OTHER
|
||||
title TEXT NOT NULL, -- human event title
|
||||
source_url TEXT, -- where the event came from
|
||||
fetched_at TEXT, -- when this page was read
|
||||
source TEXT, -- assembly_events | senate_events
|
||||
UNIQUE (date, chamber, kind, title, source)
|
||||
);
|
||||
"""
|
||||
)
|
||||
# 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.
|
||||
@@ -229,13 +251,13 @@ def _create_schema(conn: sqlite3.Connection) -> None:
|
||||
|
||||
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"),
|
||||
)
|
||||
"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 + scheduled_events (v2)"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@@ -514,12 +536,132 @@ def sample_selects(path: str = DEFAULT_DB, limit: int = 5) -> Dict[str, Any]:
|
||||
"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)"),
|
||||
"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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scheduled_events: the chamber "upcoming events" forward source (v2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _scheduled_events_table(conn: sqlite3.Connection, name: str = "scheduled_events") -> bool:
|
||||
cur = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(name,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def store_scheduled_events(events: Iterable[Dict[str, Any]],
|
||||
path: str = DEFAULT_DB,
|
||||
fetched_at: str = "") -> Dict[str, Any]:
|
||||
"""Insert parsed chamber events into ``scheduled_events`` idempotently.
|
||||
|
||||
Each ``event`` is a dict with keys ``date`` (ISO), ``chamber`` (upper),
|
||||
``kind`` (``FLOOR``/``COMMITTEE``/``OTHER``), ``title``, ``source_url``,
|
||||
``source``. The natural key ``(date, chamber, kind, title, source)`` makes
|
||||
re-runs a no-op (``INSERT OR IGNORE``), so the existing-cron behaviour
|
||||
("re-parse doesn't re-hit") holds: a second parse of the same calendar day
|
||||
adds no rows. Ensures the table exists (via :func:`_create_schema`). Returns
|
||||
a small summary ``{stored, cleared_stale, total, fetched_at}``.
|
||||
"""
|
||||
conn = connect(path)
|
||||
try:
|
||||
_create_schema(conn) # CREATE IF NOT EXISTS; cheap + idempotent
|
||||
# Drop a stale day's rows first so a re-published calendar (a moved or
|
||||
# renamed hearing) reflects the latest read rather than a frozen
|
||||
# snapshot. Only that day is cleared; other days are untouched.
|
||||
days = {str(e.get("date")) for e in events if e.get("date")}
|
||||
deleted = 0
|
||||
for d in days:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM scheduled_events WHERE date=? AND fetched_at<>?",
|
||||
(d, fetched_at))
|
||||
deleted += cur.rowcount
|
||||
rows = []
|
||||
for e in events:
|
||||
if not e.get("date") or not e.get("title"):
|
||||
continue
|
||||
rows.append((
|
||||
str(e["date"]).strip(),
|
||||
(e.get("chamber") or "unknown").strip().upper(),
|
||||
(e.get("kind") or "OTHER").strip().upper() or "OTHER",
|
||||
str(e["title"]).strip(),
|
||||
e.get("source_url") or None,
|
||||
fetched_at or None,
|
||||
(e.get("source") or "assembly_events").strip()
|
||||
or "assembly_events",
|
||||
))
|
||||
if rows:
|
||||
cur = conn.executemany(
|
||||
"INSERT OR IGNORE INTO scheduled_events "
|
||||
"(date, chamber, kind, title, source_url, fetched_at, "
|
||||
"source) VALUES (?,?,?,?,?,?,?)",
|
||||
rows)
|
||||
stored = cur.rowcount
|
||||
else:
|
||||
stored = 0
|
||||
conn.commit()
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM scheduled_events").fetchone()[0]
|
||||
return {"stored": stored, "cleared_stale": deleted,
|
||||
"total": total, "fetched_at": fetched_at or None}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_scheduled_events(path: str = DEFAULT_DB,
|
||||
as_of: Optional[str] = None,
|
||||
chamber: Optional[str] = None,
|
||||
limit: int = 200) -> List[Dict[str, Any]]:
|
||||
"""Return scheduled (chamber) events, optionally future-dated from ``as_of``.
|
||||
|
||||
A ``None``/absent ``as_of`` keeps every event; a date string keeps
|
||||
``date >= as_of`` (a T-1 event is excluded, matching API_CONTRACT §5).
|
||||
Degrades to ``[]`` when the table does not exist (pre-v2 DBs / fresh schema).
|
||||
"""
|
||||
conn = connect(path, foreign_keys=False)
|
||||
try:
|
||||
if not _scheduled_events_table(conn):
|
||||
return []
|
||||
sql = ("SELECT date, chamber, kind, title, source_url, fetched_at, "
|
||||
"source FROM scheduled_events WHERE 1=1")
|
||||
clauses: list = []
|
||||
args: list = []
|
||||
if as_of:
|
||||
clauses.append("date >= ?")
|
||||
args.append(str(as_of))
|
||||
if chamber:
|
||||
clauses.append("UPPER(chamber) = ?")
|
||||
args.append(chamber.strip().upper())
|
||||
if clauses:
|
||||
sql += " AND " + " AND ".join(clauses)
|
||||
sql += " ORDER BY date, chamber, kind, title LIMIT ?"
|
||||
args.append(int(limit))
|
||||
rows = conn.execute(sql, args).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def next_legislative_day(path: str = DEFAULT_DB,
|
||||
as_of: Optional[str] = None,
|
||||
chamber: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""The earliest scheduled event on/after ``as_of`` (DESIGN §6.1 "next
|
||||
legislative day"), or a ``{date: None, recess: True}`` marker when nothing is
|
||||
scheduled (a genuine recess).
|
||||
|
||||
This is the dashboard's "what's next" answer when the daily-file bill queue
|
||||
has no future rows: it surfaces the chamber events calendar. ``recess`` is
|
||||
True when there is no event after ``as_of``.
|
||||
"""
|
||||
events = load_scheduled_events(path, as_of=as_of, chamber=chamber, limit=1)
|
||||
if not events:
|
||||
return {"date": None, "recess": True}
|
||||
return {**events[0], "recess": False}
|
||||
Reference in new issue
Block a user