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:
@@ -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")
|
||||
Reference in New Issue
Block a user