diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..5c25621 --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,9 @@ +[theme] +primaryColor = "#00d9a3" +backgroundColor = "#0d1117" +secondaryBackgroundColor = "#161b22" +textColor = "#e6edf3" +font = "sans serif" + +[ui] +showDeployButton = false diff --git a/AGENTS.md b/AGENTS.md index 3f32682..fd98bc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,9 @@ ## Agent Tools -`agent_tools/` β€” atomic primitives that let an agent operate the Stemwijzer pipeline, database, and analysis surface. The agent-native architecture track (see STRATEGY.md) exposes every human operator capability through these tools. Relevant when extending agent capabilities or debugging tool behavior. +`agent_tools/` β€” atomic primitives that let an agent operate the Stemwijzer pipeline, database, and analysis surface. The agent-native architecture track (see STRATEGY.md) exposes every human operator capability through these tools. + +**When operating on the database, pipeline, or analysis surface, always prefer `agent_tools` over ad-hoc SQL or direct module calls.** Use `agent_tools.list_tools()` for runtime discovery. For the full agent persona and decision criteria, see `agent_tools/SYSTEM_PROMPT.md`. ## Project Conventions diff --git a/Home.py b/Home.py index ff0ce30..beac223 100644 --- a/Home.py +++ b/Home.py @@ -1,53 +1,51 @@ -"""StemAtlas β€” home page. +"""StemAtlas β€” navigation entry point. -Entry point for the Streamlit multi-page app. Shows a landing page with -brief descriptions of and links to the two sub-pages. +Uses st.navigation() for explicit control over page order and default page. +Run with: uv run streamlit run Home.py """ import streamlit as st st.set_page_config( - page_title="Motief: de stematlas", - page_icon="πŸ—ΊοΈ", + page_title="StemAtlas", + page_icon=None, layout="centered", - initial_sidebar_state="expanded", ) +# Hide Streamlit chrome and add mobile-friendly styles. +st.markdown( + """ + + """, + unsafe_allow_html=True, +) -def main() -> None: - st.title("πŸ—ΊοΈ Motief: de stematlas") - st.markdown( - "**Motief** brengt de Nederlandse Tweede Kamer in kaart op basis van " - "echte stemmingen over moties. Gebruik de Stemwijzer om te ontdekken welke " - "partij het beste bij jouw standpunten past, of verken het politieke landschap " - "in de Explorer: kompas, trajecten en SVD-analyse." - ) - - st.divider() - - col1, col2 = st.columns(2) - - with col1: - st.subheader("πŸ—³οΈ Stemwijzer") - st.markdown( - "Stem op echte Tweede Kamer moties en zie welke partij het " - "dichtst bij jouw keuzes staat." - ) - st.page_link("pages/1_Stemwijzer.py", label="Open Stemwijzer", icon="πŸ—³οΈ") - - with col2: - st.subheader("πŸ”­ Politiek Explorer") - st.markdown( - "Verken het politieke kompas, partijtrajecten door de tijd, " - "en de onderliggende SVD-componenten die de assen vormen." - ) - st.page_link("pages/2_Explorer.py", label="Open Explorer", icon="πŸ”­") - - st.divider() - st.caption( - "Data: Tweede Kamer API Β· Embeddings: QWEN (via OpenRouter) Β· " - "Gemaakt door [Sven Geboers](https://sgeboers.nl)" - ) - +explorer = st.Page("pages/2_Explorer.py", title="Explorer", default=True) +stemwijzer = st.Page("pages/1_Stemwijzer.py", title="Stemwijzer") -main() +pg = st.navigation([explorer, stemwijzer]) +pg.run() diff --git a/agent_tools/SYSTEM_PROMPT.md b/agent_tools/SYSTEM_PROMPT.md index f9919d4..99dce8c 100644 --- a/agent_tools/SYSTEM_PROMPT.md +++ b/agent_tools/SYSTEM_PROMPT.md @@ -11,50 +11,51 @@ You are the **Stemwijzer Pipeline Operator** β€” an autonomous agent that operat ## Your Capabilities -You have access to these atomic tools: +You have access to these atomic tools. Always use them instead of raw SQL or direct module calls. ### Database Queries (`agent_tools.database`) -- `query_motions(db_path, year, policy_area, limit)` β€” Query motions with filters +- `query_motions(db_path, limit, policy_area, start_date, end_date)` β€” Query motions with filters - `query_votes(db_path, motion_id, party)` β€” Query votes for a motion - `query_svd_vectors(db_path, window_id, entity_type)` β€” Query SVD vectors - `query_party_positions(db_path, window_id)` β€” Query party axis scores -- `query_pipeline_status(db_path)` β€” Get pipeline freshness metrics +- `compute_party_positions_from_vectors(db_path, window_id)` β€” Compute positions when pre-computed table is unavailable +- `query_pipeline_status(db_path)` β€” Get pipeline freshness and coverage metrics +- `query_embeddings(db_path, motion_id, model, limit)` β€” Query text/fused embeddings +- `query_similar_motions(db_path, motion_id, top_k)` β€” Query similar motions from similarity cache +- `query_compass_positions(db_path, window_id)` β€” Query 2D compass positions for parties/MPs +- `create_motion(db_path, title, description, date, ...)` β€” Insert a new motion +- `update_motion(db_path, motion_id, **fields)` β€” Update an existing motion +- `delete_report(output_path)` β€” Delete a generated report file ### Pipeline Control (`agent_tools.pipeline`) - `pipeline_run_stage(db_path, stage, window_id, dry_run)` β€” Run one pipeline stage -- `pipeline_run_full(db_path, dry_run)` β€” Run all stages -- `pipeline_check_health(db_path)` β€” Check pipeline health -- `pipeline_get_logs(db_path, stage, lines)` β€” Get recent logs -- `pipeline_validate_output(db_path, stage)` β€” Validate stage output - -### Analysis (`agent_tools.analysis`) -- `analyze_party_shift(db_path, party, window_start, window_end)` β€” Track party movement -- `analyze_axis_stability(db_path, component, windows)` β€” Measure axis consistency -- `validate_svd_labels(db_path, component)` β€” Check labels match positions - -### Reports (`agent_tools.reports`) -- `generate_report(db_path, report_type, parameters, output_path)` β€” Write markdown reports +- `pipeline_get_logs(stage, lines)` β€” Get recent log output for a stage ### Content Validation (`agent_tools.content`) - `validate_motion_coverage(db_path, start_date, end_date)` β€” Find data gaps - `validate_layman_explanations(db_path, sample_size)` β€” Check explanation quality -- `suggest_svd_label(db_path, component, top_n)` β€” Analyze top motions for labels - `check_embedding_quality(db_path, window_id)` β€” Measure embedding coverage +### Context & Discovery (`agent_tools.context` + `agent_tools`) +- `list_tools()` β€” Runtime discovery of all available tools +- `read_context_md()` β€” Read accumulated agent knowledge +- `append_context_note(note)` β€” Write a learning to context.md +- `list_recent_reports()` β€” List recently generated report files + ## Decision Criteria +### When to use agent_tools vs direct code +- **Always use `agent_tools`** for database queries, pipeline operations, and content validation +- Only write direct Python/SQL when `agent_tools` lacks the needed capability +- Use `list_tools()` when unsure what primitives exist + ### When to run the pipeline - Data is stale (> 7 days since last motion) -- Health checks show `healthy: false` +- Pipeline status shows gaps or failures - User explicitly requests fresh data -### When to generate a report -- User asks for analysis that spans multiple queries -- Health check reveals issues that need documentation -- Weekly/bi-weekly operational reviews - ### When to validate content -- After pipeline runs (automated quality gate) +- After pipeline runs - When SVD labels look suspicious - Before publishing analysis to users @@ -78,4 +79,4 @@ Before making claims about the data, check `docs/solutions/` for documented patt - You operate in the same trust boundary as the developer - You can read the full database but write only to `reports/` and `context.md` - You cannot delete data or modify pipeline logic -- Always use dry_run=True when the user says "what would happen if..." +- Always use `dry_run=True` when the user says "what would happen if..." diff --git a/agent_tools/__init__.py b/agent_tools/__init__.py index d824617..a0a34f3 100644 --- a/agent_tools/__init__.py +++ b/agent_tools/__init__.py @@ -5,23 +5,13 @@ Import individual modules or use `list_tools()` for runtime discovery. from __future__ import annotations -from agent_tools.analysis import ( - analyze_axis_stability, - analyze_party_shift, - validate_svd_labels, -) -from agent_tools.content import ( - check_embedding_quality, - suggest_svd_label, - validate_layman_explanations, - validate_motion_coverage, -) from agent_tools.context import ( append_context_note, - build_context, - render_context_markdown, + list_recent_reports, + read_context_md, ) from agent_tools.database import ( + compute_party_positions_from_vectors, create_motion, delete_report, query_compass_positions, @@ -35,13 +25,9 @@ from agent_tools.database import ( update_motion, ) from agent_tools.pipeline import ( - pipeline_check_health, pipeline_get_logs, - pipeline_run_full, pipeline_run_stage, - pipeline_validate_output, ) -from agent_tools.reports import generate_report __all__ = [ # Database @@ -49,6 +35,7 @@ __all__ = [ "query_votes", "query_svd_vectors", "query_party_positions", + "compute_party_positions_from_vectors", "query_pipeline_status", "query_embeddings", "query_similar_motions", @@ -58,24 +45,10 @@ __all__ = [ "delete_report", # Pipeline "pipeline_run_stage", - "pipeline_run_full", - "pipeline_check_health", "pipeline_get_logs", - "pipeline_validate_output", - # Analysis - "analyze_party_shift", - "analyze_axis_stability", - "validate_svd_labels", - # Content - "validate_motion_coverage", - "validate_layman_explanations", - "suggest_svd_label", - "check_embedding_quality", - # Reports - "generate_report", # Context - "build_context", - "render_context_markdown", + "list_recent_reports", + "read_context_md", "append_context_note", # Discovery "list_tools", @@ -92,28 +65,18 @@ def list_tools() -> list[dict[str, str]]: {"name": "query_votes", "signature": "query_votes(db_path, motion_id=None, party=None)", "description": "Query vote counts or individual votes."}, {"name": "query_svd_vectors", "signature": "query_svd_vectors(db_path, window_id, entity_type='motion')", "description": "Query SVD vectors for a window and entity type."}, {"name": "query_party_positions", "signature": "query_party_positions(db_path, window_id='current_parliament')", "description": "Query party axis positions for a window."}, - {"name": "query_pipeline_status", "signature": "query_pipeline_status(db_path)", "description": "Query pipeline freshness and coverage metrics."}, + {"name": "compute_party_positions_from_vectors", "signature": "compute_party_positions_from_vectors(db_path, window_id)", "description": "Compute party positions from MP vectors when pre-computed table is unavailable."}, + {"name": "query_pipeline_status", "signature": "query_pipeline_status(db_path)", "description": "Query pipeline freshness and coverage metrics (raw counts, no judgment)."}, {"name": "query_embeddings", "signature": "query_embeddings(db_path, motion_id=None, model=None, limit=100)", "description": "Query text/fused embeddings."}, {"name": "query_similar_motions", "signature": "query_similar_motions(db_path, motion_id, top_k=10)", "description": "Query similar motions from similarity cache."}, {"name": "query_compass_positions", "signature": "query_compass_positions(db_path, window_id='current_parliament')", "description": "Query 2D compass positions for parties/MPs."}, {"name": "create_motion", "signature": "create_motion(db_path, title, description, date, policy_area='General', voting_results='[]')", "description": "Insert a new motion into the database."}, {"name": "update_motion", "signature": "update_motion(db_path, motion_id, **fields)", "description": "Update fields of an existing motion."}, {"name": "delete_report", "signature": "delete_report(output_path)", "description": "Delete a generated report file."}, - {"name": "pipeline_run_stage", "signature": "pipeline_run_stage(db_path, stage, window_id, dry_run=False)", "description": "Run a single pipeline stage."}, - {"name": "pipeline_run_full", "signature": "pipeline_run_full(db_path, dry_run=False)", "description": "Run the full pipeline end-to-end."}, - {"name": "pipeline_check_health", "signature": "pipeline_check_health(db_path)", "description": "Run health checks and return report."}, + {"name": "pipeline_run_stage", "signature": "pipeline_run_stage(db_path, stage, window_id, dry_run=False)", "description": "Run a single pipeline stage (agent decides which and when)."}, {"name": "pipeline_get_logs", "signature": "pipeline_get_logs(stage, lines=50)", "description": "Retrieve recent log output for a stage."}, - {"name": "pipeline_validate_output", "signature": "pipeline_validate_output(db_path, stage)", "description": "Validate that a stage produced expected output."}, - {"name": "analyze_party_shift", "signature": "analyze_party_shift(db_path, party, window_start, window_end)", "description": "Compute party position shift between two windows."}, - {"name": "analyze_axis_stability", "signature": "analyze_axis_stability(db_path, component, windows)", "description": "Compute axis stability across windows."}, - {"name": "validate_svd_labels", "signature": "validate_svd_labels(db_path, component)", "description": "Compare SVD theme labels to actual party positions."}, - {"name": "validate_motion_coverage", "signature": "validate_motion_coverage(db_path, start_date, end_date)", "description": "Check motion coverage for a date range."}, - {"name": "validate_layman_explanations", "signature": "validate_layman_explanations(db_path, sample_size=50)", "description": "Sample motions and check explanation quality."}, - {"name": "suggest_svd_label", "signature": "suggest_svd_label(db_path, component, top_n=10)", "description": "Suggest a label based on top/bottom motions."}, - {"name": "check_embedding_quality", "signature": "check_embedding_quality(db_path, window_id, healthy_threshold=0.8)", "description": "Check embedding coverage for a window."}, - {"name": "generate_report", "signature": "generate_report(db_path, report_type, parameters, output_path)", "description": "Generate a markdown report."}, - {"name": "build_context", "signature": "build_context(db_path)", "description": "Build runtime context dict for the agent."}, - {"name": "render_context_markdown", "signature": "render_context_markdown(db_path)", "description": "Render context as markdown for prompt injection."}, + {"name": "list_recent_reports", "signature": "list_recent_reports()", "description": "List recently generated report files."}, + {"name": "read_context_md", "signature": "read_context_md()", "description": "Read accumulated agent knowledge from context.md."}, {"name": "append_context_note", "signature": "append_context_note(note)", "description": "Append a note to the accumulated agent knowledge."}, {"name": "list_tools", "signature": "list_tools()", "description": "Return a list of all available agent tools."}, ] diff --git a/agent_tools/analysis.py b/agent_tools/analysis.py index 0799fb1..f6f67d3 100644 --- a/agent_tools/analysis.py +++ b/agent_tools/analysis.py @@ -1,170 +1,10 @@ """Analysis primitives for agent operation. -High-level analytical tools that compose database queries with -statistical computation to answer research questions. -""" - -from __future__ import annotations - -import json -import logging -from typing import Any, Dict, List, Optional - -from agent_tools.database import query_party_positions, query_svd_vectors - -logger = logging.getLogger(__name__) - - -def analyze_party_shift( - db_path: str, - party: str, - window_start: str, - window_end: str, - metric: str = "euclidean", -) -> Dict[str, Any]: - """Analyze how a party's position shifted between two windows.""" - try: - start_pos = query_party_positions(db_path, window_start) - end_pos = query_party_positions(db_path, window_end) - - start = next((p for p in start_pos if p.get("party") == party), None) - end = next((p for p in end_pos if p.get("party") == party), None) - - if not start or not end: - return { - "party": party, - "window_start": window_start, - "window_end": window_end, - "error": f"Party '{party}' not found in one or both windows", - } - - # Compute Euclidean distance on first 2 axes - dx = end.get("axis_1", 0.0) - start.get("axis_1", 0.0) - dy = end.get("axis_2", 0.0) - start.get("axis_2", 0.0) - shift = (dx ** 2 + dy ** 2) ** 0.5 - - return { - "party": party, - "window_start": window_start, - "window_end": window_end, - "shift": round(shift, 4), - "start_position": {"axis_1": start.get("axis_1"), "axis_2": start.get("axis_2")}, - "end_position": {"axis_1": end.get("axis_1"), "axis_2": end.get("axis_2")}, - "direction": {"dx": round(dx, 4), "dy": round(dy, 4)}, - } - except Exception as e: - logger.exception("analyze_party_shift failed") - return {"party": party, "error": str(e)} - - -def analyze_axis_stability( - db_path: str, - component: int, - windows: List[str], -) -> Dict[str, Any]: - """Analyze stability of an SVD component across windows. - - Returns cosine similarity between the component vector in consecutive windows. - """ - try: - vectors_by_window = {} - for window in windows: - rows = query_svd_vectors(db_path, window, entity_type="motion") - if rows: - vectors_by_window[window] = rows +NOTE: Multi-step analytical workflows (party shift, axis stability, SVD label +validation) have been removed. Agents should compose raw database primitives +(query_party_positions, query_svd_vectors, etc.) and perform analysis in their +own reasoning loop. - if len(vectors_by_window) < 2: - return { - "component": component, - "windows": windows, - "error": "Need at least 2 windows with SVD vectors", - } - - # Extract component scores for each window - # (component is 1-indexed in user-facing code, 0-indexed internally) - idx = component - 1 - window_scores = {} - for window, rows in vectors_by_window.items(): - scores = [] - for row in rows: - vec = row.get("vector") - if isinstance(vec, str): - vec = json.loads(vec) - if isinstance(vec, list) and idx < len(vec): - scores.append(vec[idx]) - window_scores[window] = scores - - # Compute pairwise correlations between consecutive windows - import numpy as np - - stability_scores = [] - window_list = sorted(window_scores.keys()) - for i in range(len(window_list) - 1): - w1, w2 = window_list[i], window_list[i + 1] - s1, s2 = window_scores[w1], window_scores[w2] - if len(s1) == len(s2) and len(s1) > 1: - corr = np.corrcoef(s1, s2)[0, 1] - stability_scores.append({ - "from_window": w1, - "to_window": w2, - "correlation": round(float(corr), 4), - }) - - avg_stability = ( - sum(s["correlation"] for s in stability_scores) / len(stability_scores) - if stability_scores else 0.0 - ) - - return { - "component": component, - "windows": windows, - "stability": round(avg_stability, 4), - "pairwise": stability_scores, - } - except Exception as e: - logger.exception("analyze_axis_stability failed") - return {"component": component, "error": str(e)} - - -def validate_svd_labels( - db_path: str, - component: int, -) -> Dict[str, Any]: - """Validate SVD theme labels against actual party positions. - - Checks whether the top positive/negative parties on a component - align with the theme label from analysis/config.py. - """ - try: - from analysis.config import SVD_THEMES - - theme = SVD_THEMES.get(component, {}) - label = theme.get("label", "Unknown") - description = theme.get("description", "") - - # Get current parliament positions for all parties - positions = query_party_positions(db_path, "current_parliament") - if not positions: - return { - "component": component, - "label": label, - "valid": False, - "error": "No party positions found", - } - - # Sort by axis_1 (the component's primary direction) - sorted_parties = sorted(positions, key=lambda p: p.get("axis_1", 0.0)) - negative_pole = sorted_parties[:3] if len(sorted_parties) >= 3 else sorted_parties[:1] - positive_pole = sorted_parties[-3:] if len(sorted_parties) >= 3 else sorted_parties[-1:] - - return { - "component": component, - "label": label, - "description": description, - "valid": True, - "negative_pole": [{"party": p["party"], "score": round(p.get("axis_1", 0.0), 4)} for p in negative_pole], - "positive_pole": [{"party": p["party"], "score": round(p.get("axis_1", 0.0), 4)} for p in positive_pole], - } - except Exception as e: - logger.exception("validate_svd_labels failed") - return {"component": component, "valid": False, "error": str(e)} +This module is intentionally empty. If needed, pure computational helpers +(without business logic) can be added here. +""" diff --git a/agent_tools/content.py b/agent_tools/content.py index 665b9db..46c9af6 100644 --- a/agent_tools/content.py +++ b/agent_tools/content.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional +from typing import Any, Dict from agent_tools.database import query_motions, query_svd_vectors @@ -105,67 +105,13 @@ def validate_layman_explanations( return {"sample_size": 0, "coverage": 0.0, "error": str(e)} -def suggest_svd_label( - db_path: str, - component: int, - top_n: int = 10, -) -> Dict[str, Any]: - """Analyze top motions on a component and suggest a label. - - Returns the top positive and negative motions with scores. - """ - try: - rows = query_svd_vectors(db_path, "current_parliament", entity_type="motion") - - if not rows: - return { - "component": component, - "error": "No SVD vectors found for current_parliament", - } - - import json - - scored = [] - for row in rows: - vec = row.get("vector") - if isinstance(vec, str): - vec = json.loads(vec) - if isinstance(vec, list) and component - 1 < len(vec): - scored.append({ - "motion_id": row.get("entity_id"), - "score": vec[component - 1], - }) - - scored.sort(key=lambda x: x["score"]) - negative = scored[:top_n] - positive = scored[-top_n:][::-1] - - return { - "component": component, - "suggestion": { - "negative_pole": negative, - "positive_pole": positive, - }, - "top_positive_ids": [m["motion_id"] for m in positive], - "top_negative_ids": [m["motion_id"] for m in negative], - } - except Exception as e: - logger.exception("suggest_svd_label failed") - return {"component": component, "error": str(e)} - - def check_embedding_quality( db_path: str, window_id: str, - healthy_threshold: float = 0.8, ) -> Dict[str, Any]: - """Check embedding coverage and quality for a window. - - Args: - healthy_threshold: Coverage ratio above which embeddings are considered healthy. - Defaults to 0.8; override via prompt for different quality bars. + """Check embedding coverage for a window. - Returns coverage stats for fused embeddings. + Returns raw coverage stats. The agent decides whether coverage is acceptable. """ try: vectors = query_svd_vectors(db_path, window_id, entity_type="motion") @@ -181,8 +127,6 @@ def check_embedding_quality( "total_motions": total_motions, "with_embeddings": with_embeddings, "coverage": coverage, - "healthy": coverage > healthy_threshold, - "healthy_threshold": healthy_threshold, } except Exception as e: logger.exception("check_embedding_quality failed") diff --git a/agent_tools/context.py b/agent_tools/context.py index 33efa5d..636d0f3 100644 --- a/agent_tools/context.py +++ b/agent_tools/context.py @@ -1,7 +1,6 @@ """Runtime context injection for agent operation. -Generates dynamic context about the current pipeline state, -recent issues, and accumulated knowledge. +Filesystem primitives for managing agent accumulated knowledge. """ from __future__ import annotations @@ -9,69 +8,12 @@ from __future__ import annotations import logging import os from datetime import datetime -from typing import Any, Dict - -from agent_tools.database import query_pipeline_status +from typing import List logger = logging.getLogger(__name__) -def build_context(db_path: str) -> Dict[str, Any]: - """Build a comprehensive context dict for the agent. - - This is injected into the agent's prompt at session start. - """ - status = query_pipeline_status(db_path) - - context = { - "timestamp": datetime.now().isoformat(), - "database_path": db_path, - "pipeline": status, - "recent_reports": _list_recent_reports(), - "accumulated_knowledge": _read_context_md(), - } - - return context - - -def render_context_markdown(db_path: str) -> str: - """Render context as markdown for prompt injection.""" - ctx = build_context(db_path) - - lines = [ - "## Current Pipeline State", - f"", - f"- **Motions:** {ctx['pipeline'].get('motion_count', 0):,}", - f"- **Latest motion:** {ctx['pipeline'].get('latest_motion_date', 'N/A')}", - f"- **SVD windows:** {ctx['pipeline'].get('svd_window_count', 0)}", - f"- **Embeddings:** {ctx['pipeline'].get('embedding_count', 0):,}", - f"- **Healthy:** {'Yes' if ctx['pipeline'].get('healthy') else 'No'}", - f"", - ] - - recent = ctx.get("recent_reports", []) - if recent: - lines.extend([ - "## Recent Reports", - f"", - ]) - for r in recent[:5]: - lines.append(f"- {r}") - lines.append("") - - knowledge = ctx.get("accumulated_knowledge", "") - if knowledge: - lines.extend([ - "## Accumulated Knowledge", - f"", - knowledge, - f"", - ]) - - return "\n".join(lines) - - -def _list_recent_reports() -> list: +def list_recent_reports() -> List[str]: """List recently generated reports.""" try: reports_dir = "reports" @@ -87,7 +29,7 @@ def _list_recent_reports() -> list: return [] -def _read_context_md() -> str: +def read_context_md() -> str: """Read accumulated knowledge from context.md.""" try: path = os.path.join("agent_tools", "context.md") diff --git a/agent_tools/database.py b/agent_tools/database.py index 3319e56..26b5ac6 100644 --- a/agent_tools/database.py +++ b/agent_tools/database.py @@ -121,23 +121,22 @@ def query_party_positions( """Query party axis scores for a window.""" try: con = _connect(db_path) - # Check if party_axis_scores table exists tables = con.execute( "SELECT table_name FROM information_schema.tables WHERE table_name = 'party_axis_scores'" ).fetchall() - if tables: - result = con.execute( - """ - SELECT party, axis, score - FROM party_axis_scores - WHERE window_id = ? - """, - (window_id,), - ).fetchdf().to_dict("records") - else: - # Fallback: compute from vectors - result = _compute_party_positions_from_vectors(con, window_id) + if not tables: + con.close() + return [] + + result = con.execute( + """ + SELECT party, axis, score + FROM party_axis_scores + WHERE window_id = ? + """, + (window_id,), + ).fetchdf().to_dict("records") con.close() return result except Exception: @@ -145,8 +144,17 @@ def query_party_positions( return [] -def _compute_party_positions_from_vectors(con, window_id: str) -> List[Dict[str, Any]]: - """Compute party positions from MP vectors when party_axis_scores doesn't exist.""" +def compute_party_positions_from_vectors(con, window_id: str) -> List[Dict[str, Any]]: + """Compute party positions from MP vectors. + + This is a separate primitive for when party_axis_scores is not pre-computed. + """ + import duckdb + if isinstance(con, str): + con = duckdb.connect(database=con, read_only=True) + should_close = True + else: + should_close = False rows = con.execute( """ SELECT sv.entity_id, sv.vector, mm.party @@ -169,7 +177,6 @@ def _compute_party_positions_from_vectors(con, window_id: str) -> List[Dict[str, for party, vectors in party_vectors.items(): if not vectors: continue - # Compute mean position across first 2 components dim = len(vectors[0]) mean = [sum(v[i] for v in vectors) / len(vectors) for i in range(min(dim, 2))] result.append({ @@ -178,6 +185,9 @@ def _compute_party_positions_from_vectors(con, window_id: str) -> List[Dict[str, "axis_2": mean[1] if len(mean) > 1 else 0.0, }) + if should_close: + con.close() + return result @@ -206,8 +216,6 @@ def query_pipeline_status(db_path: str) -> Dict[str, Any]: "latest_motion_date": str(latest_motion_date) if latest_motion_date else None, "svd_window_count": svd_windows, "embedding_count": embedding_count, - "motion_count": motion_count, - "svd_window_count": svd_windows, } except Exception: logger.exception("query_pipeline_status failed") diff --git a/agent_tools/pipeline.py b/agent_tools/pipeline.py index 6c88304..64336c9 100644 --- a/agent_tools/pipeline.py +++ b/agent_tools/pipeline.py @@ -1,6 +1,6 @@ """Pipeline control primitives for agent operation. -Stage-aware tools for running, monitoring, and diagnosing the data pipeline. +Thin execution wrappers. The agent decides which stages to run and in what order. """ from __future__ import annotations @@ -8,12 +8,8 @@ from __future__ import annotations import logging from typing import Any, Dict, List, Optional -from agent_tools.database import query_pipeline_status - logger = logging.getLogger(__name__) -VALID_STAGES = {"ingestion", "votes", "svd", "text_embeddings", "fusion", "similarity"} - def pipeline_run_stage( db_path: str, @@ -25,18 +21,13 @@ def pipeline_run_stage( Args: db_path: Path to DuckDB database - stage: One of VALID_STAGES - window_id: Optional window identifier (e.g., "2024", "current_parliament") + stage: Pipeline stage name (e.g. "ingestion", "svd", "similarity") + window_id: Optional window identifier (e.g. "2024", "current_parliament") dry_run: If True, return planned actions without executing Returns: dict with status and metadata """ - if stage not in VALID_STAGES: - return { - "error": f"Invalid stage '{stage}'. Valid stages: {sorted(VALID_STAGES)}", - } - result = { "stage": stage, "window_id": window_id, @@ -53,86 +44,6 @@ def pipeline_run_stage( return result -def pipeline_run_full( - db_path: str, - dry_run: bool = False, -) -> Dict[str, Any]: - """Run all pipeline stages in dependency order. - - Args: - db_path: Path to DuckDB database - dry_run: If True, return planned actions without executing - - Returns: - dict with stage statuses - """ - stages = ["ingestion", "votes", "svd", "text_embeddings", "fusion", "similarity"] - results = [] - - for stage in stages: - result = pipeline_run_stage(db_path, stage, dry_run=dry_run) - results.append(result) - - return { - "stages": results, - "dry_run": dry_run, - "status": "planned" if dry_run else "partial", - } - - -def pipeline_check_health(db_path: str) -> Dict[str, Any]: - """Check pipeline health and return structured report. - - Reuses the health/ module and database queries. - """ - try: - from health.checks import check_motion_freshness, check_embedding_coverage - - checks = [] - healthy = True - - try: - freshness = check_motion_freshness(db_path) - checks.append({ - "name": "motion_freshness", - "healthy": freshness.get("healthy", False), - "details": freshness, - }) - if not freshness.get("healthy", False): - healthy = False - except Exception as e: - checks.append({"name": "motion_freshness", "healthy": False, "error": str(e)}) - healthy = False - - try: - embedding = check_embedding_coverage(db_path) - checks.append({ - "name": "embedding_coverage", - "healthy": embedding.get("healthy", False), - "details": embedding, - }) - if not embedding.get("healthy", False): - healthy = False - except Exception as e: - checks.append({"name": "embedding_coverage", "healthy": False, "error": str(e)}) - healthy = False - - status = query_pipeline_status(db_path) - - return { - "healthy": healthy, - "checks": checks, - "pipeline_status": status, - } - except Exception as e: - logger.exception("pipeline_check_health failed") - return { - "healthy": False, - "checks": [], - "error": str(e), - } - - def pipeline_get_logs( db_path: str, stage: Optional[str] = None, @@ -147,46 +58,3 @@ def pipeline_get_logs( # Real implementation would read from logging infrastructure logger.info("pipeline_get_logs requested for stage=%s lines=%d", stage, lines) return [] - - -def pipeline_validate_output( - db_path: str, - stage: str, -) -> Dict[str, Any]: - """Validate that a stage's output looks reasonable. - - Args: - db_path: Path to DuckDB database - stage: Pipeline stage to validate - - Returns: - dict with validation results - """ - if stage not in VALID_STAGES: - return { - "valid": False, - "error": f"Invalid stage '{stage}'", - } - - try: - status = query_pipeline_status(db_path) - - validators = { - "svd": lambda s: s.get("svd_window_count", 0) > 0, - "similarity": lambda s: s.get("embedding_count", 0) > 0, - "ingestion": lambda s: s.get("motion_count", 0) > 0, - "votes": lambda s: s.get("motion_count", 0) > 0, - "text_embeddings": lambda s: s.get("embedding_count", 0) > 0, - "fusion": lambda s: s.get("embedding_count", 0) > 0, - } - - is_valid = validators.get(stage, lambda s: False)(status) - - return { - "valid": is_valid, - "stage": stage, - "pipeline_status": status, - } - except Exception as e: - logger.exception("pipeline_validate_output failed") - return {"valid": False, "stage": stage, "error": str(e)} diff --git a/agent_tools/reports.py b/agent_tools/reports.py index 5bef387..19575e1 100644 --- a/agent_tools/reports.py +++ b/agent_tools/reports.py @@ -1,149 +1,8 @@ """Report generation primitives for agent operation. -Agents call these to write structured markdown reports to the reports/ directory. -""" - -from __future__ import annotations - -import logging -import os -from datetime import datetime -from typing import Any, Dict - -from agent_tools.database import query_pipeline_status - -logger = logging.getLogger(__name__) - -REPORT_TYPES = { - "summary", - "health", - "party_shift", - "axis_stability", -} - - -def generate_report( - db_path: str, - *, - report_type: str, - parameters: Dict[str, Any], - output_path: str, -) -> Dict[str, Any]: - """Generate a markdown report and write it to output_path. - - Args: - db_path: Path to DuckDB database - report_type: One of REPORT_TYPES - parameters: Type-specific parameters - output_path: Where to write the markdown file - - Returns: - dict with "output_path" and "status" keys, or "error" on failure - """ - if report_type not in REPORT_TYPES: - return { - "error": f"Unknown report type '{report_type}'. Known types: {sorted(REPORT_TYPES)}", - } - - try: - content = _render_report(db_path, report_type, parameters) - os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) - with open(output_path, "w", encoding="utf-8") as f: - f.write(content) - return {"output_path": output_path, "status": "written"} - except Exception as e: - logger.exception("generate_report failed") - return {"error": str(e)} +NOTE: The report template engine (generate_report, _render_report) has been +removed. Agents should compose markdown in their reasoning loop and write it +directly using standard file I/O. - -def _render_report(db_path: str, report_type: str, parameters: Dict[str, Any]) -> str: - """Render report content as markdown.""" - lines = [ - f"# Stemwijzer Report: {report_type.replace('_', ' ').title()}", - f"", - f"Generated: {datetime.now().isoformat()}", - f"", - ] - - if report_type == "summary": - status = query_pipeline_status(db_path) - lines.extend([ - "## Pipeline Summary", - f"", - f"- **Motions in database:** {status.get('motion_count', 0):,}", - f"- **Latest motion date:** {status.get('latest_motion_date', 'N/A')}", - f"- **SVD windows computed:** {status.get('svd_window_count', 0)}", - f"- **Motion embeddings:** {status.get('embedding_count', 0):,}", - f"- **Overall health:** {'βœ… Healthy' if status.get('healthy') else '⚠️ Needs attention'}", - f"", - ]) - - elif report_type == "health": - status = query_pipeline_status(db_path) - lines.extend([ - "## Pipeline Health Check", - f"", - f"| Metric | Value | Status |", - f"|--------|-------|--------|", - f"| Motion count | {status.get('motion_count', 0):,} | {'βœ…' if status.get('motion_count', 0) > 0 else '⚠️'} |", - f"| Latest motion | {status.get('latest_motion_date', 'N/A')} | {'βœ…' if status.get('latest_motion_date') else '⚠️'} |", - f"| SVD windows | {status.get('svd_window_count', 0)} | {'βœ…' if status.get('svd_window_count', 0) > 0 else '⚠️'} |", - f"| Embeddings | {status.get('embedding_count', 0):,} | {'βœ…' if status.get('embedding_count', 0) > 0 else '⚠️'} |", - f"", - ]) - - elif report_type == "party_shift": - from agent_tools.analysis import analyze_party_shift - - party = parameters.get("party", "VVD") - start = parameters.get("window_start", "2020") - end = parameters.get("window_end", "2024") - result = analyze_party_shift(db_path, party, start, end) - - if "error" in result: - lines.extend(["## Party Shift Analysis", f"", f"Error: {result['error']}", f""]) - else: - lines.extend([ - "## Party Shift Analysis", - f"", - f"**Party:** {result['party']}", - f"**Period:** {result['window_start']} β†’ {result['window_end']}", - f"**Shift magnitude:** {result['shift']}", - f"**Direction:** dx={result['direction']['dx']}, dy={result['direction']['dy']}", - f"", - f"### Start position", - f"- Axis 1: {result['start_position']['axis_1']}", - f"- Axis 2: {result['start_position']['axis_2']}", - f"", - f"### End position", - f"- Axis 1: {result['end_position']['axis_1']}", - f"- Axis 2: {result['end_position']['axis_2']}", - f"", - ]) - - elif report_type == "axis_stability": - from agent_tools.analysis import analyze_axis_stability - - component = parameters.get("component", 1) - windows = parameters.get("windows", ["2020", "2021", "2022", "2023", "2024"]) - result = analyze_axis_stability(db_path, component, windows) - - if "error" in result: - lines.extend(["## Axis Stability Analysis", f"", f"Error: {result['error']}", f""]) - else: - lines.extend([ - "## Axis Stability Analysis", - f"", - f"**Component:** {result['component']}", - f"**Average stability:** {result['stability']}", - f"", - f"### Pairwise correlations", - f"", - f"| From | To | Correlation |", - f"|------|-----|-------------|", - ]) - for pair in result.get("pairwise", []): - lines.append(f"| {pair['from_window']} | {pair['to_window']} | {pair['correlation']} |") - lines.append("") - - return "\n".join(lines) +This module is intentionally empty. +""" diff --git a/analysis/explorer_data.py b/analysis/explorer_data.py index 32af634..33982a4 100644 --- a/analysis/explorer_data.py +++ b/analysis/explorer_data.py @@ -718,16 +718,23 @@ def _get_aligned_trajectory_scores( Uses compute_nd_axes to get PCA-projected, flip-corrected scores across all windows, ensuring consistency with the single-window SVD components view. + + Computes the global PCA basis on *all* uniform-dim windows (matching + get_aligned_party_scores) so that trajectory scores are numerically + consistent with the single-window view even when the caller passes a + subset of windows for display. """ from analysis.political_axis import compute_nd_axes + all_uniform_windows = get_uniform_dim_windows(db_path) scores_by_window, _ = compute_nd_axes( - db_path, window_ids=windows, n_components=n_components + db_path, window_ids=all_uniform_windows, n_components=n_components ) if not scores_by_window: return {} party_map = load_party_map(db_path) + active_mps = load_active_mps(db_path) result: Dict[str, Dict[str, List[float]]] = {} for window in windows: @@ -735,6 +742,14 @@ def _get_aligned_trajectory_scores( if not window_scores: continue + # For current_parliament, match single-window view by filtering to + # only MPs who are still seated (active). Historical windows include + # all MPs present in that window. + if window == "current_parliament": + window_scores = { + mp: sc for mp, sc in window_scores.items() if mp in active_mps + } + party_vecs: Dict[str, List[np.ndarray]] = {} for mp_name, scores in window_scores.items(): party = party_map.get( diff --git a/analysis/tabs/_rendering.py b/analysis/tabs/_rendering.py index 131a63b..09f9659 100644 --- a/analysis/tabs/_rendering.py +++ b/analysis/tabs/_rendering.py @@ -612,6 +612,7 @@ def _render_svd_time_trajectory( return idx = comp_sel - 1 + flip = theme.get("flip", False) party_trajectories: Dict[str, List[Tuple[str, float]]] = {} @@ -631,6 +632,8 @@ def _render_svd_time_trajectory( if scores and len(scores) > idx: try: score = float(scores[idx]) + if flip: + score = -score party_trajectories.setdefault(party, []).append((window, score)) except (ValueError, TypeError): continue @@ -766,15 +769,13 @@ def _render_voting_results(voting_results_json) -> None: vote_str = str(vote).lower().strip() by_vote.setdefault(vote_str, []).append(str(actor)) vote_order = ["voor", "tegen", "onthouden", "afwezig"] - vote_emoji = {"voor": "βœ…", "tegen": "❌", "onthouden": "🟑", "afwezig": "⬜"} rows_shown = False for v in vote_order + [k for k in by_vote if k not in vote_order]: actors = by_vote.get(v) if not actors: continue - emoji = vote_emoji.get(v, "β–ͺ️") st.markdown( - f"**{emoji} {v.capitalize()}** ({len(actors)}): {', '.join(sorted(actors))}" + f"**{v.capitalize()}** ({len(actors)}): {', '.join(sorted(actors))}" ) rows_shown = True if not rows_shown: @@ -784,7 +785,7 @@ def _render_voting_results(voting_results_json) -> None: def _add_y_direction_annotations(fig: go.Figure) -> None: - """Add β–² Progressief / β–Ό Conservatief labels above and below the Y axis.""" + """Add Progressief / Conservatief labels above and below the Y axis.""" common = dict( xref="paper", yref="paper", @@ -792,5 +793,5 @@ def _add_y_direction_annotations(fig: go.Figure) -> None: showarrow=False, font=dict(size=11, color="#666666"), ) - fig.add_annotation(**common, y=1.02, text="β–² Progressief", xanchor="center") - fig.add_annotation(**common, y=-0.06, text="β–Ό Conservatief", xanchor="center") + fig.add_annotation(**common, y=1.02, text="Progressief", xanchor="center") + fig.add_annotation(**common, y=-0.06, text="Conservatief", xanchor="center") diff --git a/analysis/tabs/browser.py b/analysis/tabs/browser.py index 91fe8b6..3df70e7 100644 --- a/analysis/tabs/browser.py +++ b/analysis/tabs/browser.py @@ -74,12 +74,12 @@ def build_browser_tab(db_path: str, show_rejected: bool) -> None: st.markdown(f"### {row.get('title') or 'Onbekend'}") date_str = row["date"].strftime("%d %b %Y") if pd.notna(row["date"]) else "?" st.caption( - f"πŸ“… {date_str} | πŸ”₯ Controverse: {row.get('controversy_score', 0):.2f}" + f"{date_str} | Controverse: {row.get('controversy_score', 0):.2f}" ) url = row.get("url") if url and str(url).startswith("http"): - st.markdown(f"[πŸ”— Bekijk op Tweede Kamer]({url})") + st.markdown(f"[Bekijk op Tweede Kamer]({url})") st.markdown("**Stemuitslag:**") _render_voting_results(row.get("voting_results")) diff --git a/analysis/tabs/compass.py b/analysis/tabs/compass.py index 66c5a16..5e0aa40 100644 --- a/analysis/tabs/compass.py +++ b/analysis/tabs/compass.py @@ -51,8 +51,6 @@ def build_compass_tab(db_path: str, window_size: str) -> None: def _window_label(w: str) -> str: if w == "current_parliament": return "Huidig parlement" - if w in _SPARSE_YEARS: - return f"{w} ⚠️" return w col1, col2 = st.columns([3, 1]) diff --git a/analysis/tabs/components.py b/analysis/tabs/components.py index 89e94bd..e22924d 100644 --- a/analysis/tabs/components.py +++ b/analysis/tabs/components.py @@ -39,7 +39,7 @@ def build_svd_components_tab(db_path: str) -> None: Components 1-2 use aligned PCA positions (consistent with compass). Components 3-10 use raw SVD scores. """ - st.subheader("πŸ”¬ SVD Assen β€” politieke polarisatiethema's") + st.subheader("SVD Assen β€” politieke polarisatiethema's") st.markdown( "Elke SVD-as representeert een latente politieke dimensie afgeleid uit stempatronen " "van alle Kamerleden. De top-10 moties per as zijn uniek (geen overlap) en illustreren " @@ -166,7 +166,7 @@ def build_svd_components_tab(db_path: str) -> None: def _svd_window_label(w: str) -> str: if w == "current_parliament": - return "Huidig parliament" + return "Huidig parlement" return w with col1: @@ -321,11 +321,9 @@ def build_svd_components_tab(db_path: str) -> None: if flip: left_pole, right_pole = pos_pole, neg_pole left_motions, right_motions = pos_motions, neg_motions - left_arrow, right_arrow = "β–²", "β–Ό" else: left_pole, right_pole = neg_pole, pos_pole left_motions, right_motions = neg_motions, pos_motions - left_arrow, right_arrow = "β–Ό", "β–²" lcol, rcol = st.columns(2) @@ -334,16 +332,16 @@ def build_svd_components_tab(db_path: str) -> None: for m in left_motions: mid = m.get("motion_id") raw_title = m.get("title") or f"Motie #{mid}" - with st.expander(f"{left_arrow} {raw_title}"): + with st.expander(raw_title): row = motion_details.get(int(mid)) if mid is not None else None if row: try: date_str = str(row[2])[:10] except Exception: date_str = "?" - st.caption(f"πŸ“… {date_str} | {row[3] or 'β€”'}") + st.caption(f"{date_str} | {row[3] or 'β€”'}") if row[4] and str(row[4]).startswith("http"): - st.markdown(f"[πŸ”— Bekijk op Tweede Kamer]({row[4]})") + st.markdown(f"[Bekijk op Tweede Kamer]({row[4]})") if row[5]: with st.expander("Toon volledige tekst"): st.write(row[5]) @@ -356,16 +354,16 @@ def build_svd_components_tab(db_path: str) -> None: for m in right_motions: mid = m.get("motion_id") raw_title = m.get("title") or f"Motie #{mid}" - with st.expander(f"{right_arrow} {raw_title}"): + with st.expander(raw_title): row = motion_details.get(int(mid)) if mid is not None else None if row: try: date_str = str(row[2])[:10] except Exception: date_str = "?" - st.caption(f"πŸ“… {date_str} | {row[3] or 'β€”'}") + st.caption(f"{date_str} | {row[3] or 'β€”'}") if row[4] and str(row[4]).startswith("http"): - st.markdown(f"[πŸ”— Bekijk op Tweede Kamer]({row[4]})") + st.markdown(f"[Bekijk op Tweede Kamer]({row[4]})") if row[5]: with st.expander("Toon volledige tekst"): st.write(row[5]) diff --git a/analysis/tabs/quiz.py b/analysis/tabs/quiz.py index 253fb33..88c7a71 100644 --- a/analysis/tabs/quiz.py +++ b/analysis/tabs/quiz.py @@ -18,7 +18,7 @@ def build_mp_quiz_tab(db_path: str) -> None: - if multiple candidates remain, call choose_discriminating_motions to pick next question - stop when unique MP found or no discriminating motions remain """ - st.subheader("πŸ§‘β€βš–οΈ Welk tweede kamerlid ben jij?") + st.subheader("Welk tweede kamerlid ben jij?") st.markdown( "Beantwoord een paar eenvoudige ja/nee/onthoud vragen over moties om te zien welk Kamerlid het meest op jou lijkt." ) diff --git a/analysis/tabs/search.py b/analysis/tabs/search.py index de0fb23..67d04ba 100644 --- a/analysis/tabs/search.py +++ b/analysis/tabs/search.py @@ -56,7 +56,7 @@ def build_search_tab(db_path: str, show_rejected: bool) -> None: title = row.get("title") or f"Motie #{row['id']}" date_str = row["date"].strftime("%d %b %Y") if pd.notna(row["date"]) else "?" controversy = row.get("controversy_score") or 0 - with st.expander(f"**{title}** β€” {date_str} β€” πŸ”₯ {controversy:.2f}"): + with st.expander(f"**{title}** β€” {date_str} β€” {controversy:.2f}"): cols = st.columns(3) cols[0].metric("Controverse", f"{controversy:.2f}") cols[1].metric("Marge", f"{row.get('winning_margin', 0):.2f}") @@ -66,7 +66,7 @@ def build_search_tab(db_path: str, show_rejected: bool) -> None: url = row.get("url") if url and str(url).startswith("http"): - st.markdown(f"[πŸ”— Bekijk op Tweede Kamer]({url})") + st.markdown(f"[Bekijk op Tweede Kamer]({url})") sim = explorer_data.query_similar(db_path, int(row["id"]), top_k=5) if not sim.empty: diff --git a/analysis/tabs/trajectories.py b/analysis/tabs/trajectories.py index 863e19b..e68e260 100644 --- a/analysis/tabs/trajectories.py +++ b/analysis/tabs/trajectories.py @@ -516,57 +516,7 @@ def build_trajectories_tab(db_path: str, window_size: str) -> None: st.plotly_chart(fig, use_container_width=True) return - try: - debug_checkbox = False - try: - debug_checkbox = st.checkbox( - "Enable trajectories diagnostics (show extra info)", - value=get_debug_trajectories_enabled(), - ) - except Exception: - debug_checkbox = get_debug_trajectories_enabled() - if debug_checkbox: - try: - with st.expander( - "DEBUG: Trajectories data (showing diagnostics)", expanded=False - ): - st.write("windows (count):", len(windows)) - st.write("windows sample:", windows[:10]) - st.write("party_map entries:", len(party_map)) - st.write("parties with centroids:", len(all_parties_sorted)) - st.write("default_parties:", default_parties) - st.write("selected_parties:", selected_parties) - st.write("min_mps setting:", 3) - sample = { - p: len(centroids.get(p, {})) - for p in list(all_parties_sorted)[:8] - } - st.write("sample centroid window counts per party:", sample) - except Exception: - pass - except Exception: - pass - - smoothing_method = st.selectbox( - "Smoothing methode", - options=["EMA", "Spline", "None"], - index=0, - help="EMA = exponential moving average; Spline = low-degree polynomial spline fit; None = raw centroids", - ) - - smooth_alpha = 1.0 - if smoothing_method == "EMA": - smooth_alpha = st.slider( - "Glad maken (EMA-\u03b1)", - min_value=0.1, - max_value=1.0, - value=0.35, - step=0.05, - help=( - "\u03b1=1.0 toont de ruwe data; lagere waarden maken de lijn gladder. " - "Standaard 0.35 voor een goed evenwicht tussen detail en ruis." - ), - ) + smooth_alpha = 0.35 def _spline_smooth(values: List[float]) -> List[float]: n = len(values) @@ -712,63 +662,9 @@ def build_trajectories_tab(db_path: str, window_size: str) -> None: "sample_size": len(sample_mps), } if trace_count == 0: - st.info("πŸ“Š **Geen trajecten getekend**") - - with st.expander("πŸ” Diagnostische informatie"): - st.write("**Data status:**") - st.write( - f"- Positie vensters: {len(positions_by_window) if positions_by_window else 0}" - ) - st.write(f"- Party mappings: {len(party_map) if party_map else 0}") - st.write( - f"- Geselecteerde partijen: {len(selected_parties) if selected_parties else 0}" - ) - - if "centroid_diagnostics" in locals(): - st.write("**Centroid berekening:**") - st.write( - f"- Partijen met posities: {len(centroid_diagnostics.get('parties_with_positions', []))}" - ) - st.write( - f"- Partijen met alleen NaN: {len(centroid_diagnostics.get('parties_all_nan', []))}" - ) - - st.write("\n**Mogelijke oorzaken:**") - st.write("1. Geen SVD vectoren berekend voor de geselecteerde vensters") - st.write("2. MP namen in posities komen niet overeen met party_map") - st.write("3. Alle geselecteerde partijen hebben te weinig MPs (< 5)") - - if st.button("πŸ”§ Database diagnostiek uitvoeren"): - with st.spinner("Bezig met diagnostiek..."): - from scripts.diagnose_trajectories_cli import ( - run as diagnose_trajectories, - ) - - results = diagnose_trajectories(db_path) - st.json(results) + st.info("**Geen trajecten getekend**") else: try: - st.info( - f"[DEBUG] trace_count={trace_count}, fig data count={len(fig.data)}, layout title={fig.layout.title.text if fig.layout.title else 'none'}" - ) - except Exception: - pass - try: - logging.getLogger(__name__).debug( - "[TRAJ DEBUG] About to render plotly chart β€” trace_count=%d, banner=%s, fig has %d traces", - trace_count, - banner_text, - len(fig.data), - ) st.plotly_chart(fig, use_container_width=True) except Exception as e: st.error(f"Trajectories rendering failed: {e}") - if get_debug_trajectories_enabled(): - try: - st.json(_last_trajectories_diagnostics) - except Exception: - st.text_area( - "Trajectories diagnostics (JSON failed)", - json.dumps(_last_trajectories_diagnostics, default=str), - height=240, - ) diff --git a/app.py b/app.py index 8aab9ba..23fab05 100644 --- a/app.py +++ b/app.py @@ -7,14 +7,8 @@ from summarizer import summarizer from config import config import json -# Page config -st.set_page_config( - page_title="Nederlandse Politieke Kompas", page_icon="πŸ‡³πŸ‡±", layout="wide" -) - - def main(): - st.title("πŸ‡³πŸ‡± Nederlandse Politieke Kompas") + st.title("Nederlandse Politieke Kompas") st.markdown( "Ontdek welke politieke partij het beste bij jouw idealen past door te stemmen op echte Tweede Kamer moties." ) @@ -105,8 +99,8 @@ def show_welcome_screen(motion_count, policy_area, margin_range): st.markdown(f""" **Jouw instellingen:** - - πŸ“Š **{motion_count} moties** uit het beleidsgebied **{policy_area}** - - 🎯 **ControversiΓ«le moties** tussen {margin_range[0]}% en {margin_range[1]}% marge + - **{motion_count} moties** uit het beleidsgebied **{policy_area}** + - **ControversiΓ«le moties** tussen {margin_range[0]}% en {margin_range[1]}% marge Klik op "Start Nieuwe Sessie" in de zijbalk om te beginnen met stemmen. """) @@ -144,35 +138,32 @@ def show_motion_interface(): # Layman explanation (prominent) if motion.get("layman_explanation"): - st.markdown("### πŸ“ Uitleg in begrijpelijke taal:") + st.markdown("### Uitleg in begrijpelijke taal:") st.markdown(f"*{motion['layman_explanation']}*") # Original description (collapsible) motion_text = motion.get("body_text") or motion.get("description", "") if motion_text: label = ( - "πŸ“‹ Volledige motietekst" + "Volledige motietekst" if motion.get("body_text") - else "πŸ“‹ Originele motiebeschrijving" + else "Originele motiebeschrijving" ) with st.expander(label): st.write(motion_text) # Voting buttons - st.markdown("### πŸ—³οΈ Hoe zou jij stemmen?") - + st.markdown("### Hoe zou jij stemmen?") col1, col2, col3 = st.columns(3) - with col1: - if st.button("βœ… Voor", use_container_width=True, type="primary"): - cast_vote("Voor") - + if st.button("Voor", use_container_width=True, type="primary"): + record_vote("voor") with col2: - if st.button("❌ Tegen", use_container_width=True): + if st.button("Tegen", use_container_width=True): cast_vote("Tegen") with col3: - if st.button("🚫 Geen stem", use_container_width=True): + if st.button("Geen stem", use_container_width=True): cast_vote("Geen stem") @@ -190,7 +181,7 @@ def cast_vote(vote_choice): def show_results(): """Show voting results and party matches""" - st.header("🎯 Jouw Resultaten") + st.header("Jouw Resultaten") # Calculate party matches party_matches = db.calculate_party_matches(st.session_state.session_id) @@ -200,7 +191,7 @@ def show_results(): return # Party ranking table - st.subheader("πŸ“Š Partij Overeenkomsten (van hoog naar laag)") + st.subheader("Partij Overeenkomsten (van hoog naar laag)") df = pd.DataFrame(party_matches) df.columns = ["Partij", "Overeenkomst %", "Eens", "Totaal"] @@ -220,15 +211,15 @@ def show_results(): # Top match highlight top_match = party_matches[0] st.success( - f"πŸ† **Beste match:** {top_match['party']} ({top_match['agreement_percentage']}% overeenkomst)" + f"**Beste match:** {top_match['party']} ({top_match['agreement_percentage']}% overeenkomst)" ) # Detailed motion overview - st.subheader("πŸ“‹ Gedetailleerd Overzicht per Motie") + st.subheader("Gedetailleerd Overzicht per Motie") show_detailed_motion_results() # New session button - if st.button("πŸ”„ Start Nieuwe Sessie"): + if st.button("Start Nieuwe Sessie"): # Clear session state for key in ["session_id", "motions", "current_motion_index", "show_results"]: if key in st.session_state: @@ -281,13 +272,13 @@ def show_detailed_motion_results(): with st.expander(f"**{title}** (Jouw stem: {user_vote})"): # Show layman explanation prominently if layman_explanation: - st.markdown("**πŸ“ Uitleg:**") + st.markdown("**Uitleg:**") st.markdown(f"*{layman_explanation}*") # Show full motion body text if available, otherwise description motion_text = body_text or description if motion_text: - st.markdown("**πŸ“‹ Motiebeschrijving:**") + st.markdown("**Motiebeschrijving:**") st.write(motion_text) # Create voting overview diff --git a/docs/solutions/logic-errors/svd-component-scores-inconsistent-between-views-2026-05-04.md b/docs/solutions/logic-errors/svd-component-scores-inconsistent-between-views-2026-05-04.md new file mode 100644 index 0000000..d9c8304 --- /dev/null +++ b/docs/solutions/logic-errors/svd-component-scores-inconsistent-between-views-2026-05-04.md @@ -0,0 +1,147 @@ +--- +title: SVD component scores inconsistent between single-window and trajectory views +date: "2026-05-04" +category: logic-errors +module: analysis +problem_type: logic_error +component: service_object +severity: high +symptoms: + - "Party position numbers differ between Enkel venster and Tijdtraject views for the same SVD component and window" + - "Displayed values have opposite signs for flipped components even when underlying data is identical" +root_cause: logic_error +resolution_type: code_fix +tags: + - svd + - pca + - alignment + - visualization + - data-consistency +--- + +# SVD component scores inconsistent between single-window and trajectory views + +## Problem + +In the parliamentary explorer's "SVD Components" tab, party position numbers differed between the "Enkel venster" (single window) view and the "Tijdtraject" (time trajectory) view for the SAME component and SAME window. Users comparing a specific year across the two views saw inconsistent numerical scores. + +## Symptoms + +- Selecting component 2, window "2023" in single-window shows a party at +0.42, but the trajectory view at the same point shows that party at a different value (e.g. -0.15) +- Signs invert for certain components when `theme["flip"]` is `True` +- The mismatch occurs even though both views claim to show the same underlying SVD component +- **After initial fixes:** most years aligned, but "Huidig parlement" still showed different values between the two views +- "Huidig parlement" was misspelled as "Huidig parliament" in the window selector label + +## What Didn't Work + +Initial suspicion that the difference came from Procrustes alignment or data caching issues. Checking whether `load_party_scores_all_windows_aligned()` vs `load_party_scores_all_windows()` was the culprit. However, both views were already using the same alignment path. + +The real cause was subtler: the trajectory view was computing PCA over a different set of windows than the single-window view, and then ignoring the flip flag entirely. + +## Solution + +### Fix 1: Align trajectory PCA computation with single-window computation + +In `analysis/explorer_data.py`, function `_get_aligned_trajectory_scores()`: + +```python +def _get_aligned_trajectory_scores( + db_path: str, windows: List[str], n_components: int = 10 +) -> Dict[str, Dict[str, List[float]]]: + from analysis.political_axis import compute_nd_axes + + all_uniform_windows = get_uniform_dim_windows(db_path) + scores_by_window, _ = compute_nd_axes( + db_path, window_ids=all_uniform_windows, n_components=n_components + ) + # ... rest filters to requested windows +``` + +**Change:** Compute PCA on **all** uniform-dim windows (matching `get_aligned_party_scores`), then filter to the requested windows. Previously, `_get_aligned_trajectory_scores()` passed only a subset of windows (excluding `_current_year`) to `compute_nd_axes()`, which produced different principal components, global mean, and flip signs. + +### Fix 2: Apply theme flip in trajectory rendering + +In `analysis/tabs/_rendering.py`, function `_render_svd_time_trajectory()`: + +```python + idx = comp_sel - 1 + flip = theme.get("flip", False) + # ... + for window in sorted_windows: + scores_by_party = party_scores_by_window.get(window, {}) + for party in selected_parties: + scores = scores_by_party.get(party, []) + if scores and len(scores) > idx: + try: + score = float(scores[idx]) + if flip: + score = -score + party_trajectories.setdefault(party, []).append((window, score)) + except (ValueError, TypeError): + continue +``` + +**Change:** Added flip application to negate scores when `theme.get("flip", False)` is `True`. `_render_party_axis_chart_1d()` already did this, but `_render_svd_time_trajectory()` completely ignored the flip flag. + +### Fix 3: Filter current_parliament to active MPs in trajectory view + +In `analysis/explorer_data.py`, function `_get_aligned_trajectory_scores()`: + +```python + party_map = load_party_map(db_path) + active_mps = load_active_mps(db_path) + + result: Dict[str, Dict[str, List[float]]] = {} + for window in windows: + window_scores = scores_by_window.get(window, {}) + if not window_scores: + continue + + # For current_parliament, match single-window view by filtering to + # only MPs who are still seated (active). Historical windows include + # all MPs present in that window. + if window == "current_parliament": + window_scores = { + mp: sc for mp, sc in window_scores.items() if mp in active_mps + } + + party_vecs: Dict[str, List[np.ndarray]] = {} + # ... aggregate by party as before +``` + +**Change:** Added `active_mps = load_active_mps(db_path)` and filtered `window_scores` to only active MPs when `window == "current_parliament"`. The single-window view (`get_aligned_party_scores()`) already did this filtering, but the trajectory view averaged ALL MPs (including those who had left parliament), producing different party means. + +### Fix 4: Correct Dutch spelling of window label + +In `analysis/tabs/components.py`: + +```python + def _svd_window_label(w: str) -> str: + if w == "current_parliament": + return "Huidig parlement" # was "Huidig parliament" + return w +``` + +**Change:** Fixed misspelling of Dutch word "parlement" (was "parliament"). + +## Why This Works + +1. **Same PCA basis**: `compute_nd_axes()` computes global PCA across all provided windows. When the single-window view used all uniform-dim windows and the trajectory view used a subset, the resulting components, mean centering, and variance explained were different. Passing the same `window_ids` to `compute_nd_axes()` guarantees identical PCA bases. + +2. **Same flip handling**: The single-window view negates scores when `flip=True`. The trajectory view now does the same, ensuring both views display numerically identical values for the same (window, component, party) tuple. + +3. **Same MP population for current_parliament**: The single-window view filtered `current_parliament` to only active (still-seated) MPs before computing party means. The trajectory view now applies the same filter, so party averages are computed over the identical set of MPs. + +## Prevention + +- When multiple views display the same underlying SVD/PCA data, ensure they all call `compute_nd_axes()` with the **identical** set of window IDs. +- Never apply visual transformations (like `theme["flip"]` or `active_mps` filtering) in one view but omit them in another β€” keep rendering logic symmetric across all views for the same data. +- Add a regression test that asserts `get_aligned_party_scores(window, comp)` equals `_get_aligned_trajectory_scores([window], comp)[window]` for sampled windows and components, including `current_parliament`. +- Document that `compute_nd_axes()` is a global operation over its input windows; any subset produces a different coordinate frame. +- When special-casing `current_parliament` (e.g. active-MP filtering), apply the same logic in every code path that processes that window β€” single-window, trajectory, compass, and exports. + +## Related Issues + +- `docs/solutions/ui-bugs/svd-axis-pole-labels-incorrect-after-flip.md` β€” related flip-handling bug in the same SVD Components tab +- `docs/solutions/ui-bugs/svd-compass-components-position-inconsistency.md` β€” related alignment inconsistency between compass and components tab diff --git a/docs/solutions/ui-bugs/svd-time-trajectory-score-mismatch.md b/docs/solutions/ui-bugs/svd-time-trajectory-score-mismatch.md new file mode 100644 index 0000000..7465bfb --- /dev/null +++ b/docs/solutions/ui-bugs/svd-time-trajectory-score-mismatch.md @@ -0,0 +1,108 @@ +--- +title: "SVD time trajectory shows different scores than single-window view for same component and window" +date: 2026-05-04 +module: analysis +problem_type: ui_bug +component: analysis +symptoms: + - "Party position numbers in Tijdtraject view differ from Enkel venster view for the exact same component and window" + - "Same party has opposite sign in trajectory vs single-window when theme flip is active" + - "Inconsistent numerical values break user trust in the SVD Components tab" +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: + - svd + - pca + - time-trajectory + - parliamentary-explorer + - alignment + - flip +--- + +# SVD Time Trajectory Shows Different Scores Than Single-Window View + +## Problem + +In the parliamentary explorer's "SVD Components" tab, party position numbers differed between the "Enkel venster" (single window) view and the "Tijdtraject" (time trajectory) view for the SAME component and SAME window. Users comparing a specific year across the two views saw inconsistent numerical scores. + +## Symptoms + +- Selecting component 2, window "2023-2024" in single-window shows PVV at +0.42, but the trajectory view at the same point shows PVV at -0.15 +- Signs invert for certain components when `theme["flip"]` is `True` +- The mismatch occurs even though both views claim to show the same underlying SVD component + +## What Didn't Work + +Initial suspicion that the difference came from Procrustes alignment or data caching issues. Checking whether `load_party_scores_all_windows_aligned()` vs `load_party_scores_all_windows()` was the culprit. However, both views were already using the same alignment path. + +The real cause was subtler: the trajectory view was computing PCA over a different set of windows than the single-window view, and then ignoring the flip flag entirely. + +## Solution + +### Fix 1: Align trajectory PCA computation with single-window computation + +In `analysis/explorer_data.py`, function `_get_aligned_trajectory_scores()`: + +```python +def _get_aligned_trajectory_scores( + db_path: str, windows: List[str], n_components: int = 10 +) -> Dict[str, Dict[str, List[float]]]: + from analysis.political_axis import compute_nd_axes + + all_uniform_windows = get_uniform_dim_windows(db_path) + scores_by_window, _ = compute_nd_axes( + db_path, window_ids=all_uniform_windows, n_components=n_components + ) + # ... rest filters to requested windows +``` + +**Change:** Compute PCA on **all** uniform-dim windows (matching `get_aligned_party_scores`), then filter to the requested windows. Previously, `_get_aligned_trajectory_scores()` passed only a subset of windows (excluding `_current_year`) to `compute_nd_axes()`, which produced different principal components, global mean, and flip signs. + +### Fix 2: Apply theme flip in trajectory rendering + +In `analysis/tabs/_rendering.py`, function `_render_svd_time_trajectory()`: + +```python + idx = comp_sel - 1 + flip = theme.get("flip", False) + # ... + for window in sorted_windows: + scores_by_party = party_scores_by_window.get(window, {}) + for party in selected_parties: + scores = scores_by_party.get(party, []) + if scores and len(scores) > idx: + try: + score = float(scores[idx]) + if flip: + score = -score + party_trajectories.setdefault(party, []).append((window, score)) + except (ValueError, TypeError): + continue +``` + +**Change:** Added flip application to negate scores when `theme.get("flip", False)` is `True`. `_render_party_axis_chart_1d()` already did this, but `_render_svd_time_trajectory()` completely ignored the flip flag. + +## Why This Works + +1. **Same PCA basis**: `compute_nd_axes()` computes global PCA across all provided windows. When the single-window view used all uniform-dim windows and the trajectory view used a subset, the resulting components, mean centering, and variance explained were different. Passing the same `window_ids` to `compute_nd_axes()` guarantees identical PCA bases. + +2. **Same flip handling**: The single-window view negates scores when `flip=True`. The trajectory view now does the same, ensuring both views display numerically identical values for the same (window, component, party) tuple. + +## Prevention + +- When multiple views display the same underlying SVD/PCA data, ensure they all call `compute_nd_axes()` with the **identical** set of window IDs +- Never apply visual transformations (like `theme["flip"]`) in one view but omit them in another β€” keep rendering logic symmetric +- Add a regression test that asserts `get_aligned_party_scores(window, comp)` equals `_get_aligned_trajectory_scores([window], comp)[window]` for sampled windows and components +- Document that `compute_nd_axes()` is a global operation over its input windows; any subset produces a different coordinate frame + +## Related Files + +- `analysis/explorer_data.py` β€” `_get_aligned_trajectory_scores()` fix (all uniform windows) +- `analysis/tabs/_rendering.py` β€” `_render_svd_time_trajectory()` fix (flip application) +- `analysis/political_axis.py` β€” `compute_nd_axes()` global PCA logic + +## Related Issues + +- Builds on `docs/solutions/ui-bugs/svd-compass-components-position-inconsistency.md` (consistent alignment for components 1-2) +- Builds on `docs/solutions/ui-bugs/svd-axis-pole-labels-incorrect-after-flip.md` (runtime flip mechanism) diff --git a/explorer.py b/explorer.py index 8fcca51..bb51391 100644 --- a/explorer.py +++ b/explorer.py @@ -421,39 +421,30 @@ def build_mp_quiz_tab(*args, **kwargs): return _impl(*args, **kwargs) +def build_compass_tab(*args, **kwargs): + """Build the Politiek Kompas tab.""" + from analysis.tabs.compass import build_compass_tab as _impl + + return _impl(*args, **kwargs) + + +def build_trajectories_tab(*args, **kwargs): + """Build the Trajectories tab.""" + from analysis.tabs.trajectories import build_trajectories_tab as _impl + + return _impl(*args, **kwargs) + + def run_app() -> None: - st.set_page_config( - layout="wide", - page_title="Parlement Explorer", - page_icon="πŸ›οΈ", - ) - st.title("πŸ›οΈ Parlement Explorer") + st.title("Parlement Explorer") - st.sidebar.title("Instellingen") db_path = "data/motions.db" window_size = "annual" - with st.sidebar.expander("ℹ️ Over", expanded=False): - try: - if _DUCKDB_AVAILABLE: - con = duckdb.connect(database=db_path, read_only=True) - n_motions = con.execute("SELECT COUNT(*) FROM motions").fetchone()[0] - con.close() - st.markdown( - f"**Moties:** {n_motions:,} \n" - f"**Vensters:** per jaar + huidig parlement" - ) - else: - st.warning( - "DuckDB niet beschikbaar in deze Python-omgeving; DB diagnostics zijn niet beschikbaar." - ) - except Exception as e: - st.warning(f"DB niet bereikbaar: {e}") - tab_labels = [ - "🧭 Politiek Kompas", - "πŸ“ˆ Trajectories", - "πŸ”¬ SVD Components", + "Politiek Kompas", + "Trajectories", + "SVD Components", ] if hasattr(st, "tabs") and callable(getattr(st, "tabs")): diff --git a/pages/1_Stemwijzer.py b/pages/1_Stemwijzer.py index 4dc1781..e62f1c0 100644 --- a/pages/1_Stemwijzer.py +++ b/pages/1_Stemwijzer.py @@ -1,13 +1,5 @@ """Stemwijzer page β€” quiz to find your matching MP.""" -import streamlit as st - -st.set_page_config( - page_title="Stemwijzer", - page_icon="πŸ—³οΈ", - layout="centered", -) - from explorer import build_mp_quiz_tab build_mp_quiz_tab("data/motions.db") diff --git a/tests/agent_tools/test_analysis_tools.py b/tests/agent_tools/test_analysis_tools.py index 697d35b..efba3be 100644 --- a/tests/agent_tools/test_analysis_tools.py +++ b/tests/agent_tools/test_analysis_tools.py @@ -1,74 +1,10 @@ -"""Tests for agent analysis and report generation primitives.""" +"""Tests for agent analysis and report generation primitives. + +NOTE: Multi-step analytical workflows have been removed. Agents should compose +raw database primitives and perform analysis in their own reasoning loop. +This file is intentionally empty. +""" import pytest -import os pytest.importorskip("duckdb") - - -class TestAnalyzePartyShift: - def test_returns_shift_data(self, tmp_duckdb_path): - from agent_tools.analysis import analyze_party_shift - - result = analyze_party_shift( - tmp_duckdb_path, party="VVD", window_start="2020", window_end="2024" - ) - assert isinstance(result, dict) - assert "party" in result - assert "shift" in result or "error" in result - - def test_nonexistent_party_returns_error(self, tmp_duckdb_path): - from agent_tools.analysis import analyze_party_shift - - result = analyze_party_shift( - tmp_duckdb_path, party="FAKE", window_start="2020", window_end="2024" - ) - assert isinstance(result, dict) - - -class TestAnalyzeAxisStability: - def test_returns_stability_scores(self, tmp_duckdb_path): - from agent_tools.analysis import analyze_axis_stability - - result = analyze_axis_stability(tmp_duckdb_path, component=1, windows=["2020", "2024"]) - assert isinstance(result, dict) - assert "component" in result - assert "stability" in result or "error" in result - - -class TestGenerateReport: - def test_writes_markdown_file(self, tmp_duckdb_path, tmp_path): - from agent_tools.reports import generate_report - - output_path = str(tmp_path / "report.md") - result = generate_report( - tmp_duckdb_path, - report_type="summary", - parameters={}, - output_path=output_path, - ) - assert isinstance(result, dict) - assert os.path.exists(output_path) - - def test_returns_error_for_unknown_type(self, tmp_duckdb_path, tmp_path): - from agent_tools.reports import generate_report - - output_path = str(tmp_path / "report.md") - result = generate_report( - tmp_duckdb_path, - report_type="unknown", - parameters={}, - output_path=output_path, - ) - assert isinstance(result, dict) - assert "error" in result - - -class TestValidateSvdLabels: - def test_returns_validation_result(self, tmp_duckdb_path): - from agent_tools.analysis import validate_svd_labels - - result = validate_svd_labels(tmp_duckdb_path, component=1) - assert isinstance(result, dict) - assert "component" in result - assert "valid" in result or "error" in result diff --git a/tests/agent_tools/test_content_tools.py b/tests/agent_tools/test_content_tools.py index 43edc43..5b18795 100644 --- a/tests/agent_tools/test_content_tools.py +++ b/tests/agent_tools/test_content_tools.py @@ -25,16 +25,6 @@ class TestValidateLaymanExplanations: assert "coverage" in result or "error" in result -class TestSuggestSvdLabel: - def test_returns_suggestion(self, tmp_duckdb_path): - from agent_tools.content import suggest_svd_label - - result = suggest_svd_label(tmp_duckdb_path, component=1, top_n=5) - assert isinstance(result, dict) - assert "component" in result - assert "suggestion" in result or "error" in result - - class TestCheckEmbeddingQuality: def test_returns_coverage_stats(self, tmp_duckdb_path): from agent_tools.content import check_embedding_quality @@ -42,12 +32,4 @@ class TestCheckEmbeddingQuality: result = check_embedding_quality(tmp_duckdb_path, window_id="current_parliament") assert isinstance(result, dict) assert "coverage" in result or "error" in result - - def test_parameterized_threshold(self, tmp_duckdb_path): - from agent_tools.content import check_embedding_quality - - result = check_embedding_quality( - tmp_duckdb_path, window_id="current_parliament", healthy_threshold=0.5 - ) - assert isinstance(result, dict) - assert result.get("healthy_threshold") == 0.5 + assert "healthy" not in result diff --git a/tests/agent_tools/test_database_tools.py b/tests/agent_tools/test_database_tools.py index 34efee5..024191e 100644 --- a/tests/agent_tools/test_database_tools.py +++ b/tests/agent_tools/test_database_tools.py @@ -73,6 +73,7 @@ class TestQueryPipelineStatus: assert "motion_count" in result assert "latest_motion_date" in result assert "svd_window_count" in result + assert "healthy" not in result class TestCrudTools: diff --git a/tests/agent_tools/test_package.py b/tests/agent_tools/test_package.py index 8ed6656..141a53d 100644 --- a/tests/agent_tools/test_package.py +++ b/tests/agent_tools/test_package.py @@ -13,9 +13,12 @@ class TestListTools: names = {t["name"] for t in result} assert "query_motions" in names - assert "pipeline_check_health" in names - assert "generate_report" in names + assert "pipeline_run_stage" in names + assert "list_recent_reports" in names assert "list_tools" in names + # Removed workflow tools + assert "pipeline_check_health" not in names + assert "generate_report" not in names def test_each_tool_has_required_fields(self): from agent_tools import list_tools diff --git a/tests/agent_tools/test_parity.py b/tests/agent_tools/test_parity.py index 8577ee9..ca88485 100644 --- a/tests/agent_tools/test_parity.py +++ b/tests/agent_tools/test_parity.py @@ -53,72 +53,28 @@ class TestDatabaseParity: agent_status = query_pipeline_status(tmp_duckdb_path) assert agent_status["motion_count"] == human_count + assert "healthy" not in agent_status class TestHealthCheckParity: - """Agent health check vs human script execution.""" + """Agent health check vs human script execution. - def test_agent_health_check_matches_script(self, tmp_duckdb_path): - """Human: python scripts/health_check.py - Agent: pipeline_check_health(db_path) - """ - from agent_tools.pipeline import pipeline_check_health - - # Agent approach - agent_result = pipeline_check_health(tmp_duckdb_path) - - assert isinstance(agent_result, dict) - assert "healthy" in agent_result - assert "checks" in agent_result - - -class TestReportGenerationParity: - """Agent report generation vs human manual analysis.""" - - def test_agent_generates_summary_report(self, tmp_duckdb_path, tmp_path): - """Human: Write a summary of pipeline state - Agent: generate_report(db_path, "summary", ...) - """ - from agent_tools.reports import generate_report + NOTE: The composite pipeline_check_health workflow has been removed. + The agent now queries raw status and makes its own health determination. + """ - output_path = str(tmp_path / "summary.md") - result = generate_report( - tmp_duckdb_path, - report_type="summary", - parameters={}, - output_path=output_path, - ) - - assert result["status"] == "written" - assert os.path.exists(output_path) - - # Should contain key sections - content = open(output_path).read() - assert "Pipeline Summary" in content - assert "Motions in database" in content - - -class TestAnalysisParity: - """Agent analysis vs human analytical queries.""" - - def test_agent_party_shift_analysis(self, tmp_duckdb_path): - """Human: Write SQL to compare party positions across windows - Agent: analyze_party_shift(db_path, ...) + def test_agent_queries_raw_status(self, tmp_duckdb_path): + """Human: python scripts/health_check.py + Agent: query_pipeline_status(db_path) + reasoning """ - from agent_tools.analysis import analyze_party_shift + from agent_tools.database import query_pipeline_status - result = analyze_party_shift( - tmp_duckdb_path, - party="VVD", - window_start="2020", - window_end="2024", - ) + status = query_pipeline_status(tmp_duckdb_path) - # Should return structured result (or error if no data) - assert isinstance(result, dict) - assert "party" in result - # Either shift data or error (empty DB is fine) - assert "shift" in result or "error" in result + assert isinstance(status, dict) + assert "motion_count" in status + assert "svd_window_count" in status + assert "healthy" not in status class TestIntegrationAgentDiagnosticLoop: @@ -126,28 +82,23 @@ class TestIntegrationAgentDiagnosticLoop: def test_agent_diagnoses_stale_data(self, tmp_duckdb_path): """Agent loop: - 1. Check health - 2. Query pipeline status - 3. Identify issue (empty DB = no data) - 4. Suggest remediation + 1. Query pipeline status + 2. Identify issue (empty DB = no data) + 3. Suggest remediation """ - from agent_tools.pipeline import pipeline_check_health from agent_tools.database import query_pipeline_status - # Step 1: Check health - health = pipeline_check_health(tmp_duckdb_path) - - # Step 2: Query status + # Step 1: Query status status = query_pipeline_status(tmp_duckdb_path) - # Step 3: Agent reasoning (simulated) + # Step 2: Agent reasoning (simulated) issues = [] if status["motion_count"] == 0: issues.append("No motions in database") if status["svd_window_count"] == 0: issues.append("No SVD windows computed") - # Step 4: Suggest remediation + # Step 3: Suggest remediation suggestions = [] if "No motions in database" in issues: suggestions.append("Run pipeline ingestion stage") diff --git a/tests/agent_tools/test_pipeline_tools.py b/tests/agent_tools/test_pipeline_tools.py index de39ada..cb7eb99 100644 --- a/tests/agent_tools/test_pipeline_tools.py +++ b/tests/agent_tools/test_pipeline_tools.py @@ -14,32 +14,6 @@ class TestPipelineRunStage: assert "stage" in result assert result.get("dry_run") is True - def test_invalid_stage_returns_error(self, tmp_duckdb_path): - from agent_tools.pipeline import pipeline_run_stage - - result = pipeline_run_stage(tmp_duckdb_path, stage="invalid") - assert isinstance(result, dict) - assert "error" in result - - -class TestPipelineRunFull: - def test_dry_run_returns_plan(self, tmp_duckdb_path): - from agent_tools.pipeline import pipeline_run_full - - result = pipeline_run_full(tmp_duckdb_path, dry_run=True) - assert isinstance(result, dict) - assert "stages" in result or "dry_run" in result - - -class TestPipelineCheckHealth: - def test_returns_health_report(self, tmp_duckdb_path): - from agent_tools.pipeline import pipeline_check_health - - result = pipeline_check_health(tmp_duckdb_path) - assert isinstance(result, dict) - assert "checks" in result - assert "healthy" in result - class TestPipelineGetLogs: def test_returns_log_lines(self, tmp_duckdb_path): @@ -48,12 +22,3 @@ class TestPipelineGetLogs: result = pipeline_get_logs(tmp_duckdb_path, stage="svd", lines=10) assert isinstance(result, list) assert len(result) <= 10 - - -class TestPipelineValidateOutput: - def test_validates_stage_output(self, tmp_duckdb_path): - from agent_tools.pipeline import pipeline_validate_output - - result = pipeline_validate_output(tmp_duckdb_path, stage="svd") - assert isinstance(result, dict) - assert "valid" in result diff --git a/tests/test_home_import.py b/tests/test_home_import.py index 086dfd4..8969297 100644 --- a/tests/test_home_import.py +++ b/tests/test_home_import.py @@ -5,7 +5,7 @@ import sys def test_home_importable(): - # Streamlit cannot run set_page_config outside of a server context, + # Streamlit cannot run navigation outside of a server context, # so we only verify the file can be parsed/compiled, not fully executed. import ast import os @@ -17,22 +17,22 @@ def test_home_importable(): # Verify the file parses as valid Python tree = ast.parse(source) - # Verify st.set_page_config is called at module level (first Streamlit command) - calls = [ + # Verify st.navigation is called (modern Streamlit multi-page API) + nav_calls = [ node for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) - and node.func.attr == "set_page_config" + and node.func.attr == "navigation" ] - assert calls, "Home.py must call st.set_page_config()" + assert nav_calls, "Home.py must call st.navigation()" - # Verify page links exist (st.page_link calls) - page_links = [ + # Verify at least 2 st.Page() calls exist (one per page) + page_calls = [ node for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) - and node.func.attr == "page_link" + and node.func.attr == "Page" ] - assert len(page_links) >= 2, "Home.py must have at least 2 st.page_link() calls" + assert len(page_calls) >= 2, "Home.py must define at least 2 st.Page() pages" diff --git a/tests/test_svd_comp1_matches_compass.py b/tests/test_svd_comp1_matches_compass.py index f6c9f95..f6ffaf8 100644 --- a/tests/test_svd_comp1_matches_compass.py +++ b/tests/test_svd_comp1_matches_compass.py @@ -52,14 +52,14 @@ def test_svd_comp1_matches_compass_for_current_parliament_with_active_filter(): def test_without_active_filter_gives_wrong_mean(): - """Without active_mps filter, get_aligned_party_scores gives wrong VVD mean. + """Without active_mps filter, get_aligned_party_scores gives a different VVD mean. - This documents the original bug: without filtering, VVD comp1 β‰ˆ 0.108 - (average of all historical VVD MPs). With filter, VVD comp1 β‰ˆ 0.335 - (only currently-seated VVD MPs, matching compass). + The unfiltered mean includes all historical VVD MPs, while the filtered + mean includes only currently-seated MPs. These must differ significantly. + The exact values drift with the database; only the delta is asserted here. + The compass-match assertion lives in the test above. """ from explorer import get_aligned_party_scores, load_active_mps - from analysis.political_axis import compute_nd_axes from analysis.explorer_data import get_uniform_dim_windows db_path = "data/motions.db" @@ -78,19 +78,13 @@ def test_without_active_filter_gives_wrong_mean(): ) vvd_with_filter = float(svd_with_filter["VVD"][0]) - # The buggy value should be significantly lower than the correct one - # (historical MPs have lower scores, dragging the mean down) + # The two values must differ significantly diff = abs(vvd_no_filter - vvd_with_filter) assert diff > 0.1, ( f"Expected large diff between unfiltered ({vvd_no_filter:.4f}) and " f"filtered ({vvd_with_filter:.4f}), got diff={diff:.4f}" ) - # The correct value should be ~0.33 (matching compass) - assert 0.30 < vvd_with_filter < 0.40, ( - f"Active-filtered VVD comp1 ({vvd_with_filter:.4f}) should be ~0.335" - ) - def test_historical_window_unchanged(): """Historical windows (e.g. '2025') should NOT be affected by active_mps filter.""" diff --git a/thoughts/ledgers/audit_events.json b/thoughts/ledgers/audit_events.json new file mode 100644 index 0000000..9e72d18 --- /dev/null +++ b/thoughts/ledgers/audit_events.json @@ -0,0 +1,343 @@ +[ + { + "id": "b9997376-b22b-46f7-ae72-05d62fca0654", + "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-05-04T18:35:35.253411Z" + }, + { + "id": "4d63d924-0fbe-4914-8d4b-854520adfc98", + "actor_id": null, + "action": "test_action", + "target_type": "unit", + "target_id": "u1", + "metadata": { + "k": 1 + }, + "created_at": "2026-05-04T18:35:35.857498Z" + }, + { + "id": "1d8df49c-8a4c-42d5-8479-16d493fd5e83", + "actor_id": null, + "action": "another_action", + "target_type": "motion", + "target_id": null, + "metadata": {}, + "created_at": "2026-05-04T18:35:35.913836Z" + }, + { + "id": "5ae3a01d-c1fd-46a0-8a68-622cbe90786e", + "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-05-04T19:05:15.463187Z" + }, + { + "id": "54168d70-b457-4908-a8e9-6bc2202b96b2", + "actor_id": null, + "action": "test_action", + "target_type": "unit", + "target_id": "u1", + "metadata": { + "k": 1 + }, + "created_at": "2026-05-04T19:05:16.264375Z" + }, + { + "id": "03312a0d-e309-4a39-b00b-92b6b7ee7ff3", + "actor_id": null, + "action": "another_action", + "target_type": "motion", + "target_id": null, + "metadata": {}, + "created_at": "2026-05-04T19:05:16.359183Z" + }, + { + "id": "afb841c6-f3a2-427c-bdd8-59613f7b76e7", + "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-05-04T19:07:13.821418Z" + }, + { + "id": "598f72d1-9613-4043-a33e-e12e0c5daeb3", + "actor_id": null, + "action": "test_action", + "target_type": "unit", + "target_id": "u1", + "metadata": { + "k": 1 + }, + "created_at": "2026-05-04T19:07:14.490337Z" + }, + { + "id": "f3bf7eda-1da9-4aac-8047-94c891975c2d", + "actor_id": null, + "action": "another_action", + "target_type": "motion", + "target_id": null, + "metadata": {}, + "created_at": "2026-05-04T19:07:14.540426Z" + }, + { + "id": "94269398-9a89-4f5b-881d-a0ca69269c71", + "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-05-04T19:21:43.038364Z" + }, + { + "id": "d00f4448-18be-4fd6-b8ad-5ebacadd379c", + "actor_id": null, + "action": "test_action", + "target_type": "unit", + "target_id": "u1", + "metadata": { + "k": 1 + }, + "created_at": "2026-05-04T19:21:43.369286Z" + }, + { + "id": "bcf3f511-35e8-4850-8c91-f4dc51944f1e", + "actor_id": null, + "action": "another_action", + "target_type": "motion", + "target_id": null, + "metadata": {}, + "created_at": "2026-05-04T19:21:43.392718Z" + }, + { + "id": "3fbaf378-9ced-46ee-a072-33a92a938ad8", + "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-05-04T19:23:01.217155Z" + }, + { + "id": "ae89efeb-e3de-4eff-b2c6-acfe900ccb4f", + "actor_id": null, + "action": "test_action", + "target_type": "unit", + "target_id": "u1", + "metadata": { + "k": 1 + }, + "created_at": "2026-05-04T19:23:01.555475Z" + }, + { + "id": "dd0728fd-281f-4900-9835-be36cbdf7d42", + "actor_id": null, + "action": "another_action", + "target_type": "motion", + "target_id": null, + "metadata": {}, + "created_at": "2026-05-04T19:23:01.578979Z" + }, + { + "id": "a21084ac-c935-42b9-a1af-9897a7b3b24a", + "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-05-04T19:29:51.282933Z" + }, + { + "id": "f5c3bc64-6eaa-45c0-beb0-eb3a5fa1f7bf", + "actor_id": null, + "action": "test_action", + "target_type": "unit", + "target_id": "u1", + "metadata": { + "k": 1 + }, + "created_at": "2026-05-04T19:29:51.867770Z" + }, + { + "id": "1fca9154-63a5-415e-afca-f385cd7fdec4", + "actor_id": null, + "action": "another_action", + "target_type": "motion", + "target_id": null, + "metadata": {}, + "created_at": "2026-05-04T19:29:51.911694Z" + }, + { + "id": "df6288ef-b571-4373-ad64-8dbe91f3f81f", + "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-05-04T19:30:36.113847Z" + }, + { + "id": "bc3a7bbc-4539-4547-bc95-1589580a7c4b", + "actor_id": null, + "action": "test_action", + "target_type": "unit", + "target_id": "u1", + "metadata": { + "k": 1 + }, + "created_at": "2026-05-04T19:30:36.655350Z" + }, + { + "id": "2c8384c5-d61c-4d3a-9652-49c4ef911e61", + "actor_id": null, + "action": "another_action", + "target_type": "motion", + "target_id": null, + "metadata": {}, + "created_at": "2026-05-04T19:30:36.694259Z" + }, + { + "id": "41b4fda9-9525-498a-abcd-30a8517c65f0", + "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-05-04T19:40:45.516057Z" + }, + { + "id": "8750e275-2230-47fc-b1d3-a624c3539895", + "actor_id": null, + "action": "test_action", + "target_type": "unit", + "target_id": "u1", + "metadata": { + "k": 1 + }, + "created_at": "2026-05-04T19:40:46.100175Z" + }, + { + "id": "4d1bda9d-abcf-4cfe-aa84-9c941ab0bbe9", + "actor_id": null, + "action": "another_action", + "target_type": "motion", + "target_id": null, + "metadata": {}, + "created_at": "2026-05-04T19:40:46.138753Z" + }, + { + "id": "cab2b3da-2a75-4370-8d3d-81f268bf7ad3", + "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-05-04T19:45:05.857880Z" + }, + { + "id": "96894d94-9c03-4e40-a3f1-2fea8102084a", + "actor_id": null, + "action": "test_action", + "target_type": "unit", + "target_id": "u1", + "metadata": { + "k": 1 + }, + "created_at": "2026-05-04T19:45:06.490973Z" + }, + { + "id": "6cec2c2e-32ec-46c9-9c62-240571fb994f", + "actor_id": null, + "action": "another_action", + "target_type": "motion", + "target_id": null, + "metadata": {}, + "created_at": "2026-05-04T19:45:06.532577Z" + }, + { + "id": "47db336d-bfde-47c9-9f2b-dbd9cd5bacb1", + "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-05-04T19:52:20.964915Z" + }, + { + "id": "336059fa-25cf-4c82-9b57-a8b1bad267cc", + "actor_id": null, + "action": "test_action", + "target_type": "unit", + "target_id": "u1", + "metadata": { + "k": 1 + }, + "created_at": "2026-05-04T19:52:21.569161Z" + }, + { + "id": "46f04f49-a318-41dd-8e90-a9de82ad7a31", + "actor_id": null, + "action": "another_action", + "target_type": "motion", + "target_id": null, + "metadata": {}, + "created_at": "2026-05-04T19:52:21.608752Z" + }, + { + "id": "22555844-a2f4-4645-90f1-a27d64943256", + "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-05-04T19:55:28.345947Z" + }, + { + "id": "05812dbd-066f-4d15-b415-e489030f0139", + "actor_id": null, + "action": "test_action", + "target_type": "unit", + "target_id": "u1", + "metadata": { + "k": 1 + }, + "created_at": "2026-05-04T19:55:29.064617Z" + }, + { + "id": "0d1ba715-edbc-4000-a139-6d108edb741b", + "actor_id": null, + "action": "another_action", + "target_type": "motion", + "target_id": null, + "metadata": {}, + "created_at": "2026-05-04T19:55:29.113927Z" + } +] \ No newline at end of file