|
|
"""Report generation primitives for agent operation.
|
|
|
|
|
|
Agents call these to write structured markdown reports to the reports/ directory.
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import logging
|
|
|
import os
|
|
|
from datetime import datetime
|
|
|
from typing import Any, Dict
|
|
|
|
|
|
from agent_tools.database import query_pipeline_status
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
REPORT_TYPES = {
|
|
|
"summary",
|
|
|
"health",
|
|
|
"party_shift",
|
|
|
"axis_stability",
|
|
|
}
|
|
|
|
|
|
|
|
|
def generate_report(
|
|
|
db_path: str,
|
|
|
*,
|
|
|
report_type: str,
|
|
|
parameters: Dict[str, Any],
|
|
|
output_path: str,
|
|
|
) -> Dict[str, Any]:
|
|
|
"""Generate a markdown report and write it to output_path.
|
|
|
|
|
|
Args:
|
|
|
db_path: Path to DuckDB database
|
|
|
report_type: One of REPORT_TYPES
|
|
|
parameters: Type-specific parameters
|
|
|
output_path: Where to write the markdown file
|
|
|
|
|
|
Returns:
|
|
|
dict with "output_path" and "status" keys, or "error" on failure
|
|
|
"""
|
|
|
if report_type not in REPORT_TYPES:
|
|
|
return {
|
|
|
"error": f"Unknown report type '{report_type}'. Known types: {sorted(REPORT_TYPES)}",
|
|
|
}
|
|
|
|
|
|
try:
|
|
|
content = _render_report(db_path, report_type, parameters)
|
|
|
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
|
f.write(content)
|
|
|
return {"output_path": output_path, "status": "written"}
|
|
|
except Exception as e:
|
|
|
logger.exception("generate_report failed")
|
|
|
return {"error": str(e)}
|
|
|
|
|
|
|
|
|
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)
|
|
|
|