From d5c6c246c0aa793ed3b1ce05273985e52d780339 Mon Sep 17 00:00:00 2001 From: Soham-G Date: Tue, 26 May 2026 13:17:52 +0530 Subject: [PATCH 1/2] feat: add Session Summary Report Generator (#77) Adds backend summarizer logic, API endpoints, and a frontend dashboard UI to display and download session markdown reports. --- api/main.py | 4 + api/routes/session.py | 66 +++++++++++++ core/intelligence/session_summarizer.py | 104 ++++++++++++++++++++ dashboard/src/routes/+page.svelte | 124 +++++++++++++++++++++++ tests/unit/test_session_summarizer.py | 125 ++++++++++++++++++++++++ 5 files changed, 423 insertions(+) create mode 100644 api/routes/session.py create mode 100644 core/intelligence/session_summarizer.py create mode 100644 tests/unit/test_session_summarizer.py 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 From 038ffab6d4f5ed74957db3e925a103aae170129f Mon Sep 17 00:00:00 2001 From: Soham-G Date: Tue, 26 May 2026 13:25:21 +0530 Subject: [PATCH 2/2] docs: update README with Session Summary feature --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. ![Execra Monitoring Dashboard Preview](docs/images/dashboard_preview.png) @@ -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