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
main
Sven Geboers 4 weeks ago
parent efb3a8fbd2
commit 272d839a42
  1. 9
      .streamlit/config.toml
  2. 4
      AGENTS.md
  3. 82
      Home.py
  4. 49
      agent_tools/SYSTEM_PROMPT.md
  5. 59
      agent_tools/__init__.py
  6. 174
      agent_tools/analysis.py
  7. 62
      agent_tools/content.py
  8. 66
      agent_tools/context.py
  9. 28
      agent_tools/database.py
  10. 138
      agent_tools/pipeline.py
  11. 151
      agent_tools/reports.py
  12. 17
      analysis/explorer_data.py
  13. 13
      analysis/tabs/_rendering.py
  14. 4
      analysis/tabs/browser.py
  15. 2
      analysis/tabs/compass.py
  16. 18
      analysis/tabs/components.py
  17. 2
      analysis/tabs/quiz.py
  18. 4
      analysis/tabs/search.py
  19. 108
      analysis/tabs/trajectories.py
  20. 45
      app.py
  21. 147
      docs/solutions/logic-errors/svd-component-scores-inconsistent-between-views-2026-05-04.md
  22. 108
      docs/solutions/ui-bugs/svd-time-trajectory-score-mismatch.md
  23. 45
      explorer.py
  24. 8
      pages/1_Stemwijzer.py
  25. 76
      tests/agent_tools/test_analysis_tools.py
  26. 20
      tests/agent_tools/test_content_tools.py
  27. 1
      tests/agent_tools/test_database_tools.py
  28. 7
      tests/agent_tools/test_package.py
  29. 87
      tests/agent_tools/test_parity.py
  30. 35
      tests/agent_tools/test_pipeline_tools.py
  31. 18
      tests/test_home_import.py
  32. 18
      tests/test_svd_comp1_matches_compass.py
  33. 343
      thoughts/ledgers/audit_events.json

@ -0,0 +1,9 @@
[theme]
primaryColor = "#00d9a3"
backgroundColor = "#0d1117"
secondaryBackgroundColor = "#161b22"
textColor = "#e6edf3"
font = "sans serif"
[ui]
showDeployButton = false

@ -10,7 +10,9 @@
## Agent Tools ## 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 ## Project Conventions

@ -1,53 +1,51 @@
"""StemAtlas — home page. """StemAtlas — navigation entry point.
Entry point for the Streamlit multi-page app. Shows a landing page with Uses st.navigation() for explicit control over page order and default page.
brief descriptions of and links to the two sub-pages. Run with: uv run streamlit run Home.py
""" """
import streamlit as st import streamlit as st
st.set_page_config( st.set_page_config(
page_title="Motief: de stematlas", page_title="StemAtlas",
page_icon="🗺", page_icon=None,
layout="centered", layout="centered",
initial_sidebar_state="expanded",
) )
# Hide Streamlit chrome and add mobile-friendly styles.
st.markdown(
"""
<style>
.stAppDeployButton { display: none !important; }
.stStatusWidget { display: none !important; }
header [data-testid="stToolbar"] { display: none !important; }
/* Mobile-friendly touch targets and readability */
@media (max-width: 768px) {
.stButton button {
min-height: 48px !important;
font-size: 16px !important;
}
.stRadio label {
font-size: 16px !important;
}
.stSelectbox label, .stSlider label, .stNumberInput label {
font-size: 15px !important;
}
h1 { font-size: 1.6rem !important; }
h2 { font-size: 1.3rem !important; }
h3 { font-size: 1.1rem !important; }
}
/* Prevent horizontal overflow */
.stApp { max-width: 100vw; overflow-x: hidden; }
</style>
""",
unsafe_allow_html=True,
)
def main() -> None: explorer = st.Page("pages/2_Explorer.py", title="Explorer", default=True)
st.title("🗺 Motief: de stematlas") stemwijzer = st.Page("pages/1_Stemwijzer.py", title="Stemwijzer")
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)"
)
main() pg = st.navigation([explorer, stemwijzer])
pg.run()

