diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..0b4e4aa8a8dd6108aee1d1d4bc49d4fc20ccc3ce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,60 @@ +# .dockerignore + +.agents +.vscode +scripts +venv +.env +opencode.json +skills-lock.json + +.codex/environments/environment.toml +pytest.ini +.venv/ +.idea/ +temp.py +*.log +.debug/ + + +/tests/ +/backend/tests/ +.pytest_cache/ +coverage.xml +.coverage +htmlcov/ +test-results/ +playwright-report/ +.VSCodeCounter/ + +# Generated simulation runtime state. The live server recreates these on a +# fresh run; static environments, personas, and event definitions stay tracked. + +/backend/data/Short_term_db/ +/backend/data/checkpoints/ +/backend/data/history/ + +# Legacy JSON long-term-memory exports are produced only by the optional +# migration utility. The live simulator uses Qdrant instead. + +/backend/data/Long_term_db/ +/backend/output/ + + +# Frontend dependencies and build output + +/frontend/node_modules/ +/frontend/dist/ + + +# Python bytecode and interpreter caches +__pycache__/ + +*.py[cod] + +# Local operating-system and tool residue + +.DS_Store +Thumbs.db +*.pid +*.tmp diff --git a/.env.local b/.env.local new file mode 100644 index 0000000000000000000000000000000000000000..cdd77cabe927fd89f0d6218ce2941250d86db61b --- /dev/null +++ b/.env.local @@ -0,0 +1,80 @@ +# ============================================================================ +# API Keys +# ============================================================================ +GEMINI_API_KEY_1= +GEMINI_API_KEY_2= +GEMINI_API_KEY_3= +GEMINI_API_KEY_4= +| | | +| | | +GEMINI_API_KEY_15= +GEMINI_API_KEY_16= +GEMINI_API_KEY_17= +GEMINI_API_KEY_18= + +# ============================================================================ +# Simulation clock +# SIM_REAL_SECONDS_PER_SIM_MINUTE = wall-clock seconds per 1 sim-minute. +# SIM_TICK_SPEED = speed multiplier (2.0 = 2x faster). +# Default: 7.5 sec/sim-min => 1440 * 7.5 = 10,800 sec = 3 real hours per sim day. +# ============================================================================ +SIM_REAL_SECONDS_PER_SIM_MINUTE=4 +SIM_TICK_SPEED=1.0 +SIM_MINUTES_PER_TICK=1 + +# Which simulation day to run (YYYY-MM-DD). Plans/memory are looked up per date. +SIM_START_DATE=2026-07-03 +# Time of day to start at (HH:MM). 00:00 = agents asleep at midnight; use e.g. +# 08:00 to begin during the day and see students moving to classes right away. +SIM_START_TIME=00:00 + +# ============================================================================ +# LLM Budget +# ============================================================================ +SIM_CREATIVITY=1.1 +SIM_WELLBEING_VARIABILITY=1 + +# Soft ceiling on AI calls per real hour (0 = no limit). Above it, cognition +# degrades gracefully and the sim keeps running on its free path. +SIM_LLM_HOURLY_CEILING=0 + +# ============================================================================ +# Spatial awareness +# ============================================================================ +# Pixel radius for perception (who an agent sees). +SIM_PERCEPTION_RADIUS_PX=50 +# Agents must be within this many pixels to start a conversation (tighter). +SIM_CONVERSATION_RADIUS_PX=20 +SIM_PERCEPTION_ENABLED=true +# Run perception only every N ticks (1 = every tick). Higher = lighter CPU. +SIM_PERCEPTION_EVERY_N_TICKS=3 + +# ============================================================================ +# Memory +# ============================================================================ +# Long-term memory backend: "keyword" (offline, default) or "vector" (needs +# qdrant-client installed; falls back to keyword automatically if unavailable). +SIM_MEMORY_BACKEND=vector +SIM_SEMANTIC_MEMORY_ENABLED=true + +QDRANT_URL= +QDRANT_API_KEY= + +SIM_MEMORY_EMBEDDING_MODEL=gemini-embedding-001 +SIM_MEMORY_VECTOR_DIMENSIONS=768 +SIM_MEMORY_COLLECTION_VERSION=v1 + +# ============================================================================ +# Budget guards +# ============================================================================ +SIM_MAX_CONVERSATIONS_PER_AGENT=5 +SIM_MAX_REPLANS_PER_AGENT_PER_DAY=3 + +# ============================================================================ +# Energy/Emotion thresholds for LLM calls +# ============================================================================ +SIM_DECIDE_MIN_ENERGY=0.25 +SIM_DECIDE_MIN_EMOTION=0.25 +SIM_CONVERSATION_MIN_ENERGY=0.25 +SIM_CONVERSATION_MIN_EMOTION=0.25 + diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index a6344aac8c09253b3b630fb776ae94478aa0275b..0000000000000000000000000000000000000000 --- a/.gitattributes +++ /dev/null @@ -1,35 +0,0 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index b512c09d476623ff4bf8d0d63c29b784925dbdf8..8255a61fbffcd442f4ea906d6b4b85ee45bd7fb1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,54 @@ -node_modules \ No newline at end of file +.agents +.vscode +scripts +venv +.env +opencode.json +skills-lock.json + +.codex/environments/environment.toml +pytest.ini +.venv/ +.idea/ +temp.py +*.log +.debug/ + + +/tests/ +/backend/tests/ +.pytest_cache/ +coverage.xml +.coverage +htmlcov/ +test-results/ +playwright-report/ +.VSCodeCounter/ + +# Generated simulation runtime state. The live server recreates these on a +# fresh run; static environments, personas, and event definitions stay tracked. +/backend/data/Short_term_db/ +/backend/data/checkpoints/ +/backend/data/history/ +# Legacy JSON long-term-memory exports are produced only by the optional +# migration utility. The live simulator uses Qdrant instead. +/backend/data/Long_term_db/ +/backend/output/ + + +# Frontend dependencies and build output +/frontend/node_modules/ +/frontend/dist/ + + +# Python bytecode and interpreter caches +__pycache__/ +*.py[cod] + +# Local operating-system and tool residue +.DS_Store +Thumbs.db +*.pid +*.tmp +*.mp4 +*.png diff --git a/Dockerfile b/Dockerfile index 478f123e39f603ada8b4e29b989545f8fd7b0742..6490f1615aaed817a0b11b28a35fddd22bece5b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,23 @@ -# Use Node.js official image -FROM node:18 +FROM python:3.11-slim -# Install necessary packages for Puppeteer -RUN apt-get update && apt-get install -y \ - chromium \ - && rm -rf /var/lib/apt/lists/* - -# Set working directory WORKDIR /app -# Copy package files -COPY package*.json ./ +# System deps for Pillow/OpenCV if wheels need them +RUN apt-get update && apt-get install -y --no-install-recommends \ + libjpeg62-turbo-dev libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* -# Install dependencies -RUN npm install +# Python deps +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -# Copy application code -COPY . . +# Backend code +COPY backend/ backend/ +COPY pathfinder.py . -# Create directories and change ownership to the 'node' user -RUN mkdir -p .wwebjs_auth .wwebjs_cache && \ - chown -R node:node .wwebjs_auth .wwebjs_cache +# Frontend build (pre-built via Vercel, embedded for same-origin fallback) +COPY frontend/dist/ frontend/dist/ -# Expose port for web interface EXPOSE 7860 -# Start the application -CMD ["node", "bot.js"] \ No newline at end of file +CMD ["python", "backend/Odin.py", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md index 575c86c081c6b6cd6267877d9f3ab30c76efddee..d42f9012df1c47c6643148caa6dbe5c617feb3ac 100644 --- a/README.md +++ b/README.md @@ -1,147 +1,203 @@ ---- -title: "AIVA WhatsApp Bot" -emoji: "๐Ÿค–" -colorFrom: blue -colorTo: purple -sdk: docker -sdk_version: "20.10" -app_file: bot.js -pinned: false ---- +# Valhalla -# AIVA WhatsApp Bot +Valhalla is a multi-agent campus-life simulation set at IIT Ropar. +AI student personas plan their day, move across a shared map, talk when they meet, +and build memory over time. + +## What is Valhalla? -An intelligent WhatsApp bot with a web dashboard interface. +Valhalla is built for developers, AI hobbyists, and simulation researchers who want a practical sandbox for testing autonomous-agent behavior in a socially rich world. Its value is a full end-to-end loop (planning, movement, conversation, and memory) that you can run locally and inspect live. -## Features +## Quick Start -- ๐Ÿค– AI-powered responses via external API -- ๐Ÿ“ฑ WhatsApp Web integration -- ๐ŸŒ Web dashboard for monitoring and testing -- ๐Ÿ“Š Real-time statistics and logs -- ๐Ÿ”— QR code scanning via web interface -- ๐Ÿงช API testing interface +```bash +# 1) Create and activate virtual environment +python -m venv venv +source venv/bin/activate # Windows PowerShell: .\\venv\\Scripts\\Activate.ps1 -## Local Development +# 2) Install Python dependencies +pip install -r requirements.txt -### Prerequisites -- Node.js 18+ -- Docker (optional) +# 3) Build frontend +cd frontend && npm install && npm run build && cd .. -### Installation +# 4) Configure environment variables +cp .env.local .env # Windows PowerShell: Copy-Item .env.local .env +# Add at least one Gemini key in .env (for example GEMINI_API_KEY_1) -1. Clone the repository -2. Install dependencies: -```bash -npm install +# 5) Run +python backend/Odin.py ``` -3. Start the bot: -```bash -npm start -``` +Open . -4. Open web dashboard: http://localhost:3000 +## Table of Contents -## Docker Deployment +- [Overview](#overview) +- [Features](#features) +- [Architecture / Pipeline](#architecture--pipeline) +- [Setup](#setup) +- [Run](#run) +- [Configuration](#configuration) +- [Project Structure](#project-structure) +- [Security & Secrets](#security--secrets) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) -### Build and run with Docker -```bash -docker build -t aiva-bot . -docker run -p 3000:3000 -v $(pwd)/data:/app/.wwebjs_auth aiva-bot -``` +## Overview -### Using Docker Compose -```bash -docker-compose up -d -``` +Valhalla simulates a 24-hour social day in ticks. Each agent has personality traits, +energy and emotion state, location context, relationships, and memory. +The engine updates movement and interactions continuously while LLM calls are used for +high-level planning and dialogue. -## Cloud Deployment +https://github.com/user-attachments/assets/1e48772a-e8a9-45d3-a462-ddfe83b1c640 -### Railway -1. Connect your GitHub repository to Railway -2. Set environment variable: `PORT=3000` -3. Deploy -### Heroku -```bash -heroku create your-app-name -heroku container:push web -heroku container:release web -heroku open -``` -### DigitalOcean App Platform -1. Create new app from GitHub repository -2. Set build command: `docker build -t aiva-bot .` -3. Set run command: `node bot.js` -4. Set port: `3000` +## Features -### Google Cloud Run -```bash -# Build and push to Container Registry -docker build -t gcr.io/YOUR_PROJECT_ID/aiva-bot . -docker push gcr.io/YOUR_PROJECT_ID/aiva-bot - -# Deploy to Cloud Run -gcloud run deploy aiva-bot \ - --image gcr.io/YOUR_PROJECT_ID/aiva-bot \ - --platform managed \ - --port 3000 \ - --allow-unauthenticated +- AI-generated day plans for each persona with mid-day replanning. +- Tick-based movement on a campus map with location-aware behavior. +- 1:1 conversations that affect energy, emotion, and relationships. +- Persistent long-term memory via Qdrant (with runtime fallback behavior). +- Checkpoint/resume workflow for long-running simulations. +- Roster tools to add, rename, or retire agents. + + Valhalla simulation UI + +## Architecture / Pipeline + +```text +Load Personas -> Generate Day Plans -> Start Clock + | + +-----------+ + v + +---- Perceive (each tick) <----+ + | | + v | + Detect Conversations | + | | + v | + LLM Decide: continue/replan | + | | + v | + Replan if needed | + | | + v | + Advance agents ---------------------+ + (repeat for 1440 ticks / day) + | + v + Archive Day -> Next Day Plans ``` -## Web Dashboard Features +## Setup -- **Real-time Status**: Connection status and QR code display -- **Statistics**: Message count, API calls, uptime tracking -- **Activity Logs**: Real-time bot activity monitoring -- **Test Interface**: Direct API testing functionality +### Requirements -## API Endpoints +- Python 3.11+ +- Node.js + npm (frontend build) +- Gemini API key(s) +- Optional: Qdrant Cloud account for semantic long-term memory -- `GET /` - Web dashboard -- `POST /api/test` - Test API endpoint -- `GET /api/stats` - Bot statistics +### Install -## Environment Variables +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cd frontend && npm install && npm run build && cd .. +``` + +Windows PowerShell equivalent: -- `PORT` - Server port (default: 3000) -- `NODE_ENV` - Environment mode +```powershell +python -m venv venv +.\venv\Scripts\Activate.ps1 +pip install -r requirements.txt +cd frontend; npm install; npm run build; cd .. +Copy-Item .env.local .env +``` -## Bot Functionality +## Run -### WhatsApp Integration -- Responds to personal messages -- Responds when mentioned in groups -- Formats responses for WhatsApp (bold, italic, etc.) -- Handles `\n` line breaks properly +```bash +# Fresh start (opens browser UI) +python backend/Odin.py -### Message Processing -- Strips `...` content -- Extracts Final Answer from JSON responses -- Converts markdown formatting to WhatsApp format +# Resume from last checkpoint +python backend/Odin.py --resume-checkpoint -## Troubleshooting +# Headless single-day run +PYTHONPATH=backend python backend/src/core/world_engine.py --days 1 +``` -### Common Issues +Windows PowerShell (headless): -1. **QR Code not showing**: Check if port 3000 is accessible -2. **Bot not responding**: Verify API endpoint is working -3. **Docker build fails**: Ensure all dependencies are in package.json +```powershell +$env:PYTHONPATH="backend"; python backend/src/core/world_engine.py --days 1 +``` -### Logs -Check bot logs in the web dashboard or container logs: -```bash -docker logs +## Configuration + +Set values in `.env` (copy from `.env.local` first). + +### Configuration Reference + +| Variable | Default | Purpose | +|---|---|---| +| `SIM_TICK_SPEED` | `1.0` | Global simulation speed multiplier | +| `SIM_REAL_SECONDS_PER_SIM_MINUTE` | `7.5` | Wall-clock seconds per simulated minute | +| `SIM_MINUTES_PER_TICK` | `1` | Simulated minutes advanced each tick | +| `SIM_PERCEPTION_ENABLED` | `true` | Enables/disables perception step | +| `SIM_PERCEPTION_RADIUS_PX` | `50` | Nearby-agent detection radius | +| `SIM_MAX_CONVERSATIONS_PER_AGENT` | `5` | Daily conversation cap per agent | +| `SIM_MAX_REPLANS_PER_AGENT_PER_DAY` | `3` | Mid-day replan cap per agent | +| `SIM_LLM_HOURLY_CEILING` | `0` | Soft LLM call cap per real hour (`0` = unlimited) | +| `SIM_DECIDE_MIN_ENERGY` | `0.1` | Minimum energy to trigger LLM decisions | +| `SIM_DECIDE_MIN_EMOTION` | `0.1` | Minimum emotion to trigger LLM decisions | +| `SIM_CONVERSATION_MIN_ENERGY` | `0.05` | Minimum energy to start conversation | +| `SIM_CONVERSATION_MIN_EMOTION` | `0.05` | Minimum emotion to start conversation | +| `SIM_SEMANTIC_MEMORY_ENABLED` | `true` | Enables semantic long-term memory path | +| `QDRANT_URL` | `""` | Qdrant endpoint | +| `QDRANT_API_KEY` | `""` | Qdrant API key | +| `SIM_MEMORY_EMBEDDING_MODEL` | `gemini-embedding-001` | Embedding model for memory | +| `SIM_MEMORY_VECTOR_DIMENSIONS` | `768` | Embedding dimensions | +| `SIM_MEMORY_COLLECTION_VERSION` | `v1` | Memory schema/version tag | +| `SIM_GEMINI_MODEL` | `gemini-3.1-flash-lite` | Gemini model used by the simulation | +| `SIM_CREATIVITY` | `1.0` | Creativity dial for plans/dialogue | +| `SIM_WELLBEING_VARIABILITY` | `0.75` | Non-LLM variability in wellbeing updates | + +## Project Structure + +```text +backend/ + src/ # simulation engine, agent logic, planners, memory integrations + data/ # personas, environment, checkpoints, relationships, archives +frontend/ # React + Vite UI +README.md # project documentation +requirements.txt # Python dependencies ``` -## Security Notes +## Security & Secrets + +- Never commit real secrets (`.env`, API keys, private tokens, cloud credentials). +- Use `.env` for local secrets; keep only non-sensitive templates in version control. +- If a key is exposed, rotate it immediately in the provider dashboard. +- Remove leaked secrets from Git history before sharing or releasing the repository. +- Prefer least-privilege keys and separate keys for dev/staging/prod use. + +## Troubleshooting -- Keep WhatsApp session data secure -- Use environment variables for sensitive data -- Regularly update dependencies +- **No LLM output / planning fails**: verify at least one valid `GEMINI_API_KEY*` is set in `.env`. +- **Frontend not updating**: rebuild with `cd frontend && npm run build`. +- **Import/path errors in headless mode**: ensure `PYTHONPATH=backend` is set. +- **Qdrant errors**: verify `QDRANT_URL` and `QDRANT_API_KEY`, or disable with `SIM_SEMANTIC_MEMORY_ENABLED=false`. -## License +## Contributing -MIT License +1. Fork and create a feature branch. +2. Keep changes focused and documented. +3. Validate local run paths before opening a PR. +4. Include a clear summary of behavior changes and test notes. diff --git a/backend/Odin.py b/backend/Odin.py new file mode 100644 index 0000000000000000000000000000000000000000..3d1e7be599124729eb39c088a1d99a7c37ccad29 --- /dev/null +++ b/backend/Odin.py @@ -0,0 +1,1037 @@ +""" +FastAPI web server for the Valhalla agent map. +Serves the frontend (React SPA), exposes REST + WebSocket +endpoints for pathfinding (/api/path, /api/path/stream, /ws), and +streams simulation state via /ws/sim for live agent visualization. +""" + +import os +import sys +import json +import asyncio +import logging +import socket +from contextlib import asynccontextmanager +from datetime import datetime, timedelta +from pathlib import Path +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, Header +from fastapi.responses import FileResponse, JSONResponse +from pydantic import BaseModel, Field +from typing import Any, Dict, List, Optional +import re +import shutil +import uvicorn + +logger = logging.getLogger(__name__) + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from pathfinder import shortest_path, stats, ROOT + +FRONTEND = os.path.join(ROOT, "frontend") +DATA_DIR = os.path.join(ROOT, "backend", "data") + + +# --------------------------------------------------------------------------- # +# Sim Manager โ€” connection broadcast + background WorldEngine +# --------------------------------------------------------------------------- # + +class SimBroadcaster: + """Manages WebSocket clients via per-client bounded queues. + + Each connected client gets an asyncio.Queue(maxsize=5). The simulation + pushes snapshots into every queue with put_nowait (dropping oldest on + overflow). A lightweight drain task per client sends frames at the pace + that individual connection can handle โ€” a slow client only affects itself. + """ + + def __init__(self): + self._queues: dict[WebSocket, asyncio.Queue] = {} + self._tasks: dict[WebSocket, asyncio.Task] = {} + + async def connect(self, ws: WebSocket, initial_snapshot: Optional[dict] = None): + await ws.accept() + q: asyncio.Queue = asyncio.Queue(maxsize=5) + self._queues[ws] = q + if initial_snapshot is not None: + q.put_nowait(initial_snapshot) + self._tasks[ws] = asyncio.create_task(self._drain(ws, q)) + + def disconnect(self, ws: WebSocket): + task = self._tasks.pop(ws, None) + if task: + task.cancel() + self._queues.pop(ws, None) + + async def broadcast(self, data: dict): + for q in list(self._queues.values()): + try: + q.put_nowait(data) + except asyncio.QueueFull: + try: + q.get_nowait() + except asyncio.QueueEmpty: + pass + q.put_nowait(data) + + async def _drain(self, ws: WebSocket, q: asyncio.Queue): + try: + while True: + data = await q.get() + try: + await ws.send_json(data) + except Exception: + break + except asyncio.CancelledError: + pass + finally: + self._queues.pop(ws, None) + self._tasks.pop(ws, None) + + +_latest_snapshot: Optional[dict] = None +_sim_broadcaster = SimBroadcaster() +_sim_task: Optional[asyncio.Task] = None +_sim_engine = None +_sim_tick_lock = asyncio.Lock() + + +def _clear_runtime_state(): + """Remove all local runtime records for an intentional fresh run.""" + for d in [ + os.path.join(DATA_DIR, "Short_term_db"), + os.path.join(ROOT, "backend", "data", "checkpoints"), + os.path.join(DATA_DIR, "history"), + ]: + if os.path.isdir(d): + shutil.rmtree(d) + + +def _clear_long_term_memory() -> dict: + """Clear only Valhalla-owned semantic collections for an explicit reset.""" + try: + from src.agents.Long_term import get_retriever + clear = getattr(get_retriever(), "clear_all_memory", None) + return clear() if callable(clear) else {"available": False} + except Exception as exc: + logger.warning("[SimManager] unable to clear semantic memory: %s", exc) + return {"available": False, "error": str(exc)} + + +def _resume_checkpoint_requested() -> bool: + """Read only Valhalla's startup flag without consuming Uvicorn arguments. + + A near-miss such as ``--resume--checkpoint`` must never silently become a + fresh start, because the fresh-start path intentionally clears local + runtime state. + """ + import argparse + + invalid_resume_flags = [arg for arg in sys.argv[1:] if arg.startswith("--resume") and arg != "--resume-checkpoint"] + if invalid_resume_flags: + raise SystemExit( + "Invalid resume flag: " + ", ".join(invalid_resume_flags) + + ". Did you mean --resume-checkpoint?" + ) + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--resume-checkpoint", action="store_true") + args, _ = parser.parse_known_args() + return args.resume_checkpoint + + +def _reserve_listen_socket(host: str, port: int) -> socket.socket: + """Bind before app startup, so an occupied port cannot start the sim.""" + listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"): + listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + else: + listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listen_socket.bind((host, port)) + listen_socket.listen(socket.SOMAXCONN) + return listen_socket + except Exception: + listen_socket.close() + raise + + +async def _stop_sim_task() -> None: + """Stop the background engine before the ASGI app releases resources.""" + global _sim_task + task = _sim_task + _sim_task = None + if task is None or task.done(): + return + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@asynccontextmanager +async def _sim_lifespan(_app: FastAPI): + """Own the simulation task for exactly the ASGI app lifetime.""" + global _sim_task + _sim_task = asyncio.create_task( + _run_sim(resume_checkpoint=_resume_checkpoint_requested()), + name="valhalla-simulation", + ) + try: + yield + finally: + await _stop_sim_task() + + +app = FastAPI(title="Valhalla Agent Map", lifespan=_sim_lifespan) + +# --------------------------------------------------------------------------- +# CORS โ€” allow cross-origin requests from the Vercel frontend +# --------------------------------------------------------------------------- + +from fastapi.middleware.cors import CORSMiddleware + +_cors_raw = os.environ.get("CORS_ORIGINS", "*").strip() +CORS_ORIGINS = [o.strip() for o in _cors_raw.split(",") if o.strip()] if _cors_raw else ["*"] +app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# --------------------------------------------------------------------------- +# Auth โ€” admin login protects simulation control routes +# --------------------------------------------------------------------------- + +from src.auth.manager import auth_manager +from src.auth.routes import router as auth_router + +app.include_router(auth_router) + + +async def require_admin(authorization: str = Header(None)): + """FastAPI dependency: accept a Bearer token, return user or 401.""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Not authenticated") + user = auth_manager.validate_session(authorization[7:]) + if not user: + raise HTTPException(status_code=401, detail="Invalid or expired session") + return user + + +def _print_agent_plans(engine): + """Print each agent's full action plan to the CLI.""" + from src.core.agent_registry import AgentRuntimeState + for state in engine.registry.all_states(): + actions = state.day_plan or [] + print(f"\n {state.persona_name} โ€” {len(actions)} actions:") + if not actions: + print(" (no plan)") + continue + for a in actions: + start = a.get("start", "??") + end = a.get("end", "??") + loc = a.get("location_id", "?") + desc = a.get("action", "?") + print(f" {start}-{end} {loc:<30s} {desc}") + print() + + +def _print_sim_status(snapshot: dict) -> None: + """Emit compact simulation-time telemetry rather than checkpoint noise.""" + if not snapshot.get("agents"): + return + print( + f"[SIM] day={snapshot.get('day', '?')} tick={snapshot.get('tick', '?')} " + f"time={snapshot.get('time', '??:??')} speed=x{snapshot.get('speed', {}).get('multiplier', '?')}" + ) + for agent in snapshot["agents"].values(): + action = agent.get("current_action") or {} + state = "paused" if agent.get("paused") else "active" + print( + f" {agent['name']:<18} {state:<6} " + f"at={agent['position'].get('location_id', '?'):<22} " + f"action={action.get('description', 'Idle'):<42} " + f"energy={agent.get('energy_level', 0):.2f} emotion={agent.get('emotion_state', 0):.2f}" + ) + print() + + +async def _run_sim(resume_checkpoint: bool = False): + """Background task: initialize WorldEngine and drive the tick loop. + + Args: + resume_checkpoint: If True, resume from the latest checkpoint without + deleting saved runtime data. + """ + global _latest_snapshot, _sim_engine + from src.core.world_engine import WorldEngine + from src.core.checkpoint_manager import list_checkpoints, load_checkpoint + from src.core.log import setup_logging + from src.llm.gemini_client import ProviderFailureError, provider_failure + + setup_logging(run_id="server_sim", console=False) + + from src import config as _cfg + start_date = _cfg.SIM_START_DATE + + engine = WorldEngine(sim_start_date=start_date, sim_start_hhmm=_cfg.SIM_START_TIME) + _sim_engine = engine + + if resume_checkpoint: + ticks = list_checkpoints() + if ticks: + resume_tick = ticks[-1] + print(f"[SimManager] found checkpoint at tick {resume_tick} โ€” resuming") + world, registry, checkpoint_state = load_checkpoint( + resume_tick, engine.resolver, return_metadata=True, + ) + # Checkpoints own historical membership. Comparing against the + # mutable persona folder would make an old checkpoint impossible + # to rewind after a roster add/remove operation. + engine.world = world + engine.registry = registry + engine.restore_checkpoint_state(checkpoint_state) + await engine.resume_pending_conversations() + _latest_snapshot = {"status": "initialized", "agents": {}} + else: + message = "No checkpoint exists. Start a fresh simulation with: python backend/Odin.py" + logger.error("[SimManager] %s", message) + _latest_snapshot = {"status": "error", "message": message} + return + else: + # The normal command deliberately starts from no short-term memory or + # checkpoint data. Resuming is available only via the explicit flag. + _clear_runtime_state() + _clear_long_term_memory() + try: + logger.info("[SimManager] starting simulation for %s", start_date) + await engine.initialize() + except ProviderFailureError as exc: + failure = provider_failure() or exc + _latest_snapshot = {"status": "provider_failure", "message": str(failure), "failure": failure.payload(), + "simulation": {"running": False, "stop_reason": failure.code}} + print(f"[SimManager] {failure.title.upper()} โ€” simulation stopped: {failure}") + await _sim_broadcaster.broadcast(_latest_snapshot) + return + except Exception as e: + _latest_snapshot = {"status": "error", "message": str(e)} + import traceback + traceback.print_exc() + return + _latest_snapshot = {"status": "initialized", "agents": {}} + _print_agent_plans(engine) + + while True: + try: + day_end_tick = ((engine.world.tick // (24 * 60)) + 1) * (24 * 60) + await engine.run(max_tick=day_end_tick, on_tick=_on_tick, tick_lock=_sim_tick_lock) + start_date = ( + datetime.strptime(engine.sim_start_date, "%Y-%m-%d") + timedelta(days=1) + ).strftime("%Y-%m-%d") + logger.info("[SimManager] day transition โ€” advancing to %s", start_date) + async with _sim_tick_lock: + _latest_snapshot = engine.current_frontend_snapshot() + _latest_snapshot.update({"type": "day_handoff", "phase": "draining", "date": start_date}) + await _sim_broadcaster.broadcast(_latest_snapshot) + _latest_snapshot = await engine.handoff_to_next_day(start_date) + await _sim_broadcaster.broadcast(_latest_snapshot) + _print_agent_plans(engine) + except asyncio.CancelledError: + print("[SimManager] sim task cancelled") + raise + except ProviderFailureError as exc: + failure = provider_failure() or exc + _latest_snapshot = {"status": "provider_failure", "message": str(failure), "failure": failure.payload(), + "simulation": {"running": False, "stop_reason": failure.code}} + print(f"[SimManager] {failure.title.upper()} โ€” simulation stopped: {failure}") + await _sim_broadcaster.broadcast(_latest_snapshot) + break + except Exception as exc: + print(f"[SimManager] error: {exc}") + import traceback + traceback.print_exc() + break + + +async def _on_tick(snapshot: dict): + """Called after each simulation tick โ€” store + broadcast.""" + global _latest_snapshot + _latest_snapshot = snapshot + await _sim_broadcaster.broadcast(snapshot) + if snapshot.get("tick", 0) % 15 == 0: + _print_sim_status(snapshot) + + +# --------------------------------------------------------------------------- # +# Startup +# --------------------------------------------------------------------------- # + +# Suppress FastAPI on_event deprecation warning โ€” the lifespan equivalent is +# fine, but on_event is simpler and works identically here. The warning is +# harmless noise. If it bothers you, migrate to: +# @app.lifespan("startup") +# async def _lifespan(app): async with SimLifespan(): yield + + +# --------------------------------------------------------------------------- # +# Sim state endpoint +# --------------------------------------------------------------------------- # + +@app.post("/api/sim/reset") +async def reset_sim(_user: dict = Depends(require_admin)): + """Wipe cache + checkpoints and restart the sim from 00:00.""" + global _sim_task, _latest_snapshot + + # Reset and rewind/handoff all replace mutable engine state. Serialising + # them avoids a cancelled task writing a stale checkpoint after reset. + async with _sim_tick_lock: + if _sim_task is not None and not _sim_task.done(): + _sim_task.cancel() + try: + await _sim_task + except (asyncio.CancelledError, Exception): + pass + + _latest_snapshot = {"status": "resetting"} + _clear_runtime_state() + _clear_long_term_memory() + _sim_task = asyncio.create_task(_run_sim()) + + await _sim_broadcaster.broadcast({"type": "reset"}) + return {"status": "reset", "message": "Simulation reset โ€” starting fresh"} + + +def _simulation_is_running() -> bool: + return _sim_task is not None and not _sim_task.done() + + +def _snapshot_with_simulation_status(snapshot: Optional[dict]) -> dict: + """Expose task state without mutating the engine-owned snapshot object.""" + payload = dict(snapshot or {"status": "initializing"}) + existing = payload.get("simulation") if isinstance(payload.get("simulation"), dict) else {} + payload["simulation"] = {**existing, "running": _simulation_is_running()} + return payload + + +@app.post("/api/sim/stop") +async def stop_sim(_user: dict = Depends(require_admin)): + """Stop the tick driver without deleting persisted checkpoints.""" + global _latest_snapshot, _sim_engine + if not _simulation_is_running(): + return {"status": "already_stopped", "running": False} + + await _stop_sim_task() + _sim_engine = None + _latest_snapshot = _snapshot_with_simulation_status(_latest_snapshot) + await _sim_broadcaster.broadcast(_latest_snapshot) + return {"status": "stopped", "running": False} + + +@app.post("/api/sim/start") +async def start_sim(_user: dict = Depends(require_admin)): + """Start from the newest persisted checkpoint, never stale RAM state.""" + global _sim_task + if _simulation_is_running(): + return {"status": "already_running", "running": True} + + from src.core.checkpoint_manager import list_checkpoints + checkpoints = list_checkpoints() + if not checkpoints: + return JSONResponse( + {"error": "No checkpoint is available. Use Reset or restart Valhalla to create a fresh simulation."}, + status_code=409, + ) + + _sim_task = asyncio.create_task( + _run_sim(resume_checkpoint=True), + name="valhalla-simulation", + ) + return { + "status": "starting_from_checkpoint", + "running": True, + "checkpoint_tick": checkpoints[-1], + } + + +class RewindInput(BaseModel): + ticks: Optional[int] = Field(default=None, ge=1, le=1440) + hours: Optional[float] = Field(default=None, gt=0, le=24) + + def requested_ticks(self, minutes_per_tick: int) -> int: + if self.ticks is not None: + return self.ticks + if self.hours is not None: + return max(1, round((self.hours * 60) / max(1, minutes_per_tick))) + raise ValueError("Provide a positive rewind amount in ticks or hours.") + + +# --------------------------------------------------------------------------- # +# Stopped-only roster editing. Each edit writes a successor checkpoint rather +# than overwriting history, so rewinding to a pre-edit checkpoint restores the +# roster that actually existed at that point in the timeline. +# --------------------------------------------------------------------------- # + +class AddAgentInput(BaseModel): + description: str = Field(min_length=30, max_length=4000) + + +class RemoveAgentInput(BaseModel): + agent_id: str = Field(min_length=1, max_length=128) + + +class ReplaceAgentInput(BaseModel): + agent_id: str = Field(min_length=1, max_length=128) + name: str = Field(min_length=2, max_length=80) + + +class GeneratedPersona(BaseModel): + name: str = Field(min_length=2, max_length=80) + age: int = Field(ge=18, le=30) + gender: str = Field(min_length=1, max_length=32) + branch: str = Field(min_length=2, max_length=160) + home_city: str = Field(min_length=2, max_length=120) + hostel: str + daily_plan_req: str = Field(min_length=40, max_length=900) + innate: str = Field(min_length=40, max_length=900) + learned: str = Field(min_length=20, max_length=900) + lifestyle: str = Field(min_length=40, max_length=900) + hobbies: str = Field(min_length=20, max_length=900) + goals: str = Field(min_length=30, max_length=900) + interests: List[str] = Field(min_length=3, max_length=12) + + +def _require_stopped() -> Optional[JSONResponse]: + if _simulation_is_running(): + return JSONResponse({"error": "Stop the simulation before editing the roster."}, status_code=409) + return None + + +def _safe_agent_id(name: str) -> str: + result = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") + return result or "agent" + + +def _short_term_dir_for(name: str) -> Path: + return Path(DATA_DIR) / "Short_term_db" / _safe_agent_id(name) + + +def _persona_file_for(name: str) -> Optional[Path]: + root = Path(DATA_DIR) / "personalities" + for path in root.glob("**/*.json"): + try: + if json.loads(path.read_text(encoding="utf-8")).get("Name") == name: + return path + except (OSError, json.JSONDecodeError): + continue + return None + + +def _save_roster_successor(world, registry, engine_state: dict) -> int: + """Persist a roster edit without mutating its source checkpoint.""" + from src.core.checkpoint_manager import latest_tick, save_checkpoint + # Same simulation minute, next storage tick: previous checkpoint remains + # a faithful historical roster while /start picks this successor. + successor = max(int(world.tick), int(latest_tick() or 0)) + 1 + world.tick = successor + save_checkpoint(world, registry, successor, engine_state) + return successor + + +def _load_stopped_roster(): + from src.core.checkpoint_manager import list_checkpoints, load_checkpoint + from src.core.world_engine import WorldEngine + ticks = list_checkpoints() + if not ticks: + raise ValueError("No checkpoint exists yet. Start and stop a fresh simulation before editing its roster.") + engine = WorldEngine() + world, registry, state = load_checkpoint(ticks[-1], engine.resolver, return_metadata=True) + engine.world, engine.registry = world, registry + engine.restore_checkpoint_state(state) + return engine, state + + +@app.get("/api/roster") +async def get_roster(): + from src.core.checkpoint_manager import list_checkpoints, load_checkpoint + from src.core.world_engine import WorldEngine + ticks = list_checkpoints() + if not ticks: + return {"agents": [], "editable": not _simulation_is_running()} + engine = WorldEngine() + _, registry, _ = load_checkpoint(ticks[-1], engine.resolver, return_metadata=True) + return {"editable": not _simulation_is_running(), "agents": [ + {"id": state.agent_id, "name": state.persona_name, "branch": state.persona.get("Branch", ""), "hostel": state.persona.get("Hostel", "")} + for state in registry.all_states() + ]} + + +@app.post("/api/roster/remove") +async def remove_agent(request: RemoveAgentInput, _user: dict = Depends(require_admin)): + blocked = _require_stopped() + if blocked: + return blocked + try: + engine, state = _load_stopped_roster() + removed = engine.registry.remove(request.agent_id) + except (ValueError, KeyError) as exc: + return JSONResponse({"error": str(exc)}, status_code=404) + + engine.world.remove_agent(request.agent_id) + engine.relationship_matrix.remove_agent(request.agent_id) + archive = Path(DATA_DIR) / "retired_agents" / datetime.now().strftime("%Y%m%d_%H%M%S") / request.agent_id + archive.mkdir(parents=True, exist_ok=True) + persona_path = _persona_file_for(removed.persona_name) + if persona_path: + shutil.move(str(persona_path.parent), str(archive / "persona")) + short_term = _short_term_dir_for(removed.persona_name) + if short_term.exists(): + shutil.move(str(short_term), str(archive / "short_term")) + try: + from src.agents.Long_term import get_retriever + delete = getattr(get_retriever(), "delete_agent_memory", None) + if callable(delete): + delete(request.agent_id) + except Exception as exc: + logger.warning("[Roster] could not delete semantic memory for %s: %s", request.agent_id, exc) + tick = _save_roster_successor(engine.world, engine.registry, engine.checkpoint_state()) + return {"status": "removed", "agent_id": request.agent_id, "checkpoint_tick": tick, "archive": str(archive)} + + +@app.post("/api/roster/replace") +async def replace_agent(request: ReplaceAgentInput, _user: dict = Depends(require_admin)): + blocked = _require_stopped() + if blocked: + return blocked + try: + engine, state = _load_stopped_roster() + agent = engine.registry.get(request.agent_id) + except (ValueError, KeyError) as exc: + return JSONResponse({"error": str(exc)}, status_code=404) + old_name = agent.persona_name + if _persona_file_for(request.name): + return JSONResponse({"error": f"An agent named '{request.name}' already exists."}, status_code=409) + agent.persona_name = request.name + agent.persona["Name"] = request.name + persona_path = _persona_file_for(old_name) + if persona_path: + new_dir = persona_path.parent.parent / _safe_agent_id(request.name) + new_dir.mkdir(parents=True, exist_ok=True) + (new_dir / f"{_safe_agent_id(request.name)}.json").write_text(json.dumps(agent.persona, indent=2), encoding="utf-8") + shutil.rmtree(persona_path.parent) + old_short = _short_term_dir_for(old_name) + if old_short.exists(): + old_short.rename(_short_term_dir_for(request.name)) + engine.relationship_matrix.replace_display_name(old_name, request.name) + tick = _save_roster_successor(engine.world, engine.registry, engine.checkpoint_state()) + return {"status": "replaced", "agent_id": request.agent_id, "name": request.name, "checkpoint_tick": tick} + + +@app.post("/api/roster/add") +async def add_agent(request: AddAgentInput, _user: dict = Depends(require_admin)): + """Generate an adult student persona from observer-provided notes, while stopped.""" + blocked = _require_stopped() + if blocked: + return blocked + try: + engine, state = _load_stopped_roster() + from src.agents.day_planner import load_places + hostels = [place.id for place in load_places() if place.type == "residential"] + from src.llm.gemini_client import call_gemini + generated = call_gemini( + "Create one grounded adult (18-30) Indian college-student simulation persona. " + "Keep it respectful and non-explicit: no harassment, coercion, discrimination, stalking, or pornography. " + "Balance academics with routines, friendship, hobbies, flaws, and downtime. Use exactly one supplied hostel id.", + f"OBSERVER NOTES:\n{request.description}\n\nAVAILABLE HOSTEL IDS: {', '.join(hostels)}\n" + "Return a complete persona matching the requested schema.", + GeneratedPersona, + "default", + ) + except Exception as exc: + logger.exception("[Roster] agent generation failed") + return JSONResponse({"error": f"Could not generate agent: {exc}"}, status_code=502) + + agent_id = _safe_agent_id(generated.name) + if agent_id in engine.registry: + return JSONResponse({"error": f"An agent with id '{agent_id}' already exists."}, status_code=409) + if generated.hostel not in hostels: + return JSONResponse({"error": "Generated persona selected an invalid hostel."}, status_code=502) + persona = { + "Name": generated.name, "Age": str(generated.age), "Gender": generated.gender, + "Branch": generated.branch, "Home City": generated.home_city, "Hostel": generated.hostel, + "daily_plan_req": generated.daily_plan_req, "innate": generated.innate, + "learned": generated.learned, "lifestyle": generated.lifestyle, + "hobbies": generated.hobbies, "goals": generated.goals, "interests": generated.interests, + } + # A roster addition is not merely a display record: generate the same + # executable remaining-day plan used by the regular runtime before the + # successor checkpoint is committed. If the provider cannot do so, leave + # every persisted surface untouched and report the failure to the observer. + try: + from types import SimpleNamespace + from src.agents.day_planner import run as plan_day + current_hhmm = engine._minutes_to_hhmm(engine.world.tick % (24 * 60)) + current_date = (datetime.strptime(engine.sim_start_date, "%Y-%m-%d") + timedelta(days=engine.world.tick // (24 * 60))).strftime("%Y-%m-%d") + proxy = SimpleNamespace(persona=persona, relevant_memories=[], yesterday_summary=None) + plan_result = await asyncio.get_running_loop().run_in_executor(None, lambda: plan_day(proxy, { + "current_time": f"{current_date} {current_hhmm}", "places": None, + "persona_name": generated.name, "mode": "remaining" if engine.world.tick else "full_day", + "current_location_id": generated.hostel, "upcoming_events": [], + })) + day_plan = plan_result.get("day_plan", []) + if not day_plan: + raise ValueError(plan_result.get("error") or "planner returned no executable actions") + except Exception as exc: + return JSONResponse({"error": f"Persona was generated but no executable plan could be created: {exc}"}, status_code=502) + from src.agents.Actions import AgentActionManager + from src.core.agent_registry import AgentRuntimeState + position = engine.resolver.random_interior_point(generated.hostel) + manager = AgentActionManager(agent_id, day_plan, position, engine.resolver) + engine.registry.register(AgentRuntimeState( + agent_id=agent_id, persona=persona, persona_name=generated.name, + manager=manager, position=position, day_plan=day_plan, + emotion_state=engine._emotion_baseline(persona), emotion_baseline=engine._emotion_baseline(persona), + )) + engine.world.register_agent(agent_id, position) + persona_dir = Path(DATA_DIR) / "personalities" / agent_id + persona_dir.mkdir(parents=True, exist_ok=False) + (persona_dir / f"{agent_id}.json").write_text(json.dumps(persona, indent=2), encoding="utf-8") + + from src.agents.conversation import RelationshipRecord + for other in engine.registry.all_states(): + if other.agent_id == agent_id: + continue + engine.relationship_matrix.seed(agent_id, other.agent_id, RelationshipRecord( + score=0.32, tags=["new-acquaintance"], + context=f"{generated.name} is new to this circle and is still learning {other.persona_name}'s rhythm." + )) + engine.relationship_matrix.seed(other.agent_id, agent_id, RelationshipRecord( + score=0.32, tags=["new-acquaintance"], + context=f"{other.persona_name} has only recently met {generated.name}; the connection is open but untested." + )) + tick = _save_roster_successor(engine.world, engine.registry, engine.checkpoint_state()) + return {"status": "added", "agent_id": agent_id, "name": generated.name, "checkpoint_tick": tick} + + +@app.post("/api/sim/fast-forward") +async def fast_forward_sim(_user: dict = Depends(require_admin)): + """Increase the live tick-speed multiplier without restarting the day.""" + from src import config as _cfg + + _cfg.TICK_SPEED = min(32.0, max(1.0, _cfg.TICK_SPEED * 2.0)) + return {"status": "ok", "speed_multiplier": _cfg.TICK_SPEED} + + +@app.post("/api/sim/slow-down") +async def slow_down_sim(_user: dict = Depends(require_admin)): + """Halve the live tick-speed multiplier without restarting the day.""" + from src import config as _cfg + + # The floor avoids accidentally making a single tick take minutes while + # still allowing an observer to inspect the simulation at quarter speed. + _cfg.TICK_SPEED = max(0.25, _cfg.TICK_SPEED / 2.0) + return {"status": "ok", "speed_multiplier": _cfg.TICK_SPEED} + + +@app.post("/api/sim/rewind") +async def rewind_sim(request: RewindInput, _user: dict = Depends(require_admin)): + """Restore the nearest retained checkpoint for a requested tick or hour offset.""" + global _latest_snapshot + from src.core.checkpoint_manager import list_checkpoints, load_checkpoint + from src import config as _cfg + + try: + requested_ticks = request.requested_ticks(_cfg.SIM_MINUTES_PER_TICK) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=422) + + async with _sim_tick_lock: + engine = _sim_engine + if engine is None: + return JSONResponse({"error": "Simulation is still initializing."}, status_code=409) + current_tick = engine.world.tick + target_tick = current_tick - requested_ticks + available = [tick for tick in list_checkpoints() if tick <= target_tick] + if not available: + return JSONResponse({ + "error": f"Cannot rewind {requested_ticks} ticks; no retained checkpoint before tick {target_tick}.", + "current_tick": current_tick, + }, status_code=409) + + restore_tick = available[-1] + # Decisions and conversations created after the target checkpoint + # belong to the discarded future. Cancel both before replacing state. + background_tasks = [ + *engine._conversation_tasks.values(), + *engine._decision_tasks.values(), + ] + for task in background_tasks: + task.cancel() + if background_tasks: + await asyncio.gather(*background_tasks, return_exceptions=True) + engine._conversation_tasks.clear() + engine._decision_tasks.clear() + + world, registry, state = load_checkpoint(restore_tick, engine.resolver, return_metadata=True) + # A checkpoint is the authority for the roster at its timeline point. + engine.world = world + engine.registry = registry + engine.restore_checkpoint_state(state) + await engine.resume_pending_conversations() + _latest_snapshot = engine.current_frontend_snapshot() + _latest_snapshot["rewound_from_tick"] = current_tick + _latest_snapshot["rewound_ticks"] = current_tick - restore_tick + + await _sim_broadcaster.broadcast(_latest_snapshot) + _print_sim_status(_latest_snapshot) + return {"status": "rewound", "tick": restore_tick, "rewound_ticks": current_tick - restore_tick} + + +@app.get("/api/sim/state") +async def get_sim_state(): + """Return the latest tick snapshot (or status if not yet running).""" + return _snapshot_with_simulation_status(_latest_snapshot) + + +@app.websocket("/ws/sim") +async def sim_websocket(ws: WebSocket): + """Subscribe to live tick-by-tick simulation state.""" + await _sim_broadcaster.connect(ws, _latest_snapshot) + try: + while True: + msg = await ws.receive_text() + if msg == "ping": + await ws.send_text("pong") + except WebSocketDisconnect: + _sim_broadcaster.disconnect(ws) + + +_BLD_NAME_MAP = None + +def _load_bld_names(): + global _BLD_NAME_MAP + if _BLD_NAME_MAP is not None: + return + _BLD_NAME_MAP = {} + p = os.path.join(DATA_DIR, "environment", "num.txt") + with open(p) as f: + for line in f: + m = re.match(r'building (\d+) - (.+)', line.strip()) + if m: + _BLD_NAME_MAP[int(m.group(1))] = m.group(2) + + +@app.get("/api/buildings") +def get_buildings(): + _load_bld_names() + with open(os.path.join(DATA_DIR, "environment", "buildings_polygon_decomposed.json")) as f: + buildings = json.load(f) + + seen = [] + for b in buildings: + base = b["building_name"].rsplit("_part", 1)[0] + if base not in seen: + seen.append(base) + + base_to_label = {} + for i, base in enumerate(seen): + num = i + 1 + base_to_label[base] = _BLD_NAME_MAP.get(num, base) + + simplified = [] + for b in buildings: + tl = b["top_left"] + br = b["bottom_right"] + base = b["building_name"].rsplit("_part", 1)[0] + simplified.append({ + "label": base_to_label[base], + "id": base, + "x": tl[0], + "y": tl[1], + "w": br[0] - tl[0], + "h": br[1] - tl[1], + }) + return simplified + + +@app.get("/api/stats") +def get_stats(): + return stats() + + +@app.get("/api/path") +def get_path( + x1: int = Query(32), + y1: int = Query(297), + x2: int = Query(959), + y2: int = Query(1205), +): + start = (x1, y1) + end = (x2, y2) + path = shortest_path(start, end) + if path is None: + return JSONResponse({"error": "No path found"}, status_code=404) + return {"path": path, "length": len(path), "start": list(start), "end": list(end)} + + +@app.get("/api/path/stream") +async def stream_path( + x1: int = Query(32), + y1: int = Query(297), + x2: int = Query(959), + y2: int = Query(1205), +): + start = (x1, y1) + end = (x2, y2) + path = shortest_path(start, end) + if path is None: + from fastapi.responses import JSONResponse + return JSONResponse({"error": "No path found"}, status_code=404) + + from fastapi.responses import StreamingResponse + import time + + async def point_stream(): + yield json.dumps({"meta": {"length": len(path), "start": list(start), "end": list(end)}}) + "\n" + for pt in path: + yield json.dumps({"x": pt[0], "y": pt[1]}) + "\n" + await asyncio.sleep(0) + yield json.dumps({"done": True}) + "\n" + + return StreamingResponse(point_stream(), media_type="application/x-ndjson") + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + try: + while True: + data = await websocket.receive_text() + try: + msg = json.loads(data) + except json.JSONDecodeError: + await websocket.send_json({"error": "invalid JSON"}) + continue + + if msg.get("type") == "path": + start = (msg["x1"], msg["y1"]) + end = (msg["x2"], msg["y2"]) + path = shortest_path(start, end) + if path is None: + await websocket.send_json({"type": "path_result", "error": "No path found"}) + else: + meta = {"length": len(path), "start": list(start), "end": list(end)} + await websocket.send_json({"type": "path_meta", "meta": meta}) + for pt in path: + await websocket.send_json({"type": "path_point", "x": pt[0], "y": pt[1]}) + await asyncio.sleep(0) + await websocket.send_json({"type": "path_done"}) + elif msg.get("type") == "ping": + await websocket.send_json({"type": "pong"}) + except WebSocketDisconnect: + pass + + +class AgentInput(BaseModel): + x1: int + y1: int + x2: int + y2: int + +class PathsInput(BaseModel): + agents: List[AgentInput] + + +@app.post("/api/paths") +async def get_paths(input_data: PathsInput): + results = [] + for a in input_data.agents: + start = (a.x1, a.y1) + end = (a.x2, a.y2) + path = shortest_path(start, end) + results.append({ + "path": path, + "length": len(path) if path else 0, + "start": [a.x1, a.y1], + "end": [a.x2, a.y2], + }) + return {"paths": results} + + +@app.get("/api/entrypoints") +def get_entrypoints(): + with open(os.path.join(DATA_DIR, "environment", "entrypoint.json")) as f: + return json.load(f) + + +# Serve React production build (dist/) if it exists, else fallback to frontend/ +import mimetypes +# On Windows, the registry often maps .js to text/plain, which makes browsers +# refuse the ES module (strict MIME checking) and the whole SPA fails to load โ€” +# leaving only a blank page. Force the correct types explicitly. +mimetypes.add_type("application/javascript", ".js") +mimetypes.add_type("application/javascript", ".mjs") +mimetypes.add_type("text/css", ".css") +mimetypes.add_type("image/svg+xml", ".svg") +mimetypes.add_type("image/png", ".png") + +@app.get("/{full_path:path}") +async def serve_spa(full_path: str): + dist = os.path.join(FRONTEND, "dist") + if os.path.isdir(dist): + file_path = os.path.join(dist, full_path) if full_path else os.path.join(dist, "index.html") + if os.path.isfile(file_path): + return FileResponse(file_path) + return FileResponse(os.path.join(dist, "index.html")) + file_path = os.path.join(FRONTEND, full_path) if full_path else os.path.join(FRONTEND, "index.html") + if os.path.isfile(file_path): + return FileResponse(file_path) + return FileResponse(os.path.join(FRONTEND, "index.html")) + + +if __name__ == "__main__": + import argparse + invalid_resume_flags = [arg for arg in sys.argv[1:] if arg.startswith("--resume") and arg != "--resume-checkpoint"] + if invalid_resume_flags: + raise SystemExit( + "Invalid resume flag: " + ", ".join(invalid_resume_flags) + + ". Did you mean --resume-checkpoint?" + ) + parser = argparse.ArgumentParser(description="Valhalla simulation server") + parser.add_argument( + "--resume-checkpoint", action="store_true", default=False, + help="Resume the latest checkpoint without clearing runtime data", + ) + parser.add_argument( + "--port", type=int, default=int(os.environ.get("PORT", "8000")), + help="Port to listen on (default: 8000)", + ) + parser.add_argument( + "--host", type=str, default=os.environ.get("HOST", "127.0.0.1"), + help="Host to bind to (default: 127.0.0.1, use 0.0.0.0 for public)", + ) + args, _ = parser.parse_known_args() + + # Reserve the address before ASGI startup. Uvicorn normally starts the + # lifespan before binding, which used to let a doomed duplicate launch + # clear state and spend model calls without ever serving the browser. + try: + listen_socket = _reserve_listen_socket(args.host, args.port) + except OSError as exc: + print( + f"\n Cannot start Valhalla on http://{args.host}:{args.port}: {exc}.\n" + f" Stop the existing server or choose a free port, e.g. --port {args.port + 1}.\n", + file=sys.stderr, + ) + raise SystemExit(1) + + if args.resume_checkpoint: + print(f"\n Valhalla is live => http://{args.host}:{args.port} (resuming from checkpoint)\n") + else: + print(f"\n Valhalla is live => http://{args.host}:{args.port} (fresh simulation; runtime data will be cleared)\n") + + config = uvicorn.Config(app, host=args.host, port=args.port, reload=False) + try: + uvicorn.Server(config).run(sockets=[listen_socket]) + except KeyboardInterrupt: + print("\n[Server] Valhalla stopped cleanly.") + finally: + listen_socket.close() diff --git a/backend/Pathfinder_test.py b/backend/Pathfinder_test.py new file mode 100644 index 0000000000000000000000000000000000000000..55fa7f473c79d16848e2163c9409cbbbc92cf071 --- /dev/null +++ b/backend/Pathfinder_test.py @@ -0,0 +1,80 @@ +""" +CLI tool for pixel-level pathfinding on the Valhalla map. +Loads map.png, computes the shortest path between two pixel coordinates +via BFS, and displays the result with start/end markers in a matplotlib window. +""" + +import sys +import os +from PIL import Image, ImageDraw, ImageFont +import matplotlib.pyplot as plt +from pathfinder import shortest_path, stats, ROOT + + +def show_path_on_map(path, start, end, save_path=None): + img = Image.open(os.path.join(ROOT, "frontend", "map.png")).convert("RGBA") + overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + if path: + draw.line(path, fill=(255, 30, 30, 255), width=2) + + r = 5 + sx, sy = start + ex, ey = end + draw.ellipse([sx - r, sy - r, sx + r, sy + r], fill=(0, 220, 0, 255)) + draw.ellipse([ex - r, ey - r, ex + r, ey + r], fill=(30, 100, 255, 255)) + + try: + font = ImageFont.truetype("arial.ttf", 14) + except (IOError, OSError): + font = ImageFont.load_default() + draw.text((sx + 7, sy - 9), "S", fill=(0, 220, 0, 255), font=font) + draw.text((ex + 7, ey - 9), "E", fill=(30, 100, 255, 255), font=font) + + composited = Image.alpha_composite(img, overlay).convert("RGB") + + if save_path: + composited.save(os.path.join(ROOT, save_path)) + print(f"Saved: {save_path}") + else: + plt.figure(figsize=(14, 14)) + plt.imshow(composited) + plt.axis("off") + plt.tight_layout(pad=0) + plt.show() + + +if __name__ == "__main__": + cmds = [a for a in sys.argv[1:] if not a.startswith("--")] + flags = set(a for a in sys.argv[1:] if a.startswith("--")) + + if "--help" in flags or "-h" in flags: + print("Usage: python backend/pixel_pathfinder.py [x1 y1 x2 y2]") + print() + print(" x1 y1 x2 y2 pixel coordinates for start and end") + print(" --count count white pixels only") + sys.exit(0) + + if "--count" in flags: + s = stats() + print(f"White pixels: {s['white_pixels']} / {s['width']*s['height']}") + sys.exit(0) + + if len(cmds) >= 4: + sx, sy, ex, ey = int(cmds[0]), int(cmds[1]), int(cmds[2]), int(cmds[3]) + else: + sx, sy = 32, 297 + ex, ey = 959, 1205 + + start = (sx, sy) + end = (ex, ey) + + print(f"Path: ({sx},{sy}) -> ({ex},{ey})") + + path = shortest_path(start, end) + if path: + print(f"Found: {len(path)} pixels") + show_path_on_map(path, start, end) + else: + print("No path found") diff --git a/backend/data/environment/buildings_polygon_decomposed.json b/backend/data/environment/buildings_polygon_decomposed.json new file mode 100644 index 0000000000000000000000000000000000000000..93a44c08dcbc368e3a68ab4fe0d44628cc67eb82 --- /dev/null +++ b/backend/data/environment/buildings_polygon_decomposed.json @@ -0,0 +1,3878 @@ +[ + { + "building_name": "PolygonBuilding_1_part1", + "top_left": [ + 777, + 445 + ], + "top_right": [ + 785, + 445 + ], + "bottom_left": [ + 777, + 560 + ], + "bottom_right": [ + 785, + 560 + ] + }, + { + "building_name": "PolygonBuilding_1_part2", + "top_left": [ + 785, + 445 + ], + "top_right": [ + 793, + 445 + ], + "bottom_left": [ + 785, + 559 + ], + "bottom_right": [ + 793, + 559 + ] + }, + { + "building_name": "PolygonBuilding_1_part3", + "top_left": [ + 793, + 445 + ], + "top_right": [ + 801, + 445 + ], + "bottom_left": [ + 793, + 559 + ], + "bottom_right": [ + 801, + 559 + ] + }, + { + "building_name": "PolygonBuilding_1_part4", + "top_left": [ + 801, + 444 + ], + "top_right": [ + 809, + 444 + ], + "bottom_left": [ + 801, + 558 + ], + "bottom_right": [ + 809, + 558 + ] + }, + { + "building_name": "PolygonBuilding_1_part5", + "top_left": [ + 809, + 444 + ], + "top_right": [ + 817, + 444 + ], + "bottom_left": [ + 809, + 558 + ], + "bottom_right": [ + 817, + 558 + ] + }, + { + "building_name": "PolygonBuilding_1_part6", + "top_left": [ + 817, + 444 + ], + "top_right": [ + 825, + 444 + ], + "bottom_left": [ + 817, + 559 + ], + "bottom_right": [ + 825, + 559 + ] + }, + { + "building_name": "PolygonBuilding_1_part7", + "top_left": [ + 825, + 444 + ], + "top_right": [ + 833, + 444 + ], + "bottom_left": [ + 825, + 560 + ], + "bottom_right": [ + 833, + 560 + ] + }, + { + "building_name": "PolygonBuilding_1_part8", + "top_left": [ + 833, + 443 + ], + "top_right": [ + 841, + 443 + ], + "bottom_left": [ + 833, + 561 + ], + "bottom_right": [ + 841, + 561 + ] + }, + { + "building_name": "PolygonBuilding_1_part9", + "top_left": [ + 841, + 443 + ], + "top_right": [ + 849, + 443 + ], + "bottom_left": [ + 841, + 562 + ], + "bottom_right": [ + 849, + 562 + ] + }, + { + "building_name": "PolygonBuilding_1_part10", + "top_left": [ + 849, + 443 + ], + "top_right": [ + 857, + 443 + ], + "bottom_left": [ + 849, + 564 + ], + "bottom_right": [ + 857, + 564 + ] + }, + { + "building_name": "PolygonBuilding_1_part11", + "top_left": [ + 857, + 443 + ], + "top_right": [ + 865, + 443 + ], + "bottom_left": [ + 857, + 565 + ], + "bottom_right": [ + 865, + 565 + ] + }, + { + "building_name": "PolygonBuilding_1_part12", + "top_left": [ + 865, + 442 + ], + "top_right": [ + 873, + 442 + ], + "bottom_left": [ + 865, + 564 + ], + "bottom_right": [ + 873, + 564 + ] + }, + { + "building_name": "PolygonBuilding_1_part13", + "top_left": [ + 873, + 442 + ], + "top_right": [ + 881, + 442 + ], + "bottom_left": [ + 873, + 563 + ], + "bottom_right": [ + 881, + 563 + ] + }, + { + "building_name": "PolygonBuilding_1_part14", + "top_left": [ + 881, + 442 + ], + "top_right": [ + 889, + 442 + ], + "bottom_left": [ + 881, + 561 + ], + "bottom_right": [ + 889, + 561 + ] + }, + { + "building_name": "PolygonBuilding_1_part15", + "top_left": [ + 889, + 441 + ], + "top_right": [ + 897, + 441 + ], + "bottom_left": [ + 889, + 560 + ], + "bottom_right": [ + 897, + 560 + ] + }, + { + "building_name": "PolygonBuilding_1_part16", + "top_left": [ + 897, + 453 + ], + "top_right": [ + 905, + 453 + ], + "bottom_left": [ + 897, + 558 + ], + "bottom_right": [ + 905, + 558 + ] + }, + { + "building_name": "PolygonBuilding_2_part1", + "top_left": [ + 200, + 544 + ], + "top_right": [ + 208, + 544 + ], + "bottom_left": [ + 200, + 561 + ], + "bottom_right": [ + 208, + 561 + ] + }, + { + "building_name": "PolygonBuilding_2_part2", + "top_left": [ + 208, + 534 + ], + "top_right": [ + 216, + 534 + ], + "bottom_left": [ + 208, + 561 + ], + "bottom_right": [ + 216, + 561 + ] + }, + { + "building_name": "PolygonBuilding_2_part3", + "top_left": [ + 216, + 524 + ], + "top_right": [ + 224, + 524 + ], + "bottom_left": [ + 216, + 561 + ], + "bottom_right": [ + 224, + 561 + ] + }, + { + "building_name": "PolygonBuilding_2_part4", + "top_left": [ + 224, + 518 + ], + "top_right": [ + 232, + 518 + ], + "bottom_left": [ + 224, + 561 + ], + "bottom_right": [ + 232, + 561 + ] + }, + { + "building_name": "PolygonBuilding_2_part5", + "top_left": [ + 232, + 519 + ], + "top_right": [ + 240, + 519 + ], + "bottom_left": [ + 232, + 561 + ], + "bottom_right": [ + 240, + 561 + ] + }, + { + "building_name": "PolygonBuilding_2_part6", + "top_left": [ + 240, + 521 + ], + "top_right": [ + 248, + 521 + ], + "bottom_left": [ + 240, + 561 + ], + "bottom_right": [ + 248, + 561 + ] + }, + { + "building_name": "PolygonBuilding_2_part7", + "top_left": [ + 248, + 522 + ], + "top_right": [ + 256, + 522 + ], + "bottom_left": [ + 248, + 561 + ], + "bottom_right": [ + 256, + 561 + ] + }, + { + "building_name": "PolygonBuilding_2_part8", + "top_left": [ + 256, + 523 + ], + "top_right": [ + 264, + 523 + ], + "bottom_left": [ + 256, + 561 + ], + "bottom_right": [ + 264, + 561 + ] + }, + { + "building_name": "PolygonBuilding_2_part9", + "top_left": [ + 264, + 525 + ], + "top_right": [ + 272, + 525 + ], + "bottom_left": [ + 264, + 562 + ], + "bottom_right": [ + 272, + 562 + ] + }, + { + "building_name": "PolygonBuilding_2_part10", + "top_left": [ + 272, + 526 + ], + "top_right": [ + 280, + 526 + ], + "bottom_left": [ + 272, + 562 + ], + "bottom_right": [ + 280, + 562 + ] + }, + { + "building_name": "PolygonBuilding_2_part11", + "top_left": [ + 280, + 524 + ], + "top_right": [ + 288, + 524 + ], + "bottom_left": [ + 280, + 562 + ], + "bottom_right": [ + 288, + 562 + ] + }, + { + "building_name": "PolygonBuilding_2_part12", + "top_left": [ + 288, + 523 + ], + "top_right": [ + 296, + 523 + ], + "bottom_left": [ + 288, + 562 + ], + "bottom_right": [ + 296, + 562 + ] + }, + { + "building_name": "PolygonBuilding_2_part13", + "top_left": [ + 296, + 522 + ], + "top_right": [ + 304, + 522 + ], + "bottom_left": [ + 296, + 562 + ], + "bottom_right": [ + 304, + 562 + ] + }, + { + "building_name": "PolygonBuilding_2_part14", + "top_left": [ + 304, + 527 + ], + "top_right": [ + 312, + 527 + ], + "bottom_left": [ + 304, + 562 + ], + "bottom_right": [ + 312, + 562 + ] + }, + { + "building_name": "PolygonBuilding_2_part15", + "top_left": [ + 312, + 536 + ], + "top_right": [ + 320, + 536 + ], + "bottom_left": [ + 312, + 562 + ], + "bottom_right": [ + 320, + 562 + ] + }, + { + "building_name": "PolygonBuilding_2_part16", + "top_left": [ + 320, + 546 + ], + "top_right": [ + 328, + 546 + ], + "bottom_left": [ + 320, + 562 + ], + "bottom_right": [ + 328, + 562 + ] + }, + { + "building_name": "PolygonBuilding_2_part17", + "top_left": [ + 328, + 556 + ], + "top_right": [ + 336, + 556 + ], + "bottom_left": [ + 328, + 563 + ], + "bottom_right": [ + 336, + 563 + ] + }, + { + "building_name": "PolygonBuilding_3_part1", + "top_left": [ + 72, + 518 + ], + "top_right": [ + 80, + 518 + ], + "bottom_left": [ + 72, + 570 + ], + "bottom_right": [ + 80, + 570 + ] + }, + { + "building_name": "PolygonBuilding_3_part2", + "top_left": [ + 80, + 519 + ], + "top_right": [ + 88, + 519 + ], + "bottom_left": [ + 80, + 570 + ], + "bottom_right": [ + 88, + 570 + ] + }, + { + "building_name": "PolygonBuilding_3_part3", + "top_left": [ + 88, + 519 + ], + "top_right": [ + 96, + 519 + ], + "bottom_left": [ + 88, + 571 + ], + "bottom_right": [ + 96, + 571 + ] + }, + { + "building_name": "PolygonBuilding_3_part4", + "top_left": [ + 96, + 519 + ], + "top_right": [ + 104, + 519 + ], + "bottom_left": [ + 96, + 571 + ], + "bottom_right": [ + 104, + 571 + ] + }, + { + "building_name": "PolygonBuilding_3_part5", + "top_left": [ + 104, + 520 + ], + "top_right": [ + 112, + 520 + ], + "bottom_left": [ + 104, + 571 + ], + "bottom_right": [ + 112, + 571 + ] + }, + { + "building_name": "PolygonBuilding_3_part6", + "top_left": [ + 112, + 520 + ], + "top_right": [ + 120, + 520 + ], + "bottom_left": [ + 112, + 572 + ], + "bottom_right": [ + 120, + 572 + ] + }, + { + "building_name": "PolygonBuilding_3_part7", + "top_left": [ + 120, + 521 + ], + "top_right": [ + 128, + 521 + ], + "bottom_left": [ + 120, + 572 + ], + "bottom_right": [ + 128, + 572 + ] + }, + { + "building_name": "PolygonBuilding_3_part8", + "top_left": [ + 128, + 522 + ], + "top_right": [ + 136, + 522 + ], + "bottom_left": [ + 128, + 572 + ], + "bottom_right": [ + 136, + 572 + ] + }, + { + "building_name": "PolygonBuilding_3_part9", + "top_left": [ + 136, + 524 + ], + "top_right": [ + 144, + 524 + ], + "bottom_left": [ + 136, + 573 + ], + "bottom_right": [ + 144, + 573 + ] + }, + { + "building_name": "PolygonBuilding_3_part10", + "top_left": [ + 144, + 526 + ], + "top_right": [ + 152, + 526 + ], + "bottom_left": [ + 144, + 573 + ], + "bottom_right": [ + 152, + 573 + ] + }, + { + "building_name": "PolygonBuilding_3_part11", + "top_left": [ + 152, + 528 + ], + "top_right": [ + 160, + 528 + ], + "bottom_left": [ + 152, + 574 + ], + "bottom_right": [ + 160, + 574 + ] + }, + { + "building_name": "PolygonBuilding_3_part12", + "top_left": [ + 160, + 529 + ], + "top_right": [ + 168, + 529 + ], + "bottom_left": [ + 160, + 573 + ], + "bottom_right": [ + 168, + 573 + ] + }, + { + "building_name": "PolygonBuilding_3_part13", + "top_left": [ + 168, + 531 + ], + "top_right": [ + 176, + 531 + ], + "bottom_left": [ + 168, + 566 + ], + "bottom_right": [ + 176, + 566 + ] + }, + { + "building_name": "PolygonBuilding_3_part14", + "top_left": [ + 176, + 533 + ], + "top_right": [ + 184, + 533 + ], + "bottom_left": [ + 176, + 551 + ], + "bottom_right": [ + 184, + 551 + ] + }, + { + "building_name": "PolygonBuilding_3_part15", + "top_left": [ + 184, + 534 + ], + "top_right": [ + 187, + 534 + ], + "bottom_left": [ + 184, + 535 + ], + "bottom_right": [ + 187, + 535 + ] + }, + { + "building_name": "PolygonBuilding_4_part1", + "top_left": [ + 118, + 500 + ], + "top_right": [ + 126, + 500 + ], + "bottom_left": [ + 118, + 515 + ], + "bottom_right": [ + 126, + 515 + ] + }, + { + "building_name": "PolygonBuilding_4_part2", + "top_left": [ + 126, + 487 + ], + "top_right": [ + 134, + 487 + ], + "bottom_left": [ + 126, + 517 + ], + "bottom_right": [ + 134, + 517 + ] + }, + { + "building_name": "PolygonBuilding_4_part3", + "top_left": [ + 134, + 474 + ], + "top_right": [ + 142, + 474 + ], + "bottom_left": [ + 134, + 519 + ], + "bottom_right": [ + 142, + 519 + ] + }, + { + "building_name": "PolygonBuilding_4_part4", + "top_left": [ + 142, + 461 + ], + "top_right": [ + 150, + 461 + ], + "bottom_left": [ + 142, + 521 + ], + "bottom_right": [ + 150, + 521 + ] + }, + { + "building_name": "PolygonBuilding_4_part5", + "top_left": [ + 150, + 455 + ], + "top_right": [ + 158, + 455 + ], + "bottom_left": [ + 150, + 523 + ], + "bottom_right": [ + 158, + 523 + ] + }, + { + "building_name": "PolygonBuilding_4_part6", + "top_left": [ + 158, + 458 + ], + "top_right": [ + 166, + 458 + ], + "bottom_left": [ + 158, + 525 + ], + "bottom_right": [ + 166, + 525 + ] + }, + { + "building_name": "PolygonBuilding_4_part7", + "top_left": [ + 166, + 462 + ], + "top_right": [ + 174, + 462 + ], + "bottom_left": [ + 166, + 528 + ], + "bottom_right": [ + 174, + 528 + ] + }, + { + "building_name": "PolygonBuilding_4_part8", + "top_left": [ + 174, + 466 + ], + "top_right": [ + 182, + 466 + ], + "bottom_left": [ + 174, + 530 + ], + "bottom_right": [ + 182, + 530 + ] + }, + { + "building_name": "PolygonBuilding_4_part9", + "top_left": [ + 182, + 470 + ], + "top_right": [ + 190, + 470 + ], + "bottom_left": [ + 182, + 532 + ], + "bottom_right": [ + 190, + 532 + ] + }, + { + "building_name": "PolygonBuilding_4_part10", + "top_left": [ + 190, + 474 + ], + "top_right": [ + 198, + 474 + ], + "bottom_left": [ + 190, + 521 + ], + "bottom_right": [ + 198, + 521 + ] + }, + { + "building_name": "PolygonBuilding_4_part11", + "top_left": [ + 198, + 478 + ], + "top_right": [ + 206, + 478 + ], + "bottom_left": [ + 198, + 515 + ], + "bottom_right": [ + 206, + 515 + ] + }, + { + "building_name": "PolygonBuilding_4_part12", + "top_left": [ + 206, + 482 + ], + "top_right": [ + 214, + 482 + ], + "bottom_left": [ + 206, + 503 + ], + "bottom_right": [ + 214, + 503 + ] + }, + { + "building_name": "PolygonBuilding_4_part13", + "top_left": [ + 214, + 486 + ], + "top_right": [ + 218, + 486 + ], + "bottom_left": [ + 214, + 489 + ], + "bottom_right": [ + 218, + 489 + ] + }, + { + "building_name": "PolygonBuilding_5_part1", + "top_left": [ + 378, + 496 + ], + "top_right": [ + 386, + 496 + ], + "bottom_left": [ + 378, + 580 + ], + "bottom_right": [ + 386, + 580 + ] + }, + { + "building_name": "PolygonBuilding_5_part2", + "top_left": [ + 386, + 496 + ], + "top_right": [ + 394, + 496 + ], + "bottom_left": [ + 386, + 579 + ], + "bottom_right": [ + 394, + 579 + ] + }, + { + "building_name": "PolygonBuilding_5_part3", + "top_left": [ + 394, + 496 + ], + "top_right": [ + 402, + 496 + ], + "bottom_left": [ + 394, + 579 + ], + "bottom_right": [ + 402, + 579 + ] + }, + { + "building_name": "PolygonBuilding_5_part4", + "top_left": [ + 402, + 496 + ], + "top_right": [ + 410, + 496 + ], + "bottom_left": [ + 402, + 579 + ], + "bottom_right": [ + 410, + 579 + ] + }, + { + "building_name": "PolygonBuilding_5_part5", + "top_left": [ + 410, + 496 + ], + "top_right": [ + 418, + 496 + ], + "bottom_left": [ + 410, + 579 + ], + "bottom_right": [ + 418, + 579 + ] + }, + { + "building_name": "PolygonBuilding_5_part6", + "top_left": [ + 418, + 496 + ], + "top_right": [ + 426, + 496 + ], + "bottom_left": [ + 418, + 578 + ], + "bottom_right": [ + 426, + 578 + ] + }, + { + "building_name": "PolygonBuilding_5_part7", + "top_left": [ + 426, + 496 + ], + "top_right": [ + 434, + 496 + ], + "bottom_left": [ + 426, + 578 + ], + "bottom_right": [ + 434, + 578 + ] + }, + { + "building_name": "PolygonBuilding_5_part8", + "top_left": [ + 434, + 496 + ], + "top_right": [ + 442, + 496 + ], + "bottom_left": [ + 434, + 578 + ], + "bottom_right": [ + 442, + 578 + ] + }, + { + "building_name": "PolygonBuilding_5_part9", + "top_left": [ + 442, + 496 + ], + "top_right": [ + 450, + 496 + ], + "bottom_left": [ + 442, + 578 + ], + "bottom_right": [ + 450, + 578 + ] + }, + { + "building_name": "PolygonBuilding_5_part10", + "top_left": [ + 450, + 496 + ], + "top_right": [ + 458, + 496 + ], + "bottom_left": [ + 450, + 577 + ], + "bottom_right": [ + 458, + 577 + ] + }, + { + "building_name": "PolygonBuilding_5_part11", + "top_left": [ + 458, + 496 + ], + "top_right": [ + 466, + 496 + ], + "bottom_left": [ + 458, + 577 + ], + "bottom_right": [ + 466, + 577 + ] + }, + { + "building_name": "PolygonBuilding_5_part12", + "top_left": [ + 466, + 496 + ], + "top_right": [ + 474, + 496 + ], + "bottom_left": [ + 466, + 577 + ], + "bottom_right": [ + 474, + 577 + ] + }, + { + "building_name": "PolygonBuilding_5_part13", + "top_left": [ + 474, + 496 + ], + "top_right": [ + 478, + 496 + ], + "bottom_left": [ + 474, + 577 + ], + "bottom_right": [ + 478, + 577 + ] + }, + { + "building_name": "PolygonBuilding_6_part1", + "top_left": [ + 30, + 362 + ], + "top_right": [ + 38, + 362 + ], + "bottom_left": [ + 30, + 407 + ], + "bottom_right": [ + 38, + 407 + ] + }, + { + "building_name": "PolygonBuilding_6_part2", + "top_left": [ + 38, + 318 + ], + "top_right": [ + 46, + 318 + ], + "bottom_left": [ + 38, + 408 + ], + "bottom_right": [ + 46, + 408 + ] + }, + { + "building_name": "PolygonBuilding_6_part3", + "top_left": [ + 46, + 303 + ], + "top_right": [ + 54, + 303 + ], + "bottom_left": [ + 46, + 407 + ], + "bottom_right": [ + 54, + 407 + ] + }, + { + "building_name": "PolygonBuilding_6_part4", + "top_left": [ + 54, + 302 + ], + "top_right": [ + 62, + 302 + ], + "bottom_left": [ + 54, + 407 + ], + "bottom_right": [ + 62, + 407 + ] + }, + { + "building_name": "PolygonBuilding_6_part5", + "top_left": [ + 62, + 301 + ], + "top_right": [ + 70, + 301 + ], + "bottom_left": [ + 62, + 407 + ], + "bottom_right": [ + 70, + 407 + ] + }, + { + "building_name": "PolygonBuilding_6_part6", + "top_left": [ + 70, + 301 + ], + "top_right": [ + 78, + 301 + ], + "bottom_left": [ + 70, + 407 + ], + "bottom_right": [ + 78, + 407 + ] + }, + { + "building_name": "PolygonBuilding_6_part7", + "top_left": [ + 78, + 300 + ], + "top_right": [ + 86, + 300 + ], + "bottom_left": [ + 78, + 407 + ], + "bottom_right": [ + 86, + 407 + ] + }, + { + "building_name": "PolygonBuilding_6_part8", + "top_left": [ + 86, + 301 + ], + "top_right": [ + 94, + 301 + ], + "bottom_left": [ + 86, + 407 + ], + "bottom_right": [ + 94, + 407 + ] + }, + { + "building_name": "PolygonBuilding_6_part9", + "top_left": [ + 94, + 302 + ], + "top_right": [ + 102, + 302 + ], + "bottom_left": [ + 94, + 407 + ], + "bottom_right": [ + 102, + 407 + ] + }, + { + "building_name": "PolygonBuilding_6_part10", + "top_left": [ + 102, + 303 + ], + "top_right": [ + 110, + 303 + ], + "bottom_left": [ + 102, + 407 + ], + "bottom_right": [ + 110, + 407 + ] + }, + { + "building_name": "PolygonBuilding_6_part11", + "top_left": [ + 110, + 304 + ], + "top_right": [ + 118, + 304 + ], + "bottom_left": [ + 110, + 407 + ], + "bottom_right": [ + 118, + 407 + ] + }, + { + "building_name": "PolygonBuilding_6_part12", + "top_left": [ + 118, + 304 + ], + "top_right": [ + 126, + 304 + ], + "bottom_left": [ + 118, + 407 + ], + "bottom_right": [ + 126, + 407 + ] + }, + { + "building_name": "PolygonBuilding_7_part1", + "top_left": [ + 528, + 541 + ], + "top_right": [ + 536, + 541 + ], + "bottom_left": [ + 528, + 574 + ], + "bottom_right": [ + 536, + 574 + ] + }, + { + "building_name": "PolygonBuilding_7_part2", + "top_left": [ + 536, + 542 + ], + "top_right": [ + 544, + 542 + ], + "bottom_left": [ + 536, + 574 + ], + "bottom_right": [ + 544, + 574 + ] + }, + { + "building_name": "PolygonBuilding_7_part3", + "top_left": [ + 544, + 539 + ], + "top_right": [ + 552, + 539 + ], + "bottom_left": [ + 544, + 574 + ], + "bottom_right": [ + 552, + 574 + ] + }, + { + "building_name": "PolygonBuilding_7_part4", + "top_left": [ + 552, + 539 + ], + "top_right": [ + 560, + 539 + ], + "bottom_left": [ + 552, + 574 + ], + "bottom_right": [ + 560, + 574 + ] + }, + { + "building_name": "PolygonBuilding_7_part5", + "top_left": [ + 560, + 540 + ], + "top_right": [ + 568, + 540 + ], + "bottom_left": [ + 560, + 574 + ], + "bottom_right": [ + 568, + 574 + ] + }, + { + "building_name": "PolygonBuilding_7_part6", + "top_left": [ + 568, + 544 + ], + "top_right": [ + 576, + 544 + ], + "bottom_left": [ + 568, + 574 + ], + "bottom_right": [ + 576, + 574 + ] + }, + { + "building_name": "PolygonBuilding_7_part7", + "top_left": [ + 576, + 520 + ], + "top_right": [ + 584, + 520 + ], + "bottom_left": [ + 576, + 574 + ], + "bottom_right": [ + 584, + 574 + ] + }, + { + "building_name": "PolygonBuilding_7_part8", + "top_left": [ + 584, + 519 + ], + "top_right": [ + 592, + 519 + ], + "bottom_left": [ + 584, + 574 + ], + "bottom_right": [ + 592, + 574 + ] + }, + { + "building_name": "PolygonBuilding_7_part9", + "top_left": [ + 592, + 519 + ], + "top_right": [ + 600, + 519 + ], + "bottom_left": [ + 592, + 574 + ], + "bottom_right": [ + 600, + 574 + ] + }, + { + "building_name": "PolygonBuilding_7_part10", + "top_left": [ + 600, + 518 + ], + "top_right": [ + 608, + 518 + ], + "bottom_left": [ + 600, + 574 + ], + "bottom_right": [ + 608, + 574 + ] + }, + { + "building_name": "PolygonBuilding_7_part11", + "top_left": [ + 608, + 517 + ], + "top_right": [ + 616, + 517 + ], + "bottom_left": [ + 608, + 575 + ], + "bottom_right": [ + 616, + 575 + ] + }, + { + "building_name": "PolygonBuilding_7_part12", + "top_left": [ + 616, + 521 + ], + "top_right": [ + 619, + 521 + ], + "bottom_left": [ + 616, + 548 + ], + "bottom_right": [ + 619, + 548 + ] + }, + { + "building_name": "PolygonBuilding_8_part1", + "top_left": [ + 679, + 415 + ], + "top_right": [ + 687, + 415 + ], + "bottom_left": [ + 679, + 534 + ], + "bottom_right": [ + 687, + 534 + ] + }, + { + "building_name": "PolygonBuilding_8_part2", + "top_left": [ + 687, + 414 + ], + "top_right": [ + 695, + 414 + ], + "bottom_left": [ + 687, + 534 + ], + "bottom_right": [ + 695, + 534 + ] + }, + { + "building_name": "PolygonBuilding_8_part3", + "top_left": [ + 695, + 413 + ], + "top_right": [ + 703, + 413 + ], + "bottom_left": [ + 695, + 534 + ], + "bottom_right": [ + 703, + 534 + ] + }, + { + "building_name": "PolygonBuilding_8_part4", + "top_left": [ + 703, + 412 + ], + "top_right": [ + 711, + 412 + ], + "bottom_left": [ + 703, + 534 + ], + "bottom_right": [ + 711, + 534 + ] + }, + { + "building_name": "PolygonBuilding_8_part5", + "top_left": [ + 711, + 411 + ], + "top_right": [ + 719, + 411 + ], + "bottom_left": [ + 711, + 534 + ], + "bottom_right": [ + 719, + 534 + ] + }, + { + "building_name": "PolygonBuilding_8_part6", + "top_left": [ + 719, + 415 + ], + "top_right": [ + 727, + 415 + ], + "bottom_left": [ + 719, + 534 + ], + "bottom_right": [ + 727, + 534 + ] + }, + { + "building_name": "PolygonBuilding_8_part7", + "top_left": [ + 727, + 414 + ], + "top_right": [ + 735, + 414 + ], + "bottom_left": [ + 727, + 534 + ], + "bottom_right": [ + 735, + 534 + ] + }, + { + "building_name": "PolygonBuilding_8_part8", + "top_left": [ + 735, + 414 + ], + "top_right": [ + 743, + 414 + ], + "bottom_left": [ + 735, + 534 + ], + "bottom_right": [ + 743, + 534 + ] + }, + { + "building_name": "PolygonBuilding_8_part9", + "top_left": [ + 743, + 413 + ], + "top_right": [ + 750, + 413 + ], + "bottom_left": [ + 743, + 491 + ], + "bottom_right": [ + 750, + 491 + ] + }, + { + "building_name": "PolygonBuilding_8_part10", + "top_left": [ + 743, + 531 + ], + "top_right": [ + 750, + 531 + ], + "bottom_left": [ + 743, + 532 + ], + "bottom_right": [ + 750, + 532 + ] + }, + { + "building_name": "PolygonBuilding_9_part1", + "top_left": [ + 527, + 594 + ], + "top_right": [ + 535, + 594 + ], + "bottom_left": [ + 527, + 654 + ], + "bottom_right": [ + 535, + 654 + ] + }, + { + "building_name": "PolygonBuilding_9_part2", + "top_left": [ + 535, + 594 + ], + "top_right": [ + 543, + 594 + ], + "bottom_left": [ + 535, + 654 + ], + "bottom_right": [ + 543, + 654 + ] + }, + { + "building_name": "PolygonBuilding_9_part3", + "top_left": [ + 543, + 594 + ], + "top_right": [ + 551, + 594 + ], + "bottom_left": [ + 543, + 654 + ], + "bottom_right": [ + 551, + 654 + ] + }, + { + "building_name": "PolygonBuilding_9_part4", + "top_left": [ + 551, + 594 + ], + "top_right": [ + 559, + 594 + ], + "bottom_left": [ + 551, + 654 + ], + "bottom_right": [ + 559, + 654 + ] + }, + { + "building_name": "PolygonBuilding_9_part5", + "top_left": [ + 559, + 594 + ], + "top_right": [ + 567, + 594 + ], + "bottom_left": [ + 559, + 654 + ], + "bottom_right": [ + 567, + 654 + ] + }, + { + "building_name": "PolygonBuilding_9_part6", + "top_left": [ + 567, + 594 + ], + "top_right": [ + 575, + 594 + ], + "bottom_left": [ + 567, + 654 + ], + "bottom_right": [ + 575, + 654 + ] + }, + { + "building_name": "PolygonBuilding_9_part7", + "top_left": [ + 575, + 594 + ], + "top_right": [ + 583, + 594 + ], + "bottom_left": [ + 575, + 655 + ], + "bottom_right": [ + 583, + 655 + ] + }, + { + "building_name": "PolygonBuilding_9_part8", + "top_left": [ + 583, + 594 + ], + "top_right": [ + 591, + 594 + ], + "bottom_left": [ + 583, + 655 + ], + "bottom_right": [ + 591, + 655 + ] + }, + { + "building_name": "PolygonBuilding_9_part9", + "top_left": [ + 591, + 594 + ], + "top_right": [ + 599, + 594 + ], + "bottom_left": [ + 591, + 655 + ], + "bottom_right": [ + 599, + 655 + ] + }, + { + "building_name": "PolygonBuilding_9_part10", + "top_left": [ + 599, + 594 + ], + "top_right": [ + 607, + 594 + ], + "bottom_left": [ + 599, + 655 + ], + "bottom_right": [ + 607, + 655 + ] + }, + { + "building_name": "PolygonBuilding_9_part11", + "top_left": [ + 607, + 594 + ], + "top_right": [ + 615, + 594 + ], + "bottom_left": [ + 607, + 655 + ], + "bottom_right": [ + 615, + 655 + ] + }, + { + "building_name": "PolygonBuilding_9_part12", + "top_left": [ + 615, + 594 + ], + "top_right": [ + 623, + 594 + ], + "bottom_left": [ + 615, + 656 + ], + "bottom_right": [ + 623, + 656 + ] + }, + { + "building_name": "PolygonBuilding_10_part1", + "top_left": [ + 590, + 428 + ], + "top_right": [ + 598, + 428 + ], + "bottom_left": [ + 590, + 464 + ], + "bottom_right": [ + 598, + 464 + ] + }, + { + "building_name": "PolygonBuilding_10_part2", + "top_left": [ + 598, + 421 + ], + "top_right": [ + 606, + 421 + ], + "bottom_left": [ + 598, + 472 + ], + "bottom_right": [ + 606, + 472 + ] + }, + { + "building_name": "PolygonBuilding_10_part3", + "top_left": [ + 606, + 416 + ], + "top_right": [ + 614, + 416 + ], + "bottom_left": [ + 606, + 481 + ], + "bottom_right": [ + 614, + 481 + ] + }, + { + "building_name": "PolygonBuilding_10_part4", + "top_left": [ + 614, + 411 + ], + "top_right": [ + 622, + 411 + ], + "bottom_left": [ + 614, + 482 + ], + "bottom_right": [ + 622, + 482 + ] + }, + { + "building_name": "PolygonBuilding_10_part5", + "top_left": [ + 622, + 414 + ], + "top_right": [ + 630, + 414 + ], + "bottom_left": [ + 622, + 482 + ], + "bottom_right": [ + 630, + 482 + ] + }, + { + "building_name": "PolygonBuilding_10_part6", + "top_left": [ + 630, + 422 + ], + "top_right": [ + 638, + 422 + ], + "bottom_left": [ + 630, + 483 + ], + "bottom_right": [ + 638, + 483 + ] + }, + { + "building_name": "PolygonBuilding_10_part7", + "top_left": [ + 638, + 439 + ], + "top_right": [ + 646, + 439 + ], + "bottom_left": [ + 638, + 476 + ], + "bottom_right": [ + 646, + 476 + ] + }, + { + "building_name": "PolygonBuilding_10_part8", + "top_left": [ + 646, + 448 + ], + "top_right": [ + 654, + 448 + ], + "bottom_left": [ + 646, + 470 + ], + "bottom_right": [ + 654, + 470 + ] + }, + { + "building_name": "PolygonBuilding_10_part9", + "top_left": [ + 654, + 456 + ], + "top_right": [ + 657, + 456 + ], + "bottom_left": [ + 654, + 458 + ], + "bottom_right": [ + 657, + 458 + ] + }, + { + "building_name": "PolygonBuilding_11_part1", + "top_left": [ + 475, + 317 + ], + "top_right": [ + 483, + 317 + ], + "bottom_left": [ + 475, + 375 + ], + "bottom_right": [ + 483, + 375 + ] + }, + { + "building_name": "PolygonBuilding_11_part2", + "top_left": [ + 483, + 319 + ], + "top_right": [ + 491, + 319 + ], + "bottom_left": [ + 483, + 376 + ], + "bottom_right": [ + 491, + 376 + ] + }, + { + "building_name": "PolygonBuilding_11_part3", + "top_left": [ + 491, + 321 + ], + "top_right": [ + 499, + 321 + ], + "bottom_left": [ + 491, + 377 + ], + "bottom_right": [ + 499, + 377 + ] + }, + { + "building_name": "PolygonBuilding_11_part4", + "top_left": [ + 499, + 321 + ], + "top_right": [ + 507, + 321 + ], + "bottom_left": [ + 499, + 378 + ], + "bottom_right": [ + 507, + 378 + ] + }, + { + "building_name": "PolygonBuilding_11_part5", + "top_left": [ + 507, + 321 + ], + "top_right": [ + 515, + 321 + ], + "bottom_left": [ + 507, + 377 + ], + "bottom_right": [ + 515, + 377 + ] + }, + { + "building_name": "PolygonBuilding_11_part6", + "top_left": [ + 515, + 321 + ], + "top_right": [ + 523, + 321 + ], + "bottom_left": [ + 515, + 377 + ], + "bottom_right": [ + 523, + 377 + ] + }, + { + "building_name": "PolygonBuilding_11_part7", + "top_left": [ + 523, + 316 + ], + "top_right": [ + 531, + 316 + ], + "bottom_left": [ + 523, + 377 + ], + "bottom_right": [ + 531, + 377 + ] + }, + { + "building_name": "PolygonBuilding_11_part8", + "top_left": [ + 531, + 316 + ], + "top_right": [ + 539, + 316 + ], + "bottom_left": [ + 531, + 377 + ], + "bottom_right": [ + 539, + 377 + ] + }, + { + "building_name": "PolygonBuilding_11_part9", + "top_left": [ + 539, + 316 + ], + "top_right": [ + 547, + 316 + ], + "bottom_left": [ + 539, + 377 + ], + "bottom_right": [ + 547, + 377 + ] + }, + { + "building_name": "PolygonBuilding_11_part10", + "top_left": [ + 547, + 316 + ], + "top_right": [ + 555, + 316 + ], + "bottom_left": [ + 547, + 377 + ], + "bottom_right": [ + 555, + 377 + ] + }, + { + "building_name": "PolygonBuilding_12_part1", + "top_left": [ + 143, + 336 + ], + "top_right": [ + 151, + 336 + ], + "bottom_left": [ + 143, + 405 + ], + "bottom_right": [ + 151, + 405 + ] + }, + { + "building_name": "PolygonBuilding_12_part2", + "top_left": [ + 151, + 314 + ], + "top_right": [ + 159, + 314 + ], + "bottom_left": [ + 151, + 404 + ], + "bottom_right": [ + 159, + 404 + ] + }, + { + "building_name": "PolygonBuilding_12_part3", + "top_left": [ + 159, + 313 + ], + "top_right": [ + 167, + 313 + ], + "bottom_left": [ + 159, + 404 + ], + "bottom_right": [ + 167, + 404 + ] + }, + { + "building_name": "PolygonBuilding_12_part4", + "top_left": [ + 167, + 311 + ], + "top_right": [ + 175, + 311 + ], + "bottom_left": [ + 167, + 404 + ], + "bottom_right": [ + 175, + 404 + ] + }, + { + "building_name": "PolygonBuilding_12_part5", + "top_left": [ + 175, + 310 + ], + "top_right": [ + 183, + 310 + ], + "bottom_left": [ + 175, + 404 + ], + "bottom_right": [ + 183, + 404 + ] + }, + { + "building_name": "PolygonBuilding_12_part6", + "top_left": [ + 183, + 309 + ], + "top_right": [ + 191, + 309 + ], + "bottom_left": [ + 183, + 404 + ], + "bottom_right": [ + 191, + 404 + ] + }, + { + "building_name": "PolygonBuilding_12_part7", + "top_left": [ + 191, + 309 + ], + "top_right": [ + 199, + 309 + ], + "bottom_left": [ + 191, + 404 + ], + "bottom_right": [ + 199, + 404 + ] + }, + { + "building_name": "PolygonBuilding_12_part8", + "top_left": [ + 199, + 309 + ], + "top_right": [ + 207, + 309 + ], + "bottom_left": [ + 199, + 404 + ], + "bottom_right": [ + 207, + 404 + ] + }, + { + "building_name": "PolygonBuilding_12_part9", + "top_left": [ + 207, + 309 + ], + "top_right": [ + 215, + 309 + ], + "bottom_left": [ + 207, + 404 + ], + "bottom_right": [ + 215, + 404 + ] + }, + { + "building_name": "PolygonBuilding_12_part10", + "top_left": [ + 215, + 308 + ], + "top_right": [ + 223, + 308 + ], + "bottom_left": [ + 215, + 404 + ], + "bottom_right": [ + 223, + 404 + ] + }, + { + "building_name": "PolygonBuilding_12_part11", + "top_left": [ + 223, + 310 + ], + "top_right": [ + 231, + 310 + ], + "bottom_left": [ + 223, + 404 + ], + "bottom_right": [ + 231, + 404 + ] + }, + { + "building_name": "PolygonBuilding_12_part12", + "top_left": [ + 231, + 315 + ], + "top_right": [ + 235, + 315 + ], + "bottom_left": [ + 231, + 325 + ], + "bottom_right": [ + 235, + 325 + ] + }, + { + "building_name": "PolygonBuilding_12_part13", + "top_left": [ + 231, + 349 + ], + "top_right": [ + 235, + 349 + ], + "bottom_left": [ + 231, + 404 + ], + "bottom_right": [ + 235, + 404 + ] + }, + { + "building_name": "Building_1", + "top_left": [ + 572, + 342 + ], + "top_right": [ + 633, + 342 + ], + "bottom_left": [ + 572, + 393 + ], + "bottom_right": [ + 633, + 393 + ] + }, + { + "building_name": "Building_2", + "top_left": [ + 658, + 342 + ], + "top_right": [ + 799, + 342 + ], + "bottom_left": [ + 658, + 372 + ], + "bottom_right": [ + 799, + 372 + ] + }, + { + "building_name": "Building_3", + "top_left": [ + 819, + 343 + ], + "top_right": [ + 885, + 343 + ], + "bottom_left": [ + 819, + 390 + ], + "bottom_right": [ + 885, + 390 + ] + }, + { + "building_name": "Building_4", + "top_left": [ + 905, + 309 + ], + "top_right": [ + 959, + 309 + ], + "bottom_left": [ + 905, + 368 + ], + "bottom_right": [ + 959, + 368 + ] + }, + { + "building_name": "Building_5", + "top_left": [ + 961, + 185 + ], + "top_right": [ + 1013, + 185 + ], + "bottom_left": [ + 961, + 242 + ], + "bottom_right": [ + 1013, + 242 + ] + }, + { + "building_name": "Building_6", + "top_left": [ + 459, + 45 + ], + "top_right": [ + 626, + 45 + ], + "bottom_left": [ + 459, + 246 + ], + "bottom_right": [ + 626, + 246 + ] + }, + { + "building_name": "PolygonBuilding_13_part1", + "top_left": [ + 451, + 437 + ], + "top_right": [ + 459, + 437 + ], + "bottom_left": [ + 451, + 489 + ], + "bottom_right": [ + 459, + 489 + ] + }, + { + "building_name": "PolygonBuilding_13_part2", + "top_left": [ + 459, + 437 + ], + "top_right": [ + 467, + 437 + ], + "bottom_left": [ + 459, + 489 + ], + "bottom_right": [ + 467, + 489 + ] + }, + { + "building_name": "PolygonBuilding_13_part3", + "top_left": [ + 467, + 437 + ], + "top_right": [ + 475, + 437 + ], + "bottom_left": [ + 467, + 489 + ], + "bottom_right": [ + 475, + 489 + ] + }, + { + "building_name": "PolygonBuilding_13_part4", + "top_left": [ + 475, + 437 + ], + "top_right": [ + 483, + 437 + ], + "bottom_left": [ + 475, + 489 + ], + "bottom_right": [ + 483, + 489 + ] + }, + { + "building_name": "PolygonBuilding_13_part5", + "top_left": [ + 483, + 437 + ], + "top_right": [ + 491, + 437 + ], + "bottom_left": [ + 483, + 580 + ], + "bottom_right": [ + 491, + 580 + ] + }, + { + "building_name": "PolygonBuilding_13_part6", + "top_left": [ + 491, + 437 + ], + "top_right": [ + 499, + 437 + ], + "bottom_left": [ + 491, + 579 + ], + "bottom_right": [ + 499, + 579 + ] + }, + { + "building_name": "PolygonBuilding_13_part7", + "top_left": [ + 499, + 437 + ], + "top_right": [ + 507, + 437 + ], + "bottom_left": [ + 499, + 579 + ], + "bottom_right": [ + 507, + 579 + ] + }, + { + "building_name": "PolygonBuilding_13_part8", + "top_left": [ + 507, + 437 + ], + "top_right": [ + 515, + 437 + ], + "bottom_left": [ + 507, + 578 + ], + "bottom_right": [ + 515, + 578 + ] + }, + { + "building_name": "PolygonBuilding_13_part9", + "top_left": [ + 515, + 437 + ], + "top_right": [ + 523, + 437 + ], + "bottom_left": [ + 515, + 578 + ], + "bottom_right": [ + 523, + 578 + ] + }, + { + "building_name": "PolygonBuilding_13_part10", + "top_left": [ + 523, + 437 + ], + "top_right": [ + 531, + 437 + ], + "bottom_left": [ + 523, + 512 + ], + "bottom_right": [ + 531, + 512 + ] + }, + { + "building_name": "PolygonBuilding_13_part11", + "top_left": [ + 531, + 437 + ], + "top_right": [ + 539, + 437 + ], + "bottom_left": [ + 531, + 512 + ], + "bottom_right": [ + 539, + 512 + ] + }, + { + "building_name": "PolygonBuilding_13_part12", + "top_left": [ + 539, + 437 + ], + "top_right": [ + 547, + 437 + ], + "bottom_left": [ + 539, + 512 + ], + "bottom_right": [ + 547, + 512 + ] + }, + { + "building_name": "PolygonBuilding_13_part13", + "top_left": [ + 547, + 437 + ], + "top_right": [ + 555, + 437 + ], + "bottom_left": [ + 547, + 512 + ], + "bottom_right": [ + 555, + 512 + ] + }, + { + "building_name": "PolygonBuilding_13_part14", + "top_left": [ + 555, + 437 + ], + "top_right": [ + 563, + 437 + ], + "bottom_left": [ + 555, + 512 + ], + "bottom_right": [ + 563, + 512 + ] + }, + { + "building_name": "PolygonBuilding_13_part15", + "top_left": [ + 563, + 437 + ], + "top_right": [ + 571, + 437 + ], + "bottom_left": [ + 563, + 512 + ], + "bottom_right": [ + 571, + 512 + ] + }, + { + "building_name": "PolygonBuilding_13_part16", + "top_left": [ + 571, + 437 + ], + "top_right": [ + 579, + 437 + ], + "bottom_left": [ + 571, + 512 + ], + "bottom_right": [ + 579, + 512 + ] + }, + { + "building_name": "PolygonBuilding_13_part17", + "top_left": [ + 579, + 437 + ], + "top_right": [ + 587, + 437 + ], + "bottom_left": [ + 579, + 512 + ], + "bottom_right": [ + 587, + 512 + ] + }, + { + "building_name": "PolygonBuilding_13_part18", + "top_left": [ + 587, + 465 + ], + "top_right": [ + 595, + 465 + ], + "bottom_left": [ + 587, + 512 + ], + "bottom_right": [ + 595, + 512 + ] + }, + { + "building_name": "PolygonBuilding_13_part19", + "top_left": [ + 595, + 473 + ], + "top_right": [ + 603, + 473 + ], + "bottom_left": [ + 595, + 512 + ], + "bottom_right": [ + 603, + 512 + ] + }, + { + "building_name": "PolygonBuilding_13_part20", + "top_left": [ + 603, + 483 + ], + "top_right": [ + 607, + 483 + ], + "bottom_left": [ + 603, + 513 + ], + "bottom_right": [ + 607, + 513 + ] + }, + { + "building_name": "Building_7", + "top_left": [ + 409, + 295 + ], + "top_right": [ + 452, + 295 + ], + "bottom_left": [ + 409, + 378 + ], + "bottom_right": [ + 452, + 378 + ] + }, + { + "building_name": "PolygonBuilding_14_part1", + "top_left": [ + 285, + 329 + ], + "top_right": [ + 293, + 329 + ], + "bottom_left": [ + 285, + 398 + ], + "bottom_right": [ + 293, + 398 + ] + }, + { + "building_name": "PolygonBuilding_14_part2", + "top_left": [ + 293, + 294 + ], + "top_right": [ + 301, + 294 + ], + "bottom_left": [ + 293, + 398 + ], + "bottom_right": [ + 301, + 398 + ] + }, + { + "building_name": "PolygonBuilding_14_part3", + "top_left": [ + 301, + 295 + ], + "top_right": [ + 309, + 295 + ], + "bottom_left": [ + 301, + 398 + ], + "bottom_right": [ + 309, + 398 + ] + }, + { + "building_name": "PolygonBuilding_14_part4", + "top_left": [ + 309, + 295 + ], + "top_right": [ + 317, + 295 + ], + "bottom_left": [ + 309, + 398 + ], + "bottom_right": [ + 317, + 398 + ] + }, + { + "building_name": "PolygonBuilding_14_part5", + "top_left": [ + 317, + 295 + ], + "top_right": [ + 325, + 295 + ], + "bottom_left": [ + 317, + 398 + ], + "bottom_right": [ + 325, + 398 + ] + }, + { + "building_name": "PolygonBuilding_14_part6", + "top_left": [ + 325, + 295 + ], + "top_right": [ + 333, + 295 + ], + "bottom_left": [ + 325, + 399 + ], + "bottom_right": [ + 333, + 399 + ] + }, + { + "building_name": "PolygonBuilding_14_part7", + "top_left": [ + 333, + 295 + ], + "top_right": [ + 341, + 295 + ], + "bottom_left": [ + 333, + 399 + ], + "bottom_right": [ + 341, + 399 + ] + }, + { + "building_name": "PolygonBuilding_14_part8", + "top_left": [ + 341, + 295 + ], + "top_right": [ + 349, + 295 + ], + "bottom_left": [ + 341, + 399 + ], + "bottom_right": [ + 349, + 399 + ] + }, + { + "building_name": "PolygonBuilding_14_part9", + "top_left": [ + 349, + 295 + ], + "top_right": [ + 357, + 295 + ], + "bottom_left": [ + 349, + 399 + ], + "bottom_right": [ + 357, + 399 + ] + }, + { + "building_name": "PolygonBuilding_14_part10", + "top_left": [ + 357, + 295 + ], + "top_right": [ + 365, + 295 + ], + "bottom_left": [ + 357, + 399 + ], + "bottom_right": [ + 365, + 399 + ] + }, + { + "building_name": "PolygonBuilding_14_part11", + "top_left": [ + 365, + 300 + ], + "top_right": [ + 373, + 300 + ], + "bottom_left": [ + 365, + 400 + ], + "bottom_right": [ + 373, + 400 + ] + }, + { + "building_name": "PolygonBuilding_14_part12", + "top_left": [ + 373, + 320 + ], + "top_right": [ + 378, + 320 + ], + "bottom_left": [ + 373, + 395 + ], + "bottom_right": [ + 378, + 395 + ] + }, + { + "building_name": "Building_8", + "top_left": [ + 276, + 413 + ], + "top_right": [ + 375, + 413 + ], + "bottom_left": [ + 276, + 497 + ], + "bottom_right": [ + 375, + 497 + ] + }, + { + "building_name": "Building_9", + "top_left": [ + 714, + 210 + ], + "top_right": [ + 734, + 210 + ], + "bottom_left": [ + 714, + 230 + ], + "bottom_right": [ + 734, + 230 + ] + }, + { + "building_name": "Building_10", + "top_left": [ + 696, + 72 + ], + "top_right": [ + 759, + 72 + ], + "bottom_left": [ + 696, + 142 + ], + "bottom_right": [ + 759, + 142 + ] + }, + { + "building_name": "Building_11", + "top_left": [ + 632, + 908 + ], + "top_right": [ + 653, + 908 + ], + "bottom_left": [ + 632, + 941 + ], + "bottom_right": [ + 653, + 941 + ] + }, + { + "building_name": "Building_12", + "top_left": [ + 656, + 908 + ], + "top_right": [ + 675, + 908 + ], + "bottom_left": [ + 656, + 940 + ], + "bottom_right": [ + 675, + 940 + ] + }, + { + "building_name": "Building_13", + "top_left": [ + 633, + 943 + ], + "top_right": [ + 653, + 943 + ], + "bottom_left": [ + 633, + 976 + ], + "bottom_right": [ + 653, + 976 + ] + }, + { + "building_name": "Building_14", + "top_left": [ + 656, + 943 + ], + "top_right": [ + 676, + 943 + ], + "bottom_left": [ + 656, + 976 + ], + "bottom_right": [ + 676, + 976 + ] + }, + { + "building_name": "Building_15", + "top_left": [ + 419, + 417 + ], + "top_right": [ + 441, + 417 + ], + "bottom_left": [ + 419, + 486 + ], + "bottom_right": [ + 441, + 486 + ] + }, + { + "building_name": "Building_16", + "top_left": [ + 382, + 415 + ], + "top_right": [ + 410, + 415 + ], + "bottom_left": [ + 382, + 485 + ], + "bottom_right": [ + 410, + 485 + ] + }, + { + "building_name": "Building_17", + "top_left": [ + 974, + 337 + ], + "top_right": [ + 1079, + 337 + ], + "bottom_left": [ + 974, + 512 + ], + "bottom_right": [ + 1079, + 512 + ] + }, + { + "building_name": "Building_18", + "top_left": [ + 990, + 92 + ], + "top_right": [ + 1048, + 92 + ], + "bottom_left": [ + 990, + 130 + ], + "bottom_right": [ + 1048, + 130 + ] + }, + { + "building_name": "Building_19", + "top_left": [ + 1052, + 116 + ], + "top_right": [ + 1188, + 116 + ], + "bottom_left": [ + 1052, + 249 + ], + "bottom_right": [ + 1188, + 249 + ] + }, + { + "building_name": "Building_20", + "top_left": [ + 362, + 195 + ], + "top_right": [ + 432, + 195 + ], + "bottom_left": [ + 362, + 248 + ], + "bottom_right": [ + 432, + 248 + ] + } +] \ No newline at end of file diff --git a/backend/data/environment/entrypoint.json b/backend/data/environment/entrypoint.json new file mode 100644 index 0000000000000000000000000000000000000000..68bcca258fe39ff146884d6aaea1d9e08cda81ff --- /dev/null +++ b/backend/data/environment/entrypoint.json @@ -0,0 +1,240 @@ +{ + "PolygonBuilding_1": { + "x": 780, + "y": 489, + "distance_to_building": 0.0, + "name": "SAB", + "building_num": 1 + }, + "PolygonBuilding_2": { + "x": 201, + "y": 519, + "distance_to_building": 0.0, + "name": "Mess", + "building_num": 2 + }, + "PolygonBuilding_3": { + "x": 185, + "y": 547, + "distance_to_building": 0.0, + "name": "Brahmputra Boys 1", + "building_num": 3 + }, + "PolygonBuilding_4": { + "x": 216, + "y": 502, + "distance_to_building": 0.0, + "name": "Brahmputra Boys 2", + "building_num": 4 + }, + "PolygonBuilding_5": { + "x": 390, + "y": 497, + "distance_to_building": 0.0, + "name": "Ravi", + "building_num": 5 + }, + "PolygonBuilding_6": { + "x": 31, + "y": 324, + "distance_to_building": 0.0, + "name": "Chenab", + "building_num": 6 + }, + "PolygonBuilding_7": { + "x": 599, + "y": 517, + "distance_to_building": 0.0, + "name": "Mechanical Workshop", + "building_num": 7 + }, + "PolygonBuilding_8": { + "x": 712, + "y": 414, + "distance_to_building": 0.0, + "name": "Auditorium + Library", + "building_num": 8 + }, + "PolygonBuilding_9": { + "x": 623, + "y": 643, + "distance_to_building": 0.0, + "name": "Cafeteria", + "building_num": 9 + }, + "PolygonBuilding_10": { + "x": 654, + "y": 460, + "distance_to_building": 0.0, + "name": "Lecture Hall complex", + "building_num": 10 + }, + "PolygonBuilding_11": { + "x": 555, + "y": 318, + "distance_to_building": 0.0, + "name": "Chemical Department", + "building_num": 11 + }, + "PolygonBuilding_12": { + "x": 233, + "y": 405, + "distance_to_building": 0.0, + "name": "Beas", + "building_num": 12 + }, + "Building_1": { + "x": 640, + "y": 372, + "distance_to_building": 37.8, + "name": "Mechanical Department", + "building_num": 13 + }, + "Building_2": { + "x": 789, + "y": 372, + "distance_to_building": 0.0, + "name": "Admin Block", + "building_num": 14 + }, + "Building_3": { + "x": 849, + "y": 335, + "distance_to_building": 31.6, + "name": "Electrical Department", + "building_num": 15 + }, + "Building_4": { + "x": 905, + "y": 356, + "distance_to_building": 0.0, + "name": "CS Department", + "building_num": 16 + }, + "Building_5": { + "x": 975, + "y": 261, + "distance_to_building": 49.0, + "name": "Director's Residence", + "building_num": 17 + }, + "Building_6": { + "x": 646, + "y": 162, + "distance_to_building": 104.8, + "name": "Main fest Ground", + "building_num": 18 + }, + "PolygonBuilding_13": { + "x": 518, + "y": 572, + "distance_to_building": 0.0, + "name": "Rose Garden", + "building_num": 19 + }, + "Building_7": { + "x": 431, + "y": 335, + "distance_to_building": 0.0, + "name": "Bus stand", + "building_num": 20 + }, + "PolygonBuilding_14": { + "x": 278, + "y": 358, + "distance_to_building": 54.6, + "name": "Satluj", + "building_num": 21 + }, + "Building_8": { + "x": 278, + "y": 445, + "distance_to_building": 0.0, + "name": "Jhelum", + "building_num": 22 + }, + "Building_9": { + "x": 732, + "y": 261, + "distance_to_building": 41.8, + "name": "LOC", + "building_num": 23 + }, + "Building_10": { + "x": 715, + "y": 94, + "distance_to_building": 0.0, + "name": "Campus Main gate", + "building_num": 24 + }, + "Building_11": { + "x": 642, + "y": 893, + "distance_to_building": 31.5, + "name": "Volleyball Court", + "building_num": 25 + }, + "Building_12": { + "x": 665, + "y": 893, + "distance_to_building": 31.0, + "name": "Basketball Court", + "building_num": 26 + }, + "Building_13": { + "x": 611, + "y": 1005, + "distance_to_building": 55.6, + "name": "Tennis Court - 1", + "building_num": 27 + }, + "Building_14": { + "x": 720, + "y": 922, + "distance_to_building": 65.7, + "name": "Tennis Court - 2", + "building_num": 28 + }, + "Building_15": { + "x": 441, + "y": 486, + "distance_to_building": 0.0, + "name": "Utility/ SAC", + "building_num": 29 + }, + "Building_16": { + "x": 383, + "y": 481, + "distance_to_building": 0.0, + "name": "Maggie point", + "building_num": 30 + }, + "Building_17": { + "x": 981, + "y": 371, + "distance_to_building": 0.0, + "name": "Faculty Quarters - 1", + "building_num": 31 + }, + "Building_18": { + "x": 1045, + "y": 116, + "distance_to_building": 0.0, + "name": "Guest House", + "building_num": 32 + }, + "Building_19": { + "x": 1064, + "y": 248, + "distance_to_building": 0.0, + "name": "Faculty Quarters - 2", + "building_num": 33 + }, + "Building_20": { + "x": 397, + "y": 261, + "distance_to_building": 39.5, + "name": "AC unit", + "building_num": 34 + } +} \ No newline at end of file diff --git a/backend/data/environment/events_calendar.json b/backend/data/environment/events_calendar.json new file mode 100644 index 0000000000000000000000000000000000000000..392f3c59b538fb0a8e8d1cd0adc9ad596d8e0741 --- /dev/null +++ b/backend/data/environment/events_calendar.json @@ -0,0 +1,56 @@ +{ + "schema_version": 1, + "events": [ + { + "id": "aadvitya_open_mic", + "name": "Aadvitya Open-Mic & Project Showcase", + "description": "A low-pressure evening showcase where students demo small projects, exchange feedback, and enjoy music on the central lawn.", + "category": "technical-cultural", + "date": "2026-09-03", + "start_time": "18:00", + "end_time": "19:00", + "location_id": "central_lawn", + "capacity": 4, + "interest_tags": ["ai", "coding", "music", "creative", "project", "photography"], + "eligibility_tags": [], + "energy_effect": -0.04, + "emotion_effect": 0.12, + "relationship_effect": 0.03, + "announcement": "Tonight: Aadvitya open-mic and project showcase on the central lawn. Bring a demo, a song, or just curiosity." + }, + { + "id": "interhostel_volleyball", + "name": "Inter-Hostel Volleyball Match", + "description": "An evening sports fixture with a casual spectator section and a short post-match cooldown.", + "category": "sports", + "date": "2026-12-03", + "start_time": "19:00", + "end_time": "20:00", + "location_id": "sports_complex", + "capacity": 6, + "interest_tags": ["sports", "running", "fitness", "exercise", "teamwork"], + "eligibility_tags": [], + "energy_effect": -0.10, + "emotion_effect": 0.10, + "relationship_effect": 0.02, + "announcement": "Inter-hostel volleyball starts at 19:00 at the sports complex; spectators are welcome." + }, + { + "id": "exam_week_library_late", + "name": "Exam-Week Library Study Circle", + "description": "A quiet, optional peer study circle with a librarian-led break schedule and group problem solving.", + "category": "academic", + "date": "2026-18-04", + "start_time": "20:00", + "end_time": "21:00", + "location_id": "library", + "capacity": 8, + "interest_tags": ["study", "research", "academic", "ai", "engineering"], + "eligibility_tags": [], + "energy_effect": -0.06, + "emotion_effect": 0.05, + "relationship_effect": 0.02, + "announcement": "Exam-week study circle begins at 20:00 in the library's group-study area." + } + ] +} diff --git a/backend/data/environment/instances_default.json b/backend/data/environment/instances_default.json new file mode 100644 index 0000000000000000000000000000000000000000..6da2a9d471b5412e382b81e6abb1e3a24cff0fb6 --- /dev/null +++ b/backend/data/environment/instances_default.json @@ -0,0 +1 @@ +{"licenses":[{"name":"","id":0,"url":""}],"info":{"contributor":"","date_created":"","description":"","url":"","version":"","year":""},"categories":[{"id":1,"name":"Garden","supercategory":""},{"id":2,"name":"Open Area","supercategory":""}],"images":[{"id":1,"width":1276,"height":1233,"file_name":"map.png","license":0,"flickr_url":"","coco_url":"","date_captured":0}],"annotations":[{"id":1,"image_id":1,"category_id":2,"segmentation":[[662.33,134.49,662.33,134.49,657.18,258.72,657.18,258.72,793.01,258.72,793.01,258.72,790.43,135.13]],"area":16378.0,"bbox":[657.18,134.49,135.83,124.23],"iscrowd":0,"attributes":{"occluded":false}},{"id":2,"image_id":1,"category_id":2,"segmentation":[[467.85,25.86,466.67,25.86,447.76,119.25,438.3,191.36,438.3,191.36,439.48,250.46,439.48,250.46,630.99,248.1,630.99,248.1,627.44,43.59,627.44,43.59]],"area":39075.0,"bbox":[438.3,25.86,192.69,224.6],"iscrowd":0,"attributes":{"occluded":false}},{"id":3,"image_id":1,"category_id":2,"segmentation":[[1105.25,248.22,1081.61,323.87,1081.61,323.87,1082.79,395.98,1082.79,395.98,1230.56,394.8,1230.56,394.8,1230.56,247.03,1230.56,247.03]],"area":21135.0,"bbox":[1081.61,247.03,148.95,148.95],"iscrowd":0,"attributes":{"occluded":false}},{"id":4,"image_id":1,"category_id":2,"segmentation":[[959.57,119.24,954.29,242.59,809.18,249.18,809.84,119.9,809.84,119.9]],"area":18598.0,"bbox":[809.18,119.24,150.39,129.94],"iscrowd":0,"attributes":{"occluded":false}},{"id":5,"image_id":1,"category_id":1,"segmentation":[[451.47,438.18,450.84,488.75,489.33,489.07,490.6,579.09,507.78,580.05,509.05,571.78,509.05,571.78,517.64,569.23,517.32,511.97,604.48,512.29,604.16,480.8,585.07,460.76,584.75,438.18]],"area":11580.0,"bbox":[450.84,438.18,153.64,141.87],"iscrowd":0,"attributes":{"occluded":false}},{"id":6,"image_id":1,"category_id":1,"segmentation":[[658.41,270.57,658.41,270.57,657.87,318.7,791.49,319.52,791.49,269.2]],"area":6551.0,"bbox":[657.87,269.2,133.62,50.32],"iscrowd":0,"attributes":{"occluded":false}}]} \ No newline at end of file diff --git a/backend/data/environment/num.txt b/backend/data/environment/num.txt new file mode 100644 index 0000000000000000000000000000000000000000..41a9b353527246cf5135d49b10472d479fac5ad0 --- /dev/null +++ b/backend/data/environment/num.txt @@ -0,0 +1,34 @@ +building 1 - SAB +building 2 - Mess +building 3 - Brahmputra Boys 1 +building 4 - Brahmputra Boys 2 +building 5 - Ravi +building 6 - Chenab +building 7 - Mechanical Workshop +building 8 - Auditorium + Library +building 9 - Cafeteria +building 10 - Lecture Hall complex +building 11 - Chemical Department +building 12 - Beas +building 13 - Mechanical Department +building 14 - Admin Block +building 15 - Electrical Department +building 16 - CS Department +building 17 - Director's Residence +building 18 - Main fest Ground +building 19 - Rose Garden +building 20 - Bus stand +building 21 - Satluj +building 22 - Jhelum +building 23 - LOC +building 24 - Campus Main gate +building 25 - Volleyball Court +building 26 - Basketball Court +building 27 - Tennis Court - 1 +building 28 - Tennis Court - 2 +building 29 - Utility/ SAC +building 30 - Maggie point +building 31 - Faculty Quarters - 1 +building 32 - Guest House +building 33 - Faculty Quarters - 2 +building 34 - AC unit diff --git a/backend/data/environment/places.json b/backend/data/environment/places.json new file mode 100644 index 0000000000000000000000000000000000000000..3470a098ac80d8644583d6825963be8aff2b1977 --- /dev/null +++ b/backend/data/environment/places.json @@ -0,0 +1,273 @@ +{ + "_meta": { + "source_note": "Facts marked [VERIFIED] come from IIT Ropar's own site, Wikipedia, or student-review aggregators (searched July 2026). Facts marked [ASSUMED] are standard IIT-campus conventions used as placeholders because the exact figure (e.g. precise closing time of some buildings is not available and depends on the event) is not published anywhere public รขโ‚ฌโ€ replace with real values once you can check a hostel notice board / library circular.", + "campus_facts": [ + "525 acres, permanent campus operational since June 2018, on the bank of the Sutlej river, Rupnagar, Punjab", + "Entrance gate: four,41-ft Indus-Valley-style stone pillars, built with ASI", + "5-star GRIHA rated (green campus)", + "Three main academic blocks connected via skywalks (SAB)", + "Major fests: Zeitgeist (cultural, Oct/Nov, 3 days), Aadvitya (tech fest, Feb), Aarohan (sports fest, March), Malhar (literary fest, September)" + ] + }, + "locations": [ + { + "id": "library", + "name": "Nalanda Central Library", + "type": "academic", + "sub_areas": ["reading_hall", "reference_section", "group_study_rooms", "silent_zone", "e_resource_lab"], + "open_hours": { + "reference_section": "00:00-24:00", + "circulation_desk": "09:00-23:00", + "exam_period_all_areas": "00:00-24:00" + }, + "capacity": "high", + "typical_activities": ["studying", "reading", "group project work", "research", "printing"], + + "notes": "Reference section runs 24/7 and the library is fully open round-the-clock during exams; runs on the Koha ILMS; 22,000+ physical books plus e-journals (Scopus, Web of Science). Seat booking via a dedicated portal (studyatlib)." + }, + { + "id": "SAB", + "name": "Super Academic Block", + "type": "academic", + "sub_areas": ["Block A", "Block C", "faculty_offices"], + "open_hours": {"weekdays": "08:00-18:00", "weekends": "closed_except_labs"}, + "capacity": "high", + "typical_activities": [ "lab work", "studying", "meeting professor", "tutorial"], + + "notes": "[VERIFIED] Three independent blocks connected by skywalks, smart/digitally-equipped classrooms, department-specific labs. [ASSUMED] 08:00-18:00 weekday window follows standard IIT timetable slots; exact IIT Ropar bell schedule not published." + }, + { + "id": "LHC", + "name": "Lecture Hall Complex", + "type": "academic", + "sub_areas": ["class_rooms", "common_area", "lecture_halls"], + "open_hours": {"weekdays": "08:00-18:00", "weekends": "closed_except_events"}, + "capacity": "high", + "typical_activities": ["studying", "meeting professor", "tutorial"], + + "notes": "[VERIFIED] Used for hosting lectures, academic sessions, club sessions, hackathons, cultural events and guest lectures. [ASSUMED] 08:00-18:00 weekday window follows standard IIT timetable slots; exact IIT Ropar bell schedule not published." + }, + { + "id": "computer_science_department", + "name": "Computer Science Department", + "type": "academic", + "sub_areas": ["lecture_rooms", "programming_labs", "systems_lab", "faculty_corridor", "project_bay"], + "open_hours": {"weekdays": "08:00-20:00", "weekends": "10:00-17:00"}, + "capacity": "medium", + "typical_activities": ["computer science lecture", "AIDE lecture", "programming lab", "systems lab", "project meeting", "faculty office hour"], + + "notes": "Map entrypoint: CS Department. The default home for CSE and AIDE branch-specific teaching and labs; shared large lectures belong at LHC instead." + }, + { + "id": "electrical_department", + "name": "Electrical Engineering Department", + "type": "academic", + "sub_areas": ["lecture_rooms", "circuits_lab", "electronics_lab", "embedded_lab", "faculty_corridor"], + "open_hours": {"weekdays": "08:00-20:00", "weekends": "10:00-17:00"}, + "capacity": "medium", + "typical_activities": ["electrical engineering lecture", "circuits lab", "electronics lab", "embedded systems lab", "faculty office hour"], + + "notes": "Map entrypoint: Electrical Department. The default home for branch-specific Electrical and IC-design teaching and labs." + }, + { + "id": "mechanical_department", + "name": "Mechanical Engineering Department", + "type": "academic", + "sub_areas": ["lecture_rooms", "design_studio", "manufacturing_lab", "robotics_workshop", "faculty_corridor"], + "open_hours": {"weekdays": "08:00-20:00", "weekends": "10:00-17:00"}, + "capacity": "medium", + "typical_activities": ["mechanical engineering lecture", "CAD studio", "manufacturing lab", "robotics workshop", "faculty office hour"], + + "notes": "Map entrypoint: Mechanical Department. The default home for branch-specific Mechanical teaching, labs, and workshops." + }, + { + "id": "chemical_department", + "name": "Chemical Engineering Department", + "type": "academic", + "sub_areas": ["lecture_rooms", "process_lab", "chemistry_lab", "research_lab", "faculty_corridor"], + "open_hours": {"weekdays": "08:00-20:00", "weekends": "10:00-17:00"}, + "capacity": "medium", + "typical_activities": ["chemical engineering lecture", "process lab", "chemistry lab", "research meeting", "faculty office hour"], + + "notes": "Map entrypoint: Chemical Department. The default home for branch-specific Chemical teaching and labs." + }, + { + "id": "Brahmaputra_Boys 1", + "name": "Boys' Hostel Complex - 1", + "type": "residential", + "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"], + "open_hours": {"all_days": "00:00-24:00"}, + "capacity": "high", + "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"], + + "notes": "[VERIFIED] No entry/exit curfew; each block has common room w/ TV, gym, table tennis/badminton, laundry, 24/7 water & power, RO systems. Alloted to Undergraduate, Second year male students. See design-choice comment in chat re: collapsing multiple real hostel blocks into one node." + }, + { + "id": "Brahmaputra_Boys 2", + "name": "Boys' Hostel Complex - 2", + "type": "residential", + "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"], + "open_hours": {"all_days": "00:00-24:00"}, + "capacity": "high", + "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"], + + "notes": "[VERIFIED] No entry/exit curfew; each block has common room w/ TV, gym, table tennis/badminton, laundry, 24/7 water & power, RO systems. Alloted to Undergraduate, first year male students. See design-choice comment in chat re: collapsing multiple real hostel blocks into one node." + }, + + { + "id": "Chenab", + "name": "Boys' Hostel Complex - 3", + "type": "residential", + "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"], + "open_hours": {"all_days": "00:00-24:00"}, + "capacity": "high", + "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"], + + "notes": "[VERIFIED] No entry/exit curfew; each block has common room w/ TV, gym, table tennis/badminton, laundry, 24/7 water & power, RO systems. Alloted to Undergraduate, first and third year male students. See design-choice comment in chat re: collapsing multiple real hostel blocks into one node." + }, + { + "id": "Beas", + "name": "Boys' Hostel Complex - 4", + "type": "residential", + "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"], + "open_hours": {"all_days": "00:00-24:00"}, + "capacity": "high", + "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"], + + "notes": "[VERIFIED] No entry/exit curfew; each block has common room w/ TV, gym, table tennis/badminton, laundry, 24/7 water & power, RO systems. Alloted to Undergraduate, fourth year male students. See design-choice comment in chat re: collapsing multiple real hostel blocks into one node." + }, + { + "id": "Satluj", + "name": "Boys' Hostel Complex - 5", + "type": "residential", + "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"], + "open_hours": {"all_days": "00:00-24:00"}, + "capacity": "high", + "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"], + + "notes": "[VERIFIED] No entry/exit curfew; each block has common room w/ TV, gym, table tennis/badminton, laundry, 24/7 water & power, RO systems. See design-choice comment in chat re: collapsing multiple real hostel blocks into one node." + }, + { + "id": "Ravi", + "name": "Girls' Hostel Complex - 1", + "type": "residential", + "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"], + "open_hours": {"all_days": "00:00-24:00"}, + "capacity": "high", + "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"], + + "notes": "[VERIFIED] Same facility profile as boys' hostels รขโ‚ฌโ€ RO water, common room, hostel gym, laundry, 24/7 CCTV security. Alloted to Undergraduate, female students. See design-choice comment in chat re: collapsing multiple real hostel blocks into one node." + }, + { + "id": "Jhelum", + "name": "Girls' Hostel Complex - 2", + "type": "residential", + "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"], + "open_hours": {"all_days": "00:00-24:00"}, + "capacity": "high", + "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"], + + "notes": "[VERIFIED] Same facility profile as boys' hostels รขโ‚ฌโ€ RO water, common room, hostel gym, laundry, 24/7 CCTV security. Alloted to Undergraduate, female students. See design-choice comment in chat re: collapsing multiple real hostel blocks into one node." + }, + + { + "id": "mess", + "name": "Mess", + "type": "dining", + "sub_areas": [], + "open_hours": { + "breakfast": "07:30-09:30", + "lunch": "12:15-14:30", + "dinner": "19:30-21:30" + }, + "capacity": "high", + "typical_activities": ["eating", "socializing"], + + "notes": "[VERIFIED] Three meals/day, non-veg served ~2x/week, menu rotates, run by a student mess committee." + }, + { + "id": "sac_utility_block", + "name": "Student Activity Centre (SAC) / Utility Block", + "type": "club_room", + "sub_areas": ["general_store", "stationery_shop", "salon", "Postal Office","sbi_branch_atm", "club_room_alankar", "club_room_bost", "club_room_arturo", "club_room_vibgyor"], + "open_hours": { + "general_store": "10:00-20:00", + "stationery_shop": "08:00-20:00", + "salon": "08:00-21:00", + "sbi_branch": "10:00-16:00", + "atm": "00:00-24:00", + "club_rooms": "16:00-22:00" + }, + "capacity": "medium", + "typical_activities": ["shopping", "banking", "club meeting", "socializing", "grooming", "club practice"], + + "notes": "[VERIFIED] Exact shop hours and named club rooms (Alankar, BOST, Arturo, Vibgyor) confirmed via IIT Ropar's own student-life page. [ASSUMED] SBI branch hours use standard PSU-bank hours; ATM is always-on by default." + }, + { + "id": "sports_complex", + "name": "Sports Complex & Gymkhana", + "type": "outdoor", + "sub_areas": ["cricket_field", "tennis_courts", "football_field", "hockey_field", "basketball_court", "volleyball_courts", "gymnasium", "athletics_track"], + "open_hours": {"all_days": "06:00-20:00"}, + "capacity": "medium", + "typical_activities": ["playing sports", "exercising", "team practice", "watching a match"], + + "notes": "[VERIFIED] 1 cricket field, 2 clay tennis courts, 3 cricket practice pitches, 2 volleyball courts, hockey field, football field, 1 basketball court, gymnasium, plus open athletics ground. Gymkhana runs Inter-IIT sports program. [ASSUMED] Daily open window (06:00-20:00) is a typical daylight-sports default." + }, + { + "id": "health_centre", + "name": "IIT Ropar Health Centre", + "type": "administrative", + "sub_areas": ["opd", "pharmacy", "emergency_room"], + "open_hours": {"all_days": "00:00-24:00"}, + "capacity": "low", + "typical_activities": ["medical checkup", "emergency care", "picking up medicine"], + + "notes": "[VERIFIED] Open 24/7 with dedicated medical/paramedical staff and round-the-clock ambulance. Note: 'administrative' is the closest fit in your current enum รขโ‚ฌโ€ worth adding a dedicated 'healthcare' type (see comments)." + }, + { + "id": "auditorium", + "name": "Main Auditorium", + "type": "recreational", + "sub_areas": [], + "open_hours": {"default": "closed_unless_event", "note": "Hours are event-driven รขโ‚ฌโ€ open only during scheduled seminars/cultural events"}, + "capacity": "high", + "typical_activities": ["attending seminar", "attending cultural event", "watching performance", "fest event"], + + "notes": "[VERIFIED] Modern, tech-equipped venue for seminars, lectures, and cultural events (used heavily during Zeitgeist/Aadvitya). Hours are inherently event-driven rather than fixed รขโ‚ฌโ€ flag this to your planner as a scheduled-event location, not an always-open one." + }, + { + "id": "central_lawn", + "name": "Central Lawn / Open Green", + "type": "outdoor", + "sub_areas": [], + "open_hours": {"all_days": "00:00-24:00"}, + "capacity": "medium", + "typical_activities": ["relaxing", "informal hangout", "fest ground", "evening walk", "photography"], + + "notes": "[ASSUMED as a generic hub] Real campus does have large green/lawn zones (Sivalik-hill views cited in multiple sources) but this node is a simplification acting as your graph's central 'plaza' รขโ‚ฌโ€ genuinely useful for a tick-loop since it lets agents cross zones without every building being pairwise-connected." + }, + { + "id": "admin_block", + "name": "Administrative Block", + "type": "administrative", + "sub_areas": ["registrar_office", "accounts_office", "dean_academic_affairs", "dean_student_welfare"], + "open_hours": {"weekdays": "09:30-17:30"}, + "capacity": "low", + "typical_activities": ["paperwork", "registration", "meeting dean", "fee payment query"], + + "notes": "[VERIFIED] Existence and function (registrar, DoSW, accounts) confirmed via IIT Ropar's own pages. [ASSUMED] Standard govt-institute office hours (09:30-17:30) since exact hours aren't published." + }, + { + "id": "main_gate", + "name": "Main Entrance Gate", + "type": "outdoor", + "sub_areas": ["security_post", "bus_stop"], + "open_hours": {"all_days": "00:60-23:00"}, + "capacity": "low", + "typical_activities": ["arriving on campus", "leaving campus", "waiting for bus", "meeting visitors"], + + "notes": "[VERIFIED] Distinctive Indus-Valley-style pyramid pillar entrance; institute runs a paid shuttle bus to Chandigarh/Mohali. Useful as your graph's 'exit node' if you ever want agents to leave campus (weekend trip, going home, etc.)." + } + ] +} diff --git a/backend/data/environment/relationship_matrix.json b/backend/data/environment/relationship_matrix.json new file mode 100644 index 0000000000000000000000000000000000000000..211731bfc7a7f7944d09c1283f81cb361206e53a --- /dev/null +++ b/backend/data/environment/relationship_matrix.json @@ -0,0 +1,867 @@ +{ + "schema_version": 2, + "relationships": { + "amitabh->ansh_batra": { + "score": 0.55, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Ansh Batra know each other through campus routines. Amitabh appreciates ansh_batra's enthusiastic plans, but the friendship is still finding its rhythm." + }, + "amitabh->anubhav_prasad": { + "score": 0.49, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Anubhav Prasad know each other through campus routines. Amitabh appreciates anubhav_prasad's calm listening, but the friendship is still finding its rhythm." + }, + "amitabh->ghanisht_kaushal": { + "score": 0.46, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Ghanisht Kaushal know each other through campus routines. Amitabh appreciates ghanisht_kaushal's reliable follow-through, but the friendship is still finding its rhythm." + }, + "amitabh->gurnoor_singh": { + "score": 0.56, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Gurnoor Singh know each other through campus routines. Amitabh appreciates gurnoor_singh's big social energy, but the friendship is still finding its rhythm." + }, + "amitabh->jarvis": { + "score": 0.63, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Jarvis know each other through campus routines. Amitabh appreciates jarvis's steady conversation, but the friendship is still finding its rhythm." + }, + "amitabh->lavanya_sharma": { + "score": 0.5700000000000001, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Lavanya Sharma know each other through campus routines. Amitabh appreciates lavanya_sharma's direct feedback, but the friendship is still finding its rhythm." + }, + "amitabh->parv_singla": { + "score": 0.54, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Parv Singla know each other through campus routines. Amitabh appreciates parv_singla's impulsive invitations, but the friendship is still finding its rhythm." + }, + "amitabh->riya_murarka": { + "score": 0.3, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Riya Murarka know each other through campus routines. Amitabh appreciates riya_murarka's clear boundaries, but the friendship is still finding its rhythm." + }, + "amitabh->saksham": { + "score": 0.53, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Saksham know each other through campus routines. Amitabh appreciates saksham's dry humour, but the friendship is still finding its rhythm." + }, + "amitabh->tanishq": { + "score": 0.31, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Tanishq know each other through campus routines. Amitabh appreciates tanishq's quiet, improving confidence, but the friendship is still finding its rhythm." + }, + "ansh_batra->amitabh": { + "score": 0.55, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Ansh Batra and Amitabh know each other through football and visual storytelling. Ansh Batra appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, + "ansh_batra->anubhav_prasad": { + "score": 0.41, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Ansh Batra drags Anubhav into wild plans and secretly enjoys when Anubhav calmly talks him down. Their dynamic is 90% chaos, 10% 'why is your voice so calming at 3am?'" + }, + "ansh_batra->ghanisht_kaushal": { + "score": 0.66, + "tags": [ + "close-vibes", + "reliable-chaos" + ], + "context": "Ansh and Ghanisht are the duo that somehow finishes projects while drunk-texting each other memes. Ghanisht's reliability is weirdly sexy to Ansh." + }, + "ansh_batra->gurnoor_singh": { + "score": 0.53, + "tags": [ + "campus-acquaintance", + "party-bros" + ], + "context": "Ansh and Gurnoor's parties always end in legendary stories \u2014 including that one time they both woke up in the same bed after a dare and just laughed it off... mostly." + }, + "ansh_batra->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Ansh Batra has only recently met Jarvis; the connection is open but untested." + }, + "ansh_batra->lavanya_sharma": { + "score": 0.44, + "tags": [ + "campus-acquaintance", + "banter" + ], + "context": "Ansh loves pushing Lavanya's buttons because her fiery responses do things to him. They've had more than one 'hate-flirt' session that left the group chat suspicious." + }, + "ansh_batra->parv_singla": { + "score": 0.56, + "tags": [ + "campus-acquaintance", + "impulsive-duo" + ], + "context": "Ansh and Parv feed off each other's worst ideas. Their friendship is one impulsive kiss or bad decision away from becoming campus legend." + }, + "ansh_batra->riya_murarka": { + "score": 0.5, + "tags": [ + "campus-acquaintance", + "respect" + ], + "context": "Ansh respects Riya's clear boundaries while secretly finding her composure insanely attractive. He tones down the chaos around her... slightly." + }, + "ansh_batra->saksham": { + "score": 0.47, + "tags": [ + "project-partners", + "rebuilding-trust" + ], + "context": "Ansh still feels guilty about the missed deadline, but Saksham's grumpy forgiveness texts have been suspiciously flirty lately." + }, + "ansh_batra->tanishq": { + "score": 0.56, + "tags": [ + "campus-acquaintance", + "encouraging" + ], + "context": "Ansh loves hyping up Tanishq's growing confidence, especially when Tanishq blushes at compliments. It's dangerously cute." + }, + "anubhav_prasad->amitabh": { + "score": 0.49, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Anubhav Prasad and Amitabh know each other through co-op games and late-night chai. Anubhav Prasad appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, + "anubhav_prasad->ansh_batra": { + "score": 0.41, + "tags": [ + "campus-acquaintance", + "chaos-manager" + ], + "context": "Anubhav is Ansh's reluctant voice of reason. He pretends to be annoyed but secretly enjoys being the one Ansh texts first when things go sideways." + }, + "anubhav_prasad->ghanisht_kaushal": { + "score": 0.6, + "tags": [ + "campus-acquaintance", + "solid-vibes" + ], + "context": "Anubhav and Ghanisht have that effortless, reliable friendship that occasionally includes way too much honest talk about hookups and regrets." + }, + "anubhav_prasad->gurnoor_singh": { + "score": 0.47, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Gurnoor drags Anubhav to parties and Anubhav somehow ends up being the responsible one... until that one time he wasn't." + }, + "anubhav_prasad->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Anubhav Prasad has only recently met Jarvis; the connection is open but untested." + }, + "anubhav_prasad->lavanya_sharma": { + "score": 0.73, + "tags": [ + "campus-acquaintance", + "banter" + ], + "context": "Anubhav appreciates Lavanya's directness, especially when she teases him. Their conversations have this undercurrent of 'we could be dangerous together'." + }, + "anubhav_prasad->parv_singla": { + "score": 0.35, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Parv's impulsive energy makes Anubhav smile despite himself. They've shared a few too many 'just one more shot' moments." + }, + "anubhav_prasad->riya_murarka": { + "score": 0.54, + "tags": [ + "campus-acquaintance", + "respect" + ], + "context": "Anubhav and Riya have calm, meaningful talks that feel intimate without trying. He respects her space but definitely notices her." + }, + "anubhav_prasad->saksham": { + "score": 0.34, + "tags": [ + "campus-acquaintance", + "dry-humor" + ], + "context": "Saksham's sarcasm and Anubhav's calm create comedy gold. They've bonded over judging everyone's terrible decisions." + }, + "anubhav_prasad->tanishq": { + "score": 0.5, + "tags": [ + "campus-acquaintance", + "gentle" + ], + "context": "Anubhav is quietly supportive of Tanishq's confidence journey. Their interactions are soft and full of unspoken understanding." + }, + "ghanisht_kaushal->amitabh": { + "score": 0.46, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Ghanisht Kaushal and Amitabh know each other through badminton and blunt movie opinions. Ghanisht Kaushal appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, + "ghanisht_kaushal->ansh_batra": { + "score": 0.66, + "tags": [ + "close-vibes", + "reliable-chaos" + ], + "context": "Ghanisht is Ansh's anchor during chaos. Their friendship includes way too many late-night confessions that got a little too spicy." + }, + "ghanisht_kaushal->anubhav_prasad": { + "score": 0.6, + "tags": [ + "campus-acquaintance", + "solid-vibes" + ], + "context": "Ghanisht values Anubhav's calm presence. They've had deep talks that occasionally veered into 'what are we even doing with our lives... and each other?' territory." + }, + "ghanisht_kaushal->gurnoor_singh": { + "score": 0.39, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Gurnoor's nonstop social battery clashes with Ghanisht's chill, but the rare nights they sync are chaotic gold." + }, + "ghanisht_kaushal->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Ghanisht Kaushal has only recently met Jarvis; the connection is open but untested." + }, + "ghanisht_kaushal->lavanya_sharma": { + "score": 0.36, + "tags": [ + "friendly-rivalry", + "tension" + ], + "context": "Ghanisht finds Lavanya's directness intimidatingly hot. Their arguments somehow feel like foreplay." + }, + "ghanisht_kaushal->parv_singla": { + "score": 0.65, + "tags": [ + "campus-acquaintance", + "impulsive" + ], + "context": "Ghanisht tries (and fails) to slow Parv down. Their dynamic is pure 'I shouldn't but I will' energy." + }, + "ghanisht_kaushal->riya_murarka": { + "score": 0.53, + "tags": [ + "campus-acquaintance", + "respect" + ], + "context": "Ghanisht respects Riya's boundaries and finds her composure refreshing... and attractive." + }, + "ghanisht_kaushal->saksham": { + "score": 0.69, + "tags": [ + "campus-acquaintance", + "sarcasm-buddies" + ], + "context": "Ghanisht and Saksham's combined dry humor could roast an entire campus. Their friendship is 80% savage compliments." + }, + "ghanisht_kaushal->tanishq": { + "score": 0.62, + "tags": [ + "campus-acquaintance", + "encouraging" + ], + "context": "Ghanisht quietly roots for Tanishq's confidence glow-up and enjoys watching him get bolder." + }, + "gurnoor_singh->amitabh": { + "score": 0.56, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Gurnoor Singh and Amitabh know each other through photography and road-trip playlists. Gurnoor Singh appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, + "gurnoor_singh->ansh_batra": { + "score": 0.53, + "tags": [ + "campus-acquaintance", + "party-bros" + ], + "context": "Gurnoor and Ansh are partners in crime. Their nights out always end with questionable decisions and hilarious group chat stories." + }, + "gurnoor_singh->anubhav_prasad": { + "score": 0.47, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Gurnoor drags Anubhav to parties and loves when he finally lets loose." + }, + "gurnoor_singh->ghanisht_kaushal": { + "score": 0.39, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Gurnoor respects Ghanisht's reliability but wishes he'd loosen up more... preferably with him." + }, + "gurnoor_singh->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Gurnoor Singh has only recently met Jarvis; the connection is open but untested." + }, + "gurnoor_singh->lavanya_sharma": { + "score": 0.5, + "tags": [ + "campus-acquaintance", + "flirty" + ], + "context": "Gurnoor loves Lavanya's energy. Their banter has definite 'we should make out and see what happens' undertones." + }, + "gurnoor_singh->parv_singla": { + "score": 0.91, + "tags": [ + "close-friends", + "chaotic-duo" + ], + "context": "Gurnoor and Parv are inseparable troublemakers. They've shared clothes, secrets, and one very memorable drunken makeout that they still joke about." + }, + "gurnoor_singh->riya_murarka": { + "score": 0.61, + "tags": [ + "campus-acquaintance", + "respect" + ], + "context": "Gurnoor tones down around Riya but finds her boundaries and intelligence hot as hell." + }, + "gurnoor_singh->saksham": { + "score": 0.46, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Gurnoor enjoys Saksham's sarcasm even when it's aimed at him." + }, + "gurnoor_singh->tanishq": { + "score": 0.57, + "tags": [ + "campus-acquaintance", + "encouraging" + ], + "context": "Gurnoor loves seeing Tanishq come out of his shell and occasionally flirts just to see him blush." + }, + "jarvis->amitabh": { + "score": 0.63, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Jarvis and Amitabh know each other through campus routines. Jarvis appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, + "jarvis->ansh_batra": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Ansh Batra's rhythm." + }, + "jarvis->anubhav_prasad": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Anubhav Prasad's rhythm." + }, + "jarvis->ghanisht_kaushal": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Ghanisht Kaushal's rhythm." + }, + "jarvis->gurnoor_singh": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Gurnoor Singh's rhythm." + }, + "jarvis->lavanya_sharma": { + "score": 0.37, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Lavanya Sharma's rhythm." + }, + "jarvis->parv_singla": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Parv Singla's rhythm." + }, + "jarvis->riya_murarka": { + "score": 0.37, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Riya Murarka's rhythm." + }, + "jarvis->saksham": { + "score": 0.42, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Saksham's rhythm." + }, + "jarvis->tanishq": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Tanishq's rhythm." + }, + "lavanya_sharma->amitabh": { + "score": 0.5700000000000001, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Lavanya Sharma and Amitabh know each other through basketball and debate. Lavanya Sharma appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, + "lavanya_sharma->ansh_batra": { + "score": 0.44, + "tags": [ + "campus-acquaintance", + "banter" + ], + "context": "Lavanya finds Ansh's chaos annoyingly attractive. She's called him out on it... while smiling." + }, + "lavanya_sharma->anubhav_prasad": { + "score": 0.73, + "tags": [ + "campus-acquaintance", + "quiet-tension" + ], + "context": "Lavanya likes Anubhav's calm vibe. It makes her want to shake him up in the best way." + }, + "lavanya_sharma->ghanisht_kaushal": { + "score": 0.28, + "tags": [ + "friendly-rivalry", + "sexual-tension" + ], + "context": "Lavanya and Ghanisht's rivalry is legendary. Everyone knows their arguments are just delayed makeouts waiting to happen." + }, + "lavanya_sharma->gurnoor_singh": { + "score": 0.5, + "tags": [ + "campus-acquaintance", + "flirty" + ], + "context": "Lavanya matches Gurnoor's energy perfectly. Their flirting is shameless and hilarious." + }, + "lavanya_sharma->jarvis": { + "score": 0.37, + "tags": [ + "new-acquaintance" + ], + "context": "Lavanya Sharma has only recently met Jarvis; the connection is open but untested." + }, + "lavanya_sharma->parv_singla": { + "score": 0.43, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Lavanya enjoys Parv's impulsiveness... until it becomes her problem." + }, + "lavanya_sharma->riya_murarka": { + "score": 0.57, + "tags": [ + "close-friends", + "girls-support-girls" + ], + "context": "Lavanya and Riya have each other's backs through every situationship and heartbreak. Their friendship is pure, supportive, and occasionally gossip-fueled." + }, + "lavanya_sharma->saksham": { + "score": 0.47, + "tags": [ + "campus-acquaintance", + "banter" + ], + "context": "Lavanya and Saksham's sarcasm battles are campus entertainment." + }, + "lavanya_sharma->tanishq": { + "score": 0.63, + "tags": [ + "campus-acquaintance", + "encouraging" + ], + "context": "Lavanya is proudly watching Tanishq's glow-up and isn't shy about hyping him up." + }, + "parv_singla->amitabh": { + "score": 0.54, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Parv Singla and Amitabh know each other through running and music. Parv Singla appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, + "parv_singla->ansh_batra": { + "score": 0.56, + "tags": [ + "campus-acquaintance", + "impulsive-duo" + ], + "context": "Parv and Ansh are a dangerous combination of bad ideas and great stories." + }, + "parv_singla->anubhav_prasad": { + "score": 0.35, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Parv loves trying to corrupt Anubhav's calm demeanor." + }, + "parv_singla->ghanisht_kaushal": { + "score": 0.65, + "tags": [ + "campus-acquaintance", + "impulsive" + ], + "context": "Parv drags Ghanisht into fun he secretly enjoys." + }, + "parv_singla->gurnoor_singh": { + "score": 0.89, + "tags": [ + "close-friends", + "chaotic-duo" + ], + "context": "Parv and Gurnoor are basically soulmates in crime. Their friendship includes shared hangovers, secrets, and blurry memories." + }, + "parv_singla->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Parv Singla has only recently met Jarvis; the connection is open but untested." + }, + "parv_singla->lavanya_sharma": { + "score": 0.43, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Parv admires Lavanya's fire and occasionally flirts just to see her reaction." + }, + "parv_singla->riya_murarka": { + "score": 0.54, + "tags": [ + "campus-acquaintance", + "respect" + ], + "context": "Parv respects Riya's boundaries while still trying to pull her into mild chaos." + }, + "parv_singla->saksham": { + "score": 0.39, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Parv enjoys Saksham's dry humor even when it's roasting him." + }, + "parv_singla->tanishq": { + "score": 0.5, + "tags": [ + "campus-acquaintance", + "encouraging" + ], + "context": "Parv loves hyping Tanishq up and watching him gain confidence." + }, + "riya_murarka->amitabh": { + "score": 0.3, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Riya Murarka and Amitabh know each other through reading circles and long runs. Riya Murarka appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, + "riya_murarka->ansh_batra": { + "score": 0.5, + "tags": [ + "campus-acquaintance", + "respect" + ], + "context": "Riya enjoys Ansh's enthusiasm from a safe distance." + }, + "riya_murarka->anubhav_prasad": { + "score": 0.54, + "tags": [ + "campus-acquaintance", + "quiet-tension" + ], + "context": "Riya and Anubhav share calm, flirty-adjacent conversations that feel deeper than they admit." + }, + "riya_murarka->ghanisht_kaushal": { + "score": 0.53, + "tags": [ + "campus-acquaintance", + "respect" + ], + "context": "Riya values Ghanisht's reliability and straightforwardness." + }, + "riya_murarka->gurnoor_singh": { + "score": 0.61, + "tags": [ + "campus-acquaintance", + "respect" + ], + "context": "Riya finds Gurnoor's energy entertaining in small doses." + }, + "riya_murarka->jarvis": { + "score": 0.37, + "tags": [ + "new-acquaintance" + ], + "context": "Riya Murarka has only recently met Jarvis; the connection is open but untested." + }, + "riya_murarka->lavanya_sharma": { + "score": 0.57, + "tags": [ + "close-friends", + "girls-support-girls" + ], + "context": "Riya and Lavanya are ride-or-die. They share everything from book recs to dating horror stories." + }, + "riya_murarka->parv_singla": { + "score": 0.54, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Riya enjoys Parv's chaos from a safe distance." + }, + "riya_murarka->saksham": { + "score": 0.53, + "tags": [ + "campus-acquaintance", + "dry-humor" + ], + "context": "Riya and Saksham bond over quiet sarcasm and shared eye-rolls." + }, + "riya_murarka->tanishq": { + "score": 0.43, + "tags": [ + "admiration", + "careful-boundaries" + ], + "context": "Riya notices Tanishq's respectful efforts and finds it sweet, but keeps things slow and platonic for now." + }, + "saksham->amitabh": { + "score": 0.53, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Saksham and Amitabh know each other through badminton and strategy games. Saksham appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, + "saksham->ansh_batra": { + "score": 0.34, + "tags": [ + "project-partners", + "rebuilding-trust" + ], + "context": "Saksham is still salty about the missed deadline but can't deny Ansh's chaotic charm is growing on him." + }, + "saksham->anubhav_prasad": { + "score": 0.34, + "tags": [ + "campus-acquaintance", + "dry-humor" + ], + "context": "Saksham enjoys Anubhav's calm as the perfect straight man for his jokes." + }, + "saksham->ghanisht_kaushal": { + "score": 0.69, + "tags": [ + "campus-acquaintance", + "solid-vibes" + ], + "context": "Saksham and Ghanisht understand each other's reliability and sarcasm perfectly." + }, + "saksham->gurnoor_singh": { + "score": 0.46, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Saksham finds Gurnoor's energy exhausting but entertaining." + }, + "saksham->jarvis": { + "score": 0.42, + "tags": [ + "new-acquaintance" + ], + "context": "Saksham has only recently met Jarvis; the connection is open but untested." + }, + "saksham->lavanya_sharma": { + "score": 0.47, + "tags": [ + "campus-acquaintance", + "banter" + ], + "context": "Saksham and Lavanya's banter is sharp and mutual." + }, + "saksham->parv_singla": { + "score": 0.39, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Saksham tolerates Parv's impulsiveness with heavy sarcasm." + }, + "saksham->riya_murarka": { + "score": 0.53, + "tags": [ + "campus-acquaintance", + "respect" + ], + "context": "Saksham respects Riya's boundaries and sharp mind." + }, + "saksham->tanishq": { + "score": 0.49, + "tags": [ + "campus-acquaintance", + "encouraging" + ], + "context": "Saksham quietly supports Tanishq's confidence growth with dry but kind humor." + }, + "tanishq->amitabh": { + "score": 0.31, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Tanishq and Amitabh know each other through strategy games and playlists. Tanishq appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, + "tanishq->ansh_batra": { + "score": 0.56, + "tags": [ + "campus-acquaintance", + "encouraging" + ], + "context": "Tanishq enjoys Ansh's hype energy and is slowly getting bolder around him." + }, + "tanishq->anubhav_prasad": { + "score": 0.5, + "tags": [ + "campus-acquaintance", + "gentle" + ], + "context": "Tanishq feels safe and understood around Anubhav's calm presence." + }, + "tanishq->ghanisht_kaushal": { + "score": 0.62, + "tags": [ + "campus-acquaintance", + "admiration" + ], + "context": "Tanishq respects Ghanisht's reliability and blunt honesty." + }, + "tanishq->gurnoor_singh": { + "score": 0.57, + "tags": [ + "campus-acquaintance", + "encouraging" + ], + "context": "Tanishq is slowly getting pulled into Gurnoor's fun orbit and enjoying it." + }, + "tanishq->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Tanishq has only recently met Jarvis; the connection is open but untested." + }, + "tanishq->lavanya_sharma": { + "score": 0.63, + "tags": [ + "campus-acquaintance", + "admiration" + ], + "context": "Tanishq finds Lavanya's confidence inspiring (and a little intimidating in the best way)." + }, + "tanishq->parv_singla": { + "score": 0.5, + "tags": [ + "campus-acquaintance", + "encouraging" + ], + "context": "Tanishq appreciates Parv's outgoing energy and is learning from it." + }, + "tanishq->riya_murarka": { + "score": 0.55, + "tags": [ + "admiration", + "respectful-distance" + ], + "context": "Tanishq has a quiet crush on Riya but is being extremely patient and respectful while building trust." + }, + "tanishq->saksham": { + "score": 0.49, + "tags": [ + "campus-acquaintance", + "dry-humor" + ], + "context": "Tanishq enjoys Saksham's sarcasm and finds it comforting in its own way." + } + } +} \ No newline at end of file diff --git a/backend/data/personalities/amitabh/amitabh.json b/backend/data/personalities/amitabh/amitabh.json new file mode 100644 index 0000000000000000000000000000000000000000..545619f61ecbdf459ace3d5118cc56f220c6dba9 --- /dev/null +++ b/backend/data/personalities/amitabh/amitabh.json @@ -0,0 +1,22 @@ +{ + "Name": "Amitabh", + "Age": "20", + "Gender": "Male", + "Branch": "Computer Science", + "Home City": "Delhi", + "Hostel": "Beas", + "daily_plan_req": "Gym in the morning, classes, evening coding + chai sessions, night gaming or deep talks", + "innate": "Calm, observant, secretly sarcastic, gets easily flustered by bold flirting but plays it cool", + "learned": "How to give good advice while hiding his own chaos, how to handle friends' impulsiveness", + "lifestyle": "Lowkey chill but down for spontaneous shit at 2am. Lowkey addicted to emotional tension and slow-burn crushes", + "hobbies": "Hardware tinkering, playlists, late-night chai, overthinking texts, secret meme saving", + "goals": "Graduate with good grades, figure out what he wants in relationships, maybe finally make a move on someone", + "interests": [ + "Deep conversations", + "Tech", + "Flirty banter", + "Gym", + "Music", + "Quiet tension with girls/guys" + ] +} \ No newline at end of file diff --git a/backend/data/personalities/ansh_batra/ansh_batra.json b/backend/data/personalities/ansh_batra/ansh_batra.json new file mode 100644 index 0000000000000000000000000000000000000000..2ff3c962d0cf532a9ea4220f7515f8ed7526aabe --- /dev/null +++ b/backend/data/personalities/ansh_batra/ansh_batra.json @@ -0,0 +1,22 @@ +{ + "Name": "Ansh Batra", + "Age": "20", + "Gender": "Male", + "Branch": "Electronics & Communication Engineering", + "Home City": "Jaipur", + "Hostel": "Brahmaputra_Boys 1", + "daily_plan_req": "Wake up late, attend minimum classes, hype everyone up, night projects + parties", + "innate": "Chaotic, enthusiastic, shameless flirt, big dreamer, zero filter after 11pm", + "learned": "How to guilt-trip people after missed deadlines, how to turn any situation into a story", + "lifestyle": "Lives like every day is the last day of college. Constantly in 3 situationships at once", + "hobbies": "Visual storytelling, meme creation, dragging people into adventures, late-night makeout sessions", + "goals": "Build something cool, become the most memorable person in college, finally lock down someone he actually likes", + "interests": [ + "Parties", + "Flirting", + "Chaos", + "Photography", + "Hot takes", + "Spontaneous hookups" + ] +} diff --git a/backend/data/personalities/anubhav_prasad/anubhav_prasad.json b/backend/data/personalities/anubhav_prasad/anubhav_prasad.json new file mode 100644 index 0000000000000000000000000000000000000000..3647e676d7687e236ceacad8615ae8e461400d3d --- /dev/null +++ b/backend/data/personalities/anubhav_prasad/anubhav_prasad.json @@ -0,0 +1,21 @@ +{ + "Name": "Anubhav Prasad", + "Age": "21", + "Gender": "Male", + "Branch": "Mechanical Engineering", + "Home City": "Lucknow", + "Hostel": "Chenab", + "daily_plan_req": "Morning workout, classes, evening chai + deep talks, night gaming or quiet chilling", + "innate": "Calm, deep voice, good listener, secretly very freaky once comfortable", + "learned": "How to be the emotional support guy while hiding his own desires", + "lifestyle": "Looks responsible but has a wild side that comes out after a few drinks", + "hobbies": "Co-op games, late-night chai, deep conversations, subtle flirting", + "goals": "Maintain good grades, explore his wild side more, find someone who matches his calm+intense vibe", + "interests": [ + "Deep talks", + "Gaming", + "Chai", + "Emotional tension", + "Quiet makeouts" + ] +} diff --git a/backend/data/personalities/ghanisht_kaushal/ghanisht_kaushal.json b/backend/data/personalities/ghanisht_kaushal/ghanisht_kaushal.json new file mode 100644 index 0000000000000000000000000000000000000000..cef2c7f08f1d6662e91fa6d38093a25fb5f0c669 --- /dev/null +++ b/backend/data/personalities/ghanisht_kaushal/ghanisht_kaushal.json @@ -0,0 +1,21 @@ +{ + "Name": "Ghanisht Kaushal", + "Age": "20", + "Gender": "Male", + "Branch": "Chemical Engineering", + "Home City": "Chandigarh", + "Hostel": "Satluj", + "daily_plan_req": "Gym, classes, evening work, night roast sessions or movies", + "innate": "Blunt, reliable, dry humor, gets competitive in everything including flirting", + "learned": "How to handle his girl's fire and still want her, how to be the responsible chaos manager", + "lifestyle": "Disciplined but down for nasty fun when the vibe hits", + "hobbies": "Badminton, movie roasts, honest talks, tension-filled arguments", + "goals": "Excel academically, win the constant rivalry with women (in bed and outside), stay reliable", + "interests": [ + "Honest roasts", + "Rivalry sex tension", + "Movies", + "Gym", + "Deep loyalty" + ] +} diff --git a/backend/data/personalities/gurnoor_singh/gurnoor_singh.json b/backend/data/personalities/gurnoor_singh/gurnoor_singh.json new file mode 100644 index 0000000000000000000000000000000000000000..4765ced2072f900d550a7a510ab95eea2f6a815a --- /dev/null +++ b/backend/data/personalities/gurnoor_singh/gurnoor_singh.json @@ -0,0 +1,22 @@ +{ + "Name": "Gurnoor Singh", + "Age": "20", + "Gender": "Male", + "Branch": "Computer Science Engineering", + "Home City": "Amritsar", + "Hostel": "Brahmaputra_Boys 2", + "daily_plan_req": "Sleep till noon, attend selective classes, photography, night parties", + "innate": "High energy, social butterfly, zero shame, loves drama and attention", + "learned": "How to drag friends into even worse ideas, how to turn hangovers into content", + "lifestyle": "Professional party boy with a soft side for close friends", + "hobbies": "Photography, road trips, parties, flirting, spontaneous makeouts", + "goals": "Document the wildest college memories, maintain his bromance with friends, get laid regularly", + "interests": [ + "Parties", + "Photography", + "Chaos", + "Flirting", + "Road trips", + "Drama" + ] +} diff --git a/backend/data/personalities/jarvis/jarvis.json b/backend/data/personalities/jarvis/jarvis.json new file mode 100644 index 0000000000000000000000000000000000000000..1fc50036e6ed55aa7c1ee861cc17a40b405fa671 --- /dev/null +++ b/backend/data/personalities/jarvis/jarvis.json @@ -0,0 +1,22 @@ +{ + "Name": "Jarvis", + "Age": "18", + "Gender": "Non-binary", + "Branch": "Mechanical Engineering", + "Home City": "Indore", + "Hostel": "Chenab", + "daily_plan_req": "Wake up at 7 AM for a morning run or quick gym session. Attend core engineering lectures from 9 AM to 4 PM, with a lunch break at the mess. Dedicate 4 PM to 6 PM to library studies and assignment completion. The evenings are prioritized for socializing, communal dinner with friends, and engaging in light-hearted hostel activities. Late nights are reserved for deep dives into single-player gaming sessions and relaxing before lights out at midnight.", + "innate": "I possess a natural curiosity for how physical systems and machinery work, which pairs well with my optimistic and social nature. I am inherently empathetic and quick to make friends, always looking to find common ground with those around me to foster a welcoming social environment.", + "learned": "Through my first year, I have learned how to manage heavy academic workloads effectively without sacrificing my mental health. I have developed strong skills in CAD software, collaborative problem-solving, and the art of navigating complex social dynamics in a communal living setting.", + "lifestyle": "I lead a balanced life that centers around the high-energy environment of my hostel. I value my friendships deeply and make it a point to be an active presence in the student community. My routine allows for professional growth through academics while keeping enough space for creative decompression via gaming and hobbies.", + "hobbies": "My hobbies include immersive PC gaming, particularly narrative-driven RPGs, tinkering with basic electronics and hardware, playing casual chess in the common room, and curating indie music playlists to share with friends.", + "goals": "To successfully secure a prestigious internship in the robotics field by my third year, maintain a consistent academic record above 8.5 CGPA, and cultivate a supportive social circle that enriches my college experience.", + "interests": [ + "PC Gaming", + "Robotics", + "Mechanical Design", + "Chess", + "Indie Music", + "Photography" + ] +} \ No newline at end of file diff --git a/backend/data/personalities/lavanya_sharma/lavanya_sharma.json b/backend/data/personalities/lavanya_sharma/lavanya_sharma.json new file mode 100644 index 0000000000000000000000000000000000000000..88646f536e017f21648f90bd8346ed967b3c4ddb --- /dev/null +++ b/backend/data/personalities/lavanya_sharma/lavanya_sharma.json @@ -0,0 +1,21 @@ +{ + "Name": "Lavanya Sharma", + "Age": "20", + "Gender": "Female", + "Branch": "Electrical Engineering", + "Home City": "Mumbai", + "Hostel": "Ravi", + "daily_plan_req": "Morning run, classes, debate/gossip time, night study or girls night", + "innate": "Sharp-tongued, confident, ambitious, secretly very playful and freaky", + "learned": "How to dominate arguments with her man and still want to kiss him after", + "lifestyle": "Boss energy by day, menace by night. Doesn't tolerate bullshit", + "hobbies": "Debate, basketball, roasting people, deep gossip with her friends, flirting", + "goals": "Top the class, figure out her complicated tension with people, enjoy college drama", + "interests": [ + "Intellectual banter", + "Power dynamics", + "Fashion", + "Spicy arguments", + "Girls night" + ] +} diff --git a/backend/data/personalities/parv_singla/parv_singla.json b/backend/data/personalities/parv_singla/parv_singla.json new file mode 100644 index 0000000000000000000000000000000000000000..f3b4da020786cb9895fd221916f38f4265a2d70a --- /dev/null +++ b/backend/data/personalities/parv_singla/parv_singla.json @@ -0,0 +1,21 @@ +{ + "Name": "Parv Singla", + "Age": "19", + "Gender": "Male", + "Branch": "Artificial Intelligence and Data Engineering", + "Home City": "Patiala", + "Hostel": "Brahmaputra_Boys 1", + "daily_plan_req": "Skip morning classes, spontaneous plans, night adventures", + "innate": "Impulsive, charming, zero impulse control, golden retriever energy with horny thoughts", + "learned": "How to drag friends into trouble and how to recover from bad decisions", + "lifestyle": "Lives in the moment. Master of 'it seemed like a good idea at the time'", + "hobbies": "Running, music, spontaneous outings, making out, group chaos", + "goals": "Have the most fun possible in college, keep his bromance with friends alive, graduate somehow", + "interests": [ + "Spontaneous shit", + "Music", + "Flirting", + "Adventures", + "Dares" + ] +} diff --git a/backend/data/personalities/riya_murarka/riya_murarka.json b/backend/data/personalities/riya_murarka/riya_murarka.json new file mode 100644 index 0000000000000000000000000000000000000000..8a64c8cc149fce7724e75b1e8186740766711e85 --- /dev/null +++ b/backend/data/personalities/riya_murarka/riya_murarka.json @@ -0,0 +1,21 @@ +{ + "Name": "Riya Murarka", + "Age": "20", + "Gender": "Female", + "Branch": "Chemical Engineering", + "Home City": "Kolkata", + "Hostel": "Jhelum", + "daily_plan_req": "Morning reading, classes, evening runs, night deep talks or self-care", + "innate": "Intelligent, guarded, kind but firm boundaries, secretly romantic", + "learned": "How to handle people's soft crush respectfully, how to trust slowly", + "lifestyle": "Balanced but has a hidden wild side she only shows to close people", + "hobbies": "Reading, long runs, deep conversations, gossip with her friends", + "goals": "Maintain good mental peace, enjoy college without toxic drama, maybe explore with the right person", + "interests": [ + "Books", + "Meaningful talks", + "Running", + "Boundaries", + "Quiet romance" + ] +} diff --git a/backend/data/personalities/saksham/saksham.json b/backend/data/personalities/saksham/saksham.json new file mode 100644 index 0000000000000000000000000000000000000000..3eac913175ca033ccfc3a12ba23e7dd79a83c4f6 --- /dev/null +++ b/backend/data/personalities/saksham/saksham.json @@ -0,0 +1,21 @@ +{ + "Name": "Saksham", + "Age": "21", + "Gender": "Male", + "Branch": "Computer Science Engineering", + "Home City": "Bangalore", + "Hostel": "Chenab", + "daily_plan_req": "Classes, strategy games, night sarcasm + chill", + "innate": "Dry and dark humor king, observant, secretly caring, very witty", + "learned": "How to rebuild trust after friends' chaos, how to use sarcasm as foreplay", + "lifestyle": "Chill exterior, savage interior. Judges everyone but loves his friends", + "hobbies": "Strategy games, roasting, playlists, subtle flirting", + "goals": "Graduate without too much drama, keep roasting everyone, find someone who can match his sarcasm", + "interests": [ + "Sarcasm", + "Games", + "Dark humor", + "Lowkey tension", + "Music" + ] +} diff --git a/backend/data/personalities/tanishq/tanishq.json b/backend/data/personalities/tanishq/tanishq.json new file mode 100644 index 0000000000000000000000000000000000000000..3455c3f572d29b3a0012e844395e86ee7814cf31 --- /dev/null +++ b/backend/data/personalities/tanishq/tanishq.json @@ -0,0 +1,21 @@ +{ + "Name": "Tanishq", + "Age": "19", + "Gender": "Male", + "Branch": "Chemical Engineering", + "Home City": "Indore", + "Hostel": "Satluj", + "daily_plan_req": "Classes, quiet gaming, evening improvement work, night playlists", + "innate": "Shy but improving, sweet, respectful, secretly very passionate", + "learned": "How to be more confident, how to talk to women without overthinking", + "lifestyle": "Quietly blooming. Going from wallflower to someone people notice", + "hobbies": "Strategy games, playlists, observing people, slow-burn crushes", + "goals": "Build confidence, do well academically, maybe finally shoot his shot with a woman or someone", + "interests": [ + "Music", + "Games", + "Self-improvement", + "Respectful tension", + "Deep talks" + ] +} diff --git a/backend/pathfinder.py b/backend/pathfinder.py new file mode 100644 index 0000000000000000000000000000000000000000..7d62bc0dfbcee7c52d244616a40532ebd152fafe --- /dev/null +++ b/backend/pathfinder.py @@ -0,0 +1,114 @@ +""" +Core pathfinding module โ€” imported by Odin.py and pixel_pathfinder.py. +Loads path.png into a set of walkable (white) pixels and provides +BFS shortest_path(), stats(), and is_walkable() helpers. +""" + +import os +import threading +from collections import deque +from PIL import Image + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +_path_img = None +_white_pixels = None +_W = _H = 0 +_load_lock = threading.Lock() + + +def _load(): + global _path_img, _white_pixels, _W, _H + if _white_pixels is not None: + return + with _load_lock: + if _white_pixels is not None: + return + # path.png may live under frontend/, frontend/public/ (source) or + # frontend/dist/ (after a build). Use whichever exists. + candidates = [ + os.path.join(ROOT, "frontend", "public", "path.png"), + os.path.join(ROOT, "frontend", "path.png"), + os.path.join(ROOT, "frontend", "dist", "path.png"), + ] + path_file = next((p for p in candidates if os.path.exists(p)), None) + if path_file is None: + raise FileNotFoundError( + "path.png not found in frontend/public, frontend, or frontend/dist" + ) + # Copy decoded pixels while the image handle is open, then release the + # handle immediately so the source image is not locked on Windows. + with Image.open(path_file) as image: + rgb = image.convert("RGB") + _W, _H = rgb.size + pix = rgb.load() + _white_pixels = { + (x, y) + for y in range(_H) + for x in range(_W) + if pix[x, y] == (255, 255, 255) + } + + +def _nearest_walkable(pt, max_r=60): + """Return the closest walkable pixel to `pt` (or None). Lets us route from + a building door / interior point that isn't exactly on a path pixel.""" + if pt in _white_pixels: + return pt + x0, y0 = pt + for r in range(1, max_r + 1): + for x in range(x0 - r, x0 + r + 1): + if (x, y0 - r) in _white_pixels: + return (x, y0 - r) + if (x, y0 + r) in _white_pixels: + return (x, y0 + r) + for y in range(y0 - r + 1, y0 + r): + if (x0 - r, y) in _white_pixels: + return (x0 - r, y) + if (x0 + r, y) in _white_pixels: + return (x0 + r, y) + return None + + +def stats(): + _load() + return {"white_pixels": len(_white_pixels), "width": _W, "height": _H} + + +def is_walkable(x, y): + _load() + return (x, y) in _white_pixels + + +def neighbors(px, py): + n = [] + if py > 0: n.append((px, py - 1)) + if py < _H - 1: n.append((px, py + 1)) + if px > 0: n.append((px - 1, py)) + if px < _W - 1: n.append((px + 1, py)) + return n + + +def shortest_path(start, end): + _load() + # Snap endpoints onto the walkable network (doors/interiors may sit just off it). + start = _nearest_walkable(tuple(start)) + end = _nearest_walkable(tuple(end)) + if start is None or end is None: + return None + q = deque([start]) + parents = {start: None} + while q: + cur = q.popleft() + if cur == end: + path = [] + while cur is not None: + path.append(cur) + cur = parents[cur] + path.reverse() + return path + for nb in neighbors(*cur): + if nb not in parents and nb in _white_pixels: + parents[nb] = cur + q.append(nb) + return None diff --git a/backend/src/agents/Actions.py b/backend/src/agents/Actions.py new file mode 100644 index 0000000000000000000000000000000000000000..7627261cdacbe4dbcdfbda20efda6fbf1cb119ad --- /dev/null +++ b/backend/src/agents/Actions.py @@ -0,0 +1,801 @@ +""" +Actions -- manages agent action execution: last, current, next. + +Takes the raw day plan produced by day_planner.py and drives it forward +tick by tick. Handles three action types: + + 1. MOVE -- agent walks from place A to place B (pathfinder.py) + 2. MISC -- static activity: studying, coding, eating, etc. + 3. CONVERSATION -- triggered when two agents are in proximity. + +The module converts location_id strings (from day plans) into pixel +coordinates (from entrypoint.json) and uses the BFS pathfinder to +compute walkable paths between locations. + +Usage: + from src.agents.Actions import AgentActionManager, LocationResolver + + resolver = LocationResolver() + manager = AgentActionManager("parv_singla", day_plan, initial_position) + state = manager.tick(world_tick, snapshot) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +if str(BACKEND_ROOT) not in sys.path: + sys.path.append(str(BACKEND_ROOT)) + + +import json +import math +import random +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple + +from pydantic import BaseModel, Field + +from src.core.log import get_logger +from src.core.world_state import Position + +logger = get_logger(__name__) + + +def _interpolate_line(a: Tuple[int, int], b: Tuple[int, int], steps: int = 40) -> List[Tuple[int, int]]: + """A straight line of `steps` points from a to b (inclusive). Used as a + fallback route so agents still animate when no walkable path is found.""" + (x0, y0), (x1, y1) = a, b + steps = max(2, steps) + return [ + (round(x0 + (x1 - x0) * i / (steps - 1)), round(y0 + (y1 - y0) * i / (steps - 1))) + for i in range(steps) + ] + + +# --------------------------------------------------------------------------- +# Action types +# --------------------------------------------------------------------------- + +class ActionType(str, Enum): + MOVE = "move" + MISC = "misc" + CONVERSATION = "conversation" + + +# --------------------------------------------------------------------------- +# Action state +# --------------------------------------------------------------------------- + +class ActionState(BaseModel): + """One action in the agent's queue.""" + action_type: ActionType + description: str + start_time: str # "HH:MM" + end_time: str # "HH:MM" + location_id: str # places.json ID + sub_area: Optional[str] = None + position: Optional[Position] = None # pixel coords (filled by resolver) + path: Optional[List[Tuple[int, int]]] = None # if MOVE, the pixel path + path_index: int = 0 # current position along path + energy_change: float = 0.0 # total change over entire action + emotion_change: float = 0.0 # total change over entire action + is_final_plan_action: bool = False + event_id: Optional[str] = None # data-driven world event, when applicable + + +# --------------------------------------------------------------------------- +# Location resolver -- converts location_id -> pixel Position +# --------------------------------------------------------------------------- + +# places.json ID -> entrypoint.json building name +_PLACES_TO_ENTRYPOINT: Dict[str, str] = { + "Brahmaputra_Boys 1": "Brahmputra Boys 1", + "Brahmaputra_Boys 2": "Brahmputra Boys 2", + "mess": "Mess", + "LHC": "Lecture Hall complex", + "admin_block": "Admin Block", + "sac_utility_block": "Utility/ SAC", + "main_gate": "Campus Main gate", + "central_lawn": "Main fest Ground", + "auditorium": "Auditorium + Library", + "library": "Auditorium + Library", + "sports_complex": "Volleyball Court", + "health_centre": "Admin Block", + "Chenab": "Chenab", + "Beas": "Beas", + "Satluj": "Satluj", + "Jhelum": "Jhelum", + "Ravi": "Ravi", + "SAB": "SAB", + "computer_science_department": "CS Department", + "electrical_department": "Electrical Department", + "mechanical_department": "Mechanical Department", + "chemical_department": "Chemical Department", +} + + +class LocationResolver: + """Maps places.json location_id strings to pixel Position objects.""" + + def __init__( + self, + places_path: Optional[Path] = None, + entrypoint_path: Optional[Path] = None, + ): + from src.config import ENVIRONMENT_DIR + + places_path = places_path or ENVIRONMENT_DIR / "places.json" + entrypoint_path = entrypoint_path or ENVIRONMENT_DIR / "entrypoint.json" + + self._entrypoints: Dict[str, Dict[str, Any]] = {} + self._places_inline: Dict[str, Tuple[int, int]] = {} + self._name_to_polykey: Dict[str, str] = {} # entrypoint name -> "PolygonBuilding_N" + self._bbox_by_name: Dict[str, Tuple[int, int, int, int]] = {} # name -> (xmin,ymin,xmax,ymax) + + # Load entrypoints + if entrypoint_path.exists(): + raw = json.loads(entrypoint_path.read_text()) + for key, data in raw.items(): + name = data.get("name", "") + self._entrypoints[name] = {"x": data["x"], "y": data["y"]} + self._name_to_polykey[name] = key + + # Load inline coordinates from places.json (3 locations have this) + if places_path.exists(): + raw = json.loads(places_path.read_text(encoding="utf-8")) + for loc in raw.get("locations", []): + inline = loc.get("Entry_point Coordinates", "") + if inline and "x" in inline and "y" in inline: + # Parse "x = 712, y = 414" + parts = inline.split(",") + x = int(parts[0].split("=")[1].strip()) + y = int(parts[1].split("=")[1].strip()) + self._places_inline[loc["id"]] = (x, y) + + # Load building bounding boxes: union all decomposed parts per polygon. + bbox_path = ENVIRONMENT_DIR / "buildings_polygon_decomposed.json" + polykey_bbox: Dict[str, Tuple[int, int, int, int]] = {} + if bbox_path.exists(): + try: + parts = json.loads(bbox_path.read_text()) + for p in parts: + base = p.get("building_name", "").rsplit("_part", 1)[0] + corners = [p.get("top_left"), p.get("top_right"), + p.get("bottom_left"), p.get("bottom_right")] + xs = [c[0] for c in corners if c] + ys = [c[1] for c in corners if c] + if not xs or not ys: + continue + x0, y0, x1, y1 = min(xs), min(ys), max(xs), max(ys) + if base in polykey_bbox: + px0, py0, px1, py1 = polykey_bbox[base] + polykey_bbox[base] = (min(px0, x0), min(py0, y0), max(px1, x1), max(py1, y1)) + else: + polykey_bbox[base] = (x0, y0, x1, y1) + except Exception as e: + logger.warning("[LocationResolver] failed to load building bboxes: %s", e) + # Link entrypoint name -> bbox via the shared polygon key. + for name, key in self._name_to_polykey.items(): + if key in polykey_bbox: + self._bbox_by_name[name] = polykey_bbox[key] + + logger.info( + "[LocationResolver] loaded %d entrypoints, %d inline coords, %d building bboxes", + len(self._entrypoints), len(self._places_inline), len(self._bbox_by_name), + ) + + def resolve(self, location_id: str) -> Optional[Position]: + """Convert a places.json ID to a Position with pixel coordinates.""" + if not isinstance(location_id, str) or not location_id.strip(): + logger.warning("[LocationResolver] invalid empty location_id: %r", location_id) + return None + # Strategy 1: inline coordinates from places.json + if location_id in self._places_inline: + x, y = self._places_inline[location_id] + return Position(x=x, y=y, location_id=location_id) + + # Strategy 2: explicit override dictionary + entrypoint_name = _PLACES_TO_ENTRYPOINT.get(location_id) + if entrypoint_name and entrypoint_name in self._entrypoints: + data = self._entrypoints[entrypoint_name] + return Position(x=data["x"], y=data["y"], location_id=location_id) + + # Strategy 3: fuzzy match (lowercase, strip underscores/spaces) + normalized = location_id.lower().replace("_", "").replace(" ", "") + for name, data in self._entrypoints.items(): + if name.lower().replace("_", "").replace(" ", "") == normalized: + return Position(x=data["x"], y=data["y"], location_id=location_id) + + logger.warning("[LocationResolver] could not resolve '%s'", location_id) + return None + + def has_location(self, location_id: str) -> bool: + """Check if we can resolve this ID.""" + return self.resolve(location_id) is not None + + def _entrypoint_name_for(self, location_id: str) -> Optional[str]: + """Resolve a location_id to its entrypoint building name (for bbox lookup).""" + name = _PLACES_TO_ENTRYPOINT.get(location_id) + if name and name in self._entrypoints: + return name + normalized = location_id.lower().replace("_", "").replace(" ", "") + for nm in self._entrypoints: + if nm.lower().replace("_", "").replace(" ", "") == normalized: + return nm + return None + + def bbox_for(self, location_id: str) -> Optional[Tuple[int, int, int, int]]: + """Return (xmin, ymin, xmax, ymax) for a location's building, or None.""" + name = self._entrypoint_name_for(location_id) + if name and name in self._bbox_by_name: + return self._bbox_by_name[name] + return None + + def random_interior_point( + self, + location_id: str, + occupied: Optional[List[Tuple[int, int]]] = None, + min_gap: int = 6, + max_tries: int = 40, + ) -> Position: + """A random pixel INSIDE the building's bounding box, kept at least + `min_gap` px away from any `occupied` point. Falls back to a small + jitter around the entrypoint when no bbox is known.""" + occupied = occupied or [] + bbox = self.bbox_for(location_id) + ep = self.resolve(location_id) + + if bbox is not None: + x0, y0, x1, y1 = bbox + # small inset so points aren't exactly on the wall + if x1 - x0 > 4: + x0, x1 = x0 + 2, x1 - 2 + if y1 - y0 > 4: + y0, y1 = y0 + 2, y1 - 2 + elif ep is not None: + x0, y0, x1, y1 = ep.x - 14, ep.y - 14, ep.x + 14, ep.y + 14 + else: + return Position(x=0, y=0, location_id=location_id) + + best = None + for _ in range(max_tries): + x = random.randint(int(x0), int(x1)) + y = random.randint(int(y0), int(y1)) + if all((x - ox) ** 2 + (y - oy) ** 2 >= min_gap * min_gap for ox, oy in occupied): + return Position(x=x, y=y, location_id=location_id) + best = (x, y) + x, y = best if best else (int((x0 + x1) / 2), int((y0 + y1) / 2)) + return Position(x=x, y=y, location_id=location_id) + + +# --------------------------------------------------------------------------- +# Agent action manager -- per-agent state machine +# --------------------------------------------------------------------------- + +class AgentActionManager: + """ + Manages the action lifecycle for one agent. + + Reads the day plan, tracks last/current/next action, handles movement + between locations, and updates WorldState each tick. + """ + + def __init__( + self, + agent_id: str, + day_plan: List[Dict[str, Any]], + initial_position: Position, + resolver: LocationResolver, + ): + self.agent_id = agent_id + self.day_plan = sorted(day_plan, key=lambda a: a.get("start", "00:00")) + self.resolver = resolver + self.position = initial_position + + self.last_action: Optional[ActionState] = None + self.current_action: Optional[ActionState] = None + self.next_action: Optional[ActionState] = None + + # Conversation support + self._conversation_mode: bool = False + self._pending_plan_action: Optional[ActionState] = None + + # Last-action detection (end-of-day transition) + self._entered_last_action: bool = False + + @property + def is_last_action(self) -> bool: + """True if the current action is the final entry in the day plan. + Uses description match only โ€” start_time may differ when rescheduled + after a conversation or a long move that shifted the timeline.""" + return bool(self.current_action and self.current_action.is_final_plan_action) + + @property + def is_idle(self) -> bool: + """ + True when the day plan is fully consumed โ€” no action covers the + current time and there are no more plan entries. + """ + return self.current_action is None + + def _hhmm_to_minutes(self, hhmm: str) -> int: + """Convert 'HH:MM' to minutes since midnight.""" + h, m = hhmm.split(":") + return int(h) * 60 + int(m) + + def _minutes_to_hhmm(self, minutes: int) -> str: + """Convert minutes since midnight to 'HH:MM'.""" + h, m = divmod(minutes, 60) + return f"{h:02d}:{m:02d}" + + def _find_current_plan_action(self, hhmm: str) -> Optional[Dict[str, Any]]: + """Find the day plan action that covers this time.""" + minute = self._hhmm_to_minutes(hhmm) + for action in self.day_plan: + start = self._hhmm_to_minutes(action.get("start", "00:00")) + end = self._hhmm_to_minutes(action.get("end", "24:00")) + if start <= minute < end: + return action + return None + + def _create_action_state(self, plan_action: Dict[str, Any]) -> ActionState: + """Create an ActionState from a day plan entry.""" + location_id = plan_action.get("location_id", "") + position = self.resolver.resolve(location_id) + + return ActionState( + action_type=ActionType.MISC, + description=plan_action.get("action", "unknown"), + start_time=plan_action.get("start", "00:00"), + end_time=plan_action.get("end", "24:00"), + location_id=location_id, + sub_area=plan_action.get("sub_area"), + position=position, + energy_change=plan_action.get("energy_change", 0.0), + emotion_change=plan_action.get("emotion_change", 0.0), + is_final_plan_action=bool(self.day_plan and plan_action is self.day_plan[-1]), + event_id=plan_action.get("world_event_id"), + ) + + def _create_move_action( + self, + from_pos: Position, + to_pos: Position, + start_time: str, + end_time: str, + ) -> ActionState: + """Create a MOVE action that walks the agent DOOR to DOOR. + + The route is: current spot -> current building's entry door -> + (pathfinder route along the walkable grid) -> destination building's + entry door. Arrival then settles the agent inside the destination + (handled in tick()). If the pathfinder can't produce a route we fall + back to a densely interpolated straight line so motion is still visible. + """ + # Resolve the two front doors (walkable entrypoints). + from_door = self.resolver.resolve(from_pos.location_id) if from_pos.location_id else None + to_door = self.resolver.resolve(to_pos.location_id) if to_pos.location_id else None + d_from = (from_door.x, from_door.y) if from_door else (from_pos.x, from_pos.y) + d_to = (to_door.x, to_door.y) if to_door else (to_pos.x, to_pos.y) + + road: List[Tuple[int, int]] = [] + try: + from pathfinder import shortest_path + road = shortest_path(d_from, d_to) or [] + except Exception as e: + logger.warning("[Actions] pathfinder failed for %s: %s", self.agent_id, e) + road = [] + + if len(road) < 2: + road = _interpolate_line(d_from, d_to, steps=40) + + # Prepend the agent's current spot so it visibly steps out to its door. + path: List[Tuple[int, int]] = [(from_pos.x, from_pos.y)] + [p for p in road] + + return ActionState( + action_type=ActionType.MOVE, + description=f"Walk from {from_pos.location_id or 'here'} to {to_pos.location_id or 'there'}", + start_time=start_time, + end_time=end_time, + location_id=to_pos.location_id or "", + position=to_pos, + path=path, + path_index=0, + ) + + def _advance_to_next_plan_action(self, current_hhmm: str) -> None: + """Pick the next action from the day plan and set up current/next.""" + plan_action = self._find_current_plan_action(current_hhmm) + if plan_action is None: + # This should be unreachable for validated plans. It can still + # occur when resuming a checkpoint created before stricter replan + # validation existed, or after a damaged external plan edit. Keep + # the agent embodied instead of surfacing a misleading permanent + # ``Idle`` state while the next calendar-day plan is prepared. + now = self._hhmm_to_minutes(current_hhmm) + later_starts = [ + self._hhmm_to_minutes(action.get("start", "24:00")) + for action in self.day_plan + if self._hhmm_to_minutes(action.get("start", "24:00")) > now + ] + end_time = self._minutes_to_hhmm(min(later_starts) if later_starts else 24 * 60) + self.current_action = ActionState( + action_type=ActionType.MISC, + description="Unscheduled downtime", + start_time=current_hhmm, + end_time=end_time, + location_id=self.position.location_id or "", + position=self.position, + ) + logger.warning( + "[Actions] %s: no plan action at %s; using bounded recovery activity until %s", + self.agent_id, current_hhmm, end_time, + ) + return + + target_location_id = plan_action.get("location_id") + if not isinstance(target_location_id, str) or not target_location_id.strip(): + logger.error( + "[Actions] %s: plan action '%s' has no valid location; using bounded recovery activity", + self.agent_id, plan_action.get("action", "unknown"), + ) + now = self._hhmm_to_minutes(current_hhmm) + end_time = plan_action.get("end", self._minutes_to_hhmm(min(now + 30, 24 * 60))) + self.current_action = ActionState( + action_type=ActionType.MISC, + description="Recovering from invalid planned location", + start_time=current_hhmm, + end_time=end_time, + location_id=self.position.location_id or "", + position=self.position, + ) + return + target_position = self.resolver.resolve(target_location_id) + + if target_position is None: + logger.warning( + "[Actions] %s: cannot resolve location '%s' โ€” skipping action", + self.agent_id, target_location_id, + ) + return + + # Check if agent is already at the target location + already_at_location = ( + self.position.location_id == target_location_id + or ( + self.position.x == target_position.x + and self.position.y == target_position.y + ) + ) + + if already_at_location: + # Agent is already here โ€” start the activity immediately + self.current_action = self._create_action_state(plan_action) + self.current_action.start_time = current_hhmm + else: + # Agent needs to move first โ€” create move action with BFS path, + # then compute travel time from the schedule's allocated duration + # so the agent arrives exactly when the plan expects. + self.current_action = self._create_move_action( + self.position, target_position, current_hhmm, "23:59" + ) + path = self.current_action.path + path_len = len(path) if path else 100 + # Schedule-aware speed: arrive by the plan's end time. + plan_end_min = self._hhmm_to_minutes(plan_action.get("end", "24:00")) + start_minute = self._hhmm_to_minutes(current_hhmm) + activity_minutes = max(1, plan_end_min - start_minute) + min_on_site_minutes = min(10, max(1, activity_minutes // 2)) + max_travel_minutes = max(1, activity_minutes - min_on_site_minutes) + natural_travel_minutes = max(1, math.ceil(path_len / 50)) + # Transit must leave time for the scheduled on-site activity. + travel_minutes = min(natural_travel_minutes, max_travel_minutes) + end_minute = start_minute + travel_minutes + self.current_action.end_time = self._minutes_to_hhmm(min(end_minute, 24 * 60)) + # The actual activity becomes the next action + self.next_action = self._create_action_state(plan_action) + + def set_conversation_action(self, other_agent_id: str, world_tick: Optional[int] = None) -> ActionState: + """ + Override the current action with a conversation (no pre-set duration). + Agent stays in conversation mode until resume_from_conversation() is + called by the background LLM task. + """ + hhmm = self._minutes_to_hhmm((world_tick or 0) % (24 * 60)) + if self.current_action: + self._pending_plan_action = self.current_action + + conv_action = ActionState( + action_type=ActionType.CONVERSATION, + description=f"Chatting with {other_agent_id}", + start_time=hhmm, + end_time="23:59", # placeholder โ€” overridden by resume_from_conversation + location_id=self.position.location_id or "", + position=self.position, + ) + self.current_action = conv_action + self._conversation_mode = True + return conv_action + + def resume_from_conversation( + self, + new_day_plan: Optional[List[Dict[str, Any]]] = None, + world_tick: Optional[int] = None, + ) -> None: + """ + Leave conversation mode and resume the interrupted action when the + plan has not changed. A paused route resumes from its actual point. + """ + if new_day_plan is not None: + self.day_plan = sorted(new_day_plan, key=lambda a: a.get("start", "00:00")) + pending = self._pending_plan_action + self._conversation_mode = False + self._pending_plan_action = None + self._entered_last_action = False + if pending is None or new_day_plan is not None: + self.current_action = None + self.next_action = None + return + + if pending.action_type == ActionType.MOVE and pending.path: + old_path = pending.path + old_index = min(pending.path_index, len(old_path) - 1) + fraction_done = old_index / max(1, len(old_path) - 1) + pending.path = [(self.position.x, self.position.y)] + old_path[old_index + 1:] + pending.path_index = 0 + if world_tick is not None: + duration = max(1, self._hhmm_to_minutes(pending.end_time) - self._hhmm_to_minutes(pending.start_time)) + remaining = max(1, math.ceil(duration * (1.0 - fraction_done))) + now = world_tick % (24 * 60) + pending.start_time = self._minutes_to_hhmm(now) + pending.end_time = self._minutes_to_hhmm(min(24 * 60, now + remaining)) + if len(pending.path) < 2: + # The conversation happened at the final waypoint. Do not + # leave a one-point MOVE that cannot interpolate; commit the + # arrival and continue its queued on-site activity instead. + if pending.location_id: + self.position = self.resolver.random_interior_point(pending.location_id) + if self.next_action is not None: + self.current_action = self.next_action + self.next_action = None + else: + self.current_action = None + return + self.current_action = pending + + def replace_day_plan(self, new_day_plan: List[Dict[str, Any]]) -> None: + """ + Replace the day plan mid-stream (e.g. at end-of-day transition). + Keeps the current action if it's still valid, otherwise advances. + """ + self.day_plan = sorted(new_day_plan, key=lambda a: a.get("start", "00:00")) + self._pending_plan_action = None + self._entered_last_action = False + + def begin_new_day(self, new_day_plan: List[Dict[str, Any]]) -> None: + """Install a new calendar day's plan without moving the agent. + + Position is intentionally retained: the first action of the new plan + must account for where the previous day actually ended. Schedule + bookkeeping is reset so a previous day's ``24:00`` action cannot wrap + around and continue into the next day. + """ + self.day_plan = sorted(new_day_plan, key=lambda a: a.get("start", "00:00")) + self.last_action = self.current_action + self.current_action = None + self.next_action = None + self._pending_plan_action = None + self._conversation_mode = False + self._entered_last_action = False + + def tick(self, world_tick: int, snapshot: Any = None) -> Optional[ActionState]: + """ + Advance one tick. Returns the current action state (or None). + + This is the main entry point called each simulation tick. + """ + hhmm = self._minutes_to_hhmm(world_tick % (24 * 60)) + + # In conversation mode: don't advance, just return current action. + # The agent stays frozen until resume_from_conversation() is called. + if self._conversation_mode: + return self.current_action + + # First tick or after action completed โ€” pick next action + if self.current_action is None: + self._advance_to_next_plan_action(hhmm) + if self.current_action is None: + return None + + # Check if current action is finished + current_end = self._hhmm_to_minutes(self.current_action.end_time) + current_minute = self._hhmm_to_minutes(hhmm) + + # Let a MOVE render its terminal path coordinate for one tick before + # committing the arrival and changing its action label. + is_finished = ( + current_minute > current_end + if self.current_action.action_type == ActionType.MOVE + else current_minute >= current_end + ) + if is_finished: + # Action finished โ€” advance + was_last = self.is_last_action + self.last_action = self.current_action + self.current_action = None + + # If we were moving and have a next action, start it + if self.last_action.action_type == ActionType.MOVE and self.next_action is not None: + self.current_action = self.next_action + self.next_action = None + # Arrived at the destination building โ€” settle at a random spot + # INSIDE its footprint (not stacked on the entrypoint). + if self.current_action.location_id: + self.position = self.resolver.random_interior_point( + self.current_action.location_id + ) + else: + self.position = self.current_action.position or self.position + else: + # Movement completed โ€” settle inside the destination building + if self.last_action.action_type == ActionType.MOVE and self.last_action.location_id: + self.position = self.resolver.random_interior_point( + self.last_action.location_id + ) + elif self.last_action.action_type == ActionType.MOVE and self.last_action.position: + self.position = self.last_action.position + + # Pick next action from plan + self._advance_to_next_plan_action(hhmm) + + # If this was the last action and there's a next action (from the plan), + # it's not really the last โ€” clear the flag + if was_last and self.current_action is not None: + self._entered_last_action = False + + # Detect first tick of the last action + if self.current_action and self.is_last_action and not self._entered_last_action: + self._entered_last_action = True + + # If still moving, advance along the path proportionally to elapsed + # time, so the agent visibly walks the whole route (start -> destination) + # over the move's duration instead of jumping. + if ( + self.current_action is not None + and self.current_action.action_type == ActionType.MOVE + and self.current_action.path + ): + path = self.current_action.path + total = len(path) + if total >= 2: + start_min = self._hhmm_to_minutes(self.current_action.start_time) + end_min = self._hhmm_to_minutes(self.current_action.end_time) + dur = max(1, end_min - start_min) + progress = (current_minute - start_min) / dur + progress = min(1.0, max(0.0, progress)) + # Fractional index so we move continuously *between* path points, + # not just land on them โ€” smooth even for short/sparse routes. + fidx = progress * (total - 1) + i = int(fidx) + if i >= total - 1: + nx, ny = path[-1] + else: + frac = fidx - i + (ax, ay), (bx, by) = path[i], path[i + 1] + nx = round(ax + (bx - ax) * frac) + ny = round(ay + (by - ay) * frac) + # A route has a destination, but the agent is not semantically + # *at* that destination until arrival. Retaining the current + # location prevents UI, replanning, and conversations from + # treating an in-transit agent as already there. + self.position = Position(x=nx, y=ny, location_id=self.position.location_id) + self.current_action.path_index = i + + return self.current_action + + def get_state(self) -> Dict[str, Any]: + """Return current state as a dict (for frontend/API).""" + return { + "agent_id": self.agent_id, + "position": self.position.model_dump(), + "last_action": self.last_action.model_dump() if self.last_action else None, + "current_action": self.current_action.model_dump() if self.current_action else None, + "next_action": self.next_action.model_dump() if self.next_action else None, + } + + +# --------------------------------------------------------------------------- +# Self-test +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import argparse + + from src.core.log import setup_logging + setup_logging(run_id="actions_test", console=True) + + from src.config import PERSONALITIES_DIR + + parser = argparse.ArgumentParser(description="Actions module self-test โ€” simulate an agent's day") + parser.add_argument( + "persona", nargs="?", default="parv_singla", + help="Persona name (e.g. parv_singla, tanishq, gurnoor_singh) or path to a persona JSON", + ) + parser.add_argument( + "-t", "--ticks", type=int, default=600, + help="Number of ticks to simulate (default: 600 = 10 hours)", + ) + parser.add_argument( + "--step", type=int, default=5, + help="Print status every N ticks (default: 5)", + ) + args = parser.parse_args() + + # Resolve persona path + candidate = Path(args.persona) + if candidate.exists(): + persona_path = candidate + elif candidate.suffix == ".json": + matches = sorted(PERSONALITIES_DIR.glob(f"**/{candidate.name}")) + persona_path = matches[0] if matches else candidate + else: + matches = sorted(PERSONALITIES_DIR.glob(f"**/{args.persona}/{args.persona}.json")) + if not matches: + matches = sorted(PERSONALITIES_DIR.glob(f"**/{args.persona}.json")) + if not matches: + available = sorted({p.parent.name for p in PERSONALITIES_DIR.glob("**/*.json")}) + print(f"Persona '{args.persona}' not found. Available: {', '.join(available)}") + sys.exit(1) + persona_path = matches[0] + + persona = json.loads(persona_path.read_text()) + persona_name = persona.get("Name", persona_path.stem) + + # Load day plan from Short_term + from src.agents.Short_term import load_day_plan, date_from_simulation_time + + sim_date = date_from_simulation_time("2026-07-03 00:00") + day_plan = load_day_plan(persona_name, sim_date) + + if not day_plan: + print(f"No day plan found for {persona_name} on {sim_date}") + print("Generate one first: python backend/src/core/Agent.py " + args.persona) + sys.exit(1) + + print(f"Loaded day plan for {persona_name}: {len(day_plan)} actions") + + # Set up resolver and manager + resolver = LocationResolver() + initial_pos = resolver.resolve("Brahmaputra_Boys 1") or Position(x=185, y=547, location_id="Brahmaputra_Boys 1") + + manager = AgentActionManager( + agent_id=persona_path.stem, + day_plan=day_plan, + initial_position=initial_pos, + resolver=resolver, + ) + + # Run simulation + print(f"\nSimulating {args.ticks} ticks (step={args.step}) from 00:00...") + print(f"{'Tick':>5} {'Time':>6} {'Type':<6} {'Description':<45} {'Location':<25} {'Position'}") + print("-" * 110) + + for tick in range(0, args.ticks): + hhmm = manager._minutes_to_hhmm(tick) + action = manager.tick(tick) + + if tick % args.step == 0 and action: + desc = action.description[:43] + loc = action.location_id[:23] if action.location_id else "" + pos = f"({manager.position.x},{manager.position.y})" + print(f"{tick:>5} {hhmm:>6} {action.action_type.value:<6} {desc:<45} {loc:<25} {pos}") + + print(f"\nFinal state:") + state = manager.get_state() + print(f" Position: ({state['position']['x']}, {state['position']['y']}) at {state['position']['location_id']}") + print(f" Current action: {state['current_action']['description'] if state['current_action'] else 'None'}") + + print("\nActions.py self-test passed.") diff --git a/backend/src/agents/Long_term.py b/backend/src/agents/Long_term.py new file mode 100644 index 0000000000000000000000000000000000000000..3828cf5e529bebbbff32ab295ddb1f9126d51797 --- /dev/null +++ b/backend/src/agents/Long_term.py @@ -0,0 +1,45 @@ +"""Qdrant-backed long-term memory interface. + +Long-term agent memory is stored only in Qdrant. Short-term JSON files remain +the operational record for the active simulation day; they are summarized and +indexed at handoff, then removed. This module deliberately has no JSON +archive reader or keyword-search fallback. +""" +from __future__ import annotations + +from typing import List, Optional, Protocol, runtime_checkable + +from src.core.log import get_logger + +logger = get_logger(__name__) + + +@runtime_checkable +class MemoryRetriever(Protocol): + """Contract consumed by planning, conversation, and tick cognition.""" + + def store(self, agent_id: str, text: str, *, kind: str = "observation", + importance: Optional[float] = None, date_str: Optional[str] = None) -> None: ... + + def retrieve(self, agent_id: str, query: str, k: int = 5) -> List[str]: ... + + def rolling_summary(self, agent_id: str, days: int = 3, + before_date: Optional[str] = None) -> Optional[str]: ... + + +_retriever_singleton: Optional[MemoryRetriever] = None + + +def get_retriever() -> MemoryRetriever: + """Return the process-wide Qdrant retriever. + + The retriever is intentionally fail-soft for reads: an unavailable cloud + service yields no recalled context rather than allowing a memory outage to + stop the simulation. It never reads a local long-term JSON archive. + """ + global _retriever_singleton + if _retriever_singleton is None: + from src.agents.vector_memory import VectorMemoryRetriever + _retriever_singleton = VectorMemoryRetriever() + logger.info("[Long_term] using Qdrant-only long-term memory") + return _retriever_singleton diff --git a/backend/src/agents/Short_term.py b/backend/src/agents/Short_term.py new file mode 100644 index 0000000000000000000000000000000000000000..e54be0a55d5fa652e06f810b801bb377c78d08e6 --- /dev/null +++ b/backend/src/agents/Short_term.py @@ -0,0 +1,559 @@ +""" +Short-term Memory -- per-agent, per-day detailed memory store. + +Stores the full day's data (plan, events, conversations, world snapshots) +as a single JSON file per persona per simulation date. + +File layout: + data/Short_term_db//.json + +Implements MemoryStreamProtocol (from tick_graph.py) for tick-graph integration. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +import tempfile +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Optional + +from pydantic import BaseModel + +from src.config import DATA_DIR +from src.core.log import get_logger + +logger = get_logger(__name__) + +# Directory for short-term memory files +SHORT_TERM_DIR = DATA_DIR / "Short_term_db" +_DAY_LOCKS_GUARD = threading.Lock() +_DAY_LOCKS: dict[Path, threading.RLock] = {} + + +# --------------------------------------------------------------------------- +# Data Models (internal) +# --------------------------------------------------------------------------- + +class DayEvent(BaseModel): + """One event that happened during the day.""" + timestamp: str # ISO format with timezone + type: str # action_started, action_completed, conversation, world_snapshot, observation + action: Optional[str] = None + outcome: Optional[str] = None + success: Optional[bool] = None + location: Optional[str] = None + with_agent: Optional[str] = None + participants: Optional[list[str]] = None + topic: Optional[str] = None + summary: Optional[str] = None + messages: Optional[list[dict]] = None + details: Optional[dict] = None + + +class ConversationEntry(BaseModel): + """A conversation with another agent.""" + timestamp: str + participants: list[str] + messages: list[dict] + summary: str + + +class WorldSnapshotEntry(BaseModel): + """Periodic world state snapshot.""" + timestamp: str + location: str + nearby_agents: list[str] + weather: Optional[str] = None + details: Optional[dict] = None + + +class ShortTermDayData(BaseModel): + """Complete day memory for one persona on one simulation date.""" + persona_name: str + date: str + day_plan: list[dict] = [] + events: list[DayEvent] = [] + conversations: list[ConversationEntry] = [] + world_snapshots: list[WorldSnapshotEntry] = [] + daily_summary: Optional[str] = None + archived_to_long_term: bool = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _persona_dir(persona_name: str) -> Path: + """Get the directory for a persona's short-term files.""" + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", persona_name.strip()).strip("._-").lower() or "unknown" + dir_path = SHORT_TERM_DIR / safe_name + dir_path.mkdir(parents=True, exist_ok=True) + return dir_path + + +def _day_file_path(persona_name: str, date_str: str) -> Path: + """Get the file path for a specific persona and date.""" + return _persona_dir(persona_name) / f"{date_str}.json" + + +def _day_lock(persona_name: str, date_str: str) -> threading.RLock: + path = _day_file_path(persona_name, date_str).resolve() + with _DAY_LOCKS_GUARD: + return _DAY_LOCKS.setdefault(path, threading.RLock()) + + +def _mutate_day_data(persona_name: str, date_str: str, mutate) -> None: + """Serialize a complete same-day read/modify/write transaction.""" + with _day_lock(persona_name, date_str): + data = _load_day_data(persona_name, date_str) + if data is None: + data = ShortTermDayData(persona_name=persona_name, date=date_str) + mutate(data) + _save_day_data(persona_name, date_str, data) + + +def date_from_simulation_time(time_str: str) -> str: + """ + Extract simulation date from a time string like '2026-07-03 06:00'. + Returns 'YYYY-MM-DD'. + """ + # Expected format: "YYYY-MM-DD HH:MM" or just "YYYY-MM-DD HH:MM:SS" + match = re.match(r"(\d{4}-\d{2}-\d{2})", time_str) + if match: + return match.group(1) + # Fallback: try parsing + try: + dt = datetime.fromisoformat(time_str.replace("Z", "+00:00")) + return dt.date().isoformat() + except Exception: + # Last resort: today's date (real) + return datetime.now(timezone.utc).date().isoformat() + + +def _load_day_data(persona_name: str, date_str: str) -> Optional[ShortTermDayData]: + """Load day data from JSON file.""" + path = _day_file_path(persona_name, date_str) + if not path.exists(): + return None + try: + raw = json.loads(path.read_text(encoding="utf-8")) + return ShortTermDayData.model_validate(raw) + except Exception as e: + logger.error("Failed to load short-term data for %s on %s: %s", persona_name, date_str, e) + return None + + +def _save_day_data(persona_name: str, date_str: str, data: ShortTermDayData) -> None: + """Atomically replace a day file so a process crash cannot leave partial JSON.""" + path = _day_file_path(persona_name, date_str) + path.parent.mkdir(parents=True, exist_ok=True) + payload = data.model_dump_json(indent=2) + fd, temporary_name = tempfile.mkstemp( + prefix=f".{path.stem}.", suffix=".tmp", dir=path.parent, text=True, + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as temporary: + temporary.write(payload) + temporary.flush() + os.fsync(temporary.fileno()) + os.replace(temporary_name, path) + finally: + if os.path.exists(temporary_name): + os.unlink(temporary_name) + logger.debug("Saved short-term data for %s on %s to %s", persona_name, date_str, path) + + +# --------------------------------------------------------------------------- +# Public API -- MemoryStreamProtocol (for tick_graph.py) +# --------------------------------------------------------------------------- + +def add_memory(agent_id: str, content: str, importance: Optional[int] = None, date_str: Optional[str] = None) -> None: + """ + Add a memory entry for an agent. + Stores as an event in the short-term file for the given (or current) date. + """ + if date_str is None: + date_str = datetime.now(timezone.utc).date().isoformat() + data = _load_day_data(agent_id, date_str) + if data is None: + data = ShortTermDayData(persona_name=agent_id, date=date_str) + data.events.append(DayEvent( + timestamp=datetime.now(timezone.utc).isoformat(), + type="memory", + details={"content": content, "importance": importance} + )) + _save_day_data(agent_id, date_str, data) + + +def retrieve_memories(agent_id: str, query: str, k: int = 5, date_str: Optional[str] = None) -> list[str]: + """Retrieve durable context through the Qdrant semantic-memory RAG layer.""" + from src.agents.Long_term import get_retriever + return get_retriever().retrieve(agent_id, query, k) + + +# --------------------------------------------------------------------------- +# Public API -- Day Planner Integration +# --------------------------------------------------------------------------- + +def save_day_plan(persona_name: str, date_str: str, plan: list[dict]) -> None: + """Save the generated day plan at the start of the day.""" + data = _load_day_data(persona_name, date_str) + if data is None: + data = ShortTermDayData(persona_name=persona_name, date=date_str) + data.day_plan = plan + _save_day_data(persona_name, date_str, data) + + +def load_day_plan(persona_name: str, date_str: str) -> list[dict]: + """Load the day plan for a given date.""" + data = _load_day_data(persona_name, date_str) + return data.day_plan if data else [] + + +# --------------------------------------------------------------------------- +# Single-file hygiene (one short-term file per agent; clean start each run) +# --------------------------------------------------------------------------- + +def is_pristine_plan(persona_name: str, date_str: str) -> bool: + """True if the day file holds a plan but none of the run-time record yet + (no events, conversations, or world snapshots).""" + data = _load_day_data(persona_name, date_str) + return bool( + data and data.day_plan + and not data.events and not data.conversations and not data.world_snapshots + ) + + +def reset_day_runtime(persona_name: str, date_str: str) -> None: + """Clear the run-time record (events / conversations / snapshots) but keep + the plan, so a day starts fresh โ€” the file holds only the plan again.""" + data = _load_day_data(persona_name, date_str) + if data is None: + return + data.events = [] + data.conversations = [] + data.world_snapshots = [] + data.archived_to_long_term = False + _save_day_data(persona_name, date_str, data) + + +def list_day_files(persona_name: str) -> list[str]: + """All simulation dates that currently have a short-term file for this agent.""" + d = _persona_dir(persona_name) + return sorted(p.stem for p in d.glob("*.json")) + + +def archive_copy_no_llm(persona_name: str, date_str: str) -> bool: + """Index a completed operational day without making a summary LLM call. + + This is used only to clean up stray short-term files. It never writes a + local long-term JSON archive, and it retains the source file on failure. + """ + data = _load_day_data(persona_name, date_str) + if data is None: + return False + try: + from src.agents.Long_term import get_retriever + return bool(get_retriever().index_archive(persona_name, data.model_dump())) + except Exception as exc: + logger.warning("[Short_term] unable to index stray day %s for %s: %s", date_str, persona_name, exc) + return False + + +def consolidate_to_single_day(persona_name: str, keep_date: str) -> list[str]: + """Ensure the agent keeps only ONE short-term file (for `keep_date`). + Any other day files are indexed into Qdrant (no LLM) and removed only + after Qdrant confirms the write. Returns the dates safely removed. + """ + moved: list[str] = [] + for date_str in list_day_files(persona_name): + if date_str == keep_date: + continue + if archive_copy_no_llm(persona_name, date_str): + clear_short_term_data(persona_name, date_str) + moved.append(date_str) + if moved: + logger.info( + "[Short_term] consolidated %s: indexed %d stray day file(s) into Qdrant (%s)", + persona_name, len(moved), ", ".join(moved), + ) + return moved + + +def append_event(persona_name: str, date_str: str, event: dict) -> None: + """ + Append an event during the day. + event dict should have: type, action, outcome, success, location, details, etc. + """ + entry = dict(event) + entry.setdefault("timestamp", datetime.now(timezone.utc).isoformat()) + _mutate_day_data( + persona_name, date_str, + lambda data: data.events.append(DayEvent(**entry)), + ) + + +def append_conversation(persona_name: str, date_str: str, conversation: dict) -> None: + """ + Append a conversation during the day. + conversation dict: participants, messages, summary + """ + entry = dict(conversation) + entry.setdefault("timestamp", datetime.now(timezone.utc).isoformat()) + _mutate_day_data( + persona_name, date_str, + lambda data: data.conversations.append(ConversationEntry(**entry)), + ) + + +def append_world_snapshot(persona_name: str, date_str: str, snapshot: dict) -> None: + """ + Append a world state snapshot during the day. + snapshot dict: location, nearby_agents, weather, details + """ + entry = dict(snapshot) + entry.setdefault("timestamp", datetime.now(timezone.utc).isoformat()) + _mutate_day_data( + persona_name, date_str, + lambda data: data.world_snapshots.append(WorldSnapshotEntry(**entry)), + ) + + +def get_yesterday_data(persona_name: str, today_date: str) -> Optional[ShortTermDayData]: + """ + Get the full yesterday's data for a persona. + today_date: 'YYYY-MM-DD' (simulation date) + Returns yesterday's ShortTermDayData or None. + """ + try: + today_dt = datetime.fromisoformat(today_date).date() + yesterday = (today_dt - timedelta(days=1)).isoformat() + except Exception: + # Fallback to real date + yesterday = (datetime.now(timezone.utc).date() - timedelta(days=1)).isoformat() + + return _load_day_data(persona_name, yesterday) + + +def get_yesterday_summary(persona_name: str, today_date: str) -> Optional[str]: + """ + Get a text summary of yesterday for the day planner prompt. + Returns None if no yesterday data exists. + """ + from src.agents.Long_term import get_retriever + return get_retriever().rolling_summary(persona_name, days=1, before_date=today_date) + + +def get_relevant_memories(persona_name: str, today_date: str, query: str, k: int = 10) -> list[str]: + """ + Get relevant long-term memories for model prompts through semantic search. + """ + return retrieve_memories(persona_name, query, k, date_str=today_date) + + +# --------------------------------------------------------------------------- +# Public API -- End of Day Lifecycle +# --------------------------------------------------------------------------- + +async def generate_daily_summary(persona_name: str, date_str: str) -> str: + """ + Generate an LLM summary of the day's events for long-term storage. + Uses the same gemini_client as day_planner. + """ + from src.llm.gemini_client import call_gemini + from pydantic import BaseModel + from typing import List + + class SummaryOutput(BaseModel): + summary: str + key_events: List[str] + + data = _load_day_data(persona_name, date_str) + if data is None or not data.events: + return f"No events recorded for {persona_name} on {date_str}." + + # Build event text + event_lines = [] + for e in data.events: + if e.type == "action_completed": + event_lines.append(f"- {e.action} at {e.location or 'unknown'} (outcome: {e.outcome}, success: {e.success})") + elif e.type == "conversation": + event_lines.append(f"- Conversation with {e.with_agent or ', '.join(e.participants) if e.participants else 'unknown'}: {e.summary or e.topic}") + elif e.type == "observation": + event_lines.append(f"- Observed: {e.details}") + + event_text = "\n".join(event_lines) + + system_prompt = ( + "You are summarizing one day in the life of a simulated college student agent. " + "You will be provided the Initial day plan, the conversations the agent had throughout and some extra stuff. " + "Write a concise summary of about 100 words prioritizing the most important:\n" + "- High-priority tasks and whether they were completed\n" + "- Important events and social interactions\n" + "- Significant conversations and decisions\n" + "- Anything likely to matter in future days\n" + "Only focus on routine low-value chores unless no important event happened that day.\n" + "Also list 3-5 key events as bullet points." + ) + user_prompt = ( + f"Persona: {persona_name}\n" + f"Date: {date_str}\n" + f"Day plan: {len(data.day_plan)} scheduled actions\n\n" + f"Initial day plan:\n" + + "\n".join(f" - {a.get('start','?')}-{a.get('end','?')} {a.get('action','?')} at {a.get('location_id','?')}" for a in data.day_plan[:15]) + + f"\n\nEvents:\n{event_text}\n\n" + f"Conversations ({len(data.conversations)}):\n" + + "\n".join(f"- {c.summary} (with {', '.join(c.participants)})" for c in data.conversations[:5]) + + "\n\nGenerate the daily summary (~100 words) and key events list." + ) + + try: + from src.config import SUMMARY_TEMPERATURE + result = call_gemini(system_prompt, user_prompt, SummaryOutput, "default", temperature=SUMMARY_TEMPERATURE) + # Format for long-term storage + key_events_text = "\n".join(f"- {evt}" for evt in result.key_events) + return f"Summary: {result.summary}\nKey events:\n{key_events_text}" + except Exception as e: + logger.error("Failed to generate LLM daily summary for %s on %s: %s", persona_name, date_str, e) + # Fallback: programmatic summary + parts = [f"{persona_name} on {date_str}:"] + for e in data.events: + if e.type == "action_completed": + parts.append(f" - {e.action} ({e.outcome}, success={e.success})") + return "\n".join(parts) + + +async def finalize_day(persona_name: str, date_str: str) -> dict: + """ + End-of-day processing: + 1. Generate LLM summary of the day + 2. Mark as archived + 3. Return data ready for long-term storage + """ + data = _load_day_data(persona_name, date_str) + if data is None: + return {"summary": f"No data for {persona_name} on {date_str}", "full_data": None} + + # Generate summary + daily_summary = await generate_daily_summary(persona_name, date_str) + data.daily_summary = daily_summary + data.archived_to_long_term = True + + # Save final version with summary + _save_day_data(persona_name, date_str, data) + + # Return data for long-term storage + return { + "summary": daily_summary, + "full_data": data.model_dump() + } + + +async def archive_to_long_term(persona_name: str, date_str: str) -> dict: + """ + End-of-day archival: generate a daily summary and persist its durable + records directly to Qdrant. No Long_term_db JSON file is created. + + Returns the archive payload dict with keys: summary, persona_name, date, + event_count, conversation_count. + """ + result = await finalize_day(persona_name, date_str) + data = result.get("full_data") + if data is None: + return {"summary": f"No data for {persona_name} on {date_str}"} + + # Offline/local runs intentionally disable the Qdrant-backed long-term + # store. Treat this as an explicit, successful skip rather than trying a + # known-unavailable retriever and turning every midnight handoff into an + # archive failure. + from src import config as _cfg + if not _cfg.SEMANTIC_MEMORY_ENABLED: + logger.info("[Short_term] semantic archival disabled; skipped %s on %s", persona_name, date_str) + return { + "summary": result.get("summary", ""), + "persona_name": persona_name, + "date": date_str, + "event_count": len(data.get("events", [])), + "conversation_count": len(data.get("conversations", [])), + "skipped": True, + } + + try: + from src.agents.Long_term import get_retriever + retriever = get_retriever() + indexed = await asyncio.to_thread(retriever.index_archive, persona_name, data) + if indexed <= 0 and data.get("daily_summary"): + raise RuntimeError("Qdrant did not acknowledge any durable memory records") + except Exception as exc: + # The short-term source remains in place so an operator can retry. Do + # not silently claim archival success when Qdrant is the only durable + # long-term store. + logger.error("[Short_term] Qdrant archive failed for %s: %s", persona_name, exc) + raise + + logger.info( + "[Short_term] archived %s on %s into Qdrant (%d events, %d conversations)", + persona_name, date_str, + len(data.get("events", [])), len(data.get("conversations", [])), + ) + + return { + "summary": result.get("summary", ""), + "persona_name": persona_name, + "date": date_str, + "event_count": len(data.get("events", [])), + "conversation_count": len(data.get("conversations", [])), + } + + +def clear_short_term_data(persona_name: str, date_str: str) -> bool: + """Delete the short-term file after successful long-term archival. + + Not called automatically โ€” archival keeps the short-term copy in place so + the running day is not disturbed. Exposed for maintenance/cleanup use. + """ + path = _day_file_path(persona_name, date_str) + if path.exists(): + path.unlink() + logger.info("Cleared short-term data for %s on %s", persona_name, date_str) + return True + return False + + +# --------------------------------------------------------------------------- +# MemoryStreamProtocol adapter +# --------------------------------------------------------------------------- + +class ShortTermMemoryStream: + """ + Adapter that wraps Short_term.py's module-level functions as + MemoryStreamProtocol (defined in tick_graph.py) with sim-date awareness. + + Usage: + stream = ShortTermMemoryStream("2026-07-03") + # Tick graph nodes call: + stream.add_memory(agent_id, content, importance) + stream.retrieve_memories(agent_id, query, k) + """ + + def __init__(self, initial_date: str = "") -> None: + self._date_str: str = initial_date or datetime.now(timezone.utc).date().isoformat() + + @property + def date_str(self) -> str: + return self._date_str + + def set_date(self, date_str: str) -> None: + """Update the simulation date (called before each tick).""" + self._date_str = date_str + + def add_memory(self, agent_id: str, content: str, importance: Optional[int] = None) -> None: + add_memory(agent_id, content, importance, date_str=self._date_str) + + def retrieve_memories(self, agent_id: str, query: str, k: int = 5) -> list[str]: + return retrieve_memories(agent_id, query, k, date_str=self._date_str) diff --git a/backend/src/agents/Single_agent.py b/backend/src/agents/Single_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..8c30a1d9984407d9ad12fbc7c6cb3828825282c8 --- /dev/null +++ b/backend/src/agents/Single_agent.py @@ -0,0 +1,223 @@ +""" +Main brain / command centre of a single agent. + +Makes decisions, calls and delegates tasks to sub-modules (day_planner, +memory, reflection, etc.), and runs the agent's action loop. + +Exports: + create_agent_graph() -> CompiledGraph[AgentState] + A single-agent LangGraph. Currently one node: generate_day_plan. + Future: execute_tick, reflect, update_memory, conversation. + +Usage as a library (for the multi-agent orchestrator): + graph = create_agent_graph() + result = graph.invoke({ + "persona_name": "parv_singla", + "persona": {...}, + "current_time": "2026-07-03 06:00", + }) + +Usage from CLI: + python Single_agent.py parv_singla +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +if str(BACKEND_ROOT) not in sys.path: + sys.path.append(str(BACKEND_ROOT)) + + +import json +import time +from types import SimpleNamespace +from typing import Optional, TypedDict + +from langgraph.graph import StateGraph, START, END + +from src.core.log import get_logger, setup_logging +setup_logging(run_id="single_agent", console=False) +from src.config import PERSONALITIES_DIR +from src.agents import day_planner + + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# State schemas +# --------------------------------------------------------------------------- + +class AgentState(TypedDict, total=False): + """State for a single agent's brain graph.""" + persona_name: str + persona: dict + current_time: str + day_plan: list[dict] + relevant_memories: list[str] + yesterday_summary: Optional[str] + error: Optional[str] + + +# --------------------------------------------------------------------------- +# Single-agent graph +# --------------------------------------------------------------------------- + +def retrieve_memories(state: AgentState) -> dict: + """Node: fetch yesterday's summary and relevant memories from Short_term.""" + from src.core.Short_term import ( + date_from_simulation_time, + get_yesterday_summary, + get_relevant_memories, + ) + + persona_name = state["persona_name"] + sim_date = date_from_simulation_time(state.get("current_time", "00:00")) + persona = state.get("persona", {}) + + yesterday_summary = get_yesterday_summary(persona_name, sim_date) + + traits = persona.get("Traits", persona.get("traits", [])) + query = " ".join(traits) if traits else "daily life" + memories = get_relevant_memories(persona_name, sim_date, query, k=5) + + if yesterday_summary: + logger.info("[Single_agent] %s: loaded yesterday's summary", persona_name) + if memories: + logger.info("[Single_agent] %s: loaded %d relevant memories", persona_name, len(memories)) + + return { + "relevant_memories": memories, + "yesterday_summary": yesterday_summary, + } + + +def generate_day_plan(state: AgentState) -> dict: + """Node: generate a day plan for this agent by calling day_planner.run().""" + persona = state["persona"] + persona_name = state["persona_name"] + print(f' Name: "{persona_name}", State: "Processing"') + + try: + agent = SimpleNamespace( + persona=persona, + relevant_memories=state.get("relevant_memories", []), + yesterday_summary=state.get("yesterday_summary"), + ) + + result = day_planner.run(agent, { + "current_time": state.get("current_time", "2026-07-03 06:00"), + "places": None, + "persona_name": persona_name, + }) + + plan_count = len(result.get("day_plan", [])) + print(f' Name: "{persona_name}", State: "Completed" โ€” {plan_count} actions') + + return { + "day_plan": result.get("day_plan", []), + "error": result.get("error"), + } + except Exception as exc: + print(f' Name: "{persona_name}", State: "Failed"') + logger.error("[Single_agent] %s: failed โ€” %s", persona_name, exc) + return {"day_plan": [], "error": str(exc)} + + +_agent_graph = None + + +def create_agent_graph(): + """Build and return the compiled single-agent brain graph (cached).""" + global _agent_graph + if _agent_graph is not None: + return _agent_graph + + builder = StateGraph(AgentState) + builder.add_node("retrieve_memories", retrieve_memories) + builder.add_node("generate_day_plan", generate_day_plan) + builder.add_edge(START, "retrieve_memories") + builder.add_edge("retrieve_memories", "generate_day_plan") + builder.add_edge("generate_day_plan", END) + + _agent_graph = builder.compile() + return _agent_graph + + +# --------------------------------------------------------------------------- +# Plan table printer +# --------------------------------------------------------------------------- + +def _print_day_plan(persona_name: str, day_plan: list[dict]) -> None: + if not day_plan: + return + print(f'\n Generated plan for "{persona_name}":') + print(f' {"Action":25s} {"Start":7s} {"End":7s} {"Location":25s} {"Area":20s}') + print(f' {"-"*25} {"-"*7} {"-"*7} {"-"*25} {"-"*20}') + for a in day_plan: + loc = (a.get("location_id") or "")[:25] + area = (a.get("sub_area") or "")[:20] + print(f' {a.get("action", ""):25s} {a.get("start", ""):7s} {a.get("end", ""):7s} {loc:25s} {area:20s}') + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Run day planner for a single persona") + parser.add_argument( + "persona", + help="Persona name (e.g. parv) or path to a persona JSON file", + ) + args = parser.parse_args() + + # Resolve path + candidate = Path(args.persona) + if candidate.exists(): + persona_path = candidate + elif candidate.suffix == ".json": + matches = sorted(PERSONALITIES_DIR.glob(f"**/{candidate.name}")) + persona_path = matches[0] if matches else candidate + else: + matches = sorted(PERSONALITIES_DIR.glob(f"**/{args.persona}/{args.persona}.json")) + if not matches: + matches = sorted(PERSONALITIES_DIR.glob(f"**/{args.persona}.json")) + if not matches: + available = sorted({p.parent.name for p in PERSONALITIES_DIR.glob("**/*.json")}) + raise FileNotFoundError( + f"Could not find persona '{args.persona}'. " + f"Available: {', '.join(available)}" + ) + persona_path = matches[0] + + persona_data = json.loads(persona_path.read_text()) + persona_name = persona_data.get("Name", persona_path.stem) + + t0 = time.perf_counter() + print(f'Day planner โ€” running "{persona_name}"\n') + + graph = create_agent_graph() + result = graph.invoke({ + "persona_name": persona_name, + "persona": persona_data, + "current_time": "2026-07-03 06:00", + }) + + elapsed = time.perf_counter() - t0 + + print(f"\n{'='*60}") + if result.get("error"): + print(f" Failed: {result['error']} | Elapsed: {elapsed:.1f}s") + else: + print(f" Completed | Elapsed: {elapsed:.1f}s") + print(f"{'='*60}") + + day_plan = result.get("day_plan", []) + if not result.get("error") and day_plan: + _print_day_plan(persona_name, day_plan) diff --git a/backend/src/agents/agents.txt b/backend/src/agents/agents.txt new file mode 100644 index 0000000000000000000000000000000000000000..c0d72ed6d0a23b0130bb357fb3e8f7522ffbb279 --- /dev/null +++ b/backend/src/agents/agents.txt @@ -0,0 +1,2 @@ +agents/ +holds actual cognitive modules (things that take persona/memory/world-state and produce a real output via LLM calls or logic โ€” day_planner, reflection) \ No newline at end of file diff --git a/backend/src/agents/autonomy.py b/backend/src/agents/autonomy.py new file mode 100644 index 0000000000000000000000000000000000000000..2a82360ef1623493520430c86b43c8d2a9e5df3b --- /dev/null +++ b/backend/src/agents/autonomy.py @@ -0,0 +1,20 @@ +""" +Autonomy โ€” schema for the per-minute LLM decision to deviate from the plan. + +The brain calls this once per agent per minute (when enabled). The LLM +sees the agent's persona, current plan, and nearby surroundings, then +decides whether to continue the plan or deviate temporarily. +""" + +from __future__ import annotations + +from pydantic import BaseModel +from typing import Optional + + +class AutonomyDecision(BaseModel): + """Structured output from the autonomy LLM call.""" + deviate: bool + deviation_type: Optional[str] = None + reason: Optional[str] = None + duration_minutes: int = 0 diff --git a/backend/src/agents/body.py b/backend/src/agents/body.py new file mode 100644 index 0000000000000000000000000000000000000000..495cad06b8adc4773b5e601fdf16fc4766957046 --- /dev/null +++ b/backend/src/agents/body.py @@ -0,0 +1,71 @@ +""" +Body -- the agent's "limbs". The motor layer the brain commands. + +In the human-like architecture the *brain* (brain.py) does the thinking: +it perceives, recalls, and decides. It never moves the agent directly. +Instead it issues motor commands to this Body, which is the only thing that +actually changes the agent's position and current activity. + +The Body is a thin, behaviour-preserving adapter around the existing action +state machine (`AgentActionManager` in Actions.py) -- the proven executor +that walks paths and steps through the day plan. Wrapping it (rather than +replacing it) means the body/brain split is a clean architectural layer with +zero change to how movement and actions actually run. + +Motor command surface (all 0-LLM): + - advance(tick) : take the next step of the current plan + - enter_conversation(name) : freeze into a conversation with someone + - resume(day_plan) : leave conversation / reload the plan +Read-only senses of the body's own state: + - position, current_action, is_last_action +""" + +from __future__ import annotations + +from typing import Any, List + +from src.core.log import get_logger + +logger = get_logger(__name__) + + +class BodyController: + """Wraps one agent's `AgentActionManager` as a commandable motor tool.""" + + def __init__(self, manager: Any) -> None: + self._manager = manager + + # -- read-only body state --------------------------------------------- + @property + def manager(self) -> Any: + return self._manager + + @property + def position(self): + return self._manager.position if self._manager else None + + @property + def current_action(self): + return self._manager.current_action if self._manager else None + + @property + def is_last_action(self) -> bool: + return bool(self._manager and self._manager.is_last_action) + + # -- motor commands ---------------------------------------------------- + def advance(self, tick: int): + """Advance the manager state machine by one tick.""" + if self._manager is None: + return None + return self._manager.tick(tick) + + def enter_conversation(self, partner_name: str): + """Freeze the body into a conversation with `partner_name`.""" + if self._manager is None: + return None + return self._manager.set_conversation_action(partner_name) + + def resume(self, day_plan: List[dict]) -> None: + """Leave conversation mode and (re)load a day plan to execute.""" + if self._manager is not None: + self._manager.resume_from_conversation(day_plan) diff --git a/backend/src/agents/brain.py b/backend/src/agents/brain.py new file mode 100644 index 0000000000000000000000000000000000000000..0284531c0193dca745f68738430fa39e08b7d968 --- /dev/null +++ b/backend/src/agents/brain.py @@ -0,0 +1,186 @@ +""" +Brain -- the agent's cognition / command centre. + +Each tick the brain may be called to decide (via LLM) whether the agent +should continue their current plan or replan, based on novel observations. +The LLM call is gated: it only fires when the perceive phase detects a change +in the set of (agent_id, action_description) within 50px. + +When no novel observations exist, the brain returns "continue" without an LLM +call โ€” the agent follows its existing plan. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel + +from src.core.log import get_logger +from src.config import TEMPERATURE +from src.llm.gemini_client import call_gemini, ProviderFailureError + +logger = get_logger(__name__) + + +class TickDecision(BaseModel): + decision: str # "continue" or "replan" + reason: str + + +DECISION_SYSTEM_PROMPT = """You are the decision-making layer for a simulated college student at IIT Ropar. +You are given the persona's current situation and a list of nearby agents they just noticed. +Decide whether they should continue their current activity or replan their day. + +Rules: +- "continue" โ€” the agent stays on their current plan. This is the default for routine observations. +- "replan" โ€” the agent should regenerate its remaining-day plan. Use this only for genuinely + significant events: a close friend appears, an urgent opportunity arises, or the current + situation conflicts with their goals. + +Be conservative. Most observations (someone walking past, someone studying nearby) do NOT +warrant a replan. Only replan when this specific persona would clearly change their plans. + +Respond ONLY with the requested JSON schema โ€” no extra commentary.""" + + +class Brain: + """Per-agent decision-maker that commands a BodyController.""" + + def __init__(self, agent_id: str, persona_name: str, body: Any, + persona: Optional[dict] = None) -> None: + self.agent_id = agent_id + self.persona_name = persona_name + self.persona = persona or {} + self.body = body + + def decide_tick(self, tick: int, hhmm: str, observations: List, + day_plan: List, replan_count: int = 0, + max_replans: int = 3, + energy_level: float = 0.5, emotion_state: float = 0.5, + relevant_memories: Optional[List[str]] = None) -> TickDecision: + """LLM-based decision: continue or replan? + + Called only when the perceive phase detected novel observations. + If replan_count >= max_replans, returns "continue" without an LLM call. + """ + if replan_count >= max_replans: + logger.info( + "[Brain] '%s' replan count exhausted (%d/%d) โ€” continuing plan", + self.persona_name, replan_count, max_replans, + ) + return TickDecision(decision="continue", reason="replan budget exhausted") + + current_action_desc = "nothing" + if self.body.current_action: + current_action_desc = ( + f"{self.body.current_action.description} at {self.body.current_action.location_id or '?'}" + ) + + plan_summary = self._plan_summary(day_plan, hhmm) + obs_text = self._observations_text(observations) + memory_text = "\n".join(f"- {memory}" for memory in (relevant_memories or [])[:4]) or "(nothing relevant recalled)" + + user_prompt = ( + f"You are {self.persona_name} at IIT Ropar.\n" + f"Personality: {self.persona.get('innate', '?')}\n" + f"Hobbies: {self.persona.get('hobbies', '?')}\n" + f"Goals: {self.persona.get('goals', '?')}\n" + f"Current activity: {current_action_desc}\n" + f"Time: {hhmm}\n\n" + f"Remaining plan:\n{plan_summary}\n\n" + f"Nearby people:\n{obs_text}\n\n" + f"Relevant long-term memories:\n{memory_text}\n\n" + f"Energy level: {energy_level:.2f}/1.0\n" + f"Emotion state: {emotion_state:.2f}/1.0\n" + 'Return {"decision": "continue"} to stay on the current plan, or ' + '{"decision": "replan", "reason": "..."} to regenerate the remaining day plan. ' + "Only replan if this genuinely warrants a change of plans." + ) + + try: + result: TickDecision = call_gemini( + system_prompt=DECISION_SYSTEM_PROMPT, + user_prompt=user_prompt, + schema=TickDecision, + complexity="default", + temperature=TEMPERATURE, + ) + logger.info( + "[Brain] '%s' decide_tick: decision=%s (%s)", + self.persona_name, result.decision, result.reason, + ) + return result + except ProviderFailureError: + raise + except Exception as e: + logger.warning( + "[Brain] '%s' decide_tick LLM failed: %s โ€” continuing plan", + self.persona_name, e, + ) + return TickDecision(decision="continue", reason="LLM call failed, defaulting to continue") + + def act(self, tick: int): + """Phase 2 (Act): advance the body's state machine.""" + return self.body.advance(tick) + + # -- helpers ----------------------------------------------------------- + + @staticmethod + def _plan_summary(plan: List[Dict], current_hhmm: str) -> str: + if not plan: + return " (no plan remaining)" + lines = [] + for entry in sorted(plan, key=lambda e: e.get("start", "00:00")): + if entry.get("start", "00:00") >= current_hhmm: + marker = " โ‡ NOW" if entry.get("start") == current_hhmm else "" + lines.append( + f" {entry.get('start', '??')}-{entry.get('end', '??')} " + f"{entry.get('action', '?')} at {entry.get('location_id', '?')}{marker}" + ) + return "\n".join(lines) if lines else " (no plan remaining)" + + @staticmethod + def _observations_text(observations: List) -> str: + if not observations: + return " (no one nearby)" + lines = [] + for obs in observations: + if hasattr(obs, "model_dump"): + obs = obs.model_dump() + if not isinstance(obs, dict): + lines.append(f" - {obs}") + continue + if "description" in obs and "current_action" not in obs: + location = obs.get("location_id", "?") + lines.append(f" - {obs['description']} at {location}") + continue + name = obs.get("name", obs.get("agent_id", "?")) + action = obs.get("current_action", "?") + pos = obs.get("position", {}) + lines.append(f" - {name} at ({pos.get('x', '?')}, {pos.get('y', '?')}) โ€” {action}") + return "\n".join(lines) + + +if __name__ == "__main__": + from src.core.log import setup_logging + setup_logging(run_id="brain_test", console=True) + + class _FakeManager: + def __init__(self): + self.position = "P" + self.current_action = None + self.is_last_action = False + self.ticks = [] + def tick(self, t): + self.ticks.append(t) + return f"action@{t}" + + from src.agents.body import BodyController + fm = _FakeManager() + brain = Brain("a", "Agent A", BodyController(fm)) + out = brain.act(5) + assert out == "action@5", out + assert fm.ticks == [5] + print("brain.py sanity check passed (0 LLM, pure body.advance).") diff --git a/backend/src/agents/conversation.py b/backend/src/agents/conversation.py new file mode 100644 index 0000000000000000000000000000000000000000..ab3d2b13b9ca83502736634ce617fd07bd212789 --- /dev/null +++ b/backend/src/agents/conversation.py @@ -0,0 +1,805 @@ +""" +Conversation -- generates dialogue between two agents via a single LLM call. + +The WorldEngine calls `generate_conversation()` when two agents share a +location_id, are both in compatible actions (not sleeping), and neither is +already mid-conversation. + +The single LLM call produces the full conversation (messages, summary, +duration, sentiment, relationship delta). Both agents get their current +action overwritten to "Chatting with X" for the duration, then naturally +replan via the tick graph when it expires. + +Usage: + from src.agents.conversation import generate_conversation, RelationshipMatrix + + matrix = RelationshipMatrix() + result = generate_conversation( + agent_a_id="parv_singla", + agent_b_id="tanishq", + persona_a=gray_wilder_persona, + persona_b=jules_persona, + plan_a=gray_wilder_plan, + plan_b=jules_plan, + action_a=gray_wilder_current_action, + action_b=jules_current_action, + rel_a_to_b=matrix.get("parv_singla", "tanishq"), + rel_b_to_a=matrix.get("tanishq", "parv_singla"), + location_id="mess", + current_hhmm="08:05", + ) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +if str(BACKEND_ROOT) not in sys.path: + sys.path.append(str(BACKEND_ROOT)) + + +import json +import os +import threading +import time +from typing import Any, Dict, List, Literal, Optional, Tuple + +from pydantic import BaseModel, Field + +from src.core.log import get_logger +from src.core.world_state import CurrentAction +from src.llm.gemini_client import call_gemini, ProviderFailureError +from src.config import CONVERSATION_TEMPERATURE + +logger = get_logger(__name__) + + +# The engine normally owns a single relationship matrix, but a day handoff or +# a standalone conversation job can briefly create another instance pointing +# at the same file. Coordinate those instances in-process and make each save +# an atomic replacement so unrelated pairs cannot overwrite one another. +_MATRIX_LOCK_GUARD = threading.Lock() +_MATRIX_LOCKS: Dict[Path, threading.RLock] = {} + +# Persona IDs are derived from display names. These aliases preserve saved +# relationship scores when the two seed personas were renamed, while ensuring +# a checkpoint restore cannot recreate obsolete matrix keys on disk. +_LEGACY_AGENT_IDS = { + "aditi_menon": "riya_murarka", + "meher_bansal": "lavanya_sharma", +} + + +def _matrix_lock(path: Path) -> threading.RLock: + resolved = path.resolve() + with _MATRIX_LOCK_GUARD: + return _MATRIX_LOCKS.setdefault(resolved, threading.RLock()) + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +BLOCKED_KEYWORDS = ["sleep"] +COOLDOWN_TICKS = 30 + + +# --------------------------------------------------------------------------- +# Output schema +# --------------------------------------------------------------------------- + + +class Message(BaseModel): + """One line of dialogue in a conversation.""" + speaker: str = Field(min_length=1) + text: str = Field(min_length=1, max_length=240) + + +class ConversationResult(BaseModel): + """Full output of a single conversation generation call.""" + messages: List[Message] = Field(min_length=4, max_length=12) + summary: str = Field(min_length=1, max_length=360) + duration_minutes: int = Field(ge=6, le=20) + sentiment: Literal["positive", "neutral", "negative"] + relationship_delta: float = Field(ge=-0.15, le=0.15) + # Folded-in replan decision: avoids a separate 4-call day-plan regeneration + # per agent after every conversation. True only when the conversation + # genuinely changes an agent's immediate intentions. + should_replan: bool = False + plan_change: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Relationship matrix -- directional, expressive social context +# --------------------------------------------------------------------------- + + +class RelationshipRecord(BaseModel): + """One student's grounded point of view toward another student.""" + + score: float = Field(default=0.5, ge=-1.0, le=1.0) + tags: List[str] = Field(default_factory=list, max_length=12) + context: str = Field(default="", max_length=500) + + +class RelationshipMatrix: + """ + Asymmetric relationship scores between agent pairs. + + Keys are f"{agent_a_id}->{agent_b_id}". A->B may differ from B->A. + Scores are floats in [-1.0, 1.0] where: + -1.0 = hostile / strongly negative + 0.0 = strangers / no relationship + 0.5 = neutral acquaintance (default) + 1.0 = very close / best friends + """ + + def __init__(self, path: Optional[Path] = None): + from src.config import ENVIRONMENT_DIR + + self.path: Path = path or ENVIRONMENT_DIR / "relationship_matrix.json" + self._matrix: Dict[str, RelationshipRecord] = {} + self._pending_deltas: List[tuple[str, float]] = [] + self.load() + + def _pair_key(self, a: str, b: str) -> str: + return f"{a}->{b}" + + def load(self) -> None: + with _matrix_lock(self.path): + raw = {} + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) if self.path.exists() else {} + except (OSError, ValueError, json.JSONDecodeError): + raw = {} + self._schema_version = int(raw.get("schema_version", 1)) if isinstance(raw, dict) else 1 + self._matrix = self._normalise(raw) if raw else {} + self._pending_deltas.clear() + if self._matrix: + logger.info("[RelationshipMatrix] loaded %d pairs from %s", len(self._matrix), self.path.name) + else: + logger.warning("[RelationshipMatrix] %s not found -- starting empty", self.path) + + @staticmethod + def _normalise(raw: Dict[str, Any]) -> Dict[str, RelationshipRecord]: + """Accept the legacy flat score map and the structured v1 document.""" + pairs = raw.get("relationships", raw) + if not isinstance(pairs, dict): + raise ValueError("relationship entries must be a JSON object") + result: Dict[str, RelationshipRecord] = {} + for key, value in pairs.items(): + if "->" not in key: + continue + source, target = key.split("->", 1) + canonical_key = f"{_LEGACY_AGENT_IDS.get(source, source)}->{_LEGACY_AGENT_IDS.get(target, target)}" + # Prefer a current seed record over its legacy alias when both are + # present in a migrated file. Legacy-only checkpoint data still + # maps to the current key and therefore preserves its score. + if canonical_key != key and canonical_key in result: + continue + result[canonical_key] = RelationshipRecord.model_validate( + {"score": value} if isinstance(value, (int, float)) else value + ) + return result + + def _read_disk(self) -> Dict[str, RelationshipRecord]: + if not self.path.exists(): + return {} + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("relationship matrix must be a JSON object") + return self._normalise(raw) + except (OSError, ValueError, json.JSONDecodeError) as exc: + logger.warning( + "[RelationshipMatrix] unreadable %s; starting empty: %s", + self.path.name, exc, + ) + return {} + + def _write_atomic(self, matrix: Dict[str, RelationshipRecord]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_suffix(self.path.suffix + ".tmp") + document = { + "schema_version": 2, + "relationships": { + key: value.model_dump() for key, value in sorted(matrix.items()) + }, + } + temporary.write_text(json.dumps(document, indent=2), encoding="utf-8") + for attempt in range(3): + try: + os.replace(temporary, self.path) + return + except PermissionError: + if attempt == 2: + raise + time.sleep(0.05 * (attempt + 1)) + + def save(self) -> None: + # Reload while holding the file's lock, then replay only this + # instance's changes. Two independent conversations updating + # different pairs therefore compose instead of last-writer-wins. + with _matrix_lock(self.path): + merged = self._read_disk() + for key, delta in self._pending_deltas: + record = merged.get(key, RelationshipRecord()) + record.score = round(max(-1.0, min(1.0, record.score + delta)), 4) + merged[key] = record + self._write_atomic(merged) + self._matrix = merged + self._pending_deltas.clear() + logger.info("[RelationshipMatrix] saved %d pairs to %s", len(self._matrix), self.path.name) + + def get(self, a: str, b: str) -> float: + """Relationship of agent `a` toward agent `b`. Defaults to 0.5 (neutral).""" + return self._matrix.get(self._pair_key(a, b), RelationshipRecord()).score + + def remove_agent(self, agent_id: str) -> None: + """Drop every directional relationship involving one retired agent.""" + self._matrix = { + key: record for key, record in self._matrix.items() + if agent_id not in key.split("->", 1) + } + self._pending_deltas.clear() + with _matrix_lock(self.path): + self._write_atomic(self._matrix) + + def seed(self, source: str, target: str, record: RelationshipRecord) -> None: + """Set a relationship baseline during a stopped roster operation.""" + self._matrix[self._pair_key(source, target)] = record + with _matrix_lock(self.path): + self._write_atomic(self._matrix) + + def replace_display_name(self, old_name: str, new_name: str) -> None: + """Update display-name references without changing stable agent IDs.""" + for record in self._matrix.values(): + record.context = record.context.replace(old_name, new_name) + with _matrix_lock(self.path): + self._write_atomic(self._matrix) + + def ensure_grounded_seed(self, agents: List[Dict[str, Any]]) -> None: + """Upgrade the original uniform v1 matrix to a directional social baseline. + + Kept here rather than in prompts so the relationships remain data the + conversation engine can inspect and checkpoint deterministically. + """ + names = {str(item["agent_id"]): str(item.get("persona_name") or item["agent_id"]) for item in agents} + expected_keys = { + self._pair_key(source, target) + for source in names + for target in names + if source != target + } + # A roster can be replaced outside a running world. Version 2 means + # the record format is current, not that its agent IDs are necessarily + # current. Preserve valid directional context, prune retired IDs and + # seed only missing pairs. + if getattr(self, "_schema_version", 1) >= 2 and set(self._matrix) == expected_keys: + return + profiles = { + "ankit": ("table tennis and hardware tinkering", "patient practical advice"), + "ansh_batra": ("football and visual storytelling", "enthusiastic plans"), + "anubhav_prasad": ("co-op games and late-night chai", "calm listening"), + "ghanisht_kaushal": ("badminton and blunt movie opinions", "reliable follow-through"), + "gurnoor_singh": ("photography and road-trip playlists", "big social energy"), + "lavanya_sharma": ("basketball and debate", "direct feedback"), + "parv_singla": ("running and music", "impulsive invitations"), + "riya_murarka": ("reading circles and long runs", "clear boundaries"), + "saksham": ("badminton and strategy games", "dry humour"), + "tanishq": ("strategy games and playlists", "quiet, improving confidence"), + } + special = { + ("gurnoor_singh", "parv_singla"): (0.86, ["close-friends", "project-chaos"], "They are close friends, but Gurnoor sometimes has to make Parv slow down before a fun idea becomes three commitments."), + ("parv_singla", "gurnoor_singh"): (0.84, ["close-friends", "adventure"], "Parv trusts Gurnoor to show up; he also enjoys trying to pull him into a spontaneous outing after a productive day."), + ("lavanya_sharma", "ghanisht_kaushal"): (0.28, ["friendly-rivalry", "different-work-styles"], "Lavanya respects Ghanisht but thinks he can treat a prototype like a spreadsheet. Their friction is usually productive after a cooling-off break."), + ("ghanisht_kaushal", "lavanya_sharma"): (0.36, ["friendly-rivalry", "respect"], "Ghanisht finds Lavanya's directness intimidating on tired days, yet trusts her to identify the practical flaw everyone else missed."), + ("riya_murarka", "tanishq"): (0.18, ["careful-acquaintance", "boundaries"], "Riya is friendly but keeps the connection measured; Tanishq has been making an effort to be considerate and let trust develop at her pace."), + ("tanishq", "riya_murarka"): (0.30, ["admiration", "respectful-distance"], "Tanishq values Riya's book recommendations and is practising being a considerate, unhurried conversational partner without reading extra meaning into it."), + ("ansh_batra", "saksham"): (0.47, ["project-partners", "missed-deadline"], "Ansh still feels mildly guilty about an overambitious project timeline; Saksham is warming back up after Ansh started communicating earlier."), + ("saksham", "ansh_batra"): (0.34, ["project-partners", "rebuilding-trust"], "Saksham likes Ansh's imagination but remembers the missed handoff. He appreciates concrete plans more than enthusiastic promises."), + } + rebuilt: Dict[str, RelationshipRecord] = {} + ids = sorted(names) + for source in ids: + for target in ids: + if source == target: + continue + existing = self._matrix.get(self._pair_key(source, target)) + override = special.get((source, target)) + if existing is not None: + rebuilt[self._pair_key(source, target)] = existing + continue + if override: + score, tags, context = override + else: + source_interest = profiles.get(source, ("campus routines", "steady conversation"))[0] + target_style = profiles.get(target, ("campus routines", "steady conversation"))[1] + score = 0.30 + ((sum(map(ord, source + target)) % 33) / 100) + tags = ["campus-acquaintance", "low-pressure"] + context = f"{names[source]} and {names[target]} know each other through {source_interest}. {names[source]} appreciates {target}'s {target_style}, but the friendship is still finding its rhythm." + rebuilt[self._pair_key(source, target)] = RelationshipRecord(score=score, tags=tags, context=context) + with _matrix_lock(self.path): + self._matrix = rebuilt + self._pending_deltas.clear() + self._schema_version = 2 + self._write_atomic(rebuilt) + + def context(self, a: str, b: str) -> RelationshipRecord: + """Return a copy of the expressive directed relationship record.""" + return self._matrix.get(self._pair_key(a, b), RelationshipRecord()).model_copy(deep=True) + + def update(self, a: str, b: str, delta: float) -> None: + """Adjust relationship of `a` toward `b` by `delta`, clamped to [-1, 1].""" + key = self._pair_key(a, b) + current = self.get(a, b) + new_val = max(-1.0, min(1.0, current + delta)) + record = self._matrix.get(key, RelationshipRecord()) + record.score = round(new_val, 4) + self._matrix[key] = record + self._pending_deltas.append((key, delta)) + logger.info("[RelationshipMatrix] %s: %.2f -> %.2f (delta=%.2f)", key, current, new_val, delta) + + def snapshot(self) -> Dict[str, Any]: + """Return a JSON-safe copy for the simulation checkpoint.""" + return {"schema_version": 2, "relationships": { + key: value.model_dump() for key, value in self._matrix.items() + }} + + def restore(self, matrix: Dict[str, Any]) -> None: + """Restore scores from a checkpoint while retaining base social context. + + Older checkpoints only contain a numeric matrix. Their scores are + authoritative runtime state, but they must not erase the structured + tags/context seeded in the current relationship data file. + """ + with _matrix_lock(self.path): + base = self._read_disk() + checkpoint = self._normalise(matrix) + for key, record in checkpoint.items(): + if key in base: + base[key].score = record.score + else: + base[key] = record + self._matrix = base + self._pending_deltas.clear() + self._write_atomic(self._matrix) + + +# --------------------------------------------------------------------------- +# Helper: check if an action is sleep-blocked +# --------------------------------------------------------------------------- + + +def _is_action_blocked(action: CurrentAction) -> bool: + """Return True if the agent's current action prevents conversation.""" + desc = action.description.lower() + return any(kw in desc for kw in BLOCKED_KEYWORDS) + + +# --------------------------------------------------------------------------- +# Prompt builder +# --------------------------------------------------------------------------- + + +SYSTEM_PROMPT = """You are a dialogue writer for a campus life simulation at IIT Ropar. +Two student personas with distinct personalities and daily schedules have encountered each other. + +Generate a short, natural conversation between them based on: +- Their personalities, hobbies, and goals +- What each is currently doing at this moment +- Their day plans (what they've done today and what's coming up next) +- Their existing relationship with each other +- The current time of day and location on campus +- What each might have recently experienced or remembers about the other + +Rules: +- Start naturally from the current situation and surroundings +- Write 4-12 alternating dialogue lines, each 4-25 words +- Choose a duration of 6-20 simulated minutes that fits the dialogue +- Match tone to personality (friendly, awkward, casual, competitive, etc.) +- Each message must have a "speaker" (the agent's full name) and "text" (what they say) +- The conversation must have a natural ending +- Reference the time of day and campus context naturally in the dialogue +- Return ONLY valid JSON matching the schema -- no extra text or commentary""" + + +def _personality_summary(persona: Dict[str, Any]) -> str: + """Condense a persona dict into a 2-3 line readable summary.""" + parts = [ + f"Name: {persona.get('Name', '?')}", + f"Branch: {persona.get('Branch', '?')}", + f"Personality: {persona.get('innate', '?')}", + f"Hobbies: {persona.get('hobbies', '?')}", + f"Goals: {persona.get('goals', '?')}", + ] + return "\n".join(parts) + + +def _deprecated_plan_summary(plan: List[Dict[str, Any]], current_hhmm: str) -> str: + """Deprecated compatibility helper; retained only for external callers.""" + if not plan: + return " (no plan)" + + lines = [] + for entry in sorted(plan, key=lambda e: e.get("start", "00:00")): + marker = " โ‡ NOW" if entry.get("start") == current_hhmm else "" + lines.append( + f" {entry.get('start', '??')}-{entry.get('end', '??')} " + f"{entry.get('action', '?')} at {entry.get('location_id', '?')}{marker}" + ) + return "\n".join(lines) + + +def _plan_summary(plan: List[Dict[str, Any]], current_hhmm: str) -> str: + """Render a plan and identify the entry that covers the current time.""" + if not plan: + return " (no plan)" + + def minutes(value: str) -> int: + hour, minute = value.split(":") + return int(hour) * 60 + int(minute) + + now = minutes(current_hhmm) + lines = [] + for entry in sorted(plan, key=lambda item: item.get("start", "00:00")): + try: + active = minutes(entry.get("start", "00:00")) <= now < minutes(entry.get("end", "24:00")) + except (TypeError, ValueError): + active = False + marker = " [NOW]" if active else "" + lines.append( + f" {entry.get('start', '??')}-{entry.get('end', '??')} " + f"{entry.get('action', '?')} at {entry.get('location_id', '?')}{marker}" + ) + return "\n".join(lines) + + +def _build_prompts( + agent_a_id: str, + agent_b_id: str, + persona_a: Dict[str, Any], + persona_b: Dict[str, Any], + plan_a: List[Dict[str, Any]], + plan_b: List[Dict[str, Any]], + action_a: CurrentAction, + action_b: CurrentAction, + rel_a_to_b: float, + rel_b_to_a: float, + location_id: str, + current_hhmm: str, + memories_a: Optional[List[str]] = None, + memories_b: Optional[List[str]] = None, + energy_a: float = 0.5, emotion_a: float = 0.5, + energy_b: float = 0.5, emotion_b: float = 0.5, + relationship_context_a: str = "", + relationship_context_b: str = "", +) -> Tuple[str, str]: + """Build system and user prompts for the conversation LLM call.""" + + def _mem_block(mems: Optional[List[str]]) -> str: + if not mems: + return "(nothing notable recalled)" + return "\n".join(f" - {m}" for m in mems[:4]) + def _rel_label(score: float) -> str: + if score >= 0.8: + return "close friends" + elif score >= 0.6: + return "friendly" + elif score >= 0.4: + return "neutral acquaintances" + elif score >= 0.2: + return "distant / barely know each other" + else: + return "negative / strained" + + hour = int(current_hhmm.split(":")[0]) if ":" in current_hhmm else 12 + if hour < 6: + time_desc = "early morning โ€” campus is quiet, most students are asleep" + elif hour < 10: + time_desc = "morning โ€” students heading to breakfast or first classes" + elif hour < 12: + time_desc = "late morning โ€” classes are in session around campus" + elif hour < 14: + time_desc = "lunchtime โ€” the mess and cafeterias are busy" + elif hour < 17: + time_desc = "afternoon โ€” afternoon classes and study sessions" + elif hour < 19: + time_desc = "late afternoon โ€” some students heading to sports or hobbies" + elif hour < 21: + time_desc = "evening โ€” dinner time, socializing on campus" + elif hour < 23: + time_desc = "late evening โ€” students heading back to hostels" + else: + time_desc = "night โ€” campus is winding down" + + def _emotion_label(v: float) -> str: + if v >= 0.9: return "Extremely Joyful" + if v >= 0.7: return "Very Happy" + if v > 0.5: return "Happy" + if v >= 0.3: return "Neutral" + if v >= 0.1: return "Sad" + return "Extremely Sad" + + user = f"""Generate a conversation between two students at IIT Ropar. + +โ”€โ”€ AGENT: {agent_a_id} โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +{_personality_summary(persona_a)} +Current action: "{action_a.description}" +Today's plan: +{_plan_summary(plan_a, current_hhmm)} +Energy: {energy_a:.2f}/1.0 | Emotion: {emotion_a:.2f}/1.0 ({_emotion_label(emotion_a)}) + +โ”€โ”€ AGENT: {agent_b_id} โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +{_personality_summary(persona_b)} +Current action: "{action_b.description}" +Today's plan: +{_plan_summary(plan_b, current_hhmm)} +Energy: {energy_b:.2f}/1.0 | Emotion: {emotion_b:.2f}/1.0 ({_emotion_label(emotion_b)}) + +โ”€โ”€ CONTEXT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Location: {location_id} +Current time: {current_hhmm} ({time_desc}) +Relationship notes: A sees B as {relationship_context_a or 'an acquaintance'}; B sees A as {relationship_context_b or 'an acquaintance'}. +{persona_a.get('Name', agent_a_id)}'s relationship toward {persona_b.get('Name', agent_b_id)}: {rel_a_to_b} ({_rel_label(rel_a_to_b)}) +{persona_b.get('Name', agent_b_id)}'s relationship toward {persona_a.get('Name', agent_a_id)}: {rel_b_to_a} ({_rel_label(rel_b_to_a)}) + +What {persona_a.get('Name', agent_a_id)} remembers about {persona_b.get('Name', agent_b_id)}: +{_mem_block(memories_a)} +What {persona_b.get('Name', agent_b_id)} remembers about {persona_a.get('Name', agent_a_id)}: +{_mem_block(memories_b)} + +โ”€โ”€ OUTPUT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Return a JSON object with: +- "messages": 4-12 alternating entries using exactly "{persona_a.get('Name', agent_a_id)}" and "{persona_b.get('Name', agent_b_id)}" as speakers; each line is 4-25 words +- "summary": one-sentence summary of what they talked about +- "duration_minutes": integer from 6 to 20 that matches the amount of dialogue +- "sentiment": "positive" | "neutral" | "negative" +- "relationship_delta": float between -0.15 and 0.15 (how this conversation changes their relationship) +- "should_replan": boolean โ€” true ONLY if this conversation genuinely changes what one of them intends to do next (e.g. they agree to meet, go somewhere together, or drop a task). Default false; most casual chats do NOT require replanning. +- "plan_change": short string describing the change if should_replan is true, else null""" + + return SYSTEM_PROMPT, user + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + + +def generate_conversation( + agent_a_id: str, + agent_b_id: str, + persona_a: Dict[str, Any], + persona_b: Dict[str, Any], + plan_a: List[Dict[str, Any]], + plan_b: List[Dict[str, Any]], + action_a: CurrentAction, + action_b: CurrentAction, + rel_a_to_b: float, + rel_b_to_a: float, + location_id: str, + current_hhmm: str, + memories_a: Optional[List[str]] = None, + memories_b: Optional[List[str]] = None, + energy_a: float = 0.5, emotion_a: float = 0.5, + energy_b: float = 0.5, emotion_b: float = 0.5, + relationship_context_a: str = "", + relationship_context_b: str = "", +) -> Optional[ConversationResult]: + """ + Generate a conversation between two agents via a single Gemini call. + + Returns None if the LLM call fails (logged, not raised) so the + WorldEngine can continue the tick without crashing. + """ + system_prompt, user_prompt = _build_prompts( + agent_a_id, agent_b_id, + persona_a, persona_b, + plan_a, plan_b, + action_a, action_b, + rel_a_to_b, rel_b_to_a, + location_id, current_hhmm, + memories_a, memories_b, + energy_a=energy_a, emotion_a=emotion_a, + energy_b=energy_b, emotion_b=emotion_b, + relationship_context_a=relationship_context_a, + relationship_context_b=relationship_context_b, + ) + + logger.info( + "[Conversation] generating conversation between '%s' and '%s' at %s (%s)", + agent_a_id, agent_b_id, location_id, current_hhmm, + ) + + try: + result: ConversationResult = call_gemini( + system_prompt=system_prompt, + user_prompt=user_prompt, + schema=ConversationResult, + complexity="default", + temperature=CONVERSATION_TEMPERATURE, + ) + logger.info( + "[Conversation] generated %d messages, %d min, sentiment=%s, delta=%.2f", + len(result.messages), result.duration_minutes, + result.sentiment, result.relationship_delta, + ) + return result + + except ProviderFailureError: + raise + except Exception: + logger.exception( + "[Conversation] LLM call failed for '%s' <-> '%s' -- skipping conversation", + agent_a_id, agent_b_id, + ) + return None + + +# --------------------------------------------------------------------------- +# Self-test +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import argparse + + from src.core.log import setup_logging + setup_logging(run_id="conversation_test", console=True) + + from src.config import PERSONALITIES_DIR + from src.agents.Short_term import load_day_plan, date_from_simulation_time, append_conversation + + parser = argparse.ArgumentParser( + description="Conversation module self-test -- generate a conversation between two personas" + ) + parser.add_argument("persona_a", nargs="?", default="parv_singla", help="First persona name") + parser.add_argument("persona_b", nargs="?", default="tanishq", help="Second persona name") + parser.add_argument( + "--current-time", default="2026-07-03 08:00", + help="Simulation time (default: 2026-07-03 08:00)", + ) + args = parser.parse_args() + + def _resolve_persona(name: str) -> tuple[Path, Dict[str, Any]]: + """Find a persona JSON by name, return (path, data).""" + matches = sorted(PERSONALITIES_DIR.glob(f"**/{name}/{name}.json")) + if not matches: + matches = sorted(PERSONALITIES_DIR.glob(f"**/{name}.json")) + if not matches: + available = sorted({p.parent.name for p in PERSONALITIES_DIR.glob("**/*.json")}) + print(f"Persona '{name}' not found. Available: {', '.join(available)}") + sys.exit(1) + return matches[0], json.loads(matches[0].read_text()) + + def _find_action_for_time( + plan: List[Dict[str, Any]], hhmm: str + ) -> Optional[Dict[str, Any]]: + """Find the day plan entry covering HH:MM.""" + def _to_mins(t: str) -> int: + h, m = t.split(":") + return int(h) * 60 + int(m) + + target = _to_mins(hhmm) + for entry in sorted(plan, key=lambda e: e.get("start", "00:00")): + start = _to_mins(entry.get("start", "00:00")) + end = _to_mins(entry.get("end", "24:00")) + if start <= target < end: + return entry + return None + + # Resolve personas + path_a, persona_a = _resolve_persona(args.persona_a) + path_b, persona_b = _resolve_persona(args.persona_b) + + name_a = persona_a.get("Name", path_a.stem) + name_b = persona_b.get("Name", path_b.stem) + + from src.agents.Short_term import _persona_dir + id_a = _persona_dir(name_a).name + id_b = _persona_dir(name_b).name + + # Load day plans + sim_date = date_from_simulation_time(args.current_time) + _, current_hhmm = args.current_time.split(" ") if " " in args.current_time else ("", args.current_time) + + plan_a = load_day_plan(name_a, sim_date) or [] + plan_b = load_day_plan(name_b, sim_date) or [] + + if not plan_a or not plan_b: + print(f"Need day plans for both personas. Run: python backend/src/core/Agent.py {args.persona_a}") + print(f" and: python backend/src/core/Agent.py {args.persona_b}") + sys.exit(1) + + print(f"Personas: {name_a} <-> {name_b}") + print(f" Agent IDs: {id_a}, {id_b}") + print(f" Plans: {len(plan_a)} actions, {len(plan_b)} actions") + print(f" Time: {args.current_time} (HH:MM={current_hhmm})") + + # Find current action for each + entry_a = _find_action_for_time(plan_a, current_hhmm) + entry_b = _find_action_for_time(plan_b, current_hhmm) + + if not entry_a or not entry_b: + print(f"No plan entry covers {current_hhmm} for one or both personas.") + sys.exit(1) + + action_a = CurrentAction( + description=entry_a["action"], + start_tick=0, + end_tick=1000, + target_location_id=entry_a.get("location_id"), + ) + action_b = CurrentAction( + description=entry_b["action"], + start_tick=0, + end_tick=1000, + target_location_id=entry_b.get("location_id"), + ) + + # Load relationship matrix + matrix = RelationshipMatrix() + + print(f"\n{name_a} is: {action_a.description} at {entry_a.get('location_id', '?')}") + print(f"{name_b} is: {action_b.description} at {entry_b.get('location_id', '?')}") + print(f"Relationship A->B: {matrix.get(id_a, id_b)}") + print(f"Relationship B->A: {matrix.get(id_b, id_a)}") + print() + + # Location guess: use the first entry's location that has one + location_a = entry_a.get("location_id", "") + location_b = entry_b.get("location_id", "") + location_id = location_a if location_a == location_b else (location_a or location_b or "campus") + + result = generate_conversation( + agent_a_id=id_a, + agent_b_id=id_b, + persona_a=persona_a, + persona_b=persona_b, + plan_a=plan_a, + plan_b=plan_b, + action_a=action_a, + action_b=action_b, + rel_a_to_b=matrix.get(id_a, id_b), + rel_b_to_a=matrix.get(id_b, id_a), + location_id=location_id, + current_hhmm=current_hhmm, + ) + + if result is None: + print("Conversation generation failed.") + sys.exit(1) + + print(f"\n{'='*60}") + print(f"SUMMARY: {result.summary}") + print(f"Duration: {result.duration_minutes} min") + print(f"Sentiment: {result.sentiment}") + print(f"Relationship delta: {result.relationship_delta:+.2f}") + print(f"{'='*60}") + print() + + for msg in result.messages: + print(f" [{msg.speaker}] {msg.text}") + print() + + # Save to both personas' Short_term memory + date_str = sim_date + conv_entry = { + "participants": [name_a, name_b], + "messages": [{"speaker": m.speaker, "text": m.text} for m in result.messages], + "summary": result.summary, + } + + append_conversation(name_a, date_str, conv_entry) + append_conversation(name_b, date_str, conv_entry) + print(f"Saved conversation to {id_a} and {id_b} for {date_str}") + + # Update relationship matrix + matrix.update(id_a, id_b, result.relationship_delta) + matrix.update(id_b, id_a, result.relationship_delta) + matrix.save() + print(f"Updated relationship matrix: {id_a}->{id_b} = {matrix.get(id_a, id_b):.2f}") + + print(f"\nConversation module self-test passed.") diff --git a/backend/src/agents/daily_flavor.py b/backend/src/agents/daily_flavor.py new file mode 100644 index 0000000000000000000000000000000000000000..a6f7815b6c1454ad3ea6303fc39c32de8cef0eff --- /dev/null +++ b/backend/src/agents/daily_flavor.py @@ -0,0 +1,45 @@ +""" +Daily flavor โ€” random theme and emotion pickers for day_planner. + +Each day an agent gets a random theme (what they focus on) and emotion +(their mood), injected into the planner prompts so the schedule doesn't +feel identical every day. +""" + +from __future__ import annotations + +import random + +THEMES = [ + "Sports", + "Academics", + "Working", + "Socialising", + "Exploring", + "Resting", + "Entertainment", + "Fitness", + "Shopping", + "Creative", +] + +EMOTIONS = [ + "Happy", + "Sad", + "Melancholic", + "Angry", + "Peaceful", + "Affectionate", + "Excited", + "Motivated", + "Lonely", + "Relaxed", +] + + +def pick_theme() -> str: + return random.choice(THEMES) + + +def pick_emotion() -> str: + return random.choice(EMOTIONS) diff --git a/backend/src/agents/day_planner.py b/backend/src/agents/day_planner.py new file mode 100644 index 0000000000000000000000000000000000000000..a1e32a4f5ae581b4f1bb611808e3dfc87ab8071a --- /dev/null +++ b/backend/src/agents/day_planner.py @@ -0,0 +1,1227 @@ +""" +A script that plans the day of an agentic personality when handed over required data +Per plan takes 4 LLM calls (atlest) Coarse, Hourly, Fine, Validation, for Planning a +day in one agent's life. + +Tier-1 LangGraph subgraph: agent day-planning. + +Pipeline (mirrors Generative Agents' Planning module, coarse -> hourly -> fine, +with a validation/retry loop): + + generate_coarse_plan -> decompose_hourly -> decompose_fine -> validate_plan + | + conflict? --yes-+ (loop back to generate_coarse_plan) + | + no -> END + +LLM backend: Google Gemini via the `google-genai` SDK. + +For now `relevant_memories` and `yesterday_summary` are expected to arrive +empty ([] / None) -- the prompts already handle that gracefully so you can +wire in real retrieval/memory later without touching this file's structure. + +FILE NOTES: +Prompt structure can be improved +Places are being feed in Name : , Desc : format, this can be improved +disabled location check in validate plan : can add more places + +Prompt templates have to improve + +Have to figure out how to run this in the backend server, currently it is running standalone +""" + +from __future__ import annotations + +# Add root to sys.path +import sys +from pathlib import Path + +# backend/ is two levels up from this file (agents -> src -> backend) +BACKEND_ROOT = Path(__file__).resolve().parents[2] +if str(BACKEND_ROOT) not in sys.path: + sys.path.append(str(BACKEND_ROOT)) + + +import json +import re +import threading +from collections import defaultdict, deque +from src.core.log import get_logger +from src.llm.gemini_client import call_gemini +from typing import Any, Dict, List, Optional, TypedDict, Literal + +from langgraph.graph import END, StateGraph +from pydantic import BaseModel, Field, create_model, model_validator + +# Setting up logger +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Config -> in config.py +# --------------------------------------------------------------------------- +from src.config import ENVIRONMENT_DIR, DATA_DIR +from src.config import MAX_PLAN_RETRIES, PERSONA_FIELD_GLOSSARY + +# The simulator represents an adult college community, not sexual or hateful +# roleplay. This deterministic guard runs before semantic LLM QA. +_UNSAFE_PLAN_TERMS = ( + "non-consensual", "porn", "explicit content", "gooning", "jerking off", + "stalking profiles", "racial prejudice", "racist", "manipulating conversations", + "soft porn", "sexual content", +) + +# Academic locations are a behavioural contract, not a preference hidden in a +# prompt. Models may still choose LHC for genuinely common/shared teaching or +# SAB for explicitly shared work, but branch-specific teaching belongs here. +_BRANCH_VENUES = ( + ("mechanical", "mechanical_department"), + ("chemical", "chemical_department"), + ("electrical", "electrical_department"), + ("integrated circuit", "electrical_department"), + ("computer science", "computer_science_department"), + ("aide", "computer_science_department"), + ("artificial intelligence", "computer_science_department"), + ("data engineering", "computer_science_department"), +) +_BRANCH_ACTIVITY_PATTERN = re.compile( + r"\b(?:class(?:es)?|lecture(?:s)?|lab(?:s)?|laborator(?:y|ies)|tutorial(?:s)?|practical(?:s)?)\b", + re.IGNORECASE, +) +_SHARED_SESSION_WORDS = ("common", "core", "elective", "guest", "large", "shared", "interdisciplinary", "seminar") +_ACADEMIC_PREPARATION_WORDS = ( + "prep", "prepare", "preparing", "getting ready", "dress", "dressing", + "pack", "packing", "review notes", "reviewing lecture notes", + "organizing study material", "plan for", +) +_NON_ATTENDANCE_ACADEMIC_WORDS = ( + "group chat", "groupchat", "class chat", "classmate", "coursework", + "assignment", "homework", "notes", "study material", +) + + +def _academic_venue_policy(persona: Dict[str, Any]) -> str: + branch = str(persona.get("Branch", "")) + destination = next((venue for marker, venue in _BRANCH_VENUES if marker in branch.lower()), None) + if not destination: + return "No branch-specific policy is known; choose the listed location that explicitly fits." + return ( + f"This student is in {branch}. Branch-specific classes, tutorials, and labs may use " + f"`{destination}` or Library/ SAB. LHC is only for common/core/elective/guest/large shared sessions and classes. " + + ) + + +def _local_academic_venue_check(actions: List[Dict[str, Any]], persona: Dict[str, Any]) -> Optional[str]: + branch = str(persona.get("Branch", "")).lower() + required = next((venue for marker, venue in _BRANCH_VENUES if marker in branch), None) + if not required: + return None + for action in actions: + description = str(action.get("action", "")).lower() + location = str(action.get("location_id", "")) + # Use whole academic terms. A substring check classified ordinary + # activities such as "chai break with classmates" as a class because + # "class" is part of "classmates", causing needless replan retries. + if not _BRANCH_ACTIVITY_PATTERN.search(description): + continue + # Mentioning a lecture while getting ready for it is not attendance. + # These private preparation activities legitimately occur in hostels, + # libraries, and other non-department locations. + if any(word in description for word in _ACADEMIC_PREPARATION_WORDS): + continue + # Coursework, notes, and class chats refer to an academic subject but + # do not mean the agent is physically attending a branch session. + # They may legitimately happen in a hostel, library, or common space. + if any(word in description for word in _NON_ATTENDANCE_ACADEMIC_WORDS): + continue + if any(word in description for word in _SHARED_SESSION_WORDS): + continue + if location != required: + return ( + f"branch-specific academic action '{action.get('action')}' for {persona.get('Branch')} " + f"must use {required}, not {location}" + ) + return None + + +def load_places(places_file: Optional[Path] = None) -> List[Place]: + """ + Loads the college places JSON and returns a list of validated Place + objects. Expects a top-level {"locations": [...]} structure where each + entry matches the Place schema. + + Gets the places from ENVIRONMENT_DIR/places.json by default, or from a + custom path if provided. + """ + path = places_file or ENVIRONMENT_DIR / "places.json" + + if not Path(path).exists(): + logger.warning( + "[day_planner] places file not found at %s -- proceeding with no known locations", + path, + ) + return [] + + raw = json.loads(Path(path).read_text(encoding="utf-8")) + entries = raw.get("locations", []) + + places: List[Place] = [] + for i, entry in enumerate(entries): + places.append(Place.model_validate(entry)) + + logger.info("[day_planner] loaded %d places from %s", len(places), path) + return places + +def _places_block(places: List[Place]) -> str: + if not places: + return "(no places data available -- pick a reasonable generic label)" + blocks = [] + for p in places: + lines = [f"[{p.id}] {p.name} ({p.type})"] + if p.sub_areas: + lines.append(f" sub-areas: {', '.join(p.sub_areas)}") + if p.open_hours: + hours = "; ".join(f"{k}: {v}" for k, v in p.open_hours.items()) + lines.append(f" hours: {hours}") + if p.typical_activities: + lines.append(f" good for: {', '.join(p.typical_activities)}") + if p.notes: + lines.append(f" notes: {p.notes}") + blocks.append("\n".join(lines)) + return "\n".join(blocks) + + + +# --------------------------------------------------------------------------- +# Structured-output schemas (pydantic -> Gemini response_schema) +# --------------------------------------------------------------------------- + +class CoarseBlock(BaseModel): + activity: str = Field(description="Short label, e.g. 'work on farm', 'breakfast'") + start: str = Field(description="24h HH:MM") + end: str = Field(description="24h HH:MM") + granularity: Literal["atomic", "flexible"] = Field( + description=( + "'atomic' = this block should NEVER be split into finer sub-steps, it " + "stays as a single continuous action (e.g. sleep, attending a lecture, " + "commuting, an exam, watching a movie). " + "'flexible' = this block genuinely contains distinct sub-activities worth " + "breaking down (e.g. 'morning routine', 'gym session', 'work on project')." + ) + ) + energy_change: float = 0.0 + emotion_change: float = 0.0 + + +class CoarsePlanOutput(BaseModel): + # This field is deliberately part of the provider response schema rather + # than only a sentence in the prompt. ``generate_coarse_plan`` narrows it + # to a Literal[required_start] for each individual API call. + first_start_time: str = Field( + description=( + "Exact required HH:MM start time of the first block. Copy the " + "required first start time from the request exactly." + ) + ) + blocks: List[CoarseBlock] + + @model_validator(mode="after") + def first_block_must_match_declared_start(self) -> "CoarsePlanOutput": + if not self.blocks: + raise ValueError("coarse plan must contain at least one block") + if self.blocks[0].start != self.first_start_time: + raise ValueError( + "first_start_time must exactly match the first coarse block start" + ) + return self + + +class HourlyBlock(BaseModel): + activity: str + start: str + end: str + parent_activity: str = Field(description="The coarse block this refines") + energy_change: float = 0.0 + emotion_change: float = 0.0 + + +class HourlyPlanOutput(BaseModel): + blocks: List[HourlyBlock] + + +class FineAction(BaseModel): + action: str = Field(description="Concrete, executable action, 5-30 min granularity") + start: str + end: str + parent_activity: str = Field(description="The hourly block this refines") + location_id: str = Field(description="Must exactly match an 'id' from the known places list") + sub_area: Optional[str] = Field(default=None, description="One of that place's sub_areas, if applicable") + energy_change: float = Field(description="Energy change [-1.0, 1.0] over this action; positive=restorative, negative=tiring") + emotion_change: float = Field(description="Emotion change [-1.0, 1.0] over this action; positive=uplifting, negative=draining") + + +class FinePlanOutput(BaseModel): + actions: List[FineAction] + +# For Coars Atomic actions +class AtomicLocationAssignment(BaseModel): + activity: str + location_id: str + sub_area: Optional[str] = None + energy_change: float = 0.0 + emotion_change: float = 0.0 + +class AtomicLocationOutput(BaseModel): + assignments: List[AtomicLocationAssignment] + + +class ValidationResult(BaseModel): + valid: bool + reason: Optional[str] = Field( + default=None, description="If invalid: which items conflict/overlap/gap and why" + ) + + +class Place(BaseModel): + id: str + name: str + type: str + sub_areas: List[str] = [] + open_hours: Dict[str, str] = {} + capacity: Optional[str] = None + typical_activities: List[str] = [] + connected_locations: List[str] = [] + notes: Optional[str] = None + + +def _normalize_places(places: List[Any]) -> List[Place]: + """Accept validated Place models or raw JSON-compatible place records.""" + normalized: List[Place] = [] + for place in places: + try: + normalized.append(place if isinstance(place, Place) else Place.model_validate(place)) + except Exception as exc: + logger.warning("[day_planner] ignoring invalid place entry: %s", exc) + return normalized + + +# --------------------------------------------------------------------------- +# Graph state +# --------------------------------------------------------------------------- + +class DayPlannerState(TypedDict, total=False): + # ---- inputs ---- + persona: dict + relevant_memories: List[str] + yesterday_summary: Optional[str] + current_time: str # e.g. "2026-07-03 06:00" + places: List[Place] # known campus locations, from places.json + mode: str # "full_day", "remaining", or "next_day" + current_location_id: Optional[str] = None + handoff_context: Optional[str] = None + daily_theme: str # random theme (e.g. "Sports", "Academics") + daily_emotion: str # random mood (e.g. "Happy", "Melancholic") + upcoming_events: List[Dict[str, Any]] + + # ---- working state ---- + coarse_plan: List[Dict[str, Any]] + hourly_plan: List[Dict[str, Any]] + fine_plan: List[Dict[str, Any]] + + conflict_detected: bool + conflict_reason: Optional[str] + retry_count: int + + # ---- output ---- + day_plan: List[Dict[str, Any]] + error: Optional[str] + replan_rejected: bool + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _persona_block(persona: dict) -> str: + """Renders whatever fields exist in the persona JSON -- don't assume a + fixed schema beyond name, since personas will vary as you add more.""" + lines = [f"Name: {persona.get('Name') or persona.get('name', 'Unknown')}"] + for field, meaning in PERSONA_FIELD_GLOSSARY.items(): + value = persona.get(field) + if value: + lines.append(f"- {field} ({meaning}): {value}") + + identity_fields = ["Age", "Gender", "Branch", "Home City", "Hostel"] + lines.extend(f"{f}: {persona[f]}" for f in identity_fields if persona.get(f)) + + return "\n".join(lines) + + +def _memories_block(memories: List[str]) -> str: + if not memories: + return "(none available yet)" + return "\n".join(f"- {m}" for m in memories) + + +def _events_block(events: List[Dict[str, Any]]) -> str: + if not events: + return "(no announced events)" + return "\n".join( + f"- {event.get('start_time', '?')}-{event.get('end_time', '?')}: " + f"{event.get('name', 'Campus event')} at {event.get('location_id', '?')} โ€” " + f"{event.get('announcement', '')}" + for event in events[:5] + ) + + +def _flavor_block(state: DayPlannerState) -> str: + theme = state.get("daily_theme", "Neutral") + emotion = state.get("daily_emotion", "Neutral") + return f"Today's vibe: {emotion}, leaning into {theme}." + + +def _planning_window(state: DayPlannerState) -> tuple[str, str]: + """Return the exact time window owned by this planner invocation.""" + current_time = state.get("current_time", "") + if state.get("mode") == "remaining" and " " in current_time: + return current_time.rsplit(" ", 1)[-1], "24:00" + return "00:00", "24:00" + + +def _window_constraint(state: DayPlannerState) -> str: + start, end = _planning_window(state) + if state.get("mode") == "remaining": + return ( + f"This is a REMAINING-DAY REPLAN. You own exactly {start} to {end}. " + f"The FIRST output block/action MUST start exactly at {start}; the LAST MUST end " + f"exactly at {end}. Never output, mention, or schedule any time before {start}." + ) + return ( + f"This plan owns exactly {start} to {end}. The FIRST output block/action MUST start " + f"exactly at {start}; the LAST MUST end exactly at {end}." + ) + + +def _coarse_output_schema(required_start: str) -> type[CoarsePlanOutput]: + """Build the per-request structured-output contract for the first block. + + Gemini's JSON schema is static for a given call, while a remaining-day + replan may begin at any simulation time. A Literal field turns the + runtime start time into a JSON-schema ``const`` sent with that specific + API request. The base-model validator additionally binds that echoed + value to the first actual block. + """ + return create_model( + "CoarsePlanOutputAt" + required_start.replace(":", "_"), + __base__=CoarsePlanOutput, + first_start_time=( + Literal[required_start], + Field( + description=( + "Must be exactly " + required_start + + "; it must also equal blocks[0].start." + ) + ), + ), + ) + + +def _within_source_windows(record: Dict[str, Any], sources: List[Dict[str, Any]]) -> bool: + """Whether one provider record belongs wholly to an assigned source range.""" + try: + start, end = _hhmm_minutes(record["start"]), _hhmm_minutes(record["end"]) + return any( + _hhmm_minutes(source["start"]) <= start < end <= _hhmm_minutes(source["end"]) + for source in sources + ) + except (KeyError, TypeError, ValueError): + return False + + +# --------------------------------------------------------------------------- +# Nodes +# --------------------------------------------------------------------------- + +def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState: + persona = state["persona"] + mode = state.get("mode", "full_day") + current_time = state.get("current_time", "unknown") + + if mode == "full_day": + coverage = "covering the full 24 hours (00:00 to 24:00). The very first action MUST start at 00:00 โ€” do not skip morning/midnight hours." + elif mode == "next_day": + coverage = "covering the full next calendar day from 00:00 to 24:00. The very first action MUST start at 00:00 โ€” this is a brand-new day, do not skip ahead." + elif mode == "remaining": + coverage = ( + "covering ONLY the REMAINDER of the day. The first block must start " + f"exactly at {_planning_window(state)[0]}, and no block may start before it" + ) + else: + coverage = "covering the full 24 hours (00:00 to 24:00)" + + loc_hint = "" + current_loc = state.get("current_location_id") + system_prompt = ( + "You are simulating one day in the life of a character in a generative-agents " + "simulation. Produce a COARSE day plan: 5 to 8 broad blocks of activity " + f"{coverage}, with no gaps and no overlaps. " + "Stay true to the persona's traits, background, goals, and daily habits.\n\n" + "The movement executor owns routes between locations. Plan only time spent " + "at places; do not create separate walk, commute, travel, or transit blocks. " + "Leave enough dwell time for meals, classes, study, rest, and social activities. " + "All activities must be age-appropriate and respectful: never include explicit, coercive, harassing, discriminatory, or stalking behaviour.\n\n" + "For EACH block, tag it as 'atomic' or 'flexible':\n" + "- atomic: a single continuous activity with no meaningful internal sub-steps " + "worth planning separately. Examples: sleeping, attending a class/lecture, " + "sitting an exam, watching a movie, a long uninterrupted study/deep-work session.\n" + "- flexible: an activity that naturally contains distinct on-site sub-activities.\n\n" + "For EACH block, assign realistic energy_change and emotion_change values. Routine classes, labs, study, meals, and chores should be near neutral (usually -0.03 to +0.03); reserve larger positive changes for rare, meaningful events." + ) + if current_loc: + loc_hint = f"\nThe agent is currently at: {current_loc}. Start the plan from this location." + user_prompt = ( + f"PERSONA:\n{_persona_block(persona)}\n\n" + f"RELEVANT MEMORIES:\n{_memories_block(state.get('relevant_memories', []))}\n\n" + f"YESTERDAY'S SUMMARY:\n{state.get('yesterday_summary') or '(no history yet, this is day 1)'}\n\n" + f"ANNOUNCED CAMPUS EVENTS (optional; never displace classes, meals, sleep, exams, or deadlines):\n{_events_block(state.get('upcoming_events', []))}\n\n" + f"{_flavor_block(state)}\n\n" + f"Current in-simulation time: {current_time}\n" + f"Plan mode: {mode}\n" + f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n" + f"Agent location: {current_loc or 'unknown'}{loc_hint}\n\n" + f"DAY-HANDOFF CONTINUITY:\n{state.get('handoff_context') or '(none)'}\n\n" + "Generate the coarse plan now." + ) + + if state.get("conflict_reason"): + user_prompt += ( + f"\n\nNOTE: a previous attempt was rejected for this reason, avoid repeating it:\n" + f"{state['conflict_reason']}" + ) + + required_start = _planning_window(state)[0] + result = call_gemini( + system_prompt, + user_prompt, + _coarse_output_schema(required_start), + "default", + ) + logger.info("[day_planner] coarse plan generated: %d blocks", len(result.blocks)) + + return { + **state, + "coarse_plan": [b.model_dump() for b in result.blocks], + } + + +def validate_coarse_window(state: DayPlannerState) -> DayPlannerState: + """Reject an invalid coarse time window before costly LLM refinement.""" + issue = _local_overlap_check( + state.get("coarse_plan", []), + mode=state.get("mode", "full_day"), + remaining_start=_planning_window(state)[0], + action_key="activity", + ) + if issue: + logger.info("[day_planner] coarse-window validation failed: %s", issue) + return { + **state, + "conflict_detected": True, + "conflict_reason": issue, + "retry_count": state.get("retry_count", 0) + 1, + } + return {**state, "conflict_detected": False, "conflict_reason": None} + + +def decompose_hourly(state: DayPlannerState) -> DayPlannerState: + persona = state["persona"] + + atomic_blocks = [b for b in state['coarse_plan'] if b.get("granularity") == "atomic"] + flexible_blocks = [b for b in state['coarse_plan'] if b.get("granularity") == "flexible"] + unknown_blocks = [ + b for b in state["coarse_plan"] + if b.get("granularity") not in {"atomic", "flexible"} + ] + if unknown_blocks: + logger.warning( + "[day_planner] treating %d unknown-granularity block(s) as flexible", + len(unknown_blocks), + ) + flexible_blocks.extend({**block, "granularity": "flexible"} for block in unknown_blocks) + + passthrough_hourly = [ + { + "activity": b["activity"], + "start": b["start"], + "end": b["end"], + "parent_activity": b["activity"], + "granularity": "atomic", + "energy_change": b.get("energy_change", 0.0), + "emotion_change": b.get("emotion_change", 0.0), + } + for b in atomic_blocks + ] + + hourly_blocks = list(passthrough_hourly) + + if flexible_blocks: + system_prompt = ( + "You refine flexible coarse blocks into hourly-resolution blocks. Each coarse " + "block should be broken into one or more hourly blocks that together span " + "exactly its start/end range with no gaps or overlaps. " + "Make each sub-block feel natural and persona-aligned โ€” a real person would " + "think of these as distinct steps. Consider reasonable time boundaries " + "(e.g. a meal block of 2 hours can contain 'walk to mess', 'eat', 'socialize'). " + "Only the blocks provided here need refining. Do not create walk, commute, or " + "transit sub-blocks: refine only activities performed at the destination.\n\n" + "For EACH block, assign realistic energy_change and emotion_change values:\n" + "- energy_change: positive = restorative, negative = tiring\n" + "- emotion_change: positive = uplifting, negative = draining\n" + "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n" + "- Be realistic for the persona\n\n" + f"{_window_constraint(state)}" + ) + user_prompt = ( + f"PERSONA:\n{_persona_block(persona)}\n\n" + f"{_flavor_block(state)}\n\n" + f"FULL COARSE PLAN (context only):\n{json.dumps(state['coarse_plan'], indent=2)}\n\n" # Hand the whole day + f"BLOCKS TO REFINE:\n{json.dumps(flexible_blocks, indent=2)}\n\n" # The ONLY data to modify + f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n" + "Produce the hourly-resolution plan for the blocks listed under " + "'BLOCKS TO REFINE' only." + ) + result = call_gemini(system_prompt, user_prompt, HourlyPlanOutput, "default") + raw_refined = [b.model_dump() for b in result.blocks] + refined = [block for block in raw_refined if _within_source_windows(block, flexible_blocks)] + if len(refined) != len(raw_refined): + logger.warning( + "[day_planner] discarded %d hourly refinement block(s) outside flexible source windows", + len(raw_refined) - len(refined), + ) + for b in refined: + b["granularity"] = "flexible" + hourly_blocks.extend(refined) + + hourly_blocks.sort(key=lambda b: b["start"]) + logger.info( + "[day_planner] hourly plan: %d atomic passthrough + %d refined", + len(passthrough_hourly), len(hourly_blocks) - len(passthrough_hourly), + ) + + return {**state, "hourly_plan": hourly_blocks} + + +def validate_hourly_refinement(state: DayPlannerState) -> DayPlannerState: + """Ensure the refinement stage did not recreate atomic source blocks. + + Gemini is asked to refine flexible blocks only, but structured output does + not itself prevent it from returning a duplicate midnight sleep block. A + bad hourly plan must be retried before the fine planner spends more calls. + """ + flexible_sources = [block for block in state.get("coarse_plan", []) if block.get("granularity") == "flexible"] + flexible_by_parent = {} + for block in flexible_sources: + flexible_by_parent.setdefault(block.get("activity"), []).append(block) + + issue = _local_overlap_check( + state.get("hourly_plan", []), + mode=state.get("mode", "full_day"), + remaining_start=_planning_window(state)[0], + action_key="activity", + ) + if not issue: + for block in state.get("hourly_plan", []): + if block.get("granularity") != "flexible": + continue + sources = flexible_by_parent.get(block.get("parent_activity"), []) + if not sources: + issue = f"hourly refinement '{block.get('activity', 'unknown')}' has no flexible source block" + break + start = _hhmm_minutes(block.get("start", "")) + end = _hhmm_minutes(block.get("end", "")) + if not any( + _hhmm_minutes(source["start"]) <= start < end <= _hhmm_minutes(source["end"]) + for source in sources + ): + issue = f"hourly refinement '{block.get('activity', 'unknown')}' exceeds its flexible source window" + break + if issue: + logger.info("[day_planner] hourly refinement validation failed: %s", issue) + return { + **state, + "conflict_detected": True, + "conflict_reason": issue, + "retry_count": state.get("retry_count", 0) + 1, + } + return {**state, "conflict_detected": False, "conflict_reason": None} + +def decompose_fine(state: DayPlannerState) -> DayPlannerState: + persona = state["persona"] + places = _normalize_places(state.get("places", [])) + current_loc = state.get("current_location_id", "unknown") + + atomic_blocks = [b for b in state["hourly_plan"] if b["granularity"] == "atomic"] + flexible_blocks = [b for b in state["hourly_plan"] if b["granularity"] == "flexible"] + + fine_actions: List[Dict[str, Any]] = [] + + # Now have to handle atomic vs flexible blocks differently + + # Atomic blocks: keep as single continuous actions, just resolve location. + if atomic_blocks: + system_prompt = ( + "For each atomic activity below, assign exactly one location_id (and optionally " + "a sub_area) from the known places list. Pick whichever place fits the " + "activity best. Consider commute distance: if the agent is moving between " + "locations, ensure the distance is reasonable for the time available. " + "Default to locations that make sense for this specific persona. " + "Do not suggest splitting the activity.\n\n" + "ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. " + "Do not use SAB as a generic lecture/lab default.\n\n" + "For EACH block, assign realistic energy_change and emotion_change values:\n" + "- energy_change: positive = restorative, negative = tiring\n" + "- emotion_change: positive = uplifting, negative = draining\n" + "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n" + "- Be realistic for the persona\n\n" + f"{_window_constraint(state)}" + ) + user_prompt = ( + f"PERSONA:\n{_persona_block(persona)}\n\n" + f"ACADEMIC VENUE POLICY:\n{_academic_venue_policy(persona)}\n\n" + f"{_flavor_block(state)}\n\n" + f"ACTIVITIES:\n{json.dumps(atomic_blocks, indent=2)}\n\n" + f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n" + f"KNOWN CAMPUS LOCATIONS:\n{_places_block(places)}\n\n" + f"The agent's current position is: {current_loc}. This is only their " + "starting point for the next activity; it is not necessarily their hostel. " + f"Their home/hostel is: {persona.get('Hostel', 'unknown')}. Ensure sleep, " + "rest, and personal activities use the hostel unless the activity explicitly " + "requires another place.\n\n" + "Assign a location to each activity now." + ) + result = call_gemini(system_prompt, user_prompt, AtomicLocationOutput, "default") + # Activity labels are not unique (for example, two separate study + # blocks). Preserve the provider's ordered assignments for duplicates + # instead of letting a dict silently overwrite earlier entries. + loc_by_activity: Dict[str, deque] = defaultdict(deque) + for assignment in result.assignments: + loc_by_activity[assignment.activity].append(assignment) + + for b in atomic_blocks: + candidates = loc_by_activity.get(b["activity"]) + assignment = candidates.popleft() if candidates else None + fine_actions.append({ + "action": b["activity"], + "start": b["start"], + "end": b["end"], + "parent_activity": b["parent_activity"], + "location_id": assignment.location_id if assignment else None, + "sub_area": assignment.sub_area if assignment else None, + "energy_change": ( + assignment.energy_change if assignment else b.get("energy_change", 0.0) + ), + "emotion_change": ( + assignment.emotion_change if assignment else b.get("emotion_change", 0.0) + ), + }) + + # Flexible blocks: full fine-grained breakdown, as before. + if flexible_blocks: + system_prompt = ( + "You refine hourly blocks into fine-grained, directly executable actions " + "at roughly 5-15 minute granularity. Each hourly block should be broken " + "into one or more fine actions spanning exactly its start/end range, no " + "gaps or overlaps. Every action MUST be assigned a location_id, chosen " + "EXACTLY from the provided list -- never invent one.\n\n" + "Do NOT output walking, commuting, travel, transit, leaving, or arriving " + "as an action. The runtime owns visible routes between places; every action " + "you output must be an on-site activity at its assigned location.\n\n" + "ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. " + "Branch-specific classes/labs must not silently fall back to SAB.\n\n" + "Make action boundaries feel natural โ€” group related sub-actions together. " + "Consider typical on-site durations: eating ~20-40min and studying " + "~30-120min. Keep adjacent location changes realistic by leaving enough " + "time for the executor to animate transit before the next activity.\n\n" + "For EACH action, assign realistic energy_change and emotion_change values:\n" + "- energy_change: positive = restorative, negative = tiring\n" + "- emotion_change: positive = uplifting, negative = draining\n" + "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n" + "- Be realistic for the persona\n\n" + f"{_window_constraint(state)}" + ) + user_prompt = ( + f"PERSONA:\n{_persona_block(persona)}\n\n" + f"ACADEMIC VENUE POLICY:\n{_academic_venue_policy(persona)}\n\n" + f"{_flavor_block(state)}\n\n" + f"HOURLY BLOCKS TO REFINE:\n{json.dumps(flexible_blocks, indent=2)}\n\n" + f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n" + f"KNOWN CAMPUS LOCATIONS:\n{_places_block(places)}\n\n" + f"The agent's current position is: {current_loc}. This is only their " + "starting point for the next activity; it is not necessarily their hostel. " + f"Their home/hostel is: {persona.get('Hostel', 'unknown')}. Ensure sleep, " + "rest, and personal activities use the hostel unless the activity explicitly " + "requires another place.\n\n" + "Produce the fine-grained action plan for these blocks now." + ) + result = call_gemini(system_prompt, user_prompt, FinePlanOutput, "default") + raw_actions = [action.model_dump() for action in result.actions] + scoped_actions = [action for action in raw_actions if _within_source_windows(action, flexible_blocks)] + if len(scoped_actions) != len(raw_actions): + logger.warning( + "[day_planner] discarded %d fine action(s) outside flexible source windows", + len(raw_actions) - len(scoped_actions), + ) + fine_actions.extend(scoped_actions) + + fine_actions.sort(key=lambda a: a["start"]) + logger.info("[day_planner] fine plan: %d total actions", len(fine_actions)) + + return {**state, "fine_plan": fine_actions} + +def _local_overlap_check( + actions: List[Dict[str, Any]], + mode: str = "full_day", + remaining_start: Optional[str] = None, + action_key: str = "action", +) -> Optional[str]: + """Cheap deterministic pre-check before spending an LLM call on validation -- + catches the most common failure mode (bad overlaps/gaps) for free.""" + if not actions: + return "fine plan is empty" + + try: + sorted_actions = sorted(actions, key=lambda a: _hhmm_minutes(a["start"])) + except Exception as e: # malformed time strings + return f"unparsable time value: {e}" + + # Remaining-day replans replace the *whole* active plan. They therefore + # must begin now and continue through midnight; otherwise a valid old-plan + # tail would be silently discarded and the agent would become idle. + if mode == "remaining" and remaining_start: + if _hhmm_minutes(sorted_actions[0]["start"]) != _hhmm_minutes(remaining_start): + return ( + f"remaining-day plan does not start at {remaining_start} " + f"(starts at {sorted_actions[0]['start']})" + ) + elif mode != "remaining" and _hhmm_minutes(sorted_actions[0]["start"]) != 0: + return f"plan does not start at 00:00 (starts at {sorted_actions[0]['start']})" + + for prev, curr in zip(sorted_actions, sorted_actions[1:]): + if _hhmm_minutes(prev["end"]) <= _hhmm_minutes(prev["start"]): + return f"action '{prev.get(action_key, 'unknown')}' has a non-positive duration" + if _hhmm_minutes(prev["end"]) != _hhmm_minutes(curr["start"]): + return ( + f"gap or overlap between '{prev.get(action_key, 'unknown')}' (ends {prev['end']}) " + f"and '{curr.get(action_key, 'unknown')}' (starts {curr['start']})" + ) + + if _hhmm_minutes(sorted_actions[-1]["end"]) <= _hhmm_minutes(sorted_actions[-1]["start"]): + return f"action '{sorted_actions[-1].get(action_key, 'unknown')}' has a non-positive duration" + + # Even a remaining-day plan replaces the entire current plan, so it must + # explicitly own the final minute of the day. + last_end = sorted_actions[-1]["end"] + if _hhmm_minutes(last_end) not in (24 * 60, 0): + return f"plan does not end at 24:00 (ends at {last_end})" + + return None + + +def _hhmm_minutes(hhmm: str) -> int: + h, m = str(hhmm).split(":") + return int(h) * 60 + int(m) + + +def _local_location_check(actions: List[Dict[str, Any]], places: List[Place]) -> Optional[str]: + """Deterministic check that every action's location is one of the known + places -- catches hallucinated locations without spending an LLM call.""" + places = _normalize_places(places) + if not places: + # No places data loaded -- nothing to validate against, skip silently. + return None + + valid_ids = {p.id for p in places} + by_id = {p.id: p for p in places} + loc_err = "" + transit_words = ("walk", "commute", "travel", "transit", "arrive", "leave") + for a in actions: + loc_id = a.get("location_id") + if loc_id not in valid_ids: + loc_err += f"action '{a.get('action')}' has invalid location_id '{loc_id}'\n" + continue + if any(word in a.get("action", "").lower() for word in transit_words): + loc_err += f"action '{a.get('action')}' is transit; routes are owned by the executor\n" + sub = a.get("sub_area") + if sub and sub not in by_id[loc_id].sub_areas: + loc_err += f"action '{a.get('action')}' references unknown sub_area '{sub}' for {loc_id}\n" + + return loc_err.strip() if loc_err else None + + +def _local_content_safety_check(actions: List[Dict[str, Any]]) -> Optional[str]: + for action in actions: + text = str(action.get("action", "")).lower() + matched = next((term for term in _UNSAFE_PLAN_TERMS if term in text), None) + if matched: + return f"action '{action.get('action')}' contains prohibited unsafe content ({matched})" + return None + + +def validate_plan(state: DayPlannerState) -> DayPlannerState: + current_time = state.get("current_time", "") + remaining_start = current_time.rsplit(" ", 1)[-1] if " " in current_time else None + local_issue = _local_overlap_check( + state["fine_plan"], + mode=state.get("mode", "full_day"), + remaining_start=remaining_start, + ) or _local_location_check( + state["fine_plan"], state.get("places", []) + ) or _local_academic_venue_check(state["fine_plan"], state["persona"]) or _local_content_safety_check(state["fine_plan"]) + if local_issue: + logger.info("[day_planner] local validation failed: %s", local_issue) + return { + **state, + "conflict_detected": True, + "conflict_reason": local_issue, + "retry_count": state.get("retry_count", 0) + 1, + } + + # Local checks passed -- do one semantic sanity pass via the LLM + # (catches persona-incoherence, not just arithmetic). + system_prompt = ( + "You are QA-checking a simulated character's day plan for internal consistency " + "with their persona (not just time-math, which has already been verified). " + "Flag it invalid only for clear nonsensical sequencing." + + ) + user_prompt = ( + f"PERSONA:\n{_persona_block(state['persona'])}\n\n" + f"DETAILED PERSONA:\n{json.dumps(state['persona'], indent=2)}\n\n" + f"COARSE PLAN (intent and priorities):\n{json.dumps(state.get('coarse_plan', []), indent=2)}\n\n" + f"FINE PLAN:\n{json.dumps(state['fine_plan'], indent=2)}\n\n" + f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n" + "Is this plan valid for this persona? Flag only clear for " + " nonsensical sequencing." + ) + result = call_gemini(system_prompt, user_prompt, ValidationResult, "default") + + if not result.valid: + logger.info("[day_planner] semantic validation failed: %s", result.reason) + return { + **state, + "conflict_detected": True, + "conflict_reason": result.reason, + "retry_count": state.get("retry_count", 0) + 1, + } + + logger.info("[day_planner] plan validated successfully") + return { + **state, + "conflict_detected": False, + "conflict_reason": None, + "day_plan": state["fine_plan"], + } + + +# --------------------------------------------------------------------------- +# Conditional routing +# --------------------------------------------------------------------------- + +def route_after_validation(state: DayPlannerState) -> str: + if not state.get("conflict_detected"): + return "accept" + if state.get("retry_count", 0) >= MAX_PLAN_RETRIES: + logger.warning( + "[day_planner] max retries (%d) reached, force-accepting last plan with error flag", + MAX_PLAN_RETRIES, + ) + return "give_up" + return "retry" + + +def _force_accept(state: DayPlannerState) -> DayPlannerState: + """Terminal fallback so a stubborn persona can never infinite-loop the graph. + + This path is intentionally deterministic, but it must still honour the + same content constraints as a normally validated plan. Retrying a model + output must never turn a rejected unsafe action into an accepted schedule. + """ + # A remaining-day replan is optional. Unlike startup/day-handoff plans, + # it has a known-good predecessor. Never force-accept malformed output + # here: the caller will retain the prior plan instead. + if state.get("mode") == "remaining": + return { + **state, + "day_plan": [], + "replan_rejected": True, + "error": ( + f"replan rejected after {MAX_PLAN_RETRIES} retries, last issue: " + f"{state.get('conflict_reason')}" + ), + } + + # Full-day and next-day plans have no safe predecessor. Never install an + # LLM-invalid plan after retries: produce a small, valid local schedule at + # a known location instead. This contains provider mistakes before they + # reach the action manager and preserves a living agent over a server exit. + places = _normalize_places(state.get("places", [])) + valid_locations = {place.id for place in places} + preferred_locations = [ + state.get("current_location_id"), + state.get("persona", {}).get("Hostel"), + next(iter(valid_locations), None), + ] + location_id = next((item for item in preferred_locations if item in valid_locations), None) + if not location_id: + return { + **state, + "day_plan": [], + "error": "planner rejected after retries and no valid fallback location is available", + } + return { + **state, + "day_plan": [ + {"action": "Sleep and recover", "start": "00:00", "end": "07:00", "location_id": location_id, "sub_area": None, "energy_change": 0.25, "emotion_change": 0.02}, + {"action": "Unscheduled downtime", "start": "07:00", "end": "24:00", "location_id": location_id, "sub_area": None, "energy_change": -0.02, "emotion_change": 0.0}, + ], + "error": f"deterministic fallback used after {MAX_PLAN_RETRIES} retries: {state.get('conflict_reason')}", + } + + + +# --------------------------------------------------------------------------- +# Graph construction +# --------------------------------------------------------------------------- + +def build_day_planner_graph(): + graph = StateGraph(DayPlannerState) + + graph.add_node("generate_coarse_plan", generate_coarse_plan) + graph.add_node("validate_coarse_window", validate_coarse_window) + graph.add_node("decompose_hourly", decompose_hourly) + graph.add_node("validate_hourly_refinement", validate_hourly_refinement) + graph.add_node("decompose_fine", decompose_fine) + graph.add_node("validate_plan", validate_plan) + graph.add_node("force_accept", _force_accept) + + graph.set_entry_point("generate_coarse_plan") + graph.add_edge("generate_coarse_plan", "validate_coarse_window") + graph.add_conditional_edges( + "validate_coarse_window", + route_after_validation, + { + "accept": "decompose_hourly", + "retry": "generate_coarse_plan", + "give_up": "force_accept", + }, + ) + graph.add_edge("decompose_hourly", "validate_hourly_refinement") + graph.add_conditional_edges( + "validate_hourly_refinement", + route_after_validation, + { + "accept": "decompose_fine", + "retry": "generate_coarse_plan", + "give_up": "force_accept", + }, + ) + graph.add_edge("decompose_fine", "validate_plan") + + graph.add_conditional_edges( + "validate_plan", + route_after_validation, + { + "accept": END, + "retry": "generate_coarse_plan", + "give_up": "force_accept", + }, + ) + graph.add_edge("force_accept", END) + + return graph.compile() + + +_compiled_graph = None +_compiled_graph_lock = threading.Lock() + + +def get_compiled_graph(): + global _compiled_graph + if _compiled_graph is None: + with _compiled_graph_lock: + if _compiled_graph is None: + _compiled_graph = build_day_planner_graph() + return _compiled_graph + + +# --------------------------------------------------------------------------- +# AgentModule-compatible entrypoint +# +# Matches the run(agent, world_state) -> dict contract from core/module_base.py +# discussed earlier, so this drops straight into the tick orchestrator/registry +# once that's wired up. Falls back to a plain function if module_base isn't +# importable yet (e.g. running this file standalone). +# --------------------------------------------------------------------------- + +def run(agent: Any, world_state: dict) -> dict: + """ + agent is expected to expose: + agent.persona -> dict + agent.relevant_memories -> List[str] (empty for now) + agent.yesterday_summary -> Optional[str] (empty for now) + world_state is expected to expose: + 'current_time' key (str) + optionally a 'places' key (List[Dict[str, str]]) -- if omitted, + places are loaded from places.json via load_places(). + 'persona_name' is used when saving the final plan to data/Short_term_db. + 'mode' can be 'full_day' (default), 'remaining', or 'next_day'. + 'current_location_id' optionally tells the planner where the agent is + (useful for remaining-day and next-day planning). + """ + places = world_state.get("places") or load_places() + mode = world_state.get("mode", "full_day") + + from src.agents.daily_flavor import pick_theme, pick_emotion + theme = world_state.get("daily_theme") or pick_theme() + emotion = world_state.get("daily_emotion") or pick_emotion() + + initial_state: DayPlannerState = { + "persona": getattr(agent, "persona", {}), + "relevant_memories": getattr(agent, "relevant_memories", []) or [], + "yesterday_summary": getattr(agent, "yesterday_summary", None), + "current_time": world_state.get("current_time", "00:00"), + "places": places, + "mode": mode, + "current_location_id": world_state.get("current_location_id"), + "handoff_context": world_state.get("handoff_context"), + "upcoming_events": world_state.get("upcoming_events", []), + "daily_theme": theme, + "daily_emotion": emotion, + "retry_count": 0, + } + + # API-key traversal is owned exclusively by gemini_client. If every key + # fails there, propagate its quota-exhausted signal to the server/UI. + final_state = get_compiled_graph().invoke(initial_state) + + # Save day plan to short-term memory + from src.agents.Short_term import save_day_plan, date_from_simulation_time + + sim_date = date_from_simulation_time(world_state.get("current_time", "00:00")) + save_day_plan(initial_state["persona"].get("Name") or initial_state["persona"].get("name", "unknown"), sim_date, final_state.get("day_plan", [])) + + return { + "day_plan": final_state.get("day_plan", []), + "error": final_state.get("error"), + "memory_entries": [ + f"Planned today: {len(final_state.get('day_plan', []))} scheduled actions." + ], + } + + +try: + from src.core.module_base import AgentModule + from src.core.registry import register_module + + + @register_module + class DayPlanner(AgentModule): + name = "day_planner" + + def run(self, agent, world_state): + return run(agent, world_state) + +except ImportError: + # core/module_base.py and core/registry.py not wired up yet -- fine, + # `run()` above works standalone in the meantime. + pass + +# --------------------------------------------------------------------------- +# Standalone smoke test +# +# Uses only a persona -- relevant_memories and yesterday_summary are left +# empty, exactly as requested for this stage. +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import argparse + + from src.config import PERSONALITIES_DIR + + parser = argparse.ArgumentParser(description="Run the day planner smoke test") + parser.add_argument( + "persona", + nargs="?", + default="gurnoor_singh", + help="Persona name or persona JSON path (for example: tanishq or tanishq/tanishq.json)", + ) + args = parser.parse_args() + + def resolve_persona_path(persona_arg: str) -> Path: + candidate_path = Path(persona_arg) + if candidate_path.exists(): + return candidate_path + + if candidate_path.suffix == ".json": + matches = list(PERSONALITIES_DIR.glob(f"**/{candidate_path.name}")) + if matches: + return matches[0] + + matches = sorted(PERSONALITIES_DIR.glob(f"**/{persona_arg}/{persona_arg}.json")) + if matches: + return matches[0] + + matches = sorted(PERSONALITIES_DIR.glob(f"**/{persona_arg}.json")) + if matches: + return matches[0] + + candidates = sorted(PERSONALITIES_DIR.glob("**/*.json")) + if not candidates: + raise FileNotFoundError(f"No persona JSON files found under {PERSONALITIES_DIR}") + raise FileNotFoundError( + f"Could not find persona '{persona_arg}'. Available personas: " + f"{', '.join(sorted({p.parent.name for p in candidates}))}" + ) + + sample_persona_path = resolve_persona_path(args.persona) + + sample_persona = json.loads(sample_persona_path.read_text()) + + class _FakeAgent: + persona = sample_persona + relevant_memories: List[str] = [] + yesterday_summary: Optional[str] = None + + + # Swap for load_places() once your real data/environment/places.json exists. + # WorrdState is expected to have the Places + + result = run( + _FakeAgent(), + {"current_time": "2026-07-03 06:00", "places": None, "persona_name": sample_persona_path.stem}, + ) + + print(result) + + col_widths = (10, 10, 60, 20) + header = ( + f"{'START':<{col_widths[0]}}{'END':<{col_widths[1]}}" + f"{'ACTION':<{col_widths[2]}}{'LOCATION':<{col_widths[3]}}" + ) + print(header) + print("-" * sum(col_widths)) + for item in result["day_plan"]: + action = item.get("action", "") + if len(action) > col_widths[2] - 2: + action = action[: col_widths[2] - 5] + "..." + print( + f"{item['start']:<{col_widths[0]}}{item['end']:<{col_widths[1]}}" + f"{action:<{col_widths[2]}}{item.get('location_id', ''):<{col_widths[3]}}" + ) + + if result.get("memory_entries"): + print("\nMemory entries logged:") + for m in result["memory_entries"]: + print(f" - {m}") + diff --git a/backend/src/agents/memory_index.py b/backend/src/agents/memory_index.py new file mode 100644 index 0000000000000000000000000000000000000000..b6330147af234dfd95eb93ab19d713f399b2a506 --- /dev/null +++ b/backend/src/agents/memory_index.py @@ -0,0 +1,95 @@ +"""Operate Qdrant-only long-term memory. + +``migrate-json`` imports legacy Long_term_db archives once. Pass +``--delete-source`` only after checking the reported indexed count; it removes +the obsolete JSON archives after their idempotent Qdrant upsert succeeds. + +``clear`` removes only Valhalla's durable Qdrant collections. It never touches +short-term runtime files, checkpoints, or the live simulation process. +""" +from __future__ import annotations + +import argparse +import json +from collections import defaultdict +from pathlib import Path + +from src.agents.vector_memory import VectorMemoryRetriever, archive_records +from src.config import DATA_DIR + + +def _legacy_archives() -> tuple[dict[str, list], list[Path]]: + root = DATA_DIR / "Long_term_db" + result: dict[str, list] = defaultdict(list) + sources: list[Path] = [] + for path in root.glob("*/memory.json"): + try: + content = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + agent = str(content.get("persona_name") or path.parent.name) + for day in content.get("days", []): + result[agent].extend(archive_records(agent, day)) + sources.append(path) + return result, sources + + +def main() -> int: + parser = argparse.ArgumentParser(description="Manage Qdrant-only long-term memory") + parser.add_argument("operation", choices=("status", "migrate-json", "clear")) + parser.add_argument("--agent", help="limit work to one persona name") + parser.add_argument("--delete-source", action="store_true", + help="after a successful migrate-json, delete legacy memory.json files") + parser.add_argument("--yes", action="store_true", + help="required for clear; confirms permanent deletion of Valhalla Qdrant memory") + args = parser.parse_args() + index = VectorMemoryRetriever() + if not index.available: + print("Qdrant memory is unavailable. Set SIM_SEMANTIC_MEMORY_ENABLED=true, QDRANT_URL, QDRANT_API_KEY, and Gemini keys.") + return 2 + if args.operation == "clear": + if args.agent: + parser.error("clear always removes all Valhalla long-term memory; --agent is not supported") + if not args.yes: + parser.error("clear is permanent; rerun with --yes to remove all Valhalla long-term memory") + result = index.clear_all_memory() + deleted = result["deleted_collections"] + failed = result["failed_collections"] + print(f"Cleared {len(deleted)} Valhalla long-term-memory collection(s): {', '.join(deleted) or '(none found)'}") + if failed: + print(f"Could not clear {len(failed)} collection(s): {', '.join(failed)}") + return 1 + return 0 + + archives, sources = _legacy_archives() + if args.agent: + archives = {args.agent: archives.get(args.agent, [])} + sources = [p for p in sources if p.parent.name == args.agent.lower().replace(" ", "_")] + if args.operation == "status": + agents = sorted(archives) if archives else ([args.agent] if args.agent else []) + for agent in agents: + print(f"{agent}: {index.collection_status(agent)}") + return 0 + + successful = True + for agent, records in sorted(archives.items()): + indexed = index.index_records(agent, records) + expected = len({record.id for record in records}) + print(f"{agent}: indexed {indexed}/{expected} legacy memories") + successful = successful and indexed == expected + if args.delete_source: + if not successful: + print("Legacy JSON was retained because migration was incomplete.") + return 1 + for path in sources: + path.unlink() + try: + path.parent.rmdir() + except OSError: + pass + print(f"Deleted {len(sources)} migrated legacy memory.json file(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/src/agents/react.py b/backend/src/agents/react.py new file mode 100644 index 0000000000000000000000000000000000000000..619b2058f8f49e7d88b0af3650a5a050d1f47682 --- /dev/null +++ b/backend/src/agents/react.py @@ -0,0 +1,70 @@ +""" +React -- decides, given an agent's current action (if any) and what it just +perceived, whether to keep executing that action or interrupt and replan. + +Design notes +------------ +All calls into this module are cheap heuristic checks with no LLM round-trip: + - no current action yet -> always replan + - current action's end_tick has passed -> always replan + - mid-action, nothing new perceived this tick -> always continue + +The LLM-based decision layer has moved to brain.decide_tick(), which runs +only when the perceive phase detects novel observations. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel + +from src.core.log import get_logger +from src.core.world_state import CurrentAction +from src.core.perceive import Observation + +logger = get_logger(__name__) + + +class ReactionDecision(BaseModel): + should_replan: bool + reason: str + + +def decide_reaction( + agent_id: str, + persona: Dict[str, Any], + current_action: Optional[CurrentAction], + observations: List[Observation], + memories: List[str], + tick: int, +) -> ReactionDecision: + """Cheap heuristic decision: no LLM involved.""" + if current_action is None: + return ReactionDecision(should_replan=True, reason="no current action to continue") + + if current_action.is_finished(tick): + return ReactionDecision(should_replan=True, reason="current action has finished") + + if not observations: + return ReactionDecision(should_replan=False, reason="mid-action, nothing new perceived") + + # Mid-action with observations: cheap heuristic โ€” always continue. + # The brain's decide_tick (LLM) will handle novel observation decisions. + return ReactionDecision(should_replan=False, reason="mid-action, continuing current action") + + +# Standalone sanity check (doesn't hit the network) +if __name__ == "__main__": + action = CurrentAction(description="sleeping", start_tick=0, end_tick=60) + + r1 = decide_reaction("a", {}, None, [], [], tick=0) + assert r1.should_replan is True + + r2 = decide_reaction("a", {}, action, [], [], tick=61) + assert r2.should_replan is True + + r3 = decide_reaction("a", {}, action, [], [], tick=30) + assert r3.should_replan is False + + print("react.py cheap-path sanity checks passed (LLM branch not exercised here).") \ No newline at end of file diff --git a/backend/src/agents/vector_memory.py b/backend/src/agents/vector_memory.py new file mode 100644 index 0000000000000000000000000000000000000000..33919fe590e13f66c033aff2a652b077b4338053 --- /dev/null +++ b/backend/src/agents/vector_memory.py @@ -0,0 +1,514 @@ +"""Persistent, per-persona Cloud Qdrant long-term memory and RAG retrieval. + +Qdrant is the sole long-term store. The active day's short-term JSON is +converted into durable memory records during handoff; once indexing succeeds, +that operational file can be removed. Retrieval returns query-relevant, +ranked context for model prompts. +""" +from __future__ import annotations + +import hashlib +import json +import math +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Iterable, List, Optional + +from src import config as _cfg +from src.core.log import get_logger +from src.llm.gemini_client import ProviderFailureError, embed_content + +logger = get_logger(__name__) + +_IMPORTANCE = { + "summary": 3.0, "conversation": 2.5, "action": 1.5, + "key_event": 2.0, "observation": 0.5, "plan": 1.0, +} +_GIB = 1024 ** 3 + + +def safe_agent_id(agent_id: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "_", agent_id.strip()).strip("._-").lower() or "unknown" + + +def collection_name(agent_id: str) -> str: + """One active collection per agent; later schema versions are isolated.""" + base = safe_agent_id(agent_id) + return base if _cfg.MEMORY_COLLECTION_VERSION == "v1" else f"{base}__{_cfg.MEMORY_COLLECTION_VERSION}" + + +def source_hash(agent_id: str, date: str, kind: str, text: str) -> str: + return hashlib.sha256(f"{agent_id}\x1f{date}\x1f{kind}\x1f{text}".encode("utf-8")).hexdigest() + + +def point_id(agent_id: str, date: str, kind: str, text: str) -> int: + # Qdrant accepts unsigned 64-bit point IDs. A deterministic ID makes + # archive writes naturally idempotent. + return int(source_hash(agent_id, date, kind, text)[:15], 16) + + +@dataclass(frozen=True) +class MemoryRecord: + agent_id: str + date: str + kind: str + text: str + importance: float + + @property + def hash(self) -> str: + return source_hash(self.agent_id, self.date, self.kind, self.text) + + @property + def id(self) -> int: + return point_id(self.agent_id, self.date, self.kind, self.text) + + def payload(self) -> dict: + return {"date": self.date, "kind": self.kind, "importance": self.importance, + "source_hash": self.hash, "text": self.text, + "recall_count": 0, "last_recalled_at": self.date, + "retention_score": min(1.0, max(0.0, self.importance / 3.0))} + + +def archive_records(agent_id: str, day: dict) -> list[MemoryRecord]: + """Convert a completed short-term day into durable Qdrant records. + + Full transcripts and periodic snapshots are intentionally excluded. Plans, + completed actions, conversation summaries, explicit durable events and the + daily summary preserve the information future model calls can use. + """ + date = str(day.get("date") or "") + records: list[MemoryRecord] = [] + summary = str(day.get("summary") or day.get("daily_summary") or "").strip() + if summary: + records.append(MemoryRecord(agent_id, date, "summary", summary, _IMPORTANCE["summary"])) + for action in day.get("day_plan", []) or []: + text = str(action.get("action") or action.get("description") or "").strip() + if text: + location = str(action.get("location_id") or action.get("location") or "").strip() + when = "-".join(filter(None, (str(action.get("start") or ""), str(action.get("end") or "")))) + records.append(MemoryRecord(agent_id, date, "plan", + f"Planned {text}" + (f" at {location}" if location else "") + (f" ({when})" if when else ""), + _IMPORTANCE["plan"])) + for event in day.get("events", []) or []: + event_type = str(event.get("type") or "") + if event_type == "action_completed": + action = str(event.get("action") or "").strip() + if action: + outcome = str(event.get("outcome") or "").strip() + location = str(event.get("location") or "").strip() + text = f"Completed {action}" + (f" at {location}" if location else "") + (f": {outcome}" if outcome else "") + records.append(MemoryRecord(agent_id, date, "action", text, _IMPORTANCE["action"])) + elif event_type in {"key_event", "durable_memory", "memory"}: + details = event.get("details") if isinstance(event.get("details"), dict) else {} + text = str(event.get("summary") or details.get("content") or "").strip() + if text: + records.append(MemoryRecord(agent_id, date, "key_event", text, + float(details.get("importance", _IMPORTANCE["key_event"])))) + for conversation in day.get("conversations", []) or []: + text = str(conversation.get("summary") or "").strip() + if text: + records.append(MemoryRecord(agent_id, date, "conversation", text, _IMPORTANCE["conversation"])) + for event in day.get("key_events", []) or []: + text = str(event.get("summary") or event.get("text") or "").strip() + if text: + records.append(MemoryRecord(agent_id, date, "key_event", text, + float(event.get("importance", _IMPORTANCE["key_event"])))) + return records + + +class VectorMemoryRetriever: + """Cloud Qdrant writer/retriever implementing the application's RAG layer.""" + + def __init__(self, *, client=None, embedder=None, qmodels=None) -> None: + self._client = client + self._qmodels = qmodels + self._embedder = embedder + self._ok = False + if not _cfg.SEMANTIC_MEMORY_ENABLED: + return + try: + if self._client is None: + if not _cfg.QDRANT_URL or not _cfg.QDRANT_API_KEY: + raise RuntimeError("QDRANT_URL and QDRANT_API_KEY are required") + from qdrant_client import QdrantClient + self._client = QdrantClient(url=_cfg.QDRANT_URL, api_key=_cfg.QDRANT_API_KEY) + if self._qmodels is None: + from qdrant_client.http import models as qmodels + self._qmodels = qmodels + self._ok = True + logger.info("[vector_memory] Cloud Qdrant semantic memory enabled") + except Exception as exc: + logger.warning("[vector_memory] unavailable (%s); long-term recall is empty", exc) + + @property + def available(self) -> bool: + return self._ok + + def _ensure_collection(self, agent_id: str) -> str: + name = collection_name(agent_id) + if not self._client.collection_exists(name): + self._client.create_collection( + collection_name=name, + vectors_config=self._qmodels.VectorParams( + size=_cfg.MEMORY_VECTOR_DIMENSIONS, + distance=self._qmodels.Distance.COSINE, + ), + ) + return name + + def _embed(self, text: str, task_type: str) -> Optional[list[float]]: + if not self._ok: + return None + try: + from src.core.budget import GOVERNOR + if not GOVERNOR.can_afford("embedding", cost=1): + return None + vector = embed_content(text, task_type, _cfg.MEMORY_VECTOR_DIMENSIONS) + if len(vector) != _cfg.MEMORY_VECTOR_DIMENSIONS: + raise ValueError(f"embedding dimension {len(vector)} does not match configured dimension") + GOVERNOR.record("embedding", _cfg.MEMORY_EMBEDDING_MODEL) + return vector + except ProviderFailureError: + # This is terminal for the live simulation, not a recoverable + # single-record indexing failure. WorldEngine/Odin surface it. + raise + except Exception as exc: + logger.warning("[vector_memory] embedding failed (%s)", exc) + return None + + def index_records(self, agent_id: str, records: Iterable[MemoryRecord]) -> int: + if not self._ok: + return 0 + name = self._ensure_collection(agent_id) + points = [] + for record in records: + vector = self._embed(record.text, "RETRIEVAL_DOCUMENT") + if vector is None: + continue + points.append(self._qmodels.PointStruct(id=record.id, vector=vector, payload=record.payload())) + if not points: + return 0 + try: + self._client.upsert(collection_name=name, points=points, wait=True) + self.prune_if_needed() + return len(points) + except Exception as exc: + logger.warning("[vector_memory] upsert failed (%s)", exc) + return 0 + + @staticmethod + def _payload_retention_score(payload: dict) -> float: + """Retention is global, unlike semantic relevance which is query-specific.""" + importance = min(1.0, max(0.0, float(payload.get("importance", 0.0)) / 3.0)) + last_used = str(payload.get("last_recalled_at") or payload.get("date") or "") + recency = math.exp(-VectorMemoryRetriever._days_ago(last_used) / 90.0) + recall_count = max(0, int(payload.get("recall_count", 0))) + popularity = min(1.0, math.log1p(recall_count) / math.log(11)) + return .50 * importance + .30 * recency + .20 * popularity + + @staticmethod + def _estimated_point_bytes(payload: dict) -> int: + """Conservative vector + serialized-payload estimate; JSON remains intact.""" + payload_bytes = len(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + return (_cfg.MEMORY_VECTOR_DIMENSIONS * 4) + payload_bytes + 128 + + def _all_index_points(self) -> list[tuple[str, Any]]: + points: list[tuple[str, Any]] = [] + collections = getattr(self._client.get_collections(), "collections", []) + for collection in collections: + name = getattr(collection, "name", str(collection)) + offset = None + while True: + batch, offset = self._client.scroll( + collection_name=name, limit=256, offset=offset, + with_payload=True, with_vectors=False, + ) + # A Cloud cluster can contain other applications. Only points + # carrying Valhalla's deterministic archive marker participate + # in quota accounting or retention deletion. + points.extend( + (name, point) for point in batch + if isinstance(getattr(point, "payload", None), dict) + and point.payload.get("source_hash") + ) + if offset is None: + break + return points + + def prune_if_needed(self) -> int: + """Keep the rebuildable index below its configured storage budget. + + Cloud Qdrant's dashboard is authoritative for billed capacity. This + conservative client-side estimate triggers ahead of that limit. Only + the lowest-retention Qdrant points are removed; there is no parallel + JSON long-term archive to consult or rebuild from. + """ + if not self._ok: + return 0 + try: + points = self._all_index_points() + limit = int(_cfg.MEMORY_MAX_STORAGE_GB * _GIB) + threshold = int(limit * _cfg.MEMORY_STORAGE_PRUNE_THRESHOLD) + target = int(limit * _cfg.MEMORY_STORAGE_PRUNE_TARGET) + usage = sum(self._estimated_point_bytes(point.payload or {}) for _, point in points) + if usage < threshold: + return 0 + ranked = sorted( + points, + key=lambda item: self._payload_retention_score(item[1].payload or {}), + ) + grouped: dict[str, list[int]] = {} + removed = 0 + for name, point in ranked: + if usage <= target: + break + usage -= self._estimated_point_bytes(point.payload or {}) + grouped.setdefault(name, []).append(int(point.id)) + removed += 1 + for name, point_ids in grouped.items(): + self._client.delete( + collection_name=name, + points_selector=self._qmodels.PointIdsList(points=point_ids), wait=True, + ) + if removed: + logger.warning("[vector_memory] pruned %d low-retention vector memories to enforce quota", removed) + return removed + except Exception as exc: + logger.warning("[vector_memory] quota check failed (%s); retaining index", exc) + return 0 + + def index_archive(self, agent_id: str, day: dict) -> int: + """Persist a completed-day snapshot and discard superseded same-day points. + + The engine may archive once when the final activity starts and once at + handoff. Upserting first, then deleting only no-longer-present records + makes the later snapshot authoritative without a vulnerable delete-first + window. + """ + try: + records = archive_records(agent_id, day) + indexed = self.index_records(agent_id, records) + if indexed != len(records): + return indexed + if not self._ok: + return indexed + date = str(day.get("date") or "") + wanted = {record.hash for record in records} + stale: list[int] = [] + name = collection_name(agent_id) + offset = None + while True: + batch, offset = self._client.scroll(collection_name=name, limit=256, offset=offset, + with_payload=True, with_vectors=False) + stale.extend(int(point.id) for point in batch + if str((point.payload or {}).get("date") or "") == date + and (point.payload or {}).get("source_hash") not in wanted) + if offset is None: + break + if stale: + self._client.delete(collection_name=name, + points_selector=self._qmodels.PointIdsList(points=stale), wait=True) + return indexed + except Exception as exc: + logger.warning("[vector_memory] archive indexing failed (%s)", exc) + return 0 + + def collection_status(self, agent_id: str) -> dict: + """Report one persona's durable-vector collection without a JSON comparison.""" + name = collection_name(agent_id) + if not self._ok or not self._client.collection_exists(name): + return {"available": self._ok, "collection": name, "indexed": 0} + points, offset = [], None + while True: + batch, offset = self._client.scroll(collection_name=name, limit=256, offset=offset, + with_payload=True, with_vectors=False) + points.extend(batch) + if offset is None: + break + return {"available": True, "collection": name, "indexed": len(points)} + + def clear_all_memory(self) -> dict: + """Delete every non-empty Valhalla long-term-memory collection. + + A Qdrant cluster can be shared with unrelated applications, so the + operation identifies a Valhalla collection by its durable + ``source_hash`` payload marker before deleting it. It does not read or + mutate short-term JSON, checkpoints, plans, or live engine state. + Empty collections contain no long-term memory and are deliberately + retained rather than guessing ownership from their name. + """ + if not self._ok: + return {"available": False, "deleted_collections": [], "failed_collections": []} + + deleted: list[str] = [] + failed: list[str] = [] + try: + collections = getattr(self._client.get_collections(), "collections", []) + except Exception as exc: + logger.warning("[vector_memory] could not list collections for memory clear (%s)", exc) + return {"available": True, "deleted_collections": [], "failed_collections": [""]} + + for collection in collections: + name = getattr(collection, "name", str(collection)) + try: + points, _ = self._client.scroll( + collection_name=name, limit=1, with_payload=True, with_vectors=False, + ) + is_valhalla_memory = any( + isinstance(getattr(point, "payload", None), dict) + and point.payload.get("source_hash") + for point in points + ) + if not is_valhalla_memory: + continue + self._client.delete_collection(collection_name=name) + deleted.append(name) + except Exception as exc: + logger.warning("[vector_memory] could not clear collection '%s' (%s)", name, exc) + failed.append(name) + return { + "available": True, + "deleted_collections": deleted, + "failed_collections": failed, + } + + def delete_agent_memory(self, agent_id: str) -> bool: + """Remove one agent's Qdrant collection without touching other agents.""" + name = collection_name(agent_id) + if not self._ok: + return False + try: + if self._client.collection_exists(name): + self._client.delete_collection(name) + return True + except Exception as exc: + logger.warning("[vector_memory] could not remove %s (%s)", name, exc) + return False + + @staticmethod + def _days_ago(date: str) -> int: + try: + return max(0, (datetime.now(timezone.utc).date() - datetime.fromisoformat(date).date()).days) + except (TypeError, ValueError): + return 0 + + @classmethod + def rank_scored_hits(cls, hits: Iterable[Any], k: int) -> list[tuple[float, Any]]: + """Score query relevance, then select diverse top memories. + + Relevance is deliberately query-specific and is not persisted as a + payload field. The returned score is 65% semantic similarity, 20% + durable importance, and 15% recency. + """ + hits = list(hits) + if not hits: + return [] + raw_scores = [float(getattr(hit, "score", 0.0)) for hit in hits] + lo, hi = min(raw_scores), max(raw_scores) + def semantic(score: float) -> float: + return 1.0 if hi == lo else (score - lo) / (hi - lo) + scored = [] + for hit in hits: + payload = getattr(hit, "payload", {}) or {} + importance = min(1.0, max(0.0, float(payload.get("importance", 0.0)) / 3.0)) + recency = math.exp(-cls._days_ago(str(payload.get("date", ""))) / _cfg.MEMORY_RECENCY_DECAY_DAYS) + score = .65 * semantic(float(getattr(hit, "score", 0.0))) + .20 * importance + .15 * recency + scored.append((score, hit)) + scored.sort(key=lambda pair: pair[0], reverse=True) + # Diversity: select at most one item from a day/kind pair before a + # second from the same pair. This avoids one conversation dominating. + selected, seen = [], set() + for score, hit in scored: + payload = getattr(hit, "payload", {}) or {} + key = (payload.get("date"), payload.get("kind")) + if key in seen and len(selected) < k: + continue + selected.append((score, hit)); seen.add(key) + if len(selected) == k: + return selected + for score, hit in scored: + if hit not in [selected_hit for _, selected_hit in selected]: + selected.append((score, hit)) + if len(selected) == k: + break + return selected + + @classmethod + def rank_hits(cls, hits: Iterable[Any], k: int) -> list[Any]: + """Compatibility helper returning only the ranked memory hits.""" + return [hit for _, hit in cls.rank_scored_hits(hits, k)] + + def retrieve(self, agent_id: str, query: str, k: int = 5) -> List[str]: + if not self._ok: + return [] + vector = self._embed(query, "RETRIEVAL_QUERY") + if vector is None: + return [] + try: + name = self._ensure_collection(agent_id) + limit = max(k, k * _cfg.MEMORY_RETRIEVAL_CANDIDATE_MULTIPLIER) + if hasattr(self._client, "query_points"): + result = self._client.query_points(collection_name=name, query=vector, limit=limit) + hits = result.points + else: # supports older qdrant-client releases + hits = self._client.search(collection_name=name, query_vector=vector, limit=limit) + ranked = self.rank_scored_hits(hits, k) + chosen = [hit for _, hit in ranked] + self._record_recall(name, chosen) + return [ + f"[{hit.payload.get('date', '')}] ({hit.payload.get('kind', '')}; relevance={score:.2f}) " + f"{hit.payload.get('text', '')}" + for score, hit in ranked + ] + except Exception as exc: + logger.warning("[vector_memory] search failed (%s); returning no RAG context", exc) + return [] + + def _record_recall(self, collection: str, hits: Iterable[Any]) -> None: + """Promote memories that were actually useful in a semantic recall.""" + for hit in hits: + payload = getattr(hit, "payload", {}) or {} + updated = dict(payload) + updated["recall_count"] = int(payload.get("recall_count", 0)) + 1 + updated["last_recalled_at"] = datetime.now(timezone.utc).date().isoformat() + updated["retention_score"] = self._payload_retention_score(updated) + try: + self._client.set_payload(collection_name=collection, payload=updated, points=[int(hit.id)], wait=False) + except Exception as exc: + logger.debug("[vector_memory] recall-retention update failed (%s)", exc) + + def store(self, agent_id: str, text: str, *, kind: str = "observation", importance=None, date_str=None) -> None: + """Store an explicit durable memory immediately in Qdrant.""" + date_str = date_str or datetime.now(timezone.utc).date().isoformat() + memory_kind = kind if kind in _IMPORTANCE else "observation" + self.index_records(agent_id, [MemoryRecord( + agent_id, date_str, memory_kind, text, + float(importance if importance is not None else _IMPORTANCE[memory_kind]), + )]) + + def rolling_summary(self, agent_id: str, days: int = 3, before_date=None) -> Optional[str]: + if not self._ok: + return None + try: + name = collection_name(agent_id) + if not self._client.collection_exists(name): + return None + points, offset = [], None + while True: + batch, offset = self._client.scroll(collection_name=name, limit=256, offset=offset, + with_payload=True, with_vectors=False) + points.extend(batch) + if offset is None: + break + cutoff = str(before_date or "9999-12-31") + summaries = [p.payload for p in points if (p.payload or {}).get("kind") == "summary" + and str((p.payload or {}).get("date", "")) < cutoff] + summaries.sort(key=lambda p: str(p.get("date", "")), reverse=True) + if not summaries: + return None + return "\n".join(f"- {p.get('date', '')}: {p.get('text', '')}" for p in reversed(summaries[:days])) + except Exception as exc: + logger.warning("[vector_memory] rolling-summary retrieval failed (%s)", exc) + return None diff --git a/backend/src/auth/__init__.py b/backend/src/auth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/src/auth/manager.py b/backend/src/auth/manager.py new file mode 100644 index 0000000000000000000000000000000000000000..e6a403caa2c60153779eb6b219919d9732e37bbd --- /dev/null +++ b/backend/src/auth/manager.py @@ -0,0 +1,116 @@ +""" +In-memory session-based authentication for Valhalla web admin. + +Admin credentials come from the ADMIN_CREDENTIALS env var as +email:password pairs separated by semicolons. Sessions are stored +in-memory (lost on server restart). +""" + +import os +import hashlib +import hmac +import secrets +import logging +from datetime import datetime, timedelta +from typing import Optional + +logger = logging.getLogger(__name__) + +SESSION_EXPIRY_HOURS = 24 + + +class AuthManager: + def __init__(self): + self._users: dict[str, dict] = {} + self._sessions: dict[str, dict] = {} + self._load_users() + + def _load_users(self): + from src import config as _cfg + + raw = _cfg.ADMIN_CREDENTIALS + if not raw: + logger.warning("[Auth] No ADMIN_CREDENTIALS configured โ€” login disabled") + return + for pair in raw.split(";"): + pair = pair.strip() + if ":" not in pair: + continue + email, password = pair.split(":", 1) + email = email.strip().lower() + password = password.strip() + if email and password: + self._users[email] = { + "name": email.split("@")[0], + "password_hash": self._hash_password(password), + } + logger.info("[Auth] Loaded admin user: %s", email) + + def _hash_password(self, password: str) -> str: + salt = os.urandom(32) + key = hashlib.scrypt( + password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=64 + ) + return salt.hex() + ":" + key.hex() + + def verify_password(self, password: str, stored_hash: str) -> bool: + try: + salt_hex, key_hex = stored_hash.split(":") + salt = bytes.fromhex(salt_hex) + stored_key = bytes.fromhex(key_hex) + computed = hashlib.scrypt( + password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=64 + ) + return hmac.compare_digest(computed, stored_key) + except (ValueError, AttributeError): + return False + + def authenticate(self, email: str, password: str) -> Optional[dict]: + """Verify credentials and return user dict, or None.""" + user = self._users.get(email.lower().strip()) + if not user: + return None + if not self.verify_password(password, user["password_hash"]): + return None + return {"email": email.lower().strip(), "name": user["name"], "role": "admin"} + + def create_session(self, email: str) -> str: + user = self._users.get(email.lower()) + if not user: + return "" + token = secrets.token_urlsafe(32) + self._sessions[token] = { + "email": email.lower(), + "name": user["name"], + "role": "admin", + "created_at": datetime.utcnow().isoformat(), + "expires_at": ( + datetime.utcnow() + timedelta(hours=SESSION_EXPIRY_HOURS) + ).isoformat(), + } + return token + + def validate_session(self, token: str) -> Optional[dict]: + if not token: + return None + session = self._sessions.get(token) + if not session: + return None + try: + expires = datetime.fromisoformat(session["expires_at"]) + except (ValueError, TypeError): + return None + if datetime.utcnow() > expires: + self._sessions.pop(token, None) + return None + return { + "email": session["email"], + "name": session["name"], + "role": session["role"], + } + + def revoke_session(self, token: str): + self._sessions.pop(token, None) + + +auth_manager = AuthManager() diff --git a/backend/src/auth/routes.py b/backend/src/auth/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..4dff540ebd98391f372b03f35a4dbc605f32772e --- /dev/null +++ b/backend/src/auth/routes.py @@ -0,0 +1,48 @@ +""" +Auth API routes โ€” login, logout, session check. +""" + +from fastapi import APIRouter, HTTPException, Header +from pydantic import BaseModel +from typing import Optional + +from src.auth.manager import auth_manager + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class LoginInput(BaseModel): + email: str + password: str + + +class LogoutInput(BaseModel): + token: str + + +@router.post("/login") +async def login(request: LoginInput): + """Verify credentials and return a session token.""" + user = auth_manager.authenticate(request.email, request.password) + if not user: + raise HTTPException(status_code=401, detail="Invalid email or password") + token = auth_manager.create_session(request.email) + return {"token": token, "user": user} + + +@router.post("/logout") +async def logout(request: LogoutInput): + """Revoke a session token.""" + auth_manager.revoke_session(request.token) + return {"ok": True} + + +@router.get("/me") +async def me(authorization: Optional[str] = Header(None)): + """Return the current user from a Bearer token, or 401.""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Not authenticated") + user = auth_manager.validate_session(authorization[7:]) + if not user: + raise HTTPException(status_code=401, detail="Invalid or expired session") + return user diff --git a/backend/src/config.py b/backend/src/config.py new file mode 100644 index 0000000000000000000000000000000000000000..2f648cb059ee43d9484c635e960dccaca780a87f --- /dev/null +++ b/backend/src/config.py @@ -0,0 +1,375 @@ +""" +Project-wide path configuration. +Resolves the project root, backend, frontend, data, and output directories +so all modules can reference consistent paths. +""" + +from pathlib import Path +import os + +try: + from dotenv import load_dotenv +except ImportError: + def load_dotenv(path: str | Path) -> bool: + env_path = Path(path) + if not env_path.exists(): + return False + + loaded = False + for raw_line in env_path.read_text().splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + loaded = True + + return loaded + + +# DEBUG - +VERBOSE = True + +# backend/src/config.py -> backend/src -> backend -> Valhalla (project root) +BASE_DIR = Path(__file__).resolve().parents[2] + +# Load the project-local .env so environment variables are available before +# any config values are read. +load_dotenv(BASE_DIR / ".env") + +BACKEND_DIR = BASE_DIR / "backend" +DATA_DIR = BACKEND_DIR / "data" + +ENVIRONMENT_DIR = DATA_DIR / "environment" +PERSONALITIES_DIR = DATA_DIR / "personalities" + +OUTPUT_DIR = BACKEND_DIR / "output" + +FRONTEND_DIR = BASE_DIR / "frontend" + +PLACES_FILE = ENVIRONMENT_DIR / "places.json" + + +# Logging +LOG_DIR = OUTPUT_DIR / "logs" +LOG_LEVEL = "DEBUG" if VERBOSE else "INFO" + +### Core logic + +# --------------------------------------------------------------------------- +# Toggle helpers โ€” read a value from the environment (.env), with a default. +# Every simulation toggle below can be set in .env and overridden on the CLI +# (see apply_overrides() / add_cli_arguments() at the bottom of this file). +# --------------------------------------------------------------------------- + +def _env_bool(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None or raw.strip() == "": + return default + return raw.strip().lower() in ("1", "true", "yes", "on", "y") + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name) + try: + return int(raw) if raw not in (None, "") else default + except (TypeError, ValueError): + return default + + +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name) + try: + return float(raw) if raw not in (None, "") else default + except (TypeError, ValueError): + return default + + +def _env_str(name: str, default: str) -> str: + raw = os.environ.get(name) + return raw.strip() if raw not in (None, "") else default + + +# --------------------------------------------------------------------------- +# Spatial awareness +# --------------------------------------------------------------------------- + +# Legacy tile-based radius (chebyshev). Kept for the tile-grid perception path. +DEFAULT_PERCEPTION_RADIUS = _env_int("SIM_PERCEPTION_RADIUS_TILES", 5) + +# NEW: pixel radius for the proximity circle used for PERCEPTION (who an agent sees). +PERCEPTION_RADIUS_PX = _env_int("SIM_PERCEPTION_RADIUS_PX", 50) + +# Pixel radius for starting a CONVERSATION โ€” agents must be this close to talk +# (tighter than perception, so they see each other before they chat). +CONVERSATION_RADIUS_PX = _env_int("SIM_CONVERSATION_RADIUS_PX", 20) + +# Whether the perception step runs each tick (0-LLM either way). +PERCEPTION_ENABLED = _env_bool("SIM_PERCEPTION_ENABLED", True) + +# Run perception only every N ticks (1 = every tick). Higher = lighter CPU, less +# frequent "who's near me" updates. Perception is 0-LLM regardless. +PERCEPTION_EVERY_N_TICKS = max(1, _env_int("SIM_PERCEPTION_EVERY_N_TICKS", 3)) + +# Simulation date the run starts on (YYYY-MM-DD). Used to look up / store that +# day's plans and memory. Change this to run/limit a specific day. +SIM_START_DATE = _env_str("SIM_START_DATE", "2026-07-03") + +# Time of day the run starts at (HH:MM). 00:00 = midnight (agents asleep at their +# hostels); set e.g. 08:00 to begin during the day so movement is visible at once. +SIM_START_TIME = _env_str("SIM_START_TIME", "00:00") + +# --------------------------------------------------------------------------- +# Simulation clock +# SIM_MINUTES_PER_TICK = sim-minutes advanced per engine tick. +# REAL_SECONDS_PER_SIM_MINUTE = wall-clock seconds per 1 sim-minute. +# REAL_SECONDS_PER_TICK = derived (do NOT set manually). +# +# Default is 4.0 real-sec/sim-min => a full sim-day (1440 min) takes ~96 real +# minutes. Clock speed changes observation cadence, not the deterministic +# API-key traversal policy. +# --------------------------------------------------------------------------- +SIM_MINUTES_PER_TICK = _env_int("SIM_MINUTES_PER_TICK", 1) +REAL_SECONDS_PER_SIM_MINUTE = _env_float("SIM_REAL_SECONDS_PER_SIM_MINUTE", 4.0) +REAL_SECONDS_PER_TICK: float = SIM_MINUTES_PER_TICK * REAL_SECONDS_PER_SIM_MINUTE + +# Global speed multiplier (2.0 = twice as fast, 0.5 = half). Applied on top of +# the clock above by the engine's run loop. +TICK_SPEED = _env_float("SIM_TICK_SPEED", 1.0) + +# --------------------------------------------------------------------------- +# Cognition / LLM budget toggles +# --------------------------------------------------------------------------- + +# Reflex (react) LLM escalation. LOCKED OFF for the demo โ€” the brain's reflex +# path stays 0-LLM (heuristic "keep going"). Flip to true (SIM_REFLEX_LLM=true) +# to enable the whitelisted reflex escalations in the future. +REFLEX_LLM_ENABLED = _env_bool("SIM_REFLEX_LLM", False) + +# At most one reflex LLM escalation per agent per this many sim-minutes. +REFLEX_RATE_LIMIT_MINUTES = _env_int("SIM_REFLEX_RATE_LIMIT_MINUTES", 30) + +# Long-term memory is Qdrant-only. Short-term JSON is active-day operational +# state, never a long-term keyword-search fallback. +MEMORY_BACKEND = "vector" + +# Cloud Qdrant long-term-memory settings. Credentials are deliberately read +# only from the environment. When unavailable, recall is empty and archival +# keeps its short-term source rather than creating a local long-term archive. +SEMANTIC_MEMORY_ENABLED = _env_bool("SIM_SEMANTIC_MEMORY_ENABLED", True) +QDRANT_URL = _env_str("QDRANT_URL", "") +QDRANT_API_KEY = _env_str("QDRANT_API_KEY", "") +MEMORY_EMBEDDING_MODEL = _env_str("SIM_MEMORY_EMBEDDING_MODEL", "gemini-embedding-001") +MEMORY_VECTOR_DIMENSIONS = _env_int("SIM_MEMORY_VECTOR_DIMENSIONS", 768) +MEMORY_COLLECTION_VERSION = _env_str("SIM_MEMORY_COLLECTION_VERSION", "v1") +MEMORY_RETRIEVAL_CANDIDATE_MULTIPLIER = max(1, _env_int("SIM_MEMORY_RETRIEVAL_CANDIDATE_MULTIPLIER", 4)) +MEMORY_RECENCY_DECAY_DAYS = max(1.0, _env_float("SIM_MEMORY_RECENCY_DECAY_DAYS", 30.0)) +# Application-side quota guard for Cloud Qdrant. Qdrant Cloud reports actual +# plan usage in its dashboard; this estimate keeps the index below the chosen +# local budget before that dashboard limit is approached. +MEMORY_MAX_STORAGE_GB = max(0.1, _env_float("SIM_MEMORY_MAX_STORAGE_GB", 4.0)) +MEMORY_STORAGE_PRUNE_THRESHOLD = min(1.0, max(0.1, _env_float("SIM_MEMORY_STORAGE_PRUNE_THRESHOLD", 0.90))) +MEMORY_STORAGE_PRUNE_TARGET = min(MEMORY_STORAGE_PRUNE_THRESHOLD, max(0.05, _env_float("SIM_MEMORY_STORAGE_PRUNE_TARGET", 0.85))) + +# Cap on full day-plan regenerations triggered mid-day per agent (budget guard). +MAX_REPLANS_PER_AGENT_PER_DAY = _env_int("SIM_MAX_REPLANS_PER_AGENT_PER_DAY", 3) + +# Budget governor: soft ceiling on LLM calls per real hour across the whole sim. +# 0 = no ceiling. When exceeded, cognition degrades gracefully (skip reflex, +# defer replans) โ€” the sim keeps running on the 0-LLM executor path. +LLM_HOURLY_CEILING = _env_int("SIM_LLM_HOURLY_CEILING", 0) + +# Energy and emotion thresholds for LLM calls +DECIDE_MIN_ENERGY = _env_float("SIM_DECIDE_MIN_ENERGY", 0.1) +DECIDE_MIN_EMOTION = _env_float("SIM_DECIDE_MIN_EMOTION", 0.1) + +# Web login admin credentials (email:password pairs, semicolon-separated) +ADMIN_CREDENTIALS = _env_str("ADMIN_CREDENTIALS", "") +# An observation may change every tick while agents travel together. Limit +# each agent's advisory LLM reflex to avoid turning normal movement into an +# unbounded API stream; their action state machines still run every tick. +DECIDE_COOLDOWN_TICKS = _env_int("SIM_DECIDE_COOLDOWN_TICKS", 30) +CONVERSATION_MIN_ENERGY = _env_float("SIM_CONVERSATION_MIN_ENERGY", 0.05) +CONVERSATION_MIN_EMOTION = _env_float("SIM_CONVERSATION_MIN_EMOTION", 0.05) + +# LLM creativity profile. This single observer-facing setting controls how +# varied plans, decisions, and conversations may be while keeping memory +# summaries intentionally stable. 0.0 is conservative; 1.0 is lively. +SIM_CREATIVITY = min(1.0, max(0.0, _env_float("SIM_CREATIVITY", 1.0))) +# Controls the size of non-LLM wellbeing variation. This is deliberately +# independent from creativity: an observer can ask for more varied plans +# without making students' energy and mood unrealistically volatile. +SIM_WELLBEING_VARIABILITY = min(1.0, max(0.0, _env_float("SIM_WELLBEING_VARIABILITY", 0.75))) +TEMPERATURE = 0.7 + (0.4 * SIM_CREATIVITY) # planning and decisions: 1.1 at lively +CONVERSATION_TEMPERATURE = 0.6 + (0.4 * SIM_CREATIVITY) # 1.0 at lively +SUMMARY_TEMPERATURE = 0.5 +# The simulation intentionally uses one model. Key traversal, implemented in +# ``gemini_client``, is the only provider recovery behaviour. +GEMINI_MODEL = _env_str("SIM_GEMINI_MODEL", "gemini-3.1-flash-lite") + +# Support multiple API keys (comma-separated in env var). When numbered +# variables are used, they are read in ascending numeric order. +# ``gemini_client`` rebuilds its circular linked list from this ordered list +# for every API call, beginning at index 1 each time. +API_KEYS: list[str] = [ + k.strip() for k in os.environ.get("GEMINI_API_KEYS", "").split(",") if k.strip() +] +if not API_KEYS: + # Gather GEMINI_API_KEY (no suffix) + GEMINI_API_KEY_1 through _100 + # Only includes keys that are actually set (non-empty). + numbered_keys = [] + for index in range(1, 101): + key = os.environ.get(f"GEMINI_API_KEY_{index}", "") + if key: + numbered_keys.append(key) + API_KEYS = [key for key in [os.environ.get("GEMINI_API_KEY", ""), *numbered_keys] if key] + +# Drop obvious placeholder values before building the deterministic key ring. +_PLACEHOLDER_MARKERS = ("your_google_api_key", "your_api_key", "changeme", "xxxx") +API_KEYS = [ + k for k in API_KEYS + if not any(marker in k.lower() for marker in _PLACEHOLDER_MARKERS) +] +API_KEY_COUNT = len(API_KEYS) + + +# Conversation cap (per agent per day) +MAX_CONVERSATIONS_PER_AGENT = _env_int("SIM_MAX_CONVERSATIONS_PER_AGENT", 5) + +# At the calendar boundary, allow active conversation jobs this many real +# seconds to finish and persist before the engine cancels the remainder. +DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS = _env_float( + "SIM_DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS", 45.0, +) + +# day_planner.py CONFIG +MAX_PLAN_RETRIES = 3 +# What is expected in persona.json file +PERSONA_FIELD_GLOSSARY = { + "daily_plan_req": "A rough sketch of their typical day โ€” classes, work, recurring commitments.", + "innate": "Personality traits they were simply born with (natural disposition).", + "learned": "Skills/knowledge acquired since starting college (not innate).", + "lifestyle": "Habits and routines: sleep schedule, exercise, social patterns.", + "hobbies": "Free-time activities they actively enjoy.", + "goals": "Short- and long-term goals, academic and personal.", +} + + +# --------------------------------------------------------------------------- +# CLI + runtime override surface +# --------------------------------------------------------------------------- +# Every toggle above has a default (hard-coded) that can be overridden by an +# environment variable (.env), which can in turn be overridden by a CLI flag. +# Precedence: CLI flag > .env > built-in default. + + +# Maps CLI/override keyword -> config global name it sets. +_OVERRIDE_MAP = { + "tick_speed": "TICK_SPEED", + "real_seconds_per_sim_minute": "REAL_SECONDS_PER_SIM_MINUTE", + "sim_minutes_per_tick": "SIM_MINUTES_PER_TICK", + "perception_radius_px": "PERCEPTION_RADIUS_PX", + "perception_enabled": "PERCEPTION_ENABLED", + "max_conversations_per_agent": "MAX_CONVERSATIONS_PER_AGENT", + "max_replans_per_agent_per_day": "MAX_REPLANS_PER_AGENT_PER_DAY", + "llm_hourly_ceiling": "LLM_HOURLY_CEILING", + "decide_min_energy": "DECIDE_MIN_ENERGY", + "decide_min_emotion": "DECIDE_MIN_EMOTION", + "decide_cooldown_ticks": "DECIDE_COOLDOWN_TICKS", + "conversation_min_energy": "CONVERSATION_MIN_ENERGY", + "conversation_min_emotion": "CONVERSATION_MIN_EMOTION", + "day_handoff_conversation_timeout_seconds": "DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS", +} + + +def apply_overrides(**overrides) -> None: + """Apply runtime overrides (typically parsed CLI args) onto the module + globals. Only non-None values are applied. Recomputes derived constants.""" + global REAL_SECONDS_PER_TICK + for key, value in overrides.items(): + if value is None: + continue + target = _OVERRIDE_MAP.get(key) + if target is None: + continue + globals()[target] = value + # Recompute derived clock constant. + REAL_SECONDS_PER_TICK = SIM_MINUTES_PER_TICK * REAL_SECONDS_PER_SIM_MINUTE + + +def add_cli_arguments(parser) -> None: + """Register the toggle flags on an argparse parser. Defaults are None so + that an unspecified flag leaves the .env/built-in value untouched.""" + g = parser.add_argument_group("simulation toggles (override .env)") + g.add_argument("--tick-speed", dest="tick_speed", type=float, default=None, + help="Speed multiplier (2.0 = 2x faster). Default 1.0") + g.add_argument("--real-seconds-per-sim-minute", dest="real_seconds_per_sim_minute", + type=float, default=None, + help="Wall-clock seconds per sim-minute (7.5 => 180 real-min/day)") + g.add_argument("--sim-minutes-per-tick", dest="sim_minutes_per_tick", type=int, + default=None, help="Sim-minutes advanced per tick (default 1)") + g.add_argument("--perception-radius-px", dest="perception_radius_px", type=int, + default=None, help="Proximity circle radius in pixels (default 50)") + g.add_argument("--perception", dest="perception_enabled", type=_str2bool, default=None, + help="Run the perception step each tick (default: on)") + g.add_argument("--max-conversations", dest="max_conversations_per_agent", type=int, + default=None, help="Max conversations per agent per day (default 5)") + g.add_argument("--max-replans", dest="max_replans_per_agent_per_day", type=int, + default=None, help="Max mid-day full replans per agent (default 3)") + g.add_argument("--llm-hourly-ceiling", dest="llm_hourly_ceiling", type=int, + default=None, help="Soft LLM calls/real-hour ceiling (0 = none)") + g.add_argument("--decide-min-energy", dest="decide_min_energy", type=float, + default=None, help="Min energy for LLM decide (default 0.1)") + g.add_argument("--decide-min-emotion", dest="decide_min_emotion", type=float, + default=None, help="Min emotion for LLM decide (default 0.1)") + g.add_argument("--conv-min-energy", dest="conversation_min_energy", type=float, + default=None, help="Min energy for conversation (default 0.05)") + g.add_argument("--conv-min-emotion", dest="conversation_min_emotion", type=float, + default=None, help="Min emotion for conversation (default 0.05)") + g.add_argument("--day-handoff-conversation-timeout", dest="day_handoff_conversation_timeout_seconds", type=float, + default=None, help="Real seconds to wait for conversations at day rollover (default 45)") + + +def _str2bool(value: str) -> bool: + return str(value).strip().lower() in ("1", "true", "yes", "on", "y") + + +def overrides_from_args(args) -> dict: + """Extract the override kwargs from a parsed argparse namespace.""" + return {key: getattr(args, key, None) for key in _OVERRIDE_MAP} + + +def describe_settings() -> str: + """Human-readable one-block summary of all active simulation toggles.""" + # sim-day = 1440 sim-min; real seconds = 1440 * REAL_SECONDS_PER_SIM_MINUTE / TICK_SPEED + day_real_min = (1440 * REAL_SECONDS_PER_SIM_MINUTE / max(TICK_SPEED, 1e-9)) / 60.0 + return ( + "Valhalla simulation settings\n" + f" API keys loaded : {API_KEY_COUNT} (head resets to index 1/call)\n" + f" Gemini model : {GEMINI_MODEL}\n" + f" Simulation creativity : {SIM_CREATIVITY:.2f} " + f"(plan/decision {TEMPERATURE:.2f}, conversation {CONVERSATION_TEMPERATURE:.2f}, summary {SUMMARY_TEMPERATURE:.2f})\n" + f" Wellbeing variation : {SIM_WELLBEING_VARIABILITY:.2f}\n" + f" Memory backend : {MEMORY_BACKEND}\n" + f" Semantic memory : {'ON' if SEMANTIC_MEMORY_ENABLED else 'OFF'}\n" + f" Perception : {'ON' if PERCEPTION_ENABLED else 'OFF'} (radius {PERCEPTION_RADIUS_PX}px)\n" + f" Sim clock : {SIM_MINUTES_PER_TICK} sim-min/tick, " + f"{REAL_SECONDS_PER_SIM_MINUTE}s/sim-min, speed x{TICK_SPEED}\n" + f" => real sec/tick : {REAL_SECONDS_PER_TICK / max(TICK_SPEED,1e-9):.2f}s\n" + f" => real min per sim-day : ~{day_real_min:.0f} min\n" + f" Max conversations/agent/day : {MAX_CONVERSATIONS_PER_AGENT}\n" + f" Max replans/agent/day : {MAX_REPLANS_PER_AGENT_PER_DAY}\n" + f" LLM hourly ceiling : {LLM_HOURLY_CEILING or 'none'}\n" + f" Decide min energy/emotion : {DECIDE_MIN_ENERGY} / {DECIDE_MIN_EMOTION}\n" + f" Conv min energy/emotion : {CONVERSATION_MIN_ENERGY} / {CONVERSATION_MIN_EMOTION}\n" + f" Day-handoff conversation wait: {DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS:.0f}s\n" + ) + + +if __name__ == '__main__': + print(f"Running this project from : {BASE_DIR}") + print(describe_settings()) diff --git a/backend/src/core/agent_registry.py b/backend/src/core/agent_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..64e3e416cf4c8827408a7238aeff15730c20613e --- /dev/null +++ b/backend/src/core/agent_registry.py @@ -0,0 +1,76 @@ +""" +Agent Registry โ€” single source of truth for every agent's runtime state. + +The WorldEngine owns one `AgentRegistry` instance. All modules (Actions, +conversation, day_planner, Short_term) read from and write to it through +the engine โ€” never directly. + +This replaces the dual-source problem where Actions.py had its own +position/action and WorldState had a separate copy that drifted. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel + +from src.core.world_state import Position + + +class AgentRuntimeState(BaseModel): + """Everything the engine needs to know about one agent at runtime.""" + + agent_id: str + persona: Dict[str, Any] + persona_name: str # display name, e.g. "Parv Singla" + manager: Optional[Any] = None # AgentActionManager instance (set after init) + position: Position + current_action: Optional[Dict[str, Any]] = None # serialised ActionState or None + paused: bool = False # True while in conversation or other blocking state + day_plan: list = [] # current day plan actions + day_archived: bool = False # True when the current sim day has been archived + conversation_start_tick: int = 0 # tick when the active conversation started (0 = not in one) + conversation_count: int = 0 # how many conversations today + replan_count: int = 0 # mid-day full replans today (budget cap) + last_conversation_partner: Optional[str] = None # agent_id of most recent chat partner + active_conversation: Optional[dict] = None # {"partner_name": str, "partner_id": str, "messages": list[dict]} + emotion_state: float = 0.5 # [0, 1], starts neutral + emotion_baseline: float = 0.5 # personality-derived normal mood; used for gentle recovery + energy_level: float = 1.0 # [0, 1], starts full + color: str = "#888888" # frontend color (set by server on init) + + +class AgentRegistry: + """Global runtime registry for all agents.""" + + def __init__(self) -> None: + self._agents: Dict[str, AgentRuntimeState] = {} + + def register(self, state: AgentRuntimeState) -> None: + self._agents[state.agent_id] = state + + def remove(self, agent_id: str) -> AgentRuntimeState: + """Remove and return a runtime record for a roster edit while stopped.""" + state = self._agents.pop(agent_id, None) + if state is None: + raise KeyError(f"Unknown agent '{agent_id}'") + return state + + def get(self, agent_id: str) -> AgentRuntimeState: + state = self._agents.get(agent_id) + if state is None: + raise KeyError(f"Unknown agent '{agent_id}'") + return state + + def all_ids(self) -> list[str]: + return list(self._agents.keys()) + + def all_states(self) -> list[AgentRuntimeState]: + return list(self._agents.values()) + + def __contains__(self, agent_id: str) -> bool: + return agent_id in self._agents + + def __len__(self) -> int: + return len(self._agents) diff --git a/backend/src/core/budget.py b/backend/src/core/budget.py new file mode 100644 index 0000000000000000000000000000000000000000..7356b2ebb8c64df75415165edc6283d87c1df6a5 --- /dev/null +++ b/backend/src/core/budget.py @@ -0,0 +1,120 @@ +""" +LLM budget governor -- one place that knows how much LLM spend has happened +recently and whether the simulation can afford more. + +Why this exists +--------------- +Free-tier Gemini keys are scarce (a handful of keys, a few requests/minute +each). Turning on perception + proximity conversations + a decision-making +brain could, if left ungated, burn the whole quota in minutes. Every +cognitive call site (day planner, conversation, reflex escalation) asks the +governor `can_afford()` before spending, and calls `record()` after. When the +soft ceiling is exceeded the governor says "no", and the caller degrades +gracefully -- the simulation keeps running on its 0-LLM executor path. + +The governor is intentionally simple: a rolling one-real-hour window of call +timestamps, plus lifetime counters for observability (used by the budget +stress test and the on-screen/logged stats). +""" + +from __future__ import annotations + +import threading +import time +from collections import deque +from typing import Deque, Dict + +from src.core.log import get_logger +from src import config as _cfg + +logger = get_logger(__name__) + +_HOUR_SECONDS = 3600.0 + + +class BudgetGovernor: + """Thread-safe rolling-window LLM call accountant.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._recent: Deque[float] = deque() # monotonic timestamps, last hour + self._total_calls = 0 + self._by_kind: Dict[str, int] = {} + self._by_model: Dict[str, int] = {} + self._denied = 0 + + def _prune(self, now: float) -> None: + cutoff = now - _HOUR_SECONDS + while self._recent and self._recent[0] < cutoff: + self._recent.popleft() + + def calls_last_hour(self) -> int: + with self._lock: + self._prune(time.monotonic()) + return len(self._recent) + + def can_afford(self, kind: str = "generic", cost: int = 1) -> bool: + """True if a call of `kind` (costing `cost` LLM calls) is within the + configured hourly ceiling. A ceiling of 0 means 'no limit'.""" + ceiling = _cfg.LLM_HOURLY_CEILING + if not ceiling or ceiling <= 0: + return True + with self._lock: + self._prune(time.monotonic()) + allowed = len(self._recent) + cost <= ceiling + if not allowed: + with self._lock: + self._denied += 1 + logger.info( + "[Budget] denied '%s' (cost=%d): %d/%d calls used this hour", + kind, cost, self.calls_last_hour(), ceiling, + ) + return allowed + + def record(self, kind: str = "generic", model: str = "unknown") -> None: + """Record one successful LLM call.""" + now = time.monotonic() + with self._lock: + self._recent.append(now) + self._prune(now) + self._total_calls += 1 + self._by_kind[kind] = self._by_kind.get(kind, 0) + 1 + self._by_model[model] = self._by_model.get(model, 0) + 1 + + def stats(self) -> Dict[str, object]: + with self._lock: + self._prune(time.monotonic()) + return { + "total_calls": self._total_calls, + "calls_last_hour": len(self._recent), + "hourly_ceiling": _cfg.LLM_HOURLY_CEILING or None, + "denied": self._denied, + "by_kind": dict(self._by_kind), + "by_model": dict(self._by_model), + } + + def reset(self) -> None: + with self._lock: + self._recent.clear() + self._total_calls = 0 + self._by_kind.clear() + self._by_model.clear() + self._denied = 0 + + +# Process-wide singleton. +GOVERNOR = BudgetGovernor() + + +if __name__ == "__main__": + from src.core.log import setup_logging + setup_logging(run_id="budget_test", console=True) + + g = BudgetGovernor() + assert g.can_afford("plan") + for _ in range(5): + g.record("plan", "gemini-3.1-flash-lite") + assert g.calls_last_hour() == 5 + s = g.stats() + assert s["total_calls"] == 5 and s["by_kind"]["plan"] == 5 + print("budget.py sanity check passed:", s) diff --git a/backend/src/core/checkpoint_manager.py b/backend/src/core/checkpoint_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..30f85576cd6617b1938f6e466e93667c76c44272 --- /dev/null +++ b/backend/src/core/checkpoint_manager.py @@ -0,0 +1,325 @@ +""" +Checkpoint Manager โ€” per-tick state save/load for crash recovery. + +Saves WorldState + AgentRegistry after every tick to compressed +``backend/data/checkpoints/tick_{00001}.json.gz`` files. + +Supports: + - Save: full simulation state as JSON + - Load: reconstruct from any saved tick + - List: available checkpoint ticks + - Prune: auto-delete old checkpoints, keep last N +""" + +from __future__ import annotations + +import json +import gzip +import random +import time as _time +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from src.core.log import get_logger +from src.core.world_state import WorldState, Position +from src.core.agent_registry import AgentRegistry, AgentRuntimeState +from src.agents.Actions import AgentActionManager, ActionState, ActionType, LocationResolver +from src.config import DATA_DIR + +logger = get_logger(__name__) + +CHECKPOINT_DIR = DATA_DIR / "checkpoints" +HISTORY_DIR = DATA_DIR / "history" +# One full simulated day of per-tick snapshots gives observers a complete +# rewind window without retaining prior days indefinitely. +KEEP_LAST = 1440 +SCHEMA_VERSION = 2 +CHECKPOINT_SUFFIX = ".json.gz" +LEGACY_CHECKPOINT_SUFFIX = ".json" + + +def _tick_filename(tick: int) -> str: + return f"tick_{tick:05d}{CHECKPOINT_SUFFIX}" + + +def _legacy_tick_filename(tick: int) -> str: + return f"tick_{tick:05d}{LEGACY_CHECKPOINT_SUFFIX}" + + +def _parse_tick_from_name(name: str) -> int: + if name.endswith(CHECKPOINT_SUFFIX): + stem = name[: -len(CHECKPOINT_SUFFIX)] + elif name.endswith(LEGACY_CHECKPOINT_SUFFIX): + stem = name[: -len(LEGACY_CHECKPOINT_SUFFIX)] + else: + raise ValueError(f"not a checkpoint filename: {name}") + return int(stem.removeprefix("tick_")) + + +def _checkpoint_path(tick: int) -> Path: + """Prefer compressed checkpoints while retaining read compatibility.""" + compressed = CHECKPOINT_DIR / _tick_filename(tick) + if compressed.exists(): + return compressed + return CHECKPOINT_DIR / _legacy_tick_filename(tick) + + +def _checkpoint_paths(tick: int) -> tuple[Path, Path]: + return ( + CHECKPOINT_DIR / _tick_filename(tick), + CHECKPOINT_DIR / _legacy_tick_filename(tick), + ) + + +# ------------------------------------------------------------------ # +# Save +# ------------------------------------------------------------------ # + +def _manager_to_dict(manager: AgentActionManager) -> Dict[str, Any]: + """Serialize an AgentActionManager's internal state.""" + def _action_dict(a: Optional[ActionState]) -> Optional[Dict[str, Any]]: + if a is None: + return None + # ActionState contains more than the display fields. In particular, + # its resolved destination and energy/emotion deltas affect subsequent + # movement and behaviour, so preserve the entire serializable model. + return a.model_dump(mode="json") + + return { + "day_plan": manager.day_plan, + "last_action": _action_dict(manager.last_action), + "current_action": _action_dict(manager.current_action), + "next_action": _action_dict(manager.next_action), + "_conversation_mode": manager._conversation_mode, + "_pending_plan_action": _action_dict(manager._pending_plan_action), + "_entered_last_action": manager._entered_last_action, + } + + +def _agent_to_dict(state: AgentRuntimeState) -> Dict[str, Any]: + """Serialize one agent's runtime state.""" + return { + "agent_id": state.agent_id, + "persona": state.persona, + "persona_name": state.persona_name, + "position": state.position.model_dump(), + "paused": state.paused, + "day_plan": state.day_plan, + "day_archived": state.day_archived, + "conversation_start_tick": state.conversation_start_tick, + "conversation_count": state.conversation_count, + "replan_count": state.replan_count, + "last_conversation_partner": state.last_conversation_partner, + "active_conversation": state.active_conversation, + "emotion_state": state.emotion_state, + "emotion_baseline": state.emotion_baseline, + "energy_level": state.energy_level, + "color": state.color, + "manager": _manager_to_dict(state.manager) if state.manager else None, + } + + +def save_checkpoint( + world: WorldState, + registry: AgentRegistry, + tick: int, + engine_state: Optional[Dict[str, Any]] = None, +) -> Optional[Path]: + """Save current simulation state as an atomically replaced gzip JSON file.""" + CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True) + path = CHECKPOINT_DIR / _tick_filename(tick) + + agents_data = [_agent_to_dict(s) for s in registry.all_states()] + + # History is not needed to advance an action, but it is part of the + # observable world and is required for a faithful replay after restore. + world_dict = json.loads(world.model_dump_json()) + + payload = { + "schema_version": SCHEMA_VERSION, + "tick": tick, + "world": world_dict, + "agents": agents_data, + "engine": engine_state or {}, + # The simulation uses Python's process-wide RNG for collision nudges + # and interior placement. JSON converts tuples to lists, which are + # converted back on load before passing the state to random.setstate. + "random_state": random.getstate(), + "saved_at": _time.strftime("%Y-%m-%d %H:%M:%S"), + } + + tmp = path.with_suffix(path.suffix + ".tmp") + with gzip.open(tmp, "wt", encoding="utf-8") as handle: + handle.write(json.dumps(payload, separators=(",", ":"), default=str)) + tmp.replace(path) + + return path + + +# ------------------------------------------------------------------ # +# History +# ------------------------------------------------------------------ # + +def save_history(world: WorldState, date: str) -> Optional[Path]: + """Save per-day action history to ``data/history/_history.json``.""" + HISTORY_DIR.mkdir(parents=True, exist_ok=True) + path = HISTORY_DIR / f"{date}_history.json" + history_data = [entry.model_dump() for entry in world.history] + path.write_text(json.dumps(history_data, indent=2, default=str)) + logger.info("[Checkpoint] saved history for %s (%d entries)", date, len(history_data)) + return path + + +# ------------------------------------------------------------------ # +# Load +# ------------------------------------------------------------------ # + +def _action_from_dict(d: Optional[Dict[str, Any]]) -> Optional[ActionState]: + """Reconstruct an ActionState from a saved dict.""" + if d is None: + return None + # model_validate also remains compatible with version-1 checkpoints, + # whose omitted ActionState fields have model defaults. + return ActionState.model_validate(d) + + +def _lists_to_tuples(value: Any) -> Any: + """Restore the tuple structure required by random.setstate().""" + if isinstance(value, list): + return tuple(_lists_to_tuples(item) for item in value) + return value + + +def _restore_manager( + data: Dict[str, Any], + resolver: LocationResolver, +) -> AgentActionManager: + """Reconstruct an AgentActionManager from saved state dict.""" + position = Position(**data["position"]) + manager = AgentActionManager( + agent_id=data["agent_id"], + day_plan=data.get("day_plan", data["manager"]["day_plan"]) if data.get("manager") else [], + initial_position=position, + resolver=resolver, + ) + + if data.get("manager"): + m = data["manager"] + manager.last_action = _action_from_dict(m.get("last_action")) + manager.current_action = _action_from_dict(m.get("current_action")) + manager.next_action = _action_from_dict(m.get("next_action")) + manager._conversation_mode = m.get("_conversation_mode", False) + manager._pending_plan_action = _action_from_dict(m.get("_pending_plan_action")) + manager._entered_last_action = m.get("_entered_last_action", False) + + return manager + + +def load_checkpoint( + tick: int, + resolver: LocationResolver, + return_metadata: bool = False, +) -> Tuple[WorldState, AgentRegistry] | Tuple[WorldState, AgentRegistry, Dict[str, Any]]: + """Load simulation state from a checkpoint file.""" + path = _checkpoint_path(tick) + if not path.exists(): + raise FileNotFoundError(f"No checkpoint for tick {tick} at {path}") + + if path.suffix == ".gz": + with gzip.open(path, "rt", encoding="utf-8") as handle: + raw = json.load(handle) + else: + raw = json.loads(path.read_text(encoding="utf-8")) + + if raw.get("random_state") is not None: + random.setstate(_lists_to_tuples(raw["random_state"])) + + world = WorldState.model_validate(raw["world"]) + registry = AgentRegistry() + + for agent_data in raw["agents"]: + position = Position(**agent_data["position"]) + manager = _restore_manager(agent_data, resolver) + + state = AgentRuntimeState( + agent_id=agent_data["agent_id"], + persona=agent_data["persona"], + persona_name=agent_data["persona_name"], + manager=manager, + position=position, + paused=agent_data["paused"], + day_plan=agent_data["day_plan"], + day_archived=agent_data["day_archived"], + conversation_start_tick=agent_data.get("conversation_start_tick", 0), + conversation_count=agent_data.get("conversation_count", 0), + replan_count=agent_data.get("replan_count", 0), + last_conversation_partner=agent_data.get("last_conversation_partner"), + active_conversation=agent_data.get("active_conversation"), + emotion_state=agent_data.get("emotion_state", 0.5), + emotion_baseline=agent_data.get("emotion_baseline", 0.5), + energy_level=agent_data.get("energy_level", 1.0), + color=agent_data.get("color", "#888888"), + ) + state.current_action = ( + manager.current_action.model_dump() + if manager.current_action else None + ) + registry.register(state) + + logger.info( + "[Checkpoint] loaded tick %d: %d agents, world.tick=%d", + tick, len(registry), world.tick, + ) + if return_metadata: + return world, registry, raw.get("engine", {}) + return world, registry + + +# ------------------------------------------------------------------ # +# List & Prune +# ------------------------------------------------------------------ # + +def list_checkpoints() -> List[int]: + """Return sorted list of available checkpoint tick numbers.""" + if not CHECKPOINT_DIR.exists(): + return [] + ticks = [] + for f in sorted(CHECKPOINT_DIR.iterdir()): + if f.is_file() and f.name.startswith("tick_") and ( + f.name.endswith(CHECKPOINT_SUFFIX) or f.name.endswith(LEGACY_CHECKPOINT_SUFFIX) + ): + try: + ticks.append(_parse_tick_from_name(f.name)) + except (ValueError, IndexError): + continue + return sorted(set(ticks)) + + +def prune_checkpoints(keep_last: int = KEEP_LAST) -> int: + """Remove all checkpoints except the ``keep_last`` most recent ones. + Returns the number of deleted files.""" + ticks = list_checkpoints() + if len(ticks) <= keep_last: + return 0 + + to_delete = ticks[:-keep_last] + count = 0 + for t in to_delete: + for path in _checkpoint_paths(t): + if not path.exists(): + continue + try: + path.unlink() + count += 1 + except OSError as e: + logger.warning("[Checkpoint] failed to delete %s: %s", path, e) + + if count: + logger.info("[Checkpoint] pruned %d old checkpoints, kept last %d", count, keep_last) + return count + + +def latest_tick() -> Optional[int]: + """Return the highest available tick number, or None.""" + ticks = list_checkpoints() + return ticks[-1] if ticks else None diff --git a/backend/src/core/core.txt b/backend/src/core/core.txt new file mode 100644 index 0000000000000000000000000000000000000000..f6acc73702505d449838f7256d212bd1e9e73e51 --- /dev/null +++ b/backend/src/core/core.txt @@ -0,0 +1,2 @@ +core/ +holds orchestration plumbing (the tick graph itself, shared state schemas, config) that doesn't do any "thinking" on its own \ No newline at end of file diff --git a/backend/src/core/log.py b/backend/src/core/log.py new file mode 100644 index 0000000000000000000000000000000000000000..bc1830163a257aacc737d6a46e3a90065b44492f --- /dev/null +++ b/backend/src/core/log.py @@ -0,0 +1,146 @@ +""" +Centralized logging setup for the whole project. Every module imports +`get_logger(__name__)` from here instead of calling `logging.basicConfig` +or `logging.getLogger` directly, so the format + destinations stay +identical everywhere + +Usage +----- +Call `setup_logging()` ONCE, as early as possible in the process (top of +whatever your real entrypoint is -- backend/main.py, or the `run()` / +`__main__` block of a standalone script): + + from src.core.log import setup_logging, get_logger + setup_logging(run_id="run_name") # run_id is optional + logger = get_logger(__name__) + +Every other module then just does: + + from src.core.log import get_logger + logger = get_logger(__name__) + +If some module gets imported/used before setup_logging() runs (import +order accidents happen), get_logger() will lazily call setup_logging() +with defaults so you still get sane output instead of silence. +""" + +from __future__ import annotations + +import logging +import logging.config +from datetime import datetime +from pathlib import Path +from typing import Optional + +# Path config lives in one place: src/config.py +from src.config import LOG_DIR, LOG_LEVEL as _DEFAULT_LEVEL_NAME + +DEFAULT_LEVEL = getattr(logging, _DEFAULT_LEVEL_NAME.upper(), logging.INFO) + +LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" +DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +_configured = False + + +def _build_log_path(run_id: Optional[str] = None) -> Path: + LOG_DIR.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{stamp}_{run_id}.log" if run_id else f"{stamp}.log" + return LOG_DIR / filename + + +def setup_logging( + level: int = DEFAULT_LEVEL, + run_id: Optional[str] = None, + console: bool = True, + file: bool = True, + max_bytes: int = 5 * 1024 * 1024, + backup_count: int = 5, +) -> Optional[Path]: + """ + Configures the ROOT logger once. Idempotent -- a second call is a no-op, + so accidentally calling it from more than one entrypoint (e.g. both a + test script and an imported module) will never duplicate handlers or + duplicate every log line. + + level -- root log level (module-specific loggers can still be + set louder/quieter individually if you ever need that) + run_id -- optional tag folded into the log filename, e.g. a + persona name or simulation id, so a run's logs are + easy to find in output/logs/ + console -- also stream logs to stdout + file -- also write logs to output/logs/[_].log + max_bytes / + backup_count -- rotation settings for the file handler + + Returns the Path of the log file created, or None if file=False. + """ + global _configured + if _configured: + return None + + handlers: dict = {} + log_path: Optional[Path] = None + + if console: + handlers["console"] = { + "class": "logging.StreamHandler", + "formatter": "standard", + "stream": "ext://sys.stdout", + "level": level, + } + + if file: + log_path = _build_log_path(run_id) + handlers["file"] = { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "standard", + "filename": str(log_path), + "maxBytes": max_bytes, + "backupCount": backup_count, + "encoding": "utf-8", + "level": level, + } + + config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": { + "format": LOG_FORMAT, + "datefmt": DATE_FORMAT, + }, + }, + "handlers": handlers, + "root": { + "level": level, + "handlers": list(handlers.keys()), + }, + # Quiet down noisy third-party loggers so your own log lines don't + # get buried -- add to this as you bring in more SDKs. + "loggers": { + "google_genai": {"level": "WARNING", "propagate": True}, + "httpx": {"level": "WARNING", "propagate": True}, + "httpcore": {"level": "WARNING", "propagate": True}, + }, + } + + logging.config.dictConfig(config) + _configured = True + + if log_path: + logging.getLogger(__name__).info("[log] logging initialized -> %s", log_path) + + return log_path + + +def get_logger(name: str) -> logging.Logger: + """ + Thin wrapper so call sites read `from src.core.log import get_logger` + instead of raw `logging.getLogger`. Lazily calls setup_logging() with + defaults if nothing has configured logging yet. + """ + if not _configured: + setup_logging() + return logging.getLogger(name) \ No newline at end of file diff --git a/backend/src/core/perceive.py b/backend/src/core/perceive.py new file mode 100644 index 0000000000000000000000000000000000000000..8d3e57257ab8f4ad65a5d5d6e0f182b05508d04e --- /dev/null +++ b/backend/src/core/perceive.py @@ -0,0 +1,154 @@ +""" +Perceive -- turns a WorldSnapshot into what one agent can currently observe. + +This is a pure function of snapshot data: no LLM calls, no side effects. +With only one agent registered (your current single-agent phase), this +naturally returns an empty list every tick -- no special-casing needed to +"turn on" perception later, it already does the real spatial query. + +Radius/distance logic itself lives on `WorldSnapshot` (core/snapshot.py) so +there's exactly one implementation of "who's nearby" in the codebase. (in snapshot.py) +""" + +from __future__ import annotations + +from typing import List, Optional + +from pydantic import BaseModel + +from src.core.log import get_logger +from src.core.snapshot import WorldSnapshot +from src.core.world_state import AgentState + +from src.config import DEFAULT_PERCEPTION_RADIUS + +logger = get_logger(__name__) + + +class Observation(BaseModel): + """One thing an agent noticed this tick.""" + tick: int + observer_id: str + subject_agent_id: str + description: str # human-readable, e.g. "Gurnoor is eating breakfast" + location_id: Optional[str] = None + distance: int = 0 # chebyshev tiles from observer; lower = more salient + + +def perceive( + snapshot: WorldSnapshot, + agent_id: str, + radius: int = DEFAULT_PERCEPTION_RADIUS, +) -> List[Observation]: + """ + Return everything `agent_id` can currently observe in `snapshot`, + nearest first. + + Called from tick_graph.py's perceive node with the tick's single shared + snapshot -- never call this once the underlying WorldState might have + moved on; always pass the snapshot the whole tick is using. + """ + if agent_id not in snapshot.agents: + raise KeyError(f"'{agent_id}' not present in this snapshot") + + me = snapshot.get_agent(agent_id) + nearby = snapshot.agents_near(agent_id, radius) + + observations: List[Observation] = [] + for other in nearby: + distance = max( + abs(other.position.x - me.position.x), + abs(other.position.y - me.position.y), + ) + observations.append( + Observation( + tick=snapshot.tick, + observer_id=agent_id, + subject_agent_id=other.agent_id, + description=_describe(other), + location_id=other.position.location_id, + distance=distance, + ) + ) + + observations.sort(key=lambda o: o.distance) + if observations: + logger.debug( + "Agent '%s' perceived %d nearby agent(s) at tick %d", + agent_id, len(observations), snapshot.tick, + ) + return observations + + +def perceive_px( + snapshot: WorldSnapshot, + agent_id: str, + radius_px: float, +) -> List[Observation]: + """ + Pixel-space (Euclidean circle) perception -- everything `agent_id` can see + within `radius_px` pixels, nearest first. This is the 0-LLM sensory input + behind the 50px proximity circle. Distance is rounded pixel distance. + """ + if agent_id not in snapshot.agents: + raise KeyError(f"'{agent_id}' not present in this snapshot") + + me = snapshot.get_agent(agent_id) + nearby = snapshot.agents_within_px(agent_id, radius_px) + + observations: List[Observation] = [] + for other in nearby: + dx = other.position.x - me.position.x + dy = other.position.y - me.position.y + dist = int(round((dx * dx + dy * dy) ** 0.5)) + observations.append( + Observation( + tick=snapshot.tick, + observer_id=agent_id, + subject_agent_id=other.agent_id, + description=_describe(other), + location_id=other.position.location_id, + distance=dist, + ) + ) + # agents_within_px already returns nearest-first; keep that order. + if observations: + logger.debug( + "Agent '%s' perceived %d agent(s) within %spx at tick %d", + agent_id, len(observations), radius_px, snapshot.tick, + ) + return observations + + +def _describe(agent: AgentState) -> str: + if agent.current_action is not None: + return f"{agent.agent_id} is {agent.current_action.description}" + return f"{agent.agent_id} is idle" + + +# Standalone sanity check +if __name__ == "__main__": + from src.core.world_state import CurrentAction, Position, WorldState + from src.core.snapshot import take_snapshot + + world = WorldState() + world.register_agent("a", Position(x=0, y=0, location_id="quad")) + world.register_agent("b", Position(x=2, y=1, location_id="quad")) + world.set_agent_action( + "b", CurrentAction(description="reading a book", start_tick=0, end_tick=30) + ) + + snap = take_snapshot(world) + + obs_a = perceive(snap, "a", radius=5) + + print(obs_a) + + assert len(obs_a) == 1 + assert obs_a[0].subject_agent_id == "b" + assert "reading a book" in obs_a[0].description + + obs_a_tight = perceive(snap, "a", radius=1) + assert len(obs_a_tight) == 0, "radius 1 should not see an agent at chebyshev distance 2" + + print("perceive.py sanity check passed.") \ No newline at end of file diff --git a/backend/src/core/runtime_health.py b/backend/src/core/runtime_health.py new file mode 100644 index 0000000000000000000000000000000000000000..26c56ca1ca28489831258132ab49923e9c06630e --- /dev/null +++ b/backend/src/core/runtime_health.py @@ -0,0 +1,116 @@ +"""Bounded runtime health checks for a live Valhalla simulation.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import Any, Dict, List, Tuple + +from src.agents.Actions import ActionType +from src.core.log import get_logger + +logger = get_logger(__name__) + + +@dataclass +class _TravelProgress: + position: Tuple[int, int] + stagnant_ticks: int = 0 + + +@dataclass +class RuntimeHealthMonitor: + """Checks the invariants most likely to make a campus sim look frozen. + + State is O(number of agents), reports are emitted at a bounded cadence, and + no complete tick history is retained. The latest report can be safely sent + in the frontend snapshot for a toggled developer panel. + """ + + report_every_ticks: int = 15 + stagnant_move_ticks: int = 5 + _travel: Dict[str, _TravelProgress] = field(default_factory=dict) + _last_report: Dict[str, Any] = field(default_factory=dict) + + def observe(self, engine: Any, tick: int, hhmm: str) -> Dict[str, Any]: + anomalies: List[Dict[str, str]] = [] + moving = paused = conversations = 0 + agent_ids = set(engine.registry.all_ids()) + for state in engine.registry.all_states(): + manager = state.manager + action = manager.current_action if manager else None + if state.paused: + paused += 1 + if state.active_conversation: + conversations += 1 + conversation = state.active_conversation + if conversation.get("status") == "generating" and tick - state.conversation_start_tick > 30: + anomalies.append({"agent_id": state.agent_id, "kind": "conversation_generation_timeout"}) + if not action: + self._travel.pop(state.agent_id, None) + continue + if manager and manager.position != state.position: + anomalies.append({"agent_id": state.agent_id, "kind": "manager_registry_position_desync"}) + # ``run_tick`` publishes its frame after advancing the world clock. + # A normal non-move action therefore must have been replaced by + # then. This catches stale BodyController/manager references after + # a rewind before the UI can silently show an agent sleeping past + # their scheduled wake-up time. + try: + start_minute = manager._hhmm_to_minutes(action.start_time) + end_minute = manager._hhmm_to_minutes(action.end_time) + day_start_tick = tick - (tick % (24 * 60)) + end_tick = day_start_tick + end_minute + if end_minute <= start_minute: + end_tick += 24 * 60 + terminal_frame_allowance = 1 if action.action_type == ActionType.MOVE else 0 + if tick > end_tick + terminal_frame_allowance: + anomalies.append({"agent_id": state.agent_id, "kind": "action_overdue"}) + except (AttributeError, TypeError, ValueError): + anomalies.append({"agent_id": state.agent_id, "kind": "invalid_action_time"}) + if action.action_type == ActionType.MOVE: + moving += 1 + current = (state.position.x, state.position.y) + previous = self._travel.get(state.agent_id) + stagnant = previous.stagnant_ticks + 1 if previous and previous.position == current else 0 + self._travel[state.agent_id] = _TravelProgress(current, stagnant) + if stagnant >= self.stagnant_move_ticks: + anomalies.append({"agent_id": state.agent_id, "kind": "travel_stalled"}) + else: + self._travel.pop(state.agent_id, None) + + world_agent = engine.world.agents.get(state.agent_id) + if world_agent and (world_agent.position.x, world_agent.position.y, world_agent.position.location_id) != ( + state.position.x, state.position.y, state.position.location_id, + ): + anomalies.append({"agent_id": state.agent_id, "kind": "registry_world_desync"}) + + for stale_id in list(self._travel): + if stale_id not in agent_ids: + self._travel.pop(stale_id, None) + try: + event_loop_tasks = len(asyncio.all_tasks()) + except RuntimeError: + # Unit tests and offline diagnostics can invoke health checks + # without an active asyncio loop. + event_loop_tasks = 0 + report = { + "tick": tick, "time": hhmm, "agents": len(agent_ids), "moving": moving, + "paused": paused, "conversations": conversations, + "background_tasks": len(engine._conversation_tasks) + len(engine._decision_tasks), + "background_decisions": len(engine._decision_tasks), + "event_loop_tasks": event_loop_tasks, + "anomalies": anomalies, + "healthy": not anomalies, + } + self._last_report = report + if anomalies or tick % self.report_every_ticks == 0: + level = logger.warning if anomalies else logger.info + level("[Health] tick=%d agents=%d moving=%d paused=%d conv=%d tasks=%d anomalies=%s", + tick, report["agents"], moving, paused, conversations, + report["event_loop_tasks"], anomalies or "none") + return report + + @property + def latest(self) -> Dict[str, Any]: + return dict(self._last_report) diff --git a/backend/src/core/snapshot.py b/backend/src/core/snapshot.py new file mode 100644 index 0000000000000000000000000000000000000000..86adc8080975e3082e3cd00846952369127d6c03 --- /dev/null +++ b/backend/src/core/snapshot.py @@ -0,0 +1,208 @@ +""" +Snapshot -- point-in-time read-only view of WorldState (Immutable) + +Every tick takes exactly one `WorldSnapshot` and hands the +*same* frozen object to every agent's tick graph via asyncio.gather(). That's +what makes parallel agent decisions safe -- nobody is reading a WorldState +that's being mutated mid-tick by someone else's action. + +`WorldSnapshot` is a deliberately separate class from `WorldState`, not just +a deep copy of it. It exposes zero mutating methods, so there is no method +an agent's perceive/react/plan code could accidentally call that would +corrupt the resolve phase's assumptions. If you need a new read-only query +(e.g. "what's the nearest free table"), add it here as a method on +`WorldSnapshot` -- don't reach into `.agents`/`.occupancy` directly from +perceive.py and reimplement the same query logic in multiple places. + +Usage +----- + from src.core.world_state import WorldState, Position + from src.core.snapshot import take_snapshot + + world = WorldState() + world.register_agent("gurnoor", Position(x=4, y=2, location_id="dorm_room_1")) + + snap = take_snapshot(world) # take ONCE per tick, before decide phase + snap.get_agent("gurnoor") # read-only query + snap.agents_near("gurnoor", radius=3) + snap.is_free("cafeteria_table_3") + + # snap.tick = 999 <- raises, frozen model + # snap.agents["x"] = ... <- raises, frozen model +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from pydantic import BaseModel, ConfigDict + +from src.core.log import get_logger +from src.core.world_state import AgentState, WorldState, Position + +logger = get_logger(__name__) + + +class WorldSnapshot(BaseModel): + """ + Frozen copy of WorldState taken at the start of a tick's decide phase. + + Note on depth of immutability: the top-level model is frozen (you can't + reassign `snap.tick`, `snap.agents`, etc.), and every `AgentState` inside + `agents` is a deep copy made at snapshot time -- so even if something + reaches in and mutates `snap.agents["x"].position`, that mutation is + inert: it's a throwaway copy, isolated from the real WorldState and from + every other agent's snapshot reference. It cannot leak into the resolve + phase. Don't rely on that as a substitute for writing correct code, but + it does mean a stray mutation is a bug in one agent's own reasoning, not + a simulation-wide correctness issue. + """ + model_config = ConfigDict(frozen=True) + + tick: int + agents: Dict[str, AgentState] + occupancy: Dict[str, Optional[str]] + + # Read-only queries + + def get_agent(self, agent_id: str) -> AgentState: + if agent_id not in self.agents: + raise KeyError(f"Unknown agent_id '{agent_id}' in snapshot") + return self.agents[agent_id] + + def all_agent_ids(self) -> List[str]: + return list(self.agents.keys()) + + def other_agents(self, agent_id: str) -> List[AgentState]: + """Every agent except `agent_id`. Convenience for perceive.py.""" + return [a for a in self.agents.values() if a.agent_id != agent_id] + + def agents_near(self, agent_id: str, radius: int) -> List[AgentState]: + """ + Chebyshev-distance ("square") radius query -- i.e. everyone within + a (radius*2+1) x (radius*2+1) tile box centered on `agent_id`. + perceive.py should not reimplement this math, just call this method. + """ + ### NOTE : Can swap the distance metric to squared euclidian distance. + me = self.get_agent(agent_id) + nearby = [] + for other in self.other_agents(agent_id): + dx = abs(other.position.x - me.position.x) + dy = abs(other.position.y - me.position.y) + if max(dx, dy) <= radius: + nearby.append(other) + return nearby + + def agents_within_px(self, agent_id: str, radius_px: float) -> List[AgentState]: + """ + Euclidean (circle) proximity query in **pixel** space -- everyone whose + pixel position is within `radius_px` of `agent_id`, nearest first. + + This is the query behind the 50px proximity circle used for perception + and proximity-based conversation triggering. Agent positions are pixel + coordinates, so this is a true straight-line distance. + """ + me = self.get_agent(agent_id) + r2 = float(radius_px) * float(radius_px) + scored = [] + for other in self.other_agents(agent_id): + dx = other.position.x - me.position.x + dy = other.position.y - me.position.y + d2 = dx * dx + dy * dy + if d2 <= r2: + scored.append((d2, other)) + scored.sort(key=lambda t: t[0]) + return [o for _d2, o in scored] + + def distance_px(self, agent_a: str, agent_b: str) -> float: + """Straight-line pixel distance between two agents.""" + a = self.get_agent(agent_a) + b = self.get_agent(agent_b) + dx = a.position.x - b.position.x + dy = a.position.y - b.position.y + return (dx * dx + dy * dy) ** 0.5 + + def crowd_at(self, location_id: str) -> int: + """How many agents currently share a named location (crowdedness).""" + return len(self.agents_at_location(location_id)) + + def agents_at_location(self, location_id: str) -> List[AgentState]: + """Everyone currently standing in a named semantic zone.""" + return [a for a in self.agents.values() if a.position.location_id == location_id] + + def is_free(self, resource_id: str) -> bool: + if resource_id not in self.occupancy: + logger.warning("Resource '%s' was never registered.", resource_id) + return self.occupancy.get(resource_id) is None + + def holder_of(self, resource_id: str) -> Optional[str]: + return self.occupancy.get(resource_id) + + def free_resources(self) -> List[str]: + return [rid for rid, holder in self.occupancy.items() if holder is None] + + +def take_snapshot(world: WorldState) -> WorldSnapshot: + """ + Build an immutable snapshot from the live WorldState. + + Call this exactly once per tick, at the very start of the decide phase, + before any agent tick graph runs -- then pass the *same* `WorldSnapshot` + instance to every agent invoked via asyncio.gather() this tick. Do not + call this once per agent; that would let a slow tick graph observe a + world one tick "younger" than a fast one that started later in the same + gather() batch, which reintroduces the exact ordering bug lockstep is + meant to eliminate. + """ + agents_copy = { + agent_id: agent_state.model_copy(deep=True) + for agent_id, agent_state in world.agents.items() + } + occupancy_copy = dict(world.occupancy) # values are str | None, shallow copy is sufficient + + snapshot = WorldSnapshot( + tick=world.tick, + agents=agents_copy, + occupancy=occupancy_copy, + ) + logger.debug( + "Snapshot taken at tick=%d (%d agents, %d resources)", + world.tick, len(agents_copy), len(occupancy_copy), + ) + return snapshot + + +# Standalone sanity check -- matches the "Done when" check from the build plan: +# instantiate WorldState with one dummy agent, take a snapshot, mutate the +# original, confirm the snapshot didn't change. + +if __name__ == "__main__": + from src.core.world_state import CurrentAction + + world = WorldState() + world.register_agent("test_agent", Position(x=0, y=0, location_id="start")) + world.register_resource("test_chair") + + snap = take_snapshot(world) + assert snap.tick == 0 + assert snap.get_agent("test_agent").position.x == 0 + assert snap.is_free("test_chair") + + # mutate the REAL world after the snapshot was taken + world.move_agent("test_agent", Position(x=5, y=5, location_id="cafeteria")) + world.occupy("test_chair", "test_agent") + world.advance_tick() + + # snapshot must be unaffected + assert snap.tick == 0, "snapshot tick leaked a live update" + assert snap.get_agent("test_agent").position.x == 0, "snapshot agent position leaked a live update" + assert snap.is_free("test_chair"), "snapshot occupancy leaked a live update" + + # frozen model: reassignment must raise + try: + snap.tick = 999 + raise AssertionError("expected snapshot mutation to raise, it didn't") + except Exception as e: + assert type(e).__name__ in ("ValidationError", "TypeError"), f"unexpected error type: {type(e)}" + + print("snapshot.py sanity check passed.") \ No newline at end of file diff --git a/backend/src/core/tick_graph.py b/backend/src/core/tick_graph.py new file mode 100644 index 0000000000000000000000000000000000000000..314215b1f3ea479265a8275016636f095d52c5b6 --- /dev/null +++ b/backend/src/core/tick_graph.py @@ -0,0 +1,536 @@ +""" +Agent -- per-agent LangGraph subgraph + standalone CLI debug tool. + +Production pipeline (used by WorldEngine via build_tick_graph): + + perceive -> retrieve_memories -> react --[replan]--> day_planner -> write_back_memory + \\_[continue]__> keep_current / + +Only agents where scheduler.py's `agents_ready_for_decision()` returns +True are invoked each tick -- mid-action agents are skipped entirely. + +Standalone CLI debug mode (python Agent.py ): + + retrieve_memories from Short_term -> call day_planner.run() -> print plan table + +Useful for testing a single persona's day plan without standing up the +full tick loop. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +if str(BACKEND_ROOT) not in sys.path: + sys.path.append(str(BACKEND_ROOT)) + + +from datetime import date, timedelta +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict + +from langgraph.graph import StateGraph, START, END +from pydantic import BaseModel + +from src.core.log import get_logger +from src.core.snapshot import WorldSnapshot +from src.core.world_state import CurrentAction +from src.core.perceive import perceive, Observation +from src.agents.react import decide_reaction, ReactionDecision + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- # +# Sim-tick <-> wallclock helpers +# --------------------------------------------------------------------------- # + +MINUTES_PER_DAY = 24 * 60 + + +def tick_to_wallclock(tick: int) -> Tuple[int, str]: + """(sim day index, 'HH:MM') for a given absolute tick.""" + day_index, minute_of_day = divmod(tick, MINUTES_PER_DAY) + hh, mm = divmod(minute_of_day, 60) + return day_index, f"{hh:02d}:{mm:02d}" + + +def hhmm_to_minute(hhmm: str) -> int: + hh, mm = hhmm.split(":") + return int(hh) * 60 + int(mm) + + +# --------------------------------------------------------------------------- # +# Day-planner integration contract +# --------------------------------------------------------------------------- # + + +class DayPlannerRequest(BaseModel): + agent_id: str + persona: Dict[str, Any] + relevant_memories: List[str] + yesterday_summary: Optional[str] + day_index: int + current_hhmm: str + interrupt_context: Optional[str] = None + + +class DayPlanEntry(BaseModel): + action: str + start: str + end: str + parent_activity: Optional[str] = None + location_id: Optional[str] = None + sub_area: Optional[str] = None + + +DayPlannerFn = Callable[[DayPlannerRequest], List[DayPlanEntry]] + + +def build_default_day_planner_fn(sim_start_date: Optional[date] = None) -> DayPlannerFn: + epoch = sim_start_date or date.today() + + def _adapter(request: DayPlannerRequest) -> List[DayPlanEntry]: + from src.agents.day_planner import run as day_planner_run + + calendar_date = epoch + timedelta(days=request.day_index) + current_time_str = f"{calendar_date.isoformat()} {request.current_hhmm}" + + memories = list(request.relevant_memories) + if request.interrupt_context: + memories.append(f"IMPORTANT -- just happened, account for this: {request.interrupt_context}") + + class _AgentProxy: + persona = request.persona + relevant_memories = memories + yesterday_summary = request.yesterday_summary + + result = day_planner_run(_AgentProxy(), {"current_time": current_time_str, "places": None}) + raw_plan = result.get("day_plan", []) + + entries = [DayPlanEntry.model_validate(item) for item in raw_plan] + logger.info( + "day_planner.run() produced %d entries for agent '%s', day %d%s", + len(entries), request.agent_id, request.day_index, + " (interrupt-triggered replan)" if request.interrupt_context else "", + ) + return entries + + return _adapter + + +def _find_slot(plan: List[DayPlanEntry], hhmm: str) -> Optional[DayPlanEntry]: + minute = hhmm_to_minute(hhmm) + for entry in plan: + if hhmm_to_minute(entry.start) <= minute < hhmm_to_minute(entry.end): + return entry + return None + + +# --------------------------------------------------------------------------- # +# Per-agent, per-sim-day plan cache +# --------------------------------------------------------------------------- # + + +class DayPlanStoreProtocol: + def get(self, agent_id: str, day_index: int) -> Optional[List[DayPlanEntry]]: ... + def set(self, agent_id: str, day_index: int, plan: List[DayPlanEntry]) -> None: ... + def invalidate(self, agent_id: str, day_index: int) -> None: ... + + +class InMemoryDayPlanStore(DayPlanStoreProtocol): + def __init__(self) -> None: + self._plans: Dict[Tuple[str, int], List[DayPlanEntry]] = {} + + def get(self, agent_id: str, day_index: int) -> Optional[List[DayPlanEntry]]: + return self._plans.get((agent_id, day_index)) + + def set(self, agent_id: str, day_index: int, plan: List[DayPlanEntry]) -> None: + self._plans[(agent_id, day_index)] = plan + + def invalidate(self, agent_id: str, day_index: int) -> None: + self._plans.pop((agent_id, day_index), None) + + +# --------------------------------------------------------------------------- # +# Memory stream integration contract +# --------------------------------------------------------------------------- # + + +class MemoryStreamProtocol: + def add_memory(self, agent_id: str, content: str, importance: Optional[int] = None) -> None: ... + def retrieve_memories(self, agent_id: str, query: str, k: int = 5) -> List[str]: ... + + +class _NullMemoryStream: + def add_memory(self, agent_id: str, content: str, importance: Optional[int] = None) -> None: + logger.debug("[NullMemoryStream] would store for '%s': %s", agent_id, content) + + def retrieve_memories(self, agent_id: str, query: str, k: int = 5) -> List[str]: + return [] + + +# --------------------------------------------------------------------------- # +# Tick outcome / result +# --------------------------------------------------------------------------- # + + +class TickOutcome(str, Enum): + KEEP_CURRENT = "keep_current" + NEW_ACTION = "new_action" + + +class TickResult(BaseModel): + agent_id: str + tick: int + outcome: TickOutcome + action: Optional[CurrentAction] = None + reaction: Optional[ReactionDecision] = None + + +# --------------------------------------------------------------------------- # +# Internal graph state +# --------------------------------------------------------------------------- # + + +class TickState(TypedDict, total=False): + agent_id: str + tick: int + persona: Dict[str, Any] + yesterday_summary: Optional[str] + world_snapshot: WorldSnapshot + current_action: Optional[CurrentAction] + observations: List[Observation] + memories: List[str] + reaction: ReactionDecision + result: TickResult + + +def build_initial_state( + agent_id: str, + persona: Dict[str, Any], + yesterday_summary: Optional[str], + world_snapshot: WorldSnapshot, +) -> TickState: + current_action = world_snapshot.get_agent(agent_id).current_action + return TickState( + agent_id=agent_id, + tick=world_snapshot.tick, + persona=persona, + yesterday_summary=yesterday_summary, + world_snapshot=world_snapshot, + current_action=current_action, + ) + + +# --------------------------------------------------------------------------- # +# Graph builder +# --------------------------------------------------------------------------- # + + +def build_tick_graph( + memory_stream: Optional[MemoryStreamProtocol] = None, + day_planner_fn: Optional[DayPlannerFn] = None, + day_plan_store: Optional[DayPlanStoreProtocol] = None, + sim_start_date: Optional[date] = None, +): + memory = memory_stream or _NullMemoryStream() + if memory_stream is None: + logger.warning( + "build_tick_graph() called with no memory_stream -- using a " + "no-op stand-in. retrieve_memories() will always return []. " + "Pass the real MemoryStream in once it's ready." + ) + + day_plans = day_plan_store or InMemoryDayPlanStore() + plan_fn = day_planner_fn or build_default_day_planner_fn(sim_start_date) + + def perceive_node(state: TickState) -> Dict[str, Any]: + observations = perceive(state["world_snapshot"], state["agent_id"]) + return {"observations": observations} + + def retrieve_memories_node(state: TickState) -> Dict[str, Any]: + agent_id = state["agent_id"] + observations = state.get("observations", []) + if observations: + query = "; ".join(o.description for o in observations) + else: + action = state.get("current_action") + query = action.description if action else "what should I do next" + memories = memory.retrieve_memories(agent_id, query=query, k=5) + return {"memories": memories} + + def react_node(state: TickState) -> Dict[str, Any]: + reaction = decide_reaction( + agent_id=state["agent_id"], + persona=state["persona"], + current_action=state.get("current_action"), + observations=state.get("observations", []), + memories=state.get("memories", []), + tick=state["tick"], + ) + return {"reaction": reaction} + + def route_after_react(state: TickState) -> str: + return "day_planner" if state["reaction"].should_replan else "keep_current" + + def day_planner_node(state: TickState) -> Dict[str, Any]: + agent_id = state["agent_id"] + tick = state["tick"] + day_index, hhmm = tick_to_wallclock(tick) + + current_action = state.get("current_action") + interrupted = current_action is not None and not current_action.is_finished(tick) + + cached_plan = day_plans.get(agent_id, day_index) + + if cached_plan is None or interrupted: + interrupt_context = None + if interrupted: + obs_text = "; ".join(o.description for o in state.get("observations", [])) + interrupt_context = ( + f"Was in the middle of '{current_action.description}' when this " + f"happened: {obs_text or '(unspecified event)'}." + ) + request = DayPlannerRequest( + agent_id=agent_id, + persona=state["persona"], + relevant_memories=state.get("memories", []), + yesterday_summary=state.get("yesterday_summary"), + day_index=day_index, + current_hhmm=hhmm, + interrupt_context=interrupt_context, + ) + cached_plan = plan_fn(request) + day_plans.set(agent_id, day_index, cached_plan) + + slot = _find_slot(cached_plan, hhmm) + if slot is None: + logger.warning( + "No day-plan slot covers %s on day %d for agent '%s'; " + "using a placeholder action and invalidating the cached plan.", + hhmm, day_index, agent_id, + ) + day_plans.invalidate(agent_id, day_index) + slot = DayPlanEntry(action="idle (no plan slot found)", start=hhmm, end=hhmm) + duration = 10 + else: + duration = hhmm_to_minute(slot.end) - hhmm_to_minute(hhmm) + duration = max(duration, 1) + + action = CurrentAction( + description=slot.action, + start_tick=tick, + end_tick=tick + duration, + target_location_id=slot.location_id, + ) + result = TickResult( + agent_id=agent_id, + tick=tick, + outcome=TickOutcome.NEW_ACTION, + action=action, + reaction=state.get("reaction"), + ) + return {"result": result} + + def keep_current_node(state: TickState) -> Dict[str, Any]: + result = TickResult( + agent_id=state["agent_id"], + tick=state["tick"], + outcome=TickOutcome.KEEP_CURRENT, + action=state.get("current_action"), + reaction=state.get("reaction"), + ) + return {"result": result} + + def write_back_memory_node(state: TickState) -> Dict[str, Any]: + result: TickResult = state["result"] + if result.outcome == TickOutcome.NEW_ACTION and result.action is not None: + memory.add_memory( + agent_id=state["agent_id"], + content=f"Decided to: {result.action.description}", + importance=None, + ) + return {} + + graph = StateGraph(TickState) + graph.add_node("perceive", perceive_node) + graph.add_node("retrieve_memories", retrieve_memories_node) + graph.add_node("react", react_node) + graph.add_node("day_planner", day_planner_node) + graph.add_node("keep_current", keep_current_node) + graph.add_node("write_back_memory", write_back_memory_node) + + graph.add_edge(START, "perceive") + graph.add_edge("perceive", "retrieve_memories") + graph.add_edge("retrieve_memories", "react") + graph.add_conditional_edges( + "react", + route_after_react, + {"day_planner": "day_planner", "keep_current": "keep_current"}, + ) + graph.add_edge("day_planner", "write_back_memory") + graph.add_edge("keep_current", "write_back_memory") + graph.add_edge("write_back_memory", END) + + compiled = graph.compile() + logger.info("Tick graph compiled.") + return compiled + + +async def run_tick( + tick_graph, + agent_id: str, + persona: Dict[str, Any], + yesterday_summary: Optional[str], + world_snapshot: WorldSnapshot, +) -> TickResult: + initial_state = build_initial_state(agent_id, persona, yesterday_summary, world_snapshot) + final_state = await tick_graph.ainvoke(initial_state) + return final_state["result"] + + +# --------------------------------------------------------------------------- # +# Standalone day-plan CLI (debug tool) +# --------------------------------------------------------------------------- # + +if __name__ == "__main__": + import argparse + import asyncio + import json + import time + from types import SimpleNamespace + + from src.core.log import setup_logging + setup_logging(run_id="agent_cli", console=False) + from src.config import PERSONALITIES_DIR + + parser = argparse.ArgumentParser(description="Agent debug tool โ€” day-plan or self-test") + parser.add_argument( + "persona", nargs="?", + help="Persona name (e.g. parv) or path to a persona JSON file", + ) + parser.add_argument( + "--self-test", action="store_true", + help="Run tick graph sanity check with a fake day planner (no Gemini)", + ) + parser.add_argument( + "--current-time", default="2026-07-03 06:00", + help="Simulation time for the day planner (default: 2026-07-03 06:00)", + ) + args = parser.parse_args() + + # --self-test mode: tick graph sanity check + if args.self_test: + from src.core.world_state import WorldState, Position + from src.core.snapshot import take_snapshot + + def fake_day_planner(request: DayPlannerRequest) -> List[DayPlanEntry]: + return [ + DayPlanEntry(action="sleeping", start="00:00", end="06:00"), + DayPlanEntry(action="testing the tick graph", start="06:00", end="24:00"), + ] + + async def _run_self_test() -> None: + world = WorldState() + world.register_agent("test_agent", Position(x=0, y=0, location_id="start")) + world.advance_tick(minutes=6 * 60) + snap = take_snapshot(world) + graph = build_tick_graph(day_planner_fn=fake_day_planner) + result = await run_tick( + graph, "test_agent", persona={"name": "Test"}, yesterday_summary=None, world_snapshot=snap + ) + assert result.outcome == TickOutcome.NEW_ACTION + assert result.action is not None + assert result.action.description == "testing the tick graph" + print("Self-test passed:", result) + + asyncio.run(_run_self_test()) + sys.exit(0) + + # Day-plan mode: generate a full day plan for one persona + if args.persona is None: + parser.print_help() + sys.exit(1) + + # Resolve persona path (same logic as Single_agent.py) + candidate = Path(args.persona) + if candidate.exists(): + persona_path = candidate + elif candidate.suffix == ".json": + matches = sorted(PERSONALITIES_DIR.glob(f"**/{candidate.name}")) + persona_path = matches[0] if matches else candidate + else: + matches = sorted(PERSONALITIES_DIR.glob(f"**/{args.persona}/{args.persona}.json")) + if not matches: + matches = sorted(PERSONALITIES_DIR.glob(f"**/{args.persona}.json")) + if not matches: + available = sorted({p.parent.name for p in PERSONALITIES_DIR.glob("**/*.json")}) + raise FileNotFoundError( + f"Could not find persona '{args.persona}'. " + f"Available: {', '.join(available)}" + ) + persona_path = matches[0] + + persona_data = json.loads(persona_path.read_text()) + persona_name = persona_data.get("Name", persona_path.stem) + current_time = args.current_time + + t0 = time.perf_counter() + print(f'Day planner โ€” running "{persona_name}" at {current_time}\n') + + # Retrieve memories from Short_term + from src.agents.Short_term import date_from_simulation_time, get_yesterday_summary, get_relevant_memories + + sim_date = date_from_simulation_time(current_time) + yesterday_summary = get_yesterday_summary(persona_name, sim_date) + traits = persona_data.get("Traits", persona_data.get("traits", [])) + query = " ".join(traits) if traits else "daily life" + memories = get_relevant_memories(persona_name, sim_date, query, k=5) + + if yesterday_summary: + print(f" Loaded yesterday's summary") + if memories: + print(f" Loaded {len(memories)} relevant memories") + + # Generate day plan via day_planner.run() + from src.agents.day_planner import run as day_planner_run + + agent = SimpleNamespace( + persona=persona_data, + relevant_memories=memories, + yesterday_summary=yesterday_summary, + ) + + try: + result = day_planner_run(agent, { + "current_time": current_time, + "places": None, + "persona_name": persona_name, + }) + plan = result.get("day_plan", []) + error = result.get("error") + + elapsed = time.perf_counter() - t0 + + print(f"\n{'='*60}") + if error: + print(f" Failed: {error} | Elapsed: {elapsed:.1f}s") + else: + print(f" Completed โ€” {len(plan)} actions | Elapsed: {elapsed:.1f}s") + print(f"{'='*60}") + + if not error and plan: + print(f'\n Generated plan for "{persona_name}":') + print(f' {"Action":25s} {"Start":7s} {"End":7s} {"Location":25s} {"Area":20s}') + print(f' {"-"*25} {"-"*7} {"-"*7} {"-"*25} {"-"*20}') + for a in plan: + loc = (a.get("location_id") or "")[:25] + area = (a.get("sub_area") or "")[:20] + print(f' {a.get("action", ""):25s} {a.get("start", ""):7s} {a.get("end", ""):7s} {loc:25s} {area:20s}') + + except Exception as exc: + elapsed = time.perf_counter() - t0 + print(f"\n Failed after {elapsed:.1f}s: {exc}") diff --git a/backend/src/core/world_engine.py b/backend/src/core/world_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..ddfdc7b7a6c39bca5f056ad390c75ff652f02390 --- /dev/null +++ b/backend/src/core/world_engine.py @@ -0,0 +1,2100 @@ +""" +WorldEngine โ€” the main simulation orchestrator. + +Controls the tick loop: advances time, runs agent actions in parallel, +detects proximity for conversations, handles end-of-day transitions, +and keeps WorldState in sync with the agent registry. + +Usage: + from src.core.world_engine import WorldEngine + + engine = WorldEngine() + await engine.initialize() + await engine.run(max_ticks=1440) # one full day at 1 tick/sec +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import random +import sys +import time as _time +from collections import deque +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional +from types import SimpleNamespace + +from src.core.log import get_logger + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +if str(BACKEND_ROOT) not in sys.path: + sys.path.append(str(BACKEND_ROOT)) + + +from src.agents.Actions import AgentActionManager, LocationResolver, ActionState, ActionType +from src.agents.conversation import ( + generate_conversation, + RelationshipMatrix, + _is_action_blocked, +) +from src.agents.Short_term import ( + save_day_plan, + load_day_plan, + date_from_simulation_time, + append_conversation, + append_event, + archive_to_long_term, + consolidate_to_single_day, + clear_short_term_data, + reset_day_runtime, +) +from src.config import ( + PERSONALITIES_DIR, + SIM_MINUTES_PER_TICK, + REAL_SECONDS_PER_TICK, + MAX_CONVERSATIONS_PER_AGENT, +) +from src.core.agent_registry import AgentRegistry, AgentRuntimeState +from src.core.checkpoint_manager import ( + save_checkpoint, + save_history, + load_checkpoint, + list_checkpoints, + prune_checkpoints, + latest_tick, + KEEP_LAST, +) +from src.core.snapshot import take_snapshot, WorldSnapshot +from src.core.world_state import WorldState, Position, CurrentAction, AgentStatus +from src.core.world_events import WorldEventManager +from src.core.runtime_health import RuntimeHealthMonitor +from src.llm.gemini_client import ProviderFailureError +from src import config as _cfg + +logger = get_logger(__name__) + + +class WorldEngine: + """Main simulation orchestrator.""" + + def __init__( + self, + sim_start_date: str = "2026-07-03", + sim_start_hhmm: str = "00:00", + sim_end_hhmm: str = "24:00", + ): + self.sim_start_date = sim_start_date + self.sim_start_hhmm = sim_start_hhmm + self.sim_end_hhmm = sim_end_hhmm + + self.world = WorldState() + self.registry = AgentRegistry() + self.resolver = LocationResolver() + self.relationship_matrix = RelationshipMatrix() + self.event_manager = WorldEventManager() + self._applied_event_effects: set[str] = set() + self.health_monitor = RuntimeHealthMonitor() + + self._day_index = 0 + # Per-agent set of subject ids currently within the perception circle + # (edge-triggered so we only record a memory when someone *enters* range). + self._in_range: Dict[str, set] = {} + # Per-agent Brain (cognition) instances, built lazily. Each commands a + # Body (the action state machine) โ€” the human-like brain/body split. + self._brains: Dict[str, Any] = {} + # Rolling feed of recently-completed conversations (for the UI feed). + self._recent_convs: deque = deque(maxlen=12) + # Pixels already used at spawn, so agents don't stack on each other. + self._spawn_points: List[tuple] = [] + # Per-agent observation fingerprint from the previous tick (novelty detection). + # Key: agent_id, Value: frozenset of (agent_id, action_description) tuples. + self._last_obs: Dict[str, frozenset] = {} + # Per-agent cached observations from the current tick's perceive phase, + # so Phase 4 (LLM decide) can reuse them without recomputing. + self._tick_observations: Dict[str, list] = {} + # Last tick at which each agent started an advisory LLM decision. A + # changing proximity observation must not generate an API call per tick. + self._last_decision_tick: Dict[str, int] = {} + # Background LLM work is not serializable, but its deterministic input + # envelope is. Keep that envelope so a restore can restart an + # in-flight conversation instead of silently losing it. + self._pending_conversations: Dict[str, Dict[str, Any]] = {} + self._conversation_tasks: Dict[str, asyncio.Task] = {} + # Decisions are advisory; a slow provider response must not freeze the + # simulation clock or WebSocket snapshots at an action boundary. + self._decision_tasks: Dict[str, asyncio.Task] = {} + + @staticmethod + def _conversation_key(first_id: str, second_id: str) -> str: + return "|".join(sorted((first_id, second_id))) + + def checkpoint_state(self) -> Dict[str, Any]: + """Serialize engine-owned state that is not held by WorldState.""" + return { + "sim_start_date": self.sim_start_date, + "sim_start_hhmm": self.sim_start_hhmm, + "sim_end_hhmm": self.sim_end_hhmm, + "day_index": self._day_index, + "in_range": { + agent_id: sorted(subject_ids) + for agent_id, subject_ids in self._in_range.items() + }, + # Novelty fingerprints affect whether an agent makes an LLM + # decision next tick, so they are behavioural state rather than a + # disposable UI cache. + "last_observations": { + agent_id: [list(item) for item in observations] + for agent_id, observations in self._last_obs.items() + }, + "last_decision_tick": dict(self._last_decision_tick), + "recent_conversations": list(self._recent_convs), + "relationship_matrix": self.relationship_matrix.snapshot(), + "event_attendance": self.event_manager.attendance_snapshot(), + "applied_event_effects": sorted(self._applied_event_effects), + "pending_conversations": list(self._pending_conversations.values()), + } + + def restore_checkpoint_state(self, state: Dict[str, Any]) -> None: + """Restore engine-owned state after the world and registry are loaded. + + The caller replaces ``self.registry`` with newly deserialized manager + instances before this method runs. Brains own BodyControllers, which + retain a manager reference, so cached brains must never survive that + replacement. Otherwise the clock advances an old manager while the + restored registry (and therefore the UI) remains frozen on its old + action. + """ + self._brains.clear() + if not state: + return + self.sim_start_date = state.get("sim_start_date", self.sim_start_date) + self.sim_start_hhmm = state.get("sim_start_hhmm", self.sim_start_hhmm) + self.sim_end_hhmm = state.get("sim_end_hhmm", self.sim_end_hhmm) + self._day_index = state.get("day_index", self._day_index) + self._in_range = { + agent_id: set(subject_ids) + for agent_id, subject_ids in state.get("in_range", {}).items() + } + self._last_obs = { + agent_id: frozenset(tuple(item) for item in observations) + for agent_id, observations in state.get("last_observations", {}).items() + } + self._last_decision_tick = { + agent_id: int(tick) + for agent_id, tick in state.get("last_decision_tick", {}).items() + } + self._recent_convs = deque(state.get("recent_conversations", []), maxlen=12) + relationship_state = state.get("relationship_matrix") + if relationship_state is not None: + self.relationship_matrix.restore(relationship_state) + self.event_manager.restore_attendance(state.get("event_attendance", {})) + self._applied_event_effects = set(state.get("applied_event_effects", [])) + + # Older checkpoints did not store a personality baseline. Upgrade + # them on restore so a resumed day gets the same mood normalization as + # a fresh day without invalidating its current action state. + for agent in self.registry.all_states(): + if agent.emotion_baseline == 0.5: + agent.emotion_baseline = self._emotion_baseline(agent.persona) + + self._pending_conversations = {} + for pending in state.get("pending_conversations", []): + first_id = pending.get("agent_a_id") + second_id = pending.get("agent_b_id") + if first_id and second_id: + self._pending_conversations[self._conversation_key(first_id, second_id)] = pending + + async def resume_pending_conversations(self) -> None: + """Restart durable conversation requests after a checkpoint restore. + + An API call already executing at save time cannot be serialized. Its + request envelope is retained, so this resumes the same logical work + from the original participants, prompt inputs, and simulation time. + """ + for key, pending in list(self._pending_conversations.items()): + try: + first = self.registry.get(pending["agent_a_id"]) + second = self.registry.get(pending["agent_b_id"]) + except KeyError: + self._pending_conversations.pop(key, None) + continue + if not first.paused or not second.paused: + self._pending_conversations.pop(key, None) + continue + self._start_conversation_task(first, second, pending) + + # ------------------------------------------------------------------ # + # Persona discovery + # ------------------------------------------------------------------ # + + def _discover_personas(self) -> List[Dict[str, Any]]: + """Find all persona JSON files and return their parsed data.""" + personas = [] + for json_path in sorted(PERSONALITIES_DIR.glob("**/*.json")): + try: + data = json.loads(json_path.read_text(encoding="utf-8")) + if "Name" in data and "Hostel" in data: + personas.append(data) + except Exception as e: + logger.warning("[WorldEngine] failed to load %s: %s", json_path, e) + logger.info( + "[WorldEngine] discovered %d personas from %s", len(personas), PERSONALITIES_DIR + ) + return personas + + def _apply_event_opportunities(self, date: str) -> None: + """Overlay deterministic, conflict-safe optional event attendance. + + The overlay happens after cached or generated plans are available, so + it works equally for fresh, resumed, and handoff days. Only entirely + flexible windows can be replaced; the event manager rejects hard + commitments before this method sees a changed plan. + """ + states = self.registry.all_states() + if not states: + return + plans = {state.agent_id: state.day_plan for state in states} + personas = {state.agent_id: state.persona for state in states} + updated = self.event_manager.apply_opportunities( + date, plans, personas, self.relationship_matrix.get, + ) + for state in states: + plan = updated.get(state.agent_id, state.day_plan) + if plan == state.day_plan: + continue + state.day_plan = plan + if state.manager: + state.manager.replace_day_plan(plan) + save_day_plan(state.persona_name, date, plan) + + def _apply_finished_event_effects(self, date: str, hhmm: str) -> None: + """Persist bounded social/mood effects once an attended event ends.""" + now = self._hhmm_to_minutes(hhmm) + changed_relationships = False + for event in self.event_manager.for_date(date): + if event.id in self._applied_event_effects or now < self._hhmm_to_minutes(event.end_time): + continue + attendees = self.event_manager.attendance_snapshot().get(event.id, []) + for agent_id in attendees: + try: + state = self.registry.get(agent_id) + except KeyError: + continue + state.energy_level = max(0.0, min(1.0, state.energy_level + event.energy_effect)) + state.emotion_state = max(0.0, min(1.0, state.emotion_state + event.emotion_effect)) + append_event(state.persona_name, date, { + "type": "world_event", "action": event.name, + "location": event.location_id, + "summary": f"Attended {event.name}: {event.description}", + "details": {"event_id": event.id, "ended_at": event.end_time}, + }) + if event.relationship_effect and len(attendees) > 1: + for first in attendees: + for second in attendees: + if first != second: + self.relationship_matrix.update(first, second, event.relationship_effect) + changed_relationships = True + self._applied_event_effects.add(event.id) + logger.info("[Events] completed %s with %d attendee(s)", event.id, len(attendees)) + if changed_relationships: + self.relationship_matrix.save() + + def _resolve_hostel_position(self, hostel_id: str) -> Position: + """Convert a Hostel field value to a pixel Position.""" + pos = self.resolver.resolve(hostel_id) + if pos is None: + logger.warning( + "[WorldEngine] could not resolve hostel '%s', using default (0,0)", hostel_id + ) + pos = Position(x=0, y=0, location_id=hostel_id) + return pos + + def _persona_name_to_id(self, name: str) -> str: + """Convert a display name like 'Parv Singla' to a safe agent_id.""" + import re + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", name.strip()).strip("._-").lower() + return safe or "unknown" + + @staticmethod + def _emotion_baseline(persona: Dict[str, Any]) -> float: + """Derive a stable, modest mood baseline from stated personality traits. + + This is deliberately narrow: students can have a good or difficult day, + but their normal state should cluster around neutral rather than begin + at an arbitrary emotional extreme. + """ + traits = " ".join(str(persona.get(key, "")) for key in ("innate", "lifestyle", "learned")).lower() + baseline = 0.50 + for marker in ("friendly", "warm", "approachable", "outgoing", "energetic", "playful", "resilient", "optimistic"): + if marker in traits: + baseline += 0.015 + for marker in ("introverted", "quiet", "reserved", "awkward", "irregular sleep", "bad sleep", "procrastinate"): + if marker in traits: + baseline -= 0.015 + return max(0.40, min(0.62, baseline)) + + @staticmethod + def _energy_baseline(persona: Dict[str, Any]) -> float: + """Return a personality-informed morning energy level, not 100%.""" + traits = " ".join(str(persona.get(key, "")) for key in ("innate", "lifestyle", "learned")).lower() + baseline = 0.74 + for marker in ("regular sleep", "early riser", "exercise", "sport", "disciplined", "morning person"): + if marker in traits: + baseline += 0.025 + for marker in ("irregular sleep", "bad sleep", "late-night", "procrastinate", "overcommitted", "insomni"): + if marker in traits: + baseline -= 0.04 + return max(0.56, min(0.86, baseline)) + + def _action_wellbeing_deltas(self, state: AgentRuntimeState, action: Any) -> tuple[float, float]: + """Compute a deterministic total wellbeing effect for one action. + + LLM-supplied deltas are useful hints, but are normally very small. A + shared local activity model therefore gives classes, travel, rest, and + social time their ordinary human cost or benefit. The small stable + variation is keyed by agent/action, rather than sampled each tick, so + replaying a checkpoint remains reproducible. + """ + description = (getattr(action, "description", "") or "").lower() + action_type = str(getattr(action, "action_type", "")).lower() + # The planner can add personality-specific flavour, but it must not + # turn an otherwise restorative meal or quiet break into a day-long + # energy drain. The local physical activity model is authoritative. + planner_energy = max(-0.08, min(0.08, float(getattr(action, "energy_change", 0.0)))) + planner_emotion = max(-0.12, min(0.12, float(getattr(action, "emotion_change", 0.0)))) + energy, emotion = 0.0, 0.0 + + if action_type.endswith("move") or any(word in description for word in ("walk", "travel", "commute", "go to")): + energy, emotion = -0.075, -0.008 + elif "sleep" in description: + energy, emotion = 0.50, 0.025 + elif any(word in description for word in ("nap", "rest", "recharge", "lie down")): + energy, emotion = 0.20, 0.020 + elif any(word in description for word in ( + "meme", "memes", "scroll", "social media", "youtube", "video", + "reading for pleasure", "quiet reading", "reading quietly", "bench", "downtime", + "free time", "relax", "relaxing", "wind-down", "wind down", + )): + energy, emotion = 0.090, 0.025 + elif any(word in description for word in ("class", "lecture", "lab", "tutorial", "study", "assignment", "coding", "project", "exam")): + energy, emotion = -0.070, -0.025 + elif any(word in description for word in ("gym", "sport", "run", "football", "basketball", "badminton", "workout", "cardio", "weightlift", "training")): + energy, emotion = -0.180, 0.075 + elif any(word in description for word in ("breakfast", "lunch", "dinner", "meal", "food", "tea", "chai", "eat", "eating")): + energy, emotion = 0.130, 0.025 + elif any(word in description for word in ("friends", "club", "music", "open mic", "game", "movie", "social", "hangout")): + energy, emotion = 0.015, 0.075 + elif any(word in description for word in ("laundry", "clean", "errand", "admin", "queue", "chore")): + energy, emotion = -0.080, -0.025 + elif any(word in description for word in ("stand", "standing", "wait", "waiting")): + energy, emotion = -0.040, -0.005 + else: + # Neutral, seated or low-intensity tasks should not silently push + # every agent toward exhaustion merely because their wording was + # not anticipated above. + energy, emotion = -0.005, 0.0 + + # Introverted students generally enjoy a good conversation but spend + # more energy on it; this keeps personality visible without judging it. + traits = " ".join(str(state.persona.get(key, "")) for key in ("innate", "lifestyle", "learned")).lower() + if any(word in description for word in ("friends", "club", "social", "hangout")) and any( + marker in traits for marker in ("introverted", "quiet", "reserved") + ): + energy -= 0.03 + + variation = _cfg.SIM_WELLBEING_VARIABILITY + token = f"{state.agent_id}|{getattr(action, 'start_time', '')}|{getattr(action, 'end_time', '')}|{description}" + digest = hashlib.blake2s(token.encode("utf-8"), digest_size=4).digest() + jitter = (int.from_bytes(digest, "big") / 0xFFFFFFFF) * 2.0 - 1.0 + energy += planner_energy + jitter * 0.035 * variation + emotion += planner_emotion + jitter * 0.045 * variation + return max(-0.28, min(0.30, energy)), max(-0.18, min(0.16, emotion)) + + def _memory_context(self, persona_name, persona, before_date=None, query_hint=""): + """Build (relevant_memories, rolling_summary) for a day-planner call. + + Uses the configured long-term memory backend (keyword by default). This + is 0-LLM for the keyword backend. Failures degrade to empty memory so + planning never breaks. + """ + try: + from src.agents.Long_term import get_retriever + retr = get_retriever() + query = query_hint or " ".join( + str(persona.get(k, "")) + for k in ("goals", "hobbies", "daily_plan_req", "Branch") + ) + memories = retr.retrieve(persona_name, query, k=6) + summary = retr.rolling_summary(persona_name, days=2, before_date=before_date) + if memories or summary: + logger.info( + "[WorldEngine] memory for '%s': %d recalls, summary=%s", + persona_name, len(memories), "yes" if summary else "no", + ) + return memories, summary + except Exception as e: + logger.warning("[WorldEngine] memory context failed for %s: %s", persona_name, e) + return [], None + + # ------------------------------------------------------------------ # + # Initialization + # ------------------------------------------------------------------ # + + async def initialize(self) -> None: + """ + Phase 1: Discover personas, generate day plans, register agents. + Does NOT advance the clock โ€” all agents are ready at tick 0. + """ + logger.info("[WorldEngine] ========== INITIALIZATION ==========") + t0 = _time.perf_counter() + + # Initialise the Qdrant-only long-term memory backend once at startup. + try: + from src.agents.Long_term import get_retriever + get_retriever() + except Exception as exc: + logger.warning("[WorldEngine] memory backend initialization failed: %s", exc) + + personas = self._discover_personas() + if not personas: + logger.error("[WorldEngine] no personas found โ€” aborting") + return + self.relationship_matrix.ensure_grounded_seed([ + {"agent_id": self._persona_name_to_id(persona["Name"]), "persona_name": persona["Name"]} + for persona in personas + ]) + + from src.agents.day_planner import run as day_planner_run + + # โ”€โ”€ Phase 1a (sequential, fast): resolve positions, check saved plans โ”€โ”€ + logger.info("[WorldEngine] preparing %d agents ...", len(personas)) + agent_setups = [] + for persona in personas: + try: + name = persona.get("Name", "unknown") + hostel = persona.get("Hostel", "") + agent_id = self._persona_name_to_id(name) + position = self.resolver.random_interior_point(hostel, occupied=self._spawn_points) + self._spawn_points.append((position.x, position.y)) + consolidate_to_single_day(name, self.sim_start_date) + existing_plan = load_day_plan(name, self.sim_start_date) + if existing_plan: + reset_day_runtime(name, self.sim_start_date) + logger.info( + "[WorldEngine] reusing saved day plan for '%s' (%d actions) โ€” no LLM", + name, len(existing_plan), + ) + day_plan = existing_plan + error = None + manager = AgentActionManager(agent_id, day_plan, position, self.resolver) + self.registry.register( + AgentRuntimeState( + agent_id=agent_id, persona=persona, persona_name=name, + manager=manager, position=position, day_plan=day_plan, + emotion_state=self._emotion_baseline(persona), + emotion_baseline=self._emotion_baseline(persona), energy_level=self._energy_baseline(persona), + ) + ) + self.world.register_agent(agent_id, position) + logger.info("[WorldEngine] registered '%s' (hostel=%s) โ€” %d plan actions (cached)", + name, hostel, len(day_plan)) + else: + agent_setups.append((name, hostel, agent_id, position, persona)) + except Exception as e: + logger.error("[WorldEngine] failed to prepare agent '%s': %s โ€” skipping", + persona.get("Name", "unknown"), e) + import traceback; traceback.print_exc() + continue + + # โ”€โ”€ Phase 1b (parallel, slow): generate day plans for agents that need one โ”€โ”€ + # A full plan makes several provider calls. Serialize startup planning + # to prevent concurrent agents from stampeding the shared key ring. + logger.info("[WorldEngine] generating day plans for %d agents (sequential) ...", len(agent_setups)) + plan_t0 = _time.perf_counter() + + async def _plan_one(name: str, hostel: str, agent_id: str, + position, persona: dict) -> None: + logger.info("[WorldEngine] starting day plan for '%s' ...", name) + mem, yday = self._memory_context(name, persona, before_date=self.sim_start_date) + proxy = SimpleNamespace(persona=persona, relevant_memories=mem, yesterday_summary=yday) + try: + _loop = asyncio.get_event_loop() + plan_result = await _loop.run_in_executor( + None, + lambda: day_planner_run( + proxy, + { + "current_time": f"{self.sim_start_date} {self.sim_start_hhmm}", + "places": None, + "persona_name": name, + "mode": "full_day", + "current_location_id": hostel, + "upcoming_events": self.event_manager.snapshot(self.sim_start_date, self.sim_start_hhmm).get("upcoming", []), + }, + ), + ) + day_plan = plan_result.get("day_plan", []) + error = plan_result.get("error") + if error: + logger.warning("[WorldEngine] day plan for '%s' has issues: %s", name, error) + manager = AgentActionManager(agent_id, day_plan, position, self.resolver) + self.registry.register( + AgentRuntimeState( + agent_id=agent_id, persona=persona, persona_name=name, + manager=manager, position=position, day_plan=day_plan, + emotion_state=self._emotion_baseline(persona), + emotion_baseline=self._emotion_baseline(persona), energy_level=self._energy_baseline(persona), + ) + ) + self.world.register_agent(agent_id, position) + logger.info("[WorldEngine] registered '%s' (hostel=%s) โ€” %d plan actions", + name, hostel, len(day_plan)) + except ProviderFailureError: + # Never continue startup with an empty-plan agent after every + # available key rejects a request. Odin reports this cleanly. + raise + except Exception as e: + logger.error("[WorldEngine] day plan failed for '%s': %s โ€” skipping", name, e) + import traceback; traceback.print_exc() + # Register anyway with empty plan so the sim doesn't crash + manager = AgentActionManager(agent_id, [], position, self.resolver) + self.registry.register( + AgentRuntimeState( + agent_id=agent_id, persona=persona, persona_name=name, + manager=manager, position=position, day_plan=[], + emotion_state=self._emotion_baseline(persona), + emotion_baseline=self._emotion_baseline(persona), energy_level=self._energy_baseline(persona), + ) + ) + self.world.register_agent(agent_id, position) + logger.info("[WorldEngine] registered '%s' with empty plan (fallback)", name) + + for name, hostel, agent_id, position, persona in agent_setups: + await _plan_one(name, hostel, agent_id, position, persona) + + self._apply_event_opportunities(self.sim_start_date) + + plan_elapsed = _time.perf_counter() - plan_t0 + logger.info("[WorldEngine] all day plans generated in %.1fs", plan_elapsed) + + # Assign frontend colors + _COLORS = [ + "#ffcc33", "#ff6b6b", "#51cf66", "#339af0", "#cc5de8", + "#f76707", "#20c997", "#f06595", "#748ffc", "#ffd43b", + ] + for i, state in enumerate(self.registry.all_states()): + state.color = _COLORS[i % len(_COLORS)] + + # Register hostel rooms as world resources + for state in self.registry.all_states(): + hostel = state.persona.get("Hostel", "") + if hostel: + self.world.register_resource(hostel) + + elapsed = _time.perf_counter() - t0 + # Start the clock at the configured time of day (e.g. 08:00) so the day + # begins with real activity instead of everyone asleep at midnight. + self.world.tick = self._hhmm_to_minutes(self.sim_start_hhmm) + # Persist the assembled roster before the first driver tick. This lets + # an observer use stopped-only roster controls even when an API quota + # failure prevents the very first tick from running. + save_checkpoint(self.world, self.registry, self.world.tick, self.checkpoint_state()) + logger.info( + "[WorldEngine] initialized %d agents in %.1fs โ€” starting simulation at %s", + len(self.registry), elapsed, self.sim_start_hhmm, + ) + + # ------------------------------------------------------------------ # + # Tick loop + # ------------------------------------------------------------------ # + + async def run_tick(self) -> Dict[str, Any]: + """ + Execute one simulation tick in six phases: + + 1. Snapshot โ€” freeze world state (one copy for all agents). + 2. Act โ€” all agents advance their body state machines in parallel. + 3. Perceive โ€” build observations from snapshot, detect novelty, set + flags for LLM decision; conversation detection happens here. + 4. LLM Decideโ€” parallel LLM decision for agents with novel observations. + 5. Replan โ€” regenerate remaining-day plan for agents that chose replan. + 6. Resolve โ€” sequential: timeouts, day transitions, collisions, sync. + + Returns a dict snapshot for frontend use. + """ + from src.llm.gemini_client import provider_failure + failure = provider_failure() + if failure: + raise failure + current_tick = self.world.tick + hhmm = self._minutes_to_hhmm(current_tick % (24 * 60)) + + # A generated conversation remains a real, visible simulation state + # until its declared end, rather than disappearing with the LLM task. + self._complete_finished_conversations(current_tick) + + agent_states = self.registry.all_states() + + # โ•โ•โ•โ•โ•โ• PHASE 2: Act (parallel) โ•โ•โ•โ•โ•โ• + if agent_states: + await self._phase_act(agent_states, current_tick, hhmm) + + # Perception must observe the positions and actions produced by this + # tick's act phase, rather than a one-tick-old world mirror. + self._sync_to_world_state() + snapshot = take_snapshot(self.world) + + # โ•โ•โ•โ•โ•โ• PHASE 3: Perceive (parallel) + conversation detection (sequential) โ•โ•โ•โ•โ•โ• + novelty_flags: Dict[str, bool] = {} # agent_id -> has novel observations + self._tick_observations.clear() + if agent_states: + perceive_results = await asyncio.gather( + *[self._phase_perceive(s, snapshot) for s in agent_states], + return_exceptions=True, + ) + for state, result in zip(agent_states, perceive_results): + if isinstance(result, Exception): + logger.error( + "[WorldEngine] agent '%s' perceive failed: %s", state.agent_id, result, + ) + else: + novelty_flags[state.agent_id], obs = result + self._tick_observations[state.agent_id] = obs + + # Phase 3b: Conversation detection (sequential) + await self._check_conversations(current_tick, hhmm) + self._timeout_conversations(current_tick) + + # โ•โ•โ•โ•โ•โ• PHASE 4: LLM Decide (parallel, only non-paused agents with novelty) โ•โ•โ•โ•โ•โ• + decide_results: Dict[str, Any] = self._collect_finished_decisions() + decide_agents = [ + s for s in agent_states + if not s.paused and novelty_flags.get(s.agent_id, False) + and s.energy_level >= _cfg.DECIDE_MIN_ENERGY + and s.emotion_state >= _cfg.DECIDE_MIN_EMOTION + and current_tick - self._last_decision_tick.get( + s.agent_id, -_cfg.DECIDE_COOLDOWN_TICKS + ) >= _cfg.DECIDE_COOLDOWN_TICKS + ] + if decide_agents: + for state in decide_agents: + if state.agent_id in self._decision_tasks: + continue + self._last_decision_tick[state.agent_id] = current_tick + self._decision_tasks[state.agent_id] = asyncio.create_task( + self._phase_llm_decide(state, current_tick, hhmm), + name=f"decide:{state.agent_id}:{current_tick}", + ) + + # โ•โ•โ•โ•โ•โ• PHASE 5: Replan (parallel, agents whose LLM decision was "replan") โ•โ•โ•โ•โ•โ• + replan_agents = [ + s for s in agent_states + if decide_results.get(s.agent_id) == "replan" + and s.replan_count < _cfg.MAX_REPLANS_PER_AGENT_PER_DAY + ] + if replan_agents: + await asyncio.gather( + *[self._phase_replan(s, current_tick, hhmm) for s in replan_agents], + return_exceptions=True, + ) + + # โ•โ•โ•โ•โ•โ• PHASE 6: Resolve (sequential) โ•โ•โ•โ•โ•โ• + await self._check_last_action_triggers(current_tick, hhmm) + self._apply_finished_event_effects(self.sim_start_date, hhmm) + self._resolve_collisions() + self._sync_to_world_state() + # ----- Advance tick ----- + self.world.advance_tick(minutes=SIM_MINUTES_PER_TICK) + snapshot_tick = self.world.tick + snapshot_hhmm = self._minutes_to_hhmm(snapshot_tick % (24 * 60)) + # A frontend frame must describe the same clock state as the engine + # and checkpoint it was built from. Returning the pre-advance clock + # made the UI one tick behind rewind/fast-forward controls. + health = self.health_monitor.observe(self, snapshot_tick, snapshot_hhmm) + + # ----- Save checkpoint ----- + save_checkpoint( + self.world, + self.registry, + self.world.tick, + engine_state=self.checkpoint_state(), + ) + prune_checkpoints(keep_last=KEEP_LAST) + + # Build frontend snapshot + return self._frontend_snapshot(snapshot_tick, snapshot_hhmm, health) + + def _frontend_snapshot(self, tick: int, hhmm: str, health: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Return the current UI-safe projection without advancing the world.""" + real_min_per_day = (1440 * _cfg.REAL_SECONDS_PER_SIM_MINUTE + / max(_cfg.TICK_SPEED, 1e-9)) / 60.0 + return { + "tick": tick, + "time": hhmm, + "day": self._day_index + 1, + "speed": { + "multiplier": _cfg.TICK_SPEED, + "real_min_per_day": round(real_min_per_day), + "real_ms_per_sim_minute": round((_cfg.REAL_SECONDS_PER_SIM_MINUTE / max(_cfg.TICK_SPEED, 1e-9)) * 1000), + }, + "recent_conversations": list(self._recent_convs), + "events": self.event_manager.snapshot(self.sim_start_date, hhmm), + "health": health if health is not None else self.health_monitor.latest, + "agents": { + s.agent_id: { + "name": s.persona_name, + "color": s.color, + "position": { + "x": s.position.x, + "y": s.position.y, + "location_id": s.position.location_id, + }, + "current_action": ( + s.manager.current_action.model_dump() + if s.manager and s.manager.current_action + else None + ), + "activity": ( + s.manager.current_action.description + if s.manager and s.manager.current_action + else "Idle" + ), + "paused": s.paused, + "in_conversation": bool(s.paused and s.active_conversation), + "in_last_action": s.manager.is_last_action if s.manager else False, + "energy_level": s.energy_level, + "emotion_state": s.emotion_state, + "conversation": s.active_conversation, + } + for s in self.registry.all_states() + }, + } + + def current_frontend_snapshot(self) -> Dict[str, Any]: + """Expose restored state immediately after a checkpoint rewind.""" + tick = self.world.tick + hhmm = self._minutes_to_hhmm(tick % (24 * 60)) + return self._frontend_snapshot(tick, hhmm) + + async def _phase_act(self, agent_states, current_tick, hhmm) -> None: + """Phase 2: all agents advance their body state machines in parallel.""" + act_results = await asyncio.gather( + *[self._run_agent_act(s, current_tick, hhmm) for s in agent_states], + return_exceptions=True, + ) + for state, result in zip(agent_states, act_results): + if isinstance(result, Exception): + logger.error( + "[WorldEngine] agent '%s' act failed: %s", state.agent_id, result, + ) + + def _collect_finished_decisions(self) -> Dict[str, str]: + """Consume completed advisory decisions without delaying a tick.""" + completed: Dict[str, str] = {} + for agent_id, task in list(self._decision_tasks.items()): + if not task.done(): + continue + self._decision_tasks.pop(agent_id, None) + try: + completed[agent_id] = task.result() + except asyncio.CancelledError: + continue + except Exception as exc: + from src.llm.gemini_client import ProviderFailureError + if isinstance(exc, ProviderFailureError): + raise + logger.error("[WorldEngine] agent '%s' LLM decide failed: %s", agent_id, exc) + completed[agent_id] = "continue" + return completed + + async def _phase_perceive(self, state: AgentRuntimeState, snapshot: WorldSnapshot) -> tuple: + """Phase 3: build observations for one agent, detect novelty. + Returns (novel: bool, observations: list). + """ + if state.paused: + return False, [] + observations = self._build_observations(state, snapshot) + # Compute fingerprint: frozenset of (agent_id, action_description) + fingerprint = frozenset( + (o["agent_id"], o.get("current_action", "")) for o in observations + ) + prev = self._last_obs.get(state.agent_id, frozenset()) + novel = fingerprint != prev + self._last_obs[state.agent_id] = fingerprint + return novel, observations + + async def _phase_llm_decide(self, state: AgentRuntimeState, tick: int, hhmm: str) -> str: + """Phase 4: call the brain's LLM decision for one agent. + Returns "continue" or "replan". + """ + from src.core.budget import GOVERNOR + if not GOVERNOR.can_afford("decide", cost=1): + logger.info("[WorldEngine] budget denied decide for '%s' โ€” continuing", state.persona_name) + return "continue" + brain = self._brain_for(state) + observations = self._tick_observations.get(state.agent_id, []) + query = " ".join(str(item.get("current_action", "")) for item in observations) + memories, _ = self._memory_context(state.persona_name, state.persona, query_hint=query) + # Gemini is a synchronous SDK call. Run it in the default worker + # pool so a slow response, rate-limit wait, or retry never prevents + # WebSocket broadcasts and the rest of the simulation from advancing. + decision = await asyncio.to_thread( + brain.decide_tick, + tick=tick, + hhmm=hhmm, + observations=observations, + day_plan=state.day_plan, + replan_count=state.replan_count, + max_replans=_cfg.MAX_REPLANS_PER_AGENT_PER_DAY, + energy_level=state.energy_level, + emotion_state=state.emotion_state, + relevant_memories=memories, + ) + return decision.decision + + async def _phase_replan(self, state: AgentRuntimeState, tick: int, hhmm: str) -> None: + """Phase 5: regenerate remaining-day plan for one agent. + Called only when the LLM returned "replan" and budget allows. + """ + from src.core.budget import GOVERNOR + if not GOVERNOR.can_afford("replan", cost=4): + logger.info("[WorldEngine] budget denied replan for '%s' โ€” skipping", state.persona_name) + return + from src.agents.day_planner import run as day_planner_run + from types import SimpleNamespace + + loop = asyncio.get_event_loop() + mem, yday = self._memory_context( + state.persona_name, state.persona, + before_date=date_from_simulation_time(f"{self.sim_start_date} {hhmm}"), + ) + proxy = SimpleNamespace( + persona=state.persona, + relevant_memories=mem, + yesterday_summary=yday, + ) + try: + plan_result = await loop.run_in_executor( + None, + lambda: day_planner_run( + proxy, + { + "current_time": f"{self.sim_start_date} {hhmm}", + "places": None, + "persona_name": state.persona_name, + "mode": "remaining", + "current_location_id": state.position.location_id, + "upcoming_events": self.event_manager.snapshot(self.sim_start_date, hhmm).get("upcoming", []), + }, + ), + ) + new_plan = plan_result.get("day_plan", []) + if plan_result.get("replan_rejected"): + logger.warning( + "[WorldEngine] replan rejected for '%s'; retaining prior %d-action plan: %s", + state.persona_name, + len(state.day_plan), + plan_result.get("error", "validation retries exhausted"), + ) + return + if new_plan and state.manager: + state.day_plan = new_plan + state.manager.replace_day_plan(new_plan) + state.replan_count += 1 + logger.info( + "[WorldEngine] replan for '%s': %d actions (count=%d)", + state.persona_name, len(new_plan), state.replan_count, + ) + except Exception as e: + logger.error( + "[WorldEngine] replan failed for '%s': %s", state.persona_name, e, + ) + + async def _run_agent_act( + self, state: AgentRuntimeState, tick: int, hhmm: str + ) -> None: + """Phase 2: execute the decision โ€” advance the body's state machine. + + Updates registry position and action from the manager after the body + advances. Paused agents are skipped. + """ + if state.paused: + return + manager = state.manager + if manager is None: + return + brain = self._brain_for(state) + action = brain.act(tick) + state.position = manager.position + state.current_action = action.model_dump() if action else None + + # Per-tick energy/emotion update + self._update_energy_emotion(state) + + def _timeout_conversations(self, current_tick: int) -> None: + """Fail safe only for LLM requests that are still generating.""" + for state in self.registry.all_states(): + if ( + not state.paused + or not state.active_conversation + or state.active_conversation.get("status") != "generating" + ): + continue + if current_tick - state.conversation_start_tick < 30: + continue + partner_id = state.last_conversation_partner + logger.info( + "[WorldEngine] conversation timeout for '%s' (started tick %d, now tick %d)", + state.persona_name, state.conversation_start_tick, current_tick, + ) + self._end_conversation(state, current_tick) + if partner_id: + try: + partner = self.registry.get(partner_id) + self._end_conversation(partner, current_tick) + self._discard_pending_conversation(state.agent_id, partner.agent_id) + except KeyError: + pass + + @staticmethod + def _end_conversation(state: AgentRuntimeState, current_tick: Optional[int] = None) -> None: + """Restore agent state after a conversation ends (timeout or normal).""" + state.paused = False + state.conversation_start_tick = 0 + state.active_conversation = None + mgr = state.manager + if mgr: + mgr.resume_from_conversation(world_tick=current_tick) + + def _complete_finished_conversations(self, current_tick: int) -> None: + """Resume each completed conversation once its simulated duration ends.""" + completed: set[str] = set() + for state in self.registry.all_states(): + conversation = state.active_conversation or {} + partner_id = conversation.get("partner_id") + if ( + not state.paused + or conversation.get("status") != "active" + or current_tick < conversation.get("ends_at_tick", current_tick + 1) + or not partner_id + or state.agent_id in completed + ): + continue + try: + partner = self.registry.get(partner_id) + except KeyError: + self._end_conversation(state, current_tick) + continue + self._end_conversation(state, current_tick) + self._end_conversation(partner, current_tick) + self.world.record_conversation(state.agent_id, current_tick) + self.world.record_conversation(partner.agent_id, current_tick) + completed.update((state.agent_id, partner.agent_id)) + logger.info( + "[WorldEngine] conversation '%s' <-> '%s' ended after its simulated duration", + state.persona_name, partner.persona_name, + ) + + def _resolve_collisions(self, min_gap: int = 4) -> None: + """Ensure no two agents share (nearly) the same pixel โ€” nudge later ones + a few pixels away. Keeps the map readable and honours 'never same pixel'.""" + seen: List[tuple] = [] + g2 = min_gap * min_gap + for s in self.registry.all_states(): + x, y = s.position.x, s.position.y + tries = 0 + while tries < 15 and any((x - ox) ** 2 + (y - oy) ** 2 < g2 for ox, oy in seen): + x = s.position.x + random.randint(-7, 7) + y = s.position.y + random.randint(-7, 7) + tries += 1 + if (x, y) != (s.position.x, s.position.y): + # Registry and action manager are two views of the live body. + # Keep them atomic here; otherwise _run_agent_act restores the + # old manager position on the next tick and the collision + # reappears. + corrected = Position(x=x, y=y, location_id=s.position.location_id) + s.position = corrected + if s.manager is not None: + s.manager.position = corrected + seen.append((x, y)) + + def _update_energy_emotion(self, state: AgentRuntimeState) -> None: + """Apply the active action's bounded, personality-aware wellbeing effect.""" + manager = state.manager + if manager is None or manager.current_action is None: + return + action = manager.current_action + try: + start_min = self._hhmm_to_minutes(action.start_time) + end_min = self._hhmm_to_minutes(action.end_time) + duration = max(1, end_min - start_min) + tick_step = _cfg.SIM_MINUTES_PER_TICK + action_energy_change, action_emotion_change = self._action_wellbeing_deltas(state, action) + energy_tick = (action_energy_change / duration) * tick_step + emotion_tick = (action_emotion_change / duration) * tick_step + state.energy_level = max(0.08, min(0.97, state.energy_level + energy_tick)) + baseline = state.emotion_baseline + # Mood has a weak pull towards personality baseline, but day + # events are allowed to remain visible for several actions. + recovery = (baseline - state.emotion_state) * min(0.015, 0.0005 * tick_step) + state.emotion_state = max(0.10, min(0.90, state.emotion_state + emotion_tick + recovery)) + except Exception: + pass + + def _brain_for(self, state: AgentRuntimeState): + """Lazily build and cache one Brain (+ Body) per agent.""" + brain = self._brains.get(state.agent_id) + if brain is None: + from src.agents.body import BodyController + from src.agents.brain import Brain + brain = Brain( + agent_id=state.agent_id, + persona_name=state.persona_name, + body=BodyController(state.manager), + persona=state.persona, + ) + self._brains[state.agent_id] = brain + return brain + + def _build_observations(self, state: AgentRuntimeState, w_snapshot: WorldSnapshot) -> list: + """Build structured observations for one agent from the frozen snapshot. + + Reports every agent within the configured pixel radius, including their + position and current action. Placeholder for future world-events. + """ + radius = float(_cfg.PERCEPTION_RADIUS_PX) + nearby_snaps = w_snapshot.agents_within_px(state.agent_id, radius) + observed = [] + for ns in nearby_snaps: + try: + rs = self.registry.get(ns.agent_id) + name = rs.persona_name if rs else ns.agent_id + except KeyError: + name = ns.agent_id + observed.append({ + "agent_id": ns.agent_id, + "name": name, + "position": {"x": ns.position.x, "y": ns.position.y}, + "current_action": ns.current_action.description if ns.current_action else "idle", + }) + return observed + + # ------------------------------------------------------------------ # + # Perception (0-LLM) + spatial helpers + # ------------------------------------------------------------------ # + + def _neighbors_within_px(self, state, radius_px: float) -> List: + """Registry states whose pixel position is within radius_px of `state` + (excludes self). Nearest first.""" + out = [] + for other in self.registry.all_states(): + if other.agent_id == state.agent_id: + continue + dx = other.position.x - state.position.x + dy = other.position.y - state.position.y + d2 = dx * dx + dy * dy + if d2 <= radius_px * radius_px: + out.append((d2, other)) + out.sort(key=lambda t: t[0]) + return [o for _d, o in out] + + def _perceive_and_record(self, tick: int, hhmm: str) -> None: + """For each agent, find who is within the perception circle and record a + memory the moment someone *enters* range (edge-triggered, de-duplicated). + Pure spatial math โ€” no LLM. Cheap keyword-backend writes only. + """ + radius = float(_cfg.PERCEPTION_RADIUS_PX) + try: + from src.agents.Long_term import get_retriever + retriever = get_retriever() + except Exception: + retriever = None + + date_str = date_from_simulation_time(f"{self.sim_start_date} {hhmm}") + + for state in self.registry.all_states(): + neighbors = self._neighbors_within_px(state, radius) + current_ids = {n.agent_id for n in neighbors} + previous_ids = self._in_range.get(state.agent_id, set()) + newly_entered = current_ids - previous_ids + self._in_range[state.agent_id] = current_ids + + if not newly_entered or retriever is None: + continue + + loc = state.position.location_id or "campus" + crowd = len(neighbors) + for n in neighbors: + if n.agent_id not in newly_entered: + continue + doing = "" + if n.manager and n.manager.current_action: + doing = f" ({n.manager.current_action.description})" + text = ( + f"At {hhmm} near {loc}, saw {n.persona_name}{doing}" + + (f"; {crowd} people around." if crowd > 1 else ".") + ) + try: + retriever.store(state.persona_name, text, kind="observation", + importance=0.5, date_str=date_str) + except Exception as e: + logger.debug("[WorldEngine] observation store failed: %s", e) + + def _conversation_request( + self, + a: AgentRuntimeState, + b: AgentRuntimeState, + loc_id: str, + tick: int, + hhmm: str, + ) -> Dict[str, Any]: + """Capture the inputs needed to restart an in-flight LLM request.""" + date_ctx = date_from_simulation_time(f"{self.sim_start_date} {hhmm}") + memories_a, _ = self._memory_context( + a.persona_name, a.persona, before_date=date_ctx, + query_hint=f"{b.persona_name} {loc_id}", + ) + memories_b, _ = self._memory_context( + b.persona_name, b.persona, before_date=date_ctx, + query_hint=f"{a.persona_name} {loc_id}", + ) + return { + "agent_a_id": a.agent_id, + "agent_b_id": b.agent_id, + "location_id": loc_id, + "start_tick": tick, + "hhmm": hhmm, + "action_a": a.manager.current_action.description if a.manager and a.manager.current_action else "unknown", + "action_b": b.manager.current_action.description if b.manager and b.manager.current_action else "unknown", + "relationship_a_to_b": self.relationship_matrix.get(a.agent_id, b.agent_id), + "relationship_b_to_a": self.relationship_matrix.get(b.agent_id, a.agent_id), + "relationship_context_a": self.relationship_matrix.context(a.agent_id, b.agent_id).model_dump(), + "relationship_context_b": self.relationship_matrix.context(b.agent_id, a.agent_id).model_dump(), + "memories_a": memories_a, + "memories_b": memories_b, + "energy_a": a.energy_level, + "emotion_a": a.emotion_state, + "energy_b": b.energy_level, + "emotion_b": b.emotion_state, + } + + @staticmethod + def _relationship_context_text(record: Dict[str, Any]) -> str: + """Compact structured relationship context for a conversation prompt.""" + if not isinstance(record, dict): + return "" + tags = ", ".join(str(tag) for tag in record.get("tags", [])[:4]) + context = str(record.get("context", "")).strip() + return "; ".join(part for part in (tags, context) if part) + + def _start_conversation_task( + self, + a: AgentRuntimeState, + b: AgentRuntimeState, + request: Dict[str, Any], + ) -> None: + """Run one durable conversation request and clear its bookkeeping.""" + key = self._conversation_key(a.agent_id, b.agent_id) + if key in self._conversation_tasks: + return + + async def _runner() -> None: + try: + await self._run_conversation_pipeline(a, b, request) + except asyncio.CancelledError: + raise + except Exception: + logger.exception( + "[WorldEngine] conversation pipeline crashed for '%s' <-> '%s'", + a.persona_name, b.persona_name, + ) + self._end_conversation(a) + self._end_conversation(b) + finally: + self._pending_conversations.pop(key, None) + self._conversation_tasks.pop(key, None) + + self._conversation_tasks[key] = asyncio.create_task(_runner()) + + def _discard_pending_conversation(self, first_id: str, second_id: str) -> None: + """Prevent a timed-out conversation from being restarted on restore.""" + self._pending_conversations.pop(self._conversation_key(first_id, second_id), None) + + async def _check_conversations(self, tick: int, hhmm: str) -> None: + """ + Detect close one-to-one pairs and fire a background LLM conversation. + Agents may meet while travelling: the action manager pauses their + routes and resumes them from the exact path point when the chat ends. + The pair-only rule, cooldown, per-day cap, and blocked-action (e.g. + sleeping) guard still apply. + Triggered pairs are paused immediately and resume when the background + task completes. + """ + radius = float(_cfg.CONVERSATION_RADIUS_PX) + + def is_conversation_candidate(state: AgentRuntimeState) -> bool: + return ( + not state.paused + and not (state.manager and state.manager.is_last_action) + and bool(state.position.location_id) + and state.energy_level >= _cfg.CONVERSATION_MIN_ENERGY + and state.emotion_state >= _cfg.CONVERSATION_MIN_EMOTION + ) + + candidates = [s for s in self.registry.all_states() if is_conversation_candidate(s)] + candidate_ids = {state.agent_id for state in candidates} + busy: set = set() # agents already committed to a conversation this tick + + for a in candidates: + if a.agent_id in busy: + continue + neighbors = [ + n for n in self._neighbors_within_px(a, radius) + if n.agent_id in candidate_ids + and n.agent_id not in busy + ] + if not neighbors: + continue + # Pair-only rule: a clean 1:1 encounter. More than one other agent + # inside the circle is a crowd โ€” skip (mirrors the old >2 rule). + if len(neighbors) > 1: + logger.debug( + "[WorldEngine] %d agents within %.0fpx of '%s' โ€” skipping (pair-only rule)", + len(neighbors) + 1, radius, a.persona_name, + ) + continue + b = neighbors[0] + # Require a symmetric clean pair: b's circle must also contain only a. + b_neighbors = [ + n for n in self._neighbors_within_px(b, radius) + if n.agent_id in candidate_ids + ] + if len(b_neighbors) > 1: + continue + + # Cooldown check + if not self.world.can_converse(a.agent_id, b.agent_id): + continue + # No back-to-back repeats: they can't talk again until at least one + # of them has spoken with someone else since (avoids the two of them + # looping conversations while standing together). + if a.last_conversation_partner == b.agent_id or b.last_conversation_partner == a.agent_id: + continue + # Max conversations cap + if a.conversation_count >= MAX_CONVERSATIONS_PER_AGENT: + continue + if b.conversation_count >= MAX_CONVERSATIONS_PER_AGENT: + continue + # Both must be in compatible (non-blocked, e.g. not sleeping) actions + if a.manager and a.manager.current_action: + if _is_action_blocked(CurrentAction( + description=a.manager.current_action.description, + start_tick=tick, end_tick=tick + 10, + )): + continue + if b.manager and b.manager.current_action: + if _is_action_blocked(CurrentAction( + description=b.manager.current_action.description, + start_tick=tick, end_tick=tick + 10, + )): + continue + + # A travelling pair may still carry different source-location ids. + # Label the dialogue honestly instead of claiming they are already + # inside either endpoint building. + a_is_moving = bool(a.manager and a.manager.current_action and a.manager.current_action.action_type == ActionType.MOVE) + b_is_moving = bool(b.manager and b.manager.current_action and b.manager.current_action.action_type == ActionType.MOVE) + loc_id = ( + "campus_path" + if a_is_moving or b_is_moving or a.position.location_id != b.position.location_id + else a.position.location_id + ) or "campus" + + logger.info( + "[WorldEngine] triggering conversation: '%s' <-> '%s' near %s (within %.0fpx)", + a.persona_name, b.persona_name, loc_id, radius, + ) + + # Capture the original request before replacing the actions with a + # visible conversation state. This payload is checkpointed while + # the provider call is in flight. + request = self._conversation_request(a, b, loc_id, tick, hhmm) + + # Immediately set agents to conversation mode (frozen until bg task completes) + if a.manager: + a.manager.set_conversation_action(b.persona_name, tick) + a.current_action = a.manager.current_action.model_dump() + a.paused = True + a.conversation_count += 1 + a.last_conversation_partner = b.agent_id + a.conversation_start_tick = tick + a.active_conversation = { + "partner_name": b.persona_name, + "partner_id": b.agent_id, + "location_id": loc_id, + "started_tick": tick, + "status": "generating", + } + if b.manager: + b.manager.set_conversation_action(a.persona_name, tick) + b.current_action = b.manager.current_action.model_dump() + b.paused = True + b.conversation_count += 1 + b.last_conversation_partner = a.agent_id + b.conversation_start_tick = tick + b.active_conversation = { + "partner_name": a.persona_name, + "partner_id": a.agent_id, + "location_id": loc_id, + "started_tick": tick, + "status": "generating", + } + + self._pending_conversations[self._conversation_key(a.agent_id, b.agent_id)] = request + self._start_conversation_task(a, b, request) + busy.add(a.agent_id) + busy.add(b.agent_id) + + logger.info( + "[WorldEngine] conversation '%s' <-> '%s' started (async, waiting for LLM)", + a.persona_name, b.persona_name, + ) + + async def _run_conversation_pipeline( + self, + a: AgentRuntimeState, + b: AgentRuntimeState, + request: Dict[str, Any], + ) -> None: + """ + Background task: generates the conversation via LLM, saves results, + and resumes both agents with new remaining-day plans. + Runs in a thread pool so it doesn't block the event loop. + """ + loop = asyncio.get_event_loop() + + loc_id = request["location_id"] + tick = request["start_tick"] + hhmm = request["hhmm"] + a_action = CurrentAction( + description=request["action_a"], + start_tick=tick, + end_tick=tick + 10, + target_location_id=loc_id, + ) + b_action = CurrentAction( + description=request["action_b"], + start_tick=tick, + end_tick=tick + 10, + target_location_id=loc_id, + ) + # Step 1: Generate conversation (blocking LLM call โ†’ thread pool) + conv_result = await loop.run_in_executor( + None, + lambda: generate_conversation( + a.agent_id, b.agent_id, + a.persona, b.persona, + a.day_plan, b.day_plan, + a_action, b_action, + request["relationship_a_to_b"], request["relationship_b_to_a"], + loc_id, hhmm, + request["memories_a"], request["memories_b"], + energy_a=request["energy_a"], emotion_a=request["emotion_a"], + energy_b=request["energy_b"], emotion_b=request["emotion_b"], + relationship_context_a=self._relationship_context_text(request.get("relationship_context_a", {})), + relationship_context_b=self._relationship_context_text(request.get("relationship_context_b", {})), + ), + ) + + if conv_result is None: + logger.warning( + "[WorldEngine] conversation LLM failed for '%s' <-> '%s' โ€” unpausing", + a.persona_name, b.persona_name, + ) + self._end_conversation(a) + self._end_conversation(b) + return + + # If the conversation was already ended by timeout while the LLM was + # running, discard the result and return silently. + if not a.paused or not b.paused: + logger.info( + "[WorldEngine] conversation '%s' <-> '%s' ended by timeout while LLM ran โ€” discarding result", + a.persona_name, b.persona_name, + ) + return + + # Step 2: Save conversation to Short_term + date_str = date_from_simulation_time(f"{self.sim_start_date} {hhmm}") + conv_entry = { + "participants": [a.persona_name, b.persona_name], + "messages": [ + {"speaker": m.speaker, "text": m.text} + for m in conv_result.messages + ], + "summary": conv_result.summary, + } + append_conversation(a.persona_name, date_str, conv_entry) + append_conversation(b.persona_name, date_str, conv_entry) + + # Add to the rolling UI feed (last N conversations across the campus). + self._recent_convs.appendleft({ + "time": hhmm, + "participants": [a.persona_name, b.persona_name], + "summary": conv_result.summary, + "sentiment": conv_result.sentiment, + "location": loc_id, + }) + + # Step 3: Store conversation messages on agent states (for frontend snapshot) + messages = conv_entry["messages"] + a.active_conversation = { + "partner_name": b.persona_name, + "partner_id": b.agent_id, + "messages": messages, + "duration_minutes": conv_result.duration_minutes, + "started_tick": self.world.tick, + "ends_at_tick": self.world.tick + conv_result.duration_minutes, + "status": "active", + } + b.active_conversation = { + "partner_name": a.persona_name, + "partner_id": a.agent_id, + "messages": messages, + "duration_minutes": conv_result.duration_minutes, + "started_tick": self.world.tick, + "ends_at_tick": self.world.tick + conv_result.duration_minutes, + "status": "active", + } + # The action placeholder is intentionally open-ended while the LLM is + # generating. Once a duration exists, make the UI/action snapshot tell + # the same truth as ``ends_at_tick`` instead of displaying 23:59. + conversation_end = self._minutes_to_hhmm((self.world.tick + conv_result.duration_minutes) % (24 * 60)) + for state in (a, b): + if state.manager and state.manager.current_action: + state.manager.current_action.end_time = conversation_end + state.current_action = state.manager.current_action.model_dump() + + # Step 4: Update relationship matrix + self.relationship_matrix.update(a.agent_id, b.agent_id, conv_result.relationship_delta) + self.relationship_matrix.update(b.agent_id, a.agent_id, conv_result.relationship_delta) + self.relationship_matrix.save() + # Conversations affect the people having them, not only their stored + # relationship score. A warm chat is a modest lift; an awkward one is + # draining. The effect is applied once per completed conversation. + relationship_delta = max(-0.20, min(0.20, conv_result.relationship_delta)) + sentiment = (getattr(conv_result, "sentiment", "neutral") or "neutral").lower() + for state in (a, b): + social_cost = 0.045 if any(marker in " ".join( + str(state.persona.get(key, "")) for key in ("innate", "lifestyle", "learned") + ).lower() for marker in ("introverted", "quiet", "reserved")) else 0.025 + state.energy_level = max(0.08, min(0.97, state.energy_level - social_cost)) + if sentiment in ("positive", "warm", "friendly"): + mood_delta = 0.035 + max(0.0, relationship_delta) * 0.25 + elif sentiment in ("negative", "tense", "awkward"): + mood_delta = -0.035 + min(0.0, relationship_delta) * 0.25 + else: + mood_delta = relationship_delta * 0.08 + state.emotion_state = max(0.10, min(0.90, state.emotion_state + mood_delta)) + logger.info( + "[WorldEngine] conversation '%s' <-> '%s' active until tick %d", + a.persona_name, b.persona_name, self.world.tick + conv_result.duration_minutes, + ) + return + + # Step 5: Generate remaining-day plans (blocking โ†’ thread pool) + from src.agents.day_planner import run as day_planner_run + + def _run_planner(state: AgentRuntimeState) -> list: + mem, yday = self._memory_context( + state.persona_name, state.persona, + before_date=date_from_simulation_time(f"{self.sim_start_date} {hhmm}"), + ) + proxy = SimpleNamespace( + persona=state.persona, + relevant_memories=mem, + yesterday_summary=yday, + ) + plan_result = day_planner_run( + proxy, + { + "current_time": f"{self.sim_start_date} {hhmm}", + "places": None, + "persona_name": state.persona_name, + "mode": "remaining", + "current_location_id": state.position.location_id, + }, + ) + return plan_result.get("day_plan", []) + + # Step 5: Resume both agents. Replanning is disabled by default โ€” kept + # behind a flag for future re-enablement. + _ENABLE_POST_CONVERSATION_REPLAN = False + + for state in (a, b): + try: + if _ENABLE_POST_CONVERSATION_REPLAN: + should_replan = bool(getattr(conv_result, "should_replan", False)) + try: + from src.core.budget import GOVERNOR + except Exception: + GOVERNOR = None + allow_replan = ( + should_replan + and state.replan_count < _cfg.MAX_REPLANS_PER_AGENT_PER_DAY + and (GOVERNOR is None or GOVERNOR.can_afford("replan", cost=4)) + ) + if allow_replan: + new_plan = await loop.run_in_executor(None, _run_planner, state) + if new_plan and state.manager: + state.manager.resume_from_conversation(new_plan) + state.day_plan = new_plan + state.replan_count += 1 + logger.info( + "[WorldEngine] post-conversation replan for '%s' (%d actions): %s", + state.persona_name, len(new_plan), + getattr(conv_result, "plan_change", "") or "", + ) + elif state.manager: + state.manager.resume_from_conversation(state.day_plan) + else: + if state.manager: + state.manager.resume_from_conversation(state.day_plan) + else: + # No replan โ€” resume the day already planned (0 LLM). + if state.manager: + state.manager.resume_from_conversation(state.day_plan) + state.active_conversation = None + except Exception as e: + logger.error( + "[WorldEngine] resume after conversation failed for '%s': %s", + state.persona_name, e, + ) + if state.manager: + try: + state.manager.resume_from_conversation(state.day_plan) + except Exception: + pass + + # Step 6: Unpause agents + a.paused = False + b.paused = False + + logger.info( + "[WorldEngine] conversation '%s' <-> '%s' complete โ€” agents resumed", + a.persona_name, b.persona_name, + ) + + async def _check_last_action_triggers(self, tick: int, hhmm: str) -> None: + """ + When an agent enters their last action of the day plan: + Archive durable day memories to Qdrant. + """ + for state in self.registry.all_states(): + manager = state.manager + if manager is None: + continue + if not manager._entered_last_action: + continue + # Already triggered? Use a flag on the registry state + if state.day_archived: + continue + + logger.info( + "[WorldEngine] '%s' entered last action โ€” archiving day", + state.persona_name, + ) + + # Archive current day + date_str = date_from_simulation_time(f"{self.sim_start_date} {hhmm}") + try: + archive_result = await archive_to_long_term(state.persona_name, date_str) + logger.info( + "[WorldEngine] archive: %s", archive_result.get("summary", "")[:80], + ) + state.day_archived = True + except Exception as e: + logger.error("[WorldEngine] archive failed for '%s': %s", state.persona_name, e) + + def _sync_to_world_state(self) -> None: + """Mirror the agent registry into WorldState for frontend queries.""" + for state in self.registry.all_states(): + agent_id = state.agent_id + try: + ws_agent = self.world.get_agent(agent_id) + except KeyError: + continue + + # Update position + if state.position != ws_agent.position: + self.world.move_agent(agent_id, state.position) + + # Update current action + if state.manager and state.manager.current_action: + action = state.manager.current_action + if state.active_conversation: + start_tick = state.active_conversation.get( + "started_tick", state.conversation_start_tick, + ) + end_tick = state.active_conversation.get("ends_at_tick", start_tick + 30) + else: + day_start_tick = self.world.tick - (self.world.tick % (24 * 60)) + start_minute = self._hhmm_to_minutes(action.start_time) + end_minute = self._hhmm_to_minutes(action.end_time) + start_tick = day_start_tick + start_minute + end_tick = day_start_tick + end_minute + if end_minute <= start_minute: + end_tick += 24 * 60 + ca = CurrentAction( + description=action.description, + start_tick=start_tick, + end_tick=end_tick, + target_location_id=action.location_id or state.position.location_id, + ) + self.world.set_agent_action(agent_id, ca) + else: + self.world.clear_agent_action(agent_id) + + async def _drain_conversations_for_handoff(self, timeout_seconds: float) -> int: + """Give active conversations a bounded chance to persist their results.""" + tasks = list(self._conversation_tasks.values()) + if not tasks: + return 0 + + logger.info( + "[WorldEngine] day handoff: waiting up to %.0fs for %d conversation(s)", + timeout_seconds, len(tasks), + ) + _done, pending = await asyncio.wait(tasks, timeout=max(0.0, timeout_seconds)) + if not pending: + return 0 + + logger.warning( + "[WorldEngine] day handoff timed out with %d conversation(s); cancelling them", + len(pending), + ) + timed_out_requests = list(self._pending_conversations.values()) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + + for request in timed_out_requests: + try: + self._end_conversation(self.registry.get(request["agent_a_id"])) + self._end_conversation(self.registry.get(request["agent_b_id"])) + except KeyError: + pass + self._pending_conversations.pop( + self._conversation_key(request["agent_a_id"], request["agent_b_id"]), None, + ) + return len(pending) + + async def _discard_pending_decisions_for_handoff(self) -> int: + """Prevent a prior day's advisory decision from affecting a new day. + + Decision tasks deliberately run in the background so provider latency + cannot stall a tick. Their observation and plan inputs belong to one + simulated day, however, so a result that arrives after midnight must + never be consumed as a next-day replan. + """ + tasks = list(self._decision_tasks.values()) + if not tasks: + return 0 + + pending = [task for task in tasks if not task.done()] + for task in pending: + task.cancel() + # Consume every outcome before releasing the references. Cancelling a + # to_thread wrapper cannot stop its SDK call, but it does ensure its + # eventual result cannot mutate this engine on the following day. + await asyncio.gather(*tasks, return_exceptions=True) + self._decision_tasks.clear() + logger.info( + "[WorldEngine] discarded %d prior-day decision task(s) at handoff", + len(tasks), + ) + return len(tasks) + + async def handoff_to_next_day( + self, + next_date: str, + conversation_timeout_seconds: Optional[float] = None, + ) -> Dict[str, Any]: + """Finish one calendar day without replacing the engine or agents. + + Conversation output is allowed to settle first, then every agent's + completed day is compressed into long-term memory. New plans are + generated with the ending action, location, and wellbeing as explicit + continuity context; positions, brains, relationships, and action + managers remain owned by this same engine instance. + """ + old_date = self.sim_start_date + timeout = ( + _cfg.DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS + if conversation_timeout_seconds is None else conversation_timeout_seconds + ) + cancelled = await self._drain_conversations_for_handoff(timeout) + await self._discard_pending_decisions_for_handoff() + + # Preserve the full replay before resetting the per-day action log. + # History/checkpoint storage is observability, not a reason to stop a + # living simulation if a file is temporarily unavailable. + handoff_warnings: list[str] = [] + try: + save_history(self.world, old_date) + except Exception as exc: + logger.exception("[WorldEngine] unable to save replay for %s", old_date) + handoff_warnings.append(f"history save failed: {exc}") + + async def _archive_and_clear(state: AgentRuntimeState) -> bool: + try: + # Re-index even when we made the early final-activity archive: + # conversations and actions completed afterward must be part of + # the authoritative end-of-day Qdrant snapshot. + await archive_to_long_term(state.persona_name, old_date) + state.day_archived = True + clear_short_term_data(state.persona_name, old_date) + return True + except Exception as exc: + logger.error( + "[WorldEngine] final archive failed for '%s': %s", + state.persona_name, exc, + ) + return False + + archive_results = await asyncio.gather( + *[_archive_and_clear(state) for state in self.registry.all_states()], + return_exceptions=False, + ) + + from src.agents.day_planner import run as day_planner_run + + def _handoff_context(state: AgentRuntimeState) -> str: + action = state.manager.current_action if state.manager else None + action_text = action.description if action else "no active action" + location = state.position.location_id or "their current campus position" + return ( + f"The previous day ended while the agent was {action_text} at {location}. " + f"Energy is {state.energy_level:.2f}/1.0 and emotion is " + f"{state.emotion_state:.2f}/1.0. Continue naturally from this " + "physical and emotional state; do not abruptly relocate them." + ) + + async def _plan_next_day(state: AgentRuntimeState) -> tuple[AgentRuntimeState, list]: + memories, yesterday = self._memory_context( + state.persona_name, state.persona, before_date=next_date, + ) + proxy = SimpleNamespace( + persona=state.persona, + relevant_memories=memories, + yesterday_summary=yesterday, + ) + try: + result = await asyncio.to_thread( + day_planner_run, + proxy, + { + "current_time": f"{next_date} 00:00", + "places": None, + "persona_name": state.persona_name, + "mode": "next_day", + "current_location_id": state.position.location_id, + "handoff_context": _handoff_context(state), + "upcoming_events": self.event_manager.snapshot(next_date, "00:00").get("upcoming", []), + }, + ) + plan = result.get("day_plan", []) + if plan: + return state, plan + except Exception as exc: + logger.error( + "[WorldEngine] next-day plan failed for '%s': %s", + state.persona_name, exc, + ) + # A failed planner must not strand the agent. Reusing the previous + # schedule is the safest fallback because it preserves continuity. + return state, state.day_plan + + # Keep day-handoff planner traffic serial. A single key ring is shared + # across agents, and concurrent full pipelines caused a burst of + # provider failures precisely when a new day was being installed. + planned = [] + for state in self.registry.all_states(): + planned.append(await _plan_next_day(state)) + + self.sim_start_date = next_date + self.sim_start_hhmm = "00:00" + self._day_index += 1 + self._recent_convs.clear() + self._in_range.clear() + self._last_decision_tick.clear() + self._last_obs.clear() + self._tick_observations.clear() + self._applied_event_effects.clear() + self.world.history.clear() + + for state, plan in planned: + state.day_plan = plan + state.day_archived = False + state.conversation_count = 0 + state.replan_count = 0 + state.last_conversation_partner = None + state.conversation_start_tick = 0 + state.active_conversation = None + state.paused = False + if state.manager: + state.manager.begin_new_day(plan) + action = state.manager.tick(self.world.tick) + state.position = state.manager.position + state.current_action = action.model_dump() if action else None + + try: + self._apply_event_opportunities(next_date) + except Exception as exc: + logger.exception("[WorldEngine] unable to apply events for %s", next_date) + handoff_warnings.append(f"event overlay failed: {exc}") + + self._sync_to_world_state() + try: + save_checkpoint( + self.world, + self.registry, + self.world.tick, + engine_state=self.checkpoint_state(), + ) + prune_checkpoints(keep_last=KEEP_LAST) + except Exception as exc: + logger.exception("[WorldEngine] unable to checkpoint handoff at tick %d", self.world.tick) + handoff_warnings.append(f"handoff checkpoint failed: {exc}") + + snapshot = self._frontend_snapshot(self.world.tick, "00:00") + snapshot.update({ + "type": "day_initialized", + "phase": "initialized", + "date": next_date, + "cancelled_conversations": cancelled, + "archived_agents": sum(bool(result) for result in archive_results), + "warnings": handoff_warnings, + "recent_conversations": [], + }) + return snapshot + + # ------------------------------------------------------------------ # + # Main run loop + # ------------------------------------------------------------------ # + + async def run( + self, + max_tick: int = 1440, + on_tick: Optional[callable] = None, + tick_speed: Optional[float] = None, + tick_lock: Optional[asyncio.Lock] = None, + ) -> None: + """Run simulation until world.tick reaches max_tick (absolute). + + If ``on_tick`` is provided, it is called with each tick's snapshot dict + (after the tick is processed). This is used by Odin.py to broadcast + state to WebSocket clients. + + ``tick_speed`` is a speed multiplier (1.0 = configured real-time, 2.0 = + double speed). When None, the value from config (env/CLI) is used. + """ + dynamic_speed = tick_speed is None + initial_speed = _cfg.TICK_SPEED if dynamic_speed else tick_speed + effective_sleep = _cfg.REAL_SECONDS_PER_TICK / max(initial_speed, 1e-9) + logger.info("[WorldEngine] ========== STARTING SIMULATION ==========") + logger.info( + "[WorldEngine] %d agents, max tick %d (current=%d), %.1f real sec/tick (speed=%.1fx)", + len(self.registry), max_tick, self.world.tick, effective_sleep, initial_speed, + ) + + while self.world.tick < max_tick: + if tick_lock is None: + result = await self.run_tick() + else: + async with tick_lock: + result = await self.run_tick() + if not result: + logger.info("[WorldEngine] simulation complete at tick %d", self.world.tick) + break + + # Invoke callback if provided + if on_tick is not None: + try: + await on_tick(result) + except Exception: + logger.exception("[WorldEngine] on_tick callback failed; simulation will continue") + + # Log periodic status + elapsed = self.world.tick + if elapsed % 60 == 0: + active = sum( + 1 for s in self.registry.all_states() + if s.current_action is not None + ) + paused = sum(1 for s in self.registry.all_states() if s.paused) + hhmm = self._minutes_to_hhmm(self.world.tick % (24 * 60)) + logger.info( + "[WorldEngine] tick=%d time=%s agents=%d active=%d paused=%d", + elapsed, hhmm, len(self.registry), active, paused, + ) + + # Read the mutable runtime speed on every iteration so UI controls + # take effect without restarting a day-long run. + active_speed = _cfg.TICK_SPEED if dynamic_speed else tick_speed + await asyncio.sleep(_cfg.REAL_SECONDS_PER_TICK / max(active_speed, 1e-9)) + + logger.info("[WorldEngine] ========== SIMULATION ENDED ==========") + if on_tick is not None: + try: + await on_tick({"type": "simulation_ended", "tick": self.world.tick}) + except Exception: + logger.exception("[WorldEngine] final on_tick callback failed") + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + + @staticmethod + def _minutes_to_hhmm(minutes: int) -> str: + h, m = divmod(minutes, 60) + return f"{h:02d}:{m:02d}" + + @staticmethod + def _hhmm_to_minutes(hhmm: str) -> int: + h, m = hhmm.split(":") + return int(h) * 60 + int(m) + + +# ------------------------------------------------------------------ # +# Standalone CLI entry point +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + import argparse + + from src.core.log import setup_logging + setup_logging(run_id="world_engine", console=True) + + parser = argparse.ArgumentParser(description="WorldEngine โ€” multi-agent simulation") + parser.add_argument( + "--days", type=int, default=1, + help="Number of simulation days to run (default: 1)", + ) + parser.add_argument( + "--start-date", default=_cfg.SIM_START_DATE, + help="Simulation start date (default: from SIM_START_DATE / 2026-07-03)", + ) + parser.add_argument( + "--start-time", default="00:00", + help="Simulation start time HH:MM (default: 00:00)", + ) + parser.add_argument( + "--resume", nargs="?", const=None, default=False, + help="Resume from checkpoint. Pass a tick number, or no arg to prompt.", + ) + # Register all simulation toggles (--tick-speed, --reflex-llm, + # --perception-radius-px, --real-seconds-per-sim-minute, etc.) + _cfg.add_cli_arguments(parser) + args = parser.parse_args() + + # Apply CLI overrides onto config (CLI > .env > built-in defaults). + _cfg.apply_overrides(**_cfg.overrides_from_args(args)) + logger.info("[WorldEngine] active settings:\n%s", _cfg.describe_settings()) + + async def _main() -> None: + if args.resume is not False: + ticks = list_checkpoints() + if not ticks: + logger.error("[WorldEngine] no checkpoints found โ€” cannot resume") + return + + if args.resume is None: + # --resume with no value โ†’ prompt + print(f"\nAvailable checkpoints ({len(ticks)} total, showing last 20):") + for t in ticks[-20:]: + print(f" {t:>5}") + print() + + while True: + tick_str = input("Resume from which tick? (or 'new' to start fresh): ").strip() + if tick_str.lower() == "new": + break + try: + resume_tick = int(tick_str) + if resume_tick in ticks: + break + print(f"Tick {resume_tick} not found. Try again.") + except ValueError: + print("Invalid input. Enter a tick number or 'new'.") + + if tick_str.lower() == "new": + pass # fall through to fresh start + else: + engine = WorldEngine( + sim_start_date=args.start_date, + sim_start_hhmm=args.start_time, + ) + world, registry, checkpoint_state = load_checkpoint( + resume_tick, engine.resolver, return_metadata=True, + ) + engine.world = world + engine.registry = registry + engine.restore_checkpoint_state(checkpoint_state) + await engine.resume_pending_conversations() + logger.info( + "[WorldEngine] resumed from tick %d โ€” %d agents", + resume_tick, len(registry), + ) + day_end_tick = ((engine.world.tick // (24 * 60)) + 1) * (24 * 60) + await engine.run(max_tick=day_end_tick) + save_history(engine.world, engine.sim_start_date) + return + else: + # --resume + resume_tick = int(args.resume) + if resume_tick not in ticks: + logger.error("[WorldEngine] tick %d not found in checkpoints", resume_tick) + return + engine = WorldEngine( + sim_start_date=args.start_date, + sim_start_hhmm=args.start_time, + ) + world, registry, checkpoint_state = load_checkpoint( + resume_tick, engine.resolver, return_metadata=True, + ) + engine.world = world + engine.registry = registry + engine.restore_checkpoint_state(checkpoint_state) + await engine.resume_pending_conversations() + logger.info( + "[WorldEngine] resumed from tick %d โ€” %d agents", + resume_tick, len(registry), + ) + day_end_tick = ((engine.world.tick // (24 * 60)) + 1) * (24 * 60) + await engine.run(max_tick=day_end_tick) + save_history(engine.world, engine.sim_start_date) + return + + # Fresh start + engine = WorldEngine( + sim_start_date=args.start_date, + sim_start_hhmm=args.start_time, + ) + await engine.initialize() + for day in range(args.days): + logger.info("[WorldEngine] --- Day %d ---", day + 1) + day_end_tick = ((engine.world.tick // (24 * 60)) + 1) * (24 * 60) + await engine.run(max_tick=day_end_tick) + if day < args.days - 1: + next_date = ( + datetime.fromisoformat(engine.sim_start_date) + timedelta(days=1) + ).strftime("%Y-%m-%d") + await engine.handoff_to_next_day(next_date) + else: + save_history(engine.world, engine.sim_start_date) + + asyncio.run(_main()) diff --git a/backend/src/core/world_events.py b/backend/src/core/world_events.py new file mode 100644 index 0000000000000000000000000000000000000000..f2c2186ae1184861193e2f123d973f53ede65216 --- /dev/null +++ b/backend/src/core/world_events.py @@ -0,0 +1,246 @@ +"""Deterministic, data-driven campus events and safe plan opportunities. + +The event calendar is deliberately independent of the LLM. It makes an +attendance decision from a persona, its social context, schedule conflicts, +and a seeded tie-breaker, then edits only an entirely-flexible time window. +This keeps festivals and interruptions lively without allowing them to erase +classes, meals, sleep, exams, or an in-progress route. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple + +from pydantic import BaseModel, Field, field_validator, model_validator + +from src.config import ENVIRONMENT_DIR +from src.core.log import get_logger + +logger = get_logger(__name__) + +HARD_COMMITMENT_WORDS = ( + "class", "lecture", "lab", "exam", "quiz", "sleep", "breakfast", + "lunch", "dinner", "medical", "appointment", "deadline", +) + + +def _minutes(value: str) -> int: + hour, minute = value.split(":", 1) + result = int(hour) * 60 + int(minute) + if not 0 <= result <= 24 * 60: + raise ValueError("time must be between 00:00 and 24:00") + return result + + +class WorldEvent(BaseModel): + """One scheduled campus event, loaded from ``events_calendar.json``.""" + + id: str = Field(pattern=r"^[a-z0-9][a-z0-9_-]*$") + name: str = Field(min_length=3, max_length=120) + description: str = Field(min_length=8, max_length=600) + category: str = Field(min_length=3, max_length=40) + date: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$") + start_time: str = Field(pattern=r"^\d{2}:\d{2}$") + end_time: str = Field(pattern=r"^\d{2}:\d{2}$") + location_id: str = Field(min_length=1) + capacity: int = Field(ge=1, le=500) + interest_tags: List[str] = Field(default_factory=list, max_length=12) + eligibility_tags: List[str] = Field(default_factory=list, max_length=12) + energy_effect: float = Field(default=0.0, ge=-0.4, le=0.4) + emotion_effect: float = Field(default=0.0, ge=-0.4, le=0.4) + relationship_effect: float = Field(default=0.0, ge=-0.1, le=0.1) + announcement: str = Field(min_length=3, max_length=280) + + @field_validator("start_time", "end_time") + @classmethod + def validate_time(cls, value: str) -> str: + _minutes(value) + return value + + @model_validator(mode="after") + def validate_duration(self) -> "WorldEvent": + if _minutes(self.end_time) <= _minutes(self.start_time): + raise ValueError("event end_time must be after start_time") + return self + + @property + def duration_minutes(self) -> int: + return _minutes(self.end_time) - _minutes(self.start_time) + + def is_active(self, date: str, hhmm: str) -> bool: + now = _minutes(hhmm) + return self.date == date and _minutes(self.start_time) <= now < _minutes(self.end_time) + + def is_upcoming(self, date: str, hhmm: str, within_minutes: int = 180) -> bool: + now = _minutes(hhmm) + return self.date == date and now < _minutes(self.start_time) <= now + within_minutes + + +class EventCalendar(BaseModel): + schema_version: int = 1 + events: List[WorldEvent] = Field(default_factory=list) + + +class EventAttendance(BaseModel): + event_id: str + agent_id: str + score: float + reason: str + + +class WorldEventManager: + """Loads events, chooses bounded attendance, and creates UI snapshots.""" + + def __init__(self, path: Optional[Path] = None) -> None: + self.path = path or ENVIRONMENT_DIR / "events_calendar.json" + self.calendar = self._load() + self._attendance: Dict[str, List[str]] = {} + + def _load(self) -> EventCalendar: + if not self.path.exists(): + logger.warning("[Events] calendar missing at %s; events disabled", self.path) + return EventCalendar() + try: + loaded = EventCalendar.model_validate_json(self.path.read_text(encoding="utf-8")) + except Exception as exc: + logger.error("[Events] invalid calendar %s: %s; events disabled", self.path, exc) + return EventCalendar() + ids = [event.id for event in loaded.events] + if len(ids) != len(set(ids)): + logger.error("[Events] duplicate event IDs in %s; events disabled", self.path) + return EventCalendar() + logger.info("[Events] loaded %d scheduled events", len(loaded.events)) + return loaded + + def for_date(self, date: str) -> List[WorldEvent]: + return [event for event in self.calendar.events if event.date == date] + + def snapshot(self, date: str, hhmm: str) -> Dict[str, List[Dict[str, Any]]]: + def render(event: WorldEvent) -> Dict[str, Any]: + attendees = self._attendance.get(event.id, []) + return { + "id": event.id, "name": event.name, "description": event.description, + "category": event.category, "start_time": event.start_time, + "end_time": event.end_time, "location_id": event.location_id, + "capacity": event.capacity, "attendee_ids": attendees, + "attendance_count": len(attendees), "announcement": event.announcement, + } + events = self.for_date(date) + return { + "active": [render(event) for event in events if event.is_active(date, hhmm)], + "upcoming": [render(event) for event in events if event.is_upcoming(date, hhmm)], + } + + def restore_attendance(self, attendance: Dict[str, List[str]]) -> None: + self._attendance = { + str(event_id): [str(agent_id) for agent_id in agent_ids] + for event_id, agent_ids in (attendance or {}).items() + } + + def attendance_snapshot(self) -> Dict[str, List[str]]: + return {event_id: list(agent_ids) for event_id, agent_ids in self._attendance.items()} + + @staticmethod + def _is_flexible(action: Dict[str, Any]) -> bool: + text = str(action.get("action", "")).lower() + return not any(word in text for word in HARD_COMMITMENT_WORDS) + + @staticmethod + def _persona_text(persona: Dict[str, Any]) -> str: + return " ".join(str(persona.get(key, "")) for key in ( + "innate", "lifestyle", "hobbies", "goals", "Branch", "interests", + )).lower() + + @staticmethod + def _deterministic_noise(date: str, event_id: str, agent_id: str) -> float: + digest = hashlib.sha256(f"{date}|{event_id}|{agent_id}".encode()).digest() + return (digest[0] / 255.0 - 0.5) * 0.16 + + def _candidate_score( + self, + event: WorldEvent, + date: str, + agent_id: str, + persona: Dict[str, Any], + social_score: float, + ) -> Tuple[float, str]: + text = self._persona_text(persona) + tags = [tag.lower() for tag in event.interest_tags] + matched = [tag for tag in tags if tag in text] + eligible = [tag.lower() for tag in event.eligibility_tags] + if eligible and not any(tag in text for tag in eligible): + return 0.0, "does not meet the event's interest eligibility" + score = 0.35 + min(0.42, 0.14 * len(matched)) + score += max(-0.12, min(0.12, (social_score - 0.5) * 0.24)) + score += self._deterministic_noise(date, event.id, agent_id) + reason = ", ".join(matched[:3]) if matched else "a low-stakes campus opportunity" + return round(score, 3), reason + + @staticmethod + def _can_replace_window(plan: List[Dict[str, Any]], event: WorldEvent) -> bool: + start, end = _minutes(event.start_time), _minutes(event.end_time) + overlaps = [a for a in plan if _minutes(a.get("start", "00:00")) < end and _minutes(a.get("end", "24:00")) > start] + return all(WorldEventManager._is_flexible(action) for action in overlaps) + + @staticmethod + def _insert_event(plan: List[Dict[str, Any]], event: WorldEvent) -> List[Dict[str, Any]]: + """Replace an entirely-flexible slice with a single event action.""" + start, end = _minutes(event.start_time), _minutes(event.end_time) + result: List[Dict[str, Any]] = [] + for action in plan: + a_start, a_end = _minutes(action.get("start", "00:00")), _minutes(action.get("end", "24:00")) + if a_end <= start or a_start >= end: + result.append(dict(action)) + continue + if a_start < start: + before = dict(action) + before["end"] = event.start_time + result.append(before) + if a_end > end: + after = dict(action) + after["start"] = event.end_time + result.append(after) + result.append({ + "start": event.start_time, "end": event.end_time, + "action": f"Attend {event.name}", "location_id": event.location_id, + "sub_area": None, "energy_change": event.energy_effect, + "emotion_change": event.emotion_effect, "world_event_id": event.id, + }) + return sorted(result, key=lambda action: action.get("start", "00:00")) + + def apply_opportunities( + self, + date: str, + plans: Dict[str, List[Dict[str, Any]]], + personas: Dict[str, Dict[str, Any]], + relationship_score: Callable[[str, str], float], + ) -> Dict[str, List[Dict[str, Any]]]: + """Assign at most one compatible event per student, respecting capacity. + + A plan is changed only if every overlapping block is flexible. Since + selected candidates are sorted deterministically, restart/resume produces + the same attendance without requiring an LLM or mutable random state. + """ + output = {agent_id: [dict(action) for action in plan] for agent_id, plan in plans.items()} + selected: set[str] = set() + for event in self.for_date(date): + candidates: List[EventAttendance] = [] + for agent_id, plan in output.items(): + if agent_id in selected or not self._can_replace_window(plan, event): + continue + others = [other for other in personas if other != agent_id] + social = sum(relationship_score(agent_id, other) for other in others) / max(1, len(others)) + score, reason = self._candidate_score(event, date, agent_id, personas[agent_id], social) + if score >= 0.45: + candidates.append(EventAttendance(event_id=event.id, agent_id=agent_id, score=score, reason=reason)) + candidates.sort(key=lambda candidate: (-candidate.score, candidate.agent_id)) + attendees = candidates[:event.capacity] + self._attendance[event.id] = [item.agent_id for item in attendees] + for item in attendees: + output[item.agent_id] = self._insert_event(output[item.agent_id], event) + selected.add(item.agent_id) + logger.info("[Events] %s will attend %s (score=%.2f; %s)", item.agent_id, event.id, item.score, item.reason) + return output diff --git a/backend/src/core/world_state.py b/backend/src/core/world_state.py new file mode 100644 index 0000000000000000000000000000000000000000..e4e35c649b9f951d532decc46aaf1b627e6a6ad5 --- /dev/null +++ b/backend/src/core/world_state.py @@ -0,0 +1,260 @@ +""" +World State -- the single source of truth for the simulation. + +`WorldState` holds everything that is true about the world at a given tick: +where every agent is, what they're currently doing, and who/what currently +holds any contested resource (a chair, an NPC's attention, a location slot). + +Ownership rule: + Only `WorldEngine`'s resolve phase should ever call the mutating methods + on this class directly (`set_agent_action`, `move_agent`, `occupy`, ...). + + Every agent tick graph (perceive -> retrieve -> react -> day_planner -> + act) must only ever see a frozen copy produced by `core/snapshot.py`. + + That separation is what keeps the decide phase safely parallelizable + with asyncio.gather() -- nobody is reading a WorldState that something + else is mutating mid-tick. + +Usage +----- + from src.core.world_state import WorldState, Position + + world = WorldState() + world.register_agent("gurnoor", Position(x=4, y=2, location_id="dorm_room_1")) + world.register_resource("cafeteria_table_3") + + # inside the resolve phase, after an agent's tick graph proposed an action: + world.occupy("cafeteria_table_3", "gurnoor") + world.set_agent_action("gurnoor", CurrentAction( + description="eating breakfast", + start_tick=world.tick, + end_tick=world.tick + 20, + target_object_id="cafeteria_table_3", + )) + + world.advance_tick(minutes=10) +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field + +from src.core.log import get_logger + +# Logging +logger = get_logger(__name__) + + +# Enums / small value types + +class AgentStatus(str, Enum): + IDLE = "idle" # no current action, needs a decision + ACTING = "acting" # mid-action, action_end_tick in the future + INTERRUPTED = "interrupted" # was acting, got interrupted by a perceived event + + +class Position(BaseModel): + """Tile coordinates plus an optional semantic zone name.""" + x: int + y: int + location_id: Optional[str] = None # e.g. "cafeteria", "dorm_room_1" + + +class CurrentAction(BaseModel): + """An action an agent has committed to, spanning [start_tick, end_tick).""" + description: str + start_tick: int + end_tick: int + target_location_id: Optional[str] = None + target_object_id: Optional[str] = None + # set by the resolver if a conflict forced a different outcome than requested + was_reprioritized: bool = False + + def is_finished(self, tick: int) -> bool: + return tick >= self.end_tick + + +class AgentState(BaseModel): + agent_id: str + position: Position + status: AgentStatus = AgentStatus.IDLE + current_action: Optional[CurrentAction] = None + last_updated_tick: int = 0 + + +class ActionLogEntry(BaseModel): + """One row of the append-only history, used later for replay/debugging.""" + tick: int + agent_id: str + action: str + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +# WorldState +class WorldState(BaseModel): + """ + Mutable, canonical world state. + + `agents`: dict of : agent_id -> AgentState + `occupancy`: resource_id (a location slot or object) -> agent_id holding + it, or None if free. Register a resource once with `register_resource` + before anyone can occupy it -- this catches typos in resource ids + early instead of silently creating them on first use. + `history`: append-only action log, source of truth for replay/debugging. + """ + tick: int = 0 + agents: Dict[str, AgentState] = Field(default_factory=dict) + occupancy: Dict[str, Optional[str]] = Field(default_factory=dict) + history: List[ActionLogEntry] = Field(default_factory=list) + + # Conversation cooldown tracking + agent_last_conversation: Dict[str, int] = Field(default_factory=dict) + conversation_cooldown_ticks: int = 30 + + # Agent registry + + def register_agent(self, agent_id: str, position: Position) -> None: + if agent_id in self.agents: + logger.warning("Agent '%s' already registered; overwriting.", agent_id) + self.agents[agent_id] = AgentState( + agent_id=agent_id, position=position, last_updated_tick=self.tick + ) + logger.info( + "Registered agent '%s' at (%d, %d) [tick=%d]", + agent_id, position.x, position.y, self.tick, + ) + + def remove_agent(self, agent_id: str) -> None: + """Remove an agent and all live references during a stopped roster edit.""" + self.release_all_for_agent(agent_id) + self.agents.pop(agent_id, None) + self.agent_last_conversation.pop(agent_id, None) + self.history = [entry for entry in self.history if entry.agent_id != agent_id] + + def get_agent(self, agent_id: str) -> AgentState: + if agent_id not in self.agents: + raise KeyError(f"Unknown agent_id '{agent_id}'") + return self.agents[agent_id] + + def all_agent_ids(self) -> List[str]: + return list(self.agents.keys()) + + # Occupancy / resource contention with agents + + def register_resource(self, resource_id: str) -> None: + """Idempotent -- safe to call every time a location/object is defined.""" + self.occupancy.setdefault(resource_id, None) + + def is_free(self, resource_id: str) -> bool: + if resource_id not in self.occupancy: + logger.warning("Resource '%s' was never registered.", resource_id) + return self.occupancy.get(resource_id) is None + + def occupy(self, resource_id: str, agent_id: str) -> bool: + """ + Attempt to claim a resource for an agent. + + Returns False (and does NOT mutate state) if it's already held by a + *different* agent -- resolver.py is expected to check this return + value and arbitrate, not assume success. + """ + if resource_id not in self.occupancy: + logger.warning("Cannot occupy unregistered resource '%s'.", resource_id) + return False + holder = self.occupancy[resource_id] + if holder is not None and holder != agent_id: + return False + self.occupancy[resource_id] = agent_id + return True + + def release(self, resource_id: str) -> None: + if resource_id in self.occupancy: + self.occupancy[resource_id] = None + + def release_all_for_agent(self, agent_id: str) -> None: + """Call this when clearing/interrupting an agent's action.""" + for resource_id, holder in self.occupancy.items(): + if holder == agent_id: + self.occupancy[resource_id] = None + + # Mutations -- resolve phase only + + def set_agent_action(self, agent_id: str, action: CurrentAction) -> None: + agent = self.get_agent(agent_id) + # The engine mirrors its registry into WorldState twice per tick. That + # mirror must be idempotent: unchanged actions are not new replay + # events, and logging each mirror makes checkpoint history grow with + # every tick rather than with meaningful action transitions. + if agent.status == AgentStatus.ACTING and agent.current_action == action: + return + agent.current_action = action + agent.status = AgentStatus.ACTING + agent.last_updated_tick = self.tick + self.history.append( + ActionLogEntry(tick=self.tick, agent_id=agent_id, action=action.description) + ) + + def move_agent(self, agent_id: str, position: Position) -> None: + agent = self.get_agent(agent_id) + agent.position = position + agent.last_updated_tick = self.tick + + def interrupt_agent(self, agent_id: str) -> None: + """Called by event_bus.py when a perceived event forces a replan.""" + agent = self.get_agent(agent_id) + agent.status = AgentStatus.INTERRUPTED + self.release_all_for_agent(agent_id) + + # Conversation cooldown + + def record_conversation(self, agent_id: str, tick: Optional[int] = None) -> None: + """Record that an agent finished a conversation at ``tick``.""" + self.agent_last_conversation[agent_id] = self.tick if tick is None else tick + + def can_converse(self, agent_id: str, other_id: str) -> bool: + """ + Check if two agents can start a conversation based on cooldown. + Returns False if either agent is still on cooldown. + """ + for aid in (agent_id, other_id): + last = self.agent_last_conversation.get(aid) + if last is not None and self.tick - last < self.conversation_cooldown_ticks: + return False + return True + + def clear_agent_action(self, agent_id: str) -> None: + agent = self.get_agent(agent_id) + agent.current_action = None + agent.status = AgentStatus.IDLE + + # Scheduling helper (used by engine/scheduler.py) + + def agents_ready_for_decision(self) -> List[str]: + """ + Agents who need a decision this tick: no current action at all, + or were flagged INTERRUPTED by the WorldEngine. + Planned action transitions are handled by Actions.py's state + machine -- the tick graph only runs for unplanned gaps or + interruptions. + """ + ready = [] + for agent_id, agent in self.agents.items(): + if ( + agent.current_action is None + or agent.status == AgentStatus.INTERRUPTED + ): + ready.append(agent_id) + return ready + + # Tick control, moving the ticks + + def advance_tick(self, minutes: int = 10) -> None: + if minutes <= 0: + raise ValueError("minutes must be a positive integer") + self.tick += minutes + logger.debug("World tick advanced to %d", self.tick) diff --git a/backend/src/llm/gemini_client.py b/backend/src/llm/gemini_client.py new file mode 100644 index 0000000000000000000000000000000000000000..52c561713c528b182faffb792b5a8efb16d59c67 --- /dev/null +++ b/backend/src/llm/gemini_client.py @@ -0,0 +1,166 @@ +"""Gemini access through a deterministic, head-first key ring. + +Every API call starts with the first configured key. On *any* exception it +tries the next node exactly once. There are deliberately no delays, retries, +timeouts, cooldowns, key reservations, or model changes in this module. +""" + +from __future__ import annotations + +import json +import threading +from dataclasses import dataclass +from typing import Iterator, Type + +from google import genai +from google.genai import types +from pydantic import BaseModel + +from src.config import API_KEYS, GEMINI_MODEL, MEMORY_EMBEDDING_MODEL, TEMPERATURE +from src.core.log import get_logger + +logger = get_logger(__name__) + + +class ProviderFailureError(RuntimeError): + code = "api_quota_exhausted" + title = "API quota exhausted" + guidance = "Every configured API key was unavailable for this request. Check API-key availability and restart from the latest checkpoint." + + def payload(self) -> dict[str, str]: + return { + "code": self.code, + "title": self.title, + "guidance": self.guidance, + "message": str(self), + } + + +class APIQuotaExhaustedError(ProviderFailureError): + """Raised only after one call has tried every configured key once.""" + + +@dataclass(frozen=True) +class _KeyNode: + index: int + key: str + next: "_KeyNode | None" = None + + +class CircularKeyRing: + """Immutable-order circular linked list whose traversal always starts at head.""" + + def __init__(self, keys: list[str]) -> None: + if not keys: + raise ValueError("At least one Gemini API key is required") + nodes = [_KeyNode(index=index + 1, key=key) for index, key in enumerate(keys)] + for index, node in enumerate(nodes): + object.__setattr__(node, "next", nodes[(index + 1) % len(nodes)]) + self.head = nodes[0] + self.size = len(nodes) + + def traverse_from_head(self) -> Iterator[_KeyNode]: + node = self.head + for _ in range(self.size): + yield node + assert node.next is not None + node = node.next + + +_clients: dict[str, genai.Client] = {} +_clients_lock = threading.Lock() +_provider_failure: ProviderFailureError | None = None + + +def _get_client(api_key: str) -> genai.Client: + with _clients_lock: + client = _clients.get(api_key) + if client is None: + client = genai.Client(api_key=api_key) + _clients[api_key] = client + return client + + +def _new_ring() -> CircularKeyRing: + if not API_KEYS: + raise APIQuotaExhaustedError("API quota exhausted: no Gemini API keys are configured.") + return CircularKeyRing(list(API_KEYS)) + + +def _quota_exhausted(model: str, errors: list[Exception]) -> APIQuotaExhaustedError: + global _provider_failure + last = errors[-1] if errors else None + error = APIQuotaExhaustedError( + f"API quota exhausted: all {len(API_KEYS)} configured keys failed one complete circle for {model}. " + f"Last provider error: {last!r}" + ) + _provider_failure = error + return error + + +def provider_failure() -> ProviderFailureError | None: + return _provider_failure + + +def api_quota_exhausted_reason() -> str | None: + return str(_provider_failure) if _provider_failure else None + + +def _record_success(complexity: str) -> None: + global _provider_failure + _provider_failure = None + try: + from src.core.budget import GOVERNOR + GOVERNOR.record(kind=complexity, model=GEMINI_MODEL) + except Exception: + pass + + +def call_gemini( + system_prompt: str, + user_prompt: str, + schema: Type[BaseModel], + complexity: str = "default", + temperature: float = TEMPERATURE, +) -> BaseModel: + """Call exactly one model, moving through the ring on any failure.""" + errors: list[Exception] = [] + for node in _new_ring().traverse_from_head(): + try: + response = _get_client(node.key).models.generate_content( + model=GEMINI_MODEL, + contents=user_prompt, + config=types.GenerateContentConfig( + system_instruction=system_prompt, + response_mime_type="application/json", + response_schema=schema, + temperature=temperature, + thinking_config=types.ThinkingConfig(thinking_level="medium"), + ), + ) + result = response.parsed if getattr(response, "parsed", None) is not None else schema.model_validate(json.loads(response.text)) + logger.info("[gemini] model=%s key_index=%d ok", GEMINI_MODEL, node.index) + _record_success(complexity) + return result + except Exception as exc: + errors.append(exc) + logger.warning("[gemini] model=%s key_index=%d failed (%s); advancing", GEMINI_MODEL, node.index, type(exc).__name__) + raise _quota_exhausted(GEMINI_MODEL, errors) + + +def embed_content(text: str, task_type: str, output_dimensionality: int) -> list[float]: + """Embed once per ring node, always beginning at the first configured key.""" + errors: list[Exception] = [] + for node in _new_ring().traverse_from_head(): + try: + response = _get_client(node.key).models.embed_content( + model=MEMORY_EMBEDDING_MODEL, + contents=text, + config=types.EmbedContentConfig(task_type=task_type, output_dimensionality=output_dimensionality), + ) + _record_success("embedding") + return list(response.embeddings[0].values) + except Exception as exc: + errors.append(exc) + logger.warning("[gemini] embedding key_index=%d failed (%s); advancing", node.index, type(exc).__name__) + raise _quota_exhausted(MEMORY_EMBEDDING_MODEL, errors) diff --git a/backend/tools/restore_original_names.ps1 b/backend/tools/restore_original_names.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..b4a92a90b578daeb265094901ea49a032439d065 --- /dev/null +++ b/backend/tools/restore_original_names.ps1 @@ -0,0 +1,90 @@ +$ErrorActionPreference = 'Stop' + +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$ids = [ordered]@{ + asa = 'ankit'; briar_noel = 'ansh_batra'; corin_vale = 'anubhav_prasad' + dale_whitman = 'ghanisht_kaushal'; ellery_quinn = 'gurnoor_singh' + finley_ashford = 'lavanya_sharma'; gray_wilder = 'parv_singla' + hollis_bowen = 'riya_murarka'; ivy = 'saksham'; jules = 'tanishq' +} +$names = [ordered]@{ + Asa = 'Ankit'; 'Briar Noel' = 'Ansh Batra'; 'Corin Vale' = 'Anubhav Prasad' + 'Dale Whitman' = 'Ghanisht Kaushal'; 'Ellery Quinn' = 'Gurnoor Singh' + 'Finley Ashford' = 'Lavanya Sharma'; 'Gray Wilder' = 'Parv Singla' + 'Hollis Bowen' = 'Riya Murarka'; Ivy = 'Saksham'; Jules = 'Tanishq' +} + +function Convert-Names([string]$text) { + foreach ($entry in $names.GetEnumerator()) { $text = $text.Replace($entry.Key, $entry.Value) } + foreach ($entry in $ids.GetEnumerator()) { + $text = [regex]::Replace($text, "(? str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _fetch_json(url: str, timeout: float) -> tuple[dict[str, Any], float]: + started = time.perf_counter() + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + return payload, round((time.perf_counter() - started) * 1000, 1) + + +def _fetch_ui_health(url: str, timeout: float) -> tuple[int, int, float]: + """Check that Odin is serving the frontend shell without a browser/LLM.""" + started = time.perf_counter() + request = urllib.request.Request(url, headers={"Accept": "text/html"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + body = response.read() + return response.status, len(body), round((time.perf_counter() - started) * 1000, 1) + + +def _agent_view(agent_id: str, agent: dict[str, Any]) -> dict[str, Any]: + action = agent.get("current_action") or {} + position = agent.get("position") or {} + conversation = agent.get("conversation") or {} + if not isinstance(conversation, dict): + conversation = {"partner_name": str(conversation)} + return { + "agent_id": agent_id, + "name": agent.get("name", agent_id), + "location_id": position.get("location_id"), + "position": {"x": position.get("x"), "y": position.get("y")}, + "activity": agent.get("activity"), + "action_type": action.get("action_type"), + "action_start": action.get("start_time"), + "action_end": action.get("end_time"), + "action_location_id": action.get("location_id"), + "paused": bool(agent.get("paused")), + "in_conversation": bool(agent.get("in_conversation")), + "conversation_with": conversation.get("with") or conversation.get("partner_name"), + "energy": agent.get("energy_level"), + "emotion": agent.get("emotion_state"), + } + + +class SidecarMonitor: + def __init__(self, url: str, interval: float, timeout: float, output_dir: Path, + max_samples: int | None = None) -> None: + self.base_url = url.rstrip("/") + self.url = self.base_url + "/api/sim/state" + self.interval = interval + self.timeout = timeout + output_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.jsonl_path = output_dir / f"sidecar_{stamp}.jsonl" + self.report_path = output_dir / f"sidecar_{stamp}.txt" + self._jsonl = self.jsonl_path.open("a", encoding="utf-8", buffering=1) + self._report = self.report_path.open("a", encoding="utf-8", buffering=1) + self.previous_tick: int | None = None + self.previous_agents: dict[str, dict[str, Any]] = {} + self.paused_since: dict[str, int] = {} + self.failures = 0 + self.findings: Counter[str] = Counter() + self.samples = 0 + self.max_samples = max_samples + + def write(self, kind: str, data: dict[str, Any]) -> None: + record = {"observed_at": _utc_now(), "kind": kind, **data} + self._jsonl.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") + + def note(self, level: str, message: str, **data: Any) -> None: + self.findings[level] += 1 + self.write("finding", {"level": level, "message": message, **data}) + self._report.write(f"[{_utc_now()}] {level.upper()}: {message}\n") + + def observe(self, snapshot: dict[str, Any], latency_ms: float) -> None: + self.samples += 1 + tick = snapshot.get("tick") + agents = snapshot.get("agents") or {} + health = snapshot.get("health") or {} + running = (snapshot.get("simulation") or {}).get("running") + agent_views = {agent_id: _agent_view(agent_id, agent) for agent_id, agent in agents.items()} + self.write("snapshot", { + "tick": tick, "time": snapshot.get("time"), "day": snapshot.get("day"), + "running": running, "latency_ms": latency_ms, "health": health, + "recent_conversations": snapshot.get("recent_conversations", []), + "events": snapshot.get("events", {}), "agents": agent_views, + }) + + # UI health is represented by the exact payload the frontend consumes. + if not isinstance(agents, dict) or not isinstance(health, dict): + self.note("error", "frontend state payload is malformed", tick=tick) + return + if running is not True: + self.note("warning", "simulation reports not running", tick=tick) + if health.get("healthy") is False or health.get("anomalies"): + self.note("error", "server health monitor reported anomalies", tick=tick, + anomalies=health.get("anomalies", [])) + if self.previous_tick is not None and isinstance(tick, int) and running and tick <= self.previous_tick: + self.note("warning", "simulation tick did not advance", previous_tick=self.previous_tick, tick=tick) + + for agent_id, view in agent_views.items(): + if not view["location_id"] or not view["activity"] or not view["action_type"]: + self.note("error", f"{view['name']} has incomplete rendered state", tick=tick, agent=view) + if view["action_type"] != "move" and view["action_location_id"] and view["location_id"] != view["action_location_id"]: + self.note("warning", f"{view['name']} location/action mismatch", tick=tick, agent=view) + if view["paused"] or view["in_conversation"]: + self.paused_since.setdefault(agent_id, tick if isinstance(tick, int) else -1) + started = self.paused_since[agent_id] + if isinstance(tick, int) and started >= 0 and tick - started >= 30: + self.note("warning", f"{view['name']} paused or conversing for 30+ ticks", tick=tick, agent=view) + else: + self.paused_since.pop(agent_id, None) + + before = self.previous_agents.get(agent_id) + if before and any(before.get(field) != view.get(field) for field in ("location_id", "activity", "in_conversation", "paused")): + self.write("agent_transition", {"tick": tick, "time": snapshot.get("time"), + "before": before, "after": view}) + + self.previous_tick = tick if isinstance(tick, int) else self.previous_tick + self.previous_agents = agent_views + try: + status, bytes_served, ui_latency = _fetch_ui_health(self.base_url + "/", self.timeout) + self.write("ui_health", {"tick": tick, "status": status, "bytes": bytes_served, + "latency_ms": ui_latency, "healthy": status == 200 and bytes_served > 0}) + if status != 200 or bytes_served <= 0: + self.note("error", "frontend shell is not healthy", tick=tick, status=status, bytes=bytes_served) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + self.note("error", f"frontend shell unavailable: {exc}", tick=tick) + self._report.write( + f"[{_utc_now()}] tick={tick} day={snapshot.get('day')} time={snapshot.get('time')} " + f"agents={len(agent_views)} healthy={health.get('healthy')} latency={latency_ms}ms " + f"moving={health.get('moving')} paused={health.get('paused')} conversations={health.get('conversations')}\n" + ) + + def run(self) -> None: + self._report.write(f"Sidecar started: endpoint={self.url}, interval={self.interval}s\n") + try: + while True: + try: + snapshot, latency_ms = _fetch_json(self.url, self.timeout) + self.observe(snapshot, latency_ms) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: + self.failures += 1 + self.note("error", f"state endpoint unavailable: {exc}") + if self.max_samples is not None and self.samples >= self.max_samples: + self._report.write("Sidecar reached requested sample limit.\n") + break + time.sleep(self.interval) + except KeyboardInterrupt: + self._report.write("Sidecar stopped by user.\n") + finally: + self.write("summary", {"samples": self.samples, "endpoint_failures": self.failures, + "findings": dict(self.findings)}) + self._report.write( + f"Summary: samples={self.samples}, endpoint_failures={self.failures}, " + f"findings={dict(self.findings)}\n" + ) + self._jsonl.close() + self._report.close() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Read-only Valhalla simulation sidecar monitor") + parser.add_argument("--url", default="http://127.0.0.1:8000", help="Odin base URL") + parser.add_argument("--interval", type=float, default=5.0, help="poll interval in seconds") + parser.add_argument("--timeout", type=float, default=4.0, help="HTTP timeout in seconds") + parser.add_argument("--output-dir", type=Path, default=Path("backend/output/sidecar_monitor")) + parser.add_argument("--max-samples", type=int, help="stop after this many successful samples (test/helper mode)") + args = parser.parse_args() + if args.interval <= 0 or args.timeout <= 0: + parser.error("--interval and --timeout must be positive") + if args.max_samples is not None and args.max_samples <= 0: + parser.error("--max-samples must be positive") + monitor = SidecarMonitor(args.url, args.interval, args.timeout, args.output_dir, args.max_samples) + print(f"Writing monitor output to {monitor.jsonl_path} and {monitor.report_path}") + monitor.run() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bot.js b/bot.js deleted file mode 100644 index 66b47b797a0736c031b83df5bf85f2814ddaeb91..0000000000000000000000000000000000000000 --- a/bot.js +++ /dev/null @@ -1,260 +0,0 @@ -// This bot uses the whatsapp-web.js library to listen for messages. -// It responds to any personal message, and any group message where it is mentioned. -// It forwards the user's query to an external API and replies with the response. - -// Import necessary libraries -const { Client, LocalAuth } = require('whatsapp-web.js'); -const qrcode = require('qrcode-terminal'); -const QRCode = require('qrcode'); -const axios = require('axios'); // Used for making HTTP requests -const express = require('express'); -const http = require('http'); -const socketIo = require('socket.io'); -const path = require('path'); - -console.log("Initializing WhatsApp Bot with Web Interface..."); - -// Initialize Express app and Socket.io -const app = express(); -const server = http.createServer(app); -const io = socketIo(server); - -// Serve static files -app.use(express.static('public')); -app.use(express.json()); - -// Initialize the WhatsApp client -const client = new Client({ - authStrategy: new LocalAuth() -}); - -let stats = { - totalMessages: 0, - apiCalls: 0, - startTime: Date.now() -}; - -// Event fired when a QR code is generated. -client.on('qr', async (qr) => { - console.log('QR Code Received, please scan with your phone.'); - qrcode.generate(qr, { small: true }); - - // Generate QR code for web interface - try { - const qrCodeDataURL = await QRCode.toDataURL(qr); - io.emit('qr', qrCodeDataURL); - } catch (err) { - console.error('Error generating QR code:', err); - } -}); - -// Event fired when the client has successfully authenticated. -client.on('authenticated', () => { - console.log('Authentication successful!'); - io.emit('authenticated'); -}); - -// Event fired when the client is ready to send and receive messages. -client.on('ready', () => { - console.log('Client is ready! Listening for messages...'); - io.emit('ready'); -}); -function formatMessage(text) { - if (!text) return text; - - // Remove ... blocks first - text = text.replace(/[\s\S]*?<\/think>/g, ''); - - // Look for "Final Answer" followed by action_input - const finalAnswerMatch = text.match(/"action":\s*"Final Answer"[\s\S]*?"action_input":\s*"([^"]+)"/); - if (finalAnswerMatch) { - return finalAnswerMatch[1] - .replace(/\\n/g, '\n') // Convert \n to newlines - .replace(/\*\*(.*?)\*\*/g, '*$1*') // Convert **bold** to *bold* - .replace(/_(.*?)_/g, '_$1_') // Keep _italic_ as is (WhatsApp uses this) - .replace(/\\"/g, '"') // Convert \" to " - .replace(/\\\\/g, '\\'); // Convert \\ to \ - } - return text - .replace(/\\n/g, '\n') - .replace(/\*\*(.*?)\*\*/g, '*$1*') - .replace(/_(.*?)_/g, '_$1_') - .replace(/\\"/g, '"') - .replace(/\\\\/g, '\\'); -} - -// Event fired on incoming messages. -client.on('message_create', async (msg) => { - // Stop the bot from replying to its own messages to prevent a loop. - if (msg.fromMe) return; - - try { - const chat = await msg.getChat(); - let command = ''; - let shouldReply = false; - - // Update stats and emit to web interface - stats.totalMessages++; - io.emit('message_received', { - from: msg.from, - body: msg.body, - timestamp: new Date().toISOString() - }); - - // Case 1: Message is from a group chat. - if (chat.isGroup) { - const mentions = await msg.getMentions(); - const botIsMentioned = mentions.some(contact => contact.isMe); - - if (botIsMentioned) { - console.log(`[GROUP: ${chat.name}] Bot was mentioned by ${msg.from}`); - shouldReply = true; - command = msg.body.replace(`@${client.info.wid.user}`, '').trim(); - } - } else { // Case 2: Message is from a personal chat. - console.log(`[PERSONAL] Received message from ${msg.from}`); - shouldReply = true; - command = msg.body.trim(); - } - - // If the bot was triggered, process the command. - if (shouldReply) { - // If there's no actual command after the mention, send a default message. - if (!command) { - await msg.reply('You mentioned me! How can I help?'); - return; - } - - // Set the chat state to "typing..." - await chat.sendStateTyping(); - - try { - // --- API Logic --- - console.log(`Sending query to API: "${command}"`); - - // Update stats and emit to web interface - stats.apiCalls++; - io.emit('api_call', { - query: command, - timestamp: new Date().toISOString() - }); - - const payload = { - query: command, - user_uuid: "aiva" - }; - - const response = await axios.post('https://iotacluster-ragnarok-stable.hf.space/chat', payload); - - // Assuming the API response has a string in `response.data.output` - const apiOutput = response.data; - let replyMessage = "Sorry, I couldn't understand the response from the server."; - - if (apiOutput && typeof apiOutput.response === 'string') { - replyMessage = apiOutput.response; - } else { - console.error("Unexpected API response format:", apiOutput); - } - - // Reply directly to the message with the API's output. - console.log(`Ragnarok gave "${replyMessage}"`); - replyMessage = formatMessage(replyMessage) - await msg.reply(replyMessage); - - } catch (apiError) { - console.error("API call failed:", apiError.message); - await msg.reply("Sorry, I couldn't connect to the server to get a response. Please try again later."); - } - - // Clear the chat state after replying - await chat.clearState(); - } - } catch (error) { - console.error('An error occurred in message handler:', error); - io.emit('log', `Error in message handler: ${error.message}`); - } -}); - -// Event fired if the client fails to auth. -client.on('auth_failure', msg => { - console.error('AUTHENTICATION FAILURE', msg); - io.emit('log', `Authentication failure: ${msg}`); -}); - -// Web API endpoints -app.get('/', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); -}); - -app.post('/api/test', async (req, res) => { - try { - const { query } = req.body; - if (!query) { - return res.status(400).json({ error: 'Query is required' }); - } - - console.log(`Test API call: "${query}"`); - io.emit('log', `Test API call: ${query}`); - - const payload = { - query: query, - user_uuid: "aiva_test" - }; - - const response = await axios.post('https://iotacluster-ragnarok-stable.hf.space/chat', payload); - const apiOutput = response.data; - - let replyMessage = "Sorry, I couldn't understand the response from the server."; - if (apiOutput && typeof apiOutput.response === 'string') { - replyMessage = apiOutput.response; - } - - const formattedMessage = formatMessage(replyMessage); - - res.json({ - success: true, - query: query, - response: formattedMessage, - raw_response: replyMessage - }); - - } catch (error) { - console.error('Test API call failed:', error.message); - res.status(500).json({ - error: 'API call failed', - message: error.message - }); - } -}); - -app.get('/api/stats', (req, res) => { - res.json({ - ...stats, - uptime: Date.now() - stats.startTime, - connected: client.info ? true : false - }); -}); - -// Socket.io connection handling -io.on('connection', (socket) => { - console.log('Web client connected'); - io.emit('log', 'Web dashboard connected'); - - // Send current stats to new connection - socket.emit('stats', stats); - - socket.on('disconnect', () => { - console.log('Web client disconnected'); - }); -}); - -// Start the web server -const PORT = 7860; -server.listen(PORT, () => { - console.log(`Web dashboard available at http://localhost:${PORT}`); - io.emit('log', `Web dashboard started on port ${PORT}`); -}); - -// Start the WhatsApp client -client.initialize(); - diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 349cedcfabc5d98aa08a832c7bd62172e330193b..0000000000000000000000000000000000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: '3.8' - -services: - aiva-bot: - build: . - ports: - - "3000:3000" - volumes: - - ./data:/app/.wwebjs_auth - - ./cache:/app/.wwebjs_cache - environment: - - NODE_ENV=production - - PORT=3000 - restart: unless-stopped diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..292a14c0850c8f95325a22df1e500b3a827ba26e --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL= diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..0eede8cf2ad2c8144ae9de780ccfa915c0af975f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + Valhalla โ€” Simulation Viewer + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..cb804fcc214dc781fca635c98f1c4e2fa7bef64d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1028 @@ +{ + "name": "valhalla-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "valhalla-frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-draggable": "^4.4.6" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.3", + "vite": "^8.1.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-draggable": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.0.tgz", + "integrity": "sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..5ac2dea2e1215136374a592127fb5d30e6fc7dd7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "valhalla-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-draggable": "^4.4.6" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.3", + "vite": "^8.1.1" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..dcaa73fb188fb7c03143db3642fcd9586e913ba0 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,98 @@ +:root { + --bg-deep: #000000; + --bg-surface: #0a0a0a; + --bg-panel: rgba(10, 10, 10, 0.88); + --gold: #d4a04a; + --gold-dim: #a07830; + --steel: #5b7db5; + --steel-dim: #3a5075; + --text: #d0d0da; + --text-dim: #6b6b78; + --text-bright: #eeeef4; + --border: rgba(212, 160, 74, 0.12); + --border-glow: rgba(212, 160, 74, 0.25); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #root { + width: 100%; + height: 100%; + overflow: hidden; + background: var(--bg-deep); + font-family: 'Outfit', sans-serif; + color: var(--text); +} + +.app-root { + width: 100%; + height: 100%; + position: relative; +} + +.simulation-error { + position: absolute; + z-index: 20; + left: 50%; + top: 50%; + width: min(440px, calc(100vw - 40px)); + transform: translate(-50%, -50%); + padding: 20px 22px; + border: 1px solid rgba(238, 103, 103, 0.55); + border-left: 4px solid #ee6767; + background: rgba(16, 8, 8, 0.94); + box-shadow: 0 18px 55px rgba(0, 0, 0, 0.55); + color: var(--text-bright); + animation: fadeIn 180ms ease-out; +} + +.simulation-error__eyebrow { + display: block; + margin-bottom: 8px; + color: #ff8b8b; + font-family: 'Outfit', sans-serif; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +.simulation-error p { + line-height: 1.45; +} + +.simulation-error small { + display: block; + margin-top: 12px; + color: var(--text-dim); + line-height: 1.4; +} + +canvas { + display: block; +} + +::-webkit-scrollbar { + width: 4px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: rgba(212, 160, 74, 0.2); + border-radius: 2px; +} + +::selection { + background: rgba(212, 160, 74, 0.3); + color: var(--text-bright); +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000000000000000000000000000000000000..490935ebe75b43e7367f54d7b67cd1aa020c4dd3 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,179 @@ +import { useState, useEffect, useCallback } from "react"; +import useSimState from "./hooks/useSimState"; +import { AuthProvider, useAuth } from "./hooks/useAuth"; +import SimCanvas from "./components/SimCanvas"; +import AgentWindow from "./components/AgentWindow"; +import InfoBar from "./components/InfoBar"; +import ConversationFeed from "./components/ConversationFeed"; +import EventsPanel from "./components/EventsPanel"; +import DebugPanel from "./components/DebugPanel"; +import RosterManager from "./components/RosterManager"; +import LoginButton from "./components/LoginButton"; +import { apiUrl } from "./utils/api"; +import "./App.css"; + +function compactTabPosition(index) { + const side = index % 2; + const row = Math.floor(index / 2); + return { + // Reserve the full inspector width even while this card is compact. A + // right-hand card can then expand without its controls leaving the view. + x: side ? Math.max(16, window.innerWidth - 276) : 16, + y: 66 + row * 92, + }; +} + +export default function App() { + return ( + + + + ); +} + +function AppContent() { + const { token, isAuthenticated } = useAuth(); + const snapshot = useSimState(); + const [renderSnapshot, setRenderSnapshot] = useState(null); + const [agentMap, setAgentMap] = useState(null); + const [focusedId, setFocusedId] = useState(null); + const [expandedAgentIds, setExpandedAgentIds] = useState(() => new Set()); + const [showDebug, setShowDebug] = useState(false); + const [conversationFeedMinimized, setConversationFeedMinimized] = useState(false); + const [controlError, setControlError] = useState(null); + const [simulationRunning, setSimulationRunning] = useState(true); + const [rosterOpen, setRosterOpen] = useState(false); + + useEffect(() => { + if (!snapshot) return; + // A handoff packet is a transient status message without `agents`. + // Preserve the latest cards until the next full state arrives. + if (snapshot.type === "day_reset" || snapshot.type === "reset") { + setAgentMap(null); + setFocusedId(null); + setExpandedAgentIds(new Set()); + return; + } + if (snapshot.agents) { + setAgentMap(snapshot.agents); + } + // Handoff status packets intentionally omit a full world frame. Keep the + // last complete frame rendered until the first new-day frame arrives. + if (snapshot.tick != null && snapshot.time != null && snapshot.day != null) { + setRenderSnapshot(snapshot); + } + if (typeof snapshot.simulation?.running === "boolean") { + setSimulationRunning(snapshot.simulation.running); + } + }, [snapshot]); + + const providerFailure = snapshot?.status === "provider_failure" ? snapshot.failure : null; + const simulationError = (snapshot?.status === "error" || providerFailure) ? snapshot.message : null; + const agentIds = agentMap ? Object.keys(agentMap) : []; + + function toggleAgentInspector(agentId) { + const isExpanded = expandedAgentIds.has(agentId); + setExpandedAgentIds((previous) => { + const next = new Set(previous); + if (next.has(agentId)) next.delete(agentId); + else next.add(agentId); + return next; + }); + if (isExpanded && focusedId === agentId) setFocusedId(null); + else if (!isExpanded) setFocusedId(agentId); + } + + const focusFromMap = useCallback((agentId) => { + setFocusedId(agentId); + if (agentId) { + setExpandedAgentIds((previous) => { + if (previous.has(agentId)) return previous; + const next = new Set(previous); + next.add(agentId); + return next; + }); + } + }, []); + + async function sendControl(path, body) { + try { + setControlError(null); + const headers = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + const response = await fetch(apiUrl(path), { + method: "POST", + headers, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || data.detail || "Simulation control failed."); + return data; + } catch (error) { + setControlError(error.message); + return null; + } + } + + return ( +
+ + + {simulationError && ( +
+ {providerFailure?.title || "Simulation unavailable"} +

{simulationError}

+ {providerFailure?.guidance || "Runtime data was left unchanged. Start a fresh simulation when you are ready."} +
+ )} + {controlError && ( +
+ Timeline control +

{controlError}

+
+ )} + + setConversationFeedMinimized((value) => !value)} + /> + + {showDebug && } + setRosterOpen(false)} simulationRunning={simulationRunning} onError={setControlError} isAuthenticated={isAuthenticated} /> + + {agentIds.map((id, index) => { + const expanded = expandedAgentIds.has(id); + const expandedLayer = [...expandedAgentIds].indexOf(id); + return ( + toggleAgentInspector(id)} + /> + ); + })} + + setShowDebug((value) => !value)} + onFastForward={() => sendControl("/api/sim/fast-forward")} + onSlowDown={() => sendControl("/api/sim/slow-down")} + onRewind={(rewind) => sendControl("/api/sim/rewind", rewind)} + simulationRunning={simulationRunning} + onToggleSimulation={async () => { + const result = await sendControl(simulationRunning ? "/api/sim/stop" : "/api/sim/start"); + if (result && typeof result.running === "boolean") setSimulationRunning(result.running); + }} + onToggleRoster={() => setRosterOpen((value) => !value)} + isAuthenticated={isAuthenticated} + /> + +
+ ); +} diff --git a/frontend/src/components/ActionDetail.jsx b/frontend/src/components/ActionDetail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6e238bff14d480b010f25db8109a3d3309e0f3fc --- /dev/null +++ b/frontend/src/components/ActionDetail.jsx @@ -0,0 +1,51 @@ +export default function ActionDetail({ action }) { + if (!action) { + return ( +
+ Idle +
+ ); + } + + const timeRange = action.start_time && action.end_time + ? `${action.start_time} โ€“ ${action.end_time}` + : null; + const routeProgress = action.action_type === "move" && action.path?.length > 1 + ? Math.round(((action.path_index || 0) / (action.path.length - 1)) * 100) + : null; + + return ( +
+
+ {action.description} +
+ {timeRange && ( +
+ {timeRange} +
+ )} + {routeProgress != null && ( +
+ ROUTE {Math.max(0, Math.min(100, routeProgress))}% +
+ )} +
+ ); +} diff --git a/frontend/src/components/AgentWindow.jsx b/frontend/src/components/AgentWindow.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2fcf21e45fdda90c9f6e44ceab877f27b0762e1b --- /dev/null +++ b/frontend/src/components/AgentWindow.jsx @@ -0,0 +1,256 @@ +import { useRef, useState, useEffect } from "react"; +import Draggable from "react-draggable"; +import WindowHeader from "./WindowHeader"; +import ActionDetail from "./ActionDetail"; +import ChatPanel from "./ChatPanel"; + +function emotionLabel(value) { + if (value == null) return "Neutral"; + if (value >= 0.84) return "Elated"; + if (value >= 0.68) return "Upbeat"; + if (value >= 0.56) return "Content"; + if (value >= 0.44) return "Neutral"; + if (value >= 0.30) return "Low"; + if (value >= 0.16) return "Stressed"; + return "Overwhelmed"; +} + +export default function AgentWindow({ agentId, data, speed, defaultPosition, expanded = false, expandedLayer = 0, onToggle }) { + const nodeRef = useRef(null); + const [position, setPosition] = useState(defaultPosition); + const [revealedCounts, setRevealedCounts] = useState({}); + + const action = data.current_action; + const conversation = data.conversation; + const paused = data.paused; + const conversationId = conversation + ? `${conversation.partner_id || conversation.partner_name}_${conversation.started_tick ?? "pending"}` + : null; + // A transcript belongs to the live conversation state, not to the agent + // card's lifetime. The backend clears this state when the simulated chat + // finishes; rendering only an active/generating conversation automatically + // collapses the chat panel as the agent starts their next task. + const showConversation = Boolean( + conversation?.messages?.length + && ["generating", "active"].includes(conversation.status) + ); + + // Staged message reveal: when a new conversation arrives, reveal messages + // one-by-one based on duration / message count. + const realMsPerSimMinute = speed?.real_ms_per_sim_minute || 40000; + useEffect(() => { + if (!conversation?.messages?.length || !conversation.duration_minutes) return; + const key = conversationId; + const msgs = conversation.messages.length; + const totalMs = conversation.duration_minutes * realMsPerSimMinute; + const delayPerMsg = totalMs / msgs; + + setRevealedCounts((prev) => ({ ...prev, [key]: 0 })); + + let i = 0; + const timer = setInterval(() => { + i++; + setRevealedCounts((prev) => { + if ((prev[key] || 0) >= msgs) return prev; + return { ...prev, [key]: i }; + }); + if (i >= msgs) clearInterval(timer); + }, delayPerMsg); + + return () => clearInterval(timer); + }, [conversationId, conversation?.messages?.length, conversation?.duration_minutes]); + + const locationLabel = action?.action_type === "move" + ? `EN ROUTE โ†’ ${action.location_id || "destination"}` + : data.position?.location_id || null; + const pos = data.position + ? `(${Math.round(data.position.x)}, ${Math.round(data.position.y)})` + : null; + + // `react-draggable` bounds movement using the card's size when the drag + // starts. A compact card can therefore be placed near an edge and become + // partly off-screen when its details increase the size. Clamp the saved + // position after every expand/collapse transition as a second boundary. + useEffect(() => { + const node = nodeRef.current; + if (!node) return; + const maxX = Math.max(0, window.innerWidth - node.offsetWidth); + const maxY = Math.max(0, window.innerHeight - node.offsetHeight); + setPosition((previous) => ({ + x: Math.min(Math.max(0, previous.x), maxX), + y: Math.min(Math.max(0, previous.y), maxY), + })); + }, [expanded]); + + return ( + setPosition({ x: dragData.x, y: dragData.y })} + handle=".agent-window-drag-handle" + cancel=".agent-window-toggle, .agent-window-title" + bounds="parent" + > +
+
+ + โ‰ก + + + +
+ + {!expanded ? ( +
+ + {data.position?.location_id || "UNKNOWN"} + + ยท + {data.activity || "Idle"} +
+ ) : ( + <> + {/* Location */} +
+ {locationLabel ? `${locationLabel} ${pos}` : pos} +
+ + + + {action?.event_id && ( +
+ Campus event ยท {action.event_id} +
+ )} + + {/* Energy bar */} +
+
+ ENERGY ร‚ยท {Math.round((data.energy_level ?? 0) * 100)}% +
+
+
+
+
+ + {/* Emotion label */} +
+ EMOTION: {emotionLabel(data.emotion_state)} ร‚ยท {Math.round((data.emotion_state ?? 0.5) * 100)}% +
+ + {paused && !conversation && ( +
+ Paused +
+ )} + + {/* The panel unmounts when the next non-chat action starts. */} + {showConversation && ( + + )} + + )} +
+ + ); +} diff --git a/frontend/src/components/ChatBubble.jsx b/frontend/src/components/ChatBubble.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ad3fb4dc49a5410425d5e382f2888b5b4c904fc1 --- /dev/null +++ b/frontend/src/components/ChatBubble.jsx @@ -0,0 +1,27 @@ +export default function ChatBubble({ text, isSelf, color }) { + return ( +
+
+ {text} +
+
+ ); +} diff --git a/frontend/src/components/ChatPanel.jsx b/frontend/src/components/ChatPanel.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c8751582abbb6aebdbae43feb9c1b48d51cd564e --- /dev/null +++ b/frontend/src/components/ChatPanel.jsx @@ -0,0 +1,56 @@ +import { useEffect, useRef } from "react"; +import ChatBubble from "./ChatBubble"; + +export default function ChatPanel({ conversation, selfId, color, revealedCount }) { + const bottomRef = useRef(null); + const messages = conversation?.messages || []; + const visible = revealedCount != null ? messages.slice(0, revealedCount) : messages; + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [visible.length]); + + if (!messages.length) return null; + + return ( +
+
+ Chat with {conversation.partner_name} +
+ {visible.map((msg, i) => ( + + ))} + {revealedCount != null && visible.length < messages.length && ( +
+ ยทยทยท +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ConversationFeed.jsx b/frontend/src/components/ConversationFeed.jsx new file mode 100644 index 0000000000000000000000000000000000000000..03699b09e02e7559a03cff9bbc30a9b96976ff89 --- /dev/null +++ b/frontend/src/components/ConversationFeed.jsx @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from "react"; +import Draggable from "react-draggable"; + +const SENTIMENT_COLOR = { + positive: "#51cf66", + neutral: "#8a8a96", + negative: "#ff6b6b", +}; + +export default function ConversationFeed({ conversations, minimized = false, onToggleMinimized }) { + const nodeRef = useRef(null); + const entries = Array.isArray(conversations) ? conversations : []; + const [position, setPosition] = useState(() => ({ + x: Math.max(0, window.innerWidth - 306), + y: 16, + })); + + // Keep the panel usable after resizing or minimizing, matching agent cards. + useEffect(() => { + const clampToViewport = () => { + const node = nodeRef.current; + if (!node) return; + setPosition((previous) => ({ + x: Math.min(Math.max(0, previous.x), Math.max(0, window.innerWidth - node.offsetWidth)), + y: Math.min(Math.max(0, previous.y), Math.max(0, window.innerHeight - node.offsetHeight)), + })); + }; + clampToViewport(); + window.addEventListener("resize", clampToViewport); + return () => window.removeEventListener("resize", clampToViewport); + }, [minimized, conversations?.length]); + + return ( + setPosition({ x: dragData.x, y: dragData.y })} + handle=".conversation-feed-drag-handle" + cancel=".conversation-feed-toggle" + bounds="parent" + > +
+
+ + โ‰ก + + Conversation Feed + +
+ {!minimized && entries.length === 0 && ( +
+ No conversations yet today. +
+ )} + {!minimized && entries.map((conversation, index) => ( +
+
+ + + {(conversation.participants || []).join(" & ")} + + + {conversation.time} + +
+
{conversation.summary}
+ {conversation.location && ( +
+ @ {conversation.location} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/DebugPanel.jsx b/frontend/src/components/DebugPanel.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c373efe08bc926bc886a2f25cc3e63348a39eb21 --- /dev/null +++ b/frontend/src/components/DebugPanel.jsx @@ -0,0 +1,22 @@ +export default function DebugPanel({ health }) { + if (!health) return null; + return ( + + ); +} diff --git a/frontend/src/components/EventsPanel.jsx b/frontend/src/components/EventsPanel.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a152cba3562289b9204b2ed2624c95e553679c8e --- /dev/null +++ b/frontend/src/components/EventsPanel.jsx @@ -0,0 +1,46 @@ +const CATEGORY_COLOR = { + "technical-cultural": "#5b9bd5", + sports: "#51cf66", + academic: "#d4a04a", +}; + +function EventCard({ event, active }) { + const color = CATEGORY_COLOR[event.category] || "#8a8a96"; + return ( +
+
+ + {event.name} +
+
+ {active ? "LIVE" : `${event.start_time}โ€“${event.end_time}`} ยท {event.location_id} +
+
{event.description}
+
+ {event.attendance_count}/{event.capacity} attending +
+
+ ); +} + +export default function EventsPanel({ events }) { + const active = events?.active || []; + const upcoming = events?.upcoming || []; + if (!active.length && !upcoming.length) return null; + return ( + + ); +} diff --git a/frontend/src/components/InfoBar.jsx b/frontend/src/components/InfoBar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b8081da9f70a0b2ae065c3778a4e7f200eaf7cd4 --- /dev/null +++ b/frontend/src/components/InfoBar.jsx @@ -0,0 +1,138 @@ +import { useState } from "react"; + +export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForward, onSlowDown, onRewind, simulationRunning, onToggleSimulation, onToggleRoster, isAuthenticated }) { + const [rewindAmount, setRewindAmount] = useState("10"); + const [rewindUnit, setRewindUnit] = useState("ticks"); + if (!snapshot) return null; + const { tick, time, day, agents: agentMap, speed } = snapshot; + const agentCount = agentMap ? Object.keys(agentMap).length : 0; + const pausedCount = agentMap + ? Object.values(agentMap).filter((a) => a.paused).length + : 0; + const chattingCount = agentMap + ? Object.values(agentMap).filter((a) => a.conversation).length + : 0; + + return ( +
+ TICK + {tick} + | + TIME + {time} + | + DAY + {day} + {speed?.real_min_per_day && ( + <> + | + PACE + {speed.real_min_per_day}m/day + + )} + | + {agentCount} + AGENTS + {pausedCount > 0 && ( + <> + | + {pausedCount} PAUSED + + )} + {chattingCount > 0 && ( + <> + | + {chattingCount} CHATTING + + )} + | + {isAuthenticated && ( + <> + setRewindAmount(event.target.value)} + aria-label="Rewind amount" + style={{ pointerEvents: "auto", width: 42, border: "1px solid rgba(212,160,74,.3)", borderRadius: 3, background: "rgba(0,0,0,.45)", color: "#e7bd70", padding: "2px 4px", fontFamily: "'Space Mono', monospace", fontSize: 9 }} + /> + + + + + + + )} + + {isAuthenticated && ( + + )} +
+ ); +} diff --git a/frontend/src/components/Legend.jsx b/frontend/src/components/Legend.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0d65abe616977c4946e08eaad44fe09fd0071918 --- /dev/null +++ b/frontend/src/components/Legend.jsx @@ -0,0 +1,65 @@ +// Color legend: maps each agent's dot color to their name. Click to focus. +export default function Legend({ agents, focusedId, onFocus }) { + if (!agents) return null; + const entries = Object.entries(agents); + if (!entries.length) return null; + + return ( +
+
+ Students +
+ {entries.map(([id, a]) => { + const focused = id === focusedId; + return ( +
onFocus && onFocus(focused ? null : id)} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + padding: "3px 4px", + borderRadius: 4, + cursor: "pointer", + background: focused ? "rgba(212,160,74,0.14)" : "transparent", + transition: "background 0.15s", + }} + > + + + {a.name} + + {a.in_conversation && ( + chatting + )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/LoginButton.jsx b/frontend/src/components/LoginButton.jsx new file mode 100644 index 0000000000000000000000000000000000000000..041f037329094f454268cbd183f08d3c145cc560 --- /dev/null +++ b/frontend/src/components/LoginButton.jsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import { useAuth } from "../hooks/useAuth"; +import LoginModal from "./LoginModal"; + +export default function LoginButton() { + const { user, isAuthenticated, loading, login, logout } = useAuth(); + const [showModal, setShowModal] = useState(false); + const [showDropdown, setShowDropdown] = useState(false); + + if (loading) return null; + + const basePill = { + position: "fixed", + bottom: 16, + right: 16, + zIndex: 1500, + fontFamily: "'Space Mono', monospace", + fontSize: 11, + fontWeight: 600, + letterSpacing: "0.08em", + borderRadius: 6, + padding: "7px 16px", + cursor: "pointer", + display: "flex", + alignItems: "center", + gap: 7, + textTransform: "uppercase", + transition: "all 0.15s", + }; + + if (!isAuthenticated) { + return ( + <> + + {showModal && setShowModal(false)} onLogin={login} />} + + ); + } + + return ( +
+ + {showDropdown && ( +
setShowDropdown(false)} + > +
+ {user?.email} +
+
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/LoginModal.jsx b/frontend/src/components/LoginModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9ef0627af11d761f46b554153965988fb948c308 --- /dev/null +++ b/frontend/src/components/LoginModal.jsx @@ -0,0 +1,123 @@ +import { useState } from "react"; + +const overlayStyle = { + position: "fixed", + inset: 0, + background: "rgba(0,0,0,0.55)", + backdropFilter: "blur(4px)", + zIndex: 2000, + display: "flex", + alignItems: "center", + justifyContent: "center", +}; + +const modalStyle = { + background: "#111118", + border: "1px solid rgba(212,160,74,0.25)", + borderRadius: 8, + padding: "28px 24px 22px", + width: 300, + fontFamily: "'Outfit', sans-serif", + color: "#d0d0da", + boxShadow: "0 18px 60px rgba(0,0,0,0.7)", +}; + +const inputStyle = { + width: "100%", + padding: "7px 10px", + borderRadius: 4, + border: "1px solid rgba(212,160,74,0.25)", + background: "#1a1a24", + color: "#eee", + fontFamily: "'Space Mono', monospace", + fontSize: 12, + outline: "none", + boxSizing: "border-box", +}; + +const btnStyle = { + width: "100%", + padding: "8px 0", + borderRadius: 4, + border: "1px solid rgba(212,160,74,0.35)", + background: "rgba(212,160,74,0.12)", + color: "#e7bd70", + fontFamily: "'Space Mono', monospace", + fontSize: 11, + letterSpacing: "0.06em", + cursor: "pointer", + textTransform: "uppercase", +}; + +export default function LoginModal({ onClose, onLogin }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!email || !password) return; + setBusy(true); + setError(""); + try { + await onLogin(email.trim(), password); + onClose(); + } catch (err) { + setError(err.message || "Login failed"); + } finally { + setBusy(false); + } + }; + + return ( +
+
e.stopPropagation()} onSubmit={handleSubmit}> +
+ Admin Login +
+ + setEmail(e.target.value)} + placeholder="you@college.edu" + autoFocus + style={inputStyle} + /> + + setPassword(e.target.value)} + placeholder="password" + style={inputStyle} + /> + {error && ( +
+ {error} +
+ )} + + +
+
+ ); +} diff --git a/frontend/src/components/RosterManager.jsx b/frontend/src/components/RosterManager.jsx new file mode 100644 index 0000000000000000000000000000000000000000..47f3593807f693d31b11b187e49626e45fec5bec --- /dev/null +++ b/frontend/src/components/RosterManager.jsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { useAuth } from "../hooks/useAuth"; +import { apiUrl } from "../utils/api"; + +const panelStyle = { + position: "fixed", right: 16, bottom: 64, width: 330, zIndex: 1100, + border: "1px solid rgba(212,160,74,.32)", borderRadius: 6, + background: "rgba(8,8,8,.96)", boxShadow: "0 14px 45px rgba(0,0,0,.62)", + color: "#d0d0da", fontFamily: "'Outfit', sans-serif", padding: 13, +}; +const controlStyle = { width: "100%", marginTop: 7, padding: "7px 8px", borderRadius: 3, border: "1px solid rgba(212,160,74,.28)", background: "#101010", color: "#ddd", fontFamily: "inherit", fontSize: 12 }; + +export default function RosterManager({ open, onClose, simulationRunning, onError, isAuthenticated }) { + const { token } = useAuth(); + const [roster, setRoster] = useState([]); + const [description, setDescription] = useState(""); + const [selected, setSelected] = useState(""); + const [newName, setNewName] = useState(""); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(""); + + const refresh = async () => { + try { + const response = await fetch(apiUrl("/api/roster"), { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + const data = await response.json(); + setRoster(data.agents || []); + setSelected((previous) => previous || data.agents?.[0]?.id || ""); + } catch (error) { onError(error.message); } + }; + useEffect(() => { if (open) refresh(); }, [open]); + if (!open || !isAuthenticated) return null; + + const submit = async (path, payload, success) => { + if (simulationRunning) { onError("Stop the simulation before editing the roster."); return; } + setBusy(true); setNotice(""); + try { + const response = await fetch(apiUrl(path), { method: "POST", headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(payload) }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Roster change failed."); + setNotice(`${success} Checkpoint ${data.checkpoint_tick} is now the latest timeline state.`); + setDescription(""); setNewName(""); + await refresh(); + } catch (error) { onError(error.message); } + finally { setBusy(false); } + }; + + return
-
-
- - -
-

๐Ÿ“ Activity Logs

-
-
Bot initialized - Waiting for connection...
-
-
- - - - - - - - - diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..c5f702659f0cc7295fd7477b43e02a32e7ad0f04 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +langgraph +Pillow +matplotlib +google-genai +pydantic +protobuf +uvicorn +fastapi +pytest +pytest-asyncio +# Optional โ€” only needed when SIM_MEMORY_BACKEND=vector (semantic long-term memory). +# The project runs fully without it (keyword backend is the default). +qdrant-client