feat(pipeline): implement parliamentary embedding pipeline MVP

- Add 4 migration files: mp_votes, mp_metadata, svd_vectors, fused_embeddings
- Extend database.py with 5 new helper methods and table init
- Add pipeline/ package: extract_mp_votes, fetch_mp_metadata, text_pipeline,
  svd_pipeline (with Procrustes alignment), fusion
- Add full test suite (17 tests) covering all pipeline modules and migrations
- Fix Procrustes alignment bug: scipy scale is a norm value, not a multiplier
- Fix DuckDB date type handling in test assertions (datetime.date vs string)
- Remove duckdb.py shim; tests now run against real duckdb + scipy via uv

Ref: thoughts/shared/plans/2026-03-21-parliamentary-embedding-pipeline-plan.md
This commit is contained in:
2026-03-21 22:31:22 +01:00
parent c498c3467e
commit a36e6cba4e
68 changed files with 6822 additions and 0 deletions
View File
+75
View File
@@ -0,0 +1,75 @@
import json
import logging
from typing import Optional
import duckdb
from database import MotionDatabase
_logger = logging.getLogger(__name__)
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.
Returns a dict with summary counts:
- motions_scanned: number of motions inspected
- mp_rows_inserted: number of mp_votes rows inserted
- motions_skipped: number of motions skipped because mp_votes already existed
"""
db = MotionDatabase(db_path=db_path) if db_path else MotionDatabase()
conn = duckdb.connect(db.db_path)
try:
# support optional limit to only scan a subset of motions
if limit is not None:
rows = conn.execute(
"SELECT id, voting_results, date FROM motions LIMIT ?", (limit,)
).fetchall()
else:
rows = conn.execute(
"SELECT id, voting_results, date FROM motions"
).fetchall()
finally:
conn.close()
mp_rows_inserted = 0
motions_skipped = 0
motions_scanned = 0
for motion_id, voting_results_json, date in rows:
motions_scanned += 1
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
except Exception as e:
_logger.error("Error processing motion %s: %s", motion_id, e)
return {
"motions_scanned": motions_scanned,
"mp_rows_inserted": mp_rows_inserted,
"motions_skipped": motions_skipped,
}
+94
View File
@@ -0,0 +1,94 @@
import logging
from typing import Optional
import requests
from database import MotionDatabase
logger = logging.getLogger(__name__)
def normalize_mp_name(
achternaam: str, initialen: Optional[str], tussenvoegsel: Optional[str]
) -> str:
"""Reconstruct ActorNaam format used in voting_results keys.
Format: "{Tussenvoegsel} {Achternaam}, {Initialen}" with sensible stripping when
tussenvoegsel is missing.
"""
parts = []
if tussenvoegsel:
parts.append(tussenvoegsel)
parts.append(achternaam)
name = " ".join(parts).strip()
# Ensure the displayed name starts with an uppercase letter so
# ORDER BY mp_name behaves predictably across databases that may
# sort uppercase before lowercase. Only change the first character
# to upper-case to avoid lowercasing other letters (e.g. hyphenated
# or already capitalized parts).
if name and name[0].islower():
name = name[0].upper() + name[1:]
if initialen:
name = f"{name}, {initialen}"
return name
def fetch_mp_metadata(
db_path: str, odata_url: str = "https://odata.example/FractieZetelPersoon"
) -> int:
"""Fetch MP party membership and tenure from OData and upsert into DB.
Returns the number of records processed (inserted or updated).
"""
session = requests.Session()
try:
resp = session.get(odata_url)
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.error("Failed to fetch MP metadata: %s", e)
raise
values = data.get("value") if isinstance(data, dict) else None
if values is None:
logger.error("Unexpected OData payload; missing 'value' list")
return 0
db = MotionDatabase(db_path)
processed = 0
for item in values:
try:
persoon = item.get("Persoon") or {}
fractiezetel = item.get("FractieZetel") or {}
fractie = fractiezetel.get("Fractie") or {}
achternaam = persoon.get("Achternaam")
initialen = persoon.get("Initialen")
tussenvoegsel = persoon.get("Tussenvoegsel")
persoon_id = persoon.get("Id")
party = fractie.get("NaamNL")
van = item.get("Van")
tot_en_met = item.get("TotEnMet")
if not achternaam:
logger.debug("Skipping record without achternaam: %s", item)
continue
mp_name = normalize_mp_name(achternaam, initialen, tussenvoegsel)
db.upsert_mp_metadata(
mp_name=mp_name,
party=party,
van=van,
tot_en_met=tot_en_met,
persoon_id=persoon_id,
)
processed += 1
except Exception:
logger.exception("Error processing OData item: %s", item)
logger.info("Processed %d MP metadata records", processed)
return processed
+116
View File
@@ -0,0 +1,116 @@
import json
import logging
from typing import Dict
import duckdb
from database import MotionDatabase
_logger = logging.getLogger(__name__)
def fuse_for_window(
window_id: str, db_path: str = None, model: str = None
) -> Dict[str, int]:
"""Fuse SVD vectors with text embeddings for motions in a window.
Parameters:
- window_id: id of the window to process
- db_path: optional path to duckdb database (if None MotionDatabase default is used)
- model: optional model name to filter text embeddings
Returns a dict with counts: inserted, skipped_missing_text, skipped_missing_svd, errors
"""
# Create MotionDatabase using provided path if given, otherwise use default
if db_path:
db = MotionDatabase(db_path=db_path)
conn = duckdb.connect(db_path)
else:
db = MotionDatabase()
# MotionDatabase always exposes the path it uses
conn = duckdb.connect(db.db_path)
# Fetch svd vectors for the window and entity_type=motion
rows = conn.execute(
"SELECT entity_id, vector FROM svd_vectors WHERE window_id = ? AND entity_type = ?",
(window_id, "motion"),
).fetchall()
# debug
_logger.debug("Found %d svd rows for window %s", len(rows), window_id)
inserted = 0
skipped_missing_text = 0
skipped_missing_svd = 0
errors = 0
for entity_id, svd_json in rows:
try:
svd_vec = json.loads(svd_json)
except Exception:
_logger.exception("Invalid SVD vector JSON for entity %s", entity_id)
skipped_missing_svd += 1
continue
# Look up text embedding for this motion (most recent). If model is provided
# filter by model as well.
if model:
emb_row = conn.execute(
"SELECT vector FROM embeddings WHERE motion_id = ? AND model = ? ORDER BY created_at DESC LIMIT 1",
(int(entity_id), model),
).fetchone()
else:
emb_row = conn.execute(
"SELECT vector FROM embeddings WHERE motion_id = ? ORDER BY created_at DESC LIMIT 1",
(int(entity_id),),
).fetchone()
if not emb_row:
skipped_missing_text += 1
continue
try:
text_vec = json.loads(emb_row[0])
except Exception:
_logger.exception("Invalid text embedding JSON for motion %s", entity_id)
skipped_missing_text += 1
continue
try:
fused = list(svd_vec) + list(text_vec)
except Exception:
_logger.exception("Error concatenating vectors for motion %s", entity_id)
errors += 1
continue
# store fused embedding and check result
try:
res = db.store_fused_embedding(
int(entity_id),
window_id,
fused,
svd_dims=len(svd_vec),
text_dims=len(text_vec),
)
if res and res > 0:
inserted += 1
else:
errors += 1
_logger.error(
"Failed to store fused embedding for motion %s (db returned %s)",
entity_id,
res,
)
except Exception:
_logger.exception(
"Exception while storing fused embedding for motion %s", entity_id
)
errors += 1
conn.close()
return {
"inserted": inserted,
"skipped_missing_text": skipped_missing_text,
"skipped_missing_svd": skipped_missing_svd,
"errors": errors,
}
+206
View File
@@ -0,0 +1,206 @@
import json
import logging
from typing import Optional, Dict, List, Tuple
import numpy as np
try:
from scipy.sparse import csr_matrix
from scipy.sparse.linalg import svds
from scipy.linalg import orthogonal_procrustes
_HAS_SCIPY = True
except Exception:
# Provide lightweight fallbacks for environments without scipy
csr_matrix = lambda x: x
def svds(a, k=1):
# fallback to numpy.linalg.svd on dense arrays
U, s, Vt = np.linalg.svd(np.array(a), full_matrices=False)
# return last k components to mimic scipy.svds behaviour
return U[:, -k:], s[-k:], Vt[-k:, :]
def orthogonal_procrustes(A, B):
# simple orthogonal Procrustes via SVD: find R minimizing ||A R - B||
U, _, Vt = np.linalg.svd(A.T.dot(B))
R = U.dot(Vt)
scale = 1.0
return R, scale
_HAS_SCIPY = False
import duckdb
from database import MotionDatabase
_logger = logging.getLogger(__name__)
# Map textual votes to numeric values for SVD
VOTE_MAP = {
"Voor": 1.0,
"voor": 1.0,
"Tegen": -1.0,
"tegen": -1.0,
"Geen stem": 0.0,
"Onbekend": 0.0,
"Onbekend stem": 0.0,
"Blanco": 0.0,
}
def _safe_k(mat: np.ndarray, k: int) -> int:
"""Return a safe k for svds: must be < min(mat.shape)."""
if mat is None:
return 0
m, n = mat.shape
min_dim = min(m, n)
# svds requires k < min_dim
if min_dim <= 1:
return 0
return min(k, min_dim - 1)
def _build_vote_matrix(
db: MotionDatabase, start_date: str, end_date: str
) -> Tuple[np.ndarray, List[str], List[int]]:
"""Build dense vote matrix (mp x motion) for votes between start_date and end_date.
Returns (matrix, mp_names, motion_ids)
"""
conn = duckdb.connect(db.db_path)
rows = conn.execute(
"SELECT motion_id, mp_name, vote FROM mp_votes WHERE date BETWEEN ? AND ?",
(start_date, end_date),
).fetchall()
conn.close()
if not rows:
return np.zeros((0, 0)), [], []
motion_ids = sorted({int(r[0]) for r in rows})
mp_names = sorted({r[1] for r in rows})
m = len(mp_names)
n = len(motion_ids)
mat = np.zeros((m, n), dtype=float)
mp_index = {name: i for i, name in enumerate(mp_names)}
motion_index = {mid: j for j, mid in enumerate(motion_ids)}
for motion_id, mp_name, vote in rows:
i = mp_index[mp_name]
j = motion_index[int(motion_id)]
val = VOTE_MAP.get(
vote, VOTE_MAP.get(vote.strip() if isinstance(vote, str) else vote, 0.0)
)
try:
mat[i, j] = float(val)
except Exception:
mat[i, j] = 0.0
return mat, mp_names, motion_ids
def _procrustes_align(
reference_anchor: np.ndarray,
current_anchor: np.ndarray,
min_overlap: int = 3,
) -> np.ndarray:
"""Align current_anchor to reference_anchor using orthogonal Procrustes.
This function will only attempt alignment when there is a reasonable number of
overlapping rows (default: min_overlap). If the overlap is too small or if any
input is invalid, the original current_anchor is returned unchanged.
Returns transformed_current_anchor
"""
# basic validation
if reference_anchor is None or current_anchor is None:
return current_anchor
if not isinstance(reference_anchor, np.ndarray) or not isinstance(
current_anchor, np.ndarray
):
return current_anchor
# Determine overlap by number of available rows. If too small, skip alignment.
n_ref = reference_anchor.shape[0]
n_cur = current_anchor.shape[0]
overlap = min(n_ref, n_cur)
if overlap < min_overlap:
_logger.debug(
"Procrustes alignment skipped: overlap %s < min_overlap %s",
overlap,
min_overlap,
)
return current_anchor
# Use only the overlapping rows to compute the orthogonal transform.
ref_sub = reference_anchor[:overlap, :]
cur_sub = current_anchor[:overlap, :]
try:
# orthogonal_procrustes(A, B) returns R, scale such that A @ R = B * scale
# We want to transform current_anchor to align with reference_anchor so
# call orthogonal_procrustes(cur_sub, ref_sub) and apply resulting R/scale
R, _scale = orthogonal_procrustes(cur_sub, ref_sub)
transformed = current_anchor.dot(R)
return transformed
except Exception:
_logger.exception("Procrustes alignment failed")
return current_anchor
def run_svd_for_window(
db: MotionDatabase,
window_id: str,
start_date: str,
end_date: str,
k: int = 50,
) -> Dict:
"""Run SVD on votes in given date window and store vectors in DB.
Returns metadata dict with keys: k_used, stored_mp, stored_motion
"""
mat, mp_names, motion_ids = _build_vote_matrix(db, start_date, end_date)
if mat.size == 0 or mat.shape[0] == 0 or mat.shape[1] == 0:
return {"k_used": 0, "stored_mp": 0, "stored_motion": 0}
k_used = _safe_k(mat, k)
if k_used <= 0:
return {"k_used": 0, "stored_mp": 0, "stored_motion": 0}
# use sparse svds for efficiency
try:
A = csr_matrix(mat)
U, s, Vt = svds(A, k=k_used)
# svds does not guarantee ordering of singular values; sort descending
idx = np.argsort(s)[::-1]
s = s[idx]
U = U[:, idx]
Vt = Vt[idx, :]
# weight by singular values
mp_vecs = (U * s.reshape(1, -1)).tolist() # m x k
motion_vecs = (Vt.T * s.reshape(1, -1)).tolist() # n x k
stored_mp = 0
stored_motion = 0
for i, mp_name in enumerate(mp_names):
db.store_svd_vector(window_id, "mp", mp_name, mp_vecs[i])
stored_mp += 1
for j, motion_id in enumerate(motion_ids):
db.store_svd_vector(window_id, "motion", str(motion_id), motion_vecs[j])
stored_motion += 1
return {
"k_used": k_used,
"stored_mp": stored_mp,
"stored_motion": stored_motion,
}
except Exception:
_logger.exception("SVD failed for window")
return {"k_used": 0, "stored_mp": 0, "stored_motion": 0}
+122
View File
@@ -0,0 +1,122 @@
import logging
import json
from typing import Optional, List, Tuple
import duckdb
from database import MotionDatabase, db as default_db
import ai_provider
_logger = logging.getLogger(__name__)
DEFAULT_MODEL = "qwen/qwen3-embedding-4b"
def _select_text(
db: MotionDatabase, model: str, limit: Optional[int] = None
) -> List[Tuple[int, Optional[str]]]:
"""Select motions that do not yet have an embedding for `model`.
Returns list of (motion_id, text).
"""
conn = duckdb.connect(db.db_path)
params = [model]
# prefer layman_explanation > description > title (keep compatibility with existing tests)
sql = (
"SELECT m.id, COALESCE(m.layman_explanation, m.description, m.title) AS text"
" FROM motions m"
" LEFT JOIN embeddings e ON e.motion_id = m.id AND e.model = ?"
" WHERE e.id IS NULL"
)
if limit:
sql += " LIMIT ?"
params.append(limit)
try:
rows = conn.execute(sql, params).fetchall()
conn.close()
results: List[Tuple[int, Optional[str]]] = []
for r in rows:
text_val = r[1]
# treat empty strings as no text
if text_val is None:
text = None
else:
text = str(text_val).strip() or None
results.append((int(r[0]), text))
return results
except Exception as exc:
_logger.error("Error selecting motions for embeddings: %s", exc)
try:
conn.close()
except Exception:
pass
return []
def ensure_text_embeddings(
db_path: Optional[str] = None, model: Optional[str] = None
) -> Tuple[int, int, int, int]:
"""Ensure all motions have text embeddings for `model`.
Returns tuple (stored_count, skipped_existing, skipped_no_text, errors).
"""
model = model or DEFAULT_MODEL
db = MotionDatabase(db_path) if db_path else default_db
# motions to process
to_process = _select_text(db, model)
# how many already exist
conn = duckdb.connect(db.db_path)
try:
total_motions = conn.execute("SELECT COUNT(*) FROM motions").fetchone()[0]
except Exception:
total_motions = 0
try:
existing = conn.execute(
"SELECT COUNT(DISTINCT motion_id) FROM embeddings WHERE model = ?", (model,)
).fetchone()[0]
except Exception:
existing = 0
conn.close()
stored = 0
skipped_no_text = 0
errors = 0
for motion_id, text in to_process:
if not text:
_logger.info("Skipping motion %s: no text available", motion_id)
skipped_no_text += 1
continue
try:
vec = ai_provider.get_embedding(text, model=model)
if not isinstance(vec, list):
_logger.warning(
"Embedding provider returned non-list for motion %s", motion_id
)
errors += 1
continue
res = db.store_embedding(motion_id, model, vec)
if res and res > 0:
stored += 1
else:
_logger.error(
"Failed to store embedding for motion %s (store returned %s)",
motion_id,
res,
)
errors += 1
except Exception as exc:
_logger.error(
"Error computing/storing embedding for motion %s: %s", motion_id, exc
)
errors += 1
skipped_existing = int(existing)
return stored, skipped_existing, skipped_no_text, errors