From 9f538a87840db95a154f80bd53501d52c5ccb08a Mon Sep 17 00:00:00 2001 From: Sven Geboers Date: Tue, 24 Mar 2026 23:55:48 +0100 Subject: [PATCH 1/5] feat(explorer): add ChristenUnie colour alias and CURRENT_PARLIAMENT_PARTIES constant docs/superpowers/plans/2026-03-24-svd-tab-redesign.md --- explorer.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/explorer.py b/explorer.py index bacdeee..6d74eeb 100644 --- a/explorer.py +++ b/explorer.py @@ -49,6 +49,7 @@ PARTY_COLOURS: Dict[str, str] = { "DENK": "#00897B", "50PLUS": "#7E57C2", "Volt": "#572AB7", + "ChristenUnie": "#0288D1", "Unknown": "#9E9E9E", } @@ -69,6 +70,29 @@ KNOWN_MAJOR_PARTIES = [ ] +# Parties currently seated in the Tweede Kamer (2023 election cycle). +# Deze zijn de entity_ids zoals opgeslagen in svd_vectors voor window='2025'. +CURRENT_PARLIAMENT_PARTIES: frozenset[str] = frozenset( + { + "PVV", + "VVD", + "NSC", + "BBB", + "D66", + "GroenLinks-PvdA", + "CDA", + "SP", + "ChristenUnie", + "SGP", + "Volt", + "DENK", + "PvdD", + "JA21", + "FVD", + } +) + + # --------------------------------------------------------------------------- # Cached loaders # --------------------------------------------------------------------------- From 151566192964e6c5f2f089500cd758e5c1ac200e Mon Sep 17 00:00:00 2001 From: Sven Geboers Date: Tue, 24 Mar 2026 23:57:38 +0100 Subject: [PATCH 2/5] feat(explorer): add _render_party_axis_chart helper --- explorer.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/explorer.py b/explorer.py index 6d74eeb..9db4131 100644 --- a/explorer.py +++ b/explorer.py @@ -203,6 +203,73 @@ def load_party_map(db_path: str) -> Dict[str, str]: return {} +def _render_party_axis_chart( + party_scores: Dict[str, List[float]], comp_sel: int +) -> None: + """Render a 1D horizontal Plotly scatter of party positions on SVD axis `comp_sel`. + + Each party is plotted at its score on a single horizontal axis (y=0). + """ + if not party_scores: + st.caption("_Partijdata niet beschikbaar voor deze as._") + return + + axis_idx = comp_sel - 1 # 0-based index into the 50-dim vector + data: list[dict] = [] + for party, vec in party_scores.items(): + if axis_idx < len(vec): + data.append({"party": party, "score": vec[axis_idx]}) + + if not data: + st.caption("_Geen partijscores voor deze as._") + return + + scores = [d["score"] for d in data] + parties = [d["party"] for d in data] + colours = [PARTY_COLOURS.get(p, "#9E9E9E") for p in parties] + hover = [f"{p}: {s:.3f}" for p, s in zip(parties, scores)] + + fig = go.Figure() + # Baseline + x_min, x_max = min(scores) * 1.15, max(scores) * 1.15 + fig.add_trace( + go.Scatter( + x=[x_min, x_max], + y=[0, 0], + mode="lines", + line={"color": "#cccccc", "width": 1}, + hoverinfo="skip", + showlegend=False, + ) + ) + # Party markers + fig.add_trace( + go.Scatter( + x=scores, + y=[0] * len(scores), + mode="markers+text", + text=parties, + textposition="top center", + marker={"size": 12, "color": colours}, + hovertext=hover, + hoverinfo="text", + showlegend=False, + ) + ) + fig.update_layout( + height=160, + margin={"l": 10, "r": 10, "t": 10, "b": 30}, + xaxis={ + "title": "← Negatieve pool | Positieve pool →", + "zeroline": True, + "zerolinecolor": "#aaaaaa", + }, + yaxis={"visible": False, "range": [-1, 2]}, + plot_bgcolor="white", + ) + st.plotly_chart(fig, use_container_width=True) + + @st.cache_data(show_spinner="Moties laden…") def load_motions_df(db_path: str) -> pd.DataFrame: """Load the full motions table as a pandas DataFrame (read-only).""" From 6b8ec93fe091ee4d029eb5d77f2c023f2d308ec3 Mon Sep 17 00:00:00 2001 From: Sven Geboers Date: Tue, 24 Mar 2026 23:58:38 +0100 Subject: [PATCH 3/5] =?UTF-8?q?feat(explorer):=20restructure=20SVD=20tab?= =?UTF-8?q?=20=E2=80=94=20pole-split=20motions,=20party=20axis=20chart,=20?= =?UTF-8?q?inline=20expanders=20with=20voting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- explorer.py | 118 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 73 insertions(+), 45 deletions(-) diff --git a/explorer.py b/explorer.py index 9db4131..019e84a 100644 --- a/explorer.py +++ b/explorer.py @@ -929,62 +929,90 @@ def build_svd_components_tab(db_path: str) -> None: ) comp_sel = comp_options[comp_sel_idx] - # Show theme explanation + poles + # Show theme explanation theme = SVD_THEMES.get(comp_sel, {}) if theme: st.info(f"**{theme['label']}** — {theme['explanation']}") - pos = theme.get("positive_pole", "") - neg = theme.get("negative_pole", "") - if pos or neg: - pcol, ncol = st.columns(2) - with pcol: - st.success(f"▲ **Positieve pool:** {pos}") - with ncol: - st.error(f"▼ **Negatieve pool:** {neg}") motions = comp_map.get(comp_sel, []) - col1, col2 = st.columns([1, 2]) - with col1: - st.markdown("**Top-moties (titels)**") - for m in motions: - mid = m.get("motion_id") - score = m.get("score", 0.0) - title = m.get("title") or f"Motie #{mid}" - sign = "▲" if score >= 0 else "▼" - if st.button(f"{sign} {mid}: {title[:72]}", key=f"btn_{comp_sel}_{mid}"): - st.session_state["svd_selected_mid"] = mid + # Party axis chart + party_scores = load_party_axis_scores(db_path) + _render_party_axis_chart(party_scores, comp_sel) - with col2: - sel_mid = st.session_state.get("svd_selected_mid") - if not sel_mid and motions: - sel_mid = motions[0].get("motion_id") - if sel_mid: - # fetch motion metadata from DB for completeness + # Batch-fetch motion details (title, date, policy_area, url, body_text, voting_results) + motion_ids = [m.get("motion_id") for m in motions if m.get("motion_id") is not None] + motion_details: Dict[int, tuple] = {} + if motion_ids: try: + placeholders = ", ".join("?" for _ in motion_ids) con = duckdb.connect(database=db_path, read_only=True) - row = con.execute( - "SELECT id, title, date, policy_area, url, body_text FROM motions WHERE id=?", - [int(sel_mid)], - ).fetchone() + db_rows = con.execute( + f"SELECT id, title, date, policy_area, url, body_text, voting_results " + f"FROM motions WHERE id IN ({placeholders})", + [int(mid) for mid in motion_ids], + ).fetchall() con.close() + motion_details = {r[0]: r for r in db_rows} except Exception: - row = None - - if row: - st.markdown(f"### {row[1] or f'Motie #{row[0]}'}") - try: - date_str = str(row[2])[:10] - except Exception: - date_str = "?" - st.caption(f"📅 {date_str} | {row[3]}") - if row[4] and str(row[4]).startswith("http"): - st.markdown(f"[🔗 Bekijk op Tweede Kamer]({row[4]})") - if row[5]: - with st.expander("Toon volledige tekst"): - st.write(row[5]) - else: - st.info(f"Metadata not found in DB for motion {sel_mid}") + logger.exception("Failed to batch-fetch motion details") + + # Split motions by pole sign + pos_motions = [m for m in motions if float(m.get("score", 0.0)) >= 0] + neg_motions = [m for m in motions if float(m.get("score", 0.0)) < 0] + + pos_pole = ( + theme.get("positive_pole", "Positieve pool") if theme else "Positieve pool" + ) + neg_pole = ( + theme.get("negative_pole", "Negatieve pool") if theme else "Negatieve pool" + ) + + pcol, ncol = st.columns(2) + + with pcol: + st.success(f"▲ **Positieve pool:** {pos_pole}") + for m in pos_motions: + mid = m.get("motion_id") + raw_title = m.get("title") or f"Motie #{mid}" + with st.expander(f"▲ {raw_title[:80]}"): + row = motion_details.get(int(mid)) if mid is not None else None + if row: + try: + date_str = str(row[2])[:10] + except Exception: + date_str = "?" + st.caption(f"📅 {date_str} | {row[3] or '—'}") + if row[4] and str(row[4]).startswith("http"): + st.markdown(f"[🔗 Bekijk op Tweede Kamer]({row[4]})") + if row[5]: + with st.expander("Toon volledige tekst"): + st.write(row[5]) + _render_voting_results(row[6]) + else: + st.caption("_Geen metadata beschikbaar_") + + with ncol: + st.error(f"▼ **Negatieve pool:** {neg_pole}") + for m in neg_motions: + mid = m.get("motion_id") + raw_title = m.get("title") or f"Motie #{mid}" + with st.expander(f"▼ {raw_title[:80]}"): + row = motion_details.get(int(mid)) if mid is not None else None + if row: + try: + date_str = str(row[2])[:10] + except Exception: + date_str = "?" + st.caption(f"📅 {date_str} | {row[3] or '—'}") + if row[4] and str(row[4]).startswith("http"): + st.markdown(f"[🔗 Bekijk op Tweede Kamer]({row[4]})") + if row[5]: + with st.expander("Toon volledige tekst"): + st.write(row[5]) + _render_voting_results(row[6]) + else: + st.caption("_Geen metadata beschikbaar_") def build_mp_quiz_tab(db_path: str) -> None: From fc1884ecd8d61b0a6f388870be477fbfd85a2275 Mon Sep 17 00:00:00 2001 From: Sven Geboers Date: Wed, 25 Mar 2026 00:00:51 +0100 Subject: [PATCH 4/5] feat(explorer): harden SVD tab batch-fetch motion details Include plan: docs/superpowers/plans/2026-03-24-svd-tab-redesign.md --- explorer.py | 38 ++++++++++++++++++++++++++------------ uv.lock | 36 ------------------------------------ 2 files changed, 26 insertions(+), 48 deletions(-) diff --git a/explorer.py b/explorer.py index 019e84a..0053940 100644 --- a/explorer.py +++ b/explorer.py @@ -83,6 +83,7 @@ CURRENT_PARLIAMENT_PARTIES: frozenset[str] = frozenset( "CDA", "SP", "ChristenUnie", + "CU", # alias for ChristenUnie "SGP", "Volt", "DENK", @@ -944,18 +945,31 @@ def build_svd_components_tab(db_path: str) -> None: motion_ids = [m.get("motion_id") for m in motions if m.get("motion_id") is not None] motion_details: Dict[int, tuple] = {} if motion_ids: - try: - placeholders = ", ".join("?" for _ in motion_ids) - con = duckdb.connect(database=db_path, read_only=True) - db_rows = con.execute( - f"SELECT id, title, date, policy_area, url, body_text, voting_results " - f"FROM motions WHERE id IN ({placeholders})", - [int(mid) for mid in motion_ids], - ).fetchall() - con.close() - motion_details = {r[0]: r for r in db_rows} - except Exception: - logger.exception("Failed to batch-fetch motion details") + # Defensively convert motion_ids to integers, skipping invalid values + ids_int: List[int] = [] + for mid in motion_ids: + try: + ids_int.append(int(mid)) + except Exception: + logger.warning("Skipping invalid motion id in SVD batch fetch: %r", mid) + + # If no valid ids remain, skip the DB query + if ids_int: + con = None + try: + placeholders = ", ".join("?" for _ in ids_int) + con = duckdb.connect(database=db_path, read_only=True) + db_rows = con.execute( + f"SELECT id, title, date, policy_area, url, body_text, voting_results " + f"FROM motions WHERE id IN ({placeholders})", + ids_int, + ).fetchall() + motion_details = {r[0]: r for r in db_rows} + except Exception: + logger.exception("Failed to batch-fetch motion details") + finally: + if con: + con.close() # Split motions by pole sign pos_motions = [m for m in motions if float(m.get("score", 0.0)) >= 0] diff --git a/uv.lock b/uv.lock index 94779ca..d29b319 100644 --- a/uv.lock +++ b/uv.lock @@ -273,15 +273,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -706,15 +697,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/d2/c6e44dba74f17c6216ce1b56044a9b93a929f1c2d5bdaff892512b260f5e/plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0", size = 9910315, upload-time = "2026-03-02T21:10:18.131Z" }, ] -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - [[package]] name = "protobuf" version = "6.31.1" @@ -841,22 +823,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" }, ] -[[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1139,7 +1105,6 @@ dependencies = [ { name = "lxml" }, { name = "openai" }, { name = "plotly" }, - { name = "pytest" }, { name = "requests" }, { name = "schedule" }, { name = "scikit-learn" }, @@ -1156,7 +1121,6 @@ requires-dist = [ { name = "lxml", specifier = ">=6.0.2" }, { name = "openai", specifier = ">=1.99.7" }, { name = "plotly", specifier = ">=5.0" }, - { name = "pytest", specifier = ">=9.0.2" }, { name = "requests", specifier = ">=2.32.4" }, { name = "schedule", specifier = ">=1.2.2" }, { name = "scikit-learn", specifier = ">=1.8.0" },