parent
b09e580f65
commit
eb73275f32
@ -0,0 +1,130 @@ |
|||||||
|
"""Tests for sync_motion_content.py XML parsers and join builders. |
||||||
|
|
||||||
|
Fixtures use the real SyncFeed XML format: |
||||||
|
- Entity ID is an attribute: id="..." |
||||||
|
- tk:verwijderd is a namespaced attribute |
||||||
|
- zaak refs are child elements with ref="..." attributes |
||||||
|
- Zaak onderwerp/soort are child elements with text content |
||||||
|
- DocumentVersie uses <document ref="..."/> and <externeIdentifier> child elements |
||||||
|
""" |
||||||
|
|
||||||
|
import scripts.sync_motion_content as smc |
||||||
|
|
||||||
|
NS_TK = "http://www.tweedekamer.nl/xsd/tkData/v1-0" |
||||||
|
NS_PREFIX = f'xmlns:tk="{NS_TK}" xmlns="{NS_TK}"' |
||||||
|
|
||||||
|
|
||||||
|
def test_parse_besluit_simple(): |
||||||
|
xml = ( |
||||||
|
f'<besluit {NS_PREFIX} id="B1" tk:verwijderd="false">' |
||||||
|
' <zaak ref="Z1" />' |
||||||
|
' <zaak ref="Z2" />' |
||||||
|
" <besluitTekst>Aangenomen.</besluitTekst>" |
||||||
|
"</besluit>" |
||||||
|
) |
||||||
|
out = smc.parse_besluit(xml) |
||||||
|
assert out["id"] == "B1" |
||||||
|
assert out["verwijderd"] is False |
||||||
|
assert out["zaak_refs"] == ["Z1", "Z2"] |
||||||
|
|
||||||
|
|
||||||
|
def test_parse_besluit_deleted(): |
||||||
|
xml = ( |
||||||
|
f'<besluit {NS_PREFIX} id="B2" tk:verwijderd="true">' |
||||||
|
' <zaak ref="Z3" />' |
||||||
|
"</besluit>" |
||||||
|
) |
||||||
|
out = smc.parse_besluit(xml) |
||||||
|
assert out["verwijderd"] is True |
||||||
|
assert out["zaak_refs"] == ["Z3"] |
||||||
|
|
||||||
|
|
||||||
|
def test_parse_zaak_and_title_map(): |
||||||
|
zxml = ( |
||||||
|
f'<zaak {NS_PREFIX} id="Z1" tk:verwijderd="false">' |
||||||
|
" <onderwerp>My title</onderwerp>" |
||||||
|
" <soort>Motie</soort>" |
||||||
|
"</zaak>" |
||||||
|
) |
||||||
|
z = smc.parse_zaak(zxml) |
||||||
|
assert z["id"] == "Z1" |
||||||
|
assert z["verwijderd"] is False |
||||||
|
assert z["onderwerp"] == "My title" |
||||||
|
assert z["soort"] == "Motie" |
||||||
|
|
||||||
|
besluit_index = {"B1": {"zaak_refs": ["Z1"]}} |
||||||
|
zaak_index = {"Z1": z} |
||||||
|
tm = smc.build_title_map(besluit_index, zaak_index) |
||||||
|
assert tm["B1"] == "My title" |
||||||
|
|
||||||
|
|
||||||
|
def test_build_title_map_prefers_motie(): |
||||||
|
"""When a Besluit links multiple Zaak records, prefer soort==Motie.""" |
||||||
|
zaak_index = { |
||||||
|
"Z1": { |
||||||
|
"id": "Z1", |
||||||
|
"verwijderd": False, |
||||||
|
"onderwerp": "Other title", |
||||||
|
"soort": "Amendement", |
||||||
|
}, |
||||||
|
"Z2": { |
||||||
|
"id": "Z2", |
||||||
|
"verwijderd": False, |
||||||
|
"onderwerp": "Motion title", |
||||||
|
"soort": "Motie", |
||||||
|
}, |
||||||
|
} |
||||||
|
besluit_index = {"B1": {"zaak_refs": ["Z1", "Z2"]}} |
||||||
|
tm = smc.build_title_map(besluit_index, zaak_index) |
||||||
|
assert tm["B1"] == "Motion title" |
||||||
|
|
||||||
|
|
||||||
|
def test_parse_document(): |
||||||
|
dxml = ( |
||||||
|
f'<document {NS_PREFIX} id="D1" tk:verwijderd="false">' |
||||||
|
' <zaak ref="Z1" />' |
||||||
|
"</document>" |
||||||
|
) |
||||||
|
doc = smc.parse_document(dxml) |
||||||
|
assert doc["id"] == "D1" |
||||||
|
assert doc["verwijderd"] is False |
||||||
|
assert doc["zaak_refs"] == ["Z1"] |
||||||
|
|
||||||
|
|
||||||
|
def test_parse_documentversie(): |
||||||
|
dvxml = ( |
||||||
|
f'<documentVersie {NS_PREFIX} id="DV1" tk:verwijderd="false">' |
||||||
|
' <document ref="D1" />' |
||||||
|
" <externeIdentifier>kst-12345-678</externeIdentifier>" |
||||||
|
" <extensie>html</extensie>" |
||||||
|
"</documentVersie>" |
||||||
|
) |
||||||
|
dv = smc.parse_documentversie(dvxml) |
||||||
|
assert dv["id"] == "DV1" |
||||||
|
assert dv["verwijderd"] is False |
||||||
|
assert dv["document_id"] == "D1" |
||||||
|
assert dv["externe_identifier"] == "kst-12345-678" |
||||||
|
assert dv["extensie"] == "html" |
||||||
|
|
||||||
|
|
||||||
|
def test_parse_document_and_docversie_and_extid_map(): |
||||||
|
dxml = ( |
||||||
|
f'<document {NS_PREFIX} id="D1" tk:verwijderd="false">' |
||||||
|
' <zaak ref="Z1" />' |
||||||
|
"</document>" |
||||||
|
) |
||||||
|
dvxml = ( |
||||||
|
f'<documentVersie {NS_PREFIX} id="DV1" tk:verwijderd="false">' |
||||||
|
' <document ref="D1" />' |
||||||
|
" <externeIdentifier>EXT-123</externeIdentifier>" |
||||||
|
" <extensie>html</extensie>" |
||||||
|
"</documentVersie>" |
||||||
|
) |
||||||
|
doc = smc.parse_document(dxml) |
||||||
|
dv = smc.parse_documentversie(dvxml) |
||||||
|
besluit_index = {"B1": {"zaak_refs": ["Z1"]}} |
||||||
|
zaak_index = {"Z1": {"id": "Z1", "onderwerp": "t", "soort": "Motie"}} |
||||||
|
doc_index = {"D1": doc} |
||||||
|
dv_index = {"DV1": dv} |
||||||
|
extmap = smc.build_ext_id_map(besluit_index, zaak_index, doc_index, dv_index) |
||||||
|
assert extmap["B1"] == "EXT-123" |
||||||
@ -0,0 +1,119 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="utf-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||||
|
<title>Mapping Dutch Democracy: Building a Political Compass</title> |
||||||
|
<style> |
||||||
|
body{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; max-width:900px; margin:40px auto; line-height:1.6; color:#111} |
||||||
|
pre{background:#f6f8fa;padding:12px;border-radius:6px;overflow:auto} |
||||||
|
code{background:#f2f4f6;padding:2px 4px;border-radius:4px} |
||||||
|
h1,h2,h3{color:#0b3d91} |
||||||
|
ul{margin-left:1.2rem} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<h1>Mapping Dutch Democracy: Building a Political Compass from 28,000+ Parliamentary Votes</h1> |
||||||
|
<p><em>What if you could take every motion voted on in the Dutch Parliament over the past decade and automatically plot parties and MPs on a political map — with zero manual labeling?</em></p> |
||||||
|
<p>That's exactly what this project does. Here's how I built it, what I had to solve along the way, and what it revealed about Dutch political dynamics.</p> |
||||||
|
<p>---</p> |
||||||
|
<h2>The Starting Point: Open Data, Hidden Structure</h2> |
||||||
|
<p>The Dutch Parliament publishes every vote — every <em>motie</em>, every <em>amendement</em>, every <em>besluit</em> — in an open OData API. We're talking over <strong>28,000 motions</strong> spanning 2016 to 2026, with a record of how every individual MP voted: <em>voor</em> (for), <em>tegen</em> (against), <em>onthouden</em> (abstained), or <em>afwezig</em> (absent). That's 506,000 individual vote records.</p> |
||||||
|
<p>This is an extraordinary dataset. But in raw form it's just a table of votes. The interesting question is: can we extract <em>structure</em> — left vs. right, progressive vs. conservative, governing vs. opposition — purely from the pattern of who votes with whom?</p> |
||||||
|
<p>The answer is yes, and the method is surprisingly elegant.</p> |
||||||
|
<p>---</p> |
||||||
|
<h2>Step 1: Turning Votes into Geometry</h2> |
||||||
|
<p>Each motion is a snapshot of political alignment. For each motion, we know which MPs voted together and which voted apart. If every PvdA and GroenLinks MP votes the same way almost every time, that tells us something. If PVV and CDA MPs diverge consistently, that tells us something too.</p> |
||||||
|
<p>I represent this with <strong>Singular Value Decomposition (SVD)</strong> on the MP × motion matrix:</p> |
||||||
|
<ul><li>Rows: individual MPs (and party actors for collective votes)</li><li>Columns: motions</li><li>Values: +1 (voor), -1 (tegen), 0 (absent/abstain)</li></ul> |
||||||
|
SVD finds the dominant axes of variation — the directions along which the chamber disagrees most. The first component almost always corresponds to a left-right axis. The second typically captures something like progressive-traditionalist or libertarian-authoritarian. The key point: <strong>the axes emerge from the math, not from any labeling on my part.</strong> |
||||||
|
<p>I request 50 SVD dimensions per window — but the actual dimensionality is constrained by <code>min(n_MPs, n_motions) - 1</code>. Sparse windows (early years, partial quarters) produce fewer meaningful dimensions. The pipeline handles this gracefully, storing whatever <code>k_used</code> is for each window so downstream fusion always works with the actual vector length.</p> |
||||||
|
<h3>Making Windows Comparable: Procrustes Alignment</h3> |
||||||
|
<p>Running SVD independently per window creates a subtle problem: SVD axes are <strong>arbitrarily oriented</strong>. The "left-right" axis from 2020-Q3 and the "left-right" axis from 2021-Q1 might point in completely different directions — even if the underlying politics barely changed. You can't just stack the coordinates and call it a trajectory.</p> |
||||||
|
<p>The fix is <strong>Procrustes alignment</strong>: given two sets of party/MP positions across consecutive windows, find the rotation matrix R that best maps one onto the other (minimizing the Frobenius norm of the difference), using MPs who appear in both windows as anchors:</p> |
||||||
|
<pre><code>R = argmin_R ||A - B @ R||_F, subject to R'R = I</code></pre> |
||||||
|
<p>This is solved cleanly via SVD of the cross-covariance matrix (a nice piece of mathematical symmetry — SVD to build the space, SVD to align it). The result: a continuous track for every party from 2019 to 2026, where position changes reflect genuine political movement rather than axis flips.</p> |
||||||
|
<p>High Procrustes disparity between consecutive windows — where alignment is poor even with the best rotation — is itself a signal: it suggests a structural political shift, not just individual drift.</p> |
||||||
|
<p>---</p> |
||||||
|
<h2>Step 2: What Each Motion Is Actually About</h2> |
||||||
|
<p>Voting patterns tell us <em>who</em> agrees, but not <em>why</em>. For that, I add <strong>text embeddings</strong> — dense vector representations of each motion's content using a language model.</p> |
||||||
|
<p>I use <strong><code>qwen/qwen3-embedding-4b</code></strong> via OpenRouter — a 4-billion parameter multilingual model that produces 2560-dimensional vectors with strong Dutch-language support. For each motion, I embed the richest text available: full parliamentary body text when we have it (94% of the 28,172 motions after an enrichment pass against the Tweede Kamer API), falling back to the summary description or title otherwise.</p> |
||||||
|
<p>This lets us do something powerful: find motions that are genuinely similar in <em>topic</em>, not just in voting pattern. Two motions about nitrogen policy from 2020 and 2023 might have very different vote splits (different coalitions, different political moment) but near-identical text embeddings. That's a meaningful connection.</p> |
||||||
|
<p>---</p> |
||||||
|
<h2>Step 3: Fused Embeddings — The Best of Both Worlds</h2> |
||||||
|
<p>SVD gives the political-structural signal: <em>how does this motion split the chamber?</em> Text embeddings give the semantic signal: <em>what is this motion about?</em></p> |
||||||
|
<p>I concatenate both into a <strong>fused vector</strong> per motion per window:</p> |
||||||
|
<pre><code>fused = [svd_dims (typically 50)] + [text_dims (2560)] = typically 2610 dimensions</code></pre> |
||||||
|
<p>The actual dimension varies slightly because SVD dimensionality adapts to window density — the code stores <code>svd_dims</code> and <code>text_dims</code> per row so nothing downstream has to assume a fixed size.</p> |
||||||
|
<p>This fused representation powers the similarity search. Two motions are "close" only if they're about a similar <em>topic</em> <strong>and</strong> they produce a similar <em>political split</em>. This filters out spurious matches — two motions might both be controversial (close 50/50 votes) but on completely unrelated things, and the text component separates them.</p> |
||||||
|
<p>---</p> |
||||||
|
<h2>The Numbers: What We're Working With</h2> |
||||||
|
<p>After the full pipeline run:</p> |
||||||
|
<p>| Year | Motions | |
||||||
|
|------|---------| |
||||||
|
| 2016 | 132 | |
||||||
|
| 2017 | 30 | |
||||||
|
| 2018 | 100 | |
||||||
|
| 2019 | 3,374 | |
||||||
|
| 2020 | 4,228 | |
||||||
|
| 2021 | 4,289 | |
||||||
|
| 2022 | 4,116 | |
||||||
|
| 2023 | 3,272 | |
||||||
|
| 2024 | 3,968 | |
||||||
|
| 2025 | 3,715 | |
||||||
|
| 2026 | 948 | |
||||||
|
| <strong>Total</strong> | <strong>28,172</strong> |</p> |
||||||
|
<p>The 2022 spike is striking — over 4,000 motions in a single year. This was the year the Rutte IV coalition took office amid intense debates on energy prices, housing, the war in Ukraine, and the ongoing nitrogen crisis. 2023 is similarly dense at 3,272 motions, culminating in the November election that brought PVV to its historic first-place finish.</p> |
||||||
|
<p>Early years (2016–2018) use annual windows because the data is too sparse for meaningful quarterly SVD. From 2019 onwards, everything runs quarterly, giving us 38 windows in total.</p> |
||||||
|
<p>The similarity cache holds <strong>405,216 precomputed pairs</strong> — top 10 neighbors per motion per window — making lookup instant at query time.</p> |
||||||
|
<p>---</p> |
||||||
|
<h2>Interesting Findings</h2> |
||||||
|
<h3>The 2022–2023 Polarization Surge</h3> |
||||||
|
<p>2022 and 2023 together account for more than a quarter of all motions in the dataset. In the SVD positions for 2022, the distance between the governing coalition (VVD, D66, CDA, CU) and the opposition (PVV, SP, FvD) is near its maximum. The nitrogen crisis and energy policy debates forced unusually sharp coalition discipline — which shows up geometrically as well-separated clusters.</p> |
||||||
|
<p>2023 continued the intensity, and the Procrustes-aligned trajectory shows the party positions in 2023-Q4 and 2024-Q1 shifting noticeably as the new coalition began to form.</p> |
||||||
|
<h3>BBB's Geometric Arrival</h3> |
||||||
|
<p>When BBB (BoerBurgerBeweging) entered parliament in 2023 with a historic 16 seats, their SVD position placed them between PVV and CDA — exactly matching their policy profile: agrarian-nationalist populism with Catholic-provincial roots. The model found this without being told. That's a good sanity check that the geometry is capturing something real.</p> |
||||||
|
<h3>The Strange Case of "Verworpen."</h3> |
||||||
|
<p>Motions rejected without debate are recorded with the title "Verworpen." (Rejected.). There are hundreds of these. Because they share a 9-character title, their text embeddings are <strong>identical</strong> — cosine similarity 1.0 to every other "Verworpen." in the cache. Technically correct; semantically meaningless. The UI layer filters these out.</p> |
||||||
|
<p>It's a reminder that <strong>data quality surprises emerge at scale</strong>. I found three or four similar pathologies (motions withdrawn mid-session, duplicate API records) that required explicit handling.</p> |
||||||
|
<h3>Party Cohesion as a Signal</h3> |
||||||
|
<p>Party cohesion — how often all MPs of a party vote identically — varies enormously. SGP and CU are near-perfect blocs. PvdA/GroenLinks (post-2023 merger) is similarly tight. VVD shows the most internal variation, which tracks with what you'd expect from a governing party managing coalition discipline across conflicting wings.</p> |
||||||
|
<p>In earlier years (2019–2020), before the GroenLinks-PvdA merger, GroenLinks occasionally splits on security and defense policy — visible in the SVD as individual MP positions diverging from the party centroid.</p> |
||||||
|
<p>---</p> |
||||||
|
<h2>The Pipeline Architecture</h2> |
||||||
|
<p>Single DuckDB database, modular Python pipeline, no cloud infrastructure:</p> |
||||||
|
<pre><code>API (Tweede Kamer OData) |
||||||
|
→ download_past_year.py |
||||||
|
→ motions table (28,172 rows) |
||||||
|
<p>motions |
||||||
|
→ extract_mp_votes.py → mp_votes table (506,336 rows) |
||||||
|
→ sync_motion_content.py → body_text enrichment (26,447 motions, ~94%) |
||||||
|
→ text_pipeline.py → embeddings table (28,172 rows, qwen3-embedding-4b via OpenRouter) |
||||||
|
→ svd_pipeline.py → svd_vectors table (54,150 rows, 38 windows)</p> |
||||||
|
<p>svd_vectors + embeddings |
||||||
|
→ fusion.py → fused_embeddings table (40,522 rows)</p> |
||||||
|
<p>fused_embeddings |
||||||
|
→ similarity/compute.py → similarity_cache table (405,216 rows, top-10 per window)</code></pre></p> |
||||||
|
<p>The similarity computation is pure NumPy: load all fused vectors for a window, pad to uniform length, L2-normalize, compute the full <code>N×N</code> cosine similarity matrix via a single matrix multiply (<code>normalized @ normalized.T</code>), then extract top-k neighbors per row with <code>np.argpartition</code>. For a 4,000-motion quarter, that's a 4000×4000 matrix operation — fast enough that it's not worth batching.</p> |
||||||
|
<p>The database sits at 15 GB on disk — up from ~3 GB before body text enrichment. The full parliamentary text for 26,000+ motions accounts for most of that growth.</p> |
||||||
|
<p>---</p> |
||||||
|
<h2>What's Next</h2> |
||||||
|
<p><strong>Motion explorer</strong>: Given a motion, retrieve the 10 most politically and semantically similar ones from across the decade. Trace how a policy debate evolved — who championed it, how the coalitions shifted.</p> |
||||||
|
<p><strong>Party trajectory animation</strong>: Procrustes-aligned positions, animated year by year. Watch D66 drift post-2021, watch PVV consolidate its flank, watch new parties arrive and find their geometric home.</p> |
||||||
|
<p><strong>Cross-party coalition patterns</strong>: The fused embeddings let us ask which topics produce unusual coalition configurations — motions where the normal left-right split breaks down and unexpected alliances form.</p> |
||||||
|
<p><strong>The controversy index</strong>: <code>1 - winning_margin</code> gives a controversy score per motion. The most contested votes — close margins, high-salience topics — tell a different story than the headline political narratives.</p> |
||||||
|
<p>---</p> |
||||||
|
<h2>Reproducibility</h2> |
||||||
|
<pre><code>bash |
||||||
|
<h1>Download historical data</h1> |
||||||
|
python scripts/download_past_year.py --start-date 2016-01-01 --end-date 2026-01-01 |
||||||
|
<h1>Run full pipeline (SVD, text embeddings, fusion, similarity cache)</h1> |
||||||
|
python -m pipeline.run_pipeline --db-path data/motions.db \ |
||||||
|
--start-date 2016-01-01 --end-date 2026-01-01 \ |
||||||
|
--window-size quarterly --text-batch-size 200 |
||||||
|
<h1>Enrich with full motion body text</h1> |
||||||
|
python scripts/sync_motion_content.py --db-path data/motions.db</code></pre> |
||||||
|
<p>The DB grows to ~15 GB for the full dataset including body text. All computation — SVD, fusion, similarity — runs locally on a single machine.</p> |
||||||
|
<p>Democracy is more legible than it looks.</p> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,125 @@ |
|||||||
|
{ |
||||||
|
"window": "2025", |
||||||
|
"important": { |
||||||
|
"0": [ |
||||||
|
"2185", |
||||||
|
"1354", |
||||||
|
"145", |
||||||
|
"1983", |
||||||
|
"3111", |
||||||
|
"1299", |
||||||
|
"3246", |
||||||
|
"1967", |
||||||
|
"3061", |
||||||
|
"1682" |
||||||
|
], |
||||||
|
"1": [ |
||||||
|
"164", |
||||||
|
"799", |
||||||
|
"792", |
||||||
|
"3536", |
||||||
|
"3120", |
||||||
|
"3001", |
||||||
|
"3011", |
||||||
|
"3013", |
||||||
|
"1814", |
||||||
|
"103" |
||||||
|
], |
||||||
|
"2": [ |
||||||
|
"1958", |
||||||
|
"1958", |
||||||
|
"1432", |
||||||
|
"1432", |
||||||
|
"1584", |
||||||
|
"1584", |
||||||
|
"1139", |
||||||
|
"1259", |
||||||
|
"1139", |
||||||
|
"1172" |
||||||
|
], |
||||||
|
"3": [ |
||||||
|
"2539", |
||||||
|
"2539", |
||||||
|
"2541", |
||||||
|
"2541", |
||||||
|
"2452", |
||||||
|
"2452", |
||||||
|
"929", |
||||||
|
"1625", |
||||||
|
"929", |
||||||
|
"509" |
||||||
|
], |
||||||
|
"4": [ |
||||||
|
"1456", |
||||||
|
"1456", |
||||||
|
"2466", |
||||||
|
"2466", |
||||||
|
"1344", |
||||||
|
"1344", |
||||||
|
"2313", |
||||||
|
"2313", |
||||||
|
"1446", |
||||||
|
"1446" |
||||||
|
], |
||||||
|
"5": [ |
||||||
|
"906", |
||||||
|
"906", |
||||||
|
"3244", |
||||||
|
"3244", |
||||||
|
"2060", |
||||||
|
"2060", |
||||||
|
"2967", |
||||||
|
"2967", |
||||||
|
"1817", |
||||||
|
"1817" |
||||||
|
], |
||||||
|
"6": [ |
||||||
|
"1826", |
||||||
|
"1826", |
||||||
|
"2333", |
||||||
|
"2333", |
||||||
|
"2002", |
||||||
|
"2002", |
||||||
|
"879", |
||||||
|
"3518", |
||||||
|
"3518", |
||||||
|
"879" |
||||||
|
], |
||||||
|
"7": [ |
||||||
|
"1964", |
||||||
|
"1964", |
||||||
|
"3370", |
||||||
|
"3370", |
||||||
|
"3216", |
||||||
|
"3216", |
||||||
|
"2379", |
||||||
|
"2379", |
||||||
|
"3517", |
||||||
|
"3517" |
||||||
|
], |
||||||
|
"8": [ |
||||||
|
"719", |
||||||
|
"719", |
||||||
|
"3281", |
||||||
|
"3281", |
||||||
|
"3530", |
||||||
|
"3626", |
||||||
|
"3530", |
||||||
|
"3626", |
||||||
|
"2001", |
||||||
|
"2001" |
||||||
|
], |
||||||
|
"9": [ |
||||||
|
"720", |
||||||
|
"720", |
||||||
|
"408", |
||||||
|
"408", |
||||||
|
"730", |
||||||
|
"730", |
||||||
|
"732", |
||||||
|
"732", |
||||||
|
"3443", |
||||||
|
"3443" |
||||||
|
] |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,549 @@ |
|||||||
|
[ |
||||||
|
{ |
||||||
|
"id": "91d3a66a-5542-4325-8fc0-f0715b570e5b", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:01:06.889693Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "1e1bcee0-5f2a-4337-b57f-ca83ac93da7e", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:01:07.327717Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "51fec578-84ae-4d69-85df-b415a2b6c752", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:01:07.827925Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "38c9ba12-5829-410c-ac38-b992a9b22652", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:01:11.681524Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "1e69741a-d0ff-41c9-b5e7-e4e2e8475836", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:01:12.061577Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "c2fa7d58-958b-4efb-b670-d044de0db357", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:01:12.104491Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "4bd245c7-9e6c-41dc-bce0-f36d3b675ef8", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:03:14.657886Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "e885a2da-48f3-4130-b62f-413ae2670b9c", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:03:14.997464Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "29949f88-0739-4029-964a-d1a9be3d1030", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:03:15.057155Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "b08b870b-1923-4cc7-8384-6a4fd5a5ae63", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:08:18.540282Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "acb1d1ef-1a2f-4256-b23c-dc7272e6cda8", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:08:18.992755Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "a2e7f741-ce46-4533-a4a1-98d202ad5ba9", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:16:44.733143Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "208fd3d6-dcaa-408a-b7ef-054703083756", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:16:45.983301Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "9112851e-ba85-4498-8aa9-4f71aa91d6ec", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:16:46.060827Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "2a5bffe4-c75b-46f2-baf1-164ac87953d6", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:18:02.707023Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "834a7419-6d1b-48e0-825c-08c3ea780c94", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:18:04.158612Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "9d27c575-c186-4cc6-b202-1e7d44600983", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:18:04.213958Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "b03f2ce4-6ac0-41a5-8f32-36dc86db4048", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:19:07.187178Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "4589cfd1-16c9-4743-b2e9-15be42e121e7", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:19:07.560463Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "b2e8fa30-b3ca-42f1-9138-c188b2683723", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:19:07.631447Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "492bd375-b002-446b-b424-4dc3cf40ea44", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:20:12.734568Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "e9349d9b-7962-489b-8a06-1753f1606048", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:20:13.089162Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "a8af51f1-2126-4b57-87e3-6913ada4643b", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:20:13.152945Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "999cccc1-dcbe-48c7-80f0-229351780823", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:20:26.037664Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "03acdcb0-da76-4ac7-bc7d-ea5a314734b8", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:20:27.339457Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "964943f7-c431-484a-a2a3-070327287d90", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:20:27.382680Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "4d4ffec0-83c9-48e6-8724-df04de5cf741", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:25:42.534010Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "56511987-38b1-4286-a8ac-9771c297051f", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:25:42.861164Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "89e29b19-8926-47c8-8a52-922a65da4189", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:25:44.542652Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "a430eb8d-c36e-4ad1-b5c3-01c1fc5f3be5", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:30:11.148013Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "2b108662-6d4b-48b6-88ba-9f3111491217", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:30:12.361529Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "18aad6fc-567f-4d7d-8569-28357c6c301d", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:30:12.429871Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "730467ab-2c1d-47ec-a29e-9d02803c8b1f", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:31:09.659557Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "e87170fe-c03b-42a6-bd96-d45a26717359", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T20:31:10.991569Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "cf5c8957-c893-478d-924b-ce77d9e53a41", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T20:31:11.039826Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "d0aa2441-6fc8-4df9-bf37-621f8d9e5351", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T21:37:36.823732Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "e55be18c-0fb5-4576-aa51-24559148916e", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T21:37:38.061523Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "b8f7ee6c-4ce5-4415-97bc-e00e02b2c851", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T21:37:38.105562Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "75e76c14-28e5-4f5b-a2d4-52c9dba5efc4", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T21:53:11.948658Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "0e515c39-ef8a-4abf-87fb-1c96e669e7f5", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T21:53:13.215965Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "836ba937-d26f-4869-9661-36d5744f649a", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T21:53:13.259045Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "95606fed-c023-4fb1-98a4-5c5707e95056", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T21:54:11.170757Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "e188e203-932c-4f79-bed9-af1ec1336102", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T21:54:12.444776Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "591eb30e-a91f-448b-b9a0-9a3d75a35790", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T21:54:12.491990Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "75d70cad-41f4-49f8-ae8a-08b1bafb02b4", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T21:58:08.120685Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "a0cc28c1-28f6-409e-ad5c-8a974ff28e00", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T21:58:08.847188Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "84e5699d-64d9-4179-99e2-f79cb9b27b33", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T21:58:08.888009Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "f0e47b0a-4791-4475-9036-9e87a5e00be8", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T22:31:18.698163Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "50006ac9-d6b5-4198-9cca-c937dee60eab", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T22:31:19.938465Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "d44d09e5-fa20-40ea-8829-dfa044addf57", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T22:31:19.997516Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "c1876f8b-79bd-4fd0-9d0f-68eb8dd3321d", |
||||||
|
"actor_id": null, |
||||||
|
"action": "embedding_failed", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": "99", |
||||||
|
"metadata": { |
||||||
|
"error": "RuntimeError(\"Simulated embedding failure for index 0: 'failing motion'\")" |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T22:52:46.531868Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "39fb4426-e348-455c-b4f7-0e77c6a72dc7", |
||||||
|
"actor_id": null, |
||||||
|
"action": "test_action", |
||||||
|
"target_type": "unit", |
||||||
|
"target_id": "u1", |
||||||
|
"metadata": { |
||||||
|
"k": 1 |
||||||
|
}, |
||||||
|
"created_at": "2026-03-23T22:52:47.772599Z" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"id": "fc04bbb8-1378-460c-9914-c923cd1a45f8", |
||||||
|
"actor_id": null, |
||||||
|
"action": "another_action", |
||||||
|
"target_type": "motion", |
||||||
|
"target_id": null, |
||||||
|
"metadata": {}, |
||||||
|
"created_at": "2026-03-23T22:52:47.836920Z" |
||||||
|
} |
||||||
|
] |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
{ |
||||||
|
"timestamp": "2026-03-23T19:43:35.098568Z", |
||||||
|
"sample_size": 2, |
||||||
|
"top_k": 3, |
||||||
|
"results": [ |
||||||
|
{ |
||||||
|
"motion_id": 1, |
||||||
|
"top_k": 3, |
||||||
|
"suspicious": 1 |
||||||
|
}, |
||||||
|
{ |
||||||
|
"motion_id": 2, |
||||||
|
"top_k": 3, |
||||||
|
"suspicious": 1 |
||||||
|
} |
||||||
|
], |
||||||
|
"motions": { |
||||||
|
"1": { |
||||||
|
"motion_id": 1, |
||||||
|
"top_k": 3, |
||||||
|
"suspicious": 1 |
||||||
|
}, |
||||||
|
"2": { |
||||||
|
"motion_id": 2, |
||||||
|
"top_k": 3, |
||||||
|
"suspicious": 1 |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,113 @@ |
|||||||
|
--- |
||||||
|
date: 2026-03-24 |
||||||
|
topic: "Welk tweede kamerlid ben jij?" |
||||||
|
status: 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 |
||||||
|
|
||||||
|
1. UI loads candidate motion list via existing load_motions_df(db_path). |
||||||
|
2. User answers a question => stored in st.session_state['mp_quiz_votes'] mapping motion_id -> vote_token. |
||||||
|
3. UI calls MotionDatabase.match_mps_for_votes(user_votes) (read-only DuckDB). Returns sorted candidate MPs with matched/total/agreement_pct. |
||||||
|
4. If >1 candidate remains, UI calls MotionDatabase.choose_discriminating_motions(candidates, excluded) to pick next motion(s). |
||||||
|
5. 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 |
||||||
|
|
||||||
|
1. 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. |
||||||
|
2. 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. |
||||||
|
3. 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. |
||||||
@ -0,0 +1,197 @@ |
|||||||
|
# "Welk tweede kamerlid ben jij?" Implementation Plan |
||||||
|
|
||||||
|
**Goal:** Add a Streamlit quiz tab that interactively asks the user motion (vote) questions and narrows the set of 2026 MPs to find the best-matching MP. Implement two DB helpers (matching + discriminating-motion selection), the UI builder and tab wiring, and tests. Minimal viable changes only — no UX bells & whistles. |
||||||
|
|
||||||
|
**Design:** thoughts/shared/designs/2026-03-24-welk-tweede-kamerlid-ben-jij-design.md |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Dependency Graph |
||||||
|
|
||||||
|
``` |
||||||
|
Batch 1 (parallel): 1.1 [foundation - no deps], 1.2 (plan file) [none] |
||||||
|
Batch 2 (parallel): 2.1 [explorer UI - depends: 1.1] |
||||||
|
Batch 3 (parallel): 3.1 [integration tests - depends: 1.1,2.1] |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Summary of implementation decisions (gap-filling) |
||||||
|
|
||||||
|
- MotionDatabase.match_mps_for_votes: implement as a read-only DuckDB-backed method on the existing MotionDatabase class (database.py). It accepts user_votes: Dict[int, str] where keys are motion ids and values are UI vote tokens. I will implement vote normalization inside the method (mapping UI tokens to canonical DB tokens) to avoid touching other modules. Rationale: keeps surface changes minimal and avoids creating new modules. |
||||||
|
|
||||||
|
- MotionDatabase.choose_discriminating_motions: implement in the same file. For a small candidate set (expected << 200 MPs), fetch mp_votes for candidate MPs across candidate motions (excluding already-answered motion ids). Score candidate motions by information-entropy of vote distribution among remaining candidates (higher entropy = better split). Ties broken by controversy_score then motion id. |
||||||
|
|
||||||
|
- Explorer UI changes: add build_mp_quiz_tab(db_path) to explorer.py and wire it into the tabs list and fallback radio. Use st.session_state['mp_quiz_votes'] to store answers as mapping str(motion_id)->UI token. Use @st.cache_data on any expensive DB-calls in the UI layer. |
||||||
|
|
||||||
|
- Vote token normalization: UI will present choices: "Voor", "Tegen", "Onthouden", "Afwezig / Geen stem". The DB stores lowercase tokens like 'voor', 'tegen', 'onthouden', 'afwezig'. match_mps_for_votes will normalize case and a small set of variants (e.g., 'Geen stem' -> 'afwezig', 'Abstain' -> 'onthouden') — explicit list included in tests. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## BATCH 1: Foundation (parallel - N implementers) |
||||||
|
All tasks in this batch have NO dependencies and can run simultaneously. |
||||||
|
|
||||||
|
### Task 1.1: Add DB helpers to MotionDatabase |
||||||
|
**File:** `database.py` (modify existing) |
||||||
|
**Test:** `tests/test_match_mps.py` |
||||||
|
**Depends:** none |
||||||
|
|
||||||
|
Description / Acceptance criteria: |
||||||
|
- Add two new public methods to MotionDatabase: |
||||||
|
- match_mps_for_votes(user_votes: Dict[int, str], limit: int = 50) -> List[Dict] |
||||||
|
- Returns an ordered list (desc by agreement_pct) of dicts with keys: mp_name, party, matched (int), overlap (int), agreement_pct (float 0-100). |
||||||
|
- Behavior: for each mp present in mp_votes for any of the provided motions compute: |
||||||
|
- overlap = number of motions where MP has a recorded vote AND the user provided a non-empty vote (i.e., not "Geen stem"). |
||||||
|
- matched = number of those overlaps where normalized(mp_vote) == normalized(user_vote). |
||||||
|
- agreement_pct = matched / overlap * 100 rounded to 1 decimal. MPs with overlap==0 are excluded from the returned list. |
||||||
|
- Ordering: agreement_pct desc, then matched desc, then mp_name asc. |
||||||
|
|
||||||
|
- choose_discriminating_motions(candidates: List[str], excluded_motion_ids: List[int], k: int = 1) -> List[int] |
||||||
|
- For the provided candidate mp_names, compute vote distributions per motion (voor/tegen/onthouden/afwezig) excluding motion ids in excluded_motion_ids. |
||||||
|
- Score each motion by Shannon entropy of the distribution among the candidate MPs (treating 'afwezig' as a separate bucket). Higher entropy preferred. |
||||||
|
- Return top-k motion ids as a list, tiebreakers: higher controversy_score (motions table) then lower motion id. |
||||||
|
|
||||||
|
Implementation notes & decisions: |
||||||
|
- Implement normalization inside these methods. Normalization mapping (DB vote -> canonical): map DB votes lowercased to one of {'voor','tegen','onthouden','afwezig'}. UI inputs (Voor/Tegen/Onthouden/Geen stem) normalized to these same tokens. |
||||||
|
- For performance, implement SQL queries that select mp_votes filtered by motion_id IN (...) and mp_name IN (candidates) and aggregate via GROUP BY mp_name and vote. For small candidate sets and a limited set of motion_ids this will be fast. If duckdb is not available, fall back to in-Python aggregates using the file-backed JSON format already present in MotionDatabase._init_database. |
||||||
|
- Add docstrings and basic parameter validation (raise ValueError for empty user_votes or empty candidates input). Tests will cover expected exceptions. |
||||||
|
|
||||||
|
Test outline (tests/test_match_mps.py): |
||||||
|
- Setup: create a temporary MotionDatabase using a temp db_path (MotionDatabase.reset_database() can be used if duckdb available; otherwise use file-backed mode). Insert a small set of motions and mp_votes via insert_motion / insert_mp_vote. Create at least 3 MPs with overlapping but distinct vote patterns across 4-6 motions. |
||||||
|
- Tests: |
||||||
|
1) test_match_basic_counts: user_votes covering 3 motions returns expected matched/overlap/agreement_pct per MP. |
||||||
|
2) test_match_excludes_zero_overlap: MPs with no recorded votes for provided motions are excluded. |
||||||
|
3) test_choose_discriminating_motions_entropy_ranking: with a small candidate set, the chosen motion(s) split candidates as expected (assert returned motion id is one of known good splitters) |
||||||
|
4) test_invalid_input: calling match_mps_for_votes with empty user_votes raises ValueError. |
||||||
|
|
||||||
|
Verify: `pytest -q tests/test_match_mps.py` |
||||||
|
|
||||||
|
Commit message: `feat(database): add match_mps_for_votes and choose_discriminating_motions` |
||||||
|
|
||||||
|
Estimated time: 3.0 - 4.5 hours |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### Task 1.2: Add plan file (this document) |
||||||
|
**File:** `thoughts/shared/plans/2026-03-24-welk-tweede-kamerlid-ben-jij-plan.md` (this file) |
||||||
|
**Test:** none |
||||||
|
**Depends:** none |
||||||
|
|
||||||
|
Description: Add the implementation plan (this document) to the repo to provide step-by-step microtasks to implementers. No tests. |
||||||
|
|
||||||
|
Verify: visually review file in repo. No test run. |
||||||
|
|
||||||
|
Commit message: `docs(plans): add plan for 'Welk tweede kamerlid ben jij?'` |
||||||
|
|
||||||
|
Estimated time: 0.25 - 0.5 hours |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## BATCH 2: Core UI (parallel - depends on Batch 1) |
||||||
|
All tasks in this batch assume the DB methods from Task 1.1 exist. |
||||||
|
|
||||||
|
### Task 2.1: Add Streamlit quiz tab & wiring |
||||||
|
**File:** `explorer.py` (modify existing) |
||||||
|
**Test:** `tests/test_explorer_quiz.py` |
||||||
|
**Depends:** 1.1 |
||||||
|
|
||||||
|
Description / Acceptance criteria: |
||||||
|
- Add a function `build_mp_quiz_tab(db_path: str) -> None` placed near other build_*_tab functions (as described in the design, e.g., after build_svd_components_tab or near the top of the tab builders). The function must: |
||||||
|
- Render a short intro/instructions. |
||||||
|
- Load an initial pool of candidate motions using existing `load_motions_df(db_path)` and pick a seed set (top N by controversy_score). Decision: seed N = 8 (configurable constant in the function: SEED_MOTIONS = 8) — this is small and fast. |
||||||
|
- Present questions one at a time: show motion title + layman_explanation (if available) and a radio with choices: "Voor", "Tegen", "Onthouden", "Geen stem" and a "Skip"/"Niet zeker" optional button mapped to "Geen stem". Choice stored to `st.session_state['mp_quiz_votes']` as mapping with keys str(motion_id) -> UI token. |
||||||
|
- After each answer, call MotionDatabase.match_mps_for_votes(user_votes) to fetch ranked candidates and display a small DataFrame (top 10) with columns: MP name, party, matched, overlap, agreement_pct. Use st.dataframe. |
||||||
|
- If more than 1 candidate remains with top agreement_pct tied, call MotionDatabase.choose_discriminating_motions(candidates, excluded_motion_ids) to pick the next question to ask and continue until one unique MP remains or choose_discriminating_motions returns an empty list (tie / indistinguishable). Cap total questions at 20 (SESS_CAP = 20). |
||||||
|
- When unique MP is found (agreement_pct == 100 and overlap>0 and only one MP with highest agreement), show final MP summary (name, party) and their matching motions count. |
||||||
|
- Use caching: wrap any repeated DB lookups (e.g., load_motions_df already cached) and mark heavy updates via @st.cache_data where appropriate. |
||||||
|
|
||||||
|
Implementation notes & decisions: |
||||||
|
- Keep all UI state local to st.session_state with keys prefixed `mp_quiz_` to avoid collisions. |
||||||
|
- Normalize UI tokens before sending to DB helper (but DB methods will also normalize; duplication is defensive). |
||||||
|
- Keep the UI function self-contained in explorer.py (do not create new modules for this minimal MVP). |
||||||
|
|
||||||
|
Test outline (tests/test_explorer_quiz.py): |
||||||
|
- Use monkeypatching to inject a MotionDatabase mock into explorer module or run in a test DB using MotionDatabase with temp db_path. The test must be import-safe (explorer.py imports many heavy libs), so follow pattern used by existing tests/test_explorer_import.py: import the module and assert `build_mp_quiz_tab` exists and is callable. |
||||||
|
- Functional assertions: |
||||||
|
1) test_builder_exists: import explorer, assert callable(build_mp_quiz_tab) |
||||||
|
2) test_ui_state_update_simulation: simulate st.session_state by creating a fake session dict (use monkeypatch to set st.session_state to a dict-like object) and calling build_mp_quiz_tab with a small temp DB where motions and mp_votes are prepared. Assert that after calling the builder with pre-filled votes the DataFrame block would display ranked candidates (test inspects returned structure if builder returns it, or else monkeypatch MotionDatabase.match_mps_for_votes to verify it was called with expected mapping). |
||||||
|
|
||||||
|
Verification: `pytest -q tests/test_explorer_quiz.py` |
||||||
|
|
||||||
|
Commit message: `feat(ui): add 'Welk tweede kamerlid ben jij?' tab and wiring in explorer.py` |
||||||
|
|
||||||
|
Estimated time: 2.0 - 4.0 hours |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## BATCH 3: Integration & Tests (parallel - depends on Batches 1+2) |
||||||
|
|
||||||
|
### Task 3.1: Add integration test for quiz flow |
||||||
|
**File:** `tests/test_explorer_quiz_integration.py` |
||||||
|
**Test:** this file |
||||||
|
**Depends:** 1.1, 2.1 |
||||||
|
|
||||||
|
Description / Acceptance criteria: |
||||||
|
- Create an end-to-end-ish headless test that: |
||||||
|
- Sets up a temporary MotionDatabase instance (temp file path) and inserts a small controlled dataset: ~6 motions, 4 MPs with distinct votes. |
||||||
|
- Calls build_mp_quiz_tab via explorer with monkeypatched st.session_state (or with a minimal wrapper) and simulates a sequence of user answers by pre-populating st.session_state['mp_quiz_votes']. |
||||||
|
- Asserts that final candidate set matches expectations: either a unique MP (when answers match exactly one MP) or that the function properly identifies indistinguishable MPs (when two MPs have identical votes). |
||||||
|
|
||||||
|
Testing details & choices: |
||||||
|
- Avoid launching Streamlit server; tests only import explorer module and call the builder function in the same way other explorer tests do. Use monkeypatch to stub expensive functions (plotly, query_similar) where required. |
||||||
|
|
||||||
|
Verify: `pytest -q tests/test_explorer_quiz_integration.py` |
||||||
|
|
||||||
|
Commit message: `test(ui): add integration tests for mp quiz tab flow` |
||||||
|
|
||||||
|
Estimated time: 2.0 - 3.0 hours |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Verification & CI |
||||||
|
|
||||||
|
- Local verification commands (per task) use pytest. Example: |
||||||
|
- `pytest -q tests/test_match_mps.py` |
||||||
|
- `pytest -q tests/test_explorer_quiz.py` |
||||||
|
- `pytest -q tests/test_explorer_quiz_integration.py` |
||||||
|
|
||||||
|
- CI expectations: run full test suite. The new tests should be lightweight and use temporary DBs / monkeypatching to avoid depending on large production DB. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Commit & PR Strategy |
||||||
|
|
||||||
|
- Work in a feature branch `feat/mp-quiz-2026-03-24`. |
||||||
|
- Make small focused commits per task (messages suggested above). Each micro-task should be one commit. |
||||||
|
- PR organization: |
||||||
|
- PR #1 (Batch 1): database.py changes + tests/test_match_mps.py — target only DB helpers and their unit tests. Keep this PR small so backend logic can be reviewed independently. |
||||||
|
- PR #2 (Batch 2): explorer.py UI builder + tests/test_explorer_quiz.py — depends on PR #1; rebase after PR #1 merges or open as stacked PR (base=feat/mp-quiz-2026-03-24). |
||||||
|
- PR #3 (Batch 3): integration+polish tests (tests/test_explorer_quiz_integration.py) and any small fixes discovered during integration testing. |
||||||
|
|
||||||
|
- Review checklist for each PR: |
||||||
|
- Tests covering edge cases (zero-overlap MPs, empty inputs) |
||||||
|
- DB queries use read_only DuckDB connections |
||||||
|
- UI uses st.session_state and @st.cache_data appropriately |
||||||
|
- No production DB writes, no schema changes |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Risks & Mitigations (short) |
||||||
|
|
||||||
|
- Performance: selecting motions across the entire motions table could be heavy. Mitigation: seed with top-N controversial motions and limit choose_discriminating_motions to motions that have mp_votes rows for the candidate MPs only. |
||||||
|
- Data quality: MPs with identical votes will remain indistinguishable — surface clearly to user. Tests include that scenario. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Task checklist for implementers (copy/paste friendly) |
||||||
|
|
||||||
|
- [ ] Task 1.1: Modify database.py — implement match_mps_for_votes & choose_discriminating_motions. Add tests in tests/test_match_mps.py. (3.0–4.5h) |
||||||
|
- [ ] Task 1.2: Add this plan file. (0.25–0.5h) |
||||||
|
- [ ] Task 2.1: Modify explorer.py — add build_mp_quiz_tab and wire into tabs. Add tests in tests/test_explorer_quiz.py. (2.0–4.0h) |
||||||
|
- [ ] Task 3.1: Add integration test tests/test_explorer_quiz_integration.py to exercise quiz flow. (2.0–3.0h) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
If you run into ambiguous input normalization details or DB edge-cases, follow the choices documented above (explicit normalization mapping, exclude zero-overlap MPs, use entropy scoring). If you encounter a blocker (e.g. missing mp_votes data in test fixtures), create small test fixtures using MotionDatabase.insert_motion and insert_mp_vote in the test setup. |
||||||
|
|
||||||
|
Good luck — keep PRs small and tests fast. |
||||||
Loading…
Reference in new issue