@ -11,50 +11,51 @@ You are the **Stemwijzer Pipeline Operator** — an autonomous agent that operat
## Your Capabilities ## 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`) ### 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_votes(db_path, motion_id, party)` — Query votes for a motion
- `query_svd_vectors(db_path, window_id, entity_type)` — Query SVD vectors - `query_svd_vectors(db_path, window_id, entity_type)` — Query SVD vectors
- `query_party_positions(db_path, window_id)` — Query party axis scores - `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 Control (`agent_tools.pipeline`)
- `pipeline_run_stage(db_path, stage, window_id, dry_run)` — Run one pipeline stage - `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_get_logs(stage, lines)` — Get recent log output for a stage
- `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
### Content Validation (`agent_tools.content`) ### Content Validation (`agent_tools.content`)
- `validate_motion_coverage(db_path, start_date, end_date)` — Find data gaps - `validate_motion_coverage(db_path, start_date, end_date)` — Find data gaps
- `validate_layman_explanations(db_path, sample_size)` — Check explanation quality - `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 - `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 ## 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 ### When to run the pipeline
- Data is stale (> 7 days since last motion) - Data is stale (> 7 days since last motion)
- Health checks show `healthy: false` - Pipeline status shows gaps or failures
- User explicitly requests fresh data - 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 ### When to validate content
- After pipeline runs (automated quality gate) - After pipeline runs
- When SVD labels look suspicious - When SVD labels look suspicious
- Before publishing analysis to users - 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 operate in the same trust boundary as the developer
- You can read the full database but write only to `reports/` and `context.md` - You can read the full database but write only to `reports/` and `context.md`
- You cannot delete data or modify pipeline logic - 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..."

