feat: implement agent-native architecture (U1-U6)
Implements the agent-native architecture plan (docs/plans/2026-05-01-002-agent-native-architecture-plan.md): - U1: Database query primitives (agent_tools/database.py) - query_motions, query_votes, query_svd_vectors, query_party_positions, query_pipeline_status - U2: Pipeline control primitives (agent_tools/pipeline.py) - pipeline_run_stage, pipeline_run_full, pipeline_check_health, pipeline_get_logs, pipeline_validate_output - U3: Analysis & report generation (agent_tools/analysis.py, reports.py) - analyze_party_shift, analyze_axis_stability, validate_svd_labels, generate_report - U4: Content validation primitives (agent_tools/content.py) - validate_motion_coverage, validate_layman_explanations, suggest_svd_label, check_embedding_quality - U5: System prompt & context injection (SYSTEM_PROMPT.md, context.py, context.md) - U6: Parity verification tests (tests/agent_tools/test_parity.py) Tests: 238 passed, 2 skipped AGENTS.md updated to surface agent_tools/
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""Tests for agent analysis and report generation primitives."""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests for agent content validation primitives."""
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("duckdb")
|
||||
|
||||
|
||||
class TestValidateMotionCoverage:
|
||||
def test_returns_coverage_gaps(self, tmp_duckdb_path):
|
||||
from agent_tools.content import validate_motion_coverage
|
||||
|
||||
result = validate_motion_coverage(tmp_duckdb_path, start_date="2024-01-01", end_date="2024-12-31")
|
||||
assert isinstance(result, dict)
|
||||
assert "gaps" in result
|
||||
assert "coverage_rate" in result or "error" in result
|
||||
|
||||
|
||||
class TestValidateLaymanExplanations:
|
||||
def test_returns_quality_report(self, tmp_duckdb_path):
|
||||
from agent_tools.content import validate_layman_explanations
|
||||
|
||||
result = validate_layman_explanations(tmp_duckdb_path, sample_size=5)
|
||||
assert isinstance(result, dict)
|
||||
assert "sample_size" in result
|
||||
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
|
||||
|
||||
result = check_embedding_quality(tmp_duckdb_path, window_id="current_parliament")
|
||||
assert isinstance(result, dict)
|
||||
assert "coverage" in result or "error" in result
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for agent database query primitives."""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
|
||||
pytest.importorskip("duckdb")
|
||||
|
||||
|
||||
class TestQueryMotions:
|
||||
def test_returns_motion_rows(self, tmp_duckdb_path):
|
||||
from agent_tools.database import query_motions
|
||||
|
||||
result = query_motions(tmp_duckdb_path)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_respects_limit(self, tmp_duckdb_path):
|
||||
from agent_tools.database import query_motions
|
||||
|
||||
result = query_motions(tmp_duckdb_path, limit=5)
|
||||
assert len(result) <= 5
|
||||
|
||||
def test_empty_db_returns_empty_list(self, tmp_duckdb_path):
|
||||
from agent_tools.database import query_motions
|
||||
|
||||
result = query_motions(tmp_duckdb_path)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestQueryVotes:
|
||||
def test_returns_vote_counts(self, tmp_duckdb_path):
|
||||
from agent_tools.database import query_votes
|
||||
|
||||
result = query_votes(tmp_duckdb_path, motion_id=1)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_filters_by_party(self, tmp_duckdb_path):
|
||||
from agent_tools.database import query_votes
|
||||
|
||||
result = query_votes(tmp_duckdb_path, motion_id=1, party="VVD")
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
class TestQuerySvdVectors:
|
||||
def test_returns_vectors(self, tmp_duckdb_path):
|
||||
from agent_tools.database import query_svd_vectors
|
||||
|
||||
result = query_svd_vectors(tmp_duckdb_path, window_id="current_parliament")
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_filters_by_entity_type(self, tmp_duckdb_path):
|
||||
from agent_tools.database import query_svd_vectors
|
||||
|
||||
result = query_svd_vectors(
|
||||
tmp_duckdb_path, window_id="current_parliament", entity_type="mp"
|
||||
)
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
class TestQueryPartyPositions:
|
||||
def test_returns_party_scores(self, tmp_duckdb_path):
|
||||
from agent_tools.database import query_party_positions
|
||||
|
||||
result = query_party_positions(tmp_duckdb_path, window_id="current_parliament")
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
class TestQueryPipelineStatus:
|
||||
def test_returns_status_dict(self, tmp_duckdb_path):
|
||||
from agent_tools.database import query_pipeline_status
|
||||
|
||||
result = query_pipeline_status(tmp_duckdb_path)
|
||||
assert isinstance(result, dict)
|
||||
assert "motion_count" in result
|
||||
assert "latest_motion_date" in result
|
||||
assert "svd_window_count" in result
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Parity tests: verify agent tools can achieve what humans can.
|
||||
|
||||
These tests ensure the agent-native architecture satisfies the parity principle:
|
||||
"Whatever the user can do through the UI/scripts, the agent can achieve through tools."
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("duckdb")
|
||||
|
||||
|
||||
class TestDatabaseParity:
|
||||
"""Agent database queries vs human SQL queries."""
|
||||
|
||||
def test_agent_query_motions_matches_raw_sql(self, tmp_duckdb_path):
|
||||
"""Human: SELECT * FROM motions LIMIT 10
|
||||
Agent: query_motions(db_path, limit=10)
|
||||
"""
|
||||
import duckdb
|
||||
from agent_tools.database import query_motions
|
||||
|
||||
# Human approach — handle empty DB gracefully
|
||||
con = duckdb.connect(tmp_duckdb_path)
|
||||
try:
|
||||
human_result = con.execute("SELECT * FROM motions LIMIT 10").fetchdf().to_dict("records")
|
||||
except Exception:
|
||||
human_result = []
|
||||
con.close()
|
||||
|
||||
# Agent approach
|
||||
agent_result = query_motions(tmp_duckdb_path, limit=10)
|
||||
|
||||
# Both should return lists
|
||||
assert isinstance(human_result, list)
|
||||
assert isinstance(agent_result, list)
|
||||
assert len(agent_result) == len(human_result)
|
||||
|
||||
def test_agent_pipeline_status_matches_raw_query(self, tmp_duckdb_path):
|
||||
"""Human: SELECT COUNT(*) FROM motions
|
||||
Agent: query_pipeline_status(db_path)
|
||||
"""
|
||||
import duckdb
|
||||
from agent_tools.database import query_pipeline_status
|
||||
|
||||
con = duckdb.connect(tmp_duckdb_path)
|
||||
try:
|
||||
human_count = con.execute("SELECT COUNT(*) FROM motions").fetchone()[0]
|
||||
except Exception:
|
||||
human_count = 0
|
||||
con.close()
|
||||
|
||||
agent_status = query_pipeline_status(tmp_duckdb_path)
|
||||
|
||||
assert agent_status["motion_count"] == human_count
|
||||
|
||||
|
||||
class TestHealthCheckParity:
|
||||
"""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
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestIntegrationAgentDiagnosticLoop:
|
||||
"""Integration: Agent performs full diagnostic loop."""
|
||||
|
||||
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
|
||||
"""
|
||||
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
|
||||
status = query_pipeline_status(tmp_duckdb_path)
|
||||
|
||||
# Step 3: 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
|
||||
suggestions = []
|
||||
if "No motions in database" in issues:
|
||||
suggestions.append("Run pipeline ingestion stage")
|
||||
if "No SVD windows computed" in issues:
|
||||
suggestions.append("Run SVD computation after ingestion")
|
||||
|
||||
assert isinstance(issues, list)
|
||||
assert isinstance(suggestions, list)
|
||||
# Empty DB should produce actionable suggestions
|
||||
assert len(suggestions) > 0
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for agent pipeline control primitives."""
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("duckdb")
|
||||
|
||||
|
||||
class TestPipelineRunStage:
|
||||
def test_dry_run_returns_planned_actions(self, tmp_duckdb_path):
|
||||
from agent_tools.pipeline import pipeline_run_stage
|
||||
|
||||
result = pipeline_run_stage(tmp_duckdb_path, stage="svd", window_id="2024", dry_run=True)
|
||||
assert isinstance(result, dict)
|
||||
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):
|
||||
from agent_tools.pipeline import pipeline_get_logs
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user