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
@@ -0,0 +1,58 @@
import os
import pathlib
import sqlite3
import re
import pytest
def test_migration_file_exists_and_name():
migrations_dir = pathlib.Path("migrations")
expected_name = "2026-03-22-add-audit-events.sql"
migration_path = migrations_dir / expected_name
# File must exist
assert migration_path.exists(), f"Migration file {migration_path} does not exist"
# Name sanity check
assert migration_path.name == expected_name
def _strip_sql_comments(sql_text: str) -> str:
# Remove SQL single-line comments -- ... and C-style /* ... */
# Use multiline-aware single-line removal for safety.
no_single = re.sub(r"--.*?$", "", sql_text, flags=re.MULTILINE)
no_block = re.sub(r"/\*.*?\*/", "", no_single, flags=re.DOTALL)
return no_block.strip()
def test_optional_apply_sql_if_db_available():
"""
If TEST_DB_URL is provided, attempt to apply the SQL.
For safety this test will skip applying when the SQL is empty or commented out.
Only sqlite URLs (sqlite:///path/to/db) are attempted here to avoid adding
extra dependencies; other URL schemes will cause the test to be skipped.
"""
db_url = os.environ.get("TEST_DB_URL")
if not db_url:
pytest.skip("TEST_DB_URL not set - skipping DB application")
migration_path = pathlib.Path("migrations") / "2026-03-22-add-audit-events.sql"
sql = migration_path.read_text(encoding="utf8")
stripped = _strip_sql_comments(sql)
if not stripped:
pytest.skip("Migration SQL is empty or commented out - skipping application")
# Only handle sqlite URLs here
if db_url.startswith("sqlite:///"):
db_path = db_url.replace("sqlite:///", "", 1)
try:
conn = sqlite3.connect(db_path)
try:
conn.executescript(sql)
finally:
conn.close()
except Exception as e:
pytest.skip(f"Could not apply SQL to sqlite DB: {e}")
else:
pytest.skip(f"TEST_DB_URL set but scheme not supported by this test: {db_url}")
@@ -0,0 +1,85 @@
import os
import re
import pathlib
import pytest
# small migration filename/header tests; keep imports minimal
MIGRATION_FILENAME = "2026-03-22-add-similarity-cache.sql"
MIGRATION_PATH = pathlib.Path("migrations") / MIGRATION_FILENAME
def _strip_sql_comments(sql: str) -> str:
"""Remove SQL single-line (-- ...) and C-style (/* ... */) comments.
This is a best-effort stripper sufficient for the test's purpose.
"""
# remove block comments
sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.S)
# remove line comments
sql = re.sub(r"--.*?$", "", sql, flags=re.M)
return sql.strip()
def test_migration_file_exists_and_header():
# file must exist
assert MIGRATION_PATH.exists(), f"Migration file {MIGRATION_PATH} not found"
text = MIGRATION_PATH.read_text(encoding="utf8")
# header should reference the filename and purpose
assert MIGRATION_FILENAME in text.splitlines()[0], (
"First line should include the filename"
)
assert "similarity" in text.lower(), "Header should mention similarity"
def test_optional_apply_migration_safe():
# If TEST_DB_URL is set, try to apply the SQL only if it contains non-comment statements.
db_url = os.environ.get("TEST_DB_URL")
sql = MIGRATION_PATH.read_text(encoding="utf8")
stripped = _strip_sql_comments(sql)
# If there is no DB url, consider this a filename/header validation test only.
if not db_url:
pytest.skip("TEST_DB_URL not set; skipping DB apply step")
# If the SQL is empty (only comments), nothing to apply — test passes.
if not stripped:
pytest.skip("Migration contains no executable SQL; nothing to apply")
# Otherwise attempt to execute the SQL. Be conservative: if drivers are missing or
# connection fails, skip the test rather than failing CI. Only unexpected errors
# during execution should fail the test.
try:
if db_url.startswith("sqlite:"):
import sqlite3
# sqlite URL might be sqlite:///path or sqlite:///:memory:
path = db_url.split("sqlite:", 1)[1]
# normalize prefixes like ///
path = path.lstrip("/") or ":memory:"
conn = sqlite3.connect(path)
try:
conn.executescript(sql)
finally:
conn.close()
elif db_url.startswith("postgresql:") or db_url.startswith("postgres:"):
try:
import psycopg2
except Exception as e: # pragma: no cover - driver may be absent in CI
pytest.skip(f"psycopg2 not available: {e}")
# psycopg2 accepts a DSN; rely on that here.
conn = psycopg2.connect(db_url)
try:
cur = conn.cursor()
cur.execute(sql)
conn.commit()
finally:
conn.close()
else:
pytest.skip(f"DB URL scheme not supported by this test: {db_url}")
except Exception as exc:
# Unexpected error while applying SQL should fail the test.
raise
@@ -0,0 +1,29 @@
"""Smoke test for the migration test_db fixture.
This test imports the `test_db` fixture and asserts expected behavior in two
cases:
- If the environment variable TEST_DB_URL is not set, the fixture should yield
None.
- If TEST_DB_URL is set, the fixture should yield a connection-like object
(we check for an object with a `cursor` attribute or the sqlite3 connection
type).
"""
import os
import types
import pytest
def test_migration_fixture_smoke(test_db):
"""Smoke test ensuring the test_db fixture yields expected values."""
url = os.environ.get("TEST_DB_URL")
if not url:
assert test_db is None
else:
# For sqlite we expect a sqlite3.Connection which has a 'cursor'
# method. Be permissive and accept any object with a 'cursor'
# attribute or callable.
assert test_db is not None
assert hasattr(test_db, "cursor") or hasattr(test_db, "execute")