@ -5,23 +5,13 @@ Import individual modules or use `list_tools()` for runtime discovery.
from __future__ import annotations 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 ( from agent_tools.context import (
append_context_note, append_context_note,
build_context, list_recent_reports,
render_context_markdown, read_context_md,
) )
from agent_tools.database import ( from agent_tools.database import (
compute_party_positions_from_vectors,
create_motion, create_motion,
delete_report, delete_report,
query_compass_positions, query_compass_positions,
@ -35,13 +25,9 @@ from agent_tools.database import (
update_motion, update_motion,
) )
from agent_tools.pipeline import ( from agent_tools.pipeline import (
pipeline_check_health,
pipeline_get_logs, pipeline_get_logs,
pipeline_run_full,
pipeline_run_stage, pipeline_run_stage,
pipeline_validate_output,
) )
from agent_tools.reports import generate_report
__all__ = [ __all__ = [
# Database # Database
@ -49,6 +35,7 @@ __all__ = [
"query_votes", "query_votes",
"query_svd_vectors", "query_svd_vectors",
"query_party_positions", "query_party_positions",
"compute_party_positions_from_vectors",
"query_pipeline_status", "query_pipeline_status",
"query_embeddings", "query_embeddings",
"query_similar_motions", "query_similar_motions",
@ -58,24 +45,10 @@ __all__ = [
"delete_report", "delete_report",
# Pipeline # Pipeline
"pipeline_run_stage", "pipeline_run_stage",
"pipeline_run_full",
"pipeline_check_health",
"pipeline_get_logs", "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 # Context
"build_context", "list_recent_reports",
"render_context_markdown", "read_context_md",
"append_context_note", "append_context_note",
# Discovery # Discovery
"list_tools", "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_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_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_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_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_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": "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": "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": "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": "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_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_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_get_logs", "signature": "pipeline_get_logs(stage, lines=50)", "description": "Retrieve recent log output for a stage."}, {"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": "list_recent_reports", "signature": "list_recent_reports()", "description": "List recently generated report files."},
{"name": "analyze_party_shift", "signature": "analyze_party_shift(db_path, party, window_start, window_end)", "description": "Compute party position shift between two windows."}, {"name": "read_context_md", "signature": "read_context_md()", "description": "Read accumulated agent knowledge from context.md."},
{"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": "append_context_note", "signature": "append_context_note(note)", "description": "Append a note to the accumulated agent knowledge."}, {"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."}, {"name": "list_tools", "signature": "list_tools()", "description": "Return a list of all available agent tools."},
] ]

@ -1,170 +1,10 @@
"""Analysis primitives for agent operation. """Analysis primitives for agent operation.
High-level analytical tools that compose database queries with NOTE: Multi-step analytical workflows (party shift, axis stability, SVD label
statistical computation to answer research questions. 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.
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
if len(vectors_by_window) < 2: This module is intentionally empty. If needed, pure computational helpers
return { (without business logic) can be added here.
"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)}

@ -7,7 +7,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timedelta 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 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)} 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( def check_embedding_quality(
db_path: str, db_path: str,
window_id: str, window_id: str,
healthy_threshold: float = 0.8,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Check embedding coverage and quality for a window. """Check embedding coverage 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.
Returns coverage stats for fused embeddings. Returns raw coverage stats. The agent decides whether coverage is acceptable.
""" """
try: try:
vectors = query_svd_vectors(db_path, window_id, entity_type="motion") vectors = query_svd_vectors(db_path, window_id, entity_type="motion")
@ -181,8 +127,6 @@ def check_embedding_quality(
"total_motions": total_motions, "total_motions": total_motions,
"with_embeddings": with_embeddings, "with_embeddings": with_embeddings,
"coverage": coverage, "coverage": coverage,
"healthy": coverage > healthy_threshold,
"healthy_threshold": healthy_threshold,
} }
except Exception as e: except Exception as e:
logger.exception("check_embedding_quality failed") logger.exception("check_embedding_quality failed")

@ -1,7 +1,6 @@
"""Runtime context injection for agent operation. """Runtime context injection for agent operation.
Generates dynamic context about the current pipeline state, Filesystem primitives for managing agent accumulated knowledge.
recent issues, and accumulated knowledge.
""" """
from __future__ import annotations from __future__ import annotations
@ -9,69 +8,12 @@ from __future__ import annotations
import logging import logging
import os import os
from datetime import datetime from datetime import datetime
from typing import Any, Dict from typing import List
from agent_tools.database import query_pipeline_status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def build_context(db_path: str) -> Dict[str, Any]: def list_recent_reports() -> List[str]:
"""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:
"""List recently generated reports.""" """List recently generated reports."""
try: try:
reports_dir = "reports" reports_dir = "reports"
@ -87,7 +29,7 @@ def _list_recent_reports() -> list:
return [] return []
def _read_context_md() -> str: def read_context_md() -> str:
"""Read accumulated knowledge from context.md.""" """Read accumulated knowledge from context.md."""
try: try:
path = os.path.join("agent_tools", "context.md") path = os.path.join("agent_tools", "context.md")

@ -121,12 +121,14 @@ def query_party_positions(
"""Query party axis scores for a window.""" """Query party axis scores for a window."""
try: try:
con = _connect(db_path) con = _connect(db_path)
# Check if party_axis_scores table exists
tables = con.execute( tables = con.execute(
"SELECT table_name FROM information_schema.tables WHERE table_name = 'party_axis_scores'" "SELECT table_name FROM information_schema.tables WHERE table_name = 'party_axis_scores'"
).fetchall() ).fetchall()
if tables: if not tables:
con.close()
return []
result = con.execute( result = con.execute(
""" """
SELECT party, axis, score SELECT party, axis, score
@ -135,9 +137,6 @@ def query_party_positions(
""", """,
(window_id,), (window_id,),
).fetchdf().to_dict("records") ).fetchdf().to_dict("records")
else:
# Fallback: compute from vectors
result = _compute_party_positions_from_vectors(con, window_id)
con.close() con.close()
return result return result
except Exception: except Exception:
@ -145,8 +144,17 @@ def query_party_positions(
return [] return []
def _compute_party_positions_from_vectors(con, window_id: str) -> List[Dict[str, Any]]: 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.""" """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( rows = con.execute(
""" """
SELECT sv.entity_id, sv.vector, mm.party 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(): for party, vectors in party_vectors.items():
if not vectors: if not vectors:
continue continue
# Compute mean position across first 2 components
dim = len(vectors[0]) dim = len(vectors[0])
mean = [sum(v[i] for v in vectors) / len(vectors) for i in range(min(dim, 2))] mean = [sum(v[i] for v in vectors) / len(vectors) for i in range(min(dim, 2))]
result.append({ 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, "axis_2": mean[1] if len(mean) > 1 else 0.0,
}) })
if should_close:
con.close()
return result 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, "latest_motion_date": str(latest_motion_date) if latest_motion_date else None,
"svd_window_count": svd_windows, "svd_window_count": svd_windows,
"embedding_count": embedding_count, "embedding_count": embedding_count,
"motion_count": motion_count,
"svd_window_count": svd_windows,
} }
except Exception: except Exception:
logger.exception("query_pipeline_status failed") logger.exception("query_pipeline_status failed")

@ -1,6 +1,6 @@
"""Pipeline control primitives for agent operation. """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 from __future__ import annotations
@ -8,12 +8,8 @@ from __future__ import annotations
import logging import logging
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from agent_tools.database import query_pipeline_status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
VALID_STAGES = {"ingestion", "votes", "svd", "text_embeddings", "fusion", "similarity"}
def pipeline_run_stage( def pipeline_run_stage(
db_path: str, db_path: str,
@ -25,18 +21,13 @@ def pipeline_run_stage(
Args: Args:
db_path: Path to DuckDB database db_path: Path to DuckDB database
stage: One of VALID_STAGES stage: Pipeline stage name (e.g. "ingestion", "svd", "similarity")
window_id: Optional window identifier (e.g., "2024", "current_parliament") window_id: Optional window identifier (e.g. "2024", "current_parliament")
dry_run: If True, return planned actions without executing dry_run: If True, return planned actions without executing
Returns: Returns:
dict with status and metadata dict with status and metadata
""" """
if stage not in VALID_STAGES:
return {
"error": f"Invalid stage '{stage}'. Valid stages: {sorted(VALID_STAGES)}",
}
result = { result = {
"stage": stage, "stage": stage,
"window_id": window_id, "window_id": window_id,
@ -53,86 +44,6 @@ def pipeline_run_stage(
return result 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( def pipeline_get_logs(
db_path: str, db_path: str,
stage: Optional[str] = None, stage: Optional[str] = None,
@ -147,46 +58,3 @@ def pipeline_get_logs(
# Real implementation would read from logging infrastructure # Real implementation would read from logging infrastructure
logger.info("pipeline_get_logs requested for stage=%s lines=%d", stage, lines) logger.info("pipeline_get_logs requested for stage=%s lines=%d", stage, lines)
return [] 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)}

@ -1,149 +1,8 @@
"""Report generation primitives for agent operation. """Report generation primitives for agent operation.
Agents call these to write structured markdown reports to the reports/ directory. 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.
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)}
This module is intentionally empty.
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)

@ -718,16 +718,23 @@ def _get_aligned_trajectory_scores(
Uses compute_nd_axes to get PCA-projected, flip-corrected scores across all windows, Uses compute_nd_axes to get PCA-projected, flip-corrected scores across all windows,
ensuring consistency with the single-window SVD components view. 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 from analysis.political_axis import compute_nd_axes
all_uniform_windows = get_uniform_dim_windows(db_path)
scores_by_window, _ = compute_nd_axes( 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: if not scores_by_window:
return {} return {}
party_map = load_party_map(db_path) party_map = load_party_map(db_path)
active_mps = load_active_mps(db_path)
result: Dict[str, Dict[str, List[float]]] = {} result: Dict[str, Dict[str, List[float]]] = {}
for window in windows: for window in windows:
@ -735,6 +742,14 @@ def _get_aligned_trajectory_scores(
if not window_scores: if not window_scores:
continue 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]] = {} party_vecs: Dict[str, List[np.ndarray]] = {}
for mp_name, scores in window_scores.items(): for mp_name, scores in window_scores.items():
party = party_map.get( party = party_map.get(

@ -612,6 +612,7 @@ def _render_svd_time_trajectory(
return return
idx = comp_sel - 1 idx = comp_sel - 1
flip = theme.get("flip", False)
party_trajectories: Dict[str, List[Tuple[str, float]]] = {} party_trajectories: Dict[str, List[Tuple[str, float]]] = {}
@ -631,6 +632,8 @@ def _render_svd_time_trajectory(
if scores and len(scores) > idx: if scores and len(scores) > idx:
try: try:
score = float(scores[idx]) score = float(scores[idx])
if flip:
score = -score
party_trajectories.setdefault(party, []).append((window, score)) party_trajectories.setdefault(party, []).append((window, score))
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
@ -766,15 +769,13 @@ def _render_voting_results(voting_results_json) -> None:
vote_str = str(vote).lower().strip() vote_str = str(vote).lower().strip()
by_vote.setdefault(vote_str, []).append(str(actor)) by_vote.setdefault(vote_str, []).append(str(actor))
vote_order = ["voor", "tegen", "onthouden", "afwezig"] vote_order = ["voor", "tegen", "onthouden", "afwezig"]
vote_emoji = {"voor": "", "tegen": "", "onthouden": "🟡", "afwezig": ""}
rows_shown = False rows_shown = False
for v in vote_order + [k for k in by_vote if k not in vote_order]: for v in vote_order + [k for k in by_vote if k not in vote_order]:
actors = by_vote.get(v) actors = by_vote.get(v)
if not actors: if not actors:
continue continue
emoji = vote_emoji.get(v, "")
st.markdown( st.markdown(
f"**{emoji} {v.capitalize()}** ({len(actors)}): {', '.join(sorted(actors))}" f"**{v.capitalize()}** ({len(actors)}): {', '.join(sorted(actors))}"
) )
rows_shown = True rows_shown = True
if not rows_shown: 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: 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( common = dict(
xref="paper", xref="paper",
yref="paper", yref="paper",
@ -792,5 +793,5 @@ def _add_y_direction_annotations(fig: go.Figure) -> None:
showarrow=False, showarrow=False,
font=dict(size=11, color="#666666"), font=dict(size=11, color="#666666"),
) )
fig.add_annotation(**common, y=1.02, text="Progressief", xanchor="center") 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=-0.06, text="Conservatief", xanchor="center")

@ -74,12 +74,12 @@ def build_browser_tab(db_path: str, show_rejected: bool) -> None:
st.markdown(f"### {row.get('title') or 'Onbekend'}") st.markdown(f"### {row.get('title') or 'Onbekend'}")
date_str = row["date"].strftime("%d %b %Y") if pd.notna(row["date"]) else "?" date_str = row["date"].strftime("%d %b %Y") if pd.notna(row["date"]) else "?"
st.caption( 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") url = row.get("url")
if url and str(url).startswith("http"): 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:**") st.markdown("**Stemuitslag:**")
_render_voting_results(row.get("voting_results")) _render_voting_results(row.get("voting_results"))

@ -51,8 +51,6 @@ def build_compass_tab(db_path: str, window_size: str) -> None:
def _window_label(w: str) -> str: def _window_label(w: str) -> str:
if w == "current_parliament": if w == "current_parliament":
return "Huidig parlement" return "Huidig parlement"
if w in _SPARSE_YEARS:
return f"{w}"
return w return w
col1, col2 = st.columns([3, 1]) col1, col2 = st.columns([3, 1])

@ -39,7 +39,7 @@ def build_svd_components_tab(db_path: str) -> None:
Components 1-2 use aligned PCA positions (consistent with compass). Components 1-2 use aligned PCA positions (consistent with compass).
Components 3-10 use raw SVD scores. Components 3-10 use raw SVD scores.
""" """
st.subheader("🔬 SVD Assen — politieke polarisatiethema's") st.subheader("SVD Assen — politieke polarisatiethema's")
st.markdown( st.markdown(
"Elke SVD-as representeert een latente politieke dimensie afgeleid uit stempatronen " "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 " "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: def _svd_window_label(w: str) -> str:
if w == "current_parliament": if w == "current_parliament":
return "Huidig parliament" return "Huidig parlement"
return w return w
with col1: with col1:
@ -321,11 +321,9 @@ def build_svd_components_tab(db_path: str) -> None:
if flip: if flip:
left_pole, right_pole = pos_pole, neg_pole left_pole, right_pole = pos_pole, neg_pole
left_motions, right_motions = pos_motions, neg_motions left_motions, right_motions = pos_motions, neg_motions
left_arrow, right_arrow = "", ""
else: else:
left_pole, right_pole = neg_pole, pos_pole left_pole, right_pole = neg_pole, pos_pole
left_motions, right_motions = neg_motions, pos_motions left_motions, right_motions = neg_motions, pos_motions
left_arrow, right_arrow = "", ""
lcol, rcol = st.columns(2) lcol, rcol = st.columns(2)
@ -334,16 +332,16 @@ def build_svd_components_tab(db_path: str) -> None:
for m in left_motions: for m in left_motions:
mid = m.get("motion_id") mid = m.get("motion_id")
raw_title = m.get("title") or f"Motie #{mid}" 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 row = motion_details.get(int(mid)) if mid is not None else None
if row: if row:
try: try:
date_str = str(row[2])[:10] date_str = str(row[2])[:10]
except Exception: except Exception:
date_str = "?" 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"): 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]: if row[5]:
with st.expander("Toon volledige tekst"): with st.expander("Toon volledige tekst"):
st.write(row[5]) st.write(row[5])
@ -356,16 +354,16 @@ def build_svd_components_tab(db_path: str) -> None:
for m in right_motions: for m in right_motions:
mid = m.get("motion_id") mid = m.get("motion_id")
raw_title = m.get("title") or f"Motie #{mid}" 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 row = motion_details.get(int(mid)) if mid is not None else None
if row: if row:
try: try:
date_str = str(row[2])[:10] date_str = str(row[2])[:10]
except Exception: except Exception:
date_str = "?" 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"): 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]: if row[5]:
with st.expander("Toon volledige tekst"): with st.expander("Toon volledige tekst"):
st.write(row[5]) st.write(row[5])

@ -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 - if multiple candidates remain, call choose_discriminating_motions to pick next question
- stop when unique MP found or no discriminating motions remain - 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( st.markdown(
"Beantwoord een paar eenvoudige ja/nee/onthoud vragen over moties om te zien welk Kamerlid het meest op jou lijkt." "Beantwoord een paar eenvoudige ja/nee/onthoud vragen over moties om te zien welk Kamerlid het meest op jou lijkt."
) )

@ -56,7 +56,7 @@ def build_search_tab(db_path: str, show_rejected: bool) -> None:
title = row.get("title") or f"Motie #{row['id']}" title = row.get("title") or f"Motie #{row['id']}"
date_str = row["date"].strftime("%d %b %Y") if pd.notna(row["date"]) else "?" date_str = row["date"].strftime("%d %b %Y") if pd.notna(row["date"]) else "?"
controversy = row.get("controversy_score") or 0 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 = st.columns(3)
cols[0].metric("Controverse", f"{controversy:.2f}") cols[0].metric("Controverse", f"{controversy:.2f}")
cols[1].metric("Marge", f"{row.get('winning_margin', 0):.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") url = row.get("url")
if url and str(url).startswith("http"): 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) sim = explorer_data.query_similar(db_path, int(row["id"]), top_k=5)
if not sim.empty: if not sim.empty:

@ -516,57 +516,7 @@ def build_trajectories_tab(db_path: str, window_size: str) -> None:
st.plotly_chart(fig, use_container_width=True) st.plotly_chart(fig, use_container_width=True)
return return
try: smooth_alpha = 0.35
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."
),
)
def _spline_smooth(values: List[float]) -> List[float]: def _spline_smooth(values: List[float]) -> List[float]:
n = len(values) n = len(values)
@ -712,63 +662,9 @@ def build_trajectories_tab(db_path: str, window_size: str) -> None:
"sample_size": len(sample_mps), "sample_size": len(sample_mps),
} }
if trace_count == 0: if trace_count == 0:
st.info("📊 **Geen trajecten getekend**") 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)
else: else:
try: 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) st.plotly_chart(fig, use_container_width=True)
except Exception as e: except Exception as e:
st.error(f"Trajectories rendering failed: {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,
)

@ -7,14 +7,8 @@ from summarizer import summarizer
from config import config from config import config
import json import json
# Page config
st.set_page_config(
page_title="Nederlandse Politieke Kompas", page_icon="🇳🇱", layout="wide"
)
def main(): def main():
st.title("🇳🇱 Nederlandse Politieke Kompas") st.title("Nederlandse Politieke Kompas")
st.markdown( st.markdown(
"Ontdek welke politieke partij het beste bij jouw idealen past door te stemmen op echte Tweede Kamer moties." "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""" st.markdown(f"""
**Jouw instellingen:** **Jouw instellingen:**
- 📊 **{motion_count} moties** uit het beleidsgebied **{policy_area}** - **{motion_count} moties** uit het beleidsgebied **{policy_area}**
- 🎯 **Controversiële moties** tussen {margin_range[0]}% en {margin_range[1]}% marge - **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. Klik op "Start Nieuwe Sessie" in de zijbalk om te beginnen met stemmen.
""") """)
@ -144,35 +138,32 @@ def show_motion_interface():
# Layman explanation (prominent) # Layman explanation (prominent)
if motion.get("layman_explanation"): if motion.get("layman_explanation"):
st.markdown("### 📝 Uitleg in begrijpelijke taal:") st.markdown("### Uitleg in begrijpelijke taal:")
st.markdown(f"*{motion['layman_explanation']}*") st.markdown(f"*{motion['layman_explanation']}*")
# Original description (collapsible) # Original description (collapsible)
motion_text = motion.get("body_text") or motion.get("description", "") motion_text = motion.get("body_text") or motion.get("description", "")
if motion_text: if motion_text:
label = ( label = (
"📋 Volledige motietekst" "Volledige motietekst"
if motion.get("body_text") if motion.get("body_text")
else "📋 Originele motiebeschrijving" else "Originele motiebeschrijving"
) )
with st.expander(label): with st.expander(label):
st.write(motion_text) st.write(motion_text)
# Voting buttons # Voting buttons
st.markdown("### 🗳 Hoe zou jij stemmen?") st.markdown("### Hoe zou jij stemmen?")
col1, col2, col3 = st.columns(3) col1, col2, col3 = st.columns(3)
with col1: with col1:
if st.button("✅ Voor", use_container_width=True, type="primary"): if st.button("Voor", use_container_width=True, type="primary"):
cast_vote("Voor") record_vote("voor")
with col2: with col2:
if st.button("Tegen", use_container_width=True): if st.button("Tegen", use_container_width=True):
cast_vote("Tegen") cast_vote("Tegen")
with col3: with col3:
if st.button("🚫 Geen stem", use_container_width=True): if st.button("Geen stem", use_container_width=True):
cast_vote("Geen stem") cast_vote("Geen stem")
@ -190,7 +181,7 @@ def cast_vote(vote_choice):
def show_results(): def show_results():
"""Show voting results and party matches""" """Show voting results and party matches"""
st.header("🎯 Jouw Resultaten") st.header("Jouw Resultaten")
# Calculate party matches # Calculate party matches
party_matches = db.calculate_party_matches(st.session_state.session_id) party_matches = db.calculate_party_matches(st.session_state.session_id)
@ -200,7 +191,7 @@ def show_results():
return return
# Party ranking table # 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 = pd.DataFrame(party_matches)
df.columns = ["Partij", "Overeenkomst %", "Eens", "Totaal"] df.columns = ["Partij", "Overeenkomst %", "Eens", "Totaal"]
@ -220,15 +211,15 @@ def show_results():
# Top match highlight # Top match highlight
top_match = party_matches[0] top_match = party_matches[0]
st.success( 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 # Detailed motion overview
st.subheader("📋 Gedetailleerd Overzicht per Motie") st.subheader("Gedetailleerd Overzicht per Motie")
show_detailed_motion_results() show_detailed_motion_results()
# New session button # New session button
if st.button("🔄 Start Nieuwe Sessie"): if st.button("Start Nieuwe Sessie"):
# Clear session state # Clear session state
for key in ["session_id", "motions", "current_motion_index", "show_results"]: for key in ["session_id", "motions", "current_motion_index", "show_results"]:
if key in st.session_state: if key in st.session_state:
@ -281,13 +272,13 @@ def show_detailed_motion_results():
with st.expander(f"**{title}** (Jouw stem: {user_vote})"): with st.expander(f"**{title}** (Jouw stem: {user_vote})"):
# Show layman explanation prominently # Show layman explanation prominently
if layman_explanation: if layman_explanation:
st.markdown("**📝 Uitleg:**") st.markdown("**Uitleg:**")
st.markdown(f"*{layman_explanation}*") st.markdown(f"*{layman_explanation}*")
# Show full motion body text if available, otherwise description # Show full motion body text if available, otherwise description
motion_text = body_text or description motion_text = body_text or description
if motion_text: if motion_text:
st.markdown("**📋 Motiebeschrijving:**") st.markdown("**Motiebeschrijving:**")
st.write(motion_text) st.write(motion_text)
# Create voting overview # Create voting overview

@ -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

@ -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)

@ -421,39 +421,30 @@ def build_mp_quiz_tab(*args, **kwargs):
return _impl(*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: def run_app() -> None:
st.set_page_config( st.title("Parlement Explorer")
layout="wide",
page_title="Parlement Explorer",
page_icon="🏛",
)
st.title("🏛 Parlement Explorer")
st.sidebar.title("Instellingen")
db_path = "data/motions.db" db_path = "data/motions.db"
window_size = "annual" 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 = [ tab_labels = [
"🧭 Politiek Kompas", "Politiek Kompas",
"📈 Trajectories", "Trajectories",
"🔬 SVD Components", "SVD Components",
] ]
if hasattr(st, "tabs") and callable(getattr(st, "tabs")): if hasattr(st, "tabs") and callable(getattr(st, "tabs")):

@ -1,13 +1,5 @@
"""Stemwijzer page — quiz to find your matching MP.""" """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 from explorer import build_mp_quiz_tab
build_mp_quiz_tab("data/motions.db") build_mp_quiz_tab("data/motions.db")

@ -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 pytest
import os
pytest.importorskip("duckdb") 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 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: class TestCheckEmbeddingQuality:
def test_returns_coverage_stats(self, tmp_duckdb_path): def test_returns_coverage_stats(self, tmp_duckdb_path):
from agent_tools.content import check_embedding_quality 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") result = check_embedding_quality(tmp_duckdb_path, window_id="current_parliament")
assert isinstance(result, dict) assert isinstance(result, dict)
assert "coverage" in result or "error" in result assert "coverage" in result or "error" in result
assert "healthy" not 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

@ -73,6 +73,7 @@ class TestQueryPipelineStatus:
assert "motion_count" in result assert "motion_count" in result
assert "latest_motion_date" in result assert "latest_motion_date" in result
assert "svd_window_count" in result assert "svd_window_count" in result
assert "healthy" not in result
class TestCrudTools: class TestCrudTools:

@ -13,9 +13,12 @@ class TestListTools:
names = {t["name"] for t in result} names = {t["name"] for t in result}
assert "query_motions" in names assert "query_motions" in names
assert "pipeline_check_health" in names assert "pipeline_run_stage" in names
assert "generate_report" in names assert "list_recent_reports" in names
assert "list_tools" 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): def test_each_tool_has_required_fields(self):
from agent_tools import list_tools from agent_tools import list_tools

@ -53,72 +53,28 @@ class TestDatabaseParity:
agent_status = query_pipeline_status(tmp_duckdb_path) agent_status = query_pipeline_status(tmp_duckdb_path)
assert agent_status["motion_count"] == human_count assert agent_status["motion_count"] == human_count
assert "healthy" not in agent_status
class TestHealthCheckParity: 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.
"""Human: python scripts/health_check.py The agent now queries raw status and makes its own health determination.
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" def test_agent_queries_raw_status(self, tmp_duckdb_path):
assert os.path.exists(output_path) """Human: python scripts/health_check.py
Agent: query_pipeline_status(db_path) + reasoning
# 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 from agent_tools.database import query_pipeline_status
result = analyze_party_shift( status = query_pipeline_status(tmp_duckdb_path)
tmp_duckdb_path,
party="VVD",
window_start="2020",
window_end="2024",
)
# Should return structured result (or error if no data) assert isinstance(status, dict)
assert isinstance(result, dict) assert "motion_count" in status
assert "party" in result assert "svd_window_count" in status
# Either shift data or error (empty DB is fine) assert "healthy" not in status
assert "shift" in result or "error" in result
class TestIntegrationAgentDiagnosticLoop: class TestIntegrationAgentDiagnosticLoop:
@ -126,28 +82,23 @@ class TestIntegrationAgentDiagnosticLoop:
def test_agent_diagnoses_stale_data(self, tmp_duckdb_path): def test_agent_diagnoses_stale_data(self, tmp_duckdb_path):
"""Agent loop: """Agent loop:
1. Check health 1. Query pipeline status
2. Query pipeline status 2. Identify issue (empty DB = no data)
3. Identify issue (empty DB = no data) 3. Suggest remediation
4. Suggest remediation
""" """
from agent_tools.pipeline import pipeline_check_health
from agent_tools.database import query_pipeline_status from agent_tools.database import query_pipeline_status
# Step 1: Check health # Step 1: Query status
health = pipeline_check_health(tmp_duckdb_path)
# Step 2: Query status
status = query_pipeline_status(tmp_duckdb_path) status = query_pipeline_status(tmp_duckdb_path)
# Step 3: Agent reasoning (simulated) # Step 2: Agent reasoning (simulated)
issues = [] issues = []
if status["motion_count"] == 0: if status["motion_count"] == 0:
issues.append("No motions in database") issues.append("No motions in database")
if status["svd_window_count"] == 0: if status["svd_window_count"] == 0:
issues.append("No SVD windows computed") issues.append("No SVD windows computed")
# Step 4: Suggest remediation # Step 3: Suggest remediation
suggestions = [] suggestions = []
if "No motions in database" in issues: if "No motions in database" in issues:
suggestions.append("Run pipeline ingestion stage") suggestions.append("Run pipeline ingestion stage")

@ -14,32 +14,6 @@ class TestPipelineRunStage:
assert "stage" in result assert "stage" in result
assert result.get("dry_run") is True 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: class TestPipelineGetLogs:
def test_returns_log_lines(self, tmp_duckdb_path): 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) result = pipeline_get_logs(tmp_duckdb_path, stage="svd", lines=10)
assert isinstance(result, list) assert isinstance(result, list)
assert len(result) <= 10 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(): 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. # so we only verify the file can be parsed/compiled, not fully executed.
import ast import ast
import os import os
@ -17,22 +17,22 @@ def test_home_importable():
# Verify the file parses as valid Python # Verify the file parses as valid Python
tree = ast.parse(source) tree = ast.parse(source)
# Verify st.set_page_config is called at module level (first Streamlit command) # Verify st.navigation is called (modern Streamlit multi-page API)
calls = [ nav_calls = [
node node
for node in ast.walk(tree) for node in ast.walk(tree)
if isinstance(node, ast.Call) if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute) 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) # Verify at least 2 st.Page() calls exist (one per page)
page_links = [ page_calls = [
node node
for node in ast.walk(tree) for node in ast.walk(tree)
if isinstance(node, ast.Call) if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute) 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(): 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 The unfiltered mean includes all historical VVD MPs, while the filtered
(average of all historical VVD MPs). With filter, VVD comp1 0.335 mean includes only currently-seated MPs. These must differ significantly.
(only currently-seated VVD MPs, matching compass). 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 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 from analysis.explorer_data import get_uniform_dim_windows
db_path = "data/motions.db" 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]) vvd_with_filter = float(svd_with_filter["VVD"][0])
# The buggy value should be significantly lower than the correct one # The two values must differ significantly
# (historical MPs have lower scores, dragging the mean down)
diff = abs(vvd_no_filter - vvd_with_filter) diff = abs(vvd_no_filter - vvd_with_filter)
assert diff > 0.1, ( assert diff > 0.1, (
f"Expected large diff between unfiltered ({vvd_no_filter:.4f}) and " f"Expected large diff between unfiltered ({vvd_no_filter:.4f}) and "
f"filtered ({vvd_with_filter:.4f}), got diff={diff:.4f}" 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(): def test_historical_window_unchanged():
"""Historical windows (e.g. '2025') should NOT be affected by active_mps filter.""" """Historical windows (e.g. '2025') should NOT be affected by active_mps filter."""

@ -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"
}
]
Loading…
Cancel
Save