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

11 KiB

API Contract — Legislative Dashboard UI (Spec §5)

Audience: dashboard build lane (t_b38e9af2), correlation lane (t_2736f40d), LLM lane (t_950780e0). One shared contract so no lane diverges. Principle: auth-agnostic, field-name-stable, degradeable. The preferences/personalization shape does not depend on which auth method is chosen. Base record = scraper/schema.py::BillAction (flat daily_file_records) + the correlation lane's relational tables (bills, committees, members, bill↔committee joins) + the LLM lane's per-bill tags. Status: draft v0.1 — field names are the frozen part; values and endpoints are ratifiable. Auth method = Open Decision D1; ZIP model = Open Decision D2; scoring weights = D3.


0. Conventions

  • Dates: ISO YYYY-MM-DD (date). Times are strings ("9:30am", "Upon Call of the Chair") — do NOT canonicalize to RFC3339 in the list layer; keep source form, add a derived hearing_sort field (see §2) for stable ordering.
  • Enums: chamber_origin ∈ {ASSEMBLY, SENATE}. source ∈ {ASSEMBLY_DAILY_FILE, SENATE_DAILY_FILE_PDF, LEGINFO_BILL_RESULTS}.
  • Nullability: fields a daily-file event may legitimately lack (hearing_time, location, committee for floor items, tags pre-LLM) are nullable. Consumers must render a TBD/omitted state, never crash on null.
  • IDs: bill_id = leginfo canonical ("202520260AB28"). legislator_id, district_id, tag_id, user_id = system-owned opaque IDs (snake_case, stable).
  • Auth: every /person//prefs//watch//topics and the /bills personal view requires a bearer token (method per D1). /bills, /tags, /legislators, /districts/resolve are public.
  • Errors (§6): HTTP status + { "error": { "code", "message", "field?" } }. field on 400.
  • Versioning: contract at /api/v1/*. Breaking changes → new major; add fields, never rename/delete (the frozen field names).

1. Shared record: Bill (what the UI consumes)

{
  "bill_id": "202520260AB28",
  "bill_number": "AB 28",
  "chamber_origin": "ASSEMBLY",
  "author": "Schiavo et al.",
  "short_title": "Waste: requires recycling standards…",
  "description": "Same as short_title for daily-file; enrichment may expand.",
  "committee": "Resources and Water",
  "hearing_time": "9:30am",
  "hearing_sort": 55800,
  "location": "State Capitol, Room 444",
  "date": "2026-09-07",
  "session": "2025-2026",
  "item_number": 14,
  "supplement": 2,
  "source": "ASSEMBLY_DAILY_FILE",
  "tags": ["waste", "environment"],
  "relevance": {
    "score": 5,
    "matched": [
      { "type": "watched-legislator", "value": "Leg. Schiavo (A-14, chair)" },
      { "type": "tag", "value": "waste" }
    ]
  }
}
  • hearing_sort (derived, backend-owned): seconds-from-midnight for HH:MMam/pm; "Upon Call of the Chair" / "Call of the Chair" → max (sorts last); null → max (with hearing_time: null). Enables the within-day sort in DESIGN_DASHBOARD §4.
  • relevance present for logged-in requests only; omitted/score:0 for anon. matched[] powers the Why is this here? transparency UI. Each entry: type ∈ {watched-legislator, committee-of-watched-legislator, tag}, value = human-readable reason.
  • tags may be null (no LLM analysis yet) or [] → UI renders no chips, ranking degrades to legislator-match only.

Bill list envelope

{
  "session": "2025-2026",
  "as_of": "2026-09-05T18:00:00Z",
  "chamber_availability": { "ASSEMBLY": "available", "SENATE": "pending" },
  "items": [ /* Bill[] */ ],
  "groups": {
    "2026-09-07": 14,
    "2026-09-08": 31
  }
}
  • chamber_availability drives the "Senate unavailable" note — never surface Senate rows when "pending".
  • Personal view returns items in floated-first order with the PersonalBand slice (top N) derivable by relevance.score > 0 then hearing_sort.

2. Endpoint GET /api/v1/bills

Query: ?upcoming=1&from=2026-09-01&to=2026-09-30&chamber=ASSEMBLY&tags=waste,health&author=…&q=recycling&sort=hearing_limit&personal=1

  • upcoming=1 → only future-dated events (date >= as_of and/or not yet acted on) — see §5 "upcoming" definition.
  • personal=1 (logged in) → apply the float-to-top ranking (§4 of DESIGN_DASHBOARD); response carries relevance on items.
  • Public without token; token upgrades the view (adds relevance, enables personal=1). Response: the Bill-list envelope (§1). HTTP 200. Empty → items: [] + groups: {} (UI renders /bills empty-state, not an error).

3. Personalization endpoints (logged-in)

3.1 POST /api/v1/districts/resolve — ZIP/address → districts → legislators

// request (one of the input modes; Open Decision D2 sets which is primary)
{ "zip": "90210" }                 // fast path (may be ambiguous)
{ "address": "…" }                 // authoritative (Legislature full-address tool)
{ "mode": "manual", "district": "A-14" }   // fallback

