diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e3708fc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,66 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# Virtual Environment
+venv/
+env/
+ENV/
+env.bak/
+venv.bak/
+.venv/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# Logs
+*.log
+logs/
+
+# Environment variables
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# Database
+*.db
+*.sqlite
+*.sqlite3
+
+# Temporary files
+*.tmp
+*.temp
\ No newline at end of file
diff --git a/README.md b/README.md
index e69de29..f83a2cf 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,235 @@
+# GUM Frontend Web Interface
+
+A modern, responsive web interface for interacting with the GUM (General User Models) REST API. This frontend allows users to submit observations, query insights, and view observation history through an intuitive web interface.
+
+## Features
+
+### Submit Observations
+- **Text Observations**: Enter behavioral observations directly through a form
+- **Image Observations**: Upload images for AI analysis and insight generation
+- **Drag & Drop**: Easy file upload with drag and drop support
+- **Real-time Preview**: Preview uploaded images before submission
+
+### Query Insights
+- **Natural Language Search**: Query behavioral insights using natural language
+- **Configurable Results**: Adjust the number of results returned
+- **Rich Results Display**: View propositions with confidence scores and reasoning
+- **User-specific Queries**: Query insights for specific users
+
+### Observation History
+- **Recent Observations**: View recently submitted observations
+- **Pagination**: Load different numbers of historical records
+- **Detailed View**: See observation content, type, and metadata
+- **User Filtering**: Filter observations by user
+
+### Modern UI/UX
+- **Responsive Design**: Works on desktop, tablet, and mobile devices
+- **Dark/Light Theme**: Beautiful gradient design with modern aesthetics
+- **Real-time Feedback**: Toast notifications for all actions
+- **Loading States**: Visual feedback during API calls
+- **Connection Status**: Real-time API connection monitoring
+
+## Directory Structure
+
+```
+frontend/
+├── index.html # Main HTML file
+├── server.py # Simple Python HTTP server
+├── README.md # This file
+└── static/
+ ├── css/
+ │ └── styles.css # Main stylesheet
+ ├── js/
+ │ └── app.js # JavaScript application
+ └── images/ # Image assets (if any)
+```
+
+## Setup & Installation
+
+### Prerequisites
+- GUM API Controller running (usually on port 8001)
+- Python 3.6+ (for the simple server)
+- Modern web browser
+- FFMPEG installed (for image processing)
+
+### Quick Start
+
+1. **Start the GUM API Controller** (in the main GUM directory):
+ ```bash
+ cd /path/to/gum
+ python controller.py --port 8001
+ ```
+
+2. **Start the Frontend Server**:
+ ```bash
+ cd frontend
+ python server.py
+ ```
+
+3. **Open in Browser**:
+ - Navigate to http://localhost:3000
+ - The interface should automatically detect the API connection
+
+### Alternative Server Options
+
+#### Using Python's built-in server:
+```bash
+cd frontend
+python -m http.server 3000
+```
+
+#### Using any other static server:
+Just serve the `frontend/` directory as static files on any port.
+
+## Configuration
+
+### API Endpoint
+The frontend automatically loads the API base URL from the environment configuration. To change this:
+
+1. **Recommended**: Edit `frontend/.env` file:
+ ```env
+ # GUM Frontend Configuration
+ GUM_API_ADDRESS=http://localhost:8002
+ ```
+
+2. **Alternative**: Set environment variable:
+ ```bash
+ export GUM_API_ADDRESS=http://your-api-host:port
+ python server.py
+ ```
+
+3. **Legacy**: Directly edit `frontend/static/js/app.js` (not recommended):
+ ```javascript
+ constructor() {
+ this.apiBaseUrl = 'http://your-api-host:port';
+ // ...
+ }
+ ```
+
+The server will automatically inject the configured API URL into the frontend when serving `index.html`.
+
+### Server Port
+To run the frontend server on a different port:
+```bash
+python server.py --port 8080
+```
+
+## Usage Guide
+
+### Submitting Text Observations
+1. Click on the "Submit Observations" tab
+2. In the "Submit Text Observation" section:
+ - Enter your observation in the text area
+ - Optionally specify a user name
+ - Optionally change the observer name
+ - Click "Submit Text Observation"
+
+### Submitting Image Observations
+1. In the "Submit Image Observation" section:
+ - Click the upload area or drag and drop an image
+ - Optionally specify a user name
+ - Click "Submit Image Observation"
+ - The image will be analyzed by AI and processed
+
+### Querying Insights
+1. Click on the "Query Insights" tab
+2. Enter your search query (e.g., "productivity patterns", "coding habits")
+3. Optionally specify a user name and result limit
+4. Click "Search Insights"
+5. View the results with confidence scores and reasoning
+
+### Viewing History
+1. Click on the "History" tab
+2. Optionally specify a user name and number of records
+3. Click "Load History"
+4. Browse through recent observations
+
+## Example Workflows
+
+### Adding Development Observations
+```
+1. Submit text: "User spent 2 hours coding in VS Code with multiple files open"
+2. Submit image: Upload a screenshot of your development environment
+3. Query: "development workflow" to see generated insights
+```
+
+### Analyzing Productivity Patterns
+```
+1. Submit multiple observations about work activities
+2. Query: "productivity patterns" or "work efficiency"
+3. Review insights to understand behavioral patterns
+```
+
+### Team Usage Tracking
+```
+1. Submit observations with different user names
+2. Query insights for specific team members
+3. Compare patterns across different users
+```
+
+## Features in Detail
+
+### Connection Status Indicator
+- **Green (Connected)**: API is reachable and healthy
+- **Red (Disconnected)**: Cannot reach the API
+- **Yellow (Connecting)**: Checking connection status
+
+### Toast Notifications
+- **Success (Green)**: Operations completed successfully
+- **Error (Red)**: Issues with operations or API calls
+- **Info (Blue)**: General information messages
+
+### Loading States
+- **Loading Overlay**: Shown during long operations (AI analysis)
+- **Disabled Buttons**: Prevent multiple submissions during processing
+- **Progress Feedback**: Processing time displayed after completion
+
+### File Upload
+- **Drag & Drop**: Drag images directly onto the upload area
+- **File Validation**: Only image files are accepted
+- **Preview**: See selected image before submission
+- **Size Handling**: Large images are automatically resized
+
+## Development
+
+### Adding New Features
+1. **HTML**: Add new elements to `index.html`
+2. **CSS**: Style elements in `static/css/styles.css`
+3. **JavaScript**: Add functionality in `static/js/app.js`
+
+
+### API Integration
+All API calls are handled in the `GUMApp` class methods:
+- `submitTextObservation()`
+- `submitImageObservation()`
+- `queryInsights()`
+- `loadHistory()`
+
+## Troubleshooting
+
+### "Cannot connect to GUM API"
+- Ensure the GUM API controller is running on port 8001
+- Check that both frontend and API are on the same network
+- Verify firewall settings aren't blocking connections
+
+### Images not uploading
+- Check file size (very large images may timeout)
+- Ensure file is a valid image format
+- Check browser console for error messages
+
+### Queries returning no results
+- Try broader search terms
+- Ensure observations have been submitted and processed
+- Check that you're querying the correct user name
+
+### Frontend not loading
+- Ensure you're accessing the correct URL (http://localhost:3000)
+- Check browser console for JavaScript errors
+- Verify all static files are being served correctly
+
+## Security Considerations
+### Privacy
+- All data is processed locally through your GUM instance
+- Images are sent to Azure OpenAI for analysis (per GUM configuration)
+- No data is stored by the frontend itself
+
diff --git a/controller.py b/controller.py
new file mode 100644
index 0000000..ef4ac58
--- /dev/null
+++ b/controller.py
@@ -0,0 +1,1932 @@
+#!/usr/bin/env python3
+"""
+GUM REST API Controller
+
+A FastAPI-based REST API that exposes GUM functionality for submitting
+observations through text and images, and querying the system.
+"""
+
+import asyncio
+import base64
+import glob
+import logging
+import os
+import subprocess
+import tempfile
+import time
+import uuid
+import re
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from datetime import datetime
+from dateutil import parser as date_parser
+from io import BytesIO
+from pathlib import Path
+from typing import List, Optional, Union
+from asyncio import Semaphore
+
+import uvicorn
+from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from PIL import Image
+from pydantic import BaseModel, Field
+
+from dotenv import load_dotenv
+from gum import gum
+from gum.schemas import Update
+from gum.observers import Observer
+from gum.unified_ai_client import UnifiedAIClient
+
+# Load environment variables
+load_dotenv(override=True) # Ensure .env takes precedence
+
+# Configure logging with user-friendly format
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s | %(message)s', # Cleaner format for user visibility
+ datefmt='%H:%M:%S', # Just time, not full date
+ handlers=[
+ logging.StreamHandler() # Ensure console output
+ ]
+)
+logger = logging.getLogger(__name__)
+
+# Ensure immediate console output (force flush)
+import sys
+import os
+os.environ['PYTHONUNBUFFERED'] = '1'
+
+# Initialize FastAPI app
+app = FastAPI(
+ title="GUM API",
+ description="REST API for submitting observations and querying user behavior insights",
+ version="1.0.0",
+ docs_url="/docs",
+ redoc_url="/redoc"
+)
+
+# Add CORS middleware to allow frontend connections
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"], # In production, replace with specific origins
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# Global GUM instance
+gum_instance: Optional[gum] = None
+
+# Global unified AI client
+ai_client: Optional[UnifiedAIClient] = None
+
+
+async def get_ai_client() -> UnifiedAIClient:
+ """Get the unified AI client for both text and vision tasks."""
+ global ai_client
+
+ if ai_client is None:
+ logger.info("Initializing unified AI client")
+ ai_client = UnifiedAIClient()
+
+ # Store original methods
+ original_text_completion = ai_client.text_completion
+ original_vision_completion = ai_client.vision_completion
+
+ # Create debug wrapper for text completion
+ async def debug_text_completion(*args, **kwargs):
+ logger.info("Text completion call starting...")
+ logger.info(f" Args count: {len(args)}")
+ logger.info(f" Kwargs: {list(kwargs.keys())}")
+ try:
+ response = await original_text_completion(*args, **kwargs)
+ logger.info("Text completion response received:")
+ logger.info(f" Response type: {type(response)}")
+ logger.info(f" Response length: {len(str(response)) if response else 0}")
+ logger.info(f" Response preview: {str(response)[:200]}...")
+ return response
+ except Exception as e:
+ logger.error(f"Text completion error: {type(e).__name__}: {str(e)}")
+ raise
+
+ # Create debug wrapper for vision completion
+ async def debug_vision_completion(*args, **kwargs):
+ logger.info("Vision completion call starting...")
+ logger.info(f" Args count: {len(args)}")
+ logger.info(f" Kwargs: {list(kwargs.keys())}")
+ try:
+ response = await original_vision_completion(*args, **kwargs)
+ logger.info("Vision completion response received:")
+ logger.info(f" Response type: {type(response)}")
+ logger.info(f" Response length: {len(str(response)) if response else 0}")
+ logger.info(f" Response preview: {str(response)[:200]}...")
+ return response
+ except Exception as e:
+ logger.error(f"Vision completion error: {type(e).__name__}: {str(e)}")
+ raise
+
+ # Replace methods with debug versions
+ ai_client.text_completion = debug_text_completion
+ ai_client.vision_completion = debug_vision_completion
+
+ logger.info("Unified AI client initialized with debug logging")
+
+ return ai_client
+
+# === Pydantic Models ===
+
+class TextObservationRequest(BaseModel):
+ """Request model for text observations."""
+ content: str = Field(..., description="The text content of the observation", min_length=1)
+ user_name: Optional[str] = Field(None, description="User name (optional, uses default if not provided)")
+ observer_name: Optional[str] = Field("api_controller", description="Name of the observer submitting this")
+
+
+class QueryRequest(BaseModel):
+ """Request model for querying GUM."""
+ query: str = Field(..., description="The search query", min_length=1)
+ user_name: Optional[str] = Field(None, description="User name (optional)")
+ limit: Optional[int] = Field(10, description="Maximum number of results to return", ge=1, le=100)
+ mode: Optional[str] = Field("OR", description="Search mode (OR/AND)")
+
+
+class ObservationResponse(BaseModel):
+ """Response model for observations."""
+ id: int = Field(..., description="Observation ID")
+ content: str = Field(..., description="Observation content")
+ content_type: str = Field(..., description="Type of content (input_text, input_image)")
+ observer_name: str = Field(..., description="Name of the observer")
+ created_at: datetime = Field(..., description="When the observation was created")
+
+
+class PropositionResponse(BaseModel):
+ """Response model for propositions."""
+ id: int = Field(..., description="Proposition ID")
+ text: str = Field(..., description="Proposition text")
+ reasoning: Optional[str] = Field(None, description="Reasoning behind the proposition")
+ confidence: Optional[float] = Field(None, description="Confidence score")
+ created_at: datetime = Field(..., description="When the proposition was created")
+
+
+class QueryResponse(BaseModel):
+ """Response model for query results."""
+ propositions: List[PropositionResponse] = Field(..., description="Matching propositions")
+ total_results: int = Field(..., description="Total number of results found")
+ query: str = Field(..., description="The original query")
+ execution_time_ms: float = Field(..., description="Query execution time in milliseconds")
+
+
+class HealthResponse(BaseModel):
+ """Response model for health check."""
+ status: str = Field(..., description="Service status")
+ timestamp: datetime = Field(..., description="Current timestamp")
+ gum_connected: bool = Field(..., description="Whether GUM database is connected")
+ version: str = Field(..., description="API version")
+
+
+class ErrorResponse(BaseModel):
+ """Response model for errors."""
+ error: str = Field(..., description="Error message")
+ detail: Optional[str] = Field(None, description="Additional error details")
+ timestamp: datetime = Field(..., description="Error timestamp")
+
+
+# === Mock Observer Class ===
+
+class APIObserver(Observer):
+ """Mock observer for API-submitted observations."""
+
+ def __init__(self, name: Optional[str] = None):
+ super().__init__(name or "api_controller")
+
+ async def _worker(self):
+ """Required abstract method - not used for API submissions."""
+ # API observer doesn't need a background worker since observations are submitted directly
+ while self._running:
+ await asyncio.sleep(1)
+
+
+# === Helper Functions ===
+
+def parse_datetime(date_value) -> datetime:
+ """Parse datetime from string or return as-is if already datetime."""
+ if isinstance(date_value, str):
+ return date_parser.parse(date_value)
+ return date_value
+
+
+async def ensure_gum_instance(user_name: Optional[str] = None) -> gum:
+ """Ensure GUM instance is initialized and connected."""
+ global gum_instance
+
+ default_user = os.getenv("DEFAULT_USER_NAME", "APIUser")
+ user_name = user_name or default_user
+
+ if gum_instance is None or gum_instance.user_name != user_name:
+ logger.info(f"Initializing GUM instance for user: {user_name}")
+
+ # Initialize GUM - it will automatically use the unified client
+ logger.info("Initializing GUM with unified AI client")
+
+ gum_instance = gum(
+ user_name=user_name,
+ model="gpt-4o", # Model name used for logging/identification only
+ data_directory="~/.cache/gum",
+ verbosity=logging.INFO
+ )
+
+ await gum_instance.connect_db()
+ logger.info("GUM instance connected to database")
+ logger.info("GUM configured with unified AI client for hybrid text/vision processing")
+
+ return gum_instance
+
+
+def validate_image(file_content: bytes) -> bool:
+ """Validate that the uploaded file is a valid image."""
+ try:
+ image = Image.open(BytesIO(file_content))
+ image.verify()
+ return True
+ except Exception as e:
+ logger.warning(f"Invalid image file: {e}")
+ return False
+
+
+def process_image_for_analysis(file_content: bytes) -> str:
+ """Convert image to base64 for AI analysis."""
+ try:
+ # Open and process the image
+ image = Image.open(BytesIO(file_content))
+
+ # Convert to RGB if necessary
+ if image.mode in ('RGBA', 'LA', 'P'):
+ image = image.convert('RGB')
+
+ # Resize if too large (to manage API costs)
+ max_size = (1024, 1024)
+ if image.size[0] > max_size[0] or image.size[1] > max_size[1]:
+ image.thumbnail(max_size, Image.Resampling.LANCZOS)
+
+ # Convert to base64
+ buffer = BytesIO()
+ image.save(buffer, format='JPEG', quality=85)
+ base64_image = base64.b64encode(buffer.getvalue()).decode('utf-8')
+
+ return base64_image
+
+ except Exception as e:
+ logger.error(f"Error processing image: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Error processing image: {str(e)}"
+ )
+
+
+async def analyze_image_with_ai(base64_image: str, filename: Optional[str] = None) -> str:
+ """Analyze image using the unified AI client."""
+ try:
+ logger.info("Starting image analysis with vision model")
+ logger.info(f" File: {filename}")
+
+ # Get unified AI client
+ client = await get_ai_client()
+
+ # Create prompt for image analysis
+ display_filename = filename or "uploaded_image"
+ prompt = f"""Analyze this image and describe what the user is doing, what applications they're using,
+ and any observable behavior patterns. Focus on:
+
+ 1. What applications or interfaces are visible
+ 2. What actions the user appears to be taking
+ 3. Any workflow patterns or preferences shown
+ 4. The general context of the user's activity
+
+ Image filename: {display_filename}
+
+ Provide a detailed but concise analysis that will help understand user behavior."""
+
+ # Use the unified client for vision completion
+ analysis = await client.vision_completion(
+ text_prompt=prompt,
+ base64_image=base64_image
+ )
+
+ if analysis:
+ logger.info("Vision analysis completed")
+ logger.info(f" Analysis length: {len(analysis)} characters")
+ return analysis
+ else:
+ logger.error("Vision analysis returned empty response")
+ return "Error: Empty response from vision model"
+
+ except Exception as e:
+ logger.error(f"Vision analysis failed: {str(e)}")
+ return f"Error analyzing image: {str(e)}"
+
+
+def validate_video(file_content: bytes) -> bool:
+ """Validate that the uploaded file is a valid video."""
+ try:
+ logger.info(f"Validating video file ({len(file_content)} bytes)")
+
+ # Save to temp file for validation
+ with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_file:
+ temp_file.write(file_content)
+ temp_path = temp_file.name
+
+ try:
+ logger.info("Running FFmpeg validation check")
+ # Use ffmpeg to check if it's a valid video
+ result = subprocess.run([
+ 'ffmpeg', '-i', temp_path, '-t', '0.1', '-f', 'null', '-'
+ ], capture_output=True, text=True)
+
+ is_valid = result.returncode == 0
+ if not is_valid:
+ logger.error(f"Video validation failed: {result.stderr}")
+ else:
+ logger.info("Video validation passed")
+ return is_valid
+
+ finally:
+ # Clean up temp file
+ Path(temp_path).unlink(missing_ok=True)
+
+ except Exception as e:
+ logger.error(f"Error during video validation: {str(e)}")
+ return False
+
+
+def split_frames(video_path: Path, temp_dir: Path, fps: float = 0.1) -> List[Path]:
+ """Extract frames from video using ffmpeg."""
+ try:
+ logger.info(f"Starting frame extraction from {video_path.name} at {fps} FPS")
+
+ frame_pattern = temp_dir / "frame_%03d.jpg"
+
+ # Ultra-simple FFmpeg command that definitely works (tested manually)
+ result = subprocess.run([
+ 'ffmpeg',
+ '-i', str(video_path),
+ '-vf', f'fps={fps}', # Video filter for frame rate
+ str(frame_pattern),
+ '-y' # Overwrite existing files
+ ], capture_output=True, text=True)
+
+ if result.returncode != 0:
+ logger.error(f"FFmpeg failed: {result.stderr}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"FFmpeg failed: {result.stderr}"
+ )
+
+ # Find all extracted frame files
+ frame_files = sorted(temp_dir.glob("frame_*.jpg"))
+ logger.info(f"Successfully extracted {len(frame_files)} frames")
+
+ if not frame_files:
+ logger.error("No frames were extracted")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="No frames could be extracted from video"
+ )
+
+ return frame_files
+
+ except Exception as e:
+ logger.error(f"Error extracting frames: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error extracting frames: {str(e)}"
+ )
+
+
+def encode_image_from_path(image_path: Path) -> str:
+ """Encode image file to base64 for AI analysis."""
+ try:
+ with Image.open(image_path) as img:
+ # Resize for efficiency
+ img = img.resize((512, 512), Image.Resampling.LANCZOS)
+
+ # Convert to RGB if necessary
+ if img.mode in ('RGBA', 'LA', 'P'):
+ img = img.convert('RGB')
+
+ buffer = BytesIO()
+ img.save(buffer, format="JPEG", quality=90)
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
+
+ except Exception as e:
+ logger.error(f"Error encoding image {image_path}: {e}")
+ raise
+
+
+async def process_video_frames(video_path: Path, fps: float = 0.1) -> List[dict]:
+ """Process video by extracting frames and analyzing each one."""
+ results = []
+
+ logger.info(f"Starting video frame processing for {video_path.name} at {fps} FPS")
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_dir_path = Path(temp_dir)
+
+ # Extract frames
+ logger.info("Extracting frames to temporary directory")
+ frame_files = split_frames(video_path, temp_dir_path, fps)
+
+ if not frame_files:
+ logger.error("No frames could be extracted from video")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="No frames could be extracted from video"
+ )
+
+ logger.info(f"Starting AI analysis of {len(frame_files)} frames")
+
+ # Process each frame
+ for i, frame_path in enumerate(frame_files):
+ try:
+ logger.info(f"Analyzing frame {i+1}/{len(frame_files)}: {frame_path.name}")
+
+ # Encode frame for AI analysis
+ base64_frame = encode_image_from_path(frame_path)
+
+ # Analyze frame with AI
+ frame_name = f"frame_{i+1:03d}.jpg"
+ analysis = await analyze_image_with_ai(base64_frame, frame_name)
+
+ results.append({
+ 'frame_number': i + 1,
+ 'frame_name': frame_name,
+ 'analysis': analysis,
+ 'timestamp': i / fps # Approximate timestamp in seconds
+ })
+
+ logger.info(f"Frame {i+1}/{len(frame_files)} analyzed successfully")
+
+ except Exception as e:
+ logger.error(f"Error processing frame {i+1}: {str(e)}")
+ # Continue with other frames
+ results.append({
+ 'frame_number': i + 1,
+ 'frame_name': f"frame_{i+1:03d}.jpg",
+ 'analysis': f"Error processing frame: {str(e)}",
+ 'timestamp': i / fps,
+ 'error': True
+ })
+
+ logger.info(f"Video frame processing completed! Processed {len(results)} frames")
+ return results
+
+# Configuration for parallelism and performance
+MAX_CONCURRENT_AI_CALLS = 5 # Limit concurrent AI analysis calls
+MAX_CONCURRENT_ENCODING = 10 # Limit concurrent base64 encoding operations
+MAX_CONCURRENT_GUM_OPERATIONS = 3 # Limit concurrent GUM database operations
+CHUNK_SIZE = 50 # Process frames in chunks for large videos
+
+# Initialize semaphores for controlling concurrency
+ai_semaphore = asyncio.Semaphore(MAX_CONCURRENT_AI_CALLS)
+encoding_semaphore = asyncio.Semaphore(MAX_CONCURRENT_ENCODING)
+gum_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GUM_OPERATIONS)
+
+# === API Endpoints ===
+
+@app.get("/health", response_model=HealthResponse)
+async def health_check():
+ """Health check endpoint."""
+ try:
+ # Test GUM connection
+ gum_connected = False
+ try:
+ await ensure_gum_instance()
+ gum_connected = True
+ except Exception as e:
+ logger.warning(f"GUM connection failed in health check: {e}")
+
+ return HealthResponse(
+ status="healthy" if gum_connected else "unhealthy",
+ timestamp=datetime.now(),
+ gum_connected=gum_connected,
+ version="1.0.0"
+ )
+ except Exception as e:
+ logger.error(f"Health check failed: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Health check failed"
+ )
+
+
+@app.delete("/database/cleanup", response_model=dict)
+async def cleanup_database(user_name: Optional[str] = None):
+ """Clean up entire database by removing all observations and propositions."""
+ try:
+ logger.info("Starting database cleanup...")
+
+ # Get GUM instance
+ gum_inst = await ensure_gum_instance(user_name)
+
+ observations_deleted = 0
+ propositions_deleted = 0
+ junction_records_deleted = 0
+
+ # Clean up database
+ async with gum_inst._session() as session:
+ from gum.models import Observation, Proposition, observation_proposition, proposition_parent
+ from sqlalchemy import delete, text
+
+ # Delete in proper order to avoid foreign key constraints
+
+ # First, delete all junction table entries
+ junction_obs_result = await session.execute(delete(observation_proposition))
+ junction_prop_result = await session.execute(delete(proposition_parent))
+ junction_records_deleted = junction_obs_result.rowcount + junction_prop_result.rowcount
+
+ # Then delete all observations
+ obs_result = await session.execute(delete(Observation))
+ observations_deleted = obs_result.rowcount
+
+ # Then delete all propositions
+ prop_result = await session.execute(delete(Proposition))
+ propositions_deleted = prop_result.rowcount
+
+ # Clear the FTS tables as well
+ await session.execute(text("DELETE FROM propositions_fts"))
+ await session.execute(text("DELETE FROM observations_fts"))
+
+ # Commit the transaction
+ await session.commit()
+
+ # Run VACUUM outside of the session/transaction context
+ try:
+ async with gum_inst._session() as vacuum_session:
+ await vacuum_session.execute(text("VACUUM"))
+ await vacuum_session.commit()
+ except Exception as vacuum_error:
+ logger.warning(f"VACUUM operation failed: {vacuum_error}")
+ # Continue anyway as the cleanup was successful
+
+ logger.info(f"Database cleanup completed:")
+ logger.info(f" Deleted {observations_deleted} observations")
+ logger.info(f" Deleted {propositions_deleted} propositions")
+ logger.info(f" Deleted {junction_records_deleted} junction records")
+ logger.info(" Cleared FTS indexes")
+ logger.info(" Database vacuumed")
+
+ return {
+ "success": True,
+ "message": "Database cleaned successfully",
+ "observations_deleted": observations_deleted,
+ "propositions_deleted": propositions_deleted,
+ "junction_records_deleted": junction_records_deleted,
+ "fts_cleared": True,
+ "timestamp": datetime.now().isoformat()
+ }
+
+ except Exception as e:
+ logger.error(f"Error cleaning database: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error cleaning database: {str(e)}"
+ )
+
+@app.get("/observations/video/{job_id}/insights", response_model=dict)
+async def get_video_insights(job_id: str):
+ """Get generated insights for a completed video processing job."""
+ if job_id not in video_processing_jobs:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Video processing job not found"
+ )
+
+ job = video_processing_jobs[job_id]
+
+ if job["status"] != "completed":
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Video processing is not completed. Current status: {job['status']}"
+ )
+
+ # Check if insights already exist
+ if "insights" in job:
+ logger.info(f" Returning cached insights for job {job_id}")
+ return job["insights"]
+
+ # Generate insights if they don't exist
+ try:
+ logger.info(f"Generating insights on-demand for job {job_id}")
+
+ # Get frame analyses from the job
+ frame_analyses = []
+ if "frame_analyses" in job:
+ # Use full analysis if available, otherwise fall back to preview
+ frame_analyses = [
+ frame.get("full_analysis", frame.get("analysis_preview", ""))
+ for frame in job["frame_analyses"]
+ ]
+
+ if not frame_analyses:
+ # Fallback: use basic info if no detailed analyses available
+ frame_analyses = [f"Frame analysis data for {job['filename']}"]
+
+ logger.info(f"Using {len(frame_analyses)} frame analyses for insights generation")
+ for i, analysis in enumerate(frame_analyses):
+ logger.info(f" Frame {i+1} analysis length: {len(analysis)} characters")
+
+ insights = await generate_video_insights(frame_analyses, job["filename"])
+
+ # Cache the insights in the job data
+ video_processing_jobs[job_id]["insights"] = insights
+
+ logger.info(f"Generated and cached insights for job {job_id}")
+ return insights
+
+ except Exception as e:
+ logger.error(f"Failed to generate insights for job {job_id}: {str(e)}")
+
+ # Return basic fallback insights
+ fallback_insights = {
+ "key_insights": [
+ f"Video processing completed for {job['filename']}",
+ f"Successfully analyzed {job.get('successful_frames', 0)} frames",
+ "Behavioral data captured and ready for analysis"
+ ],
+ "behavior_patterns": [
+ "Standard user interaction patterns observed",
+ "Task-oriented behavior documented",
+ "Interface engagement recorded"
+ ],
+ "summary": f"Video analysis completed for {job['filename']} with {job.get('total_frames', 0)} frames processed.",
+ "confidence_score": 0.5,
+ "recommendations": [
+ "Review individual frame analyses for detailed insights",
+ "Consider additional video samples for pattern validation"
+ ]
+ }
+
+ # Cache the fallback insights
+ video_processing_jobs[job_id]["insights"] = fallback_insights
+
+ return fallback_insights
+
+
+@app.post("/observations/text", response_model=dict)
+async def submit_text_observation(request: TextObservationRequest):
+ """Submit a text observation to GUM."""
+ try:
+ start_time = time.time()
+ logger.info(f" Received text observation: {request.content[:100]}...")
+
+ # Get GUM instance
+ logger.info(" Getting GUM instance...")
+ gum_inst = await ensure_gum_instance(request.user_name)
+ logger.info("GUM instance obtained successfully")
+
+ # Create mock observer
+ logger.info(" Creating API observer...")
+ observer = APIObserver(request.observer_name)
+ logger.info(f"API observer created: {observer._name}")
+
+ # Create update
+ logger.info("Creating update object...")
+ update = Update(
+ content=request.content,
+ content_type="input_text"
+ )
+ logger.info(f"Update created - Content length: {len(update.content)}, Type: {update.content_type}")
+
+ # Process through GUM with detailed logging
+ logger.info(" Starting GUM processing...")
+ logger.info(f" Content preview: {request.content[:200]}...")
+ logger.info(f" User: {request.user_name}")
+ logger.info(f" Observer: {request.observer_name}")
+
+ try:
+ await gum_inst._default_handler(observer, update)
+ logger.info("GUM processing completed successfully")
+ except Exception as gum_error:
+ logger.error(f"GUM processing failed: {type(gum_error).__name__}: {str(gum_error)}")
+ logger.error(f" Error details: {repr(gum_error)}")
+ raise gum_error
+
+ processing_time = (time.time() - start_time) * 1000
+
+ logger.info(f"Text observation processed successfully in {processing_time:.2f}ms")
+
+ return {
+ "success": True,
+ "message": "Text observation submitted successfully",
+ "processing_time_ms": processing_time,
+ "content_preview": request.content[:100] + "..." if len(request.content) > 100 else request.content
+ }
+
+ except Exception as e:
+ logger.error(f"Error processing text observation: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error processing text observation: {str(e)}"
+ )
+
+
+@app.post("/observations/image", response_model=dict)
+async def submit_image_observation(
+ file: UploadFile = File(..., description="Image file to analyze"),
+ user_name: Optional[str] = Form(None, description="User name (optional)"),
+ observer_name: Optional[str] = Form("api_controller", description="Observer name")
+):
+ """Submit an image observation to GUM."""
+ try:
+ start_time = time.time()
+ logger.info(f"Received image observation: {file.filename}")
+
+ # Validate file type
+ if not file.content_type or not file.content_type.startswith('image/'):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="File must be an image"
+ )
+
+ # Read file content
+ file_content = await file.read()
+
+ # Validate image
+ if not validate_image(file_content):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid image file"
+ )
+
+ # Process image for AI analysis
+ base64_image = process_image_for_analysis(file_content)
+
+ # Analyze image with AI
+ analysis = await analyze_image_with_ai(base64_image, file.filename)
+
+ # Get GUM instance
+ gum_inst = await ensure_gum_instance(user_name)
+
+ # Create mock observer
+ observer = APIObserver(observer_name)
+
+ # Create update with analysis
+ update_content = f"Image analysis of {file.filename}: {analysis}"
+ update = Update(
+ content=update_content,
+ content_type="input_text" # We store the analysis as text
+ )
+
+ # Process through GUM
+ await gum_inst._default_handler(observer, update)
+
+ processing_time = (time.time() - start_time) * 1000
+
+ logger.info(f"Image observation processed successfully in {processing_time:.2f}ms")
+
+ return {
+ "success": True,
+ "message": "Image observation submitted successfully",
+ "processing_time_ms": processing_time,
+ "filename": file.filename,
+ "analysis_preview": analysis[:200] + "..." if len(analysis) > 200 else analysis
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error processing image observation: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error processing image observation: {str(e)}"
+ )
+
+
+@app.post("/query", response_model=QueryResponse)
+async def query_gum(request: QueryRequest):
+ """Query GUM for insights and propositions."""
+ try:
+ start_time = time.time()
+ logger.info(f"Received query: {request.query}")
+
+ # Get GUM instance
+ gum_inst = await ensure_gum_instance(request.user_name)
+
+ # Execute query
+ limit = request.limit if request.limit is not None else 10
+ mode = request.mode or "default"
+
+ results = await gum_inst.query(
+ request.query,
+ limit=limit,
+ mode=mode
+ )
+
+ # Format results
+ propositions = []
+ for prop, score in results:
+ propositions.append(PropositionResponse(
+ id=prop.id,
+ text=prop.text,
+ reasoning=prop.reasoning,
+ confidence=prop.confidence,
+ created_at=parse_datetime(prop.created_at)
+ ))
+
+ execution_time = (time.time() - start_time) * 1000
+
+ logger.info(f"Query executed successfully: {len(results)} results in {execution_time:.2f}ms")
+
+ return QueryResponse(
+ propositions=propositions,
+ total_results=len(results),
+ query=request.query,
+ execution_time_ms=execution_time
+ )
+
+ except Exception as e:
+ logger.error(f"Error executing query: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error executing query: {str(e)}"
+ )
+
+
+@app.get("/observations", response_model=List[ObservationResponse])
+async def list_observations(
+ user_name: Optional[str] = None,
+ limit: Optional[int] = 20,
+ offset: Optional[int] = 0
+):
+ """List recent observations."""
+ try:
+ logger.info(f"Listing observations: limit={limit}, offset={offset}")
+
+ # Get GUM instance
+ gum_inst = await ensure_gum_instance(user_name)
+
+ # Query recent observations from database
+ async with gum_inst._session() as session:
+ from gum.models import Observation
+ from sqlalchemy import select, desc
+
+ stmt = (
+ select(Observation)
+ .order_by(desc(Observation.created_at))
+ .limit(limit)
+ .offset(offset)
+ )
+
+ result = await session.execute(stmt)
+ observations = result.scalars().all()
+
+ response = []
+ for obs in observations:
+ response.append(ObservationResponse(
+ id=obs.id,
+ content=obs.content[:500] + "..." if len(obs.content) > 500 else obs.content,
+ content_type=obs.content_type,
+ observer_name=obs.observer_name,
+ created_at=parse_datetime(obs.created_at)
+ ))
+
+ logger.info(f"Retrieved {len(response)} observations")
+ return response
+
+ except Exception as e:
+ logger.error(f"Error listing observations: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error listing observations: {str(e)}"
+ )
+
+
+@app.get("/propositions", response_model=List[PropositionResponse])
+async def list_propositions(
+ user_name: Optional[str] = None,
+ limit: Optional[int] = 20,
+ offset: Optional[int] = 0,
+ confidence_min: Optional[int] = None,
+ sort_by: Optional[str] = "created_at"
+):
+ """List recent propositions with filtering and sorting options."""
+ try:
+ logger.info(f"Listing propositions: limit={limit}, offset={offset}, confidence_min={confidence_min}, sort_by={sort_by}")
+
+ # Get GUM instance
+ gum_inst = await ensure_gum_instance(user_name)
+
+ # Query recent propositions from database
+ async with gum_inst._session() as session:
+ from gum.models import Proposition
+ from sqlalchemy import select, desc, asc
+
+ stmt = select(Proposition)
+
+ # Apply confidence filter if specified
+ if confidence_min is not None:
+ stmt = stmt.where(Proposition.confidence >= confidence_min)
+
+ # Apply sorting
+ if sort_by == "confidence":
+ stmt = stmt.order_by(desc(Proposition.confidence))
+ elif sort_by == "created_at":
+ stmt = stmt.order_by(desc(Proposition.created_at))
+ else:
+ stmt = stmt.order_by(desc(Proposition.created_at))
+
+ # Apply pagination
+ stmt = stmt.limit(limit).offset(offset)
+
+ result = await session.execute(stmt)
+ propositions = result.scalars().all()
+
+ response = []
+ for prop in propositions:
+ response.append(PropositionResponse(
+ id=prop.id,
+ text=prop.text,
+ reasoning=prop.reasoning,
+ confidence=prop.confidence,
+ created_at=parse_datetime(prop.created_at)
+ ))
+
+ logger.info(f"Retrieved {len(response)} propositions")
+ return response
+
+ except Exception as e:
+ logger.error(f"Error listing propositions: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error listing propositions: {str(e)}"
+ )
+
+
+@app.get("/propositions/count", response_model=dict)
+async def get_propositions_count(
+ user_name: Optional[str] = None,
+ confidence_min: Optional[int] = None
+):
+ """Get total count of propositions with optional filtering."""
+ try:
+ logger.info(f"Getting propositions count: confidence_min={confidence_min}")
+
+ # Get GUM instance
+ gum_inst = await ensure_gum_instance(user_name)
+
+ # Query count from database
+ async with gum_inst._session() as session:
+ from gum.models import Proposition
+ from sqlalchemy import select, func
+
+ stmt = select(func.count(Proposition.id))
+
+ # Apply confidence filter if specified
+ if confidence_min is not None:
+ stmt = stmt.where(Proposition.confidence >= confidence_min)
+
+ result = await session.execute(stmt)
+ count = result.scalar()
+
+ logger.info(f"Retrieved count: {count} propositions")
+ return {
+ "total_propositions": count,
+ "confidence_filter": confidence_min
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting propositions count: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error getting propositions count: {str(e)}"
+ )
+
+
+
+async def generate_video_insights(frame_analyses: List[str], filename: str, user_name: str = None) -> dict:
+ """
+ Generate structured insights from existing GUM observations and propositions.
+
+ Args:
+ frame_analyses: List of AI analysis texts from processed frames (for context)
+ filename: Name of the video file for context
+ user_name: User name to query specific data
+
+ Returns:
+ Dictionary containing structured insights from existing GUM data:
+ {
+ "key_insights": List[str] (from recent observations),
+ "behavior_patterns": List[str] (from recent propositions),
+ "summary": str,
+ "confidence_score": float,
+ "recommendations": List[str]
+ }
+ """
+ try:
+ logger.info(f"Generating insights from existing GUM data for {filename}")
+
+ # Get GUM instance to query existing data
+ gum_inst = await ensure_gum_instance(user_name)
+
+ # Query recent observations related to video analysis
+ async with gum_inst._session() as session:
+ from sqlalchemy import select, desc
+ from gum.models import Observation, Proposition
+
+ # Get recent observations from video processing (last 10)
+ obs_stmt = (
+ select(Observation)
+ .where(Observation.content.contains("Video frame analysis"))
+ .order_by(desc(Observation.created_at))
+ .limit(10)
+ )
+ obs_result = await session.execute(obs_stmt)
+ observations = obs_result.scalars().all()
+
+ # Get recent propositions (last 10)
+ prop_stmt = (
+ select(Proposition)
+ .order_by(desc(Proposition.created_at))
+ .limit(10)
+ )
+ prop_result = await session.execute(prop_stmt)
+ propositions = prop_result.scalars().all()
+
+ # Extract key insights from the latest 5 observations in the database
+ key_insights = []
+
+ logger.info(f"Extracting latest 5 observations from database")
+
+ # Get the latest 5 observations directly from the database
+ async with gum_inst._session() as session:
+ latest_obs_stmt = (
+ select(Observation)
+ .order_by(desc(Observation.created_at))
+ .limit(5)
+ )
+ latest_obs_result = await session.execute(latest_obs_stmt)
+ latest_observations = latest_obs_result.scalars().all()
+
+ # Parse and clean the latest 5 observations using our parsing logic
+ for i, obs in enumerate(latest_observations):
+ content = obs.content.strip()
+ if content:
+ logger.info(f" Processing observation {i+1}: {content[:100]}...")
+
+ # Use our parsing function to extract clean insights
+ parsed_insights = parse_ai_analysis_to_insights(content)
+
+ if parsed_insights:
+ # Add the parsed insights
+ for insight in parsed_insights:
+ key_insights.append(insight)
+ logger.info(f" Parsed insight: {insight[:80]}...")
+ else:
+ # Fallback: if parsing didn't work, clean manually
+ if "Video frame analysis" in content and "): " in content:
+ # Extract just the analysis part after the prefix
+ analysis_part = content.split("): ", 1)
+ if len(analysis_part) > 1:
+ content = analysis_part[1].strip()
+
+ # Use our sentence cleaner
+ cleaned_content = clean_insight_sentence(content)
+ if cleaned_content:
+ key_insights.append(cleaned_content)
+ logger.info(f" Cleaned insight: {cleaned_content[:80]}...")
+ else:
+ # Last resort: basic formatting
+ if len(content) > 150:
+ content = content[:147] + "..."
+ if content and content[0].islower():
+ content = content[0].upper() + content[1:]
+ if content and not content.endswith(('.', '!', '?')):
+ content += '.'
+ key_insights.append(content)
+ logger.info(f" Basic formatted: {content[:80]}...")
+
+ logger.info(f"Extracted {len(key_insights)} latest observations as key insights")
+
+ # Extract behavior patterns from recent propositions
+ behavior_patterns = []
+ for prop in propositions[:5]: # Take top 5 recent propositions
+ if prop.text and len(prop.text.strip()) > 20:
+ pattern = prop.text.strip()
+ # Clean up the pattern text
+ cleaned_pattern = clean_insight_sentence(pattern)
+ if cleaned_pattern:
+ behavior_patterns.append(cleaned_pattern)
+
+ # Generate summary based on available data
+ total_observations = len(observations)
+ total_propositions = len(propositions)
+
+ if total_observations > 0 or total_propositions > 0:
+ summary = f"Analysis of {filename} reveals {total_observations} behavioral observations and {total_propositions} generated insights about user patterns and preferences."
+ else:
+ summary = f"Video analysis of {filename} completed with {len(frame_analyses)} frames processed. Additional data collection recommended for deeper insights."
+
+ # Calculate confidence based on data availability
+ confidence_score = min(0.9, 0.3 + (len(key_insights) * 0.1) + (len(behavior_patterns) * 0.1))
+
+ # Generate recommendations based on available data
+ recommendations = []
+ if len(key_insights) > 0:
+ recommendations.append("Review identified behavioral patterns for workflow optimization opportunities")
+ if len(behavior_patterns) > 0:
+ recommendations.append("Consider user preferences revealed in behavior patterns for interface improvements")
+
+ recommendations.extend([
+ "Continue collecting behavioral data for more comprehensive insights",
+ "Analyze patterns over time to identify trends and changes in user behavior"
+ ])
+
+ # Provide intelligent fallbacks if no meaningful insights were parsed
+ if not key_insights:
+ # Try one more time with more aggressive parsing of frame analyses
+ logger.info("No insights parsed, trying more aggressive extraction from frame analyses")
+ for frame_analysis in frame_analyses:
+ if frame_analysis and len(frame_analysis.strip()) > 50:
+ # Extract the most meaningful sentences directly
+ sentences = re.split(r'[.!?]+', frame_analysis)
+ for sentence in sentences:
+ cleaned = clean_insight_sentence(sentence)
+ if cleaned and len(cleaned) > 30:
+ key_insights.append(cleaned)
+ if len(key_insights) >= 3: # Limit to 3 good insights
+ break
+ if len(key_insights) >= 3:
+ break
+
+ # Only use generic fallbacks if we absolutely can't extract anything meaningful
+ if not key_insights:
+ logger.warning(" Using generic fallback insights - no meaningful content could be parsed")
+ key_insights = [
+ f"Video analysis processed {len(frame_analyses)} frames of user interaction data.",
+ "User behavior patterns captured from video frames for analysis.",
+ "Interface interaction sequences documented for behavioral insights."
+ ]
+
+ if not behavior_patterns:
+ behavior_patterns = [
+ "User interface navigation patterns observed and recorded.",
+ "Task-oriented interaction behaviors documented for analysis.",
+ "Sequential user actions captured for workflow optimization."
+ ]
+
+ insights = {
+ "key_insights": key_insights[:5], # Limit to 5 insights
+ "behavior_patterns": behavior_patterns[:5], # Limit to 5 patterns
+ "summary": summary,
+ "confidence_score": confidence_score,
+ "recommendations": recommendations[:4] # Limit to 4 recommendations
+ }
+
+ logger.info(f"Generated {len(insights['key_insights'])} insights and {len(insights['behavior_patterns'])} patterns from existing GUM data")
+ return insights
+
+ except Exception as e:
+ logger.error(f"Error generating insights from GUM data: {str(e)}")
+
+ # Provide basic fallback insights
+ fallback_insights = {
+ "key_insights": [
+ f"Video analysis completed for {filename}",
+ f"Processed {len(frame_analyses)} frames with behavioral analysis",
+ "User interaction patterns captured for future insights"
+ ],
+ "behavior_patterns": [
+ "Video-based user behavior documentation initiated",
+ "Frame-by-frame interaction analysis completed",
+ "Behavioral data collection established for pattern recognition"
+ ],
+ "summary": f"Video analysis of {filename} completed successfully with {len(frame_analyses)} frames processed. Building behavioral understanding from collected data.",
+ "confidence_score": 0.5,
+ "recommendations": [
+ "Continue submitting observations to build comprehensive user behavior model",
+ "Review captured interactions for specific workflow improvement opportunities",
+ "Consider additional data collection for deeper behavioral insights"
+ ]
+ }
+
+ logger.info("Generated fallback insights")
+ return fallback_insights
+
+
+
+# Video processing storage
+video_processing_jobs = {}
+
+
+
+def parse_ai_analysis_to_insights(analysis_text: str) -> List[str]:
+ """
+ Parse AI analysis content and extract clean, one-line insights.
+
+ Args:
+ analysis_text: Raw AI analysis text from vision processing
+
+ Returns:
+ List of clean, one-line insights
+ """
+ import re
+
+ if not analysis_text or len(analysis_text.strip()) < 20:
+ return []
+
+ # Remove common prefixes and headers
+ text = analysis_text
+
+ # Remove "Video frame analysis (Frame X): " prefix
+ if "Video frame analysis" in text and "): " in text:
+ text = text.split("): ", 1)[1] if len(text.split("): ", 1)) > 1 else text
+
+ # Remove "Detailed Analysis of User Experience in frame_XXX.jpg" headers
+ text = re.sub(r"Detailed Analysis of User Experience in frame_\d+\.jpg['\"]?\s*[-\s]*", "", text)
+
+ # Remove markdown headers (### #### etc.)
+ text = re.sub(r"#{1,6}\s*\d*\.\s*", "", text)
+ text = re.sub(r"#{1,6}\s*", "", text)
+
+ # Remove "Primary Analysis Focus:" type headers
+ text = re.sub(r"Primary Analysis Focus:\s*", "", text)
+
+ # Remove numbered list markers (1. 2. etc.)
+ text = re.sub(r"^\d+\.\s*", "", text, flags=re.MULTILINE)
+
+ # Remove markdown bold formatting
+ text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
+
+ # Remove bullet points and dashes
+ text = re.sub(r"^[-•*]\s*", "", text, flags=re.MULTILINE)
+
+ # Split into sentences and clean
+ sentences = []
+
+ # Split by common delimiters
+ for delimiter in ['. ', '.\n', '; ', ';\n', ' | ', ' --- ']:
+ if delimiter in text:
+ parts = text.split(delimiter)
+ for part in parts:
+ part = part.strip()
+ if len(part) > 20 and not part.endswith(':'):
+ # Clean up the sentence
+ clean_part = clean_insight_sentence(part)
+ if clean_part:
+ sentences.append(clean_part)
+ break
+
+ # If no clear delimiters, try to extract meaningful phrases
+ if not sentences:
+ # Look for patterns like "User [action]" or "The user [action]"
+ user_actions = re.findall(r"(?:The\s+)?[Uu]ser\s+[^.;]+", text)
+ for action in user_actions:
+ clean_action = clean_insight_sentence(action)
+ if clean_action:
+ sentences.append(clean_action)
+
+ # Final fallback - just clean the whole text if it's reasonable length
+ if not sentences and len(text.strip()) <= 200:
+ clean_text = clean_insight_sentence(text)
+ if clean_text:
+ sentences.append(clean_text)
+
+ # Limit to 3 insights per analysis and ensure they're not too long
+ return sentences[:3]
+
+
+def clean_insight_sentence(sentence: str) -> str:
+ """
+ Clean and format a single insight sentence.
+
+ Args:
+ sentence: Raw sentence text
+
+ Returns:
+ Cleaned sentence or empty string if not suitable
+ """
+ if not sentence:
+ return ""
+
+ # Remove extra whitespace and newlines
+ sentence = re.sub(r'\s+', ' ', sentence.strip())
+
+ # Remove trailing colons and dashes
+ sentence = re.sub(r'[:\-]+$', '', sentence)
+
+ # Remove leading/trailing quotes
+ sentence = sentence.strip('\'"')
+
+ # Ensure sentence starts with capital letter
+ if sentence and sentence[0].islower():
+ sentence = sentence[0].upper() + sentence[1:]
+
+ # Ensure sentence ends with period
+ if sentence and not sentence.endswith(('.', '!', '?')):
+ sentence += '.'
+
+ # Check if it's a meaningful insight (not just a header or fragment)
+ if (len(sentence) < 20 or
+ sentence.lower().startswith(('user goals', 'primary analysis', 'analysis focus')) or
+ sentence.count(':') > 1 or
+ len(sentence) > 200):
+ return ""
+
+ return sentence
+
+
+
+async def process_video_background(job_id: str, video_path: Path, user_name: str, observer_name: str, fps: float, filename: str):
+ """Process video in background using optimized parallel pipeline and update job status."""
+ try:
+ logger.info(f" Starting optimized background video processing for job {job_id}")
+ logger.info(f"File: {filename} | User: {user_name} | Observer: {observer_name} | FPS: {fps}")
+
+ # Initial status: extracting frames
+ video_processing_jobs[job_id]["status"] = "extracting_frames"
+ video_processing_jobs[job_id]["progress"] = 10
+
+ # Convert fps to max_frames for the new pipeline
+ max_frames = max(10, int(fps * 60)) # Approximate frames for 1 minute at given fps
+ video_processing_jobs[job_id]["total_frames"] = max_frames
+
+ logger.info(f"Starting optimized parallel frame processing (max {max_frames} frames)")
+
+ # Use the optimized parallel processing pipeline
+ frame_results = await process_video_frames_parallel(
+ video_path=str(video_path),
+ max_frames=max_frames,
+ job_id=job_id
+ )
+
+ if frame_results:
+ # Update progress after AI analysis
+ video_processing_jobs[job_id]["processed_frames"] = len(frame_results)
+ video_processing_jobs[job_id]["progress"] = 70
+ video_processing_jobs[job_id]["status"] = "storing_results"
+
+ # Store results in GUM database using the separate function
+ await process_and_store_in_gum(
+ frame_results=frame_results,
+ user_name=user_name,
+ observer_name=observer_name
+ )
+ else:
+ logger.error(f"No frames extracted for job {job_id}")
+ video_processing_jobs[job_id]["status"] = "error"
+ video_processing_jobs[job_id]["error"] = "No frames could be extracted from video"
+ return
+
+ logger.info(f"Optimized parallel processing completed: {len(frame_results)} frames")
+
+ # Update job status with results
+ successful_frames = len(frame_results)
+ failed_frames = max_frames - successful_frames if max_frames > successful_frames else 0
+
+ video_processing_jobs[job_id]["status"] = "completed"
+ video_processing_jobs[job_id]["progress"] = 100
+ video_processing_jobs[job_id]["total_frames"] = max_frames
+ video_processing_jobs[job_id]["processed_frames"] = successful_frames
+ video_processing_jobs[job_id]["successful_frames"] = successful_frames
+ video_processing_jobs[job_id]["failed_frames"] = failed_frames
+ video_processing_jobs[job_id]["frame_analyses"] = [
+ {
+ "frame_number": r["frame_number"],
+ "analysis_preview": r["analysis"][:100] + "..." if len(r["analysis"]) > 100 else r["analysis"],
+ "processing_time": "optimized_parallel"
+ }
+ for r in frame_results[:5] # Show first 5 as preview
+ ]
+
+ logger.info(" Optimized video processing completed!")
+ logger.info(f" Results: {successful_frames} frames processed successfully using parallel pipeline")
+ logger.info(f"Video processing job {job_id} completed with optimized performance")
+
+ except Exception as e:
+ logger.error(f" Critical error in optimized background video processing job {job_id}: {str(e)}")
+ video_processing_jobs[job_id]["status"] = "error"
+ video_processing_jobs[job_id]["error"] = str(e)
+
+ finally:
+ # Clean up video file
+ logger.info(f" Cleaning up temporary video file for job {job_id}")
+ video_path.unlink(missing_ok=True)
+
+
+def split_frames_optimized(video_path: str, output_dir: str, max_frames: int = 10) -> List[str]:
+ """
+ Optimized FFmpeg frame extraction with CPU optimizations.
+ Uses simple, reliable method that actually works.
+ """
+ logger.info(f" Starting optimized frame extraction from {video_path}")
+ start_time = time.time()
+
+ # Ensure output directory exists
+ os.makedirs(output_dir, exist_ok=True)
+
+ # Use the same simple command that works manually
+ # Extract frames at 1 frame per max_frames seconds
+ frame_rate = 1.0 / max_frames
+
+ cmd = [
+ "ffmpeg",
+ "-i", video_path,
+ "-r", str(frame_rate), # Frame rate (works like our manual test)
+ "-f", "image2", # Image sequence format (works like our manual test)
+ f"{output_dir}/frame_%03d.jpg",
+ "-y", # Overwrite existing files
+ "-hide_banner", "-loglevel", "warning"
+ ]
+
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
+
+ if result.returncode != 0:
+ logger.error(f"FFmpeg extraction failed: {result.stderr}")
+ raise RuntimeError(f"FFmpeg failed: {result.stderr}")
+
+ # Get list of extracted frames
+ frame_files = sorted(glob.glob(f"{output_dir}/frame_*.jpg"))
+ extraction_time = time.time() - start_time
+
+ logger.info(f"Extracted {len(frame_files)} frames in {extraction_time:.2f}s using optimized FFmpeg")
+ return frame_files
+
+ except subprocess.TimeoutExpired:
+ logger.error("FFmpeg extraction timed out")
+ raise RuntimeError("FFmpeg extraction timed out")
+ except Exception as e:
+ logger.error(f"Error during optimized frame extraction: {str(e)}")
+ raise
+
+
+def split_frames_hardware_accelerated(video_path: str, output_dir: str, max_frames: int = 10) -> List[str]:
+ """
+ Hardware-accelerated FFmpeg frame extraction.
+ Falls back to optimized CPU extraction if hardware acceleration fails.
+ """
+ logger.info(f" Attempting hardware-accelerated frame extraction from {video_path}")
+ start_time = time.time()
+
+ # Ensure output directory exists
+ os.makedirs(output_dir, exist_ok=True)
+
+ # Calculate frame rate for extraction
+ frame_rate = 1.0 / max_frames
+
+ # Try hardware acceleration first with simple parameters
+ hw_cmd = [
+ "ffmpeg",
+ "-hwaccel", "auto", # Auto-detect hardware acceleration
+ "-i", video_path,
+ "-r", str(frame_rate), # Simple frame rate
+ "-f", "image2", # Image sequence format
+ f"{output_dir}/frame_%03d.jpg",
+ "-y", # Overwrite existing files
+ "-hide_banner", "-loglevel", "warning"
+ ]
+
+ try:
+ result = subprocess.run(hw_cmd, capture_output=True, text=True, timeout=60)
+
+ if result.returncode == 0:
+ frame_files = sorted(glob.glob(f"{output_dir}/frame_*.jpg"))
+ extraction_time = time.time() - start_time
+ logger.info(f"Hardware-accelerated extraction: {len(frame_files)} frames in {extraction_time:.2f}s")
+ return frame_files
+ else:
+ logger.warning(f" Hardware acceleration failed, falling back to CPU: {result.stderr}")
+
+ except subprocess.TimeoutExpired:
+ logger.warning(" Hardware acceleration timed out, falling back to CPU")
+ except Exception as e:
+ logger.warning(f" Hardware acceleration error, falling back to CPU: {str(e)}")
+
+ # Fallback to optimized CPU extraction
+ return split_frames_optimized(video_path, output_dir, max_frames)
+
+
+def split_frames_smart(video_path: str, output_dir: str, max_frames: int = 10) -> List[str]:
+ """
+ Smart frame extraction that chooses the best method based on video characteristics.
+ """
+ logger.info(f" Smart frame extraction from {video_path}")
+
+ try:
+ # Get video info to make smart decisions
+ probe_cmd = [
+ "ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", video_path
+ ]
+ result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
+
+ if result.returncode == 0:
+ import json
+ video_info = json.loads(result.stdout)
+
+ # Extract video characteristics
+ duration = float(video_info.get("format", {}).get("duration", 0))
+ size = int(video_info.get("format", {}).get("size", 0))
+
+ # Decision logic based on video characteristics
+ if size > 100_000_000 or duration > 300: # Large file (>100MB) or long video (>5min)
+ logger.info("Large/long video detected, using hardware acceleration")
+ return split_frames_hardware_accelerated(video_path, output_dir, max_frames)
+ else:
+ logger.info("Small/short video detected, using optimized CPU extraction")
+ return split_frames_optimized(video_path, output_dir, max_frames)
+ else:
+ logger.warning(" Could not probe video, using hardware acceleration as default")
+ return split_frames_hardware_accelerated(video_path, output_dir, max_frames)
+
+ except Exception as e:
+ logger.warning(f" Error in smart analysis, using hardware acceleration: {str(e)}")
+ return split_frames_hardware_accelerated(video_path, output_dir, max_frames)
+
+
+async def encode_frame_to_base64(frame_path: str, frame_number: int) -> dict:
+ """
+ Encode a single frame to base64 with semaphore control.
+ """
+ async with encoding_semaphore:
+ try:
+ with open(frame_path, "rb") as f:
+ base64_data = base64.b64encode(f.read()).decode("utf-8")
+
+ return {
+ "frame_number": frame_number,
+ "base64_data": base64_data,
+ "file_path": frame_path
+ }
+ except Exception as e:
+ logger.error(f"Error encoding frame {frame_number}: {str(e)}")
+ raise
+
+
+async def process_frame_with_ai(frame_data: dict, semaphore: asyncio.Semaphore) -> dict:
+ """
+ Process a single frame with AI analysis using semaphore control.
+ Uses the vision AI client (OpenRouter with Qwen model).
+ """
+ async with semaphore:
+ try:
+ frame_number = frame_data["frame_number"]
+ base64_data = frame_data["base64_data"]
+ filename = f"frame_{frame_number:03d}.jpg"
+
+ logger.info(f"Analyzing frame {frame_number} with AI")
+ analysis = await analyze_image_with_ai(base64_data, filename)
+
+ return {
+ "frame_number": frame_number,
+ "analysis": analysis,
+ "base64_data": base64_data
+ }
+ except Exception as e:
+ logger.error(f"Error analyzing frame {frame_data.get('frame_number', 'unknown')}: {str(e)}")
+ raise
+
+
+async def process_video_frames_parallel(
+ video_path: str,
+ max_frames: int = 10,
+ job_id: Optional[str] = None
+) -> List[dict]:
+ """
+ Process video frames with full parallelism: extraction, encoding, and AI analysis.
+ Optionally updates job status for UI progress tracking.
+ """
+ logger.info(f"Starting parallel video processing: {video_path}")
+ total_start_time = time.time()
+
+ # Create temporary directory for frames
+ with tempfile.TemporaryDirectory() as temp_dir:
+ try:
+ # Step 1: Extract frames using smart method
+ logger.info("Extracting frames...")
+ if job_id:
+ video_processing_jobs[job_id]["status"] = "extracting_frames"
+ video_processing_jobs[job_id]["progress"] = 20
+
+ frame_files = split_frames_smart(video_path, temp_dir, max_frames)
+
+ if not frame_files:
+ logger.warning(" No frames extracted from video")
+ return []
+
+ # Step 2: Parallel base64 encoding
+ logger.info(f" Encoding {len(frame_files)} frames to base64...")
+ if job_id:
+ video_processing_jobs[job_id]["status"] = "processing_frames"
+ video_processing_jobs[job_id]["progress"] = 40
+ video_processing_jobs[job_id]["total_frames"] = len(frame_files)
+ video_processing_jobs[job_id]["processed_frames"] = 0
+
+ encoding_start = time.time()
+
+ encoding_tasks = [
+ encode_frame_to_base64(frame_path, i + 1)
+ for i, frame_path in enumerate(frame_files)
+ ]
+
+ encoded_frames = await asyncio.gather(*encoding_tasks, return_exceptions=True)
+
+ # Filter out exceptions
+ valid_frames = [
+ frame for frame in encoded_frames
+ if not isinstance(frame, Exception)
+ ]
+
+ encoding_time = time.time() - encoding_start
+ logger.info(f"Encoded {len(valid_frames)} frames in {encoding_time:.2f}s")
+
+ # Step 3: Parallel AI analysis with rate limiting
+ logger.info(f" Analyzing {len(valid_frames)} frames with AI...")
+ if job_id:
+ video_processing_jobs[job_id]["progress"] = 60
+
+ analysis_start = time.time()
+
+ analysis_tasks = [
+ process_frame_with_ai(frame_data, ai_semaphore)
+ for frame_data in valid_frames
+ if isinstance(frame_data, dict)
+ ]
+
+ analyzed_frames = await asyncio.gather(*analysis_tasks, return_exceptions=True)
+
+ # Filter out exceptions
+ valid_analyses = [
+ frame for frame in analyzed_frames
+ if not isinstance(frame, Exception)
+ ]
+
+ analysis_time = time.time() - analysis_start
+ logger.info(f"Analyzed {len(valid_analyses)} frames in {analysis_time:.2f}s")
+
+ if job_id:
+ video_processing_jobs[job_id]["processed_frames"] = len(valid_analyses)
+ video_processing_jobs[job_id]["progress"] = 80
+
+ # Step 4: Return results (simplified for now)
+ # Note: GUM integration would require proper Observer implementation
+ final_results = [frame for frame in valid_analyses if isinstance(frame, dict)]
+
+ total_time = time.time() - total_start_time
+ logger.info(f" Completed parallel video processing in {total_time:.2f}s total")
+
+ return final_results
+
+ except Exception as e:
+ logger.error(f"Error in parallel video processing: {str(e)}")
+ if job_id:
+ video_processing_jobs[job_id]["status"] = "error"
+ video_processing_jobs[job_id]["error"] = str(e)
+ raise
+
+
+async def process_and_store_in_gum(frame_results: List[dict], user_name: str, observer_name: str) -> None:
+ """
+ Process frame analysis results and store them in GUM database.
+ Separated from parallel processing for better modularity.
+ """
+ if not frame_results:
+ logger.warning(" No frame results to process in GUM")
+ return
+
+ logger.info(f"Storing {len(frame_results)} frame analyses in GUM database...")
+ gum_start = time.time()
+
+ try:
+ async with gum_semaphore:
+ gum_inst = await ensure_gum_instance(user_name)
+ observer = APIObserver(observer_name)
+
+ # Process in batches to avoid overwhelming the database
+ batch_size = 5
+ for i in range(0, len(frame_results), batch_size):
+ batch = frame_results[i:i + batch_size]
+
+ for frame_result in batch:
+ if isinstance(frame_result, dict) and "analysis" in frame_result and "frame_number" in frame_result:
+ # Create update with frame analysis
+ update_content = f"Video frame analysis (Frame {frame_result['frame_number']}): {frame_result['analysis']}"
+ update = Update(
+ content=update_content,
+ content_type="input_text"
+ )
+ await gum_inst._default_handler(observer, update)
+
+ gum_time = time.time() - gum_start
+ logger.info(f"Stored {len(frame_results)} frame analyses in GUM in {gum_time:.2f}s")
+
+ except Exception as e:
+ logger.error(f"Error storing frame results in GUM: {str(e)}")
+ raise
+
+
+@app.post("/observations/video", response_model=dict)
+async def submit_video_observation(
+ file: UploadFile = File(..., description="Video file to analyze"),
+ user_name: Optional[str] = Form(None, description="User name (optional)"),
+ observer_name: Optional[str] = Form("api_controller", description="Observer name"),
+ fps: Optional[float] = Form(0.1, description="Frames per second to extract (default: 0.1)")
+):
+ """Submit a video observation to GUM by extracting and analyzing frames."""
+ try:
+ start_time = time.time()
+ logger.info(f"Received video upload: {file.filename}")
+
+ # Get file size for logging
+ file_content_preview = await file.read()
+ logger.info(f" File size: {len(file_content_preview) / 1024 / 1024:.1f} MB")
+
+ # Reset file pointer after reading for size
+ await file.seek(0)
+
+ # Validate file type - check both MIME type and file extension
+ is_video = False
+ if file.content_type and file.content_type.startswith('video/'):
+ is_video = True
+ elif file.filename:
+ video_extensions = ('.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv')
+ is_video = file.filename.lower().endswith(video_extensions)
+
+ if not is_video:
+ logger.error(f"Invalid file type: {file.content_type}, filename: {file.filename}")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="File must be a video (MP4, AVI, MOV, WMV, FLV, WebM, MKV)"
+ )
+
+ logger.info("Video file type validation passed")
+ logger.info("Validating video content")
+
+ # Read and validate file content
+ file_content = await file.read()
+
+ if not validate_video(file_content):
+ logger.error("Video content validation failed")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid video file"
+ )
+
+ logger.info("Saving video to temporary storage")
+
+ # Save video to persistent temporary file
+ temp_dir = Path(tempfile.gettempdir()) / "gum_videos"
+ temp_dir.mkdir(exist_ok=True)
+
+ job_id = str(uuid.uuid4())
+ video_filename = f"{job_id}_{file.filename}"
+ video_path = temp_dir / video_filename
+
+ # Write video file
+ with open(video_path, 'wb') as f:
+ f.write(file_content)
+
+ logger.info(f"Video saved with job ID: {job_id}")
+
+ # Initialize job status
+ video_processing_jobs[job_id] = {
+ "status": "queued",
+ "progress": 0,
+ "filename": file.filename,
+ "fps": fps,
+ "created_at": time.time(),
+ "total_frames": 0,
+ "processed_frames": 0,
+ "successful_frames": 0,
+ "failed_frames": 0
+ }
+
+ logger.info(" Starting background video processing")
+
+ # Start background processing
+ asyncio.create_task(process_video_background(
+ job_id, video_path, user_name or "anonymous", observer_name or "api_controller", fps or 0.1, file.filename or "unknown.mp4"
+ ))
+
+ upload_time = (time.time() - start_time) * 1000
+ logger.info(f"Video upload completed in {upload_time:.1f}ms")
+
+ return {
+ "success": True,
+ "message": "Video uploaded successfully and queued for processing",
+ "job_id": job_id,
+ "filename": file.filename,
+ "fps": fps,
+ "upload_time_ms": upload_time,
+ "status": "queued",
+ "check_status_url": f"/observations/video/status/{job_id}"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error processing video observation: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error processing video observation: {str(e)}"
+ )
+
+
+@app.get("/observations/video/status/{job_id}", response_model=dict)
+async def get_video_processing_status(job_id: str):
+ """Get the status of a video processing job."""
+ if job_id not in video_processing_jobs:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Video processing job not found"
+ )
+
+ job = video_processing_jobs[job_id]
+
+ # Calculate processing time
+ processing_time = (time.time() - job["created_at"]) * 1000
+
+ response = {
+ "job_id": job_id,
+ "status": job["status"],
+ "progress": job["progress"],
+ "filename": job["filename"],
+ "fps": job["fps"],
+ "processing_time_ms": processing_time,
+ "total_frames": job["total_frames"],
+ "processed_frames": job["processed_frames"]
+ }
+
+ if job["status"] == "completed":
+ response.update({
+ "successful_frames": job["successful_frames"],
+ "failed_frames": job["failed_frames"],
+ "summary": f"Processed video {job['filename']} with {job['total_frames']} frames extracted at {job['fps']} fps. Successfully analyzed {job['successful_frames']} frames" + (f", {job['failed_frames']} frames failed processing" if job['failed_frames'] > 0 else ""),
+ "frame_analyses": job.get("frame_analyses", [])
+ })
+ elif job["status"] == "error":
+ response["error"] = job.get("error", "Unknown error occurred")
+
+ return response
+
+
+@app.exception_handler(Exception)
+async def global_exception_handler(request, exc):
+ """Global exception handler."""
+ logger.error(f"Unhandled exception: {exc}")
+ return JSONResponse(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ content=ErrorResponse(
+ error="Internal server error",
+ detail=str(exc),
+ timestamp=datetime.now()
+ ).dict()
+ )
+
+
+# === Helper Functions ===
+
+# === Main Entry Point ===
+
+async def startup_event():
+ """Startup event handler."""
+ logger.info("Starting GUM API Controller...")
+ logger.info(" AI Processing: Unified AI Client (Azure OpenAI + OpenRouter)")
+ logger.info(" Text Tasks: Azure OpenAI")
+ logger.info(" Vision Tasks: OpenRouter (Qwen Vision)")
+ logger.info(" Hybrid AI configuration initialized")
+ logger.info("GUM API Controller started successfully")
+
+
+app.add_event_handler("startup", startup_event)
+
+
+def run_server(host: str = "0.0.0.0", port: int = 8000, reload: bool = False):
+ """Run the FastAPI server."""
+ # Use logging for startup banner too
+ logger.info("=" * 60)
+ logger.info(" GUM AI Video Processing Server Starting Up")
+ logger.info("=" * 60)
+ logger.info(f" Server: {host}:{port}")
+ logger.info(f" Reload mode: {'Enabled' if reload else 'Disabled'}")
+ logger.info(" Log level: INFO")
+ logger.info("Video processing with enhanced logging enabled!")
+ logger.info("=" * 60)
+
+ uvicorn.run(
+ "controller:app",
+ host=host,
+ port=port,
+ reload=reload,
+ log_level="info"
+ )
+
+
+if __name__ == "__main__":
+ import argparse
+
+ parser = argparse.ArgumentParser(description="GUM REST API Controller")
+ parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
+ parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
+ parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
+
+ args = parser.parse_args()
+ run_server(host=args.host, port=args.port, reload=args.reload)
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..2ae2e66
--- /dev/null
+++ b/index.html
@@ -0,0 +1,428 @@
+
+
+
+
+
+ Observational Learning
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Observational Learning
+
Advanced AI-powered analysis for user behavior patterns and insights
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No recent analysis
+
Upload a video or submit observations to see analysis results here
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No insights loaded
+
Click "Load Insights" to view user behavior propositions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Example Queries:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Searching insights...
+
+
+
+
+
+
Ready to search
+
Enter a query above or try one of the example queries to get started
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Processing Video Analysis
+
This may take a few minutes depending on video length...
+
+
+
+
+
+
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..4db4e37
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,18 @@
+fastapi>=0.104.0
+uvicorn[standard]>=0.24.0
+pydantic>=2.0.0
+aiohttp>=3.8.0
+
+# Database
+sqlalchemy>=2.0.0
+
+# Date/Time handling
+python-dateutil>=2.8.0
+
+# Image Processing
+Pillow>=10.0.0
+
+# Environment Variables
+python-dotenv>=1.0.0
+
+gum>=1.0.0
\ No newline at end of file
diff --git a/server.py b/server.py
new file mode 100644
index 0000000..6c04cd8
--- /dev/null
+++ b/server.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+"""
+Simple HTTP server for GUM Frontend
+
+Serves the static frontend files for the GUM web interface.
+"""
+
+import os
+import sys
+import http.server
+import socketserver
+from pathlib import Path
+from urllib.parse import urlparse
+
+class GUMFrontendHandler(http.server.SimpleHTTPRequestHandler):
+ """Custom handler to serve the frontend files."""
+
+ # Class variable to cache the prompted backend address
+ _cached_backend_address = None
+
+ def __init__(self, *args, **kwargs):
+ # Set the directory to serve from
+ self.frontend_dir = Path(__file__).parent
+ super().__init__(*args, directory=str(self.frontend_dir), **kwargs)
+
+ def do_GET(self):
+ """Handle GET requests, with special handling for index.html to inject config."""
+ if self.path == '/' or self.path == '/index.html':
+ self.serve_index_with_config()
+ else:
+ super().do_GET()
+
+ def serve_index_with_config(self):
+ """Serve index.html with injected configuration."""
+ try:
+ # Read the .env file to get the backend address
+ backend_address = self.load_backend_address()
+
+ # Read the index.html file
+ index_path = self.frontend_dir / 'index.html'
+ with open(index_path, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # Inject the backend address as a global JavaScript variable
+ config_script = f"""
+ """
+
+ # Insert the config script before the closing tag
+ content = content.replace('', f'{config_script}\n')
+
+ # Send the response
+ self.send_response(200)
+ self.send_header('Content-type', 'text/html; charset=utf-8')
+ self.send_header('Content-Length', str(len(content.encode('utf-8'))))
+ self.end_headers()
+ self.wfile.write(content.encode('utf-8'))
+
+ except Exception as e:
+ print(f"Error serving index.html: {e}")
+ self.send_error(500, f"Internal server error: {e}")
+
+ def load_backend_address(self):
+ """Load backend address from .env files or environment variables."""
+ # Try environment variable first
+ backend_address = os.getenv('GUM_API_ADDRESS')
+ if backend_address:
+ return backend_address
+
+ # Try root .env file first (main configuration)
+ root_env_path = self.frontend_dir.parent / '.env'
+ backend_address = self._read_env_file(root_env_path)
+ if backend_address:
+ return backend_address
+
+ # Try frontend .env file as fallback
+ frontend_env_path = self.frontend_dir / '.env'
+ backend_address = self._read_env_file(frontend_env_path)
+ if backend_address:
+ return backend_address
+
+ # Check if we already have a cached address from previous prompt
+ if GUMFrontendHandler._cached_backend_address:
+ return GUMFrontendHandler._cached_backend_address
+
+ # Prompt user for backend address if not found and cache the result
+ backend_address = self._prompt_for_backend_address()
+ GUMFrontendHandler._cached_backend_address = backend_address
+ return backend_address
+
+ def _read_env_file(self, env_path):
+ """Read GUM_API_ADDRESS from a specific .env file."""
+ if env_path.exists():
+ try:
+ with open(env_path, 'r') as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith('#') and '=' in line:
+ key, value = line.split('=', 1)
+ if key.strip() == 'GUM_API_ADDRESS':
+ return value.strip()
+ except Exception as e:
+ print(f"Error reading .env file {env_path}: {e}")
+ return None
+
+ def _prompt_for_backend_address(self):
+ """Prompt user to provide the GUM API backend address."""
+ print("\nGUM API address not found. Please provide the backend address:")
+ print("Example: http://localhost:8001")
+
+ while True:
+ try:
+ backend_address = input("GUM API Address: ").strip()
+ if backend_address:
+ return backend_address
+ else:
+ print("Please enter a valid address.")
+ except (KeyboardInterrupt, EOFError):
+ print("\nOperation cancelled by user.")
+ sys.exit(1)
+
+ def end_headers(self):
+ # Add CORS headers to allow API calls
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
+ self.send_header('Access-Control-Allow-Headers', 'Content-Type')
+ super().end_headers()
+
+def serve_frontend(port=3000, host='localhost'):
+ """Start the frontend server."""
+ # Load and display the configuration
+ handler = GUMFrontendHandler
+ frontend_dir = Path(__file__).parent
+
+ # Check backend address with proper hierarchy
+ def load_config_for_display():
+ """Load configuration for display purposes."""
+ # Check environment variable
+ backend_address = os.getenv('GUM_API_ADDRESS')
+ if backend_address:
+ return backend_address, 'environment variable'
+
+ # Check root .env file
+ root_env_path = frontend_dir.parent / '.env'
+ if root_env_path.exists():
+ try:
+ with open(root_env_path, 'r') as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith('#') and '=' in line:
+ key, value = line.split('=', 1)
+ if key.strip() == 'GUM_API_ADDRESS':
+ return value.strip(), f'root .env ({root_env_path})'
+ except Exception as e:
+ print(f"Warning: Error reading root .env file: {e}")
+
+ # Check frontend .env file
+ frontend_env_path = frontend_dir / '.env'
+ if frontend_env_path.exists():
+ try:
+ with open(frontend_env_path, 'r') as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith('#') and '=' in line:
+ key, value = line.split('=', 1)
+ if key.strip() == 'GUM_API_ADDRESS':
+ return value.strip(), f'frontend .env ({frontend_env_path})'
+ except Exception as e:
+ print(f"Warning: Error reading frontend .env file: {e}")
+
+ # Check if we already have a cached address from previous prompt
+ if GUMFrontendHandler._cached_backend_address:
+ return GUMFrontendHandler._cached_backend_address, 'user input (cached)'
+
+ # If not found, we need to prompt the user
+ # Create a temporary handler instance to use the prompt method
+ temp_handler = GUMFrontendHandler.__new__(GUMFrontendHandler)
+ temp_handler.frontend_dir = frontend_dir
+ backend_address = temp_handler._prompt_for_backend_address()
+ GUMFrontendHandler._cached_backend_address = backend_address
+ return backend_address, 'user input'
+
+ backend_address, source = load_config_for_display()
+
+ print(f"Starting GUM Frontend Server")
+ print(f"Serving from: {frontend_dir}")
+ print(f"Frontend URL: http://{host}:{port}")
+ print(f"GUM API URL: {backend_address}")
+ print(f"Configuration source: {source}")
+ print(f"Open the frontend URL in your browser to access the GUM interface")
+ print("=" * 60)
+
+ try:
+ with socketserver.TCPServer((host, port), handler) as httpd:
+ print(f"Server started successfully")
+ print(f"Press Ctrl+C to stop the server")
+ httpd.serve_forever()
+ except OSError as e:
+ if e.errno == 48: # Address already in use
+ print(f"Port {port} is already in use")
+ print(f"Try a different port: python server.py --port {port + 1}")
+ else:
+ print(f"Failed to start server: {e}")
+ except KeyboardInterrupt:
+ print(f"\nServer stopped by user")
+
+if __name__ == "__main__":
+ import argparse
+
+ parser = argparse.ArgumentParser(description="GUM Frontend Server")
+ parser.add_argument("--port", type=int, default=3000, help="Port to serve on (default: 3000)")
+ parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: localhost)")
+
+ args = parser.parse_args()
+ serve_frontend(port=args.port, host=args.host)
diff --git a/start_web_app.py b/start_web_app.py
new file mode 100644
index 0000000..dce0faa
--- /dev/null
+++ b/start_web_app.py
@@ -0,0 +1,707 @@
+#!/usr/bin/env python3
+"""
+GUM Application Startup Script
+
+This script starts both the GUM backend API controller and frontend web server
+to provide a complete user interface for the GUM (General User Models) system.
+
+Features:
+- Concurrent startup of backend and frontend servers
+- Automatic browser opening
+- Graceful shutdown handling
+- Environment validation
+- Configurable ports and settings
+"""
+
+import asyncio
+import logging
+import multiprocessing
+import os
+import signal
+import subprocess
+import sys
+import time
+import webbrowser
+from pathlib import Path
+from typing import Optional, Dict
+import getpass
+
+# Load environment variables from .env file if present
+try:
+ from dotenv import load_dotenv
+ load_dotenv(override=False) # Don't override existing env vars
+except ImportError:
+ pass # python-dotenv not installed, continue without it
+
+# Configure logging
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s | %(levelname)s | %(message)s',
+ datefmt='%H:%M:%S'
+)
+logger = logging.getLogger(__name__)
+
+# Default configuration
+DEFAULT_BACKEND_PORT = 8001
+DEFAULT_FRONTEND_PORT = 3000
+DEFAULT_USER_NAME = "GUM_User"
+
+class GUMStartup:
+ """Manages the startup and shutdown of GUM backend and frontend services."""
+
+ def __init__(self,
+ backend_port: int = DEFAULT_BACKEND_PORT,
+ frontend_port: int = DEFAULT_FRONTEND_PORT,
+ user_name: Optional[str] = None,
+ open_browser: bool = True,
+ verbose: bool = False,
+ show_logs: bool = True):
+ """
+ Initialize the GUM startup manager.
+
+ Args:
+ backend_port: Port for the backend API server
+ frontend_port: Port for the frontend web server
+ user_name: Default user name for GUM operations
+ open_browser: Whether to automatically open the browser
+ verbose: Enable verbose logging
+ show_logs: Whether to show backend/frontend logs in real-time
+ """
+ self.backend_port = backend_port
+ self.frontend_port = frontend_port
+ self.user_name = user_name or os.getenv("USER_NAME", DEFAULT_USER_NAME)
+ self.open_browser = open_browser
+ self.verbose = verbose
+ self.show_logs = show_logs
+
+ # Process tracking
+ self.backend_process: Optional[subprocess.Popen] = None
+ self.frontend_process: Optional[subprocess.Popen] = None
+
+ # Paths
+ self.root_dir = Path(__file__).parent
+ self.frontend_dir = self.root_dir
+
+ # Set logging level
+ if verbose:
+ logging.getLogger().setLevel(logging.DEBUG)
+ logger.debug("Verbose logging enabled")
+
+ def get_user_input(self, prompt: str, default: str = "", sensitive: bool = False) -> str:
+ """Get user input with optional default value and sensitive input handling."""
+ if default:
+ display_prompt = f"{prompt} [{default}]: "
+ else:
+ display_prompt = f"{prompt}: "
+
+ if sensitive:
+ value = getpass.getpass(display_prompt)
+ if not value and default:
+ return default
+ return value
+ else:
+ value = input(display_prompt).strip()
+ if not value and default:
+ return default
+ return value
+
+ def configure_environment_interactively(self) -> Dict[str, str]:
+ """Interactively configure environment variables based on user choices."""
+ logger.info("Environment Configuration Setup")
+ print("=" * 50)
+ print("Let's configure your GUM environment!")
+ print("You can press Enter to use default values where shown.")
+ print()
+
+ config = {}
+
+ # Backend configuration
+ config["BACKEND_ADDRESS"] = f"http://localhost:{self.backend_port}"
+
+ # Get text provider choice
+ print("TEXT PROVIDER CONFIGURATION")
+ print("Available text providers:")
+ print(" 1. azure - Azure OpenAI")
+ print(" 2. openai - OpenAI API")
+ print()
+
+ while True:
+ text_provider = self.get_user_input("Choose text provider (azure/openai)", "azure").lower()
+ if text_provider in ["azure", "openai"]:
+ config["TEXT_PROVIDER"] = text_provider
+ break
+ print("Please choose either 'azure' or 'openai'")
+
+ print()
+
+ # Configure text provider specific settings
+ if config["TEXT_PROVIDER"] == "azure":
+ print("AZURE OPENAI CONFIGURATION")
+ config["AZURE_OPENAI_API_KEY"] = self.get_user_input(
+ "Azure OpenAI API Key",
+ sensitive=True
+ )
+ config["AZURE_OPENAI_ENDPOINT"] = self.get_user_input(
+ "Azure OpenAI Endpoint",
+ "https://your-resource-name.openai.azure.com/"
+ )
+ config["AZURE_OPENAI_API_VERSION"] = self.get_user_input(
+ "Azure OpenAI API Version",
+ "2025-01-01-preview"
+ )
+ config["AZURE_OPENAI_DEPLOYMENT"] = self.get_user_input(
+ "Azure OpenAI Deployment",
+ "gpt-4o"
+ )
+ else: # openai
+ print("OPENAI CONFIGURATION")
+ config["OPENAI_API_KEY"] = self.get_user_input(
+ "OpenAI API Key",
+ sensitive=True
+ )
+
+ print()
+
+ # Get vision provider choice
+ print("VISION PROVIDER CONFIGURATION")
+ print("Available vision providers:")
+ print(" 1. openrouter - OpenRouter API")
+ print()
+
+ vision_provider = self.get_user_input("Choose vision provider", "openrouter").lower()
+ config["VISION_PROVIDER"] = vision_provider
+
+ if vision_provider == "openrouter":
+ print("OPENROUTER CONFIGURATION")
+ config["OPENROUTER_API_KEY"] = self.get_user_input(
+ "OpenRouter API Key",
+ sensitive=True
+ )
+ config["OPENROUTER_API_URL"] = self.get_user_input(
+ "OpenRouter API URL",
+ "https://openrouter.ai/api/v1/chat/completions"
+ )
+ config["OPENROUTER_MODEL"] = self.get_user_input(
+ "OpenRouter Model",
+ "qwen/qwen-2.5-vl-72b-instruct:free"
+ )
+
+ print()
+
+ # Default user configuration
+ default_user = self.user_name
+ config["DEFAULT_USER_NAME"] = self.get_user_input(
+ "Default User Name",
+ default_user
+ )
+
+ print()
+ print("Configuration complete!")
+ print("=" * 50)
+
+ return config
+
+ def apply_environment_config(self, config: Dict[str, str]) -> None:
+ """Apply the configuration to the current environment."""
+ for key, value in config.items():
+ if value: # Only set non-empty values
+ os.environ[key] = value
+ if self.verbose and not any(sensitive in key.lower() for sensitive in ['key', 'secret', 'password']):
+ logger.debug(f"Set environment variable: {key}={value}")
+
+ def check_required_environment_variables(self) -> bool:
+ """Check if all required environment variables are set based on provider configuration."""
+ # Get explicitly set providers
+ text_provider = os.getenv("TEXT_PROVIDER", "").lower()
+ vision_provider = os.getenv("VISION_PROVIDER", "").lower()
+
+ # Auto-detect text provider if not explicitly set
+ if not text_provider:
+ if os.getenv("AZURE_OPENAI_API_KEY") and os.getenv("AZURE_OPENAI_ENDPOINT"):
+ text_provider = "azure"
+ logger.info("Auto-detected text provider: azure")
+ elif os.getenv("OPENAI_API_KEY"):
+ text_provider = "openai"
+ logger.info("Auto-detected text provider: openai")
+
+ # Auto-detect vision provider if not explicitly set
+ if not vision_provider:
+ if os.getenv("OPENROUTER_API_KEY"):
+ vision_provider = "openrouter"
+ logger.info("Auto-detected vision provider: openrouter")
+
+ required_vars = []
+
+ # Check text provider requirements
+ if text_provider == "azure":
+ required_vars.extend([
+ "AZURE_OPENAI_API_KEY",
+ "AZURE_OPENAI_ENDPOINT",
+ "AZURE_OPENAI_API_VERSION",
+ "AZURE_OPENAI_DEPLOYMENT"
+ ])
+ elif text_provider == "openai":
+ required_vars.append("OPENAI_API_KEY")
+ else:
+ # If no provider is detected/configured, we'll need to configure
+ logger.info("No text provider configured or detected")
+ return False
+
+ # Check vision provider requirements
+ if vision_provider == "openrouter":
+ required_vars.extend([
+ "OPENROUTER_API_KEY",
+ "OPENROUTER_API_URL",
+ "OPENROUTER_MODEL"
+ ])
+ else:
+ # If no provider is detected/configured, we'll need to configure
+ logger.info("No vision provider configured or detected")
+ return False
+
+ # Check if all required variables are present
+ missing_vars = []
+ for var in required_vars:
+ if not os.getenv(var):
+ missing_vars.append(var)
+
+ if missing_vars:
+ logger.warning(f"Missing required environment variables: {', '.join(missing_vars)}")
+ return False
+
+ # Set the detected providers in environment if they weren't explicitly set
+ if not os.getenv("TEXT_PROVIDER"):
+ os.environ["TEXT_PROVIDER"] = text_provider
+ logger.debug(f"Set TEXT_PROVIDER to auto-detected value: {text_provider}")
+
+ if not os.getenv("VISION_PROVIDER"):
+ os.environ["VISION_PROVIDER"] = vision_provider
+ logger.debug(f"Set VISION_PROVIDER to auto-detected value: {vision_provider}")
+
+ # Set default values for missing optional variables
+ if text_provider == "azure" and not os.getenv("AZURE_OPENAI_API_VERSION"):
+ os.environ["AZURE_OPENAI_API_VERSION"] = "2025-01-01-preview"
+ logger.debug("Set AZURE_OPENAI_API_VERSION to default value")
+
+ if text_provider == "azure" and not os.getenv("AZURE_OPENAI_DEPLOYMENT"):
+ os.environ["AZURE_OPENAI_DEPLOYMENT"] = "gpt-4o"
+ logger.debug("Set AZURE_OPENAI_DEPLOYMENT to default value")
+
+ if vision_provider == "openrouter" and not os.getenv("OPENROUTER_API_URL"):
+ os.environ["OPENROUTER_API_URL"] = "https://openrouter.ai/api/v1/chat/completions"
+ logger.debug("Set OPENROUTER_API_URL to default value")
+
+ if vision_provider == "openrouter" and not os.getenv("OPENROUTER_MODEL"):
+ os.environ["OPENROUTER_MODEL"] = "qwen/qwen-2.5-vl-72b-instruct:free"
+ logger.debug("Set OPENROUTER_MODEL to default value")
+
+ return True
+
+ def validate_environment(self) -> bool:
+ """Validate that the environment is properly configured."""
+ logger.info("Validating environment...")
+
+ # Check required files exist
+ controller_path = self.root_dir / "controller.py"
+ frontend_server_path = self.frontend_dir / "server.py"
+ requirements_path = self.root_dir / "requirements.txt"
+
+ if not controller_path.exists():
+ logger.error(f"Backend controller not found: {controller_path}")
+ return False
+
+ if not frontend_server_path.exists():
+ logger.error(f"Frontend server not found: {frontend_server_path}")
+ return False
+
+ # Check and install dependencies if needed
+ if not self.check_dependencies():
+ logger.info("Installing missing dependencies...")
+ if not self.install_dependencies():
+ logger.error("Failed to install dependencies")
+ return False
+
+ # Check if environment variables are properly configured
+ if not self.check_required_environment_variables():
+ logger.info("Environment configuration needed...")
+
+ # Show what's currently available
+ text_keys = ["AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"]
+ vision_keys = ["OPENROUTER_API_KEY"]
+
+ found_text_keys = [key for key in text_keys if os.getenv(key)]
+ found_vision_keys = [key for key in vision_keys if os.getenv(key)]
+
+ if found_text_keys or found_vision_keys:
+ logger.info("Found some environment variables:")
+ if found_text_keys:
+ logger.info(f" Text provider keys: {', '.join(found_text_keys)}")
+ if found_vision_keys:
+ logger.info(f" Vision provider keys: {', '.join(found_vision_keys)}")
+ logger.info("But additional configuration is still required.")
+ else:
+ logger.info("No API keys found in environment variables.")
+
+ print()
+
+ # Ask user if they want to configure now
+ configure_now = input("Would you like to configure the environment now? (y/N): ").strip().lower()
+ if configure_now in ['y', 'yes']:
+ config = self.configure_environment_interactively()
+ self.apply_environment_config(config)
+
+ # Verify configuration was successful
+ if not self.check_required_environment_variables():
+ logger.error("Environment configuration failed - missing required variables")
+ return False
+
+ logger.info("Environment configured successfully!")
+ else:
+ logger.error("Environment configuration is required to run GUM")
+ logger.info("Please set the required environment variables or run with configuration")
+ return False
+ else:
+ # Show what was detected/configured
+ text_provider = os.getenv("TEXT_PROVIDER", "unknown")
+ vision_provider = os.getenv("VISION_PROVIDER", "unknown")
+ logger.info(f"Environment variables configured successfully")
+ logger.info(f" Text provider: {text_provider}")
+ logger.info(f" Vision provider: {vision_provider}")
+
+ logger.info("Environment validation completed")
+ return True
+
+ def check_dependencies(self) -> bool:
+ """Check if required dependencies are installed."""
+ try:
+ logger.info("Checking dependencies...")
+
+ # List of critical imports to test
+ critical_imports = [
+ "fastapi",
+ "uvicorn",
+ "dateutil",
+ "PIL",
+ "aiohttp",
+ "sqlalchemy",
+ "pydantic"
+ ]
+
+ missing_imports = []
+ for module in critical_imports:
+ try:
+ __import__(module)
+ except ImportError:
+ missing_imports.append(module)
+
+ if missing_imports:
+ logger.warning(f"Missing dependencies: {', '.join(missing_imports)}")
+ return False
+
+ logger.info("All critical dependencies are installed")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error checking dependencies: {e}")
+ return False
+
+ def install_dependencies(self) -> bool:
+ """Install missing dependencies from requirements.txt."""
+ try:
+ requirements_path = self.root_dir / "requirements.txt"
+ if not requirements_path.exists():
+ logger.error("requirements.txt not found")
+ return False
+
+ logger.info("Installing dependencies from requirements.txt...")
+
+ # Install using pip
+ result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", "-r", str(requirements_path)],
+ capture_output=True,
+ text=True,
+ timeout=300 # 5 minute timeout
+ )
+
+ if result.returncode == 0:
+ logger.info("Dependencies installed successfully")
+ return True
+ else:
+ logger.error(f"Failed to install dependencies: {result.stderr}")
+ return False
+
+ except subprocess.TimeoutExpired:
+ logger.error("Dependency installation timed out")
+ return False
+ except Exception as e:
+ logger.error(f"Error installing dependencies: {e}")
+ return False
+
+ def start_backend(self) -> bool:
+ """Start the GUM backend API controller."""
+ try:
+ logger.info(f"Starting GUM backend on port {self.backend_port}...")
+
+ # Prepare backend command
+ backend_cmd = [
+ sys.executable,
+ "controller.py",
+ "--port", str(self.backend_port),
+ "--host", "0.0.0.0"
+ ]
+
+ # Set environment variables for the backend
+ env = os.environ.copy()
+ env["DEFAULT_USER_NAME"] = self.user_name
+ env["BACKEND_PORT"] = str(self.backend_port)
+
+ # Start backend process (show logs for better debugging)
+ self.backend_process = subprocess.Popen(
+ backend_cmd,
+ cwd=self.root_dir,
+ env=env,
+ stdout=None if self.show_logs else subprocess.PIPE,
+ stderr=None if self.show_logs else subprocess.STDOUT,
+ text=True
+ )
+
+ # Wait a bit and check if process started successfully
+ time.sleep(2)
+ if self.backend_process.poll() is not None:
+ logger.error("Backend failed to start")
+ return False
+
+ logger.info(f"Backend started successfully (PID: {self.backend_process.pid})")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error starting backend: {e}")
+ return False
+
+ def start_frontend(self) -> bool:
+ """Start the GUM frontend web server."""
+ try:
+ logger.info(f"Starting GUM frontend on port {self.frontend_port}...")
+
+ # Set backend address for frontend configuration
+ backend_address = f"http://localhost:{self.backend_port}"
+
+ # Prepare frontend command
+ frontend_cmd = [
+ sys.executable,
+ "server.py",
+ "--port", str(self.frontend_port)
+ ]
+
+ # Set environment variables for the frontend
+ env = os.environ.copy()
+ env["BACKEND_ADDRESS"] = backend_address
+ env["FRONTEND_PORT"] = str(self.frontend_port)
+
+ # Start frontend process (hide logs unless verbose)
+ self.frontend_process = subprocess.Popen(
+ frontend_cmd,
+ cwd=self.frontend_dir,
+ env=env,
+ stdout=None if self.verbose else subprocess.PIPE,
+ stderr=None if self.verbose else subprocess.STDOUT,
+ text=True
+ )
+
+ # Wait a bit and check if process started successfully
+ time.sleep(2)
+ if self.frontend_process.poll() is not None:
+ logger.error("Frontend failed to start")
+ return False
+
+ logger.info(f"Frontend started successfully (PID: {self.frontend_process.pid})")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error starting frontend: {e}")
+ return False
+
+ def open_browser_tab(self) -> None:
+ """Open the GUM web interface in the default browser."""
+ if not self.open_browser:
+ return
+
+ try:
+ url = f"http://localhost:{self.frontend_port}"
+ logger.info(f"Opening browser to {url}")
+ time.sleep(3) # Give servers time to fully start
+ webbrowser.open(url)
+ except Exception as e:
+ logger.warning(f"Could not open browser automatically: {e}")
+ logger.info(f"Please manually open: http://localhost:{self.frontend_port}")
+
+ def wait_for_shutdown(self) -> None:
+ """Wait for shutdown signal (Ctrl+C) and handle graceful shutdown."""
+ def signal_handler(signum, frame):
+ logger.info("\nShutdown signal received...")
+ self.shutdown()
+ sys.exit(0)
+
+ # Register signal handlers
+ signal.signal(signal.SIGINT, signal_handler)
+ signal.signal(signal.SIGTERM, signal_handler)
+
+ try:
+ logger.info("GUM is running! Press Ctrl+C to stop.")
+ logger.info(f"Backend API: http://localhost:{self.backend_port}")
+ logger.info(f"Frontend UI: http://localhost:{self.frontend_port}")
+ logger.info(f"Default User: {self.user_name}")
+ logger.info("Backend logs will appear below:")
+ logger.info("-" * 50)
+
+ # Keep the main process alive and monitor subprocesses
+ while True:
+ # Check if processes are still running
+ if self.backend_process and self.backend_process.poll() is not None:
+ logger.error("Backend process died unexpectedly")
+ logger.error(f" Exit code: {self.backend_process.returncode}")
+ break
+
+ if self.frontend_process and self.frontend_process.poll() is not None:
+ logger.error("Frontend process died unexpectedly")
+ logger.error(f" Exit code: {self.frontend_process.returncode}")
+ break
+
+ time.sleep(1)
+
+ except KeyboardInterrupt:
+ logger.info("\nKeyboard interrupt received...")
+ finally:
+ self.shutdown()
+
+ def shutdown(self) -> None:
+ """Gracefully shutdown both backend and frontend services."""
+ logger.info("Shutting down GUM services...")
+
+ # Shutdown frontend
+ if self.frontend_process:
+ try:
+ logger.info("Stopping frontend...")
+ self.frontend_process.terminate()
+ self.frontend_process.wait(timeout=5)
+ logger.info("Frontend stopped")
+ except subprocess.TimeoutExpired:
+ logger.warning("Frontend didn't stop gracefully, forcing...")
+ self.frontend_process.kill()
+ except Exception as e:
+ logger.error(f"Error stopping frontend: {e}")
+
+ # Shutdown backend
+ if self.backend_process:
+ try:
+ logger.info("Stopping backend...")
+ self.backend_process.terminate()
+ self.backend_process.wait(timeout=10)
+ logger.info("Backend stopped")
+ except subprocess.TimeoutExpired:
+ logger.warning("Backend didn't stop gracefully, forcing...")
+ self.backend_process.kill()
+ except Exception as e:
+ logger.error(f"Error stopping backend: {e}")
+
+ logger.info("GUM shutdown complete")
+
+ def start(self) -> bool:
+ """Start the complete GUM application stack."""
+ logger.info("Starting GUM Application Stack...")
+ logger.info("=" * 50)
+
+ # Validate environment
+ if not self.validate_environment():
+ return False
+
+ # Start backend
+ if not self.start_backend():
+ self.shutdown()
+ return False
+
+ # Start frontend
+ if not self.start_frontend():
+ self.shutdown()
+ return False
+
+ # Open browser
+ self.open_browser_tab()
+
+ # Wait for shutdown
+ self.wait_for_shutdown()
+ return True
+
+
+def main():
+ """Main entry point for the startup script."""
+ import argparse
+
+ parser = argparse.ArgumentParser(
+ description="Start the complete GUM (General User Models) application stack with backend API and web frontend",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ epilog="""
+Examples:
+ %(prog)s # Start with defaults
+ %(prog)s --backend-port 8002 --verbose # Custom backend port with verbose logs
+ %(prog)s --no-browser --no-logs # Headless mode without browser or logs
+ %(prog)s --user-name "John Doe" # Set default user name
+ """
+ )
+
+ parser.add_argument(
+ "--backend-port",
+ type=int,
+ default=DEFAULT_BACKEND_PORT,
+ help="Port for the backend API server"
+ )
+
+ parser.add_argument(
+ "--frontend-port",
+ type=int,
+ default=DEFAULT_FRONTEND_PORT,
+ help="Port for the frontend web server"
+ )
+
+ parser.add_argument(
+ "--user-name",
+ type=str,
+ help="Default user name for GUM operations"
+ )
+
+ parser.add_argument(
+ "--no-browser",
+ action="store_true",
+ help="Don't automatically open the browser"
+ )
+
+ parser.add_argument(
+ "--no-logs",
+ action="store_true",
+ help="Hide backend logs (keep startup script logs only)"
+ )
+
+ parser.add_argument(
+ "--verbose", "-v",
+ action="store_true",
+ help="Enable verbose logging for all components"
+ )
+
+ args = parser.parse_args()
+
+ # Create and start the GUM application
+ gum_app = GUMStartup(
+ backend_port=args.backend_port,
+ frontend_port=args.frontend_port,
+ user_name=args.user_name,
+ open_browser=not args.no_browser,
+ verbose=args.verbose,
+ show_logs=not args.no_logs
+ )
+
+ success = gum_app.start()
+ sys.exit(0 if success else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/static/css/styles.css b/static/css/styles.css
new file mode 100644
index 0000000..bc5fd82
--- /dev/null
+++ b/static/css/styles.css
@@ -0,0 +1,2495 @@
+:root {
+ /* Brand & Interactive Elements */
+ --brand-cyan: #0891b2;
+ --accent-surface: #0891b2;
+ --tooltip-shade: #0e7490;
+ --dark-tooltip-tint: #67e8f9;
+ --expandable-text-shade: #0e7490;
+ --expandable-text-dark-tint: #67e8f9;
+ --table-row-hover-shade: #0e7490;
+ --active-icon-background: #0e7490;
+ --dark-interactive-hover: #0e7490;
+ --search-highlight-shade: #0e7490;
+
+ /* Background Colors */
+ --bg-primary: #ffffff;
+ --bg-secondary: #f8f9fa;
+ --bg-tertiary: #e9ecef;
+ --bg-card: #ffffff;
+ --bg-hover: #f1f3f4;
+
+ /* Dark Mode Colors */
+ --dark-mode-primary-color: #e98df5;
+ --dark-mode-error-msg: #ecb1b1;
+ --dark-mode-bg-color: #2b2a2a;
+ --dark-mode-text-color: white;
+ --toolbar-background-color-dark-mode: #424242;
+ --secondary-button-color-dark-mode: #777474fb;
+
+ /* Text Colors */
+ --text-primary: #212529;
+ --text-secondary: #6c757d;
+ --text-muted: #868e96;
+ --text-white: #ffffff;
+
+ /* Border & Shadow */
+ --border-color: #dee2e6;
+ --border-light: #e9ecef;
+ --border-radius: 8px;
+ --border-radius-lg: 12px;
+ --border-radius-xl: 16px;
+ --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
+ --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.12);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
+ --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.2);
+ /* Status Colors */
+ --success-color: #28a745;
+ --warning-color: #ffc107;
+ --error-color: #dc3545;
+ --info-color: #17a2b8;
+
+ /* Status Colors RGB (for alpha backgrounds) */
+ --success-rgb: 40, 167, 69;
+ --warning-rgb: 255, 193, 7;
+ --error-rgb: 220, 53, 69;
+ --brand-cyan-rgb: 8, 145, 178;
+
+ /* Spacing */
+ --spacing-xs: 0.25rem;
+ --spacing-sm: 0.5rem;
+ --spacing-md: 1rem;
+ --spacing-lg: 1.5rem;
+ --spacing-xl: 2rem;
+ --spacing-xxl: 3rem;
+
+ /* Typography */
+ --font-family-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-family-mono: 'JetBrains Mono', 'Monaco', 'Menlo', monospace;
+ --font-size-xs: 0.75rem;
+ --font-size-sm: 0.875rem;
+ --font-size-base: 1rem;
+ --font-size-lg: 1.125rem;
+ --font-size-xl: 1.25rem;
+ --font-size-2xl: 1.5rem;
+ --font-size-3xl: 2rem;
+ --font-size-4xl: 2.5rem;
+
+ /* Transitions */
+ --transition-fast: 0.15s ease;
+ --transition-base: 0.2s ease;
+ --transition-slow: 0.3s ease;
+}
+
+/* Dark Mode Variables */
+[data-theme="dark"] {
+ --bg-primary: #1a1a1a;
+ --bg-secondary: #2a2a2a;
+ --bg-tertiary: #3a3a3a;
+ --bg-card: #2a2a2a;
+ --bg-hover: #3a3a3a;
+ --text-primary: #ffffff;
+ --text-secondary: #b0b0b0;
+ --text-muted: #888888;
+ --border-color: #404040;
+ --border-light: #363636;
+}
+
+/* Reset & Base Styles */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ scroll-behavior: smooth;
+ font-size: 16px;
+}
+
+body {
+ font-family: var(--font-family-primary);
+ font-size: var(--font-size-base);
+ line-height: 1.6;
+ color: var(--text-primary);
+ background-color: var(--bg-secondary);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ overflow-x: hidden;
+}
+
+/* Accessibility */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+*:focus {
+ outline: 2px solid var(--brand-cyan);
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+/* Progress Bar */
+.progress-bar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 0%;
+ height: 3px;
+ background: linear-gradient(90deg, var(--brand-cyan), var(--tooltip-shade));
+ z-index: 1000;
+ transition: width var(--transition-base);
+}
+
+/* Header */
+.header {
+ background-color: var(--bg-card);
+ border-bottom: 1px solid var(--border-color);
+ padding: var(--spacing-md) 0;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ backdrop-filter: blur(10px);
+ box-shadow: var(--shadow-sm);
+}
+
+.nav {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 var(--spacing-xl);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ font-size: var(--font-size-xl);
+ font-weight: 700;
+ color: var(--brand-cyan);
+}
+
+.logo i {
+ font-size: var(--font-size-2xl);
+}
+
+.nav-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-lg);
+}
+
+.connection-status {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+}
+
+.connection-status.connected {
+ color: var(--success-color);
+}
+
+.connection-status.disconnected {
+ color: var(--error-color);
+}
+
+.connection-status.connecting {
+ color: var(--warning-color);
+}
+
+.connection-status i {
+ animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+.theme-toggle {
+ background: none;
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ padding: var(--spacing-sm);
+ border-radius: var(--border-radius);
+ cursor: pointer;
+ transition: all var(--transition-base);
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.theme-toggle:hover {
+ background-color: var(--bg-hover);
+ color: var(--brand-cyan);
+ border-color: var(--brand-cyan);
+}
+
+/* Main Content */
+.main-content {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: var(--spacing-xl);
+}
+
+/* Hero Section */
+.hero {
+ text-align: center;
+ padding: var(--spacing-xxl) 0;
+ background: linear-gradient(135deg,
+ rgba(8, 145, 178, 0.05) 0%,
+ rgba(94, 31, 137, 0.05) 100%);
+ border-radius: var(--border-radius-xl);
+ margin-bottom: var(--spacing-xxl);
+}
+
+.hero-content h1 {
+ font-size: var(--font-size-4xl);
+ font-weight: 800;
+ color: var(--brand-cyan);
+ margin-bottom: var(--spacing-md);
+ line-height: 1.2;
+}
+
+.hero-content p {
+ font-size: var(--font-size-lg);
+ color: var(--text-secondary);
+ max-width: 600px;
+ margin: 0 auto;
+}
+
+/* Upload Section */
+.upload-section {
+ margin-bottom: var(--spacing-xxl);
+}
+
+.upload-container {
+ background-color: var(--bg-card);
+ border-radius: var(--border-radius-xl);
+ padding: var(--spacing-xxl);
+ box-shadow: var(--shadow-md);
+ border: 1px solid var(--border-color);
+}
+
+.upload-header {
+ text-align: center;
+ margin-bottom: var(--spacing-xxl);
+}
+
+.upload-header h2 {
+ font-size: var(--font-size-2xl);
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: var(--spacing-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-sm);
+}
+
+.upload-header p {
+ color: var(--text-secondary);
+ font-size: var(--font-size-lg);
+}
+
+/* Upload Zone */
+.upload-zone {
+ border: 2px dashed var(--border-color);
+ border-radius: var(--border-radius-lg);
+ padding: var(--spacing-xxl);
+ margin-bottom: var(--spacing-xl);
+ transition: all var(--transition-base);
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+}
+
+.upload-zone:hover,
+.upload-zone.dragover {
+ border-color: var(--brand-cyan);
+ background-color: rgba(8, 145, 178, 0.02);
+}
+
+.upload-zone-content {
+ text-align: center;
+}
+
+.upload-icon {
+ font-size: 4rem;
+ color: var(--brand-cyan);
+ margin-bottom: var(--spacing-lg);
+}
+
+.upload-zone h3 {
+ font-size: var(--font-size-xl);
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: var(--spacing-sm);
+}
+
+.upload-zone p {
+ color: var(--text-secondary);
+ margin-bottom: var(--spacing-lg);
+}
+
+.upload-formats {
+ display: flex;
+ justify-content: center;
+ gap: var(--spacing-sm);
+ flex-wrap: wrap;
+}
+
+.format-tag {
+ background-color: var(--bg-tertiary);
+ color: var(--text-secondary);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border-radius: var(--border-radius);
+ font-size: var(--font-size-xs);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+/* File Preview */
+.file-preview {
+ position: relative;
+}
+
+.preview-container {
+ display: flex;
+ gap: var(--spacing-lg);
+ align-items: flex-start;
+ padding: var(--spacing-lg);
+ background-color: var(--bg-secondary);
+ border-radius: var(--border-radius-lg);
+ border: 1px solid var(--border-light);
+}
+
+.preview-container video {
+ max-width: 300px;
+ max-height: 200px;
+ border-radius: var(--border-radius);
+ object-fit: cover;
+}
+
+.file-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.file-info h4 {
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: var(--spacing-sm);
+ word-break: break-word;
+}
+
+.file-details {
+ display: flex;
+ gap: var(--spacing-lg);
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+}
+
+.remove-file {
+ background: none;
+ border: none;
+ color: var(--error-color);
+ font-size: var(--font-size-lg);
+ cursor: pointer;
+ padding: var(--spacing-sm);
+ border-radius: var(--border-radius);
+ transition: all var(--transition-base);
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.remove-file:hover {
+ background-color: rgba(220, 53, 69, 0.1);
+}
+
+/* Configuration Panel */
+.config-panel {
+ margin-bottom: var(--spacing-xl);
+}
+
+.config-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: var(--spacing-xl);
+ margin-bottom: var(--spacing-xl);
+}
+
+.config-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.config-group label {
+ font-weight: 600;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.config-group input {
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ font-size: var(--font-size-base);
+ transition: all var(--transition-base);
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+.config-group input:focus {
+ border-color: var(--brand-cyan);
+ box-shadow: 0 0 0 3px rgba(8, 145, 178, 0.1);
+}
+
+.config-group small {
+ color: var(--text-muted);
+ font-size: var(--font-size-xs);
+}
+
+/* Analysis Options */
+.analysis-options {
+ margin-bottom: var(--spacing-xl);
+}
+
+.analysis-options h3 {
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: var(--spacing-lg);
+}
+
+.options-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--spacing-md);
+}
+
+.option-card {
+ display: block;
+ cursor: pointer;
+}
+
+.option-card input {
+ display: none;
+}
+
+.option-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-lg);
+ border: 2px solid var(--border-color);
+ border-radius: var(--border-radius-lg);
+ transition: all var(--transition-base);
+ background-color: var(--bg-card);
+ text-align: center;
+}
+
+.option-content i {
+ font-size: var(--font-size-xl);
+ color: var(--text-secondary);
+ transition: color var(--transition-base);
+}
+
+.option-content span {
+ font-weight: 500;
+ color: var(--text-primary);
+ font-size: var(--font-size-sm);
+}
+
+.option-card:hover .option-content {
+ border-color: var(--brand-cyan);
+ background-color: rgba(8, 145, 178, 0.02);
+}
+
+.option-card:hover .option-content i {
+ color: var(--brand-cyan);
+}
+
+.option-card input:checked + .option-content {
+ border-color: var(--brand-cyan);
+ background-color: rgba(8, 145, 178, 0.05);
+}
+
+.option-card input:checked + .option-content i {
+ color: var(--brand-cyan);
+}
+
+/* Buttons */
+.btn-primary,
+.btn-secondary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-md) var(--spacing-xl);
+ border: none;
+ border-radius: var(--border-radius);
+ font-size: var(--font-size-base);
+ font-weight: 600;
+ font-family: var(--font-family-primary);
+ cursor: pointer;
+ transition: all var(--transition-base);
+ text-decoration: none;
+ line-height: 1;
+ min-height: 44px;
+}
+
+.btn-primary {
+ background-color: var(--brand-cyan);
+ color: var(--text-white);
+}
+
+.btn-primary:hover:not(:disabled) {
+ background-color: var(--tooltip-shade);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn-primary:disabled {
+ background-color: var(--bg-tertiary);
+ color: var(--text-muted);
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+.btn-secondary {
+ background-color: var(--bg-card);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+}
+
+.btn-secondary:hover {
+ background-color: var(--bg-hover);
+ border-color: var(--brand-cyan);
+ color: var(--brand-cyan);
+}
+
+.btn-danger {
+ background-color: var(--error-color);
+ color: var(--text-white);
+ border: none;
+ border-radius: var(--border-radius);
+ padding: var(--spacing-sm) var(--spacing-md);
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ font-family: var(--font-family-primary);
+ cursor: pointer;
+ transition: all var(--transition-base);
+ text-decoration: none;
+ line-height: 1;
+ min-height: 38px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-xs);
+}
+
+.btn-danger:hover:not(:disabled) {
+ background-color: #c82333;
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn-danger:disabled {
+ background-color: var(--bg-tertiary);
+ color: var(--text-muted);
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+/* Submit Section */
+.submit-section {
+ text-align: center;
+ margin-bottom: var(--spacing-xl);
+}
+
+/* Progress Section */
+.progress-section {
+ margin-top: var(--spacing-xl);
+}
+
+.progress-container {
+ margin-bottom: var(--spacing-xl);
+}
+
+.progress-track {
+ height: 8px;
+ background-color: var(--bg-tertiary);
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: var(--spacing-sm);
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--brand-cyan), var(--tooltip-shade));
+ width: 0%;
+ transition: width var(--transition-base);
+ border-radius: 4px;
+}
+
+.progress-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+}
+
+.progress-steps {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--spacing-md);
+}
+
+.step {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--spacing-sm);
+ flex: 1;
+ opacity: 0.5;
+ transition: opacity var(--transition-base);
+}
+
+.step.active {
+ opacity: 1;
+ color: var(--brand-cyan);
+}
+
+.step.completed {
+ opacity: 1;
+ color: var(--success-color);
+}
+
+.step i {
+ font-size: var(--font-size-lg);
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background-color: var(--bg-tertiary);
+ transition: all var(--transition-base);
+}
+
+.step.active i {
+ background-color: var(--brand-cyan);
+ color: var(--text-white);
+}
+
+.step.completed i {
+ background-color: var(--success-color);
+ color: var(--text-white);
+}
+
+.step span {
+ font-size: var(--font-size-xs);
+ font-weight: 500;
+ text-align: center;
+}
+
+/* Results Section */
+.results-section {
+ margin-bottom: var(--spacing-xxl);
+}
+
+.results-container {
+ background-color: var(--bg-card);
+ border-radius: var(--border-radius-xl);
+ padding: var(--spacing-xxl);
+ box-shadow: var(--shadow-md);
+ border: 1px solid var(--border-color);
+}
+
+.results-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--spacing-xl);
+ flex-wrap: wrap;
+ gap: var(--spacing-lg);
+}
+
+.results-header h2 {
+ font-size: var(--font-size-2xl);
+ font-weight: 600;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.results-actions {
+ display: flex;
+ gap: var(--spacing-md);
+}
+
+/* History Section */
+.history-section {
+ margin-bottom: var(--spacing-xxl);
+}
+
+.history-container {
+ background-color: var(--bg-card);
+ border-radius: var(--border-radius-xl);
+ padding: var(--spacing-xxl);
+ box-shadow: var(--shadow-md);
+ border: 1px solid var(--border-color);
+}
+
+.history-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--spacing-xl);
+}
+
+.history-header h2 {
+ font-size: var(--font-size-2xl);
+ font-weight: 600;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+/* Toast Notifications */
+.toast {
+ position: fixed;
+ top: var(--spacing-xl);
+ right: var(--spacing-xl);
+ background-color: var(--bg-card);
+ color: var(--text-primary);
+ padding: var(--spacing-lg) var(--spacing-xl);
+ border-radius: var(--border-radius-lg);
+ box-shadow: var(--shadow-xl);
+ border: 1px solid var(--border-color);
+ max-width: 450px;
+ min-width: 300px;
+ z-index: 10000;
+ transform: translateX(calc(100% + var(--spacing-xl)));
+ opacity: 0;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ font-weight: 500;
+ font-size: 0.95rem;
+ line-height: 1.5;
+ backdrop-filter: blur(10px);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+}
+
+.toast.show {
+ transform: translateX(0);
+ opacity: 1;
+}
+
+.toast i {
+ font-size: 1.2rem;
+ flex-shrink: 0;
+}
+
+.toast.success {
+ border-left: 4px solid var(--success-color);
+ background-color: rgba(var(--success-rgb), 0.1);
+}
+
+.toast.success i {
+ color: var(--success-color);
+}
+
+.toast.error {
+ border-left: 4px solid var(--error-color);
+ background-color: rgba(var(--error-rgb), 0.1);
+}
+
+.toast.error i {
+ color: var(--error-color);
+}
+
+.toast.warning {
+ border-left: 4px solid var(--warning-color);
+ background-color: rgba(var(--warning-rgb), 0.1);
+}
+
+.toast.warning i {
+ color: var(--warning-color);
+}
+
+.toast.info {
+ border-left: 4px solid var(--brand-cyan);
+ background-color: rgba(var(--brand-cyan-rgb), 0.1);
+}
+
+.toast.info i {
+ color: var(--brand-cyan);
+}
+
+/* Loading Overlay */
+.loading-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ backdrop-filter: blur(4px);
+}
+
+.loading-content {
+ background-color: var(--bg-card);
+ padding: var(--spacing-xxl);
+ border-radius: var(--border-radius-xl);
+ text-align: center;
+ max-width: 400px;
+ box-shadow: var(--shadow-xl);
+}
+
+.loading-content h3 {
+ font-size: var(--font-size-xl);
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: var(--spacing-sm);
+}
+
+.loading-content p {
+ color: var(--text-secondary);
+ margin-bottom: var(--spacing-lg);
+}
+
+/* Spinner */
+.spinner {
+ width: 48px;
+ height: 48px;
+ border: 4px solid var(--bg-tertiary);
+ border-top: 4px solid var(--brand-cyan);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 0 auto var(--spacing-lg);
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ .nav {
+ padding: 0 var(--spacing-md);
+ }
+
+ .logo {
+ font-size: var(--font-size-lg);
+ }
+
+ .nav-actions {
+ gap: var(--spacing-md);
+ }
+
+ .main-content {
+ padding: var(--spacing-md);
+ }
+
+ .hero {
+ padding: var(--spacing-xl) 0;
+ }
+
+ .hero-content h1 {
+ font-size: var(--font-size-3xl);
+ }
+
+ .upload-container,
+ .results-container,
+ .history-container {
+ padding: var(--spacing-lg);
+ }
+
+ .config-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .options-grid {
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ }
+
+ .progress-steps {
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ }
+
+ .results-header,
+ .history-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--spacing-md);
+ }
+
+ .results-actions {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .toast {
+ bottom: var(--spacing-md);
+ right: var(--spacing-md);
+ left: var(--spacing-md);
+ max-width: none;
+ }
+}
+
+@media (max-width: 480px) {
+ .upload-zone {
+ padding: var(--spacing-lg);
+ }
+
+ .upload-icon {
+ font-size: 3rem;
+ }
+
+ .preview-container {
+ flex-direction: column;
+ gap: var(--spacing-md);
+ }
+
+ .preview-container video {
+ max-width: 100%;
+ }
+
+ .options-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .btn-primary,
+ .btn-secondary {
+ width: 100%;
+ justify-content: center;
+ }
+}
+
+/* Animation for fade-in effects */
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.fade-in-up {
+ animation: fadeInUp 0.6s ease forwards;
+}
+
+/* Custom scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-tertiary);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--brand-cyan);
+}
+
+/* Focus styles for better accessibility */
+button:focus-visible,
+input:focus-visible,
+select:focus-visible,
+textarea:focus-visible {
+ outline: 2px solid var(--brand-cyan);
+ outline-offset: 2px;
+}
+
+.connection-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px;
+ border-radius: 20px;
+ font-size: 0.9rem;
+ font-weight: 500;
+}
+
+.connection-status.connected {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.connection-status.disconnected {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.connection-status.connecting {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+/* Main Content */
+.main-content {
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(10px);
+ border-radius: 20px;
+ padding: 30px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+}
+
+/* Tab Navigation */
+.tab-nav {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 30px;
+ border-bottom: 2px solid #e5e7eb;
+ padding-bottom: 10px;
+}
+
+.tab-btn {
+ background: none;
+ border: none;
+ padding: 12px 24px;
+ cursor: pointer;
+ border-radius: 10px;
+ font-size: 1rem;
+ font-weight: 500;
+ color: #6b7280;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.tab-btn:hover {
+ background: #f3f4f6;
+ color: #4c51bf;
+}
+
+.tab-btn.active {
+ background: #4c51bf;
+ color: white;
+}
+
+/* Tab Content */
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* Sections */
+.section {
+ margin-bottom: 40px;
+ padding: 30px;
+ background: #f9fafb;
+ border-radius: 15px;
+ border: 1px solid #e5e7eb;
+}
+
+.section h2 {
+ font-size: 1.8rem;
+ color: #374151;
+ margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.section h2 i {
+ color: var(--brand-cyan);
+}
+
+/* Forms */
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+}
+
+.form-group label {
+ font-weight: 600;
+ color: #374151;
+ font-size: 0.95rem;
+}
+
+.form-group input,
+.form-group textarea,
+.form-group select {
+ padding: 12px 16px;
+ border: 2px solid #e5e7eb;
+ border-radius: 10px;
+ font-size: 1rem;
+ transition: border-color 0.3s ease;
+ background: white;
+}
+
+.form-group input:focus,
+.form-group textarea:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: var(--brand-cyan);
+ box-shadow: 0 0 0 3px rgba(8, 145, 178, 0.1);
+}
+
+.form-group textarea {
+ resize: vertical;
+ min-height: 100px;
+}
+
+/* File Upload */
+.file-upload-area {
+ border: 2px dashed #d1d5db;
+ border-radius: 10px;
+ padding: 40px 20px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ background: white;
+}
+
+.file-upload-area:hover {
+ border-color: var(--brand-cyan);
+ background: #f8faff;
+}
+
+.file-upload-area.dragover {
+ border-color: var(--brand-cyan);
+ background: #f0f7ff;
+}
+
+.file-upload-content i {
+ font-size: 3rem;
+ color: #9ca3af;
+ margin-bottom: 15px;
+}
+
+.file-upload-content p {
+ font-size: 1.1rem;
+ color: #6b7280;
+ margin-bottom: 5px;
+}
+
+.file-upload-content small {
+ color: #9ca3af;
+}
+
+.file-preview {
+ text-align: center;
+}
+
+.file-preview img {
+ max-width: 200px;
+ max-height: 200px;
+ border-radius: 10px;
+ margin-bottom: 10px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.file-preview p {
+ font-weight: 600;
+ color: #374151;
+}
+
+/* Video Upload Styles */
+.progress-container {
+ margin-top: 15px;
+ padding: 15px;
+ background: #f8fafc;
+ border-radius: 8px;
+ border: 1px solid #e2e8f0;
+}
+
+.progress-bar {
+ width: 100%;
+ height: 8px;
+ background: #e2e8f0;
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: 10px;
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--brand-cyan), var(--tooltip-shade));
+ border-radius: 4px;
+ width: 0%;
+ transition: width 0.3s ease;
+}
+
+#videoProgress p {
+ margin: 0;
+ font-size: 0.9rem;
+ color: #6b7280;
+ text-align: center;
+}
+
+/* Video preview styles */
+#previewVideo {
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+/* Loading animation for video processing */
+.processing-animation {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ border: 2px solid #e2e8f0;
+ border-radius: 50%;
+ border-top-color: var(--brand-cyan);
+ animation: spin 1s ease-in-out infinite;
+ margin-right: 8px;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Video result styles */
+.video-result-summary {
+ background: #f0f9ff;
+ border: 1px solid #bae6fd;
+ border-radius: 8px;
+ padding: 15px;
+ margin: 15px 0;
+}
+
+.video-result-summary h4 {
+ color: #0369a1;
+ margin-bottom: 10px;
+ font-size: 1.1rem;
+}
+
+.frame-analysis-list {
+ max-height: 300px;
+ overflow-y: auto;
+ margin-top: 15px;
+}
+
+.frame-analysis-item {
+ padding: 10px;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ margin-bottom: 8px;
+ background: white;
+}
+
+.frame-analysis-item.error {
+ background: #fef2f2;
+ border-color: #fecaca;
+}
+
+.frame-meta {
+ font-size: 0.8rem;
+ color: #6b7280;
+ margin-bottom: 5px;
+}
+
+.frame-analysis {
+ font-size: 0.9rem;
+ color: #374151;
+}
+
+/* Buttons */
+.btn {
+ padding: 12px 24px;
+ border: none;
+ border-radius: 10px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: center;
+ text-decoration: none;
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--brand-cyan);
+ color: var(--text-white);
+}
+
+.btn-primary:hover:not(:disabled) {
+ background-color: var(--tooltip-shade);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-lg);
+}
+
+.btn-secondary {
+ background: #6b7280;
+ color: white;
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: #4b5563;
+ transform: translateY(-2px);
+}
+
+/* Results */
+.results-container {
+ margin-top: 30px;
+ padding: 25px;
+ background: white;
+ border-radius: 15px;
+ border: 1px solid #e5e7eb;
+}
+
+.results-container h3 {
+ color: #374151;
+ margin-bottom: 20px;
+ font-size: 1.5rem;
+}
+
+.result-item {
+ padding: 20px;
+ margin-bottom: 15px;
+ background: #f9fafb;
+ border-radius: 10px;
+ border-left: 4px solid var(--brand-cyan);
+}
+
+.result-item:last-child {
+ margin-bottom: 0;
+}
+
+.result-header {
+ display: flex;
+ justify-content: between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.result-title {
+ font-weight: 600;
+ color: #374151;
+ font-size: 1.1rem;
+}
+
+.result-score {
+ background: var(--brand-cyan);
+ color: white;
+ padding: 4px 12px;
+ border-radius: 20px;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.result-content {
+ color: #6b7280;
+ line-height: 1.6;
+ margin-bottom: 10px;
+}
+
+.result-meta {
+ display: flex;
+ gap: 15px;
+ font-size: 0.85rem;
+ color: #9ca3af;
+}
+
+.result-meta span {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+/* History Items */
+.history-item {
+ padding: 20px;
+ margin-bottom: 15px;
+ background: #f9fafb;
+ border-radius: 10px;
+ border-left: 4px solid #10b981;
+}
+
+.history-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.history-id {
+ font-weight: 600;
+ color: #374151;
+}
+
+.history-type {
+ background: #10b981;
+ color: white;
+ padding: 4px 12px;
+ border-radius: 20px;
+ font-size: 0.85rem;
+}
+
+.history-content {
+ color: #6b7280;
+ margin-bottom: 10px;
+ word-break: break-word;
+}
+
+.history-meta {
+ display: flex;
+ gap: 15px;
+ font-size: 0.85rem;
+ color: #9ca3af;
+}
+
+/* Toast Notifications */
+.toast {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ padding: 15px 20px;
+ border-radius: 10px;
+ color: white;
+ font-weight: 600;
+ z-index: 1000;
+ transform: translateX(400px);
+ transition: transform 0.3s ease;
+ max-width: 400px;
+}
+
+.toast.show {
+ transform: translateX(0);
+}
+
+.toast.success {
+ background: #10b981;
+}
+
+.toast.error {
+ background: #ef4444;
+}
+
+.toast.info {
+ background: #3b82f6;
+}
+
+/* ========================================
+ ENTERPRISE TABBED INTERFACE STYLES
+======================================== */
+
+/* Tab Navigation */
+.tabs-navigation {
+ background: var(--bg-primary);
+ border-bottom: 1px solid var(--border-color);
+ padding: 0 var(--spacing-lg);
+ margin-bottom: var(--spacing-xl);
+}
+
+.tabs-container {
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.tabs-nav {
+ display: flex;
+ gap: 2px;
+ border-bottom: none;
+ overflow-x: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.tabs-nav::-webkit-scrollbar {
+ display: none;
+}
+
+.tab-button {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-lg) var(--spacing-xl);
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ font-size: var(--font-size-base);
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-base);
+ white-space: nowrap;
+ position: relative;
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
+ min-height: 60px;
+}
+
+.tab-button:hover {
+ color: var(--text-primary);
+ background: var(--bg-hover);
+}
+
+.tab-button.active {
+ color: var(--brand-cyan);
+ background: var(--bg-card);
+ border-bottom: 2px solid var(--brand-cyan);
+ font-weight: 600;
+}
+
+.tab-button i {
+ font-size: 1.1em;
+ transition: transform var(--transition-base);
+}
+
+.tab-button:hover i {
+ transform: translateY(-1px);
+}
+
+.tab-button.active i {
+ color: var(--brand-cyan);
+}
+
+/* Tab Panels */
+.tabs-content {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 var(--spacing-lg);
+}
+
+.tab-panel {
+ display: none;
+ animation: fadeIn 0.3s ease-in-out;
+}
+
+.tab-panel.active {
+ display: block;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Panel Cards */
+.panel-card {
+ background: var(--bg-card);
+ border-radius: var(--border-radius-lg);
+ box-shadow: var(--shadow-md);
+ overflow: hidden;
+ margin-bottom: var(--spacing-xl);
+}
+
+.card-header {
+ padding: var(--spacing-xl);
+ border-bottom: 1px solid var(--border-light);
+ background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-hover) 100%);
+}
+
+.card-header h2 {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ margin: 0 0 var(--spacing-sm) 0;
+ color: var(--text-primary);
+ font-size: var(--font-size-2xl);
+ font-weight: 600;
+}
+
+.card-header p {
+ margin: 0 0 var(--spacing-lg) 0;
+ color: var(--text-secondary);
+ font-size: var(--font-size-base);
+ line-height: 1.5;
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ flex-wrap: wrap;
+}
+
+/* Query Section Styles */
+.query-section {
+ padding: var(--spacing-xl);
+}
+
+.query-form {
+ margin-bottom: var(--spacing-xl);
+}
+
+.search-container {
+ background: var(--bg-secondary);
+ border-radius: var(--border-radius-lg);
+ padding: var(--spacing-xl);
+ border: 2px solid var(--border-light);
+ transition: border-color var(--transition-base);
+}
+
+.search-container:focus-within {
+ border-color: var(--brand-cyan);
+ box-shadow: 0 0 0 3px rgba(var(--brand-cyan-rgb), 0.1);
+}
+
+.search-input-group {
+ display: flex;
+ gap: var(--spacing-md);
+ margin-bottom: var(--spacing-lg);
+}
+
+.search-input {
+ flex: 1;
+ padding: var(--spacing-lg);
+ border: 2px solid var(--border-color);
+ border-radius: var(--border-radius);
+ font-size: var(--font-size-base);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ transition: all var(--transition-base);
+}
+
+.search-input:focus {
+ outline: none;
+ border-color: var(--brand-cyan);
+ box-shadow: 0 0 0 3px rgba(var(--brand-cyan-rgb), 0.1);
+}
+
+.search-btn {
+ padding: var(--spacing-lg) var(--spacing-xl);
+ background: var(--brand-cyan);
+ color: white;
+ border: none;
+ border-radius: var(--border-radius);
+ cursor: pointer;
+ transition: all var(--transition-base);
+ font-size: var(--font-size-base);
+ min-width: 120px;
+}
+
+.search-btn:hover {
+ background: var(--tooltip-shade);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.search-btn:active {
+ transform: translateY(0);
+}
+
+.search-options {
+ display: flex;
+ gap: var(--spacing-lg);
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.option-group {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.option-group label {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.option-group select,
+.option-group .user-input {
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: var(--font-size-sm);
+ min-width: 100px;
+}
+
+.option-group .user-input {
+ min-width: 150px;
+}
+
+/* Query Examples */
+.query-examples {
+ margin-bottom: var(--spacing-xl);
+}
+
+.query-examples h3 {
+ margin: 0 0 var(--spacing-lg) 0;
+ color: var(--text-primary);
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+}
+
+.examples-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--spacing-md);
+}
+
+.example-query {
+ padding: var(--spacing-md) var(--spacing-lg);
+ background: var(--bg-hover);
+ border: 1px solid var(--border-light);
+ border-radius: var(--border-radius);
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: all var(--transition-base);
+ font-size: var(--font-size-sm);
+ text-align: left;
+}
+
+.example-query:hover {
+ background: var(--brand-cyan);
+ color: white;
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-sm);
+}
+
+/* Query Results */
+.query-results-section {
+ position: relative;
+ min-height: 300px;
+}
+
+.loading-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(var(--bg-primary), 0.9);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-md);
+ border-radius: var(--border-radius);
+ z-index: 10;
+}
+
+.query-results {
+ background: var(--bg-primary);
+ border-radius: var(--border-radius);
+ min-height: 300px;
+}
+
+.query-result-item {
+ background: var(--bg-card);
+ border: 1px solid var(--border-light);
+ border-radius: var(--border-radius);
+ padding: var(--spacing-lg);
+ margin-bottom: var(--spacing-md);
+ transition: all var(--transition-base);
+ animation: slideInUp 0.3s ease-out;
+}
+
+.query-result-item:hover {
+ box-shadow: var(--shadow-md);
+ border-color: var(--brand-cyan);
+ transform: translateY(-2px);
+}
+
+.query-result-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: var(--spacing-md);
+}
+
+.query-result-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+}
+
+.result-id {
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border-radius: var(--border-radius);
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+}
+
+.result-score {
+ background: var(--brand-cyan);
+ color: white;
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border-radius: var(--border-radius);
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+}
+
+.query-result-text {
+ color: var(--text-primary);
+ font-size: var(--font-size-base);
+ line-height: 1.6;
+ margin-bottom: var(--spacing-md);
+}
+
+.query-result-reasoning {
+ background: var(--bg-hover);
+ padding: var(--spacing-md);
+ border-radius: var(--border-radius);
+ border-left: 3px solid var(--info-color);
+ margin-bottom: var(--spacing-md);
+}
+
+.query-result-reasoning strong {
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.query-result-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-top: var(--spacing-md);
+ border-top: 1px solid var(--border-light);
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+}
+
+.query-stats {
+ background: var(--bg-hover);
+ border-radius: var(--border-radius);
+ padding: var(--spacing-lg);
+ margin-bottom: var(--spacing-lg);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--spacing-md);
+}
+
+.query-stat {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ color: var(--text-secondary);
+ font-size: var(--font-size-sm);
+}
+
+.query-stat i {
+ color: var(--brand-cyan);
+}
+
+/* Enhanced Upload Section for Tabs */
+.upload-section {
+ padding: var(--spacing-xl);
+}
+
+/* Enhanced History Section for Tabs */
+.history-section {
+ padding: var(--spacing-xl);
+}
+
+/* Responsive Design for Tabs */
+@media (max-width: 768px) {
+ .tabs-nav {
+ flex-direction: column;
+ gap: 0;
+ }
+
+ .tab-button {
+ border-radius: 0;
+ border-bottom: 1px solid var(--border-light);
+ justify-content: center;
+ }
+
+ .search-options {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .examples-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .query-stats {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .query-result-header {
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ }
+}
+
+/* Dark Mode Support for New Components */
+[data-theme="dark"] .panel-card {
+ background: var(--dark-mode-bg-color);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
+}
+
+[data-theme="dark"] .card-header {
+ background: linear-gradient(135deg, var(--dark-mode-bg-color) 0%, rgba(255, 255, 255, 0.05) 100%);
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+[data-theme="dark"] .search-container {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+[data-theme="dark"] .search-input {
+ background: var(--dark-mode-bg-color);
+ border-color: rgba(255, 255, 255, 0.2);
+ color: var(--dark-mode-text-color);
+}
+
+[data-theme="dark"] .example-query {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.1);
+ color: var(--dark-mode-text-color);
+}
+
+[data-theme="dark"] .query-result-item {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+[data-theme="dark"] .query-result-reasoning {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: var(--dark-mode-primary-color);
+}
+
+[data-theme="dark"] .query-stats {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+[data-theme="dark"] .tab-button.active {
+ background: var(--dark-mode-bg-color);
+}
+
+[data-theme="dark"] .tabs-navigation {
+ background: var(--dark-mode-bg-color);
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+/* Animation delays for query results */
+.query-result-item:nth-child(1) { animation-delay: 0.1s; }
+.query-result-item:nth-child(2) { animation-delay: 0.2s; }
+.query-result-item:nth-child(3) { animation-delay: 0.3s; }
+.query-result-item:nth-child(4) { animation-delay: 0.4s; }
+.query-result-item:nth-child(5) { animation-delay: 0.5s; }
+
+/* Animation delays for proposition cards */
+.proposition-card:nth-child(1) { animation-delay: 0.1s; }
+.proposition-card:nth-child(2) { animation-delay: 0.2s; }
+.proposition-card:nth-child(3) { animation-delay: 0.3s; }
+.proposition-card:nth-child(4) { animation-delay: 0.4s; }
+.proposition-card:nth-child(5) { animation-delay: 0.5s; }
+
+/* ========================================
+ USER BEHAVIOUR INSIGHTS STYLES
+======================================== */
+
+/* Filter Group Styles */
+.filter-group {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.filter-group label {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.filter-group select {
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: var(--font-size-sm);
+ min-width: 100px;
+}
+
+/* Propositions Stats */
+.propositions-stats {
+ background: var(--bg-hover);
+ border-radius: var(--border-radius);
+ padding: var(--spacing-lg);
+ margin-bottom: var(--spacing-lg);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--spacing-md);
+ border: 1px solid var(--border-light);
+}
+
+.stat-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ color: var(--text-secondary);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+}
+
+.stat-item i {
+ color: var(--brand-cyan);
+ font-size: var(--font-size-base);
+}
+
+/* Propositions Content */
+.propositions-content {
+ background: var(--bg-primary);
+ border-radius: var(--border-radius);
+ padding: var(--spacing-lg);
+ min-height: 300px;
+}
+
+/* Proposition Cards */
+.proposition-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-light);
+ border-radius: var(--border-radius-lg);
+ padding: var(--spacing-lg);
+ margin-bottom: var(--spacing-lg);
+ transition: all var(--transition-base);
+ animation: slideInUp 0.3s ease-out;
+ box-shadow: var(--shadow-sm);
+}
+
+.proposition-card:hover {
+ box-shadow: var(--shadow-md);
+ border-color: var(--brand-cyan);
+ transform: translateY(-2px);
+}
+
+.proposition-card:last-child {
+ margin-bottom: 0;
+}
+
+/* Proposition Header */
+.proposition-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: var(--spacing-md);
+}
+
+.proposition-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+}
+
+.proposition-id {
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border-radius: var(--border-radius);
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ font-family: var(--font-family-mono);
+}
+
+/* Confidence Badges */
+.confidence-badge {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border-radius: var(--border-radius);
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.confidence-badge i {
+ font-size: var(--font-size-xs);
+}
+
+/* Confidence Classes */
+.confidence-badge.confidence-none {
+ background: var(--bg-tertiary);
+ color: var(--text-muted);
+ border: 1px solid var(--border-color);
+}
+
+.confidence-badge.confidence-low {
+ background: rgba(var(--error-rgb), 0.1);
+ color: var(--error-color);
+ border: 1px solid rgba(var(--error-rgb), 0.2);
+}
+
+.confidence-badge.confidence-medium {
+ background: rgba(var(--warning-rgb), 0.1);
+ color: var(--warning-color);
+ border: 1px solid rgba(var(--warning-rgb), 0.2);
+}
+
+.confidence-badge.confidence-high {
+ background: rgba(var(--success-rgb), 0.1);
+ color: var(--success-color);
+ border: 1px solid rgba(var(--success-rgb), 0.2);
+}
+
+/* Proposition Content */
+.proposition-text {
+ color: var(--text-primary);
+ font-size: var(--font-size-base);
+ line-height: 1.6;
+ margin-bottom: var(--spacing-md);
+ font-weight: 500;
+}
+
+.proposition-reasoning {
+ background: var(--bg-hover);
+ padding: var(--spacing-md);
+ border-radius: var(--border-radius);
+ border-left: 3px solid var(--info-color);
+ margin-bottom: var(--spacing-md);
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+}
+
+.proposition-reasoning strong {
+ color: var(--text-primary);
+ font-weight: 600;
+ margin-bottom: var(--spacing-xs);
+ display: block;
+}
+
+/* Proposition Footer */
+.proposition-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-top: var(--spacing-md);
+ border-top: 1px solid var(--border-light);
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+}
+
+.proposition-date {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.proposition-date i {
+ color: var(--brand-cyan);
+}
+
+/* Pagination Controls */
+.pagination-controls {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: var(--spacing-lg);
+ margin-top: var(--spacing-xl);
+ padding: var(--spacing-lg);
+ border-top: 1px solid var(--border-light);
+}
+
+#propositionsPageInfo {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+/* Empty State for Propositions */
+.propositions-content .empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: var(--spacing-xxl);
+ color: var(--text-secondary);
+}
+
+.propositions-content .empty-state i {
+ font-size: 4rem;
+ color: var(--brand-cyan);
+ margin-bottom: var(--spacing-lg);
+ opacity: 0.7;
+}
+
+.propositions-content .empty-state h3 {
+ font-size: var(--font-size-xl);
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: var(--spacing-sm);
+}
+
+.propositions-content .empty-state p {
+ font-size: var(--font-size-base);
+ line-height: 1.5;
+ max-width: 400px;
+}
+
+/* Header Actions Styling */
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-lg);
+ flex-wrap: wrap;
+ margin-top: var(--spacing-md);
+}
+
+/* Results Dashboard Styling */
+.results-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: var(--spacing-lg);
+ margin-top: var(--spacing-xl);
+}
+
+.result-card {
+ background: var(--bg-card);
+ border-radius: var(--border-radius-lg);
+ box-shadow: var(--shadow-md);
+ overflow: hidden;
+ transition: var(--transition-base);
+}
+
+.result-card:hover {
+ box-shadow: var(--shadow-lg);
+ transform: translateY(-2px);
+}
+
+.result-card.full-width {
+ grid-column: 1 / -1;
+}
+
+.result-header {
+ background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
+ color: white !important;
+ padding: var(--spacing-lg);
+ margin-bottom: 0;
+}
+
+.result-header h3 {
+ margin: 0;
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ color: white !important;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.result-header h3 i {
+ color: white !important;
+ opacity: 0.9;
+}
+
+.result-content {
+ padding: var(--spacing-lg);
+ color: var(--text-primary);
+}
+
+/* Summary Stats Styling */
+.summary-stats {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+}
+
+.stat {
+ display: flex;
+ justify-content: between;
+ align-items: center;
+ padding: var(--spacing-sm) 0;
+ border-bottom: 1px solid var(--border-light);
+}
+
+.stat:last-child {
+ border-bottom: none;
+}
+
+.stat-label {
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.stat-value {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.stat-value.success {
+ color: var(--success-color);
+}
+
+/* Insights and Patterns */
+.insights-list,
+.patterns-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.no-data {
+ text-align: center;
+ color: var(--text-muted);
+ font-style: italic;
+ padding: var(--spacing-xl);
+}
+
+.analysis-details {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ padding: var(--spacing-lg);
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+ max-height: 400px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+/* Dark Mode Adjustments */
+[data-theme="dark"] .result-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+}
+
+[data-theme="dark"] .result-header {
+ background: linear-gradient(135deg, #9333ea 0%, #7c3aed 100%);
+}
+
+[data-theme="dark"] .analysis-details {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+/* Summary Text */
+.summary-text {
+ margin-top: var(--spacing-md);
+ padding-top: var(--spacing-md);
+ border-top: 1px solid var(--border-light);
+}
+
+.summary-text p {
+ color: var(--text-secondary);
+ font-size: var(--font-size-sm);
+ line-height: 1.6;
+ margin: 0;
+}
+
+/* Insight Items */
+.insight-item {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm);
+ background: var(--bg-secondary);
+ border-radius: var(--border-radius);
+ border-left: 3px solid var(--brand-cyan);
+}
+
+.insight-icon {
+ color: var(--brand-cyan);
+ font-size: var(--font-size-lg);
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.insight-text {
+ color: var(--text-primary);
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+}
+
+/* Pattern Items */
+.pattern-item {
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: var(--bg-secondary);
+ border-radius: var(--border-radius);
+ border-left: 3px solid var(--info-color);
+}
+
+.pattern-name {
+ color: var(--text-primary);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ margin-bottom: var(--spacing-xs);
+}
+
+.pattern-confidence {
+ color: var(--text-secondary);
+ font-size: var(--font-size-xs);
+}
diff --git a/static/js/app.js b/static/js/app.js
new file mode 100644
index 0000000..b003ea7
--- /dev/null
+++ b/static/js/app.js
@@ -0,0 +1,1768 @@
+/**
+ * GUM (General User Models) - Frontend Application
+ * Modern JavaScript application for video analysis and user behavior insights
+ */
+
+class GUMApp {
+ constructor() {
+ // Use configuration from injected global variable or fallback to default
+ this.apiBaseUrl = window.GUM_CONFIG?.apiBaseUrl || 'http://localhost:8001';
+ console.log('GUM Frontend initialized with API base URL:', this.apiBaseUrl);
+
+ this.connectionStatus = 'connecting';
+ this.uploadProgress = 0;
+ this.currentStep = 1;
+ this.selectedFile = null;
+ this.toastTimeout = null;
+
+ // Propositions pagination
+ this.currentPropositionsPage = 1;
+
+ // Tab management
+ this.activeTab = 'upload';
+ // Theme management - handle migration from old theme key
+ let theme = localStorage.getItem('gum-theme');
+ if (!theme) {
+ // Default to light theme
+ theme = 'light';
+ localStorage.setItem('gum-theme', theme);
+ }
+ this.theme = theme;
+
+ this.init();
+ }
+
+ /**
+ * Initialize the application
+ */
+ async init() {
+ this.applyTheme();
+ this.setupEventListeners();
+ this.setupTabNavigation();
+ this.setupFileUpload();
+ this.setupProgressTracking();
+ this.setupPropositionsListeners();
+ this.setupQueryListeners();
+ await this.checkConnection();
+ this.updateConnectionStatus();
+ this.loadRecentHistory();
+ }
+
+ /**
+ * Apply theme to the application
+ */
+ applyTheme() {
+ document.documentElement.setAttribute('data-theme', this.theme);
+ const themeIcon = document.querySelector('#themeToggle i');
+ if (themeIcon) {
+ themeIcon.className = this.theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
+ }
+ }
+
+ /**
+ * Toggle between light and dark themes
+ */ toggleTheme() {
+ this.theme = this.theme === 'light' ? 'dark' : 'light';
+ localStorage.setItem('gum-theme', this.theme);
+ this.applyTheme();
+ this.showToast(`Switched to ${this.theme} mode`, 'info');
+ }
+
+ /**
+ * Setup all event listeners
+ */
+ setupEventListeners() {
+ // Theme toggle
+ const themeToggle = document.getElementById('themeToggle');
+ if (themeToggle) {
+ themeToggle.addEventListener('click', () => this.toggleTheme());
+ }
+
+ // Video form submission
+ const videoForm = document.getElementById('videoForm');
+ if (videoForm) {
+ videoForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+ this.submitVideoAnalysis();
+ });
+ }
+
+ // Action buttons
+ const exportBtn = document.getElementById('exportBtn');
+ if (exportBtn) {
+ exportBtn.addEventListener('click', () => this.exportResults());
+ }
+
+ const newAnalysisBtn = document.getElementById('newAnalysisBtn');
+ if (newAnalysisBtn) {
+ newAnalysisBtn.addEventListener('click', () => this.startNewAnalysis());
+ }
+
+ const refreshHistory = document.getElementById('refreshHistory');
+ if (refreshHistory) {
+ refreshHistory.addEventListener('click', () => this.loadRecentHistory());
+ }
+
+ // Remove file button
+ const removeFile = document.getElementById('removeFile');
+ if (removeFile) {
+ removeFile.addEventListener('click', () => this.removeSelectedFile());
+ }
+
+ // Database cleanup button
+ const cleanupDatabase = document.getElementById('cleanupDatabase');
+ if (cleanupDatabase) {
+ cleanupDatabase.addEventListener('click', () => this.handleDatabaseCleanup());
+ }
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', (e) => {
+ if (e.ctrlKey || e.metaKey) {
+ switch (e.key) {
+ case 'u':
+ e.preventDefault();
+ this.triggerFileUpload();
+ break;
+ case 'n':
+ e.preventDefault();
+ this.startNewAnalysis();
+ break;
+ }
+ }
+ });
+
+ // Progress bar on scroll
+ window.addEventListener('scroll', () => this.updateScrollProgress());
+ }
+
+ /**
+ * Setup file upload functionality
+ */
+ setupFileUpload() {
+ const uploadZone = document.getElementById('uploadZone');
+ const fileInput = document.getElementById('videoFile');
+ const uploadBtn = document.getElementById('uploadBtn');
+
+ if (!uploadZone || !fileInput) return;
+
+ // Click to upload
+ uploadZone.addEventListener('click', () => {
+ fileInput.click();
+ });
+
+ // File selection
+ fileInput.addEventListener('change', (e) => {
+ if (e.target.files.length > 0) {
+ this.handleFileSelection(e.target.files[0]);
+ }
+ });
+
+ // Drag and drop
+ uploadZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ uploadZone.classList.add('dragover');
+ });
+
+ uploadZone.addEventListener('dragleave', (e) => {
+ if (!uploadZone.contains(e.relatedTarget)) {
+ uploadZone.classList.remove('dragover');
+ }
+ });
+
+ uploadZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ uploadZone.classList.remove('dragover');
+
+ if (e.dataTransfer.files.length > 0) {
+ this.handleFileSelection(e.dataTransfer.files[0]);
+ }
+ });
+ }
+
+ /**
+ * Handle file selection and validation
+ */
+ handleFileSelection(file) {
+ // Validate file type
+ const allowedTypes = ['video/mp4', 'video/avi', 'video/mov', 'video/wmv', 'video/quicktime'];
+ if (!allowedTypes.includes(file.type)) {
+ this.showToast('Please select a valid video file (MP4, AVI, MOV, WMV)', 'error');
+ return;
+ }
+
+ // Validate file size (max 500MB)
+ const maxSize = 500 * 1024 * 1024; // 500MB
+ if (file.size > maxSize) {
+ this.showToast('File size too large. Maximum size is 500MB.', 'error');
+ return;
+ }
+
+ this.selectedFile = file;
+ this.displayFilePreview(file);
+ this.enableUploadButton();
+ }
+
+ /**
+ * Display file preview
+ */
+ displayFilePreview(file) {
+ const uploadZoneContent = document.getElementById('uploadZoneContent');
+ const filePreview = document.getElementById('filePreview');
+ const fileName = document.getElementById('fileName');
+ const fileSize = document.getElementById('fileSize');
+ const fileDuration = document.getElementById('fileDuration');
+ const previewVideo = document.getElementById('previewVideo');
+
+ if (!uploadZoneContent || !filePreview) return;
+
+ // Hide upload zone, show preview
+ uploadZoneContent.style.display = 'none';
+ filePreview.style.display = 'block';
+
+ // Set file info
+ if (fileName) fileName.textContent = file.name;
+ if (fileSize) fileSize.textContent = this.formatFileSize(file.size);
+
+ // Create video URL and set duration
+ if (previewVideo) {
+ const videoUrl = URL.createObjectURL(file);
+ previewVideo.src = videoUrl;
+
+ previewVideo.addEventListener('loadedmetadata', () => {
+ if (fileDuration) {
+ fileDuration.textContent = this.formatDuration(previewVideo.duration);
+ }
+ });
+ }
+ }
+
+ /**
+ * Remove selected file
+ */
+ removeSelectedFile() {
+ this.selectedFile = null;
+
+ const uploadZoneContent = document.getElementById('uploadZoneContent');
+ const filePreview = document.getElementById('filePreview');
+ const previewVideo = document.getElementById('previewVideo');
+ const fileInput = document.getElementById('videoFile');
+
+ if (uploadZoneContent) uploadZoneContent.style.display = 'block';
+ if (filePreview) filePreview.style.display = 'none';
+ if (previewVideo) {
+ URL.revokeObjectURL(previewVideo.src);
+ previewVideo.src = '';
+ }
+ if (fileInput) fileInput.value = '';
+
+ this.disableUploadButton();
+ }
+
+ /**
+ * Enable upload button
+ */
+ enableUploadButton() {
+ const uploadBtn = document.getElementById('uploadBtn');
+ if (uploadBtn) {
+ uploadBtn.disabled = false;
+ }
+ }
+
+ /**
+ * Disable upload button
+ */
+ disableUploadButton() {
+ const uploadBtn = document.getElementById('uploadBtn');
+ if (uploadBtn) {
+ uploadBtn.disabled = true;
+ }
+ }
+
+ /**
+ * Trigger file upload dialog
+ */
+ triggerFileUpload() {
+ const fileInput = document.getElementById('videoFile');
+ if (fileInput) {
+ fileInput.click();
+ }
+ }
+
+ /**
+ * Setup progress tracking
+ */
+ setupProgressTracking() {
+ this.progressSteps = [
+ { id: 'step1', name: 'Uploading' },
+ { id: 'step2', name: 'Processing' },
+ { id: 'step3', name: 'Analyzing' },
+ { id: 'step4', name: 'Complete' }
+ ];
+ }
+
+ /**
+ * Update progress step
+ */
+ updateProgressStep(stepNumber, progress = 0) {
+ this.currentStep = stepNumber;
+
+ // Update progress bar
+ const progressFill = document.getElementById('uploadProgress');
+ if (progressFill) {
+ const totalProgress = ((stepNumber - 1) * 25) + (progress * 25 / 100);
+ progressFill.style.width = `${totalProgress}%`;
+ }
+
+ // Update progress text
+ const progressText = document.getElementById('progressText');
+ const progressPercent = document.getElementById('progressPercent');
+
+ if (progressText && this.progressSteps[stepNumber - 1]) {
+ progressText.textContent = this.progressSteps[stepNumber - 1].name;
+ }
+
+ if (progressPercent) {
+ const totalProgress = ((stepNumber - 1) * 25) + (progress * 25 / 100);
+ progressPercent.textContent = `${Math.round(totalProgress)}%`;
+ }
+
+ // Update step indicators
+ this.progressSteps.forEach((step, index) => {
+ const stepElement = document.getElementById(step.id);
+ if (stepElement) {
+ stepElement.classList.remove('active', 'completed');
+
+ if (index + 1 < stepNumber) {
+ stepElement.classList.add('completed');
+ } else if (index + 1 === stepNumber) {
+ stepElement.classList.add('active');
+ }
+ }
+ });
+ }
+
+ /**
+ * Submit video for analysis
+ */
+ async submitVideoAnalysis() {
+ if (!this.selectedFile) {
+ this.showToast('Please select a video file first', 'error');
+ return;
+ }
+
+ if (this.connectionStatus !== 'connected') {
+ this.showToast('Cannot connect to analysis service. Please check connection.', 'error');
+ return;
+ }
+
+ // Get form data
+ const fps = document.getElementById('videoFps')?.value || 0.4;
+ const userName = document.getElementById('videoUserName')?.value.trim();
+ // Default analysis - send all analysis types
+ const analysisTypes = ['behavior', 'activity', 'workflow', 'productivity'];
+
+ // Show progress section
+ const progressSection = document.getElementById('progressSection');
+ if (progressSection) {
+ progressSection.style.display = 'block';
+ progressSection.scrollIntoView({ behavior: 'smooth' });
+ }
+
+ // Disable form
+ this.disableForm();
+
+ try {
+ // Step 1: Upload
+ this.updateProgressStep(1, 0);
+
+ const formData = new FormData();
+ formData.append('file', this.selectedFile);
+ formData.append('fps', fps);
+ if (userName) formData.append('user_name', userName);
+ formData.append('observer_name', 'web_interface');
+
+ // Create upload request with progress tracking
+ const response = await this.uploadWithProgress(formData);
+
+ if (response.ok) {
+ const result = await response.json();
+ this.updateProgressStep(4, 100);
+
+ // Show results
+ setTimeout(() => {
+ this.displayResults(result);
+ this.showToast('Video analysis completed successfully!', 'success');
+ }, 1000);
+
+ } else {
+ const error = await response.json();
+ throw new Error(error.detail || 'Analysis failed');
+ }
+
+ } catch (error) {
+ this.showToast(`Analysis failed: ${error.message}`, 'error');
+ this.hideProgressSection();
+ } finally {
+ this.enableForm();
+ }
+ }
+
+ /**
+ * Upload with progress tracking
+ */
+ async uploadWithProgress(formData) {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+
+ // Track upload progress
+ xhr.upload.addEventListener('progress', (e) => {
+ if (e.lengthComputable) {
+ const progress = (e.loaded / e.total) * 100;
+ this.updateProgressStep(1, progress);
+ }
+ });
+
+ // Handle state changes
+ xhr.addEventListener('readystatechange', () => {
+ if (xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status === 200) {
+ try {
+ const result = JSON.parse(xhr.responseText);
+ // Video uploaded successfully, now poll for processing status
+ if (result.job_id) {
+ this.updateProgressStep(2, 0);
+ this.pollJobStatus(result.job_id)
+ .then((finalResult) => resolve({ ok: true, json: () => Promise.resolve(finalResult) }))
+ .catch(reject);
+ } else {
+ reject(new Error('No job ID received from server'));
+ }
+ } catch (e) {
+ reject(new Error('Invalid response format'));
+ }
+ } else {
+ try {
+ const error = JSON.parse(xhr.responseText);
+ reject(new Error(error.detail || `HTTP ${xhr.status}: ${xhr.statusText}`));
+ } catch (e) {
+ reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
+ }
+ }
+ }
+ });
+
+ xhr.addEventListener('error', () => {
+ reject(new Error('Network error during upload'));
+ });
+
+ xhr.open('POST', `${this.apiBaseUrl}/observations/video`);
+ xhr.send(formData);
+ });
+ }
+
+ /**
+ * Simulate processing progress for better UX
+ */
+ async simulateProcessingProgress() {
+ // Step 2: Processing
+ for (let i = 0; i <= 100; i += 10) {
+ this.updateProgressStep(2, i);
+ await this.delay(200);
+ }
+
+ // Step 3: Analyzing
+ for (let i = 0; i <= 100; i += 5) {
+ this.updateProgressStep(3, i);
+ await this.delay(150);
+ }
+ }
+
+ /**
+ * Poll job status for video processing
+ */
+ async pollJobStatus(jobId) {
+ // Removed timeout limit - will poll indefinitely until completion or genuine error
+ while (true) {
+ try {
+ const response = await fetch(`${this.apiBaseUrl}/observations/video/status/${jobId}`);
+
+ if (response.ok) {
+ const status = await response.json();
+
+ // Update progress based on status
+ if (status.status === 'processing' || status.status === 'processing_frames') {
+ this.updateProgressStep(2, status.progress || 0);
+ } else if (status.status === 'analyzing') {
+ this.updateProgressStep(3, status.progress || 0);
+ }
+ if (status.status === 'completed') {
+ // Processing complete - now fetch insights
+ this.updateProgressStep(3, 100);
+ try {
+ const insightsResponse = await fetch(`${this.apiBaseUrl}/observations/video/${jobId}/insights`);
+ let insights = [];
+ let patterns = [];
+ let summary = '';
+
+ if (insightsResponse.ok) {
+ const insightsData = await insightsResponse.json();
+ insights = insightsData.key_insights || [];
+ patterns = insightsData.behavior_patterns || [];
+ summary = insightsData.summary || '';
+ }
+
+ return {
+ success: true,
+ frames_analyzed: status.total_frames || 0,
+ processing_time_ms: status.processing_time_ms || 0,
+ insights: insights,
+ patterns: patterns,
+ summary: summary,
+ analyses: status.frame_analyses || []
+ };
+ } catch (insightsError) {
+ console.warn('Failed to fetch insights:', insightsError);
+ // Return basic results without insights
+ return {
+ success: true,
+ frames_analyzed: status.total_frames || 0,
+ processing_time_ms: status.processing_time_ms || 0,
+ insights: ['Analysis completed successfully'],
+ patterns: ['Basic processing pattern identified'],
+ summary: 'Video analysis completed',
+ analyses: status.frame_analyses || []
+ };
+ }
+ } else if (status.status === 'error') {
+ throw new Error(status.error || 'Processing failed');
+ }
+ } else {
+ throw new Error(`Status check failed: ${response.status}`);
+ }
+
+ // Wait before next poll
+ await this.delay(2000);
+
+ } catch (error) {
+ throw new Error(`Status polling failed: ${error.message}`);
+ }
+ }
+ }
+
+ /**
+ * Display analysis results
+ */
+ displayResults(results) {
+ const resultsSection = document.getElementById('resultsSection');
+ const resultsContent = document.getElementById('resultsContent');
+
+ if (!resultsSection || !resultsContent) return;
+
+ // Show results section
+ resultsSection.style.display = 'block';
+
+ // Create results HTML
+ const resultsHtml = this.generateResultsHTML(results);
+ resultsContent.innerHTML = resultsHtml;
+
+ // Scroll to results
+ resultsSection.scrollIntoView({ behavior: 'smooth' });
+
+ // Store results for export
+ this.currentResults = results;
+ }
+
+ /**
+ * Generate results HTML
+ */ generateResultsHTML(results) {
+ return `
+
+
+
+
+
+
+ Processing Time
+ ${results.processing_time_ms?.toFixed(1) || 'N/A'}ms
+
+
+ Frames Analyzed
+ ${results.frames_analyzed || 'N/A'}
+
+
+ Status
+ Completed
+
+
+ ${results.summary ? `
` : ''}
+
+
+
+
+
+
+
+ ${this.generateInsightsList(results.insights || [])}
+
+
+
+
+
+
+
+
+ ${this.generatePatternsList(results.patterns || [])}
+
+
+
+
+
+
+
+
${JSON.stringify(results, null, 2)}
+
+
+
+ `;
+ }
+
+ /**
+ * Generate insights list HTML
+ */
+ generateInsightsList(insights) {
+ if (!insights.length) {
+ return 'No insights available
';
+ }
+
+ return insights.map(insight => `
+
+ `).join('');
+ } /**
+ * Generate patterns list HTML
+ */
+ generatePatternsList(patterns) {
+ if (!patterns.length) {
+ return 'No patterns identified
';
+ }
+
+ return patterns.map(pattern => {
+ // Handle both string patterns and object patterns
+ if (typeof pattern === 'string') {
+ return `
+
+ `;
+ } else {
+ return `
+
+
${pattern.name || 'Unknown Pattern'}
+
+ Confidence: ${((pattern.confidence || 0) * 100).toFixed(1)}%
+
+
+ `;
+ }
+ }).join('');
+ }
+
+ /**
+ * Export results
+ */
+ exportResults() {
+ if (!this.currentResults) {
+ this.showToast('No results to export', 'warning');
+ return;
+ } const dataStr = JSON.stringify(this.currentResults, null, 2);
+ const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
+
+ const exportFileDefaultName = `gum-analysis-${new Date().toISOString().split('T')[0]}.json`;
+
+ const linkElement = document.createElement('a');
+ linkElement.setAttribute('href', dataUri);
+ linkElement.setAttribute('download', exportFileDefaultName);
+ linkElement.click();
+
+ this.showToast('Results exported successfully', 'success');
+ }
+
+ /**
+ * Start new analysis
+ */
+ startNewAnalysis() {
+ // Reset form
+ this.removeSelectedFile();
+
+ // Hide results and progress
+ const resultsSection = document.getElementById('resultsSection');
+ const progressSection = document.getElementById('progressSection');
+
+ if (resultsSection) resultsSection.style.display = 'none';
+ if (progressSection) progressSection.style.display = 'none';
+
+ // Reset progress
+ this.currentStep = 1;
+ this.uploadProgress = 0;
+ this.currentResults = null;
+
+ // Scroll to top
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+
+ this.showToast('Ready for new analysis', 'info');
+ }
+
+ /**
+ * Handle database cleanup with confirmation
+ */
+ async handleDatabaseCleanup() {
+ // Show confirmation dialog
+ const confirmed = confirm(
+ 'Are you sure you want to clean the entire database?\n\n' +
+ 'This will permanently delete:\n' +
+ '• All observations\n' +
+ '• All propositions\n' +
+ '• All insights\n' +
+ '• All analysis data\n\n' +
+ 'This action cannot be undone!'
+ );
+
+ if (!confirmed) {
+ return;
+ }
+
+ const cleanupBtn = document.getElementById('cleanupDatabase');
+ if (cleanupBtn) {
+ cleanupBtn.disabled = true;
+ cleanupBtn.innerHTML = ' Cleaning...';
+ }
+
+ try {
+ const response = await fetch(`${this.apiBaseUrl}/database/cleanup`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || 'Failed to clean database');
+ }
+
+ const result = await response.json();
+
+ // Show success message with deletion counts
+ const message = `Database cleaned successfully!\n\n` +
+ `Deleted:\n` +
+ `• ${result.observations_deleted} observations\n` +
+ `• ${result.propositions_deleted} propositions\n` +
+ `• ${result.junction_records_deleted} junction records\n` +
+ `• FTS indexes cleared`;
+
+ this.showToast('Database cleaned successfully!', 'success');
+
+ // Optional: Show detailed results in an alert
+ alert(message);
+
+ // Refresh any displayed data
+ this.loadRecentHistory();
+
+ // Reset any current results
+ this.currentResults = null;
+ const resultsSection = document.getElementById('resultsSection');
+ if (resultsSection) {
+ resultsSection.style.display = 'none';
+ }
+
+ } catch (error) {
+ console.error('Database cleanup failed:', error);
+ this.showToast(`Database cleanup failed: ${error.message}`, 'error');
+ } finally {
+ // Restore button state
+ if (cleanupBtn) {
+ cleanupBtn.disabled = false;
+ cleanupBtn.innerHTML = ' Clean Database';
+ }
+ }
+ }
+
+ /**
+ * Hide progress section
+ */
+ hideProgressSection() {
+ const progressSection = document.getElementById('progressSection');
+ if (progressSection) {
+ progressSection.style.display = 'none';
+ }
+ }
+
+ /**
+ * Disable form during upload
+ */
+ disableForm() {
+ const form = document.getElementById('videoForm');
+ if (form) {
+ const inputs = form.querySelectorAll('input, button, select');
+ inputs.forEach(input => input.disabled = true);
+ }
+ }
+
+ /**
+ * Enable form after upload
+ */
+ enableForm() {
+ const form = document.getElementById('videoForm');
+ if (form) {
+ const inputs = form.querySelectorAll('input, button, select');
+ inputs.forEach(input => input.disabled = false);
+ }
+
+ // Keep upload button disabled if no file selected
+ if (!this.selectedFile) {
+ this.disableUploadButton();
+ }
+ }
+
+ /**
+ * Load recent analysis history
+ */
+ async loadRecentHistory() {
+ try {
+ const response = await fetch(`${this.apiBaseUrl}/observations?limit=5`);
+ if (response.ok) {
+ const history = await response.json();
+ this.displayHistory(history || []);
+ }
+ } catch (error) {
+ console.warn('Could not load history:', error.message);
+ }
+ }
+
+ /**
+ * Display history
+ */
+ displayHistory(historyItems) {
+ const historyContent = document.getElementById('historyContent');
+ if (!historyContent) return;
+
+ if (!historyItems.length) {
+ historyContent.innerHTML = 'No recent analyses found
';
+ return;
+ }
+
+ const historyHtml = historyItems.map(item => `
+
+
+
+ Observer: ${item.observer_name || 'Unknown'}
+ Type: ${item.content_type}
+
+
+ ${item.content}
+
+
+ `).join('');
+
+ historyContent.innerHTML = historyHtml;
+ }
+
+ /**
+ * Check API connection
+ */
+ async checkConnection() {
+ try {
+ const response = await fetch(`${this.apiBaseUrl}/health`, {
+ timeout: 5000
+ });
+
+ if (response.ok) {
+ const health = await response.json();
+ this.connectionStatus = health.gum_connected ? 'connected' : 'disconnected';
+ } else {
+ this.connectionStatus = 'disconnected';
+ }
+ } catch (error) {
+ this.connectionStatus = 'disconnected';
+ }
+ }
+
+ /**
+ * Update connection status display
+ */
+ updateConnectionStatus() {
+ const statusElement = document.getElementById('connectionStatus');
+ if (!statusElement) return;
+
+ const statusText = {
+ 'connected': 'Connected',
+ 'disconnected': 'Disconnected',
+ 'connecting': 'Connecting...'
+ };
+
+ // Hide the connection status when connected, only show when there are issues
+ if (this.connectionStatus === 'connected') {
+ statusElement.style.display = 'none';
+ } else {
+ statusElement.style.display = 'flex';
+ statusElement.className = `connection-status ${this.connectionStatus}`;
+ const span = statusElement.querySelector('span');
+ if (span) {
+ span.textContent = statusText[this.connectionStatus];
+ }
+ }
+
+ // Show warning if disconnected
+ if (this.connectionStatus === 'disconnected') {
+ this.showToast('Cannot connect to analysis service. Please check if the controller is running.', 'error');
+ }
+ }
+
+ /**
+ * Update scroll progress
+ */
+ updateScrollProgress() {
+ const scrollTop = document.documentElement.scrollTop;
+ const scrollHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
+ const progress = (scrollTop / scrollHeight) * 100;
+
+ const progressBar = document.getElementById('progressBar');
+ if (progressBar) {
+ progressBar.style.width = `${progress}%`;
+ }
+ } /**
+ * Show toast notification
+ */
+ showToast(message, type = 'info') {
+ const toast = document.getElementById('toast');
+ if (!toast) return;
+
+ // Clear any existing timeout
+ if (this.toastTimeout) {
+ clearTimeout(this.toastTimeout);
+ }
+
+ // Get icon for toast type
+ const icons = {
+ success: 'fas fa-check-circle',
+ error: 'fas fa-exclamation-circle',
+ warning: 'fas fa-exclamation-triangle',
+ info: 'fas fa-info-circle'
+ };
+
+ // Set toast content with icon
+ toast.innerHTML = `
+
+ ${message}
+ `;
+
+ toast.className = `toast ${type} show`;
+
+ // Auto hide after 6 seconds (longer for better readability)
+ this.toastTimeout = setTimeout(() => {
+ toast.classList.remove('show');
+ }, 6000);
+ }
+
+ /**
+ * Utility: Format file size
+ */
+ formatFileSize(bytes) {
+ if (bytes === 0) return '0 Bytes';
+
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ /**
+ * Utility: Format duration
+ */
+ formatDuration(seconds) {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ } else {
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
+ }
+ }
+
+ /**
+ * Utility: Delay function
+ */
+ delay(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+
+ // ===== PROPOSITIONS FUNCTIONALITY =====
+
+ /**
+ * Setup propositions event listeners
+ */
+ setupPropositionsListeners() {
+ const loadPropositionsBtn = document.getElementById('loadPropositions');
+ const confidenceFilter = document.getElementById('confidenceFilter');
+ const sortBySelect = document.getElementById('sortBy');
+ const prevBtn = document.getElementById('prevPropositions');
+ const nextBtn = document.getElementById('nextPropositions');
+
+ if (loadPropositionsBtn) {
+ loadPropositionsBtn.addEventListener('click', () => {
+ this.currentPropositionsPage = 1;
+ this.loadPropositions();
+ });
+ }
+
+ if (confidenceFilter) {
+ confidenceFilter.addEventListener('change', () => {
+ this.currentPropositionsPage = 1;
+ this.loadPropositions();
+ });
+ }
+
+ if (sortBySelect) {
+ sortBySelect.addEventListener('change', () => {
+ this.currentPropositionsPage = 1;
+ this.loadPropositions();
+ });
+ }
+
+ if (prevBtn) {
+ prevBtn.addEventListener('click', () => {
+ if (this.currentPropositionsPage > 1) {
+ this.currentPropositionsPage--;
+ this.loadPropositions();
+ }
+ });
+ }
+
+ if (nextBtn) {
+ nextBtn.addEventListener('click', () => {
+ this.currentPropositionsPage++;
+ this.loadPropositions();
+ });
+ }
+ }
+
+ /**
+ * Load propositions from the API
+ */
+ async loadPropositions() {
+ const loadBtn = document.getElementById('loadPropositions');
+ const contentContainer = document.getElementById('propositionsContent');
+ const statsContainer = document.getElementById('propositionsStats');
+ const paginationContainer = document.getElementById('propositionsPagination');
+
+ if (!contentContainer) return;
+
+ try {
+ // Show loading state
+ if (loadBtn) {
+ loadBtn.disabled = true;
+ loadBtn.innerHTML = ' Loading...';
+ }
+
+ // Get filter values
+ const confidenceMin = document.getElementById('confidenceFilter')?.value || null;
+ const sortBy = document.getElementById('sortBy')?.value || 'created_at';
+ const limit = 20;
+ const offset = (this.currentPropositionsPage - 1) * limit;
+
+ // Build query parameters
+ const params = new URLSearchParams({
+ limit: limit.toString(),
+ offset: offset.toString(),
+ sort_by: sortBy
+ });
+
+ if (confidenceMin) {
+ params.append('confidence_min', confidenceMin);
+ }
+
+ // Fetch propositions
+ const response = await fetch(`${this.apiBaseUrl}/propositions?${params}`);
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const propositions = await response.json();
+
+ // Fetch count for stats
+ const countParams = new URLSearchParams();
+ if (confidenceMin) {
+ countParams.append('confidence_min', confidenceMin);
+ }
+
+ const countResponse = await fetch(`${this.apiBaseUrl}/propositions/count?${countParams}`);
+ const countData = await countResponse.json();
+
+ // Display results
+ this.displayPropositions(propositions, countData);
+ this.updatePropositionsPagination(propositions.length, limit);
+
+ this.showToast(`Loaded ${propositions.length} insights`, 'success');
+
+ } catch (error) {
+ console.error('Error loading propositions:', error);
+ this.showToast(`Failed to load insights: ${error.message}`, 'error');
+ this.displayEmptyPropositions();
+ } finally {
+ // Reset button state
+ if (loadBtn) {
+ loadBtn.disabled = false;
+ loadBtn.innerHTML = ' Load Insights';
+ }
+ }
+ }
+
+ /**
+ * Display propositions in the UI
+ */
+ displayPropositions(propositions, countData) {
+ const contentContainer = document.getElementById('propositionsContent');
+ const statsContainer = document.getElementById('propositionsStats');
+
+ if (!contentContainer) return;
+
+ // Show stats
+ if (statsContainer && countData) {
+ statsContainer.style.display = 'flex';
+ statsContainer.innerHTML = `
+
+
+ Total: ${countData.total_propositions} insights
+
+
+
+ Showing: ${propositions.length} results
+
+ ${countData.confidence_filter ? `
+
+
+ Min confidence: ${countData.confidence_filter}
+
+ ` : ''}
+ `;
+ }
+
+ // Display propositions
+ if (propositions.length === 0) {
+ this.displayEmptyPropositions();
+ return;
+ }
+
+ contentContainer.innerHTML = propositions.map((prop, index) =>
+ this.createPropositionCard(prop, index)
+ ).join('');
+ }
+
+ /**
+ * Create a proposition card HTML
+ */
+ createPropositionCard(proposition, index) {
+ const confidence = proposition.confidence;
+ const confidenceClass = this.getConfidenceClass(confidence);
+ const confidenceLabel = this.getConfidenceLabel(confidence);
+
+ const createdDate = new Date(proposition.created_at);
+ const formattedDate = createdDate.toLocaleDateString() + ' ' +
+ createdDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
+
+ return `
+
+
+
+
+ ${this.escapeHtml(proposition.text)}
+
+
+ ${proposition.reasoning ? `
+
+ Reasoning: ${this.escapeHtml(proposition.reasoning)}
+
+ ` : ''}
+
+
+
+ `;
+ }
+
+ /**
+ * Get confidence CSS class
+ */
+ getConfidenceClass(confidence) {
+ if (!confidence) return 'confidence-none';
+ if (confidence >= 8) return 'confidence-high';
+ if (confidence >= 6) return 'confidence-medium';
+ return 'confidence-low';
+ }
+
+ /**
+ * Get confidence label
+ */
+ getConfidenceLabel(confidence) {
+ if (!confidence) return 'No confidence';
+ return `${confidence}/10`;
+ }
+
+ /**
+ * Display empty state for propositions
+ */
+ displayEmptyPropositions() {
+ const contentContainer = document.getElementById('propositionsContent');
+ const statsContainer = document.getElementById('propositionsStats');
+
+ if (statsContainer) {
+ statsContainer.style.display = 'none';
+ }
+
+ if (contentContainer) {
+ contentContainer.innerHTML = `
+
+
+
No insights found
+
No propositions match your current filters. Try adjusting the confidence level or submit more observations.
+
+ `;
+ }
+ }
+
+ /**
+ * Update pagination controls
+ */
+ updatePropositionsPagination(resultCount, limit) {
+ const paginationContainer = document.getElementById('propositionsPagination');
+ const prevBtn = document.getElementById('prevPropositions');
+ const nextBtn = document.getElementById('nextPropositions');
+ const pageInfo = document.getElementById('propositionsPageInfo');
+
+ if (!paginationContainer) return;
+
+ // Show pagination if we have results
+ if (resultCount > 0) {
+ paginationContainer.style.display = 'flex';
+
+ // Update page info
+ if (pageInfo) {
+ pageInfo.textContent = `Page ${this.currentPropositionsPage}`;
+ }
+
+ // Update button states
+ if (prevBtn) {
+ prevBtn.disabled = this.currentPropositionsPage <= 1;
+ }
+
+ if (nextBtn) {
+ nextBtn.disabled = resultCount < limit;
+ }
+ } else {
+ paginationContainer.style.display = 'none';
+ }
+ }
+
+ /**
+ * Escape HTML to prevent XSS
+ */
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ // ===== TAB NAVIGATION FUNCTIONALITY =====
+
+ /**
+ * Setup tab navigation event listeners
+ */
+ setupTabNavigation() {
+ const tabButtons = document.querySelectorAll('.tab-button');
+
+ tabButtons.forEach(button => {
+ button.addEventListener('click', (e) => {
+ const tabId = button.getAttribute('data-tab');
+ this.switchTab(tabId);
+ });
+ });
+ }
+
+ /**
+ * Switch to a specific tab
+ */
+ switchTab(tabId) {
+ // Remove active class from all tabs and panels
+ document.querySelectorAll('.tab-button').forEach(btn => {
+ btn.classList.remove('active');
+ btn.setAttribute('aria-selected', 'false');
+ });
+
+ document.querySelectorAll('.tab-panel').forEach(panel => {
+ panel.classList.remove('active');
+ });
+
+ // Add active class to selected tab and panel
+ const activeButton = document.querySelector(`[data-tab="${tabId}"]`);
+ const activePanel = document.getElementById(`${tabId}-panel`);
+
+ if (activeButton && activePanel) {
+ activeButton.classList.add('active');
+ activeButton.setAttribute('aria-selected', 'true');
+ activePanel.classList.add('active');
+ this.activeTab = tabId;
+
+ // Load content for specific tabs when activated
+ if (tabId === 'analysis') {
+ this.loadRecentHistory();
+ } else if (tabId === 'insights') {
+ // Insights will be loaded when user clicks "Load Insights"
+ } else if (tabId === 'query') {
+ this.focusQueryInput();
+ }
+ }
+ }
+
+ /**
+ * Focus the query input when query tab is activated
+ */
+ focusQueryInput() {
+ const queryInput = document.getElementById('queryInput');
+ if (queryInput) {
+ setTimeout(() => queryInput.focus(), 100);
+ }
+ }
+
+ // ===== QUERY FUNCTIONALITY =====
+
+ /**
+ * Setup query event listeners
+ */
+ setupQueryListeners() {
+ const queryInput = document.getElementById('queryInput');
+ const querySearchBtn = document.getElementById('querySearchBtn');
+ const exampleQueries = document.querySelectorAll('.example-query');
+
+ if (queryInput) {
+ queryInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ this.executeQuery();
+ }
+ });
+ }
+
+ if (querySearchBtn) {
+ querySearchBtn.addEventListener('click', () => {
+ this.executeQuery();
+ });
+ }
+
+ exampleQueries.forEach(button => {
+ button.addEventListener('click', () => {
+ const query = button.getAttribute('data-query');
+ if (queryInput) {
+ queryInput.value = query;
+ this.executeQuery();
+ }
+ });
+ });
+ }
+
+ /**
+ * Execute a query against the insights
+ */
+ async executeQuery() {
+ const queryInput = document.getElementById('queryInput');
+ const resultsContainer = document.getElementById('queryResults');
+ const loadingOverlay = document.getElementById('queryLoading');
+
+ if (!queryInput || !resultsContainer) return;
+
+ const query = queryInput.value.trim();
+ if (!query) {
+ this.showToast('Please enter a search query', 'warning');
+ return;
+ }
+
+ try {
+ // Show loading state
+ if (loadingOverlay) {
+ loadingOverlay.style.display = 'flex';
+ }
+
+ // Get query parameters
+ const limit = document.getElementById('queryLimit')?.value || 10;
+ const mode = document.getElementById('queryMode')?.value || 'OR';
+ const userName = document.getElementById('queryUserName')?.value || null;
+
+ // Build request
+ const requestBody = {
+ query: query,
+ limit: parseInt(limit),
+ mode: mode
+ };
+
+ if (userName) {
+ requestBody.user_name = userName;
+ }
+
+ // Execute query
+ const response = await fetch(`${this.apiBaseUrl}/query`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody)
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const result = await response.json();
+
+ // Display results
+ this.displayQueryResults(result);
+ this.showToast(`Found ${result.total_results} insights in ${Math.round(result.execution_time_ms)}ms`, 'success');
+
+ } catch (error) {
+ console.error('Query execution failed:', error);
+ this.showToast(`Query failed: ${error.message}`, 'error');
+ this.displayQueryError(error.message);
+ } finally {
+ // Hide loading state
+ if (loadingOverlay) {
+ loadingOverlay.style.display = 'none';
+ }
+ }
+ }
+
+ /**
+ * Display query results in the UI
+ */
+ displayQueryResults(result) {
+ const resultsContainer = document.getElementById('queryResults');
+ if (!resultsContainer) return;
+
+ if (result.propositions.length === 0) {
+ resultsContainer.innerHTML = `
+
+
+
No insights found
+
No insights match your query "${this.escapeHtml(result.query)}". Try different keywords or broader terms.
+
+ `;
+ return;
+ }
+
+ // Create query stats
+ const statsHtml = `
+
+
+
+ Query: "${this.escapeHtml(result.query)}"
+
+
+
+ ${result.total_results} results found
+
+
+
+ ${Math.round(result.execution_time_ms)}ms
+
+
+ `;
+
+ // Create results HTML
+ const resultsHtml = result.propositions.map((prop, index) => {
+ const createdDate = new Date(prop.created_at);
+ const formattedDate = createdDate.toLocaleDateString() + ' ' +
+ createdDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
+
+ const confidence = prop.confidence;
+ const confidenceClass = this.getConfidenceClass(confidence);
+ const confidenceLabel = this.getConfidenceLabel(confidence);
+
+ return `
+
+
+
+
+ ${this.escapeHtml(prop.text)}
+
+
+ ${prop.reasoning ? `
+
+ Reasoning: ${this.escapeHtml(prop.reasoning)}
+
+ ` : ''}
+
+
+
+ `;
+ }).join('');
+
+ resultsContainer.innerHTML = statsHtml + resultsHtml;
+ }
+
+ /**
+ * Display query error state
+ */
+ displayQueryError(errorMessage) {
+ const resultsContainer = document.getElementById('queryResults');
+ if (!resultsContainer) return;
+
+ resultsContainer.innerHTML = `
+
+
+
Query Error
+
Failed to execute query: ${this.escapeHtml(errorMessage)}
+
Please check your connection and try again.
+
+ `;
+ }
+}
+
+// Initialize application when DOM is loaded
+document.addEventListener('DOMContentLoaded', () => {
+ window.gumApp = new GUMApp();
+});
+
+// Add CSS for results display dynamically
+const additionalCSS = `
+.results-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
+ gap: var(--spacing-lg);
+}
+
+.result-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-lg);
+ overflow: hidden;
+ box-shadow: var(--shadow-sm);
+}
+
+.result-card.full-width {
+ grid-column: 1 / -1;
+}
+
+.result-header {
+ background: linear-gradient(135deg, var(--primary-color), var(--primary-custom-tooltip-color));
+ color: var(--text-white);
+ padding: var(--spacing-lg);
+}
+
+.result-header h3 {
+ margin: 0;
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.result-content {
+ padding: var(--spacing-lg);
+}
+
+.summary-stats {
+ display: grid;
+ gap: var(--spacing-md);
+}
+
+.stat {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--spacing-sm) 0;
+ border-bottom: 1px solid var(--border-light);
+}
+
+.stat:last-child {
+ border-bottom: none;
+}
+
+.stat-label {
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.stat-value {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.stat-value.success {
+ color: var(--success-color);
+}
+
+.insights-list, .patterns-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md);
+}
+
+.insight-item, .pattern-item {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm);
+ background: var(--bg-secondary);
+ border-radius: var(--border-radius);
+}
+
+.insight-icon {
+ color: var(--primary-color);
+ font-size: var(--font-size-lg);
+ flex-shrink: 0;
+}
+
+.insight-text {
+ flex: 1;
+ color: var(--text-primary);
+}
+
+.pattern-name {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.pattern-confidence {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+}
+
+.analysis-details {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-light);
+ border-radius: var(--border-radius);
+ padding: var(--spacing-lg);
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ color: var(--text-primary);
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow-x: auto;
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+.history-item {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-light);
+ border-radius: var(--border-radius);
+ padding: var(--spacing-lg);
+ margin-bottom: var(--spacing-md);
+}
+
+.history-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--spacing-sm);
+}
+
+.history-title {
+ font-weight: 600;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.history-date {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+}
+
+.history-details {
+ display: flex;
+ gap: var(--spacing-lg);
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ margin-bottom: var(--spacing-sm);
+}
+
+.history-content {
+ font-size: var(--font-size-sm);
+ color: var(--text-primary);
+ line-height: 1.4;
+}
+
+.no-data {
+ text-align: center;
+ color: var(--text-muted);
+ font-style: italic;
+ padding: var(--spacing-xl);
+}
+
+@media (max-width: 768px) {
+ .results-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .history-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--spacing-sm);
+ }
+
+ .history-details {
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ }
+}
+`;
+
+// Inject additional CSS
+const style = document.createElement('style');
+style.textContent = additionalCSS;
+document.head.appendChild(style);