feat: agent-native refactor, SVD consistency fixes, UX cleanup, mobile support
- Refactor agent_tools to atomic primitives (24 tools, delete workflows) - Fix SVD component score inconsistency between single-window and trajectory views (same PCA basis, same flip handling, same active-MP filter for current_parliament) - Fix Dutch spelling: Huidig parliament -> Huidig parlement - Remove all decorative emojis from UI (app.py, explorer.py, analysis tabs) - Add dark theme matching sgeboers.nl (mint accent on dark background) - Remove browser tab favicon and Streamlit chrome (deploy button, running status) - Remove trajectories debug UI and EMA settings (hardcoded smooth_alpha=0.35) - Switch layout to centered for mobile readability - Add responsive CSS for mobile (touch targets, font sizing, overflow prevention) - Update AGENTS.md and SYSTEM_PROMPT.md with active tool instructions - Add compound docs for SVD consistency bug - Update tests: 214 passed, 3 skipped
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
NOTE: The composite pipeline_check_health workflow has been removed.
|
||||
The agent now queries raw status and makes its own health determination.
|
||||
"""
|
||||
|
||||
def test_agent_queries_raw_status(self, tmp_duckdb_path):
|
||||
"""Human: python scripts/health_check.py
|
||||
Agent: pipeline_check_health(db_path)
|
||||
Agent: query_pipeline_status(db_path) + reasoning
|
||||
"""
|
||||
from agent_tools.pipeline import pipeline_check_health
|
||||
from agent_tools.database import query_pipeline_status
|
||||
|
||||
# Agent approach
|
||||
agent_result = pipeline_check_health(tmp_duckdb_path)
|
||||
status = query_pipeline_status(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
|
||||
|
||||
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, ...)
|
||||
"""
|
||||
from agent_tools.analysis import analyze_party_shift
|
||||
|
||||
result = analyze_party_shift(
|
||||
tmp_duckdb_path,
|
||||
party="VVD",
|
||||
window_start="2020",
|
||||
window_end="2024",
|
||||
)
|
||||
|
||||
# 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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user