Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

</td>
<td width="50%" valign="top">
Expand Down
4 changes: 4 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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")
66 changes: 66 additions & 0 deletions api/routes/session.py
Original file line number Diff line number Diff line change
@@ -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")
104 changes: 104 additions & 0 deletions core/intelligence/session_summarizer.py
Original file line number Diff line number Diff line change
@@ -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()
)
124 changes: 124 additions & 0 deletions dashboard/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
let isSendingAction = $state(false);
let isUndoingAction = $state(false);

// Session Report Modal State
let showReportModal = $state(false);
let reportMarkdown = $state<string | null>(null);
let isFetchingReport = $state(false);
let reportSessionIdInput = $state('');
let reportError = $state<string | null>(null);

// Form inputs for simulating a new action
let simType = $state('user_click');
let simDesc = $state('Clicked dashboard simulation button');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -419,6 +468,29 @@
</h2>

<div class="space-y-3">
<!-- Session Report Generator -->
<div class="flex flex-col space-y-2 p-3 bg-[#0b1020]/60 border border-slate-800 rounded-md">
<div class="flex items-center justify-between">
<div class="text-xs">
<p class="font-bold text-slate-300">Session Summary</p>
<p class="text-[10px] text-slate-500 mt-0.5">Generate markdown report</p>
</div>
<button
onclick={() => fetchReport(reportSessionIdInput)}
disabled={isFetchingReport || !reportSessionIdInput}
class="bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 text-xs px-3.5 py-1.5 rounded font-semibold transition cursor-pointer disabled:opacity-50"
>
{isFetchingReport ? 'LOADING...' : 'GENERATE'}
</button>
</div>
<input
type="text"
bind:value={reportSessionIdInput}
placeholder="Enter session_id..."
class="w-full bg-[#111827] border border-slate-800 text-slate-300 text-xs px-3 py-1.5 rounded-md focus:border-slate-700 outline-none mt-1"
/>
</div>

<div class="flex items-center justify-between p-3 bg-[#0b1020]/60 border border-slate-800 rounded-md">
<div class="text-xs">
<p class="font-bold text-slate-300">Undo Last Action</p>
Expand Down Expand Up @@ -473,3 +545,55 @@
</section>
</main>
</div>

<!-- Session Report Modal -->
{#if showReportModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm transition-opacity">
<div class="bg-[#111827] border border-slate-700 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col shadow-2xl overflow-hidden">
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-900/50">
<h3 class="font-bold text-slate-200 tracking-wider">SESSION REPORT</h3>
<button
onclick={() => showReportModal = false}
class="text-slate-500 hover:text-slate-300 transition cursor-pointer"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>

<!-- Modal Body -->
<div class="p-6 overflow-y-auto flex-grow bg-[#0b1020]">
{#if isFetchingReport}
<div class="flex flex-col items-center justify-center h-40 space-y-3 text-slate-500">
<span class="border-2 border-slate-700 border-t-emerald-500 rounded-full h-8 w-8 animate-spin"></span>
<span class="text-xs uppercase tracking-widest font-semibold">Generating Report...</span>
</div>
{:else if reportError}
<div class="p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{reportError}
</div>
{:else if reportMarkdown}
<pre class="whitespace-pre-wrap font-mono text-sm text-slate-300 leading-relaxed">{reportMarkdown}</pre>
{/if}
</div>

<!-- Modal Footer -->
<div class="px-6 py-4 border-t border-slate-800 bg-slate-900/50 flex justify-end space-x-3">
{#if reportMarkdown}
<button
onclick={downloadReport}
class="bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 text-emerald-400 px-4 py-2 rounded-md text-xs font-bold tracking-wider transition cursor-pointer"
>
DOWNLOAD .MD
</button>
{/if}
<button
onclick={() => showReportModal = false}
class="bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-300 px-4 py-2 rounded-md text-xs font-bold tracking-wider transition cursor-pointer"
>
CLOSE
</button>
</div>
</div>
</div>
{/if}
Loading