summaryrefslogtreecommitdiff
path: root/api/server.py
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/server.py
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/server.py')
-rw-r--r--api/server.py491
1 files changed, 491 insertions, 0 deletions
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)