// response
{
  "resolved_from": "zip",                // | "address" | "manual"
  "confidence": "single" | "ambiguous" | "zero" | "out-of-state",
  "districts": [
    {
      "district_id": "A-14",
      "type": "ASSEMBLY",               // | "SENATE"
      "number": 14,
      "assembly_member": { "legislator_id": "L-asm-14", "name": "M. Schiavo", "party": "D" },
      "senate_member":   { "legislator_id": "L-sen-08", "name": "R. Riu",  "party": "R" }
    }
  ],
  "suggested_topics": ["waste", "housing"],   // derived from member committees ∩ session tag cloud
  "suggested_watch":  ["L-asm-14", "L-sen-08"]
}
  • Confidence semantics (must match §8 of DESIGN_DASHBOARD): single → UI auto-advances to the candidate/legislator list. ambiguous/zero/out-of-state → UI shows the §6.4 state (candidates to confirm / manual picker / "not in CA"), never auto-watches.
  • Source: findyourrep.legislature.ca.gov (by address) + data.ca.gov boundary datasets (by zip). Re-verify freshness at redistricting.
  • Resolve does not persist — it proposes. Persisting watches happens in §3.3. (Trusting-resolve = trust requirement; see DESIGN §8.)

3.2 GET /api/v1/legislators?q=… — browse / find legislators

{ "items": [
  { "legislator_id": "L-asm-14", "name": "M. Schiavo", "party": "D",
    "chamber": "ASSEMBLY", "district": 14,
    "committees": ["Resources and Water"],
    "domains": ["waste", "water"] } ] }

3.3 GET / PUT /api/v1/prefs — the personalization store

{
  "user_id": "u_001",
  "zip": "90210",                       // optional (address lookup may leave null)
  "address": null,                      // optional, only if D2 makes address the input
  "home_chamber": "ASSEMBLY",           // optional hint
  "districts":  [ { "district_id": "A-14", "type": "ASSEMBLY", "number": 14 } ],
  "watched_legislators": [ "L-asm-14", "L-sen-08" ],
  "opted_in_tags":     [ "waste", "health" ],
  "updated_at": "2026-09-05T18:00:00Z"
}
  • GET returns current prefs (or 404 if none → UI shows setup card). PUT replaces the whole blob. Granular helpers POST /prefs/districts/confirm etc. allowed but not required.
  • This shape is auth-method-independent — D1 doesn't change it.

3.4 POST /api/v1/prefs/watch & DELETE /api/v1/prefs/watch/:legislator_id

Toggle a single watch (optimistic-update friendly; returns the updated watched_legislators). Same for topics:

3.5 POST /api/v1/prefs/topics & DELETE /api/v1/prefs/topics/:tag_id

  • 201 on add / 200 on remove; 400 with field on unknown id. Idempotent.

4. Reference data endpoints (public)

4.1 GET /api/v1/tags — the tag cloud (from the LLM lane)

{
  "as_of": "2026-09-05T18:00:00Z",
  "tags": [
    { "tag_id": "tag:health",  "label": "Health",    "count": 214 },
    { "tag_id": "tag:education","label": "Education", "count": 188 },
    { "tag_id": "tag:waste",   "label": "Waste",     "count": 42 }
  ]
}
  • count = number of bills carrying the tag (drives the · 42 bills UI affordance and the "top topics" teaser). May be sparse early (LLM lane warming) — UI shows the "filling in" hint, never a 0-count dead chip.
  • Ownership note: the taxonomy/labels are produced by the LLM lane (t_950780e0); this contract fixes their shape, not their values. A future curator role (out of scope) would own label normalization.

4.2 GET /api/v1/legislators & GET /api/v1/districts — public browse (from correlation lane)

Same shapes as §3.2 / the districts[] items; no token needed.


5. "Upcoming" — the exact, testable definition

upcoming = a bill event satisfying all:

  1. event_date = COALESCE(date, hearing date) and event_date >= as_of (future-dated), and
  2. not in a terminal "acted/closed" state the source marks (a committee that already passed it out of committee, or floor passage) — i.e., the event is an open future moment, not a historical action.
  3. For a bill with multiple future events (committee hearing then floor on different days), each event is its own upcoming row; dedupe only within a date.
  • If the source can't confirm state #2, default to including (false-negative risk: dropping a real hearing is worse than showing a closed one) and mark possibly_closed: true (future field; not required for v0.1).
  • "This week" / "This month" filters are just from/to windows over event_date.

6. Errors (all endpoints)

{ "error": { "code": "invalid_zip" | "ambiguous_district" | "district_not_found"
            | "out_of_state" | "tag_unknown" | "legislator_unknown"
            | "not_authenticated" | "prefs_not_set" | "chamber_unavailable",
             "message": "human-readable", "field": "zip" } }
  • 400 invalid/ambiguous/out-of-state (with field). 401 not authenticated. 404 unknown id / no prefs. 409 race on concurrent watch add (client retries idempotently). 503 chamber unavailable (e.g. Senate antibot) — not a hard client error; UI renders "Senate unavailable" and continues on Assembly. 503 also when the LLM tag service is down → UI degrades, no tag chips, ranking by legislator only.

7. Contract notes for each lane

  • Correlation lane (t_2736f40d): owns §3.1 resolve + §4.2 legislators/districts from correlation_sources.member_rosters (findyourrep + data.ca.gov). Must produce the districts[] + assembly_member/senate_member join to members.
  • LLM lane (t_950780e0): owns tags[] on Bill (§1) and §4.1 GET /tags. Must tolerate the deterministic fallback tagger (configurable ollama) so tags degrades to a known small set when ollama is down — v0.1 tags may be []/null until warmed.
  • Dashboard build (t_b38e9af2): implements the float-to-top ranking from relevance.score (§1) per DESIGN_DASHBOARD §4; consumes this contract verbatim. Auth method per D1 is chosen here but the shapes above are frozen regardless.
  • QA: assert hearing_sort ordering, upcoming boundary (a T-1 day is excluded), chamber_availability gates Senate rows, and the 6 empty states each return 200 items:[] (not 4xx/5xx).

Companion docs: design/DESIGN_DASHBOARD.md (IA + flows + visual system), design/wireframes/*.html (3 mockups). Open Decisions: D1 auth method, D2 ZIP vs address primary input + data-source provenance, D3 scoring weights.