diff --git a/README.md b/README.md
index 9042dcc..c4ec899 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ Traditional Workflow: Execra Workflow:
### Real-Time Monitoring Dashboard
-The SvelteKit dashboard provides a futuristic UI showing real-time telemetry, live action logs, active WebSocket connection status, and guidance feedback:
+The SvelteKit dashboard provides a futuristic UI showing real-time telemetry, live action logs, active WebSocket connection status, and guidance feedback. It also includes a **Session Summary Report Generator** to export detailed markdown reports of your execution sessions.

@@ -133,6 +133,7 @@ The SvelteKit dashboard provides a futuristic UI showing real-time telemetry, li
- 🔴 Real-time error detection
- 📡 Adapt instructions to user progress
- 🔮 Predict consequences before action
+- 📝 Auto-generate Session Summary Reports
diff --git a/api/main.py b/api/main.py
index 7fffd45..31f90f9 100644
--- a/api/main.py
+++ b/api/main.py
@@ -6,6 +6,7 @@
from api.routes import status, mode
from api.routes import actions, context
from api.websockets import guidance as ws_guidance
+from api.websockets.router import router as main_ws_router
from core.config import settings
from core.errors import handle_exception # ✅ NEW
@@ -66,6 +67,8 @@ def read_root():
app.include_router(mode.router, prefix="/api/v1")
app.include_router(actions.router, prefix="/api/v1")
app.include_router(context.router, prefix="/api/v1")
+ from api.routes import session
+ app.include_router(session.router, prefix="/api/v1")
except Exception as e:
handle_exception(e)
@@ -77,6 +80,7 @@ def read_root():
# WebSocket endpoints (no prefix — WS routes use the path as-is)
app.include_router(ws_guidance.router)
+app.include_router(main_ws_router)
# Alert suppression endpoints
app.include_router(suppression.router, prefix="/api/v1")
\ No newline at end of file
diff --git a/api/routes/session.py b/api/routes/session.py
new file mode 100644
index 0000000..3998688
--- /dev/null
+++ b/api/routes/session.py
@@ -0,0 +1,66 @@
+import logging
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import PlainTextResponse
+from dataclasses import asdict
+
+from core.intelligence.session_summarizer import SessionSummarizer, SessionSummary
+from core.errors import handle_exception
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/session", tags=["session"])
+
+summarizer = SessionSummarizer()
+
+@router.get("/{session_id}/summary", response_model=dict)
+async def get_session_summary(session_id: str):
+ """Retrieve the JSON summary of a session."""
+ try:
+ summary: SessionSummary = await summarizer.summarize(session_id)
+ # If no steps were found, it means the session likely doesn't exist or is empty
+ if summary.total_steps == 0 and summary.errors_detected == 0:
+ raise HTTPException(status_code=404, detail=f"Session {session_id} not found or has no actions")
+
+ return {
+ "status": "success",
+ "data": asdict(summary)
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error fetching session summary for {session_id}: {e}")
+ return handle_exception(e)
+
+@router.get("/{session_id}/summary.md", response_class=PlainTextResponse)
+async def get_session_summary_markdown(session_id: str):
+ """Retrieve a formatted Markdown report of a session."""
+ try:
+ summary: SessionSummary = await summarizer.summarize(session_id)
+ if summary.total_steps == 0 and summary.errors_detected == 0:
+ raise HTTPException(status_code=404, detail=f"Session {session_id} not found or has no actions")
+
+ md = f"""# Session Summary Report: {summary.session_id}
+
+**Generated At:** {summary.generated_at}
+**Task Type:** {summary.task_type}
+**Duration:** {summary.duration_seconds} seconds
+
+## Execution Metrics
+- **Total Steps Logged:** {summary.total_steps}
+- **Steps Completed:** {summary.steps_completed}
+
+## Guidance & Errors
+- **Total Guidance Delivered:** {summary.total_guidance_delivered}
+- **Average Guidance Confidence:** {summary.avg_confidence}
+- **Errors Detected:** {summary.errors_detected}
+- **Errors Resolved:** {summary.errors_resolved}
+- **Most Common Error:** {summary.most_common_error_type}
+
+---
+*Auto-generated by Execra Intelligence Layer*
+"""
+ return md
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error generating markdown summary for {session_id}: {e}")
+ raise HTTPException(status_code=500, detail="Internal server error generating summary")
diff --git a/core/intelligence/session_summarizer.py b/core/intelligence/session_summarizer.py
new file mode 100644
index 0000000..24827b8
--- /dev/null
+++ b/core/intelligence/session_summarizer.py
@@ -0,0 +1,104 @@
+import aiosqlite
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from collections import Counter
+from core.hybrid.action_logger import action_logger
+from core.security.crypto import decrypt
+
+@dataclass
+class SessionSummary:
+ session_id: str
+ duration_seconds: float
+ total_steps: int
+ steps_completed: int
+ errors_detected: int
+ errors_resolved: int
+ total_guidance_delivered: int
+ avg_confidence: float
+ most_common_error_type: str
+ task_type: str
+ generated_at: str
+
+class SessionSummarizer:
+ def __init__(self, db_path: str | None = None):
+ self.db_path = db_path or action_logger.db_path
+
+ async def summarize(self, session_id: str) -> SessionSummary:
+ total_steps = 0
+ steps_completed = 0
+ total_guidance_delivered = 0
+ sum_confidence = 0.0
+ start_time = None
+ end_time = None
+ task_type = "unknown"
+
+ # Action Log Aggregation
+ async with aiosqlite.connect(self.db_path) as db:
+ async with db.execute(
+ "SELECT timestamp, type, domain, was_guided, guidance_confidence FROM action_log WHERE session_id = ? ORDER BY timestamp",
+ (session_id,)
+ ) as cursor:
+ async for row in cursor:
+ ts_str = row[0]
+ if ts_str.endswith('Z'):
+ ts_str = ts_str[:-1] + '+00:00'
+ ts = datetime.fromisoformat(ts_str)
+ if start_time is None:
+ start_time = ts
+ end_time = ts
+
+ total_steps += 1
+ steps_completed += 1
+
+ was_guided = bool(row[3])
+ confidence = row[4]
+ if was_guided:
+ total_guidance_delivered += 1
+ if confidence is not None:
+ sum_confidence += float(confidence)
+
+ # Error History Aggregation
+ errors_detected = 0
+ errors_resolved = 0 # We don't have a resolved field in error_history per issue description, defaulting to 0
+ most_common_error_type = "none"
+
+ # Check if error_history exists
+ async with db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='error_history'") as cursor:
+ if await cursor.fetchone():
+ async with db.execute("SELECT error FROM error_history WHERE session_id = ?", (session_id,)) as cursor:
+ errors = []
+ async for row in cursor:
+ errors_detected += 1
+ encrypted_error = row[0]
+ if encrypted_error:
+ try:
+ decrypted = decrypt(encrypted_error)
+ errors.append(decrypted)
+ except Exception:
+ errors.append("unknown_error")
+
+ if errors:
+ error_types = [err.split(':')[0] if ':' in err else err for err in errors]
+ most_common_error_type = Counter(error_types).most_common(1)[0][0]
+
+ duration_seconds = 0.0
+ if start_time and end_time:
+ duration_seconds = (end_time - start_time).total_seconds()
+
+ avg_confidence = 0.0
+ if total_guidance_delivered > 0:
+ avg_confidence = sum_confidence / total_guidance_delivered
+
+ return SessionSummary(
+ session_id=session_id,
+ duration_seconds=round(duration_seconds, 2),
+ total_steps=total_steps,
+ steps_completed=steps_completed,
+ errors_detected=errors_detected,
+ errors_resolved=errors_resolved,
+ total_guidance_delivered=total_guidance_delivered,
+ avg_confidence=round(avg_confidence, 2),
+ most_common_error_type=most_common_error_type,
+ task_type=task_type,
+ generated_at=datetime.now(timezone.utc).isoformat()
+ )
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
index fe2816b..360126c 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -9,6 +9,13 @@
let isSendingAction = $state(false);
let isUndoingAction = $state(false);
+ // Session Report Modal State
+ let showReportModal = $state(false);
+ let reportMarkdown = $state(null);
+ let isFetchingReport = $state(false);
+ let reportSessionIdInput = $state('');
+ let reportError = $state(null);
+
// Form inputs for simulating a new action
let simType = $state('user_click');
let simDesc = $state('Clicked dashboard simulation button');
@@ -97,6 +104,41 @@
}
}
+ // Fetch session summary report
+ async function fetchReport(sessionId: string) {
+ if (!sessionId.trim()) return;
+ try {
+ isFetchingReport = true;
+ reportError = null;
+ reportMarkdown = null;
+ showReportModal = true;
+ const res = await fetch(`http://127.0.0.1:8000/api/v1/session/${encodeURIComponent(sessionId)}/summary.md`);
+ if (res.status === 404) {
+ throw new Error(`Session ${sessionId} not found or has no actions.`);
+ }
+ if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
+ reportMarkdown = await res.text();
+ } catch (err: any) {
+ console.error('Failed to fetch session report:', err);
+ reportError = err.message || 'Failed to fetch session report';
+ } finally {
+ isFetchingReport = false;
+ }
+ }
+
+ function downloadReport() {
+ if (!reportMarkdown) return;
+ const blob = new Blob([reportMarkdown], { type: 'text/markdown' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `session_report_${reportSessionIdInput || 'export'}.md`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+
onMount(() => {
wsService.connect();
fetchHistory();
@@ -130,6 +172,13 @@
return combined;
});
+ $effect(() => {
+ const actions = allActions();
+ if (actions.length > 0 && !reportSessionIdInput) {
+ reportSessionIdInput = actions[actions.length - 1].session_id;
+ }
+ });
+
// Stats derivations
const totalCount = $derived(allActions().length);
const digitalCount = $derived(allActions().filter(a => a.domain === 'digital').length);
@@ -419,6 +468,29 @@
+
+
+
+
+ Session Summary
+ Generate markdown report
+
+
+
+
+
+
Undo Last Action
@@ -473,3 +545,55 @@
+
+
+{#if showReportModal}
+
+
+
+
+ SESSION REPORT
+
+
+
+
+
+ {#if isFetchingReport}
+
+
+ Generating Report...
+
+ {:else if reportError}
+
+ {reportError}
+
+ {:else if reportMarkdown}
+ {reportMarkdown}
+ {/if}
+
+
+
+
+ {#if reportMarkdown}
+
+ {/if}
+
+
+
+
+{/if}
diff --git a/tests/unit/test_session_summarizer.py b/tests/unit/test_session_summarizer.py
new file mode 100644
index 0000000..d2ee4c7
--- /dev/null
+++ b/tests/unit/test_session_summarizer.py
@@ -0,0 +1,125 @@
+import pytest
+import aiosqlite
+from datetime import datetime, timezone
+from fastapi.testclient import TestClient
+
+from core.intelligence.session_summarizer import SessionSummarizer, SessionSummary
+from core.hybrid.action_logger import ActionRecord
+from api.main import app
+from core.security.crypto import encrypt
+
+client = TestClient(app)
+
+import tempfile
+
+@pytest.fixture
+async def setup_db():
+ db_path = tempfile.mktemp(suffix=".db")
+ # Initialize the tables and some synthetic data
+ async with aiosqlite.connect(db_path) as db:
+ await db.execute("""
+ CREATE TABLE action_log (
+ id TEXT PRIMARY KEY,
+ session_id TEXT,
+ timestamp TEXT,
+ type TEXT,
+ description TEXT,
+ domain TEXT,
+ was_guided INTEGER,
+ guidance_confidence REAL
+ )
+ """)
+ await db.execute("""
+ CREATE TABLE error_history (
+ id TEXT PRIMARY KEY,
+ session_id TEXT,
+ step INTEGER,
+ error TEXT
+ )
+ """)
+
+ # Insert actions
+ actions = [
+ ("act_1", "test_sess_1", "2026-05-26T10:00:00+00:00", "click", "Clicked button", "digital", 0, None),
+ ("act_2", "test_sess_1", "2026-05-26T10:00:05+00:00", "type", "Typed text", "digital", 1, 0.8),
+ ("act_3", "test_sess_1", "2026-05-26T10:00:10+00:00", "scroll", "Scrolled page", "digital", 1, 0.9)
+ ]
+ await db.executemany("INSERT INTO action_log VALUES (?, ?, ?, ?, ?, ?, ?, ?)", actions)
+
+ # Insert errors
+ errors = [
+ ("err_1", "test_sess_1", 1, "enc_NetworkError: connection timeout"),
+ ("err_2", "test_sess_1", 2, "enc_NetworkError: connection refused"),
+ ("err_3", "test_sess_1", 3, "enc_ValueError: invalid literal")
+ ]
+ await db.executemany("INSERT INTO error_history VALUES (?, ?, ?, ?)", errors)
+
+ await db.commit()
+
+ return db_path
+
+@pytest.mark.asyncio
+async def test_session_summarizer_logic(setup_db, monkeypatch):
+ monkeypatch.setattr("core.intelligence.session_summarizer.decrypt", lambda x: x.replace("enc_", ""))
+ db_path = setup_db
+ summarizer = SessionSummarizer(db_path=db_path)
+ summary = await summarizer.summarize("test_sess_1")
+
+ assert summary.session_id == "test_sess_1"
+ assert summary.total_steps == 3
+ assert summary.steps_completed == 3
+ assert summary.duration_seconds == 10.0 # 10:00:00 to 10:00:10
+ assert summary.total_guidance_delivered == 2
+ assert summary.avg_confidence == 0.85 # (0.8 + 0.9) / 2
+ assert summary.errors_detected == 3
+ assert summary.most_common_error_type == "NetworkError"
+
+def test_api_summary_json():
+ from unittest.mock import AsyncMock
+ import api.routes.session
+
+ mock_summary = SessionSummary(
+ session_id="mock_sess",
+ duration_seconds=42.0,
+ total_steps=10,
+ steps_completed=10,
+ errors_detected=2,
+ errors_resolved=0,
+ total_guidance_delivered=5,
+ avg_confidence=0.9,
+ most_common_error_type="SyntaxError",
+ task_type="unknown",
+ generated_at="2026-05-26T10:00:00+00:00"
+ )
+ api.routes.session.summarizer.summarize = AsyncMock(return_value=mock_summary)
+
+ response = client.get("/api/v1/session/mock_sess/summary")
+ assert response.status_code == 200
+ data = response.json()["data"]
+ assert data["session_id"] == "mock_sess"
+ assert data["avg_confidence"] == 0.9
+ assert data["total_steps"] == 10
+
+def test_api_summary_markdown():
+ from unittest.mock import AsyncMock
+ import api.routes.session
+
+ mock_summary = SessionSummary(
+ session_id="mock_sess",
+ duration_seconds=42.0,
+ total_steps=10,
+ steps_completed=10,
+ errors_detected=2,
+ errors_resolved=0,
+ total_guidance_delivered=5,
+ avg_confidence=0.9,
+ most_common_error_type="SyntaxError",
+ task_type="unknown",
+ generated_at="2026-05-26T10:00:00+00:00"
+ )
+ api.routes.session.summarizer.summarize = AsyncMock(return_value=mock_summary)
+
+ response = client.get("/api/v1/session/mock_sess/summary.md")
+ assert response.status_code == 200
+ assert "Session Summary Report: mock_sess" in response.text
+ assert "**Average Guidance Confidence:** 0.9" in response.text
|