7.4 KiB
| date | topic | status |
|---|---|---|
| 2026-03-24 | Welk tweede kamerlid ben jij? | draft |
Problem Statement
We need a new Streamlit tab in explorer.py titled "Welk tweede kamerlid ben jij?" that interactively narrows the list of 2026 MPs by asking the user a sequence of yes/no/abstain questions (motions). The goal: find the minimal set of motions (questions) that uniquely identify a single MP, or determine that no unique MP exists because two or more MPs have identical voting records.
Why: This is a guided identification quiz that helps users discover which MP they most resemble by iteratively comparing their answers to historic MP votes.
Constraints
- Work inside the existing Streamlit explorer (single-file UI: explorer.py).
- Use existing data models/tables: mp_votes, mp_metadata, motions (DuckDB / MotionDatabase). No new external services.
- Keep reads read-only: do not modify the DB from the UI flow.
- YAGNI: minimal viable UX first (linear question flow, basic results table), extensible later.
Approach (chosen)
I recommend a two-stage approach that balances simplicity and correctness:
- Stage A (Batch-match + ranking): Ask the user a small curated set of motions (e.g., high-controversy / high-discriminative score). Collect answers into a map motion_id -> vote and compute per-MP agreement counts using a new read-only DB helper. Show ranked candidates and whether any are unique.
- Stage B (Minimal distinguishing set): If multiple candidates tie (or more than one remain), compute a minimal discriminating set of additional motions by greedily selecting motions that best split the remaining candidate set and present them as follow-up questions until a unique MP or impossibility is reached.
Alternatives considered (rejected):
- Asking motions adaptively from the start using an information-gain search over the entire motion space. Rejected because it’s heavier to implement and harder to explain to users; we can implement a greedy information-gain variant later.
- Building a full decision tree offline for all MPs. Rejected for now because the dataset and party churn make maintenance cumbersome.
Effort estimate (rough):
- Backend: add one DB method to MotionDatabase (match_mps_for_votes) + helper to compute split scores — 2–4 hours.
- Frontend: add new Streamlit builder, UI state, and wiring into tabs — 2–4 hours.
- Testing & polish: 2–3 hours.
Risks & dependencies
- Data quality: If mp_votes.party or mp_metadata are incomplete, matching may be imperfect. We rely on existing backfill scripts to improve party fields.
- Performance: Joins over mp_votes can be large; we'll limit candidate motion set and use read-only DuckDB queries, with caching where appropriate.
Architecture
High-level components (all in-process Streamlit app):
- Explorer UI (explorer.py) — new tab builder build_mp_quiz_tab. Presents questions and displays results.
- MotionDatabase (database.py) — new read-only method match_mps_for_votes(user_votes, limit) that returns per-MP agreement and overlap counts. Also a helper choose_discriminating_motions(candidates, excluded_motion_ids, k=1) that scores motions by how well they split candidate MPs.
- DuckDB (data) — existing tables: motions, mp_votes, mp_metadata.
All calls stay local — the Streamlit UI instantiates MotionDatabase(db_path) and calls the new read methods.
Components and Responsibilities
-
build_mp_quiz_tab (explorer.py)
- Render intro and instructions.
- Load an initial pool of candidate motions (curated by controversy or SVD components via existing load_motions_df).
- Present one question at a time, store answers in st.session_state (motion_id -> vote).
- After each answer (or on demand), call MotionDatabase.match_mps_for_votes to get ranked candidates.
- If multiple candidates remain, call the discriminating-motion helper to pick the next question.
- Show final result (unique MP or note that multiple MPs are indistinguishable).
-
MotionDatabase.match_mps_for_votes (database.py)
- Input: user_votes dict {motion_id: vote_str}
- Output: ordered list of {mp_name, party, matched, total, agreement_pct}
- Implementation: create an in-memory relation of user_votes, join with mp_votes where mp_name LIKE '%,%' and aggregate matched / overlap counts. Order by agreement_pct, matched desc.
-
MotionDatabase.choose_discriminating_motions (database.py)
- Input: remaining candidate mp_names, excluded_motion_ids
- Output: motion_id(s) ranked by split-score (e.g., entropy or max-min split)
- Implementation: for a small candidate set, compute how many MPs vote 'voor'/'tegen'/'onthouden' on each motion and pick motion with best split.
Files to modify (concrete)
-
explorer.py
- Add function: build_mp_quiz_tab(...) near other build_*_tab functions (e.g., after build_svd_components_tab).
- Add new tab label to the tab_labels list and wire into the st.tabs and fallback radio branches. (See existing tab pattern at explorer.py around lines ~626-779.)
-
database.py
- Add methods: match_mps_for_votes and choose_discriminating_motions near calculate_party_matches / mp_votes helpers.
Data Flow
- UI loads candidate motion list via existing load_motions_df(db_path).
- User answers a question => stored in st.session_state['mp_quiz_votes'] mapping motion_id -> vote_token.
- UI calls MotionDatabase.match_mps_for_votes(user_votes) (read-only DuckDB). Returns sorted candidate MPs with matched/total/agreement_pct.
- If >1 candidate remains, UI calls MotionDatabase.choose_discriminating_motions(candidates, excluded) to pick next motion(s).
- Repeat until one candidate remains OR no motion splits candidates (tie by identical voting histories).
Error Handling
- Validation: normalize UI votes to the canonical tokens used in mp_votes (lowercase Dutch tokens like 'voor','tegen','onthouden','afwezig').
- Empty or missing data: if user_votes is empty or no overlaps exist, show helpful message and fall back to top-ranked MPs by similarity.
- Division-by-zero: in match computations, treat zero-overlap MPs as excluded from ranking and surface a clear message.
- Timeouts / heavy queries: restrict candidate set and use read-only DuckDB and caching (@st.cache_data) to avoid repeated heavy queries.
Testing Strategy
- Unit tests for database methods (new tests/test_match_mps.py):
- small synthetic mp_votes fixture to assert matched/total/agreement_pct logic.
- tests for choose_discriminating_motions producing expected splits.
- Integration test for explorer tab (tests/test_explorer_quiz.py): render the builder function in a headless mode and assert UI state updates and DB calls succeed (similar to existing tests/test_explorer_import.py).
Open Questions
- Do we want an initial curated motion set (top-10 controversial), or start fully adaptive? I'll implement a small curated seed and make adaptive/discovery optional.
- UX: Should we let users skip a question (abstain) and count abstain as a valid token? I assume yes and will treat abstain as a normal vote that matches mp_votes 'onthouden' or 'afwezig' values.
- Performance limits: how many motions should we allow the user to answer (arbitrary cap e.g., 20)? I suggest 20 to keep interactions snappy.
Next steps
I'm proceeding to create the design doc file at thoughts/shared/designs/2026-03-24-welk-tweede-kamerlid-ben-jij-design.md and commit it. Interrupt if you want changes. After that I'll spawn the planner to create a detailed implementation plan based on this design.