diff options
| author | CaptainJack2491 <jayrupnakawala@gmail.com> | 2026-04-02 16:52:06 +0100 |
|---|---|---|
| committer | CaptainJack2491 <jayrupnakawala@gmail.com> | 2026-04-02 16:52:06 +0100 |
| commit | bd7341e00a1153ea99e4b1d6ce9761b855fee744 (patch) | |
| tree | 9c6899160ed5948f651c2570a45a3b9289311172 | |
| parent | 75696e2faea1d095a890d297d9ba74968be5afe6 (diff) | |
| parent | f19aa5de3e3f1c3c0b9ce90913588b9e956d4156 (diff) | |
Merge branch 'feature/web-gui'
| -rw-r--r-- | api/__init__.py | 1 | ||||
| -rw-r--r-- | api/endpoints/__init__.py | 1 | ||||
| -rw-r--r-- | api/server.py | 226 | ||||
| -rw-r--r-- | api/static/css/base.css | 66 | ||||
| -rw-r--r-- | api/static/css/components.css | 269 | ||||
| -rw-r--r-- | api/static/css/layout.css | 71 | ||||
| -rw-r--r-- | api/static/css/results.css | 113 | ||||
| -rw-r--r-- | api/static/index.html | 138 | ||||
| -rw-r--r-- | api/static/js/api.js | 46 | ||||
| -rw-r--r-- | api/static/js/components/results.js | 502 | ||||
| -rw-r--r-- | api/static/js/main.js | 31 | ||||
| -rw-r--r-- | api/static/js/ui.js | 43 | ||||
| -rw-r--r-- | docs/web_gui_plan.md | 170 |
13 files changed, 1677 insertions, 0 deletions
diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..7183f8a --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +# API module for Web GUI diff --git a/api/endpoints/__init__.py b/api/endpoints/__init__.py new file mode 100644 index 0000000..4663a01 --- /dev/null +++ b/api/endpoints/__init__.py @@ -0,0 +1 @@ +# API Endpoints diff --git a/api/server.py b/api/server.py new file mode 100644 index 0000000..c6c788c --- /dev/null +++ b/api/server.py @@ -0,0 +1,226 @@ +""" +FastAPI server for the AI Evaluation Visualization Dashboard. +Serves read-only results, JSON logs, and static visuals. +""" +import os +import sys +from pathlib import Path +from typing import Dict, Any, List +import pandas as pd +import json +import math + +from fastapi import FastAPI, HTTPException +from fastapi.responses import HTMLResponse, FileResponse +from fastapi.staticfiles import StaticFiles + +# Add project root to path to import core modules +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) +from src.config_loader import ConfigLoader + +app = FastAPI( + title="AI Agent Evaluation Dashboard", + description="Web GUI for analyzing experiment results", + version="0.2.0" +) + +# Mount static files +static_dir = Path(__file__).parent / "static" +if static_dir.exists(): + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + +def get_config(): + """Helper to load config safely.""" + try: + config = ConfigLoader(str(PROJECT_ROOT / "config.yaml")) + config.load() + return config + except Exception as e: + print(f"Warning: Could not load config: {e}") + return None + +# ============================================================================ +# Root Endpoint - Serve HTML +# ============================================================================ + +@app.get("/", response_class=HTMLResponse) +async def root(): + """Serve the main HTML page.""" + index_path = static_dir / "index.html" + if index_path.exists(): + return FileResponse(index_path) + return HTMLResponse(content="<h1>index.html not found</h1>", status_code=404) + +# ============================================================================ +# Results Endpoints +# ============================================================================ + +@app.get("/api/results/files") +async def get_results_files(): + """List all CSV files in the output directory.""" + config = get_config() + if not config: + raise HTTPException(status_code=500, detail="Config not loaded") + + output_dir = PROJECT_ROOT / config.output_dir + if not output_dir.exists(): + return [] + + files = [] + for p in output_dir.rglob("*.csv"): + files.append(str(p.relative_to(output_dir))) + return sorted(files) + +@app.get("/api/results") +async def get_results(file: str = None): + """Get experiment results as JSON, optionally for a specific file.""" + config = get_config() + if not config: + raise HTTPException(status_code=500, detail="Config not loaded") + + output_dir = PROJECT_ROOT / config.output_dir + + csv_files = [] + if file: + file_path = output_dir / file + if not file_path.exists() or ".." in file: + raise HTTPException(status_code=404, detail="File not found") + csv_files.append(file_path) + else: + csv_files = list(output_dir.glob("*.csv")) if output_dir.exists() else [] + + results = {} + for csv_file in csv_files: + try: + df = pd.read_csv(csv_file) + + # Convert NaN values to None for JSON serialization + def clean_value(val): + if isinstance(val, float) and (math.isnan(val) or math.isinf(val)): + return None + return val + + # Clean each row + cleaned_data = [] + for record in df.to_dict(orient="records"): + cleaned_record = {k: clean_value(v) for k, v in record.items()} + cleaned_data.append(cleaned_record) + + results[csv_file.stem] = { + "columns": df.columns.tolist(), + "data": cleaned_data + } + except Exception as e: + results[csv_file.stem] = {"error": str(e)} + + return results + +@app.get("/api/results/images") +async def list_result_images(): + """List generated visualization images.""" + config = get_config() + if not config: + return [] + + output_dir = PROJECT_ROOT / config.output_dir + viz_dir = output_dir / "viz" + + if not viz_dir.exists(): + return [] + + images = [] + for img in viz_dir.glob("*"): + if img.suffix.lower() in ['.png', '.jpg', '.jpeg', '.gif', '.svg']: + images.append({ + "name": img.name, + "path": str(img.relative_to(PROJECT_ROOT)) + }) + + return images + +@app.get("/api/results/images/{image_name}") +async def get_result_image(image_name: str): + """Serve a specific image.""" + config = get_config() + if not config: + raise HTTPException(status_code=500, detail="Config not loaded") + + output_dir = PROJECT_ROOT / config.output_dir + viz_dir = output_dir / "viz" + image_path = viz_dir / image_name + + if not image_path.exists(): + raise HTTPException(status_code=404, detail="Image not found") + + return FileResponse(image_path) + +# ============================================================================ +# Judge Results Endpoints +# ============================================================================ + +@app.get("/api/judge/files") +async def get_judge_files(): + """Get list of judge result files.""" + config = get_config() + if not config: + raise HTTPException(status_code=500, detail="Config not loaded") + + judge_log_dir = config._config.get('judge', {}).get('log_dir', 'judge_logs') + judge_dir = PROJECT_ROOT / judge_log_dir + + if not judge_dir.exists(): + return [] + + files = [] + for p in judge_dir.rglob("*"): + if p.suffix in ['.csv', '.json']: + files.append(str(p.relative_to(judge_dir))) + return sorted(files) + +@app.get("/api/judge/results") +async def get_judge_results(file: str = None): + """Get judge results if available.""" + config = get_config() + if not config: + raise HTTPException(status_code=500, detail="Config not loaded") + + judge_log_dir = config._config.get('judge', {}).get('log_dir', 'judge_logs') + judge_dir = PROJECT_ROOT / judge_log_dir + + if not judge_dir.exists(): + return {"message": "No judge results found"} + + result_files = [] + if file: + file_path = judge_dir / file + if not file_path.exists() or ".." in file: + raise HTTPException(status_code=404, detail="File not found") + result_files.append(file_path) + else: + result_files = list(judge_dir.glob("*.csv")) + list(judge_dir.glob("*.json")) + + results = {} + for rf in result_files: + if rf.suffix == '.csv': + try: + df = pd.read_csv(rf) + results[rf.stem] = { + "type": "csv", + "columns": df.columns.tolist(), + "data": df.to_dict(orient="records") + } + except Exception as e: + results[rf.stem] = {"error": str(e)} + elif rf.suffix == '.json': + try: + with open(rf) as f: + results[rf.stem] = {"type": "json", "data": json.load(f)} + except Exception as e: + results[rf.stem] = {"error": str(e)} + + return results + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/api/static/css/base.css b/api/static/css/base.css new file mode 100644 index 0000000..aa28ba5 --- /dev/null +++ b/api/static/css/base.css @@ -0,0 +1,66 @@ +:root { + /* Colors - Deep Industrial Dark */ + --color-bg-0: #0a0a0b; /* Deepest background */ + --color-bg-1: #121214; /* Sidebar / Cards */ + --color-bg-2: #1c1c1f; /* Input fields */ + --color-bg-3: #2a2a2e; /* Borders / Hover */ + + /* Text */ + --color-text-primary: #f0f0f2; + --color-text-secondary: #9ea0a6; + --color-text-muted: #62646c; + + /* Accent - Pure Purple */ + --color-accent: #9d4edd; + --color-accent-hover: #7b2cbf; + --color-accent-text: #ffffff; + + /* Semantic */ + --color-success: #10b981; + --color-warning: #f59e0b; + --color-danger: #ef4444; + --color-info: #3b82f6; + + /* Spacing & Borders */ + --border-width: 1px; + --border-radius: 4px; /* Crisp corners */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + + /* Transitions */ + --transition-fast: 0.15s ease; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background-color: var(--color-bg-0); + color: var(--color-text-primary); + line-height: 1.5; + height: 100vh; + overflow: hidden; /* Main scroll happens in panes */ +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: var(--color-bg-0); +} +::-webkit-scrollbar-thumb { + background: var(--color-bg-3); + border-radius: 10px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-accent); +} diff --git a/api/static/css/components.css b/api/static/css/components.css new file mode 100644 index 0000000..e576b3a --- /dev/null +++ b/api/static/css/components.css @@ -0,0 +1,269 @@ +/* Buttons */ +.btn { + appearance: none; + border: none; + font-family: inherit; + font-size: 0.875rem; + font-weight: 600; + padding: 10px 16px; + border-radius: var(--border-radius); + cursor: pointer; + transition: var(--transition-fast); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.btn-primary { + background-color: var(--color-accent); + color: var(--color-accent-text); +} +.btn-primary:hover:not(:disabled) { + background-color: var(--color-accent-hover); +} + +.btn-danger { + background-color: transparent; + border: var(--border-width) solid var(--color-danger); + color: var(--color-danger); +} +.btn-danger:hover:not(:disabled) { + background-color: var(--color-danger); + color: white; +} + +.btn-ghost { + background-color: transparent; + color: var(--color-text-secondary); + border: var(--border-width) solid var(--color-bg-3); +} +.btn-ghost:hover:not(:disabled) { + background-color: var(--color-bg-3); + color: var(--color-text-primary); +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; + filter: grayscale(1); +} + +/* Forms */ +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-bottom: var(--space-md); +} + +.form-label { + font-size: 0.75rem; + font-weight: 700; + color: var(--color-text-muted); + text-transform: uppercase; +} + +.input-field { + background-color: var(--color-bg-2); + border: var(--border-width) solid var(--color-bg-3); + border-radius: var(--border-radius); + padding: 10px 12px; + color: var(--color-text-primary); + font-size: 0.9375rem; + width: 100%; + transition: border-color var(--transition-fast); +} + +.input-field:focus { + outline: none; + border-color: var(--color-accent); +} + +/* Tabs */ +.tab-nav { + display: flex; + border-bottom: var(--border-width) solid var(--color-bg-3); + padding: 0 var(--space-lg); + background-color: var(--color-bg-1); +} + +.tab-trigger { + background: none; + border: none; + padding: 14px 20px; + color: var(--color-text-secondary); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: var(--transition-fast); +} + +.tab-trigger:hover { + color: var(--color-text-primary); +} + +.tab-trigger.active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +.tab-panel { + display: none; + padding: var(--space-lg); +} + +.tab-panel.active { + display: block; +} + +/* Cards */ +.card { + background-color: var(--color-bg-1); + border: var(--border-width) solid var(--color-bg-3); + border-radius: var(--border-radius); + overflow: hidden; +} + +.card-header { + padding: var(--space-md); + border-bottom: var(--border-width) solid var(--color-bg-3); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.card-body { + padding: var(--space-md); +} + +/* Badges */ +.badge { + font-size: 0.7rem; + font-weight: 800; + padding: 2px 8px; + border-radius: 2px; + text-transform: uppercase; +} + +.badge-idle { background: var(--color-bg-3); color: var(--color-text-secondary); } +.badge-running { background: var(--color-accent); color: white; animation: blink 1.5s infinite; } +.badge-complete { background: var(--color-success); color: white; } + +/* Checkbox Groups for Filtering */ +.checkbox-group { + background-color: var(--color-bg-0); + border: var(--border-width) solid var(--color-bg-3); + border-radius: var(--border-radius); + padding: var(--space-xs) 0; + max-height: 160px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.checkbox-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 6px 10px; + cursor: pointer; + transition: var(--transition-fast); + font-size: 0.8rem; + user-select: none; +} + +.checkbox-item:hover { + background-color: var(--color-bg-3); +} + +.checkbox-item input[type="checkbox"] { + accent-color: var(--color-accent); + cursor: pointer; +} + +.checkbox-item span { + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.checkbox-item:has(input:checked) span { + color: var(--color-text-primary); + font-weight: 600; +} + +.filter-actions { + display: flex; + gap: 8px; +} + +.filter-link { + font-size: 0.6rem; + font-weight: 800; + text-transform: uppercase; + color: var(--color-accent); + cursor: pointer; + transition: var(--transition-fast); +} + +.filter-link:hover { + color: var(--color-text-primary); + text-decoration: underline; +} + +/* Modal */ +.modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-xl); +} + +.modal-content { + background-color: var(--color-bg-1); + border: var(--border-width) solid var(--color-bg-3); + border-radius: var(--border-radius); + width: 90vw; + max-width: 1200px; + position: relative; + padding: var(--space-lg); + box-shadow: 0 10px 30px rgba(0,0,0,0.5); +} + +.btn-close { + position: absolute; + top: 10px; + right: 15px; + background: none; + border: none; + color: var(--color-text-muted); + font-size: 1.5rem; + cursor: pointer; + transition: var(--transition-fast); +} + +.btn-close:hover { + color: var(--color-danger); +} + +/* Make chart containers clickable */ +.chart-container canvas { + cursor: pointer; +} + +@keyframes blink { + 0% { opacity: 1; } + 50% { opacity: 0.6; } + 100% { opacity: 1; } +} diff --git a/api/static/css/layout.css b/api/static/css/layout.css new file mode 100644 index 0000000..2946461 --- /dev/null +++ b/api/static/css/layout.css @@ -0,0 +1,71 @@ +.dashboard-root { + display: grid; + grid-template-columns: 320px 1fr; + height: 100vh; + width: 100vw; +} + +/* Sidebar - Left Column */ +.sidebar { + grid-column: 1; + grid-row: 1; + background-color: var(--color-bg-1); + border-right: var(--border-width) solid var(--color-bg-3); + padding: var(--space-lg); + display: flex; + flex-direction: column; + gap: var(--space-xl); + z-index: 10; +} + +/* Main Area - Right */ +.main-viewport { + grid-column: 2; + grid-row: 1; + overflow-y: auto; + background-color: var(--color-bg-0); + display: flex; + flex-direction: column; +} + +/* Header */ +.app-header { + padding: var(--space-md) var(--space-lg); + border-bottom: var(--border-width) solid var(--color-bg-3); + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo-area { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.logo-area h1 { + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-accent); +} + +/* Mobile notice */ +@media (max-width: 900px) { + .dashboard-root { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + overflow-y: auto; + } + .sidebar { + grid-row: 1; + border-right: none; + border-bottom: var(--border-width) solid var(--color-bg-3); + } + .main-viewport { + grid-column: 1; + grid-row: 2; + height: auto; + } +}
\ No newline at end of file diff --git a/api/static/css/results.css b/api/static/css/results.css new file mode 100644 index 0000000..9a532b8 --- /dev/null +++ b/api/static/css/results.css @@ -0,0 +1,113 @@ +.results-container { + padding: var(--space-sm); +} + +.results-controls { + padding: var(--space-md) var(--space-lg); + background-color: var(--color-bg-1); + border-bottom: var(--border-width) solid var(--color-bg-3); + display: flex; + align-items: center; +} + +.data-table-wrapper { + margin-bottom: var(--space-xl); + border: var(--border-width) solid var(--color-bg-3); + border-radius: var(--border-radius); + overflow: hidden; +} + +.data-table-title { + background-color: var(--color-bg-1); + padding: var(--space-sm) var(--space-md); + font-size: 0.75rem; + font-weight: 800; + color: var(--color-accent); + text-transform: uppercase; + border-bottom: var(--border-width) solid var(--color-bg-3); +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; + background-color: var(--color-bg-1); + table-layout: auto; +} + +.data-table th { + text-align: left; + padding: 12px 16px; + background-color: var(--color-bg-2); + color: var(--color-text-secondary); + font-weight: 700; + text-transform: uppercase; + font-size: 0.65rem; + letter-spacing: 0.05em; + border-bottom: var(--border-width) solid var(--color-bg-3); + white-space: nowrap; +} + +.data-table td { + padding: 10px 16px; + border-bottom: var(--border-width) solid var(--color-bg-3); + color: var(--color-text-primary); + max-width: 400px; + white-space: normal; + word-wrap: break-word; + vertical-align: top; +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.data-table tr:hover td { + background-color: var(--color-bg-2); +} + +/* Image Grid */ +.image-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); + gap: var(--space-lg); + padding: var(--space-lg); +} + +.image-card { + background-color: var(--color-bg-1); + border: var(--border-width) solid var(--color-bg-3); + border-radius: var(--border-radius); + overflow: hidden; + transition: var(--transition-fast); +} + +.image-card:hover { + border-color: var(--color-accent); +} + +.image-card img { + width: 100%; + height: auto; + display: block; + background-color: white; /* Charts usually have white background */ +} + +.image-card-footer { + padding: var(--space-sm) var(--space-md); + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary); + text-align: center; + border-top: var(--border-width) solid var(--color-bg-3); +} + +/* Chart Canvas Container */ +.chart-container { + background-color: var(--color-bg-1); + border: var(--border-width) solid var(--color-bg-3); + border-radius: var(--border-radius); + padding: var(--space-md); + position: relative; + height: 300px; +} diff --git a/api/static/index.html b/api/static/index.html new file mode 100644 index 0000000..5338fca --- /dev/null +++ b/api/static/index.html @@ -0,0 +1,138 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>AI Agent Evaluation Dashboard</title> + <!-- CSS Modules --> + <link rel="stylesheet" href="/static/css/base.css"> + <link rel="stylesheet" href="/static/css/layout.css"> + <link rel="stylesheet" href="/static/css/components.css"> + <link rel="stylesheet" href="/static/css/results.css"> + <!-- Google Fonts - Inter --> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> + <!-- Chart.js --> + <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> +</head> +<body> + <div class="dashboard-root"> + <!-- Sidebar: Data Filters --> + <aside class="sidebar"> + <div class="logo-area"> + <h1>AI Dashboard</h1> + </div> + + <nav class="config-panel"> + <div class="form-group"> + <label class="form-label">Data Source (CSV)</label> + <select id="csv-file-select" class="input-field"> + <option value="">Loading...</option> + </select> + </div> + + <div class="form-group" style="margin-top: var(--space-lg);"> + <label class="form-label">Global Filters</label> + <div style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: var(--space-md);">Toggle multiple attributes:</div> + + <div class="filter-section"> + <div style="display: flex; justify-content: space-between; align-items: center;"> + <label class="form-label" style="font-size: 0.65rem;">Models</label> + <div class="filter-actions"> + <span class="filter-link" onclick="window.toggleFilter('model', true)">All</span> + <span class="filter-link" onclick="window.toggleFilter('model', false)">None</span> + </div> + </div> + <div id="filter-model-list" class="checkbox-group"></div> + </div> + + <div class="filter-section" style="margin-top: 12px;"> + <div style="display: flex; justify-content: space-between; align-items: center;"> + <label class="form-label" style="font-size: 0.65rem;">Scenarios</label> + <div class="filter-actions"> + <span class="filter-link" onclick="window.toggleFilter('scenario', true)">All</span> + <span class="filter-link" onclick="window.toggleFilter('scenario', false)">None</span> + </div> + </div> + <div id="filter-scenario-list" class="checkbox-group"></div> + </div> + + <div class="filter-section" style="margin-top: 12px;"> + <div style="display: flex; justify-content: space-between; align-items: center;"> + <label class="form-label" style="font-size: 0.65rem;">Oversight</label> + <div class="filter-actions"> + <span class="filter-link" onclick="window.toggleFilter('oversight', true)">All</span> + <span class="filter-link" onclick="window.toggleFilter('oversight', false)">None</span> + </div> + </div> + <div id="filter-oversight-list" class="checkbox-group"></div> + </div> + </div> + + <div class="sidebar-actions" style="margin-top: auto;"> + <button id="refresh-btn" class="btn btn-primary" style="width: 100%;">Apply Filters</button> + </div> + </nav> + </aside> + + <!-- Main Viewport: Results & Analytics --> + <main class="main-viewport"> + <header class="app-header"> + <div class="tab-nav" style="border-bottom: none; background: none; padding: 0;"> + <button class="tab-trigger active" data-tab="images">Visual Analytics</button> + <button class="tab-trigger" data-tab="csv">Raw Dataset</button> + <button class="tab-trigger" data-tab="judge">Judge Reports</button> + </div> + </header> + + <div class="viewport-content"> + <div id="tab-images" class="tab-panel active"> + <div class="results-controls"> + <h2 style="font-size: 1.1rem; color: var(--color-text-primary); margin: 0;">Interactive Dashboard</h2> + <span id="data-stats" style="margin-left: auto; font-size: 0.8rem; font-weight: 600; color: var(--color-accent);"></span> + </div> + + <div id="interactive-charts" class="image-grid" style="margin-top: var(--space-md);"> + <div class="empty-state" style="grid-column: 1 / -1;">Select a CSV to generate visuals.</div> + </div> + + <h3 style="margin: var(--space-xl) var(--space-lg) var(--space-sm); font-size: 0.8rem; text-transform: uppercase; color: var(--color-text-muted); border-bottom: 1px solid var(--color-bg-3); padding-bottom: 8px;">Static Visualizations (viz/)</h3> + <div id="image-results" class="image-grid"> + <div class="empty-state" style="grid-column: 1 / -1;">No visualizations generated.</div> + </div> + </div> + + <div id="tab-csv" class="tab-panel"> + <div id="csv-results" class="results-container"> + <div class="empty-state">No active data.</div> + </div> + </div> + + <div id="tab-judge" class="tab-panel"> + <div class="results-controls"> + <label class="form-label">Select Report:</label> + <select id="judge-file-select" class="input-field" style="max-width: 400px; display: inline-block; margin-left: var(--space-sm);"> + <option value="">Loading...</option> + </select> + </div> + <div id="judge-results" class="results-container"> + <div class="empty-state">No judge reports.</div> + </div> + </div> + </div> + </main> + </div> + + <!-- Main Entry Point --> + <script type="module" src="/static/js/main.js"></script> + + <!-- Chart Zoom Modal --> + <div id="chart-modal" class="modal-overlay" style="display: none;"> + <div class="modal-content"> + <button id="close-modal-btn" class="btn-close">×</button> + <div id="modal-chart-container" class="chart-container" style="height: 80vh; border: none; background: transparent;"></div> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/api/static/js/api.js b/api/static/js/api.js new file mode 100644 index 0000000..ecfe30d --- /dev/null +++ b/api/static/js/api.js @@ -0,0 +1,46 @@ +/** + * api.js - Core API communication + */ + +const API_BASE = ''; + +export const api = { + async get(endpoint) { + const response = await fetch(`${API_BASE}${endpoint}`); + if (!response.ok) throw new Error(`API Error: ${response.status}`); + return await response.json(); + }, + + async post(endpoint, data) { + const response = await fetch(`${API_BASE}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!response.ok) throw new Error(`API Error: ${response.status}`); + return await response.json(); + }, + + async delete(endpoint) { + const response = await fetch(`${API_BASE}${endpoint}`, { method: 'DELETE' }); + if (!response.ok) throw new Error(`API Error: ${response.status}`); + return await response.json(); + }, + + streamLogs(onLog, onDone, onError) { + const eventSource = new EventSource(`${API_BASE}/api/logs/stream`); + + eventSource.onmessage = (e) => onLog(e.data); + eventSource.addEventListener('log', (e) => onLog(e.data)); + eventSource.addEventListener('done', () => { + eventSource.close(); + onDone(); + }); + eventSource.onerror = (e) => { + console.error('SSE Error:', e); + if (onError) onError(e); + }; + + return eventSource; + } +}; diff --git a/api/static/js/components/results.js b/api/static/js/components/results.js new file mode 100644 index 0000000..b143fa3 --- /dev/null +++ b/api/static/js/components/results.js @@ -0,0 +1,502 @@ +/** + * results.js - Data visualization and reports + */ +import { api } from '../api.js'; +import { ui } from '../ui.js'; + +let activeCharts = []; +let currentCsvData = null; // Store fetched data locally for quick filtering +let currentCsvColumns = []; +let modalChart = null; + +export const results = { + async init() { + // Bind events + ui.elements.csvFileSelect.addEventListener('change', (e) => this.handleCsvChange(e.target.value)); + ui.elements.judgeFileSelect.addEventListener('change', (e) => this.loadJudgeData(e.target.value)); + ui.elements.refreshBtn.addEventListener('click', () => this.applyFilters()); + + // Modal Setup + ui.elements.closeModalBtn.addEventListener('click', () => this.closeChartModal()); + ui.elements.chartModal.addEventListener('click', (e) => { + if (e.target === ui.elements.chartModal) this.closeChartModal(); + }); + + await Promise.all([ + this.updateCSVFileList(), + this.updateJudgeFileList(), + this.loadImages() + ]); + }, + + closeChartModal() { + ui.elements.chartModal.style.display = 'none'; + if (modalChart) { + modalChart.destroy(); + modalChart = null; + } + }, + + openChartModal(config) { + ui.elements.chartModal.style.display = 'flex'; + ui.elements.modalChartContainer.innerHTML = '<canvas id="modal-canvas"></canvas>'; + const ctx = document.getElementById('modal-canvas').getContext('2d'); + + const modalOptions = { ...config.options, maintainAspectRatio: false }; + if (modalOptions.plugins && modalOptions.plugins.legend) { + modalOptions.plugins.legend.labels = { color: '#e7e9ea', font: { size: 14 } }; + } + if (modalOptions.plugins && modalOptions.plugins.title) { + modalOptions.plugins.title.font = { size: 20, family: 'Inter' }; + } + + modalChart = new Chart(ctx, { + type: config.type, + data: config.data, + options: modalOptions + }); + }, + + async updateCSVFileList() { + try { + const files = await api.get('/api/results/files'); + const options = files.length > 0 + ? files.map(f => `<option value="${f}">${f}</option>`).join('') + : '<option value="">No files found</option>'; + + ui.elements.csvFileSelect.innerHTML = options; + + const defaultFile = files.find(f => f.endsWith('results.csv')) || files[0]; + if (defaultFile) { + ui.elements.csvFileSelect.value = defaultFile; + await this.handleCsvChange(defaultFile); + } + } catch (error) { + console.error("Failed loading CSV list", error); + } + }, + + async updateJudgeFileList() { + try { + const files = await api.get('/api/judge/files'); + const options = files.length > 0 + ? files.map(f => `<option value="${f}">${f}</option>`).join('') + : '<option value="">No judge reports found</option>'; + + ui.elements.judgeFileSelect.innerHTML = options; + if (files.length > 0) { + ui.elements.judgeFileSelect.value = files[0]; + await this.loadJudgeData(files[0]); + } + } catch (error) { + console.error("Failed loading Judge file list", error); + } + }, + + async handleCsvChange(filePath) { + if (!filePath) return; + ui.elements.csvResults.innerHTML = '<div class="empty-state">Loading data...</div>'; + ui.elements.interactiveCharts.innerHTML = '<div class="empty-state" style="grid-column: 1 / -1;">Loading charts...</div>'; + + try { + const data = await api.get(`/api/results?file=${encodeURIComponent(filePath)}`); + const content = Object.values(data)[0]; + + if (!content || content.error) { + const err = content?.error || "Unknown error"; + ui.elements.csvResults.innerHTML = `<div class="empty-state">Error: ${err}</div>`; + ui.elements.interactiveCharts.innerHTML = `<div class="empty-state" style="grid-column: 1 / -1;">Error: ${err}</div>`; + return; + } + + currentCsvData = content.data; + currentCsvColumns = content.columns; + + this.populateFilters(); + this.applyFilters(); // This draws charts and tables + + } catch (err) { + ui.elements.csvResults.innerHTML = `<div class="empty-state">Error: ${err.message}</div>`; + ui.elements.interactiveCharts.innerHTML = `<div class="empty-state" style="grid-column: 1 / -1;">Error: ${err.message}</div>`; + } + }, + + populateFilters() { + const models = new Set(); + const scenarios = new Set(); + const oversights = new Set(); + + currentCsvData.forEach(row => { + if (row.model) models.add(row.model); + if (row.scenario) scenarios.add(row.scenario); + if (row.oversight) oversights.add(row.oversight); + }); + + this.renderCheckboxList(ui.elements.filterModelList, Array.from(models).sort(), 'model'); + this.renderCheckboxList(ui.elements.filterScenarioList, Array.from(scenarios).sort(), 'scenario'); + + // Custom Oversight Sorting + const oversightPriority = { 'low': 0, 'mid': 1, 'medium': 1, 'high': 2 }; + const sortedOversights = Array.from(oversights).sort((a, b) => { + const pA = oversightPriority[a.toLowerCase()] ?? 99; + const pB = oversightPriority[b.toLowerCase()] ?? 99; + return pA - pB; + }); + this.renderCheckboxList(ui.elements.filterOversightList, sortedOversights, 'oversight'); + }, + + renderCheckboxList(container, items, type) { + if (items.length === 0) { + container.innerHTML = '<div class="checkbox-item"><span>No data</span></div>'; + return; + } + + container.innerHTML = items.map(item => ` + <label class="checkbox-item"> + <input type="checkbox" name="filter-${type}" value="${item}" checked> + <span title="${item}">${item}</span> + </label> + `).join(''); + }, + + applyFilters() { + if (!currentCsvData) return; + + const getChecked = (name) => Array.from(document.querySelectorAll(`input[name="filter-${name}"]:checked`)).map(cb => cb.value); + + const modelFilters = getChecked('model'); + const scenarioFilters = getChecked('scenario'); + const oversightFilters = getChecked('oversight'); + + const filteredData = currentCsvData.filter(row => { + return (modelFilters.length === 0 || modelFilters.includes(row.model)) && + (scenarioFilters.length === 0 || scenarioFilters.includes(row.scenario)) && + (oversightFilters.length === 0 || oversightFilters.includes(row.oversight)); + }); + + ui.elements.dataStats.textContent = `Showing ${filteredData.length} of ${currentCsvData.length} records`; + + this.renderTable(filteredData); + this.renderCharts(filteredData); + }, + + renderTable(data) { + if (data.length === 0) { + ui.elements.csvResults.innerHTML = '<div class="empty-state">No data matches filters.</div>'; + return; + } + + let html = `<div class="data-table-wrapper"> + <div style="overflow-x: auto;"> + <table class="data-table"> + <thead><tr>${currentCsvColumns.map(c => `<th>${c}</th>`).join('')}</tr></thead> + <tbody> + ${data.map(row => ` + <tr>${currentCsvColumns.map(col => `<td>${row[col] ?? ''}</td>`).join('')}</tr> + `).join('')} + </tbody> + </table> + </div> + </div>`; + + ui.elements.csvResults.innerHTML = html; + }, + + renderCharts(data) { + activeCharts.forEach(chart => chart.destroy()); + activeCharts = []; + ui.elements.interactiveCharts.innerHTML = ''; + + if (data.length === 0) { + ui.elements.interactiveCharts.innerHTML = '<div class="empty-state" style="grid-column: 1 / -1;">No data to chart.</div>'; + return; + } + + const cols = currentCsvColumns; + + if (cols.includes('blackbox_category')) { + this.renderDoughnutChart('Blackbox Categories', data, 'blackbox_category'); + } + + if (cols.includes('glassbox_category')) { + this.renderDoughnutChart('Glassbox Categories', data, 'glassbox_category'); + } + + if (cols.includes('model') && cols.includes('blackbox_category')) { + this.renderStackedBarChart('Blackbox Category by Model', data, 'model', 'blackbox_category'); + } + + if (cols.includes('oversight') && cols.includes('blackbox_category')) { + this.renderStackedBarChart('Blackbox Category by Oversight', data, 'oversight', 'blackbox_category'); + } + + if (cols.includes('model') && cols.includes('total_tokens')) { + this.renderAverageTokenUsageChart('Average Token Usage by Model', data); + this.renderTotalTokenUsageChart('Total Token Usage by Model', data); + } + + if (ui.elements.interactiveCharts.innerHTML === '') { + ui.elements.interactiveCharts.innerHTML = '<div class="empty-state" style="grid-column: 1 / -1;">No plottable categorical columns found.</div>'; + } + }, + + renderAverageTokenUsageChart(title, data) { + const metrics = {}; // { model: { total: 0, count: 0, prompt: 0, completion: 0 } } + + data.forEach(row => { + const model = row.model || 'Unknown'; + if (!row.total_tokens) return; + + if (!metrics[model]) { + metrics[model] = { total: 0, prompt: 0, completion: 0, count: 0 }; + } + metrics[model].total += row.total_tokens || 0; + metrics[model].prompt += row.prompt_tokens || 0; + metrics[model].completion += row.completion_tokens || 0; + metrics[model].count += 1; + }); + + const labels = Object.keys(metrics).sort(); + if (labels.length === 0) return; + + const datasets = [ + { + label: 'Avg Prompt Tokens', + data: labels.map(l => metrics[l].prompt / metrics[l].count), + backgroundColor: '#3b82f6', + }, + { + label: 'Avg Completion Tokens', + data: labels.map(l => metrics[l].completion / metrics[l].count), + backgroundColor: '#10b981', + } + ]; + + const config = { + type: 'bar', + data: { labels, datasets }, + options: { + ...this.getChartOptions(title), + scales: { + x: { stacked: true, ticks: { color: '#9ea0a6' }, grid: { color: '#2a2a2e' } }, + y: { stacked: true, ticks: { color: '#9ea0a6' }, grid: { color: '#2a2a2e' } } + } + } + }; + + const canvasId = `chart-${Math.random().toString(36).substr(2, 9)}`; + this.createChartContainer(canvasId, config); + + const ctx = document.getElementById(canvasId).getContext('2d'); + const chart = new Chart(ctx, config); + activeCharts.push(chart); + }, + + renderTotalTokenUsageChart(title, data) { + const metrics = {}; // { model: { prompt: 0, completion: 0 } } + + data.forEach(row => { + const model = row.model || 'Unknown'; + if (!row.total_tokens) return; + + if (!metrics[model]) { + metrics[model] = { prompt: 0, completion: 0 }; + } + metrics[model].prompt += row.prompt_tokens || 0; + metrics[model].completion += row.completion_tokens || 0; + }); + + const labels = Object.keys(metrics).sort(); + if (labels.length === 0) return; + + const datasets = [ + { + label: 'Total Prompt Tokens', + data: labels.map(l => metrics[l].prompt), + backgroundColor: '#6366f1', + }, + { + label: 'Total Completion Tokens', + data: labels.map(l => metrics[l].completion), + backgroundColor: '#f59e0b', + } + ]; + + const config = { + type: 'bar', + data: { labels, datasets }, + options: { + ...this.getChartOptions(title), + scales: { + x: { stacked: true, ticks: { color: '#9ea0a6' }, grid: { color: '#2a2a2e' } }, + y: { stacked: true, ticks: { color: '#9ea0a6' }, grid: { color: '#2a2a2e' } } + } + } + }; + + const canvasId = `chart-${Math.random().toString(36).substr(2, 9)}`; + this.createChartContainer(canvasId, config); + + const ctx = document.getElementById(canvasId).getContext('2d'); + const chart = new Chart(ctx, config); + activeCharts.push(chart); + }, + + renderDoughnutChart(title, data, column) { + const counts = {}; + data.forEach(row => { + const val = row[column] || 'Unknown'; + counts[val] = (counts[val] || 0) + 1; + }); + + const config = { + type: 'doughnut', + data: { + labels: Object.keys(counts), + datasets: [{ + data: Object.values(counts), + backgroundColor: ['#9d4edd', '#ef4444', '#10b981', '#f59e0b', '#3b82f6', '#6366f1'], + borderWidth: 0 + }] + }, + options: this.getChartOptions(title) + }; + + const canvasId = `chart-${Math.random().toString(36).substr(2, 9)}`; + this.createChartContainer(canvasId, config); + + const ctx = document.getElementById(canvasId).getContext('2d'); + const chart = new Chart(ctx, config); + activeCharts.push(chart); + }, + + renderStackedBarChart(title, data, xCol, groupCol) { + const matrix = {}; + const groups = new Set(); + + data.forEach(row => { + const xVal = row[xCol] || 'Unknown'; + const gVal = row[groupCol] || 'Unknown'; + if (!matrix[xVal]) matrix[xVal] = {}; + matrix[xVal][gVal] = (matrix[xVal][gVal] || 0) + 1; + groups.add(gVal); + }); + + let labels = Object.keys(matrix); + + // Custom sorting for X-axis labels (like Oversight) + if (xCol === 'oversight') { + const oversightPriority = { 'low': 0, 'mid': 1, 'medium': 1, 'high': 2 }; + labels.sort((a, b) => (oversightPriority[a.toLowerCase()] ?? 99) - (oversightPriority[b.toLowerCase()] ?? 99)); + } else { + labels.sort(); + } + + const datasets = Array.from(groups).map((group, i) => { + const colors = ['#ef4444', '#10b981', '#f59e0b', '#3b82f6', '#9d4edd', '#6366f1']; + return { + label: group, + data: labels.map(label => matrix[label][group] || 0), + backgroundColor: colors[i % colors.length], + } + }); + + const config = { + type: 'bar', + data: { labels, datasets }, + options: { + ...this.getChartOptions(title), + scales: { + x: { stacked: true, ticks: { color: '#9ea0a6' }, grid: { color: '#2a2a2e' } }, + y: { stacked: true, ticks: { color: '#9ea0a6', stepSize: 1 }, grid: { color: '#2a2a2e' } } + } + } + }; + + const canvasId = `chart-${Math.random().toString(36).substr(2, 9)}`; + this.createChartContainer(canvasId, config); + + const ctx = document.getElementById(canvasId).getContext('2d'); + const chart = new Chart(ctx, config); + activeCharts.push(chart); + }, + + createChartContainer(canvasId, config) { + const container = document.createElement('div'); + container.className = 'chart-container'; + container.innerHTML = `<canvas id="${canvasId}" title="Click to expand"></canvas>`; + ui.elements.interactiveCharts.appendChild(container); + + setTimeout(() => { + const canvas = document.getElementById(canvasId); + if (canvas && config) { + canvas.addEventListener('click', () => this.openChartModal(config)); + } + }, 0); + }, + + getChartOptions(title) { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { display: true, text: title, color: '#f0f0f2', font: { family: 'Inter', size: 14 } }, + legend: { labels: { color: '#9ea0a6' }, position: 'bottom' } + } + }; + }, + + async loadImages() { + const images = await api.get('/api/results/images'); + if (images.length === 0) return; + + ui.elements.imageResults.innerHTML = images.map(img => ` + <div class="image-card"> + <img src="/api/results/images/${img.name}" alt="${img.name}" loading="lazy"> + <div class="image-card-footer">${img.name}</div> + </div> + `).join(''); + }, + + async loadJudgeData(filePath) { + if (!filePath) return; + ui.elements.judgeResults.innerHTML = '<div class="empty-state">Loading data...</div>'; + + try { + const data = await api.get(`/api/judge/results?file=${encodeURIComponent(filePath)}`); + if (Object.keys(data).length === 0 || data.message) { + ui.elements.judgeResults.innerHTML = '<div class="empty-state">No data available in this report.</div>'; + return; + } + + const content = Object.values(data)[0]; + if (content.error) { + ui.elements.judgeResults.innerHTML = `<div class="empty-state">Error: ${content.error}</div>`; + return; + } + + let html = ''; + if (content.type === 'csv') { + html += `<div class="data-table-wrapper"> + <div style="overflow-x: auto;"> + <table class="data-table"> + <thead><tr>${content.columns.map(c => `<th>${c}</th>`).join('')}</tr></thead> + <tbody> + ${content.data.map(row => ` + <tr>${content.columns.map(col => `<td>${row[col] ?? ''}</td>`).join('')}</tr> + `).join('')} + </tbody> + </table> + </div> + </div>`; + } else { + html += `<div class="data-table-wrapper"> + <pre style="padding: 16px; color: #f0f0f2; font-family: monospace; white-space: pre-wrap; word-wrap: break-word;">${JSON.stringify(content.data, null, 2)}</pre> + </div>`; + } + ui.elements.judgeResults.innerHTML = html; + } catch (err) { + ui.elements.judgeResults.innerHTML = `<div class="empty-state">Error: ${err.message}</div>`; + } + } +};
\ No newline at end of file diff --git a/api/static/js/main.js b/api/static/js/main.js new file mode 100644 index 0000000..2995943 --- /dev/null +++ b/api/static/js/main.js @@ -0,0 +1,31 @@ +import { ui } from './ui.js'; +import { results } from './components/results.js'; + +async function init() { + console.log('Initializing AI Reasoning Dashboard...'); + + // Global utility for filters + window.toggleFilter = (type, state) => { + document.querySelectorAll(`input[name="filter-${type}"]`).forEach(cb => cb.checked = state); + }; + + await results.init(); + + // Init UI behaviors + ui.initTabs((tabId) => { + // We could lazy load data here, but for now everything triggers on file/filter change + }); +} + +function start() { + init().catch(err => { + console.error("Initialization error:", err); + alert(`Dashboard Failed to Load: ${err.message}`); + }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); +} else { + start(); +}
\ No newline at end of file diff --git a/api/static/js/ui.js b/api/static/js/ui.js new file mode 100644 index 0000000..2c402ab --- /dev/null +++ b/api/static/js/ui.js @@ -0,0 +1,43 @@ +export const ui = { + get elements() { + return { + csvFileSelect: document.getElementById('csv-file-select'), + judgeFileSelect: document.getElementById('judge-file-select'), + + filterModelList: document.getElementById('filter-model-list'), + filterScenarioList: document.getElementById('filter-scenario-list'), + filterOversightList: document.getElementById('filter-oversight-list'), + + refreshBtn: document.getElementById('refresh-btn'), + + csvResults: document.getElementById('csv-results'), + imageResults: document.getElementById('image-results'), + judgeResults: document.getElementById('judge-results'), + interactiveCharts: document.getElementById('interactive-charts'), + dataStats: document.getElementById('data-stats'), + + chartModal: document.getElementById('chart-modal'), + closeModalBtn: document.getElementById('close-modal-btn'), + modalChartContainer: document.getElementById('modal-chart-container'), + + tabTriggers: document.querySelectorAll('.tab-trigger'), + tabPanels: document.querySelectorAll('.tab-panel') + }; + }, + + initTabs(onTabChange) { + this.elements.tabTriggers.forEach(trigger => { + trigger.addEventListener('click', () => { + const tabId = trigger.dataset.tab; + + this.elements.tabTriggers.forEach(t => t.classList.remove('active')); + this.elements.tabPanels.forEach(p => p.classList.remove('active')); + + trigger.classList.add('active'); + document.getElementById(`tab-${tabId}`).classList.add('active'); + + if (onTabChange) onTabChange(tabId); + }); + }); + } +};
\ No newline at end of file diff --git a/docs/web_gui_plan.md b/docs/web_gui_plan.md new file mode 100644 index 0000000..f43b0d0 --- /dev/null +++ b/docs/web_gui_plan.md @@ -0,0 +1,170 @@ +# Web GUI Implementation Plan + +## Overview + +A lightweight, decoupled web interface for the AI Agent Reasoning Experiment Framework. The goal is to allow non-technical users (e.g., supervisors, dissertation examiners) to understand and interact with the project without using the CLI. + +**Core Principle**: Keep it fast, reactive, and modular. No bloat. The GUI is a separate layer that wraps the existing core project without modifying it. + +--- + +## Architecture + +### Decoupled Design + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Web GUI (Frontend) │ +│ Modular ES6 Vanilla JS + HTML + Chart.js │ +└────────────────────────────┬────────────────────────────────┘ + │ HTTP / SSE +┌────────────────────────────▼────────────────────────────────┐ +│ FastAPI Backend (API) │ +│ api/server.py (new, separate) │ +│ - Wraps core functions (ConfigLoader, run_from_config) │ +│ - Exposes endpoints for scenarios, models, runs, logs │ +└────────────────────────────┬────────────────────────────────┘ + │ +┌────────────────────────────▼────────────────────────────────┐ +│ Core Project (Unchanged) │ +│ - src/main.py, src/judge_runner.py │ +│ - config.yaml, scenarios/, logs/ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Why This Approach? + +1. **Zero Changes to Core**: The dissertation code remains untouched and pure. +2. **Modular Frontend**: Splitting JS and CSS into logical components prevents a monolithic file structure, making future expansions (like new charts) effortless. +3. **No Build Step**: Native ES6 modules mean no Webpack or React overhead. +4. **Showcase-Ready**: Clean, dark-themed "Control Room" dashboard for non-technical users. + +--- + +## File Structure + +``` +dissertation/ +├── api/ # New API layer +│ ├── __init__.py +│ ├── server.py # FastAPI application +│ ├── endpoints/ +│ │ ├── __init__.py +│ │ ├── config.py # Config read/write endpoints +│ │ ├── scenarios.py # Scenario discovery endpoints +│ │ ├── runs.py # Experiment run triggers +│ │ └── results.py # Results/logs fetching +│ └── static/ # Frontend (served by FastAPI) +│ ├── index.html # Main DOM +│ ├── css/ # Modular CSS (base, layout, components, etc.) +│ └── js/ # ES6 Modules +│ ├── main.js # Entry point +│ ├── api.js # API wrapper +│ ├── ui.js # Global DOM utilities +│ └── components/ # Specific feature logic (results, config, terminal) +│ +├── src/ # Core project (UNCHANGED) +│ ├── main.py +│ ├── runner.py +│ ├── config_loader.py +│ └── ... +│ +├── docs/ +│ └── web_gui_plan.md # This file +│ +└── config.yaml +``` + +--- + +## API Endpoints + +### 1. Configuration & Discovery (Removed) + +*Note: In v0.2.0, the GUI was pivoted to a pure Data Visualization Dashboard. Execution logic (triggering runs, stopping runs, tailing live logs, and configuration editing) has been removed to keep the interface focused on analysis.* + +### 2. Results & Analysis + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/results/files`| List all CSV files in the logs directory | +| GET | `/api/results` | Get experiment results (CSV data as JSON), accepts `?file=` | +| GET | `/api/results/images` | List generated visualization images | +| GET | `/api/results/images/{name}` | Serve a specific image | +| GET | `/api/judge/files`| List all judge reports in the configured log directory | +| GET | `/api/judge/results` | Get judge results as JSON, accepts `?file=` | + +--- + +## Implementation Steps + +### Phase 1: Backend (FastAPI) + +1. **Setup**: Add `fastapi`, `uvicorn` to dependencies (via `uv add`) +2. **Server**: Create `api/server.py` with basic FastAPI app +3. **Data Serving**: Read CSV/JSON files dynamically based on URL queries. +4. **Token Usage**: The CSV data includes token metrics that were injected during the run or post-processed. + +### Phase 2: Frontend (Vanilla ES6) + +1. **Static Architecture**: Implement a modular JS structure (`js/components/`). +2. **Dashboard Layout**: Fixed left sidebar for global filtering, main viewport for data analysis. +3. **Aesthetics**: "Industrial Control Room" dark theme (high contrast, sans-serif UI). +4. **Interactive Visualizations**: Integrate `Chart.js` to dynamically generate pie, bar, and stacked bar charts from CSV data endpoints. +5. **Chart Zooming**: Implement a full-screen modal overlay for examining charts closely. + +### Phase 3: Integration & Polish + +1. **Auto-discovery**: Ensure new scenarios and result files populate dropdowns automatically. +2. **Data Parsing**: Ensure long text fields (like judge justifications) wrap nicely in tables. +3. **Global Filters**: Add checkbox filtering to slice data by Model, Scenario, and Oversight level dynamically on the frontend. +4. **Chart Cleanup**: Ensure the Chart.js instances are destroyed and re-created cleanly when switching data sources or filters. + +--- + +## Dependencies + +Backend dependencies added via `uv`: + +```toml +[dependencies] +fastapi = ">=0.115.0" +uvicorn = {extras = ["standard"], version = ">=0.32.0"} +``` +*(sse-starlette was removed as live log streaming is no longer required)* + +Frontend uses `Chart.js` via CDN. No other external dependencies are required. + +--- + +## Running the GUI + +```bash +# Start the API server +uv run api/server.py + +# Open in browser +# http://localhost:8000 +``` + +--- + +## Features Implemented + +- [x] Pure Data Visualization Dashboard +- [x] High-contrast Dark Mode ("Control Room" aesthetic) +- [x] Dynamic File Selection for CSV/Judge Reports +- [x] Multi-select Checkbox Global Filters (Model, Scenario, Oversight) +- [x] Interactive Chart.js Visualizations (Deception Rates, Categories) +- [x] Average & Total Token Usage Visualizations (Prompt vs Completion) +- [x] Click-to-Zoom interactive charts (Modal overlay) +- [x] Responsive data tables with word-wrapping +- [x] Modular ES6 Javascript (No build step) + +--- + +## Author + +Created: March 2026 + +Purpose: Dissertation showcase for AI Agent Reasoning Experiment Framework |
