fix(pipeline): fix API pagination, add skip_details fast path, bulk mp_votes insert

- _get_voting_records returns (records, besluit_meta) tuple; paginate via Besluit?expand=Stemming (469/mo vs 8400)
- get_motions(skip_details=True) bypasses per-motion detail chain (3 HTTP calls/motion)
- extract_mp_votes rewritten: bulk DataFrame insert (80k rows in 1.9s), includes party-level actors
- run_pipeline.py fixed: pass db_path not db, handle dict/int return types
- download_past_year.py: skip_details=True default, limit-per-chunk default 50000
This commit is contained in:
2026-03-21 23:24:06 +01:00
parent f2a831dfcf
commit 847b783877
4 changed files with 304 additions and 74 deletions
+48 -28
View File
@@ -3,6 +3,7 @@ import logging
from typing import Optional
import duckdb
import pandas as pd
from database import MotionDatabase
@@ -13,6 +14,11 @@ def extract_mp_votes(db_path: Optional[str] = None, limit: Optional[int] = None)
"""Extract individual MP votes from motions.voting_results and store them
in the mp_votes table.
Handles both individual MP votes (ActorNaam contains comma) and party-level
votes (ActorNaam has no comma) by treating party names as actors.
Uses a single DuckDB connection with DataFrame bulk insert for performance.
Returns a dict with summary counts:
- motions_scanned: number of motions inspected
- mp_rows_inserted: number of mp_votes rows inserted
@@ -31,42 +37,56 @@ def extract_mp_votes(db_path: Optional[str] = None, limit: Optional[int] = None)
rows = conn.execute(
"SELECT id, voting_results, date FROM motions"
).fetchall()
finally:
except Exception as e:
conn.close()
raise e
mp_rows_inserted = 0
motions_skipped = 0
motions_scanned = 0
motions_skipped = 0
batch = []
for motion_id, voting_results_json, date in rows:
motions_scanned += 1
# Check if mp_votes already exist for this motion
existing = conn.execute(
"SELECT COUNT(*) FROM mp_votes WHERE motion_id = ?", (motion_id,)
).fetchone()
if existing and existing[0] > 0:
_logger.debug("Skipping motion %s, mp_votes already exist", motion_id)
motions_skipped += 1
continue
# voting_results may be stored as JSON text or as native JSON; ensure it's a dict
if isinstance(voting_results_json, str):
voting_results = json.loads(voting_results_json)
else:
voting_results = voting_results_json
for actor, vote in (voting_results or {}).items():
# Individual MP names contain a comma (e.g. "Last, F.")
# Party names have no comma (e.g. "VVD", "GroenLinks-PvdA")
party = None if "," in actor else actor
batch.append((motion_id, actor, party, vote, str(date) if date else None))
# Bulk insert via DataFrame for performance (avoids per-row connection overhead)
mp_rows_inserted = 0
if batch:
try:
if db.mp_votes_exists_for_motion(motion_id):
_logger.debug(
"Skipping motion %s because mp_votes already exist", motion_id
)
motions_skipped += 1
continue
# voting_results may be stored as JSON text or as native JSON; ensure it's a dict
if isinstance(voting_results_json, str):
voting_results = json.loads(voting_results_json)
else:
voting_results = voting_results_json
for actor, vote in (voting_results or {}).items():
# Individual MP names contain a comma (e.g. "Last, F.")
if "," not in actor:
continue
inserted_id = db.insert_mp_vote(
motion_id=motion_id, mp_name=actor, vote=vote, date=date, party=None
)
if inserted_id and inserted_id > 0:
mp_rows_inserted += 1
df = pd.DataFrame(
batch, columns=["motion_id", "mp_name", "party", "vote", "date"]
)
conn.execute(
"INSERT INTO mp_votes (motion_id, mp_name, party, vote, date) SELECT * FROM df"
)
mp_rows_inserted = len(batch)
_logger.info("Bulk inserted %d mp_votes rows", mp_rows_inserted)
except Exception as e:
_logger.error("Error processing motion %s: %s", motion_id, e)
_logger.error("Bulk insert failed: %s", e)
else:
_logger.info("No new mp_votes rows to insert")
conn.close()
return {
"motions_scanned": motions_scanned,
+12 -8
View File
@@ -115,8 +115,8 @@ def run(args: argparse.Namespace) -> int:
if not dry_run:
from pipeline.fetch_mp_metadata import fetch_mp_metadata
fetched, skipped = fetch_mp_metadata(db)
_logger.info(" mp_metadata: fetched=%d skipped=%d", fetched, skipped)
n = fetch_mp_metadata(db_path=db.db_path)
_logger.info(" mp_metadata: processed=%d", n)
else:
_logger.info(" [dry-run] would call fetch_mp_metadata(db)")
else:
@@ -128,9 +128,12 @@ def run(args: argparse.Namespace) -> int:
if not dry_run:
from pipeline.extract_mp_votes import extract_mp_votes
inserted, skipped = extract_mp_votes(db)
result = extract_mp_votes(db_path=db.db_path)
_logger.info(
" mp_votes: inserted=%d motions skipped=%d", inserted, skipped
" mp_votes: inserted=%d motions_scanned=%d skipped=%d",
result["mp_rows_inserted"],
result["motions_scanned"],
result["motions_skipped"],
)
else:
_logger.info(" [dry-run] would call extract_mp_votes(db)")
@@ -199,11 +202,12 @@ def run(args: argparse.Namespace) -> int:
model=args.text_model,
)
_logger.info(
" window %s: fused=%d skipped_no_svd=%d skipped_no_text=%d",
" window %s: fused=%d skipped_no_svd=%d skipped_no_text=%d errors=%d",
window_id,
result["fused"],
result.get("skipped_no_svd", 0),
result.get("skipped_no_text", 0),
result.get("inserted", 0),
result.get("skipped_missing_svd", 0),
result.get("skipped_missing_text", 0),
result.get("errors", 0),
)
else:
_logger.info(" [dry-run] would fuse window %s", window_id)