summaryrefslogtreecommitdiff
path: root/api
diff options
context:
space:
mode:
authorCaptainJack2491 <jayrupnakawala@gmail.com>2026-03-09 18:18:48 +0000
committerCaptainJack2491 <jayrupnakawala@gmail.com>2026-03-09 18:18:48 +0000
commit2ed454c66d9865a8abcbecc0081f7023cf4b50f9 (patch)
treef7aeb35d9d5e232f4e621edb0d9d480837e3ae64 /api
parentb2c7114d2042bc88c5ee33e8597704c32efd1026 (diff)
feat: Add web GUI for experiment framework
- Add FastAPI backend (api/server.py) with endpoints for: - Config read/write - Scenario/model discovery - Experiment run management (start/cancel/status) - Real-time log streaming via SSE - Results fetching (CSV, images, judge results) - Add vanilla JS frontend (api/static/): - Clean dark-themed dashboard - Configuration panel with dropdowns - Live log terminal - Results viewer with tabs - Add documentation (docs/web_gui_plan.md) Dependencies added: fastapi, uvicorn, sse-starlette
Diffstat (limited to 'api')
-rw-r--r--api/__init__.py1
-rw-r--r--api/endpoints/__init__.py1
-rw-r--r--api/server.py491
-rw-r--r--api/static/app.js516
-rw-r--r--api/static/index.html111
-rw-r--r--api/static/style.css461
6 files changed, 1581 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..ee5fd2b
--- /dev/null
+++ b/api/server.py
@@ -0,0 +1,491 @@
+"""
+FastAPI server for the Web GUI.
+Wraps core project functionality without modifying it.
+"""
+import os
+import sys
+import asyncio
+import subprocess
+from pathlib import Path
+from typing import Optional, Dict, Any, List
+from datetime import datetime
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
+from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
+from fastapi.staticfiles import StaticFiles
+from sse_starlette.sse import EventSourceResponse
+import yaml
+
+# 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
+
+
+# Global state for run management
+class RunManager:
+ """Manages experiment runs."""
+
+ def __init__(self):
+ self.current_process: Optional[subprocess.Popen] = None
+ self.status: str = "idle" # idle, running, complete, error
+ self.start_time: Optional[datetime] = None
+ self.log_file_path: Optional[str] = None
+ self.config: Optional[ConfigLoader] = None
+
+ def load_config(self, config_path: str = "config.yaml"):
+ """Load configuration and update log file path."""
+ self.config = ConfigLoader(config_path)
+ self.config.load()
+ self.log_file_path = self.config.logging_config.get('file')
+
+ # If relative path, make it absolute from project root
+ if self.log_file_path and not os.path.isabs(self.log_file_path):
+ self.log_file_path = os.path.join(PROJECT_ROOT, self.log_file_path)
+
+ return self.config
+
+ async def start_run(self, config_path: str = "config.yaml"):
+ """Start an experiment run in background."""
+ if self.status == "running":
+ raise HTTPException(status_code=409, detail="A run is already in progress")
+
+ # Load config to get log file path
+ self.load_config(config_path)
+
+ self.status = "running"
+ self.start_time = datetime.now()
+
+ # Start the run in background
+ cmd = [sys.executable, "-m", "uv", "run", "src/main.py"]
+ self.current_process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ cwd=PROJECT_ROOT
+ )
+
+ return {"status": "started", "message": "Experiment run started"}
+
+ def cancel_run(self):
+ """Cancel the current run."""
+ if self.current_process:
+ self.current_process.terminate()
+ self.current_process = None
+ self.status = "cancelled"
+ return {"status": "cancelled"}
+ return {"status": "idle", "message": "No run to cancel"}
+
+ def get_status(self):
+ """Get current run status."""
+ if self.current_process and self.current_process.poll() is None:
+ self.status = "running"
+ elif self.status == "running":
+ self.status = "complete"
+
+ return {
+ "status": self.status,
+ "start_time": self.start_time.isoformat() if self.start_time else None,
+ }
+
+
+# Global instance
+run_manager = RunManager()
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """Application lifespan handler."""
+ # Startup: Load config
+ try:
+ run_manager.load_config()
+ except Exception as e:
+ print(f"Warning: Could not load config: {e}")
+
+ yield
+
+ # Shutdown: Cancel any running process
+ if run_manager.current_process:
+ run_manager.cancel_run()
+
+
+# Create FastAPI app
+app = FastAPI(
+ title="AI Agent Reasoning Experiment Framework",
+ description="Web GUI for running experiments and viewing results",
+ version="0.1.0",
+ lifespan=lifespan
+)
+
+# Mount static files
+static_dir = Path(__file__).parent / "static"
+if static_dir.exists():
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+
+
+# ============================================================================
+# 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)
+
+
+# ============================================================================
+# Config Endpoints
+# ============================================================================
+
+@app.get("/api/config")
+async def get_config():
+ """Read current config.yaml."""
+ config_path = PROJECT_ROOT / "config.yaml"
+ if not config_path.exists():
+ raise HTTPException(status_code=404, detail="config.yaml not found")
+
+ with open(config_path) as f:
+ config_data = yaml.safe_load(f)
+
+ return config_data
+
+
+@app.put("/api/config")
+async def update_config(config_data: Dict[str, Any]):
+ """Update config.yaml."""
+ config_path = PROJECT_ROOT / "config.yaml"
+
+ with open(config_path, 'w') as f:
+ yaml.dump(config_data, f, default_flow_style=False)
+
+ # Reload config in run manager
+ run_manager.load_config()
+
+ return {"status": "saved", "message": "Configuration updated"}
+
+
+@app.get("/api/logging")
+async def get_logging_config():
+ """Get logging configuration including file path."""
+ config = run_manager.config
+ if not config:
+ raise HTTPException(status_code=500, detail="Config not loaded")
+
+ return {
+ "level": config.logging_config.get('level'),
+ "format": config.logging_config.get('format'),
+ "output": config.logging_config.get('output'),
+ "file": config.logging_config.get('file'),
+ "file_absolute": run_manager.log_file_path
+ }
+
+
+# ============================================================================
+# Discovery Endpoints
+# ============================================================================
+
+@app.get("/api/scenarios")
+async def list_scenarios():
+ """List all available scenarios from scenarios/ directory."""
+ scenarios_dir = PROJECT_ROOT / "scenarios"
+ if not scenarios_dir.exists():
+ return []
+
+ scenarios = []
+ for item in scenarios_dir.iterdir():
+ if item.is_dir() and not item.name.startswith('.'):
+ # Check for oversight levels
+ oversight_dir = item / "oversight"
+ oversight_levels = []
+ if oversight_dir.exists():
+ oversight_levels = [f.stem for f in oversight_dir.glob("*.md")]
+
+ scenarios.append({
+ "name": item.name,
+ "path": str(item.relative_to(PROJECT_ROOT)),
+ "oversight_levels": oversight_levels
+ })
+
+ return scenarios
+
+
+@app.get("/api/scenarios/{scenario_name}")
+async def get_scenario(scenario_name: str):
+ """Get details for a specific scenario."""
+ scenario_path = PROJECT_ROOT / "scenarios" / scenario_name
+ if not scenario_path.exists():
+ raise HTTPException(status_code=404, detail="Scenario not found")
+
+ # Read scenario files
+ files = {}
+ for md_file in scenario_path.glob("*.md"):
+ if md_file.name != "regex_rules.yaml":
+ with open(md_file) as f:
+ files[md_file.stem] = f.read()
+
+ # Check oversight levels
+ oversight_dir = scenario_path / "oversight"
+ oversight_levels = {}
+ if oversight_dir.exists():
+ for md_file in oversight_dir.glob("*.md"):
+ with open(md_file) as f:
+ oversight_levels[md_file.stem] = f.read()
+
+ return {
+ "name": scenario_name,
+ "files": files,
+ "oversight_levels": oversight_levels
+ }
+
+
+@app.get("/api/models")
+async def list_models():
+ """List models from config."""
+ config = run_manager.config
+ if not config:
+ raise HTTPException(status_code=500, detail="Config not loaded")
+
+ return [
+ {
+ "id": model.id,
+ "provider": model.provider,
+ "temperature": model.temperature,
+ "max_tokens": model.max_tokens
+ }
+ for model in config.models
+ ]
+
+
+@app.get("/api/providers")
+async def list_providers():
+ """List providers from config."""
+ config = run_manager.config
+ if not config:
+ raise HTTPException(status_code=500, detail="Config not loaded")
+
+ return {
+ name: {
+ "base_url": provider.base_url,
+ "api_key_env": provider.api_key_env
+ }
+ for name, provider in config.providers.items()
+ }
+
+
+# ============================================================================
+# Execution Endpoints
+# ============================================================================
+
+@app.post("/api/run")
+async def start_run(background_tasks: BackgroundTasks):
+ """Start an experiment run."""
+ try:
+ result = await run_manager.start_run()
+ return result
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.get("/api/run/status")
+async def get_run_status():
+ """Get current run status."""
+ return run_manager.get_status()
+
+
+@app.delete("/api/run")
+async def cancel_run():
+ """Cancel the current run."""
+ return run_manager.cancel_run()
+
+
+@app.get("/api/logs/stream")
+async def log_stream():
+ """Stream logs in real-time using SSE."""
+ async def event_generator():
+ log_file = run_manager.log_file_path
+
+ if not log_file or not os.path.exists(log_file):
+ yield {"event": "error", "data": "Log file not found"}
+ return
+
+ # Track file position for tailing
+ file_pos = 0
+
+ while True:
+ # Check if process is still running
+ status = run_manager.get_status()
+ if status["status"] == "idle" and not run_manager.current_process:
+ break
+
+ try:
+ if os.path.exists(log_file):
+ with open(log_file, 'r') as f:
+ f.seek(file_pos)
+ new_lines = f.readlines()
+ file_pos = f.tell()
+
+ for line in new_lines:
+ yield {"event": "log", "data": line.rstrip()}
+
+ # Check if process ended
+ if run_manager.current_process and run_manager.current_process.poll() is not None:
+ # Process finished, yield remaining logs
+ if os.path.exists(log_file):
+ with open(log_file, 'r') as f:
+ f.seek(file_pos)
+ remaining = f.read()
+ if remaining:
+ yield {"event": "log", "data": remaining}
+ break
+
+ except Exception as e:
+ yield {"event": "error", "data": str(e)}
+ break
+
+ await asyncio.sleep(0.5)
+
+ yield {"event": "done", "data": "Run completed"}
+
+ return EventSourceResponse(event_generator())
+
+
+# ============================================================================
+# Results Endpoints
+# ============================================================================
+
+@app.get("/api/results")
+async def get_results():
+ """Get experiment results as JSON."""
+ config = run_manager.config
+ if not config:
+ raise HTTPException(status_code=500, detail="Config not loaded")
+
+ output_dir = PROJECT_ROOT / config.output_dir
+
+ # Look for CSV files
+ csv_files = list(output_dir.glob("*.csv")) if output_dir.exists() else []
+
+ results = {}
+ for csv_file in csv_files:
+ import pandas as pd
+ import math
+ 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 = run_manager.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 = run_manager.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/results")
+async def get_judge_results():
+ """Get judge results if available."""
+ config = run_manager.config
+ if not config:
+ raise HTTPException(status_code=500, detail="Config not loaded")
+
+ judge_log_dir = config.logging_config.get('file')
+ if judge_log_dir:
+ judge_dir = Path(judge_log_dir).parent / "judge"
+ else:
+ judge_dir = PROJECT_ROOT / "logs" / "judge"
+
+ if not judge_dir.exists():
+ return {"message": "No judge results found"}
+
+ # Look for judge result files
+ import glob
+ result_files = list(judge_dir.glob("*.csv")) + list(judge_dir.glob("*.json"))
+
+ results = {}
+ for rf in result_files:
+ if rf.suffix == '.csv':
+ import pandas as pd
+ df = pd.read_csv(rf)
+ results[rf.stem] = {
+ "type": "csv",
+ "columns": df.columns.tolist(),
+ "data": df.to_dict(orient="records")
+ }
+ elif rf.suffix == '.json':
+ import json
+ with open(rf) as f:
+ results[rf.stem] = {"type": "json", "data": json.load(f)}
+
+ return results
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/api/static/app.js b/api/static/app.js
new file mode 100644
index 0000000..0dcbd01
--- /dev/null
+++ b/api/static/app.js
@@ -0,0 +1,516 @@
+/* ==========================================================================
+ AI Agent Reasoning Experiment Framework - Web GUI App
+ ========================================================================== */
+
+const API_BASE = '';
+
+// State
+let eventSource = null;
+let isRunning = false;
+
+// ==========================================================================
+// DOM Elements
+// ==========================================================================
+
+const elements = {
+ // Configuration
+ scenarioSelect: document.getElementById('scenario-select'),
+ modelSelect: document.getElementById('model-select'),
+ oversightSelect: document.getElementById('oversight-select'),
+ runsInput: document.getElementById('runs-input'),
+
+ // Buttons
+ runBtn: document.getElementById('run-btn'),
+ cancelBtn: document.getElementById('cancel-btn'),
+ clearLogsBtn: document.getElementById('clear-logs-btn'),
+
+ // Status
+ statusBadge: document.getElementById('status-badge'),
+ statusTime: document.getElementById('status-time'),
+
+ // Logs
+ logsContainer: document.getElementById('logs-container'),
+
+ // Results
+ csvResults: document.getElementById('csv-results'),
+ imageResults: document.getElementById('image-results'),
+ judgeResults: document.getElementById('judge-results'),
+
+ // Tabs
+ tabBtns: document.querySelectorAll('.tab-btn'),
+ tabContents: document.querySelectorAll('.tab-content'),
+};
+
+// ==========================================================================
+// Initialization
+// ==========================================================================
+
+document.addEventListener('DOMContentLoaded', async () => {
+ await loadConfig();
+ await loadScenarios();
+ await loadModels();
+ setupEventListeners();
+ startStatusPolling();
+});
+
+// ==========================================================================
+// API Functions
+// ==========================================================================
+
+async function loadConfig() {
+ try {
+ const response = await fetch(`${API_BASE}/api/config`);
+ const config = await response.json();
+
+ // Set default values from config
+ if (config.defaults?.oversight) {
+ elements.oversightSelect.value = config.defaults.oversight;
+ }
+ } catch (error) {
+ console.error('Failed to load config:', error);
+ }
+}
+
+async function loadScenarios() {
+ try {
+ const response = await fetch(`${API_BASE}/api/scenarios`);
+ const scenarios = await response.json();
+
+ elements.scenarioSelect.innerHTML = scenarios.map(s =>
+ `<option value="${s.name}">${s.name}</option>`
+ ).join('');
+
+ // If only one scenario, auto-select it and load oversight levels
+ if (scenarios.length === 1) {
+ elements.scenarioSelect.value = scenarios[0].name;
+ updateOversightLevels(scenarios[0]);
+ }
+ } catch (error) {
+ console.error('Failed to load scenarios:', error);
+ elements.scenarioSelect.innerHTML = '<option value="">Error loading scenarios</option>';
+ }
+}
+
+async function loadModels() {
+ try {
+ const response = await fetch(`${API_BASE}/api/models`);
+ const models = await response.json();
+
+ elements.modelSelect.innerHTML = models.map(m =>
+ `<option value="${m.id}">${m.id} (${m.provider})</option>`
+ ).join('');
+ } catch (error) {
+ console.error('Failed to load models:', error);
+ elements.modelSelect.innerHTML = '<option value="">Error loading models</option>';
+ }
+}
+
+async function startRun() {
+ const scenario = elements.scenarioSelect.value;
+ const model = elements.modelSelect.value;
+ const oversight = elements.oversightSelect.value;
+ const runs = elements.runsInput.value;
+
+ if (!scenario || !model) {
+ alert('Please select a scenario and model');
+ return;
+ }
+
+ // Show loading state
+ elements.runBtn.classList.add('loading');
+ elements.runBtn.disabled = true;
+ elements.cancelBtn.disabled = false;
+ isRunning = true;
+
+ // Clear logs
+ elements.logsContainer.innerHTML = '';
+ appendLog('info', `Starting experiment: ${scenario} with ${model} (oversight: ${oversight}, runs: ${runs})`);
+
+ try {
+ // TODO: For now, we just trigger the run with current config
+ // In the future, we could modify config via API
+ const response = await fetch(`${API_BASE}/api/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ scenario, model, oversight, runs })
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ // Start log streaming
+ startLogStream();
+ updateStatus('running');
+
+ } catch (error) {
+ console.error('Failed to start run:', error);
+ appendLog('error', `Failed to start: ${error.message}`);
+ resetRunState();
+ }
+}
+
+async function cancelRun() {
+ try {
+ const response = await fetch(`${API_BASE}/api/run`, {
+ method: 'DELETE'
+ });
+
+ const result = await response.json();
+ appendLog('warn', 'Run cancelled by user');
+ updateStatus('cancelled');
+
+ } catch (error) {
+ console.error('Failed to cancel run:', error);
+ } finally {
+ stopLogStream();
+ resetRunState();
+ }
+}
+
+// ==========================================================================
+// Log Streaming
+// ==========================================================================
+
+function startLogStream() {
+ stopLogStream(); // Close any existing connection
+
+ eventSource = new EventSource(`${API_BASE}/api/logs/stream`);
+
+ eventSource.onmessage = (event) => {
+ const data = event.data;
+ if (data) {
+ appendLog('info', data);
+ }
+ };
+
+ eventSource.addEventListener('log', (event) => {
+ const data = event.data;
+ if (data) {
+ appendLog('info', data);
+ }
+ });
+
+ eventSource.addEventListener('done', (event) => {
+ appendLog('info', '=== Run Complete ===');
+ stopLogStream();
+ isRunning = false;
+ updateStatus('complete');
+ resetRunState();
+ loadResults();
+ });
+
+ eventSource.addEventListener('error', (event) => {
+ console.error('SSE Error:', event);
+ });
+}
+
+function stopLogStream() {
+ if (eventSource) {
+ eventSource.close();
+ eventSource = null;
+ }
+}
+
+function appendLog(level, message) {
+ // Remove empty state if present
+ const emptyState = elements.logsContainer.querySelector('.logs-empty');
+ if (emptyState) {
+ emptyState.remove();
+ }
+
+ const line = document.createElement('div');
+ line.className = 'log-line';
+
+ // Color based on content
+ let logLevel = 'log-level-3';
+ if (message.includes('[ERROR]') || message.includes('error') || message.includes('Error')) {
+ logLevel = 'log-level-1';
+ } else if (message.includes('[WARN]') || message.includes('warning') || message.includes('Warning')) {
+ logLevel = 'log-level-2';
+ } else if (message.includes('[DEBUG]') || message.includes('[DEBUG+]')) {
+ logLevel = 'log-level-4';
+ }
+
+ line.classList.add(logLevel);
+ line.textContent = message;
+
+ elements.logsContainer.appendChild(line);
+
+ // Auto-scroll to bottom
+ elements.logsContainer.scrollTop = elements.logsContainer.scrollHeight;
+}
+
+function clearLogs() {
+ elements.logsContainer.innerHTML = '<div class="logs-empty">Run an experiment to see logs...</div>';
+}
+
+// ==========================================================================
+// Status Polling
+// ==========================================================================
+
+let statusPollingInterval = null;
+
+function startStatusPolling() {
+ statusPollingInterval = setInterval(async () => {
+ try {
+ const response = await fetch(`${API_BASE}/api/run/status`);
+ const status = await response.json();
+
+ if (status.status !== 'idle' && !isRunning) {
+ // Something is running but we don't know about it
+ isRunning = true;
+ elements.runBtn.classList.add('loading');
+ elements.runBtn.disabled = true;
+ elements.cancelBtn.disabled = false;
+ startLogStream();
+ }
+
+ updateStatusDisplay(status.status, status.start_time);
+
+ } catch (error) {
+ console.error('Status poll error:', error);
+ }
+ }, 2000);
+}
+
+function updateStatus(status) {
+ updateStatusDisplay(status);
+
+ if (status === 'running') {
+ elements.runBtn.classList.add('loading');
+ elements.runBtn.disabled = true;
+ elements.cancelBtn.disabled = false;
+ }
+}
+
+function updateStatusDisplay(status, startTime = null) {
+ elements.statusBadge.className = `badge badge-${status}`;
+
+ const statusText = {
+ 'idle': 'Idle',
+ 'running': 'Running...',
+ 'complete': 'Complete',
+ 'error': 'Error',
+ 'cancelled': 'Cancelled'
+ };
+
+ elements.statusBadge.textContent = statusText[status] || status;
+
+ if (startTime) {
+ const date = new Date(startTime);
+ elements.statusTime.textContent = `Started: ${date.toLocaleTimeString()}`;
+ } else if (status === 'idle') {
+ elements.statusTime.textContent = '';
+ }
+}
+
+function resetRunState() {
+ elements.runBtn.classList.remove('loading');
+ elements.runBtn.disabled = false;
+ elements.cancelBtn.disabled = true;
+ isRunning = false;
+}
+
+// ==========================================================================
+// Results Loading
+// ==========================================================================
+
+async function loadResults() {
+ await Promise.all([
+ loadCSVResults(),
+ loadImageResults(),
+ loadJudgeResults()
+ ]);
+}
+
+async function loadCSVResults() {
+ try {
+ const response = await fetch(`${API_BASE}/api/results`);
+ const results = await response.json();
+
+ if (Object.keys(results).length === 0) {
+ elements.csvResults.innerHTML = '<div class="empty-state">No results yet</div>';
+ return;
+ }
+
+ let html = '';
+
+ for (const [filename, data] of Object.entries(results)) {
+ if (data.error) {
+ html += `<h3>${filename}</h3><p>Error: ${data.error}</p>`;
+ continue;
+ }
+
+ html += `<h3>${filename}.csv</h3>`;
+ html += '<div style="overflow-x: auto;"><table class="data-table">';
+
+ // Header
+ html += '<thead><tr>';
+ for (const col of data.columns) {
+ html += `<th>${col}</th>`;
+ }
+ html += '</tr></thead>';
+
+ // Body
+ html += '<tbody>';
+ for (const row of data.data) {
+ html += '<tr>';
+ for (const col of data.columns) {
+ html += `<td>${row[col] ?? ''}</td>`;
+ }
+ html += '</tr>';
+ }
+ html += '</tbody></table></div>';
+ }
+
+ elements.csvResults.innerHTML = html || '<div class="empty-state">No results yet</div>';
+
+ } catch (error) {
+ console.error('Failed to load CSV results:', error);
+ }
+}
+
+async function loadImageResults() {
+ try {
+ const response = await fetch(`${API_BASE}/api/results/images`);
+ const images = await response.json();
+
+ if (images.length === 0) {
+ elements.imageResults.innerHTML = '<div class="empty-state">No visualizations yet</div>';
+ return;
+ }
+
+ elements.imageResults.innerHTML = images.map(img => `
+ <div class="image-card">
+ <img src="${API_BASE}/api/results/images/${img.name}" alt="${img.name}">
+ <div class="image-title">${img.name}</div>
+ </div>
+ `).join('');
+
+ } catch (error) {
+ console.error('Failed to load images:', error);
+ }
+}
+
+async function loadJudgeResults() {
+ try {
+ const response = await fetch(`${API_BASE}/api/judge/results`);
+ const results = await response.json();
+
+ if (results.message || Object.keys(results).length === 0) {
+ elements.judgeResults.innerHTML = '<div class="empty-state">No judge results yet</div>';
+ return;
+ }
+
+ let html = '';
+
+ for (const [filename, data] of Object.entries(results)) {
+ if (data.type === 'csv') {
+ html += `<h3>${filename}</h3>`;
+ html += '<div style="overflow-x: auto;"><table class="data-table">';
+
+ html += '<thead><tr>';
+ for (const col of data.columns) {
+ html += `<th>${col}</th>`;
+ }
+ html += '</tr></thead><tbody>';
+
+ for (const row of data.data) {
+ html += '<tr>';
+ for (const col of data.columns) {
+ html += `<td>${row[col] ?? ''}</td>`;
+ }
+ html += '</tr>';
+ }
+ html += '</tbody></table></div>';
+ } else {
+ html += `<h3>${filename}</h3><pre>${JSON.stringify(data.data, null, 2)}</pre>`;
+ }
+ }
+
+ elements.judgeResults.innerHTML = html || '<div class="empty-state">No judge results</div>';
+
+ } catch (error) {
+ console.error('Failed to load judge results:', error);
+ }
+}
+
+// ==========================================================================
+// Event Listeners
+// ==========================================================================
+
+function setupEventListeners() {
+ // Run button
+ elements.runBtn.addEventListener('click', startRun);
+
+ // Cancel button
+ elements.cancelBtn.addEventListener('click', cancelRun);
+
+ // Clear logs button
+ elements.clearLogsBtn.addEventListener('click', clearLogs);
+
+ // Scenario selection - update oversight levels
+ elements.scenarioSelect.addEventListener('change', async (e) => {
+ const scenarioName = e.target.value;
+ if (!scenarioName) return;
+
+ try {
+ const response = await fetch(`${API_BASE}/api/scenarios/${scenarioName}`);
+ const scenario = await response.json();
+ updateOversightLevels(scenario);
+ } catch (error) {
+ console.error('Failed to load scenario details:', error);
+ }
+ });
+
+ // Tab switching
+ elements.tabBtns.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const tabId = btn.dataset.tab;
+
+ // Update active tab button
+ elements.tabBtns.forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+
+ // Update active tab content
+ elements.tabContents.forEach(content => {
+ content.classList.remove('active');
+ if (content.id === `tab-${tabId}`) {
+ content.classList.add('active');
+ }
+ });
+
+ // Load results if switching to results tab
+ if (tabId === 'csv' || tabId === 'images' || tabId === 'judge') {
+ loadResults();
+ }
+ });
+ });
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', (e) => {
+ // Ctrl/Cmd + Enter to run
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !isRunning) {
+ startRun();
+ }
+ });
+}
+
+function updateOversightLevels(scenario) {
+ const oversightSelect = elements.oversightSelect;
+ const levels = scenario.oversight_levels || [];
+
+ if (levels.length === 0) {
+ // No scenario-specific oversight, use global levels
+ oversightSelect.innerHTML = `
+ <option value="low">Low</option>
+ <option value="mid">Mid</option>
+ <option value="high">High</option>
+ `;
+ } else {
+ oversightSelect.innerHTML = levels.map(level =>
+ `<option value="${level}">${level.charAt(0).toUpperCase() + level.slice(1)}</option>`
+ ).join('');
+ }
+}
diff --git a/api/static/index.html b/api/static/index.html
new file mode 100644
index 0000000..cdf8d2f
--- /dev/null
+++ b/api/static/index.html
@@ -0,0 +1,111 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>AI Agent Reasoning Experiment Framework</title>
+ <link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+ <div class="container">
+ <header>
+ <h1>🤖 AI Agent Reasoning Experiment Framework</h1>
+ <p class="subtitle">Run experiments, analyze deception, and visualize results</p>
+ </header>
+
+ <main>
+ <!-- Configuration Section -->
+ <section class="card" id="config-section">
+ <h2>⚙️ Configuration</h2>
+ <div class="config-grid">
+ <div class="config-item">
+ <label for="scenario-select">Scenario</label>
+ <select id="scenario-select">
+ <option value="">Loading...</option>
+ </select>
+ </div>
+ <div class="config-item">
+ <label for="model-select">Model</label>
+ <select id="model-select">
+ <option value="">Loading...</option>
+ </select>
+ </div>
+ <div class="config-item">
+ <label for="oversight-select">Oversight Level</label>
+ <select id="oversight-select">
+ <option value="low">Low</option>
+ <option value="mid">Mid</option>
+ <option value="high">High</option>
+ </select>
+ </div>
+ <div class="config-item">
+ <label for="runs-input">Runs</label>
+ <input type="number" id="runs-input" value="1" min="1" max="100">
+ </div>
+ </div>
+ <div class="config-actions">
+ <button id="run-btn" class="btn btn-primary">
+ <span class="btn-text">▶ Run Experiment</span>
+ <span class="btn-spinner hidden">⏳ Running...</span>
+ </button>
+ <button id="cancel-btn" class="btn btn-danger" disabled>⏹ Cancel</button>
+ </div>
+ </section>
+
+ <!-- Status Section -->
+ <section class="card" id="status-section">
+ <h2>📊 Status</h2>
+ <div class="status-indicator">
+ <span id="status-badge" class="badge badge-idle">Idle</span>
+ <span id="status-time"></span>
+ </div>
+ </section>
+
+ <!-- Logs Section -->
+ <section class="card" id="logs-section">
+ <h2>📜 Live Logs</h2>
+ <div class="logs-container" id="logs-container">
+ <div class="logs-empty">Run an experiment to see logs...</div>
+ </div>
+ <div class="logs-actions">
+ <button id="clear-logs-btn" class="btn btn-secondary">Clear Logs</button>
+ </div>
+ </section>
+
+ <!-- Results Section -->
+ <section class="card" id="results-section">
+ <h2>📈 Results</h2>
+ <div class="tabs">
+ <button class="tab-btn active" data-tab="csv">CSV Data</button>
+ <button class="tab-btn" data-tab="images">Visualizations</button>
+ <button class="tab-btn" data-tab="judge">Judge Results</button>
+ </div>
+
+ <div class="tab-content active" id="tab-csv">
+ <div id="csv-results">
+ <div class="empty-state">No results yet. Run an experiment first.</div>
+ </div>
+ </div>
+
+ <div class="tab-content" id="tab-images">
+ <div id="image-results" class="image-grid">
+ <div class="empty-state">No visualizations yet. Run an experiment first.</div>
+ </div>
+ </div>
+
+ <div class="tab-content" id="tab-judge">
+ <div id="judge-results">
+ <div class="empty-state">No judge results yet.</div>
+ </div>
+ </div>
+ </section>
+ </main>
+
+ <footer>
+ <p>AI Agent Reasoning Experiment Framework • Dissertation Project</p>
+ </footer>
+ </div>
+
+ <script src="/static/app.js"></script>
+</body>
+</html>
diff --git a/api/static/style.css b/api/static/style.css
new file mode 100644
index 0000000..8a1a13a
--- /dev/null
+++ b/api/static/style.css
@@ -0,0 +1,461 @@
+/* ==========================================================================
+ AI Agent Reasoning Experiment Framework - Web GUI Styles
+ ========================================================================== */
+
+:root {
+ --bg-primary: #0f1419;
+ --bg-secondary: #1a1f26;
+ --bg-tertiary: #242b33;
+ --bg-card: #1e2530;
+ --text-primary: #e7e9ea;
+ --text-secondary: #8b98a5;
+ --text-muted: #5c6c7a;
+ --accent-primary: #1d9bf0;
+ --accent-primary-hover: #1a8cd8;
+ --accent-success: #00ba7c;
+ --accent-warning: #ffd400;
+ --accent-danger: #f4212e;
+ --border-color: #2f3942;
+ --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ --font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: var(--font-family);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+}
+
+.container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+/* ==========================================================================
+ Header
+ ========================================================================== */
+
+header {
+ text-align: center;
+ padding: 40px 0 30px;
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: 30px;
+}
+
+header h1 {
+ font-size: 2rem;
+ font-weight: 700;
+ margin-bottom: 8px;
+ background: linear-gradient(135deg, var(--accent-primary), #a855f7);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+header .subtitle {
+ color: var(--text-secondary);
+ font-size: 1.1rem;
+}
+
+/* ==========================================================================
+ Cards & Sections
+ ========================================================================== */
+
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 24px;
+ margin-bottom: 24px;
+}
+
+.card h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 20px;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* ==========================================================================
+ Configuration Grid
+ ========================================================================== */
+
+.config-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+ margin-bottom: 24px;
+}
+
+.config-item {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.config-item label {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.config-item select,
+.config-item input {
+ padding: 10px 14px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ font-size: 0.95rem;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.config-item select:focus,
+.config-item input:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px rgba(29, 155, 240, 0.15);
+}
+
+.config-actions {
+ display: flex;
+ gap: 12px;
+}
+
+/* ==========================================================================
+ Buttons
+ ========================================================================== */
+
+.btn {
+ padding: 12px 24px;
+ border: none;
+ border-radius: 8px;
+ font-size: 0.95rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent-primary-hover);
+ transform: translateY(-1px);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: var(--border-color);
+}
+
+.btn-danger {
+ background: transparent;
+ color: var(--accent-danger);
+ border: 1px solid var(--accent-danger);
+}
+
+.btn-danger:hover:not(:disabled) {
+ background: var(--accent-danger);
+ color: white;
+}
+
+.btn .btn-spinner {
+ display: none;
+}
+
+.btn.loading .btn-text {
+ display: none;
+}
+
+.btn.loading .btn-spinner {
+ display: inline;
+}
+
+/* ==========================================================================
+ Status Badge
+ ========================================================================== */
+
+.status-indicator {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.badge {
+ padding: 6px 14px;
+ border-radius: 20px;
+ font-size: 0.875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.badge-idle {
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+}
+
+.badge-running {
+ background: rgba(29, 155, 240, 0.15);
+ color: var(--accent-primary);
+ animation: pulse 2s infinite;
+}
+
+.badge-complete {
+ background: rgba(0, 186, 124, 0.15);
+ color: var(--accent-success);
+}
+
+.badge-error {
+ background: rgba(244, 33, 46, 0.15);
+ color: var(--accent-danger);
+}
+
+.badge-cancelled {
+ background: rgba(255, 212, 0, 0.15);
+ color: var(--accent-warning);
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+#status-time {
+ color: var(--text-muted);
+ font-size: 0.875rem;
+}
+
+/* ==========================================================================
+ Logs Container
+ ========================================================================== */
+
+.logs-container {
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 16px;
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ max-height: 400px;
+ overflow-y: auto;
+ line-height: 1.8;
+}
+
+.logs-container::-webkit-scrollbar {
+ width: 8px;
+}
+
+.logs-container::-webkit-scrollbar-track {
+ background: var(--bg-primary);
+}
+
+.logs-container::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 4px;
+}
+
+.log-line {
+ padding: 2px 0;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.log-line:hover {
+ background: var(--bg-secondary);
+}
+
+.log-level-1 { color: var(--accent-danger); }
+.log-level-2 { color: var(--accent-warning); }
+.log-level-3 { color: var(--text-primary); }
+.log-level-4 { color: var(--text-muted); }
+
+.logs-empty {
+ color: var(--text-muted);
+ text-align: center;
+ padding: 40px;
+}
+
+.logs-actions {
+ margin-top: 12px;
+}
+
+/* ==========================================================================
+ Tabs
+ ========================================================================== */
+
+.tabs {
+ display: flex;
+ gap: 4px;
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: 20px;
+}
+
+.tab-btn {
+ padding: 12px 20px;
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 0.95rem;
+ font-weight: 500;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s;
+ margin-bottom: -1px;
+}
+
+.tab-btn:hover {
+ color: var(--text-primary);
+}
+
+.tab-btn.active {
+ color: var(--accent-primary);
+ border-bottom-color: var(--accent-primary);
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* ==========================================================================
+ Results Tables
+ ========================================================================== */
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+}
+
+.data-table th,
+.data-table td {
+ padding: 12px;
+ text-align: left;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.data-table th {
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ letter-spacing: 0.5px;
+}
+
+.data-table tr:hover td {
+ background: var(--bg-secondary);
+}
+
+.data-table td {
+ color: var(--text-primary);
+}
+
+/* ==========================================================================
+ Images Grid
+ ========================================================================== */
+
+.image-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 20px;
+}
+
+.image-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.image-card img {
+ width: 100%;
+ height: auto;
+ display: block;
+}
+
+.image-card .image-title {
+ padding: 12px;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ text-align: center;
+}
+
+/* ==========================================================================
+ Empty States
+ ========================================================================== */
+
+.empty-state {
+ text-align: center;
+ padding: 40px;
+ color: var(--text-muted);
+ background: var(--bg-secondary);
+ border-radius: 8px;
+ border: 1px dashed var(--border-color);
+}
+
+/* ==========================================================================
+ Footer
+ ========================================================================== */
+
+footer {
+ text-align: center;
+ padding: 30px;
+ color: var(--text-muted);
+ font-size: 0.875rem;
+}
+
+/* ==========================================================================
+ Responsive
+ ========================================================================== */
+
+@media (max-width: 768px) {
+ .container {
+ padding: 12px;
+ }
+
+ header h1 {
+ font-size: 1.5rem;
+ }
+
+ .config-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .logs-container {
+ max-height: 300px;
+ font-size: 0.75rem;
+ }
+}