This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .dockerignore +60 -0
  2. .env.local +80 -0
  3. .gitattributes +0 -35
  4. .gitignore +54 -1
  5. Dockerfile +14 -20
  6. README.md +165 -109
  7. backend/Odin.py +1037 -0
  8. backend/Pathfinder_test.py +80 -0
  9. backend/data/environment/buildings_polygon_decomposed.json +3878 -0
  10. backend/data/environment/entrypoint.json +240 -0
  11. backend/data/environment/events_calendar.json +56 -0
  12. backend/data/environment/instances_default.json +1 -0
  13. backend/data/environment/num.txt +34 -0
  14. backend/data/environment/places.json +273 -0
  15. backend/data/environment/relationship_matrix.json +867 -0
  16. backend/data/personalities/amitabh/amitabh.json +22 -0
  17. backend/data/personalities/ansh_batra/ansh_batra.json +22 -0
  18. backend/data/personalities/anubhav_prasad/anubhav_prasad.json +21 -0
  19. backend/data/personalities/ghanisht_kaushal/ghanisht_kaushal.json +21 -0
  20. backend/data/personalities/gurnoor_singh/gurnoor_singh.json +22 -0
  21. backend/data/personalities/jarvis/jarvis.json +22 -0
  22. backend/data/personalities/lavanya_sharma/lavanya_sharma.json +21 -0
  23. backend/data/personalities/parv_singla/parv_singla.json +21 -0
  24. backend/data/personalities/riya_murarka/riya_murarka.json +21 -0
  25. backend/data/personalities/saksham/saksham.json +21 -0
  26. backend/data/personalities/tanishq/tanishq.json +21 -0
  27. backend/pathfinder.py +114 -0
  28. backend/src/agents/Actions.py +801 -0
  29. backend/src/agents/Long_term.py +45 -0
  30. backend/src/agents/Short_term.py +559 -0
  31. backend/src/agents/Single_agent.py +223 -0
  32. backend/src/agents/agents.txt +2 -0
  33. backend/src/agents/autonomy.py +20 -0
  34. backend/src/agents/body.py +71 -0
  35. backend/src/agents/brain.py +186 -0
  36. backend/src/agents/conversation.py +805 -0
  37. backend/src/agents/daily_flavor.py +45 -0
  38. backend/src/agents/day_planner.py +1227 -0
  39. backend/src/agents/memory_index.py +95 -0
  40. backend/src/agents/react.py +70 -0
  41. backend/src/agents/vector_memory.py +514 -0
  42. backend/src/auth/__init__.py +0 -0
  43. backend/src/auth/manager.py +116 -0
  44. backend/src/auth/routes.py +48 -0
  45. backend/src/config.py +375 -0
  46. backend/src/core/agent_registry.py +76 -0
  47. backend/src/core/budget.py +120 -0
  48. backend/src/core/checkpoint_manager.py +325 -0
  49. backend/src/core/core.txt +2 -0
  50. backend/src/core/log.py +146 -0
.dockerignore ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # .dockerignore
2
+
3
+ .agents
4
+ .vscode
5
+ scripts
6
+ venv
7
+ .env
8
+ opencode.json
9
+ skills-lock.json
10
+
11
+ .codex/environments/environment.toml
12
+ pytest.ini
13
+ .venv/
14
+ .idea/
15
+ temp.py
16
+ *.log
17
+ .debug/
18
+
19
+
20
+ /tests/
21
+ /backend/tests/
22
+ .pytest_cache/
23
+ coverage.xml
24
+ .coverage
25
+ htmlcov/
26
+ test-results/
27
+ playwright-report/
28
+ .VSCodeCounter/
29
+
30
+ # Generated simulation runtime state. The live server recreates these on a
31
+ # fresh run; static environments, personas, and event definitions stay tracked.
32
+
33
+ /backend/data/Short_term_db/
34
+ /backend/data/checkpoints/
35
+ /backend/data/history/
36
+
37
+ # Legacy JSON long-term-memory exports are produced only by the optional
38
+ # migration utility. The live simulator uses Qdrant instead.
39
+
40
+ /backend/data/Long_term_db/
41
+ /backend/output/
42
+
43
+
44
+ # Frontend dependencies and build output
45
+
46
+ /frontend/node_modules/
47
+ /frontend/dist/
48
+
49
+
50
+ # Python bytecode and interpreter caches
51
+ __pycache__/
52
+
53
+ *.py[cod]
54
+
55
+ # Local operating-system and tool residue
56
+
57
+ .DS_Store
58
+ Thumbs.db
59
+ *.pid
60
+ *.tmp
.env.local ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # API Keys
3
+ # ============================================================================
4
+ GEMINI_API_KEY_1=
5
+ GEMINI_API_KEY_2=
6
+ GEMINI_API_KEY_3=
7
+ GEMINI_API_KEY_4=
8
+ | | |
9
+ | | |
10
+ GEMINI_API_KEY_15=
11
+ GEMINI_API_KEY_16=
12
+ GEMINI_API_KEY_17=
13
+ GEMINI_API_KEY_18=
14
+
15
+ # ============================================================================
16
+ # Simulation clock
17
+ # SIM_REAL_SECONDS_PER_SIM_MINUTE = wall-clock seconds per 1 sim-minute.
18
+ # SIM_TICK_SPEED = speed multiplier (2.0 = 2x faster).
19
+ # Default: 7.5 sec/sim-min => 1440 * 7.5 = 10,800 sec = 3 real hours per sim day.
20
+ # ============================================================================
21
+ SIM_REAL_SECONDS_PER_SIM_MINUTE=4
22
+ SIM_TICK_SPEED=1.0
23
+ SIM_MINUTES_PER_TICK=1
24
+
25
+ # Which simulation day to run (YYYY-MM-DD). Plans/memory are looked up per date.
26
+ SIM_START_DATE=2026-07-03
27
+ # Time of day to start at (HH:MM). 00:00 = agents asleep at midnight; use e.g.
28
+ # 08:00 to begin during the day and see students moving to classes right away.
29
+ SIM_START_TIME=00:00
30
+
31
+ # ============================================================================
32
+ # LLM Budget
33
+ # ============================================================================
34
+ SIM_CREATIVITY=1.1
35
+ SIM_WELLBEING_VARIABILITY=1
36
+
37
+ # Soft ceiling on AI calls per real hour (0 = no limit). Above it, cognition
38
+ # degrades gracefully and the sim keeps running on its free path.
39
+ SIM_LLM_HOURLY_CEILING=0
40
+
41
+ # ============================================================================
42
+ # Spatial awareness
43
+ # ============================================================================
44
+ # Pixel radius for perception (who an agent sees).
45
+ SIM_PERCEPTION_RADIUS_PX=50
46
+ # Agents must be within this many pixels to start a conversation (tighter).
47
+ SIM_CONVERSATION_RADIUS_PX=20
48
+ SIM_PERCEPTION_ENABLED=true
49
+ # Run perception only every N ticks (1 = every tick). Higher = lighter CPU.
50
+ SIM_PERCEPTION_EVERY_N_TICKS=3
51
+
52
+ # ============================================================================
53
+ # Memory
54
+ # ============================================================================
55
+ # Long-term memory backend: "keyword" (offline, default) or "vector" (needs
56
+ # qdrant-client installed; falls back to keyword automatically if unavailable).
57
+ SIM_MEMORY_BACKEND=vector
58
+ SIM_SEMANTIC_MEMORY_ENABLED=true
59
+
60
+ QDRANT_URL=
61
+ QDRANT_API_KEY=
62
+
63
+ SIM_MEMORY_EMBEDDING_MODEL=gemini-embedding-001
64
+ SIM_MEMORY_VECTOR_DIMENSIONS=768
65
+ SIM_MEMORY_COLLECTION_VERSION=v1
66
+
67
+ # ============================================================================
68
+ # Budget guards
69
+ # ============================================================================
70
+ SIM_MAX_CONVERSATIONS_PER_AGENT=5
71
+ SIM_MAX_REPLANS_PER_AGENT_PER_DAY=3
72
+
73
+ # ============================================================================
74
+ # Energy/Emotion thresholds for LLM calls
75
+ # ============================================================================
76
+ SIM_DECIDE_MIN_ENERGY=0.25
77
+ SIM_DECIDE_MIN_EMOTION=0.25
78
+ SIM_CONVERSATION_MIN_ENERGY=0.25
79
+ SIM_CONVERSATION_MIN_EMOTION=0.25
80
+
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -1 +1,54 @@
1
- node_modules
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .agents
2
+ .vscode
3
+ scripts
4
+ venv
5
+ .env
6
+ opencode.json
7
+ skills-lock.json
8
+
9
+ .codex/environments/environment.toml
10
+ pytest.ini
11
+ .venv/
12
+ .idea/
13
+ temp.py
14
+ *.log
15
+ .debug/
16
+
17
+
18
+ /tests/
19
+ /backend/tests/
20
+ .pytest_cache/
21
+ coverage.xml
22
+ .coverage
23
+ htmlcov/
24
+ test-results/
25
+ playwright-report/
26
+ .VSCodeCounter/
27
+
28
+ # Generated simulation runtime state. The live server recreates these on a
29
+ # fresh run; static environments, personas, and event definitions stay tracked.
30
+ /backend/data/Short_term_db/
31
+ /backend/data/checkpoints/
32
+ /backend/data/history/
33
+ # Legacy JSON long-term-memory exports are produced only by the optional
34
+ # migration utility. The live simulator uses Qdrant instead.
35
+ /backend/data/Long_term_db/
36
+ /backend/output/
37
+
38
+
39
+ # Frontend dependencies and build output
40
+ /frontend/node_modules/
41
+ /frontend/dist/
42
+
43
+
44
+ # Python bytecode and interpreter caches
45
+ __pycache__/
46
+ *.py[cod]
47
+
48
+ # Local operating-system and tool residue
49
+ .DS_Store
50
+ Thumbs.db
51
+ *.pid
52
+ *.tmp
53
+ *.mp4
54
+ *.png
Dockerfile CHANGED
@@ -1,29 +1,23 @@
1
- # Use Node.js official image
2
- FROM node:18
3
 
4
- # Install necessary packages for Puppeteer
5
- RUN apt-get update && apt-get install -y \
6
- chromium \
7
- && rm -rf /var/lib/apt/lists/*
8
-
9
- # Set working directory
10
  WORKDIR /app
11
 
12
- # Copy package files
13
- COPY package*.json ./
 
 
14
 
15
- # Install dependencies
16
- RUN npm install
 
17
 
18
- # Copy application code
19
- COPY . .
 
20
 
21
- # Create directories and change ownership to the 'node' user
22
- RUN mkdir -p .wwebjs_auth .wwebjs_cache && \
23
- chown -R node:node .wwebjs_auth .wwebjs_cache
24
 
25
- # Expose port for web interface
26
  EXPOSE 7860
27
 
28
- # Start the application
29
- CMD ["node", "bot.js"]
 
1
+ FROM python:3.11-slim
 
2
 
 
 
 
 
 
 
3
  WORKDIR /app
4
 
5
+ # System deps for Pillow/OpenCV if wheels need them
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ libjpeg62-turbo-dev libglib2.0-0 \
8
+ && rm -rf /var/lib/apt/lists/*
9
 
10
+ # Python deps
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
 
14
+ # Backend code
15
+ COPY backend/ backend/
16
+ COPY pathfinder.py .
17
 
18
+ # Frontend build (pre-built via Vercel, embedded for same-origin fallback)
19
+ COPY frontend/dist/ frontend/dist/
 
20
 
 
21
  EXPOSE 7860
22
 
23
+ CMD ["python", "backend/Odin.py", "--host", "0.0.0.0", "--port", "7860"]
 
README.md CHANGED
@@ -1,147 +1,203 @@
1
- ---
2
- title: "AIVA WhatsApp Bot"
3
- emoji: "🤖"
4
- colorFrom: blue
5
- colorTo: purple
6
- sdk: docker
7
- sdk_version: "20.10"
8
- app_file: bot.js
9
- pinned: false
10
- ---
11
 
12
- # AIVA WhatsApp Bot
 
 
 
 
13
 
14
- An intelligent WhatsApp bot with a web dashboard interface.
15
 
16
- ## Features
17
 
18
- - 🤖 AI-powered responses via external API
19
- - 📱 WhatsApp Web integration
20
- - 🌐 Web dashboard for monitoring and testing
21
- - 📊 Real-time statistics and logs
22
- - 🔗 QR code scanning via web interface
23
- - 🧪 API testing interface
24
 
25
- ## Local Development
 
26
 
27
- ### Prerequisites
28
- - Node.js 18+
29
- - Docker (optional)
30
 
31
- ### Installation
 
 
32
 
33
- 1. Clone the repository
34
- 2. Install dependencies:
35
- ```bash
36
- npm install
37
  ```
38
 
39
- 3. Start the bot:
40
- ```bash
41
- npm start
42
- ```
43
 
44
- 4. Open web dashboard: http://localhost:3000
45
 
46
- ## Docker Deployment
 
 
 
 
 
 
 
 
 
47
 
48
- ### Build and run with Docker
49
- ```bash
50
- docker build -t aiva-bot .
51
- docker run -p 3000:3000 -v $(pwd)/data:/app/.wwebjs_auth aiva-bot
52
- ```
53
 
54
- ### Using Docker Compose
55
- ```bash
56
- docker-compose up -d
57
- ```
58
 
59
- ## Cloud Deployment
60
 
61
- ### Railway
62
- 1. Connect your GitHub repository to Railway
63
- 2. Set environment variable: `PORT=3000`
64
- 3. Deploy
65
 
66
- ### Heroku
67
- ```bash
68
- heroku create your-app-name
69
- heroku container:push web
70
- heroku container:release web
71
- heroku open
72
- ```
73
 
74
- ### DigitalOcean App Platform
75
- 1. Create new app from GitHub repository
76
- 2. Set build command: `docker build -t aiva-bot .`
77
- 3. Set run command: `node bot.js`
78
- 4. Set port: `3000`
79
 
80
- ### Google Cloud Run
81
- ```bash
82
- # Build and push to Container Registry
83
- docker build -t gcr.io/YOUR_PROJECT_ID/aiva-bot .
84
- docker push gcr.io/YOUR_PROJECT_ID/aiva-bot
85
-
86
- # Deploy to Cloud Run
87
- gcloud run deploy aiva-bot \
88
- --image gcr.io/YOUR_PROJECT_ID/aiva-bot \
89
- --platform managed \
90
- --port 3000 \
91
- --allow-unauthenticated
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  ```
93
 
94
- ## Web Dashboard Features
95
 
96
- - **Real-time Status**: Connection status and QR code display
97
- - **Statistics**: Message count, API calls, uptime tracking
98
- - **Activity Logs**: Real-time bot activity monitoring
99
- - **Test Interface**: Direct API testing functionality
100
 
101
- ## API Endpoints
 
 
 
102
 
103
- - `GET /` - Web dashboard
104
- - `POST /api/test` - Test API endpoint
105
- - `GET /api/stats` - Bot statistics
106
 
107
- ## Environment Variables
 
 
 
 
 
 
 
108
 
109
- - `PORT` - Server port (default: 3000)
110
- - `NODE_ENV` - Environment mode
 
 
 
 
 
111
 
112
- ## Bot Functionality
113
 
114
- ### WhatsApp Integration
115
- - Responds to personal messages
116
- - Responds when mentioned in groups
117
- - Formats responses for WhatsApp (bold, italic, etc.)
118
- - Handles `\n` line breaks properly
119
 
120
- ### Message Processing
121
- - Strips `<think>...</think>` content
122
- - Extracts Final Answer from JSON responses
123
- - Converts markdown formatting to WhatsApp format
124
 
125
- ## Troubleshooting
 
 
126
 
127
- ### Common Issues
128
 
129
- 1. **QR Code not showing**: Check if port 3000 is accessible
130
- 2. **Bot not responding**: Verify API endpoint is working
131
- 3. **Docker build fails**: Ensure all dependencies are in package.json
132
 
133
- ### Logs
134
- Check bot logs in the web dashboard or container logs:
135
- ```bash
136
- docker logs <container-id>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  ```
138
 
139
- ## Security Notes
 
 
 
 
 
 
 
 
140
 
141
- - Keep WhatsApp session data secure
142
- - Use environment variables for sensitive data
143
- - Regularly update dependencies
 
144
 
145
- ## License
146
 
147
- MIT License
 
 
 
 
1
+ # Valhalla
 
 
 
 
 
 
 
 
 
2
 
3
+ Valhalla is a multi-agent campus-life simulation set at IIT Ropar.
4
+ AI student personas plan their day, move across a shared map, talk when they meet,
5
+ and build memory over time.
6
+
7
+ ## What is Valhalla?
8
 
9
+ 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.
10
 
11
+ ## Quick Start
12
 
13
+ ```bash
14
+ # 1) Create and activate virtual environment
15
+ python -m venv venv
16
+ source venv/bin/activate # Windows PowerShell: .\\venv\\Scripts\\Activate.ps1
 
 
17
 
18
+ # 2) Install Python dependencies
19
+ pip install -r requirements.txt
20
 
21
+ # 3) Build frontend
22
+ cd frontend && npm install && npm run build && cd ..
 
23
 
24
+ # 4) Configure environment variables
25
+ cp .env.local .env # Windows PowerShell: Copy-Item .env.local .env
26
+ # Add at least one Gemini key in .env (for example GEMINI_API_KEY_1)
27
 
28
+ # 5) Run
29
+ python backend/Odin.py
 
 
30
  ```
31
 
32
+ Open <http://127.0.0.1:8000>.
 
 
 
33
 
34
+ ## Table of Contents
35
 
36
+ - [Overview](#overview)
37
+ - [Features](#features)
38
+ - [Architecture / Pipeline](#architecture--pipeline)
39
+ - [Setup](#setup)
40
+ - [Run](#run)
41
+ - [Configuration](#configuration)
42
+ - [Project Structure](#project-structure)
43
+ - [Security & Secrets](#security--secrets)
44
+ - [Troubleshooting](#troubleshooting)
45
+ - [Contributing](#contributing)
46
 
47
+ ## Overview
 
 
 
 
48
 
49
+ Valhalla simulates a 24-hour social day in ticks. Each agent has personality traits,
50
+ energy and emotion state, location context, relationships, and memory.
51
+ The engine updates movement and interactions continuously while LLM calls are used for
52
+ high-level planning and dialogue.
53
 
54
+ https://github.com/user-attachments/assets/1e48772a-e8a9-45d3-a462-ddfe83b1c640
55
 
 
 
 
 
56
 
 
 
 
 
 
 
 
57
 
58
+ ## Features
 
 
 
 
59
 
60
+ - AI-generated day plans for each persona with mid-day replanning.
61
+ - Tick-based movement on a campus map with location-aware behavior.
62
+ - 1:1 conversations that affect energy, emotion, and relationships.
63
+ - Persistent long-term memory via Qdrant (with runtime fallback behavior).
64
+ - Checkpoint/resume workflow for long-running simulations.
65
+ - Roster tools to add, rename, or retire agents.
66
+
67
+ <img src="Screenshot 2026-07-25 002349.png" alt="Valhalla simulation UI" width="100%">
68
+
69
+ ## Architecture / Pipeline
70
+
71
+ ```text
72
+ Load Personas -> Generate Day Plans -> Start Clock
73
+ |
74
+ +-----------+
75
+ v
76
+ +---- Perceive (each tick) <----+
77
+ | |
78
+ v |
79
+ Detect Conversations |
80
+ | |
81
+ v |
82
+ LLM Decide: continue/replan |
83
+ | |
84
+ v |
85
+ Replan if needed |
86
+ | |
87
+ v |
88
+ Advance agents ---------------------+
89
+ (repeat for 1440 ticks / day)
90
+ |
91
+ v
92
+ Archive Day -> Next Day Plans
93
  ```
94
 
95
+ ## Setup
96
 
97
+ ### Requirements
 
 
 
98
 
99
+ - Python 3.11+
100
+ - Node.js + npm (frontend build)
101
+ - Gemini API key(s)
102
+ - Optional: Qdrant Cloud account for semantic long-term memory
103
 
104
+ ### Install
 
 
105
 
106
+ ```bash
107
+ python -m venv venv
108
+ source venv/bin/activate
109
+ pip install -r requirements.txt
110
+ cd frontend && npm install && npm run build && cd ..
111
+ ```
112
+
113
+ Windows PowerShell equivalent:
114
 
115
+ ```powershell
116
+ python -m venv venv
117
+ .\venv\Scripts\Activate.ps1
118
+ pip install -r requirements.txt
119
+ cd frontend; npm install; npm run build; cd ..
120
+ Copy-Item .env.local .env
121
+ ```
122
 
123
+ ## Run
124
 
125
+ ```bash
126
+ # Fresh start (opens browser UI)
127
+ python backend/Odin.py
 
 
128
 
129
+ # Resume from last checkpoint
130
+ python backend/Odin.py --resume-checkpoint
 
 
131
 
132
+ # Headless single-day run
133
+ PYTHONPATH=backend python backend/src/core/world_engine.py --days 1
134
+ ```
135
 
136
+ Windows PowerShell (headless):
137
 
138
+ ```powershell
139
+ $env:PYTHONPATH="backend"; python backend/src/core/world_engine.py --days 1
140
+ ```
141
 
142
+ ## Configuration
143
+
144
+ Set values in `.env` (copy from `.env.local` first).
145
+
146
+ ### Configuration Reference
147
+
148
+ | Variable | Default | Purpose |
149
+ |---|---|---|
150
+ | `SIM_TICK_SPEED` | `1.0` | Global simulation speed multiplier |
151
+ | `SIM_REAL_SECONDS_PER_SIM_MINUTE` | `7.5` | Wall-clock seconds per simulated minute |
152
+ | `SIM_MINUTES_PER_TICK` | `1` | Simulated minutes advanced each tick |
153
+ | `SIM_PERCEPTION_ENABLED` | `true` | Enables/disables perception step |
154
+ | `SIM_PERCEPTION_RADIUS_PX` | `50` | Nearby-agent detection radius |
155
+ | `SIM_MAX_CONVERSATIONS_PER_AGENT` | `5` | Daily conversation cap per agent |
156
+ | `SIM_MAX_REPLANS_PER_AGENT_PER_DAY` | `3` | Mid-day replan cap per agent |
157
+ | `SIM_LLM_HOURLY_CEILING` | `0` | Soft LLM call cap per real hour (`0` = unlimited) |
158
+ | `SIM_DECIDE_MIN_ENERGY` | `0.1` | Minimum energy to trigger LLM decisions |
159
+ | `SIM_DECIDE_MIN_EMOTION` | `0.1` | Minimum emotion to trigger LLM decisions |
160
+ | `SIM_CONVERSATION_MIN_ENERGY` | `0.05` | Minimum energy to start conversation |
161
+ | `SIM_CONVERSATION_MIN_EMOTION` | `0.05` | Minimum emotion to start conversation |
162
+ | `SIM_SEMANTIC_MEMORY_ENABLED` | `true` | Enables semantic long-term memory path |
163
+ | `QDRANT_URL` | `""` | Qdrant endpoint |
164
+ | `QDRANT_API_KEY` | `""` | Qdrant API key |
165
+ | `SIM_MEMORY_EMBEDDING_MODEL` | `gemini-embedding-001` | Embedding model for memory |
166
+ | `SIM_MEMORY_VECTOR_DIMENSIONS` | `768` | Embedding dimensions |
167
+ | `SIM_MEMORY_COLLECTION_VERSION` | `v1` | Memory schema/version tag |
168
+ | `SIM_GEMINI_MODEL` | `gemini-3.1-flash-lite` | Gemini model used by the simulation |
169
+ | `SIM_CREATIVITY` | `1.0` | Creativity dial for plans/dialogue |
170
+ | `SIM_WELLBEING_VARIABILITY` | `0.75` | Non-LLM variability in wellbeing updates |
171
+
172
+ ## Project Structure
173
+
174
+ ```text
175
+ backend/
176
+ src/ # simulation engine, agent logic, planners, memory integrations
177
+ data/ # personas, environment, checkpoints, relationships, archives
178
+ frontend/ # React + Vite UI
179
+ README.md # project documentation
180
+ requirements.txt # Python dependencies
181
  ```
182
 
183
+ ## Security & Secrets
184
+
185
+ - Never commit real secrets (`.env`, API keys, private tokens, cloud credentials).
186
+ - Use `.env` for local secrets; keep only non-sensitive templates in version control.
187
+ - If a key is exposed, rotate it immediately in the provider dashboard.
188
+ - Remove leaked secrets from Git history before sharing or releasing the repository.
189
+ - Prefer least-privilege keys and separate keys for dev/staging/prod use.
190
+
191
+ ## Troubleshooting
192
 
193
+ - **No LLM output / planning fails**: verify at least one valid `GEMINI_API_KEY*` is set in `.env`.
194
+ - **Frontend not updating**: rebuild with `cd frontend && npm run build`.
195
+ - **Import/path errors in headless mode**: ensure `PYTHONPATH=backend` is set.
196
+ - **Qdrant errors**: verify `QDRANT_URL` and `QDRANT_API_KEY`, or disable with `SIM_SEMANTIC_MEMORY_ENABLED=false`.
197
 
198
+ ## Contributing
199
 
200
+ 1. Fork and create a feature branch.
201
+ 2. Keep changes focused and documented.
202
+ 3. Validate local run paths before opening a PR.
203
+ 4. Include a clear summary of behavior changes and test notes.
backend/Odin.py ADDED
@@ -0,0 +1,1037 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI web server for the Valhalla agent map.
3
+ Serves the frontend (React SPA), exposes REST + WebSocket
4
+ endpoints for pathfinding (/api/path, /api/path/stream, /ws), and
5
+ streams simulation state via /ws/sim for live agent visualization.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import json
11
+ import asyncio
12
+ import logging
13
+ import socket
14
+ from contextlib import asynccontextmanager
15
+ from datetime import datetime, timedelta
16
+ from pathlib import Path
17
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, Header
18
+ from fastapi.responses import FileResponse, JSONResponse
19
+ from pydantic import BaseModel, Field
20
+ from typing import Any, Dict, List, Optional
21
+ import re
22
+ import shutil
23
+ import uvicorn
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+ from pathfinder import shortest_path, stats, ROOT
29
+
30
+ FRONTEND = os.path.join(ROOT, "frontend")
31
+ DATA_DIR = os.path.join(ROOT, "backend", "data")
32
+
33
+
34
+ # --------------------------------------------------------------------------- #
35
+ # Sim Manager — connection broadcast + background WorldEngine
36
+ # --------------------------------------------------------------------------- #
37
+
38
+ class SimBroadcaster:
39
+ """Manages WebSocket clients via per-client bounded queues.
40
+
41
+ Each connected client gets an asyncio.Queue(maxsize=5). The simulation
42
+ pushes snapshots into every queue with put_nowait (dropping oldest on
43
+ overflow). A lightweight drain task per client sends frames at the pace
44
+ that individual connection can handle — a slow client only affects itself.
45
+ """
46
+
47
+ def __init__(self):
48
+ self._queues: dict[WebSocket, asyncio.Queue] = {}
49
+ self._tasks: dict[WebSocket, asyncio.Task] = {}
50
+
51
+ async def connect(self, ws: WebSocket, initial_snapshot: Optional[dict] = None):
52
+ await ws.accept()
53
+ q: asyncio.Queue = asyncio.Queue(maxsize=5)
54
+ self._queues[ws] = q
55
+ if initial_snapshot is not None:
56
+ q.put_nowait(initial_snapshot)
57
+ self._tasks[ws] = asyncio.create_task(self._drain(ws, q))
58
+
59
+ def disconnect(self, ws: WebSocket):
60
+ task = self._tasks.pop(ws, None)
61
+ if task:
62
+ task.cancel()
63
+ self._queues.pop(ws, None)
64
+
65
+ async def broadcast(self, data: dict):
66
+ for q in list(self._queues.values()):
67
+ try:
68
+ q.put_nowait(data)
69
+ except asyncio.QueueFull:
70
+ try:
71
+ q.get_nowait()
72
+ except asyncio.QueueEmpty:
73
+ pass
74
+ q.put_nowait(data)
75
+
76
+ async def _drain(self, ws: WebSocket, q: asyncio.Queue):
77
+ try:
78
+ while True:
79
+ data = await q.get()
80
+ try:
81
+ await ws.send_json(data)
82
+ except Exception:
83
+ break
84
+ except asyncio.CancelledError:
85
+ pass
86
+ finally:
87
+ self._queues.pop(ws, None)
88
+ self._tasks.pop(ws, None)
89
+
90
+
91
+ _latest_snapshot: Optional[dict] = None
92
+ _sim_broadcaster = SimBroadcaster()
93
+ _sim_task: Optional[asyncio.Task] = None
94
+ _sim_engine = None
95
+ _sim_tick_lock = asyncio.Lock()
96
+
97
+
98
+ def _clear_runtime_state():
99
+ """Remove all local runtime records for an intentional fresh run."""
100
+ for d in [
101
+ os.path.join(DATA_DIR, "Short_term_db"),
102
+ os.path.join(ROOT, "backend", "data", "checkpoints"),
103
+ os.path.join(DATA_DIR, "history"),
104
+ ]:
105
+ if os.path.isdir(d):
106
+ shutil.rmtree(d)
107
+
108
+
109
+ def _clear_long_term_memory() -> dict:
110
+ """Clear only Valhalla-owned semantic collections for an explicit reset."""
111
+ try:
112
+ from src.agents.Long_term import get_retriever
113
+ clear = getattr(get_retriever(), "clear_all_memory", None)
114
+ return clear() if callable(clear) else {"available": False}
115
+ except Exception as exc:
116
+ logger.warning("[SimManager] unable to clear semantic memory: %s", exc)
117
+ return {"available": False, "error": str(exc)}
118
+
119
+
120
+ def _resume_checkpoint_requested() -> bool:
121
+ """Read only Valhalla's startup flag without consuming Uvicorn arguments.
122
+
123
+ A near-miss such as ``--resume--checkpoint`` must never silently become a
124
+ fresh start, because the fresh-start path intentionally clears local
125
+ runtime state.
126
+ """
127
+ import argparse
128
+
129
+ invalid_resume_flags = [arg for arg in sys.argv[1:] if arg.startswith("--resume") and arg != "--resume-checkpoint"]
130
+ if invalid_resume_flags:
131
+ raise SystemExit(
132
+ "Invalid resume flag: " + ", ".join(invalid_resume_flags)
133
+ + ". Did you mean --resume-checkpoint?"
134
+ )
135
+ parser = argparse.ArgumentParser(add_help=False)
136
+ parser.add_argument("--resume-checkpoint", action="store_true")
137
+ args, _ = parser.parse_known_args()
138
+ return args.resume_checkpoint
139
+
140
+
141
+ def _reserve_listen_socket(host: str, port: int) -> socket.socket:
142
+ """Bind before app startup, so an occupied port cannot start the sim."""
143
+ listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
144
+ try:
145
+ if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
146
+ listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
147
+ else:
148
+ listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
149
+ listen_socket.bind((host, port))
150
+ listen_socket.listen(socket.SOMAXCONN)
151
+ return listen_socket
152
+ except Exception:
153
+ listen_socket.close()
154
+ raise
155
+
156
+
157
+ async def _stop_sim_task() -> None:
158
+ """Stop the background engine before the ASGI app releases resources."""
159
+ global _sim_task
160
+ task = _sim_task
161
+ _sim_task = None
162
+ if task is None or task.done():
163
+ return
164
+ task.cancel()
165
+ await asyncio.gather(task, return_exceptions=True)
166
+
167
+
168
+ @asynccontextmanager
169
+ async def _sim_lifespan(_app: FastAPI):
170
+ """Own the simulation task for exactly the ASGI app lifetime."""
171
+ global _sim_task
172
+ _sim_task = asyncio.create_task(
173
+ _run_sim(resume_checkpoint=_resume_checkpoint_requested()),
174
+ name="valhalla-simulation",
175
+ )
176
+ try:
177
+ yield
178
+ finally:
179
+ await _stop_sim_task()
180
+
181
+
182
+ app = FastAPI(title="Valhalla Agent Map", lifespan=_sim_lifespan)
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # CORS — allow cross-origin requests from the Vercel frontend
186
+ # ---------------------------------------------------------------------------
187
+
188
+ from fastapi.middleware.cors import CORSMiddleware
189
+
190
+ _cors_raw = os.environ.get("CORS_ORIGINS", "*").strip()
191
+ CORS_ORIGINS = [o.strip() for o in _cors_raw.split(",") if o.strip()] if _cors_raw else ["*"]
192
+ app.add_middleware(
193
+ CORSMiddleware,
194
+ allow_origins=CORS_ORIGINS,
195
+ allow_credentials=True,
196
+ allow_methods=["*"],
197
+ allow_headers=["*"],
198
+ )
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Auth — admin login protects simulation control routes
202
+ # ---------------------------------------------------------------------------
203
+
204
+ from src.auth.manager import auth_manager
205
+ from src.auth.routes import router as auth_router
206
+
207
+ app.include_router(auth_router)
208
+
209
+
210
+ async def require_admin(authorization: str = Header(None)):
211
+ """FastAPI dependency: accept a Bearer token, return user or 401."""
212
+ if not authorization or not authorization.startswith("Bearer "):
213
+ raise HTTPException(status_code=401, detail="Not authenticated")
214
+ user = auth_manager.validate_session(authorization[7:])
215
+ if not user:
216
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
217
+ return user
218
+
219
+
220
+ def _print_agent_plans(engine):
221
+ """Print each agent's full action plan to the CLI."""
222
+ from src.core.agent_registry import AgentRuntimeState
223
+ for state in engine.registry.all_states():
224
+ actions = state.day_plan or []
225
+ print(f"\n {state.persona_name} — {len(actions)} actions:")
226
+ if not actions:
227
+ print(" (no plan)")
228
+ continue
229
+ for a in actions:
230
+ start = a.get("start", "??")
231
+ end = a.get("end", "??")
232
+ loc = a.get("location_id", "?")
233
+ desc = a.get("action", "?")
234
+ print(f" {start}-{end} {loc:<30s} {desc}")
235
+ print()
236
+
237
+
238
+ def _print_sim_status(snapshot: dict) -> None:
239
+ """Emit compact simulation-time telemetry rather than checkpoint noise."""
240
+ if not snapshot.get("agents"):
241
+ return
242
+ print(
243
+ f"[SIM] day={snapshot.get('day', '?')} tick={snapshot.get('tick', '?')} "
244
+ f"time={snapshot.get('time', '??:??')} speed=x{snapshot.get('speed', {}).get('multiplier', '?')}"
245
+ )
246
+ for agent in snapshot["agents"].values():
247
+ action = agent.get("current_action") or {}
248
+ state = "paused" if agent.get("paused") else "active"
249
+ print(
250
+ f" {agent['name']:<18} {state:<6} "
251
+ f"at={agent['position'].get('location_id', '?'):<22} "
252
+ f"action={action.get('description', 'Idle'):<42} "
253
+ f"energy={agent.get('energy_level', 0):.2f} emotion={agent.get('emotion_state', 0):.2f}"
254
+ )
255
+ print()
256
+
257
+
258
+ async def _run_sim(resume_checkpoint: bool = False):
259
+ """Background task: initialize WorldEngine and drive the tick loop.
260
+
261
+ Args:
262
+ resume_checkpoint: If True, resume from the latest checkpoint without
263
+ deleting saved runtime data.
264
+ """
265
+ global _latest_snapshot, _sim_engine
266
+ from src.core.world_engine import WorldEngine
267
+ from src.core.checkpoint_manager import list_checkpoints, load_checkpoint
268
+ from src.core.log import setup_logging
269
+ from src.llm.gemini_client import ProviderFailureError, provider_failure
270
+
271
+ setup_logging(run_id="server_sim", console=False)
272
+
273
+ from src import config as _cfg
274
+ start_date = _cfg.SIM_START_DATE
275
+
276
+ engine = WorldEngine(sim_start_date=start_date, sim_start_hhmm=_cfg.SIM_START_TIME)
277
+ _sim_engine = engine
278
+
279
+ if resume_checkpoint:
280
+ ticks = list_checkpoints()
281
+ if ticks:
282
+ resume_tick = ticks[-1]
283
+ print(f"[SimManager] found checkpoint at tick {resume_tick} — resuming")
284
+ world, registry, checkpoint_state = load_checkpoint(
285
+ resume_tick, engine.resolver, return_metadata=True,
286
+ )
287
+ # Checkpoints own historical membership. Comparing against the
288
+ # mutable persona folder would make an old checkpoint impossible
289
+ # to rewind after a roster add/remove operation.
290
+ engine.world = world
291
+ engine.registry = registry
292
+ engine.restore_checkpoint_state(checkpoint_state)
293
+ await engine.resume_pending_conversations()
294
+ _latest_snapshot = {"status": "initialized", "agents": {}}
295
+ else:
296
+ message = "No checkpoint exists. Start a fresh simulation with: python backend/Odin.py"
297
+ logger.error("[SimManager] %s", message)
298
+ _latest_snapshot = {"status": "error", "message": message}
299
+ return
300
+ else:
301
+ # The normal command deliberately starts from no short-term memory or
302
+ # checkpoint data. Resuming is available only via the explicit flag.
303
+ _clear_runtime_state()
304
+ _clear_long_term_memory()
305
+ try:
306
+ logger.info("[SimManager] starting simulation for %s", start_date)
307
+ await engine.initialize()
308
+ except ProviderFailureError as exc:
309
+ failure = provider_failure() or exc
310
+ _latest_snapshot = {"status": "provider_failure", "message": str(failure), "failure": failure.payload(),
311
+ "simulation": {"running": False, "stop_reason": failure.code}}
312
+ print(f"[SimManager] {failure.title.upper()} — simulation stopped: {failure}")
313
+ await _sim_broadcaster.broadcast(_latest_snapshot)
314
+ return
315
+ except Exception as e:
316
+ _latest_snapshot = {"status": "error", "message": str(e)}
317
+ import traceback
318
+ traceback.print_exc()
319
+ return
320
+ _latest_snapshot = {"status": "initialized", "agents": {}}
321
+ _print_agent_plans(engine)
322
+
323
+ while True:
324
+ try:
325
+ day_end_tick = ((engine.world.tick // (24 * 60)) + 1) * (24 * 60)
326
+ await engine.run(max_tick=day_end_tick, on_tick=_on_tick, tick_lock=_sim_tick_lock)
327
+ start_date = (
328
+ datetime.strptime(engine.sim_start_date, "%Y-%m-%d") + timedelta(days=1)
329
+ ).strftime("%Y-%m-%d")
330
+ logger.info("[SimManager] day transition — advancing to %s", start_date)
331
+ async with _sim_tick_lock:
332
+ _latest_snapshot = engine.current_frontend_snapshot()
333
+ _latest_snapshot.update({"type": "day_handoff", "phase": "draining", "date": start_date})
334
+ await _sim_broadcaster.broadcast(_latest_snapshot)
335
+ _latest_snapshot = await engine.handoff_to_next_day(start_date)
336
+ await _sim_broadcaster.broadcast(_latest_snapshot)
337
+ _print_agent_plans(engine)
338
+ except asyncio.CancelledError:
339
+ print("[SimManager] sim task cancelled")
340
+ raise
341
+ except ProviderFailureError as exc:
342
+ failure = provider_failure() or exc
343
+ _latest_snapshot = {"status": "provider_failure", "message": str(failure), "failure": failure.payload(),
344
+ "simulation": {"running": False, "stop_reason": failure.code}}
345
+ print(f"[SimManager] {failure.title.upper()} — simulation stopped: {failure}")
346
+ await _sim_broadcaster.broadcast(_latest_snapshot)
347
+ break
348
+ except Exception as exc:
349
+ print(f"[SimManager] error: {exc}")
350
+ import traceback
351
+ traceback.print_exc()
352
+ break
353
+
354
+
355
+ async def _on_tick(snapshot: dict):
356
+ """Called after each simulation tick — store + broadcast."""
357
+ global _latest_snapshot
358
+ _latest_snapshot = snapshot
359
+ await _sim_broadcaster.broadcast(snapshot)
360
+ if snapshot.get("tick", 0) % 15 == 0:
361
+ _print_sim_status(snapshot)
362
+
363
+
364
+ # --------------------------------------------------------------------------- #
365
+ # Startup
366
+ # --------------------------------------------------------------------------- #
367
+
368
+ # Suppress FastAPI on_event deprecation warning — the lifespan equivalent is
369
+ # fine, but on_event is simpler and works identically here. The warning is
370
+ # harmless noise. If it bothers you, migrate to:
371
+ # @app.lifespan("startup")
372
+ # async def _lifespan(app): async with SimLifespan(): yield
373
+
374
+
375
+ # --------------------------------------------------------------------------- #
376
+ # Sim state endpoint
377
+ # --------------------------------------------------------------------------- #
378
+
379
+ @app.post("/api/sim/reset")
380
+ async def reset_sim(_user: dict = Depends(require_admin)):
381
+ """Wipe cache + checkpoints and restart the sim from 00:00."""
382
+ global _sim_task, _latest_snapshot
383
+
384
+ # Reset and rewind/handoff all replace mutable engine state. Serialising
385
+ # them avoids a cancelled task writing a stale checkpoint after reset.
386
+ async with _sim_tick_lock:
387
+ if _sim_task is not None and not _sim_task.done():
388
+ _sim_task.cancel()
389
+ try:
390
+ await _sim_task
391
+ except (asyncio.CancelledError, Exception):
392
+ pass
393
+
394
+ _latest_snapshot = {"status": "resetting"}
395
+ _clear_runtime_state()
396
+ _clear_long_term_memory()
397
+ _sim_task = asyncio.create_task(_run_sim())
398
+
399
+ await _sim_broadcaster.broadcast({"type": "reset"})
400
+ return {"status": "reset", "message": "Simulation reset — starting fresh"}
401
+
402
+
403
+ def _simulation_is_running() -> bool:
404
+ return _sim_task is not None and not _sim_task.done()
405
+
406
+
407
+ def _snapshot_with_simulation_status(snapshot: Optional[dict]) -> dict:
408
+ """Expose task state without mutating the engine-owned snapshot object."""
409
+ payload = dict(snapshot or {"status": "initializing"})
410
+ existing = payload.get("simulation") if isinstance(payload.get("simulation"), dict) else {}
411
+ payload["simulation"] = {**existing, "running": _simulation_is_running()}
412
+ return payload
413
+
414
+
415
+ @app.post("/api/sim/stop")
416
+ async def stop_sim(_user: dict = Depends(require_admin)):
417
+ """Stop the tick driver without deleting persisted checkpoints."""
418
+ global _latest_snapshot, _sim_engine
419
+ if not _simulation_is_running():
420
+ return {"status": "already_stopped", "running": False}
421
+
422
+ await _stop_sim_task()
423
+ _sim_engine = None
424
+ _latest_snapshot = _snapshot_with_simulation_status(_latest_snapshot)
425
+ await _sim_broadcaster.broadcast(_latest_snapshot)
426
+ return {"status": "stopped", "running": False}
427
+
428
+
429
+ @app.post("/api/sim/start")
430
+ async def start_sim(_user: dict = Depends(require_admin)):
431
+ """Start from the newest persisted checkpoint, never stale RAM state."""
432
+ global _sim_task
433
+ if _simulation_is_running():
434
+ return {"status": "already_running", "running": True}
435
+
436
+ from src.core.checkpoint_manager import list_checkpoints
437
+ checkpoints = list_checkpoints()
438
+ if not checkpoints:
439
+ return JSONResponse(
440
+ {"error": "No checkpoint is available. Use Reset or restart Valhalla to create a fresh simulation."},
441
+ status_code=409,
442
+ )
443
+
444
+ _sim_task = asyncio.create_task(
445
+ _run_sim(resume_checkpoint=True),
446
+ name="valhalla-simulation",
447
+ )
448
+ return {
449
+ "status": "starting_from_checkpoint",
450
+ "running": True,
451
+ "checkpoint_tick": checkpoints[-1],
452
+ }
453
+
454
+
455
+ class RewindInput(BaseModel):
456
+ ticks: Optional[int] = Field(default=None, ge=1, le=1440)
457
+ hours: Optional[float] = Field(default=None, gt=0, le=24)
458
+
459
+ def requested_ticks(self, minutes_per_tick: int) -> int:
460
+ if self.ticks is not None:
461
+ return self.ticks
462
+ if self.hours is not None:
463
+ return max(1, round((self.hours * 60) / max(1, minutes_per_tick)))
464
+ raise ValueError("Provide a positive rewind amount in ticks or hours.")
465
+
466
+
467
+ # --------------------------------------------------------------------------- #
468
+ # Stopped-only roster editing. Each edit writes a successor checkpoint rather
469
+ # than overwriting history, so rewinding to a pre-edit checkpoint restores the
470
+ # roster that actually existed at that point in the timeline.
471
+ # --------------------------------------------------------------------------- #
472
+
473
+ class AddAgentInput(BaseModel):
474
+ description: str = Field(min_length=30, max_length=4000)
475
+
476
+
477
+ class RemoveAgentInput(BaseModel):
478
+ agent_id: str = Field(min_length=1, max_length=128)
479
+
480
+
481
+ class ReplaceAgentInput(BaseModel):
482
+ agent_id: str = Field(min_length=1, max_length=128)
483
+ name: str = Field(min_length=2, max_length=80)
484
+
485
+
486
+ class GeneratedPersona(BaseModel):
487
+ name: str = Field(min_length=2, max_length=80)
488
+ age: int = Field(ge=18, le=30)
489
+ gender: str = Field(min_length=1, max_length=32)
490
+ branch: str = Field(min_length=2, max_length=160)
491
+ home_city: str = Field(min_length=2, max_length=120)
492
+ hostel: str
493
+ daily_plan_req: str = Field(min_length=40, max_length=900)
494
+ innate: str = Field(min_length=40, max_length=900)
495
+ learned: str = Field(min_length=20, max_length=900)
496
+ lifestyle: str = Field(min_length=40, max_length=900)
497
+ hobbies: str = Field(min_length=20, max_length=900)
498
+ goals: str = Field(min_length=30, max_length=900)
499
+ interests: List[str] = Field(min_length=3, max_length=12)
500
+
501
+
502
+ def _require_stopped() -> Optional[JSONResponse]:
503
+ if _simulation_is_running():
504
+ return JSONResponse({"error": "Stop the simulation before editing the roster."}, status_code=409)
505
+ return None
506
+
507
+
508
+ def _safe_agent_id(name: str) -> str:
509
+ result = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
510
+ return result or "agent"
511
+
512
+
513
+ def _short_term_dir_for(name: str) -> Path:
514
+ return Path(DATA_DIR) / "Short_term_db" / _safe_agent_id(name)
515
+
516
+
517
+ def _persona_file_for(name: str) -> Optional[Path]:
518
+ root = Path(DATA_DIR) / "personalities"
519
+ for path in root.glob("**/*.json"):
520
+ try:
521
+ if json.loads(path.read_text(encoding="utf-8")).get("Name") == name:
522
+ return path
523
+ except (OSError, json.JSONDecodeError):
524
+ continue
525
+ return None
526
+
527
+
528
+ def _save_roster_successor(world, registry, engine_state: dict) -> int:
529
+ """Persist a roster edit without mutating its source checkpoint."""
530
+ from src.core.checkpoint_manager import latest_tick, save_checkpoint
531
+ # Same simulation minute, next storage tick: previous checkpoint remains
532
+ # a faithful historical roster while /start picks this successor.
533
+ successor = max(int(world.tick), int(latest_tick() or 0)) + 1
534
+ world.tick = successor
535
+ save_checkpoint(world, registry, successor, engine_state)
536
+ return successor
537
+
538
+
539
+ def _load_stopped_roster():
540
+ from src.core.checkpoint_manager import list_checkpoints, load_checkpoint
541
+ from src.core.world_engine import WorldEngine
542
+ ticks = list_checkpoints()
543
+ if not ticks:
544
+ raise ValueError("No checkpoint exists yet. Start and stop a fresh simulation before editing its roster.")
545
+ engine = WorldEngine()
546
+ world, registry, state = load_checkpoint(ticks[-1], engine.resolver, return_metadata=True)
547
+ engine.world, engine.registry = world, registry
548
+ engine.restore_checkpoint_state(state)
549
+ return engine, state
550
+
551
+
552
+ @app.get("/api/roster")
553
+ async def get_roster():
554
+ from src.core.checkpoint_manager import list_checkpoints, load_checkpoint
555
+ from src.core.world_engine import WorldEngine
556
+ ticks = list_checkpoints()
557
+ if not ticks:
558
+ return {"agents": [], "editable": not _simulation_is_running()}
559
+ engine = WorldEngine()
560
+ _, registry, _ = load_checkpoint(ticks[-1], engine.resolver, return_metadata=True)
561
+ return {"editable": not _simulation_is_running(), "agents": [
562
+ {"id": state.agent_id, "name": state.persona_name, "branch": state.persona.get("Branch", ""), "hostel": state.persona.get("Hostel", "")}
563
+ for state in registry.all_states()
564
+ ]}
565
+
566
+
567
+ @app.post("/api/roster/remove")
568
+ async def remove_agent(request: RemoveAgentInput, _user: dict = Depends(require_admin)):
569
+ blocked = _require_stopped()
570
+ if blocked:
571
+ return blocked
572
+ try:
573
+ engine, state = _load_stopped_roster()
574
+ removed = engine.registry.remove(request.agent_id)
575
+ except (ValueError, KeyError) as exc:
576
+ return JSONResponse({"error": str(exc)}, status_code=404)
577
+
578
+ engine.world.remove_agent(request.agent_id)
579
+ engine.relationship_matrix.remove_agent(request.agent_id)
580
+ archive = Path(DATA_DIR) / "retired_agents" / datetime.now().strftime("%Y%m%d_%H%M%S") / request.agent_id
581
+ archive.mkdir(parents=True, exist_ok=True)
582
+ persona_path = _persona_file_for(removed.persona_name)
583
+ if persona_path:
584
+ shutil.move(str(persona_path.parent), str(archive / "persona"))
585
+ short_term = _short_term_dir_for(removed.persona_name)
586
+ if short_term.exists():
587
+ shutil.move(str(short_term), str(archive / "short_term"))
588
+ try:
589
+ from src.agents.Long_term import get_retriever
590
+ delete = getattr(get_retriever(), "delete_agent_memory", None)
591
+ if callable(delete):
592
+ delete(request.agent_id)
593
+ except Exception as exc:
594
+ logger.warning("[Roster] could not delete semantic memory for %s: %s", request.agent_id, exc)
595
+ tick = _save_roster_successor(engine.world, engine.registry, engine.checkpoint_state())
596
+ return {"status": "removed", "agent_id": request.agent_id, "checkpoint_tick": tick, "archive": str(archive)}
597
+
598
+
599
+ @app.post("/api/roster/replace")
600
+ async def replace_agent(request: ReplaceAgentInput, _user: dict = Depends(require_admin)):
601
+ blocked = _require_stopped()
602
+ if blocked:
603
+ return blocked
604
+ try:
605
+ engine, state = _load_stopped_roster()
606
+ agent = engine.registry.get(request.agent_id)
607
+ except (ValueError, KeyError) as exc:
608
+ return JSONResponse({"error": str(exc)}, status_code=404)
609
+ old_name = agent.persona_name
610
+ if _persona_file_for(request.name):
611
+ return JSONResponse({"error": f"An agent named '{request.name}' already exists."}, status_code=409)
612
+ agent.persona_name = request.name
613
+ agent.persona["Name"] = request.name
614
+ persona_path = _persona_file_for(old_name)
615
+ if persona_path:
616
+ new_dir = persona_path.parent.parent / _safe_agent_id(request.name)
617
+ new_dir.mkdir(parents=True, exist_ok=True)
618
+ (new_dir / f"{_safe_agent_id(request.name)}.json").write_text(json.dumps(agent.persona, indent=2), encoding="utf-8")
619
+ shutil.rmtree(persona_path.parent)
620
+ old_short = _short_term_dir_for(old_name)
621
+ if old_short.exists():
622
+ old_short.rename(_short_term_dir_for(request.name))
623
+ engine.relationship_matrix.replace_display_name(old_name, request.name)
624
+ tick = _save_roster_successor(engine.world, engine.registry, engine.checkpoint_state())
625
+ return {"status": "replaced", "agent_id": request.agent_id, "name": request.name, "checkpoint_tick": tick}
626
+
627
+
628
+ @app.post("/api/roster/add")
629
+ async def add_agent(request: AddAgentInput, _user: dict = Depends(require_admin)):
630
+ """Generate an adult student persona from observer-provided notes, while stopped."""
631
+ blocked = _require_stopped()
632
+ if blocked:
633
+ return blocked
634
+ try:
635
+ engine, state = _load_stopped_roster()
636
+ from src.agents.day_planner import load_places
637
+ hostels = [place.id for place in load_places() if place.type == "residential"]
638
+ from src.llm.gemini_client import call_gemini
639
+ generated = call_gemini(
640
+ "Create one grounded adult (18-30) Indian college-student simulation persona. "
641
+ "Keep it respectful and non-explicit: no harassment, coercion, discrimination, stalking, or pornography. "
642
+ "Balance academics with routines, friendship, hobbies, flaws, and downtime. Use exactly one supplied hostel id.",
643
+ f"OBSERVER NOTES:\n{request.description}\n\nAVAILABLE HOSTEL IDS: {', '.join(hostels)}\n"
644
+ "Return a complete persona matching the requested schema.",
645
+ GeneratedPersona,
646
+ "default",
647
+ )
648
+ except Exception as exc:
649
+ logger.exception("[Roster] agent generation failed")
650
+ return JSONResponse({"error": f"Could not generate agent: {exc}"}, status_code=502)
651
+
652
+ agent_id = _safe_agent_id(generated.name)
653
+ if agent_id in engine.registry:
654
+ return JSONResponse({"error": f"An agent with id '{agent_id}' already exists."}, status_code=409)
655
+ if generated.hostel not in hostels:
656
+ return JSONResponse({"error": "Generated persona selected an invalid hostel."}, status_code=502)
657
+ persona = {
658
+ "Name": generated.name, "Age": str(generated.age), "Gender": generated.gender,
659
+ "Branch": generated.branch, "Home City": generated.home_city, "Hostel": generated.hostel,
660
+ "daily_plan_req": generated.daily_plan_req, "innate": generated.innate,
661
+ "learned": generated.learned, "lifestyle": generated.lifestyle,
662
+ "hobbies": generated.hobbies, "goals": generated.goals, "interests": generated.interests,
663
+ }
664
+ # A roster addition is not merely a display record: generate the same
665
+ # executable remaining-day plan used by the regular runtime before the
666
+ # successor checkpoint is committed. If the provider cannot do so, leave
667
+ # every persisted surface untouched and report the failure to the observer.
668
+ try:
669
+ from types import SimpleNamespace
670
+ from src.agents.day_planner import run as plan_day
671
+ current_hhmm = engine._minutes_to_hhmm(engine.world.tick % (24 * 60))
672
+ current_date = (datetime.strptime(engine.sim_start_date, "%Y-%m-%d") + timedelta(days=engine.world.tick // (24 * 60))).strftime("%Y-%m-%d")
673
+ proxy = SimpleNamespace(persona=persona, relevant_memories=[], yesterday_summary=None)
674
+ plan_result = await asyncio.get_running_loop().run_in_executor(None, lambda: plan_day(proxy, {
675
+ "current_time": f"{current_date} {current_hhmm}", "places": None,
676
+ "persona_name": generated.name, "mode": "remaining" if engine.world.tick else "full_day",
677
+ "current_location_id": generated.hostel, "upcoming_events": [],
678
+ }))
679
+ day_plan = plan_result.get("day_plan", [])
680
+ if not day_plan:
681
+ raise ValueError(plan_result.get("error") or "planner returned no executable actions")
682
+ except Exception as exc:
683
+ return JSONResponse({"error": f"Persona was generated but no executable plan could be created: {exc}"}, status_code=502)
684
+ from src.agents.Actions import AgentActionManager
685
+ from src.core.agent_registry import AgentRuntimeState
686
+ position = engine.resolver.random_interior_point(generated.hostel)
687
+ manager = AgentActionManager(agent_id, day_plan, position, engine.resolver)
688
+ engine.registry.register(AgentRuntimeState(
689
+ agent_id=agent_id, persona=persona, persona_name=generated.name,
690
+ manager=manager, position=position, day_plan=day_plan,
691
+ emotion_state=engine._emotion_baseline(persona), emotion_baseline=engine._emotion_baseline(persona),
692
+ ))
693
+ engine.world.register_agent(agent_id, position)
694
+ persona_dir = Path(DATA_DIR) / "personalities" / agent_id
695
+ persona_dir.mkdir(parents=True, exist_ok=False)
696
+ (persona_dir / f"{agent_id}.json").write_text(json.dumps(persona, indent=2), encoding="utf-8")
697
+
698
+ from src.agents.conversation import RelationshipRecord
699
+ for other in engine.registry.all_states():
700
+ if other.agent_id == agent_id:
701
+ continue
702
+ engine.relationship_matrix.seed(agent_id, other.agent_id, RelationshipRecord(
703
+ score=0.32, tags=["new-acquaintance"],
704
+ context=f"{generated.name} is new to this circle and is still learning {other.persona_name}'s rhythm."
705
+ ))
706
+ engine.relationship_matrix.seed(other.agent_id, agent_id, RelationshipRecord(
707
+ score=0.32, tags=["new-acquaintance"],
708
+ context=f"{other.persona_name} has only recently met {generated.name}; the connection is open but untested."
709
+ ))
710
+ tick = _save_roster_successor(engine.world, engine.registry, engine.checkpoint_state())
711
+ return {"status": "added", "agent_id": agent_id, "name": generated.name, "checkpoint_tick": tick}
712
+
713
+
714
+ @app.post("/api/sim/fast-forward")
715
+ async def fast_forward_sim(_user: dict = Depends(require_admin)):
716
+ """Increase the live tick-speed multiplier without restarting the day."""
717
+ from src import config as _cfg
718
+
719
+ _cfg.TICK_SPEED = min(32.0, max(1.0, _cfg.TICK_SPEED * 2.0))
720
+ return {"status": "ok", "speed_multiplier": _cfg.TICK_SPEED}
721
+
722
+
723
+ @app.post("/api/sim/slow-down")
724
+ async def slow_down_sim(_user: dict = Depends(require_admin)):
725
+ """Halve the live tick-speed multiplier without restarting the day."""
726
+ from src import config as _cfg
727
+
728
+ # The floor avoids accidentally making a single tick take minutes while
729
+ # still allowing an observer to inspect the simulation at quarter speed.
730
+ _cfg.TICK_SPEED = max(0.25, _cfg.TICK_SPEED / 2.0)
731
+ return {"status": "ok", "speed_multiplier": _cfg.TICK_SPEED}
732
+
733
+
734
+ @app.post("/api/sim/rewind")
735
+ async def rewind_sim(request: RewindInput, _user: dict = Depends(require_admin)):
736
+ """Restore the nearest retained checkpoint for a requested tick or hour offset."""
737
+ global _latest_snapshot
738
+ from src.core.checkpoint_manager import list_checkpoints, load_checkpoint
739
+ from src import config as _cfg
740
+
741
+ try:
742
+ requested_ticks = request.requested_ticks(_cfg.SIM_MINUTES_PER_TICK)
743
+ except ValueError as exc:
744
+ return JSONResponse({"error": str(exc)}, status_code=422)
745
+
746
+ async with _sim_tick_lock:
747
+ engine = _sim_engine
748
+ if engine is None:
749
+ return JSONResponse({"error": "Simulation is still initializing."}, status_code=409)
750
+ current_tick = engine.world.tick
751
+ target_tick = current_tick - requested_ticks
752
+ available = [tick for tick in list_checkpoints() if tick <= target_tick]
753
+ if not available:
754
+ return JSONResponse({
755
+ "error": f"Cannot rewind {requested_ticks} ticks; no retained checkpoint before tick {target_tick}.",
756
+ "current_tick": current_tick,
757
+ }, status_code=409)
758
+
759
+ restore_tick = available[-1]
760
+ # Decisions and conversations created after the target checkpoint
761
+ # belong to the discarded future. Cancel both before replacing state.
762
+ background_tasks = [
763
+ *engine._conversation_tasks.values(),
764
+ *engine._decision_tasks.values(),
765
+ ]
766
+ for task in background_tasks:
767
+ task.cancel()
768
+ if background_tasks:
769
+ await asyncio.gather(*background_tasks, return_exceptions=True)
770
+ engine._conversation_tasks.clear()
771
+ engine._decision_tasks.clear()
772
+
773
+ world, registry, state = load_checkpoint(restore_tick, engine.resolver, return_metadata=True)
774
+ # A checkpoint is the authority for the roster at its timeline point.
775
+ engine.world = world
776
+ engine.registry = registry
777
+ engine.restore_checkpoint_state(state)
778
+ await engine.resume_pending_conversations()
779
+ _latest_snapshot = engine.current_frontend_snapshot()
780
+ _latest_snapshot["rewound_from_tick"] = current_tick
781
+ _latest_snapshot["rewound_ticks"] = current_tick - restore_tick
782
+
783
+ await _sim_broadcaster.broadcast(_latest_snapshot)
784
+ _print_sim_status(_latest_snapshot)
785
+ return {"status": "rewound", "tick": restore_tick, "rewound_ticks": current_tick - restore_tick}
786
+
787
+
788
+ @app.get("/api/sim/state")
789
+ async def get_sim_state():
790
+ """Return the latest tick snapshot (or status if not yet running)."""
791
+ return _snapshot_with_simulation_status(_latest_snapshot)
792
+
793
+
794
+ @app.websocket("/ws/sim")
795
+ async def sim_websocket(ws: WebSocket):
796
+ """Subscribe to live tick-by-tick simulation state."""
797
+ await _sim_broadcaster.connect(ws, _latest_snapshot)
798
+ try:
799
+ while True:
800
+ msg = await ws.receive_text()
801
+ if msg == "ping":
802
+ await ws.send_text("pong")
803
+ except WebSocketDisconnect:
804
+ _sim_broadcaster.disconnect(ws)
805
+
806
+
807
+ _BLD_NAME_MAP = None
808
+
809
+ def _load_bld_names():
810
+ global _BLD_NAME_MAP
811
+ if _BLD_NAME_MAP is not None:
812
+ return
813
+ _BLD_NAME_MAP = {}
814
+ p = os.path.join(DATA_DIR, "environment", "num.txt")
815
+ with open(p) as f:
816
+ for line in f:
817
+ m = re.match(r'building (\d+) - (.+)', line.strip())
818
+ if m:
819
+ _BLD_NAME_MAP[int(m.group(1))] = m.group(2)
820
+
821
+
822
+ @app.get("/api/buildings")
823
+ def get_buildings():
824
+ _load_bld_names()
825
+ with open(os.path.join(DATA_DIR, "environment", "buildings_polygon_decomposed.json")) as f:
826
+ buildings = json.load(f)
827
+
828
+ seen = []
829
+ for b in buildings:
830
+ base = b["building_name"].rsplit("_part", 1)[0]
831
+ if base not in seen:
832
+ seen.append(base)
833
+
834
+ base_to_label = {}
835
+ for i, base in enumerate(seen):
836
+ num = i + 1
837
+ base_to_label[base] = _BLD_NAME_MAP.get(num, base)
838
+
839
+ simplified = []
840
+ for b in buildings:
841
+ tl = b["top_left"]
842
+ br = b["bottom_right"]
843
+ base = b["building_name"].rsplit("_part", 1)[0]
844
+ simplified.append({
845
+ "label": base_to_label[base],
846
+ "id": base,
847
+ "x": tl[0],
848
+ "y": tl[1],
849
+ "w": br[0] - tl[0],
850
+ "h": br[1] - tl[1],
851
+ })
852
+ return simplified
853
+
854
+
855
+ @app.get("/api/stats")
856
+ def get_stats():
857
+ return stats()
858
+
859
+
860
+ @app.get("/api/path")
861
+ def get_path(
862
+ x1: int = Query(32),
863
+ y1: int = Query(297),
864
+ x2: int = Query(959),
865
+ y2: int = Query(1205),
866
+ ):
867
+ start = (x1, y1)
868
+ end = (x2, y2)
869
+ path = shortest_path(start, end)
870
+ if path is None:
871
+ return JSONResponse({"error": "No path found"}, status_code=404)
872
+ return {"path": path, "length": len(path), "start": list(start), "end": list(end)}
873
+
874
+
875
+ @app.get("/api/path/stream")
876
+ async def stream_path(
877
+ x1: int = Query(32),
878
+ y1: int = Query(297),
879
+ x2: int = Query(959),
880
+ y2: int = Query(1205),
881
+ ):
882
+ start = (x1, y1)
883
+ end = (x2, y2)
884
+ path = shortest_path(start, end)
885
+ if path is None:
886
+ from fastapi.responses import JSONResponse
887
+ return JSONResponse({"error": "No path found"}, status_code=404)
888
+
889
+ from fastapi.responses import StreamingResponse
890
+ import time
891
+
892
+ async def point_stream():
893
+ yield json.dumps({"meta": {"length": len(path), "start": list(start), "end": list(end)}}) + "\n"
894
+ for pt in path:
895
+ yield json.dumps({"x": pt[0], "y": pt[1]}) + "\n"
896
+ await asyncio.sleep(0)
897
+ yield json.dumps({"done": True}) + "\n"
898
+
899
+ return StreamingResponse(point_stream(), media_type="application/x-ndjson")
900
+
901
+
902
+ @app.websocket("/ws")
903
+ async def websocket_endpoint(websocket: WebSocket):
904
+ await websocket.accept()
905
+ try:
906
+ while True:
907
+ data = await websocket.receive_text()
908
+ try:
909
+ msg = json.loads(data)
910
+ except json.JSONDecodeError:
911
+ await websocket.send_json({"error": "invalid JSON"})
912
+ continue
913
+
914
+ if msg.get("type") == "path":
915
+ start = (msg["x1"], msg["y1"])
916
+ end = (msg["x2"], msg["y2"])
917
+ path = shortest_path(start, end)
918
+ if path is None:
919
+ await websocket.send_json({"type": "path_result", "error": "No path found"})
920
+ else:
921
+ meta = {"length": len(path), "start": list(start), "end": list(end)}
922
+ await websocket.send_json({"type": "path_meta", "meta": meta})
923
+ for pt in path:
924
+ await websocket.send_json({"type": "path_point", "x": pt[0], "y": pt[1]})
925
+ await asyncio.sleep(0)
926
+ await websocket.send_json({"type": "path_done"})
927
+ elif msg.get("type") == "ping":
928
+ await websocket.send_json({"type": "pong"})
929
+ except WebSocketDisconnect:
930
+ pass
931
+
932
+
933
+ class AgentInput(BaseModel):
934
+ x1: int
935
+ y1: int
936
+ x2: int
937
+ y2: int
938
+
939
+ class PathsInput(BaseModel):
940
+ agents: List[AgentInput]
941
+
942
+
943
+ @app.post("/api/paths")
944
+ async def get_paths(input_data: PathsInput):
945
+ results = []
946
+ for a in input_data.agents:
947
+ start = (a.x1, a.y1)
948
+ end = (a.x2, a.y2)
949
+ path = shortest_path(start, end)
950
+ results.append({
951
+ "path": path,
952
+ "length": len(path) if path else 0,
953
+ "start": [a.x1, a.y1],
954
+ "end": [a.x2, a.y2],
955
+ })
956
+ return {"paths": results}
957
+
958
+
959
+ @app.get("/api/entrypoints")
960
+ def get_entrypoints():
961
+ with open(os.path.join(DATA_DIR, "environment", "entrypoint.json")) as f:
962
+ return json.load(f)
963
+
964
+
965
+ # Serve React production build (dist/) if it exists, else fallback to frontend/
966
+ import mimetypes
967
+ # On Windows, the registry often maps .js to text/plain, which makes browsers
968
+ # refuse the ES module (strict MIME checking) and the whole SPA fails to load —
969
+ # leaving only a blank page. Force the correct types explicitly.
970
+ mimetypes.add_type("application/javascript", ".js")
971
+ mimetypes.add_type("application/javascript", ".mjs")
972
+ mimetypes.add_type("text/css", ".css")
973
+ mimetypes.add_type("image/svg+xml", ".svg")
974
+ mimetypes.add_type("image/png", ".png")
975
+
976
+ @app.get("/{full_path:path}")
977
+ async def serve_spa(full_path: str):
978
+ dist = os.path.join(FRONTEND, "dist")
979
+ if os.path.isdir(dist):
980
+ file_path = os.path.join(dist, full_path) if full_path else os.path.join(dist, "index.html")
981
+ if os.path.isfile(file_path):
982
+ return FileResponse(file_path)
983
+ return FileResponse(os.path.join(dist, "index.html"))
984
+ file_path = os.path.join(FRONTEND, full_path) if full_path else os.path.join(FRONTEND, "index.html")
985
+ if os.path.isfile(file_path):
986
+ return FileResponse(file_path)
987
+ return FileResponse(os.path.join(FRONTEND, "index.html"))
988
+
989
+
990
+ if __name__ == "__main__":
991
+ import argparse
992
+ invalid_resume_flags = [arg for arg in sys.argv[1:] if arg.startswith("--resume") and arg != "--resume-checkpoint"]
993
+ if invalid_resume_flags:
994
+ raise SystemExit(
995
+ "Invalid resume flag: " + ", ".join(invalid_resume_flags)
996
+ + ". Did you mean --resume-checkpoint?"
997
+ )
998
+ parser = argparse.ArgumentParser(description="Valhalla simulation server")
999
+ parser.add_argument(
1000
+ "--resume-checkpoint", action="store_true", default=False,
1001
+ help="Resume the latest checkpoint without clearing runtime data",
1002
+ )
1003
+ parser.add_argument(
1004
+ "--port", type=int, default=int(os.environ.get("PORT", "8000")),
1005
+ help="Port to listen on (default: 8000)",
1006
+ )
1007
+ parser.add_argument(
1008
+ "--host", type=str, default=os.environ.get("HOST", "127.0.0.1"),
1009
+ help="Host to bind to (default: 127.0.0.1, use 0.0.0.0 for public)",
1010
+ )
1011
+ args, _ = parser.parse_known_args()
1012
+
1013
+ # Reserve the address before ASGI startup. Uvicorn normally starts the
1014
+ # lifespan before binding, which used to let a doomed duplicate launch
1015
+ # clear state and spend model calls without ever serving the browser.
1016
+ try:
1017
+ listen_socket = _reserve_listen_socket(args.host, args.port)
1018
+ except OSError as exc:
1019
+ print(
1020
+ f"\n Cannot start Valhalla on http://{args.host}:{args.port}: {exc}.\n"
1021
+ f" Stop the existing server or choose a free port, e.g. --port {args.port + 1}.\n",
1022
+ file=sys.stderr,
1023
+ )
1024
+ raise SystemExit(1)
1025
+
1026
+ if args.resume_checkpoint:
1027
+ print(f"\n Valhalla is live => http://{args.host}:{args.port} (resuming from checkpoint)\n")
1028
+ else:
1029
+ print(f"\n Valhalla is live => http://{args.host}:{args.port} (fresh simulation; runtime data will be cleared)\n")
1030
+
1031
+ config = uvicorn.Config(app, host=args.host, port=args.port, reload=False)
1032
+ try:
1033
+ uvicorn.Server(config).run(sockets=[listen_socket])
1034
+ except KeyboardInterrupt:
1035
+ print("\n[Server] Valhalla stopped cleanly.")
1036
+ finally:
1037
+ listen_socket.close()
backend/Pathfinder_test.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CLI tool for pixel-level pathfinding on the Valhalla map.
3
+ Loads map.png, computes the shortest path between two pixel coordinates
4
+ via BFS, and displays the result with start/end markers in a matplotlib window.
5
+ """
6
+
7
+ import sys
8
+ import os
9
+ from PIL import Image, ImageDraw, ImageFont
10
+ import matplotlib.pyplot as plt
11
+ from pathfinder import shortest_path, stats, ROOT
12
+
13
+
14
+ def show_path_on_map(path, start, end, save_path=None):
15
+ img = Image.open(os.path.join(ROOT, "frontend", "map.png")).convert("RGBA")
16
+ overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
17
+ draw = ImageDraw.Draw(overlay)
18
+
19
+ if path:
20
+ draw.line(path, fill=(255, 30, 30, 255), width=2)
21
+
22
+ r = 5
23
+ sx, sy = start
24
+ ex, ey = end
25
+ draw.ellipse([sx - r, sy - r, sx + r, sy + r], fill=(0, 220, 0, 255))
26
+ draw.ellipse([ex - r, ey - r, ex + r, ey + r], fill=(30, 100, 255, 255))
27
+
28
+ try:
29
+ font = ImageFont.truetype("arial.ttf", 14)
30
+ except (IOError, OSError):
31
+ font = ImageFont.load_default()
32
+ draw.text((sx + 7, sy - 9), "S", fill=(0, 220, 0, 255), font=font)
33
+ draw.text((ex + 7, ey - 9), "E", fill=(30, 100, 255, 255), font=font)
34
+
35
+ composited = Image.alpha_composite(img, overlay).convert("RGB")
36
+
37
+ if save_path:
38
+ composited.save(os.path.join(ROOT, save_path))
39
+ print(f"Saved: {save_path}")
40
+ else:
41
+ plt.figure(figsize=(14, 14))
42
+ plt.imshow(composited)
43
+ plt.axis("off")
44
+ plt.tight_layout(pad=0)
45
+ plt.show()
46
+
47
+
48
+ if __name__ == "__main__":
49
+ cmds = [a for a in sys.argv[1:] if not a.startswith("--")]
50
+ flags = set(a for a in sys.argv[1:] if a.startswith("--"))
51
+
52
+ if "--help" in flags or "-h" in flags:
53
+ print("Usage: python backend/pixel_pathfinder.py [x1 y1 x2 y2]")
54
+ print()
55
+ print(" x1 y1 x2 y2 pixel coordinates for start and end")
56
+ print(" --count count white pixels only")
57
+ sys.exit(0)
58
+
59
+ if "--count" in flags:
60
+ s = stats()
61
+ print(f"White pixels: {s['white_pixels']} / {s['width']*s['height']}")
62
+ sys.exit(0)
63
+
64
+ if len(cmds) >= 4:
65
+ sx, sy, ex, ey = int(cmds[0]), int(cmds[1]), int(cmds[2]), int(cmds[3])
66
+ else:
67
+ sx, sy = 32, 297
68
+ ex, ey = 959, 1205
69
+
70
+ start = (sx, sy)
71
+ end = (ex, ey)
72
+
73
+ print(f"Path: ({sx},{sy}) -> ({ex},{ey})")
74
+
75
+ path = shortest_path(start, end)
76
+ if path:
77
+ print(f"Found: {len(path)} pixels")
78
+ show_path_on_map(path, start, end)
79
+ else:
80
+ print("No path found")
backend/data/environment/buildings_polygon_decomposed.json ADDED
@@ -0,0 +1,3878 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "building_name": "PolygonBuilding_1_part1",
4
+ "top_left": [
5
+ 777,
6
+ 445
7
+ ],
8
+ "top_right": [
9
+ 785,
10
+ 445
11
+ ],
12
+ "bottom_left": [
13
+ 777,
14
+ 560
15
+ ],
16
+ "bottom_right": [
17
+ 785,
18
+ 560
19
+ ]
20
+ },
21
+ {
22
+ "building_name": "PolygonBuilding_1_part2",
23
+ "top_left": [
24
+ 785,
25
+ 445
26
+ ],
27
+ "top_right": [
28
+ 793,
29
+ 445
30
+ ],
31
+ "bottom_left": [
32
+ 785,
33
+ 559
34
+ ],
35
+ "bottom_right": [
36
+ 793,
37
+ 559
38
+ ]
39
+ },
40
+ {
41
+ "building_name": "PolygonBuilding_1_part3",
42
+ "top_left": [
43
+ 793,
44
+ 445
45
+ ],
46
+ "top_right": [
47
+ 801,
48
+ 445
49
+ ],
50
+ "bottom_left": [
51
+ 793,
52
+ 559
53
+ ],
54
+ "bottom_right": [
55
+ 801,
56
+ 559
57
+ ]
58
+ },
59
+ {
60
+ "building_name": "PolygonBuilding_1_part4",
61
+ "top_left": [
62
+ 801,
63
+ 444
64
+ ],
65
+ "top_right": [
66
+ 809,
67
+ 444
68
+ ],
69
+ "bottom_left": [
70
+ 801,
71
+ 558
72
+ ],
73
+ "bottom_right": [
74
+ 809,
75
+ 558
76
+ ]
77
+ },
78
+ {
79
+ "building_name": "PolygonBuilding_1_part5",
80
+ "top_left": [
81
+ 809,
82
+ 444
83
+ ],
84
+ "top_right": [
85
+ 817,
86
+ 444
87
+ ],
88
+ "bottom_left": [
89
+ 809,
90
+ 558
91
+ ],
92
+ "bottom_right": [
93
+ 817,
94
+ 558
95
+ ]
96
+ },
97
+ {
98
+ "building_name": "PolygonBuilding_1_part6",
99
+ "top_left": [
100
+ 817,
101
+ 444
102
+ ],
103
+ "top_right": [
104
+ 825,
105
+ 444
106
+ ],
107
+ "bottom_left": [
108
+ 817,
109
+ 559
110
+ ],
111
+ "bottom_right": [
112
+ 825,
113
+ 559
114
+ ]
115
+ },
116
+ {
117
+ "building_name": "PolygonBuilding_1_part7",
118
+ "top_left": [
119
+ 825,
120
+ 444
121
+ ],
122
+ "top_right": [
123
+ 833,
124
+ 444
125
+ ],
126
+ "bottom_left": [
127
+ 825,
128
+ 560
129
+ ],
130
+ "bottom_right": [
131
+ 833,
132
+ 560
133
+ ]
134
+ },
135
+ {
136
+ "building_name": "PolygonBuilding_1_part8",
137
+ "top_left": [
138
+ 833,
139
+ 443
140
+ ],
141
+ "top_right": [
142
+ 841,
143
+ 443
144
+ ],
145
+ "bottom_left": [
146
+ 833,
147
+ 561
148
+ ],
149
+ "bottom_right": [
150
+ 841,
151
+ 561
152
+ ]
153
+ },
154
+ {
155
+ "building_name": "PolygonBuilding_1_part9",
156
+ "top_left": [
157
+ 841,
158
+ 443
159
+ ],
160
+ "top_right": [
161
+ 849,
162
+ 443
163
+ ],
164
+ "bottom_left": [
165
+ 841,
166
+ 562
167
+ ],
168
+ "bottom_right": [
169
+ 849,
170
+ 562
171
+ ]
172
+ },
173
+ {
174
+ "building_name": "PolygonBuilding_1_part10",
175
+ "top_left": [
176
+ 849,
177
+ 443
178
+ ],
179
+ "top_right": [
180
+ 857,
181
+ 443
182
+ ],
183
+ "bottom_left": [
184
+ 849,
185
+ 564
186
+ ],
187
+ "bottom_right": [
188
+ 857,
189
+ 564
190
+ ]
191
+ },
192
+ {
193
+ "building_name": "PolygonBuilding_1_part11",
194
+ "top_left": [
195
+ 857,
196
+ 443
197
+ ],
198
+ "top_right": [
199
+ 865,
200
+ 443
201
+ ],
202
+ "bottom_left": [
203
+ 857,
204
+ 565
205
+ ],
206
+ "bottom_right": [
207
+ 865,
208
+ 565
209
+ ]
210
+ },
211
+ {
212
+ "building_name": "PolygonBuilding_1_part12",
213
+ "top_left": [
214
+ 865,
215
+ 442
216
+ ],
217
+ "top_right": [
218
+ 873,
219
+ 442
220
+ ],
221
+ "bottom_left": [
222
+ 865,
223
+ 564
224
+ ],
225
+ "bottom_right": [
226
+ 873,
227
+ 564
228
+ ]
229
+ },
230
+ {
231
+ "building_name": "PolygonBuilding_1_part13",
232
+ "top_left": [
233
+ 873,
234
+ 442
235
+ ],
236
+ "top_right": [
237
+ 881,
238
+ 442
239
+ ],
240
+ "bottom_left": [
241
+ 873,
242
+ 563
243
+ ],
244
+ "bottom_right": [
245
+ 881,
246
+ 563
247
+ ]
248
+ },
249
+ {
250
+ "building_name": "PolygonBuilding_1_part14",
251
+ "top_left": [
252
+ 881,
253
+ 442
254
+ ],
255
+ "top_right": [
256
+ 889,
257
+ 442
258
+ ],
259
+ "bottom_left": [
260
+ 881,
261
+ 561
262
+ ],
263
+ "bottom_right": [
264
+ 889,
265
+ 561
266
+ ]
267
+ },
268
+ {
269
+ "building_name": "PolygonBuilding_1_part15",
270
+ "top_left": [
271
+ 889,
272
+ 441
273
+ ],
274
+ "top_right": [
275
+ 897,
276
+ 441
277
+ ],
278
+ "bottom_left": [
279
+ 889,
280
+ 560
281
+ ],
282
+ "bottom_right": [
283
+ 897,
284
+ 560
285
+ ]
286
+ },
287
+ {
288
+ "building_name": "PolygonBuilding_1_part16",
289
+ "top_left": [
290
+ 897,
291
+ 453
292
+ ],
293
+ "top_right": [
294
+ 905,
295
+ 453
296
+ ],
297
+ "bottom_left": [
298
+ 897,
299
+ 558
300
+ ],
301
+ "bottom_right": [
302
+ 905,
303
+ 558
304
+ ]
305
+ },
306
+ {
307
+ "building_name": "PolygonBuilding_2_part1",
308
+ "top_left": [
309
+ 200,
310
+ 544
311
+ ],
312
+ "top_right": [
313
+ 208,
314
+ 544
315
+ ],
316
+ "bottom_left": [
317
+ 200,
318
+ 561
319
+ ],
320
+ "bottom_right": [
321
+ 208,
322
+ 561
323
+ ]
324
+ },
325
+ {
326
+ "building_name": "PolygonBuilding_2_part2",
327
+ "top_left": [
328
+ 208,
329
+ 534
330
+ ],
331
+ "top_right": [
332
+ 216,
333
+ 534
334
+ ],
335
+ "bottom_left": [
336
+ 208,
337
+ 561
338
+ ],
339
+ "bottom_right": [
340
+ 216,
341
+ 561
342
+ ]
343
+ },
344
+ {
345
+ "building_name": "PolygonBuilding_2_part3",
346
+ "top_left": [
347
+ 216,
348
+ 524
349
+ ],
350
+ "top_right": [
351
+ 224,
352
+ 524
353
+ ],
354
+ "bottom_left": [
355
+ 216,
356
+ 561
357
+ ],
358
+ "bottom_right": [
359
+ 224,
360
+ 561
361
+ ]
362
+ },
363
+ {
364
+ "building_name": "PolygonBuilding_2_part4",
365
+ "top_left": [
366
+ 224,
367
+ 518
368
+ ],
369
+ "top_right": [
370
+ 232,
371
+ 518
372
+ ],
373
+ "bottom_left": [
374
+ 224,
375
+ 561
376
+ ],
377
+ "bottom_right": [
378
+ 232,
379
+ 561
380
+ ]
381
+ },
382
+ {
383
+ "building_name": "PolygonBuilding_2_part5",
384
+ "top_left": [
385
+ 232,
386
+ 519
387
+ ],
388
+ "top_right": [
389
+ 240,
390
+ 519
391
+ ],
392
+ "bottom_left": [
393
+ 232,
394
+ 561
395
+ ],
396
+ "bottom_right": [
397
+ 240,
398
+ 561
399
+ ]
400
+ },
401
+ {
402
+ "building_name": "PolygonBuilding_2_part6",
403
+ "top_left": [
404
+ 240,
405
+ 521
406
+ ],
407
+ "top_right": [
408
+ 248,
409
+ 521
410
+ ],
411
+ "bottom_left": [
412
+ 240,
413
+ 561
414
+ ],
415
+ "bottom_right": [
416
+ 248,
417
+ 561
418
+ ]
419
+ },
420
+ {
421
+ "building_name": "PolygonBuilding_2_part7",
422
+ "top_left": [
423
+ 248,
424
+ 522
425
+ ],
426
+ "top_right": [
427
+ 256,
428
+ 522
429
+ ],
430
+ "bottom_left": [
431
+ 248,
432
+ 561
433
+ ],
434
+ "bottom_right": [
435
+ 256,
436
+ 561
437
+ ]
438
+ },
439
+ {
440
+ "building_name": "PolygonBuilding_2_part8",
441
+ "top_left": [
442
+ 256,
443
+ 523
444
+ ],
445
+ "top_right": [
446
+ 264,
447
+ 523
448
+ ],
449
+ "bottom_left": [
450
+ 256,
451
+ 561
452
+ ],
453
+ "bottom_right": [
454
+ 264,
455
+ 561
456
+ ]
457
+ },
458
+ {
459
+ "building_name": "PolygonBuilding_2_part9",
460
+ "top_left": [
461
+ 264,
462
+ 525
463
+ ],
464
+ "top_right": [
465
+ 272,
466
+ 525
467
+ ],
468
+ "bottom_left": [
469
+ 264,
470
+ 562
471
+ ],
472
+ "bottom_right": [
473
+ 272,
474
+ 562
475
+ ]
476
+ },
477
+ {
478
+ "building_name": "PolygonBuilding_2_part10",
479
+ "top_left": [
480
+ 272,
481
+ 526
482
+ ],
483
+ "top_right": [
484
+ 280,
485
+ 526
486
+ ],
487
+ "bottom_left": [
488
+ 272,
489
+ 562
490
+ ],
491
+ "bottom_right": [
492
+ 280,
493
+ 562
494
+ ]
495
+ },
496
+ {
497
+ "building_name": "PolygonBuilding_2_part11",
498
+ "top_left": [
499
+ 280,
500
+ 524
501
+ ],
502
+ "top_right": [
503
+ 288,
504
+ 524
505
+ ],
506
+ "bottom_left": [
507
+ 280,
508
+ 562
509
+ ],
510
+ "bottom_right": [
511
+ 288,
512
+ 562
513
+ ]
514
+ },
515
+ {
516
+ "building_name": "PolygonBuilding_2_part12",
517
+ "top_left": [
518
+ 288,
519
+ 523
520
+ ],
521
+ "top_right": [
522
+ 296,
523
+ 523
524
+ ],
525
+ "bottom_left": [
526
+ 288,
527
+ 562
528
+ ],
529
+ "bottom_right": [
530
+ 296,
531
+ 562
532
+ ]
533
+ },
534
+ {
535
+ "building_name": "PolygonBuilding_2_part13",
536
+ "top_left": [
537
+ 296,
538
+ 522
539
+ ],
540
+ "top_right": [
541
+ 304,
542
+ 522
543
+ ],
544
+ "bottom_left": [
545
+ 296,
546
+ 562
547
+ ],
548
+ "bottom_right": [
549
+ 304,
550
+ 562
551
+ ]
552
+ },
553
+ {
554
+ "building_name": "PolygonBuilding_2_part14",
555
+ "top_left": [
556
+ 304,
557
+ 527
558
+ ],
559
+ "top_right": [
560
+ 312,
561
+ 527
562
+ ],
563
+ "bottom_left": [
564
+ 304,
565
+ 562
566
+ ],
567
+ "bottom_right": [
568
+ 312,
569
+ 562
570
+ ]
571
+ },
572
+ {
573
+ "building_name": "PolygonBuilding_2_part15",
574
+ "top_left": [
575
+ 312,
576
+ 536
577
+ ],
578
+ "top_right": [
579
+ 320,
580
+ 536
581
+ ],
582
+ "bottom_left": [
583
+ 312,
584
+ 562
585
+ ],
586
+ "bottom_right": [
587
+ 320,
588
+ 562
589
+ ]
590
+ },
591
+ {
592
+ "building_name": "PolygonBuilding_2_part16",
593
+ "top_left": [
594
+ 320,
595
+ 546
596
+ ],
597
+ "top_right": [
598
+ 328,
599
+ 546
600
+ ],
601
+ "bottom_left": [
602
+ 320,
603
+ 562
604
+ ],
605
+ "bottom_right": [
606
+ 328,
607
+ 562
608
+ ]
609
+ },
610
+ {
611
+ "building_name": "PolygonBuilding_2_part17",
612
+ "top_left": [
613
+ 328,
614
+ 556
615
+ ],
616
+ "top_right": [
617
+ 336,
618
+ 556
619
+ ],
620
+ "bottom_left": [
621
+ 328,
622
+ 563
623
+ ],
624
+ "bottom_right": [
625
+ 336,
626
+ 563
627
+ ]
628
+ },
629
+ {
630
+ "building_name": "PolygonBuilding_3_part1",
631
+ "top_left": [
632
+ 72,
633
+ 518
634
+ ],
635
+ "top_right": [
636
+ 80,
637
+ 518
638
+ ],
639
+ "bottom_left": [
640
+ 72,
641
+ 570
642
+ ],
643
+ "bottom_right": [
644
+ 80,
645
+ 570
646
+ ]
647
+ },
648
+ {
649
+ "building_name": "PolygonBuilding_3_part2",
650
+ "top_left": [
651
+ 80,
652
+ 519
653
+ ],
654
+ "top_right": [
655
+ 88,
656
+ 519
657
+ ],
658
+ "bottom_left": [
659
+ 80,
660
+ 570
661
+ ],
662
+ "bottom_right": [
663
+ 88,
664
+ 570
665
+ ]
666
+ },
667
+ {
668
+ "building_name": "PolygonBuilding_3_part3",
669
+ "top_left": [
670
+ 88,
671
+ 519
672
+ ],
673
+ "top_right": [
674
+ 96,
675
+ 519
676
+ ],
677
+ "bottom_left": [
678
+ 88,
679
+ 571
680
+ ],
681
+ "bottom_right": [
682
+ 96,
683
+ 571
684
+ ]
685
+ },
686
+ {
687
+ "building_name": "PolygonBuilding_3_part4",
688
+ "top_left": [
689
+ 96,
690
+ 519
691
+ ],
692
+ "top_right": [
693
+ 104,
694
+ 519
695
+ ],
696
+ "bottom_left": [
697
+ 96,
698
+ 571
699
+ ],
700
+ "bottom_right": [
701
+ 104,
702
+ 571
703
+ ]
704
+ },
705
+ {
706
+ "building_name": "PolygonBuilding_3_part5",
707
+ "top_left": [
708
+ 104,
709
+ 520
710
+ ],
711
+ "top_right": [
712
+ 112,
713
+ 520
714
+ ],
715
+ "bottom_left": [
716
+ 104,
717
+ 571
718
+ ],
719
+ "bottom_right": [
720
+ 112,
721
+ 571
722
+ ]
723
+ },
724
+ {
725
+ "building_name": "PolygonBuilding_3_part6",
726
+ "top_left": [
727
+ 112,
728
+ 520
729
+ ],
730
+ "top_right": [
731
+ 120,
732
+ 520
733
+ ],
734
+ "bottom_left": [
735
+ 112,
736
+ 572
737
+ ],
738
+ "bottom_right": [
739
+ 120,
740
+ 572
741
+ ]
742
+ },
743
+ {
744
+ "building_name": "PolygonBuilding_3_part7",
745
+ "top_left": [
746
+ 120,
747
+ 521
748
+ ],
749
+ "top_right": [
750
+ 128,
751
+ 521
752
+ ],
753
+ "bottom_left": [
754
+ 120,
755
+ 572
756
+ ],
757
+ "bottom_right": [
758
+ 128,
759
+ 572
760
+ ]
761
+ },
762
+ {
763
+ "building_name": "PolygonBuilding_3_part8",
764
+ "top_left": [
765
+ 128,
766
+ 522
767
+ ],
768
+ "top_right": [
769
+ 136,
770
+ 522
771
+ ],
772
+ "bottom_left": [
773
+ 128,
774
+ 572
775
+ ],
776
+ "bottom_right": [
777
+ 136,
778
+ 572
779
+ ]
780
+ },
781
+ {
782
+ "building_name": "PolygonBuilding_3_part9",
783
+ "top_left": [
784
+ 136,
785
+ 524
786
+ ],
787
+ "top_right": [
788
+ 144,
789
+ 524
790
+ ],
791
+ "bottom_left": [
792
+ 136,
793
+ 573
794
+ ],
795
+ "bottom_right": [
796
+ 144,
797
+ 573
798
+ ]
799
+ },
800
+ {
801
+ "building_name": "PolygonBuilding_3_part10",
802
+ "top_left": [
803
+ 144,
804
+ 526
805
+ ],
806
+ "top_right": [
807
+ 152,
808
+ 526
809
+ ],
810
+ "bottom_left": [
811
+ 144,
812
+ 573
813
+ ],
814
+ "bottom_right": [
815
+ 152,
816
+ 573
817
+ ]
818
+ },
819
+ {
820
+ "building_name": "PolygonBuilding_3_part11",
821
+ "top_left": [
822
+ 152,
823
+ 528
824
+ ],
825
+ "top_right": [
826
+ 160,
827
+ 528
828
+ ],
829
+ "bottom_left": [
830
+ 152,
831
+ 574
832
+ ],
833
+ "bottom_right": [
834
+ 160,
835
+ 574
836
+ ]
837
+ },
838
+ {
839
+ "building_name": "PolygonBuilding_3_part12",
840
+ "top_left": [
841
+ 160,
842
+ 529
843
+ ],
844
+ "top_right": [
845
+ 168,
846
+ 529
847
+ ],
848
+ "bottom_left": [
849
+ 160,
850
+ 573
851
+ ],
852
+ "bottom_right": [
853
+ 168,
854
+ 573
855
+ ]
856
+ },
857
+ {
858
+ "building_name": "PolygonBuilding_3_part13",
859
+ "top_left": [
860
+ 168,
861
+ 531
862
+ ],
863
+ "top_right": [
864
+ 176,
865
+ 531
866
+ ],
867
+ "bottom_left": [
868
+ 168,
869
+ 566
870
+ ],
871
+ "bottom_right": [
872
+ 176,
873
+ 566
874
+ ]
875
+ },
876
+ {
877
+ "building_name": "PolygonBuilding_3_part14",
878
+ "top_left": [
879
+ 176,
880
+ 533
881
+ ],
882
+ "top_right": [
883
+ 184,
884
+ 533
885
+ ],
886
+ "bottom_left": [
887
+ 176,
888
+ 551
889
+ ],
890
+ "bottom_right": [
891
+ 184,
892
+ 551
893
+ ]
894
+ },
895
+ {
896
+ "building_name": "PolygonBuilding_3_part15",
897
+ "top_left": [
898
+ 184,
899
+ 534
900
+ ],
901
+ "top_right": [
902
+ 187,
903
+ 534
904
+ ],
905
+ "bottom_left": [
906
+ 184,
907
+ 535
908
+ ],
909
+ "bottom_right": [
910
+ 187,
911
+ 535
912
+ ]
913
+ },
914
+ {
915
+ "building_name": "PolygonBuilding_4_part1",
916
+ "top_left": [
917
+ 118,
918
+ 500
919
+ ],
920
+ "top_right": [
921
+ 126,
922
+ 500
923
+ ],
924
+ "bottom_left": [
925
+ 118,
926
+ 515
927
+ ],
928
+ "bottom_right": [
929
+ 126,
930
+ 515
931
+ ]
932
+ },
933
+ {
934
+ "building_name": "PolygonBuilding_4_part2",
935
+ "top_left": [
936
+ 126,
937
+ 487
938
+ ],
939
+ "top_right": [
940
+ 134,
941
+ 487
942
+ ],
943
+ "bottom_left": [
944
+ 126,
945
+ 517
946
+ ],
947
+ "bottom_right": [
948
+ 134,
949
+ 517
950
+ ]
951
+ },
952
+ {
953
+ "building_name": "PolygonBuilding_4_part3",
954
+ "top_left": [
955
+ 134,
956
+ 474
957
+ ],
958
+ "top_right": [
959
+ 142,
960
+ 474
961
+ ],
962
+ "bottom_left": [
963
+ 134,
964
+ 519
965
+ ],
966
+ "bottom_right": [
967
+ 142,
968
+ 519
969
+ ]
970
+ },
971
+ {
972
+ "building_name": "PolygonBuilding_4_part4",
973
+ "top_left": [
974
+ 142,
975
+ 461
976
+ ],
977
+ "top_right": [
978
+ 150,
979
+ 461
980
+ ],
981
+ "bottom_left": [
982
+ 142,
983
+ 521
984
+ ],
985
+ "bottom_right": [
986
+ 150,
987
+ 521
988
+ ]
989
+ },
990
+ {
991
+ "building_name": "PolygonBuilding_4_part5",
992
+ "top_left": [
993
+ 150,
994
+ 455
995
+ ],
996
+ "top_right": [
997
+ 158,
998
+ 455
999
+ ],
1000
+ "bottom_left": [
1001
+ 150,
1002
+ 523
1003
+ ],
1004
+ "bottom_right": [
1005
+ 158,
1006
+ 523
1007
+ ]
1008
+ },
1009
+ {
1010
+ "building_name": "PolygonBuilding_4_part6",
1011
+ "top_left": [
1012
+ 158,
1013
+ 458
1014
+ ],
1015
+ "top_right": [
1016
+ 166,
1017
+ 458
1018
+ ],
1019
+ "bottom_left": [
1020
+ 158,
1021
+ 525
1022
+ ],
1023
+ "bottom_right": [
1024
+ 166,
1025
+ 525
1026
+ ]
1027
+ },
1028
+ {
1029
+ "building_name": "PolygonBuilding_4_part7",
1030
+ "top_left": [
1031
+ 166,
1032
+ 462
1033
+ ],
1034
+ "top_right": [
1035
+ 174,
1036
+ 462
1037
+ ],
1038
+ "bottom_left": [
1039
+ 166,
1040
+ 528
1041
+ ],
1042
+ "bottom_right": [
1043
+ 174,
1044
+ 528
1045
+ ]
1046
+ },
1047
+ {
1048
+ "building_name": "PolygonBuilding_4_part8",
1049
+ "top_left": [
1050
+ 174,
1051
+ 466
1052
+ ],
1053
+ "top_right": [
1054
+ 182,
1055
+ 466
1056
+ ],
1057
+ "bottom_left": [
1058
+ 174,
1059
+ 530
1060
+ ],
1061
+ "bottom_right": [
1062
+ 182,
1063
+ 530
1064
+ ]
1065
+ },
1066
+ {
1067
+ "building_name": "PolygonBuilding_4_part9",
1068
+ "top_left": [
1069
+ 182,
1070
+ 470
1071
+ ],
1072
+ "top_right": [
1073
+ 190,
1074
+ 470
1075
+ ],
1076
+ "bottom_left": [
1077
+ 182,
1078
+ 532
1079
+ ],
1080
+ "bottom_right": [
1081
+ 190,
1082
+ 532
1083
+ ]
1084
+ },
1085
+ {
1086
+ "building_name": "PolygonBuilding_4_part10",
1087
+ "top_left": [
1088
+ 190,
1089
+ 474
1090
+ ],
1091
+ "top_right": [
1092
+ 198,
1093
+ 474
1094
+ ],
1095
+ "bottom_left": [
1096
+ 190,
1097
+ 521
1098
+ ],
1099
+ "bottom_right": [
1100
+ 198,
1101
+ 521
1102
+ ]
1103
+ },
1104
+ {
1105
+ "building_name": "PolygonBuilding_4_part11",
1106
+ "top_left": [
1107
+ 198,
1108
+ 478
1109
+ ],
1110
+ "top_right": [
1111
+ 206,
1112
+ 478
1113
+ ],
1114
+ "bottom_left": [
1115
+ 198,
1116
+ 515
1117
+ ],
1118
+ "bottom_right": [
1119
+ 206,
1120
+ 515
1121
+ ]
1122
+ },
1123
+ {
1124
+ "building_name": "PolygonBuilding_4_part12",
1125
+ "top_left": [
1126
+ 206,
1127
+ 482
1128
+ ],
1129
+ "top_right": [
1130
+ 214,
1131
+ 482
1132
+ ],
1133
+ "bottom_left": [
1134
+ 206,
1135
+ 503
1136
+ ],
1137
+ "bottom_right": [
1138
+ 214,
1139
+ 503
1140
+ ]
1141
+ },
1142
+ {
1143
+ "building_name": "PolygonBuilding_4_part13",
1144
+ "top_left": [
1145
+ 214,
1146
+ 486
1147
+ ],
1148
+ "top_right": [
1149
+ 218,
1150
+ 486
1151
+ ],
1152
+ "bottom_left": [
1153
+ 214,
1154
+ 489
1155
+ ],
1156
+ "bottom_right": [
1157
+ 218,
1158
+ 489
1159
+ ]
1160
+ },
1161
+ {
1162
+ "building_name": "PolygonBuilding_5_part1",
1163
+ "top_left": [
1164
+ 378,
1165
+ 496
1166
+ ],
1167
+ "top_right": [
1168
+ 386,
1169
+ 496
1170
+ ],
1171
+ "bottom_left": [
1172
+ 378,
1173
+ 580
1174
+ ],
1175
+ "bottom_right": [
1176
+ 386,
1177
+ 580
1178
+ ]
1179
+ },
1180
+ {
1181
+ "building_name": "PolygonBuilding_5_part2",
1182
+ "top_left": [
1183
+ 386,
1184
+ 496
1185
+ ],
1186
+ "top_right": [
1187
+ 394,
1188
+ 496
1189
+ ],
1190
+ "bottom_left": [
1191
+ 386,
1192
+ 579
1193
+ ],
1194
+ "bottom_right": [
1195
+ 394,
1196
+ 579
1197
+ ]
1198
+ },
1199
+ {
1200
+ "building_name": "PolygonBuilding_5_part3",
1201
+ "top_left": [
1202
+ 394,
1203
+ 496
1204
+ ],
1205
+ "top_right": [
1206
+ 402,
1207
+ 496
1208
+ ],
1209
+ "bottom_left": [
1210
+ 394,
1211
+ 579
1212
+ ],
1213
+ "bottom_right": [
1214
+ 402,
1215
+ 579
1216
+ ]
1217
+ },
1218
+ {
1219
+ "building_name": "PolygonBuilding_5_part4",
1220
+ "top_left": [
1221
+ 402,
1222
+ 496
1223
+ ],
1224
+ "top_right": [
1225
+ 410,
1226
+ 496
1227
+ ],
1228
+ "bottom_left": [
1229
+ 402,
1230
+ 579
1231
+ ],
1232
+ "bottom_right": [
1233
+ 410,
1234
+ 579
1235
+ ]
1236
+ },
1237
+ {
1238
+ "building_name": "PolygonBuilding_5_part5",
1239
+ "top_left": [
1240
+ 410,
1241
+ 496
1242
+ ],
1243
+ "top_right": [
1244
+ 418,
1245
+ 496
1246
+ ],
1247
+ "bottom_left": [
1248
+ 410,
1249
+ 579
1250
+ ],
1251
+ "bottom_right": [
1252
+ 418,
1253
+ 579
1254
+ ]
1255
+ },
1256
+ {
1257
+ "building_name": "PolygonBuilding_5_part6",
1258
+ "top_left": [
1259
+ 418,
1260
+ 496
1261
+ ],
1262
+ "top_right": [
1263
+ 426,
1264
+ 496
1265
+ ],
1266
+ "bottom_left": [
1267
+ 418,
1268
+ 578
1269
+ ],
1270
+ "bottom_right": [
1271
+ 426,
1272
+ 578
1273
+ ]
1274
+ },
1275
+ {
1276
+ "building_name": "PolygonBuilding_5_part7",
1277
+ "top_left": [
1278
+ 426,
1279
+ 496
1280
+ ],
1281
+ "top_right": [
1282
+ 434,
1283
+ 496
1284
+ ],
1285
+ "bottom_left": [
1286
+ 426,
1287
+ 578
1288
+ ],
1289
+ "bottom_right": [
1290
+ 434,
1291
+ 578
1292
+ ]
1293
+ },
1294
+ {
1295
+ "building_name": "PolygonBuilding_5_part8",
1296
+ "top_left": [
1297
+ 434,
1298
+ 496
1299
+ ],
1300
+ "top_right": [
1301
+ 442,
1302
+ 496
1303
+ ],
1304
+ "bottom_left": [
1305
+ 434,
1306
+ 578
1307
+ ],
1308
+ "bottom_right": [
1309
+ 442,
1310
+ 578
1311
+ ]
1312
+ },
1313
+ {
1314
+ "building_name": "PolygonBuilding_5_part9",
1315
+ "top_left": [
1316
+ 442,
1317
+ 496
1318
+ ],
1319
+ "top_right": [
1320
+ 450,
1321
+ 496
1322
+ ],
1323
+ "bottom_left": [
1324
+ 442,
1325
+ 578
1326
+ ],
1327
+ "bottom_right": [
1328
+ 450,
1329
+ 578
1330
+ ]
1331
+ },
1332
+ {
1333
+ "building_name": "PolygonBuilding_5_part10",
1334
+ "top_left": [
1335
+ 450,
1336
+ 496
1337
+ ],
1338
+ "top_right": [
1339
+ 458,
1340
+ 496
1341
+ ],
1342
+ "bottom_left": [
1343
+ 450,
1344
+ 577
1345
+ ],
1346
+ "bottom_right": [
1347
+ 458,
1348
+ 577
1349
+ ]
1350
+ },
1351
+ {
1352
+ "building_name": "PolygonBuilding_5_part11",
1353
+ "top_left": [
1354
+ 458,
1355
+ 496
1356
+ ],
1357
+ "top_right": [
1358
+ 466,
1359
+ 496
1360
+ ],
1361
+ "bottom_left": [
1362
+ 458,
1363
+ 577
1364
+ ],
1365
+ "bottom_right": [
1366
+ 466,
1367
+ 577
1368
+ ]
1369
+ },
1370
+ {
1371
+ "building_name": "PolygonBuilding_5_part12",
1372
+ "top_left": [
1373
+ 466,
1374
+ 496
1375
+ ],
1376
+ "top_right": [
1377
+ 474,
1378
+ 496
1379
+ ],
1380
+ "bottom_left": [
1381
+ 466,
1382
+ 577
1383
+ ],
1384
+ "bottom_right": [
1385
+ 474,
1386
+ 577
1387
+ ]
1388
+ },
1389
+ {
1390
+ "building_name": "PolygonBuilding_5_part13",
1391
+ "top_left": [
1392
+ 474,
1393
+ 496
1394
+ ],
1395
+ "top_right": [
1396
+ 478,
1397
+ 496
1398
+ ],
1399
+ "bottom_left": [
1400
+ 474,
1401
+ 577
1402
+ ],
1403
+ "bottom_right": [
1404
+ 478,
1405
+ 577
1406
+ ]
1407
+ },
1408
+ {
1409
+ "building_name": "PolygonBuilding_6_part1",
1410
+ "top_left": [
1411
+ 30,
1412
+ 362
1413
+ ],
1414
+ "top_right": [
1415
+ 38,
1416
+ 362
1417
+ ],
1418
+ "bottom_left": [
1419
+ 30,
1420
+ 407
1421
+ ],
1422
+ "bottom_right": [
1423
+ 38,
1424
+ 407
1425
+ ]
1426
+ },
1427
+ {
1428
+ "building_name": "PolygonBuilding_6_part2",
1429
+ "top_left": [
1430
+ 38,
1431
+ 318
1432
+ ],
1433
+ "top_right": [
1434
+ 46,
1435
+ 318
1436
+ ],
1437
+ "bottom_left": [
1438
+ 38,
1439
+ 408
1440
+ ],
1441
+ "bottom_right": [
1442
+ 46,
1443
+ 408
1444
+ ]
1445
+ },
1446
+ {
1447
+ "building_name": "PolygonBuilding_6_part3",
1448
+ "top_left": [
1449
+ 46,
1450
+ 303
1451
+ ],
1452
+ "top_right": [
1453
+ 54,
1454
+ 303
1455
+ ],
1456
+ "bottom_left": [
1457
+ 46,
1458
+ 407
1459
+ ],
1460
+ "bottom_right": [
1461
+ 54,
1462
+ 407
1463
+ ]
1464
+ },
1465
+ {
1466
+ "building_name": "PolygonBuilding_6_part4",
1467
+ "top_left": [
1468
+ 54,
1469
+ 302
1470
+ ],
1471
+ "top_right": [
1472
+ 62,
1473
+ 302
1474
+ ],
1475
+ "bottom_left": [
1476
+ 54,
1477
+ 407
1478
+ ],
1479
+ "bottom_right": [
1480
+ 62,
1481
+ 407
1482
+ ]
1483
+ },
1484
+ {
1485
+ "building_name": "PolygonBuilding_6_part5",
1486
+ "top_left": [
1487
+ 62,
1488
+ 301
1489
+ ],
1490
+ "top_right": [
1491
+ 70,
1492
+ 301
1493
+ ],
1494
+ "bottom_left": [
1495
+ 62,
1496
+ 407
1497
+ ],
1498
+ "bottom_right": [
1499
+ 70,
1500
+ 407
1501
+ ]
1502
+ },
1503
+ {
1504
+ "building_name": "PolygonBuilding_6_part6",
1505
+ "top_left": [
1506
+ 70,
1507
+ 301
1508
+ ],
1509
+ "top_right": [
1510
+ 78,
1511
+ 301
1512
+ ],
1513
+ "bottom_left": [
1514
+ 70,
1515
+ 407
1516
+ ],
1517
+ "bottom_right": [
1518
+ 78,
1519
+ 407
1520
+ ]
1521
+ },
1522
+ {
1523
+ "building_name": "PolygonBuilding_6_part7",
1524
+ "top_left": [
1525
+ 78,
1526
+ 300
1527
+ ],
1528
+ "top_right": [
1529
+ 86,
1530
+ 300
1531
+ ],
1532
+ "bottom_left": [
1533
+ 78,
1534
+ 407
1535
+ ],
1536
+ "bottom_right": [
1537
+ 86,
1538
+ 407
1539
+ ]
1540
+ },
1541
+ {
1542
+ "building_name": "PolygonBuilding_6_part8",
1543
+ "top_left": [
1544
+ 86,
1545
+ 301
1546
+ ],
1547
+ "top_right": [
1548
+ 94,
1549
+ 301
1550
+ ],
1551
+ "bottom_left": [
1552
+ 86,
1553
+ 407
1554
+ ],
1555
+ "bottom_right": [
1556
+ 94,
1557
+ 407
1558
+ ]
1559
+ },
1560
+ {
1561
+ "building_name": "PolygonBuilding_6_part9",
1562
+ "top_left": [
1563
+ 94,
1564
+ 302
1565
+ ],
1566
+ "top_right": [
1567
+ 102,
1568
+ 302
1569
+ ],
1570
+ "bottom_left": [
1571
+ 94,
1572
+ 407
1573
+ ],
1574
+ "bottom_right": [
1575
+ 102,
1576
+ 407
1577
+ ]
1578
+ },
1579
+ {
1580
+ "building_name": "PolygonBuilding_6_part10",
1581
+ "top_left": [
1582
+ 102,
1583
+ 303
1584
+ ],
1585
+ "top_right": [
1586
+ 110,
1587
+ 303
1588
+ ],
1589
+ "bottom_left": [
1590
+ 102,
1591
+ 407
1592
+ ],
1593
+ "bottom_right": [
1594
+ 110,
1595
+ 407
1596
+ ]
1597
+ },
1598
+ {
1599
+ "building_name": "PolygonBuilding_6_part11",
1600
+ "top_left": [
1601
+ 110,
1602
+ 304
1603
+ ],
1604
+ "top_right": [
1605
+ 118,
1606
+ 304
1607
+ ],
1608
+ "bottom_left": [
1609
+ 110,
1610
+ 407
1611
+ ],
1612
+ "bottom_right": [
1613
+ 118,
1614
+ 407
1615
+ ]
1616
+ },
1617
+ {
1618
+ "building_name": "PolygonBuilding_6_part12",
1619
+ "top_left": [
1620
+ 118,
1621
+ 304
1622
+ ],
1623
+ "top_right": [
1624
+ 126,
1625
+ 304
1626
+ ],
1627
+ "bottom_left": [
1628
+ 118,
1629
+ 407
1630
+ ],
1631
+ "bottom_right": [
1632
+ 126,
1633
+ 407
1634
+ ]
1635
+ },
1636
+ {
1637
+ "building_name": "PolygonBuilding_7_part1",
1638
+ "top_left": [
1639
+ 528,
1640
+ 541
1641
+ ],
1642
+ "top_right": [
1643
+ 536,
1644
+ 541
1645
+ ],
1646
+ "bottom_left": [
1647
+ 528,
1648
+ 574
1649
+ ],
1650
+ "bottom_right": [
1651
+ 536,
1652
+ 574
1653
+ ]
1654
+ },
1655
+ {
1656
+ "building_name": "PolygonBuilding_7_part2",
1657
+ "top_left": [
1658
+ 536,
1659
+ 542
1660
+ ],
1661
+ "top_right": [
1662
+ 544,
1663
+ 542
1664
+ ],
1665
+ "bottom_left": [
1666
+ 536,
1667
+ 574
1668
+ ],
1669
+ "bottom_right": [
1670
+ 544,
1671
+ 574
1672
+ ]
1673
+ },
1674
+ {
1675
+ "building_name": "PolygonBuilding_7_part3",
1676
+ "top_left": [
1677
+ 544,
1678
+ 539
1679
+ ],
1680
+ "top_right": [
1681
+ 552,
1682
+ 539
1683
+ ],
1684
+ "bottom_left": [
1685
+ 544,
1686
+ 574
1687
+ ],
1688
+ "bottom_right": [
1689
+ 552,
1690
+ 574
1691
+ ]
1692
+ },
1693
+ {
1694
+ "building_name": "PolygonBuilding_7_part4",
1695
+ "top_left": [
1696
+ 552,
1697
+ 539
1698
+ ],
1699
+ "top_right": [
1700
+ 560,
1701
+ 539
1702
+ ],
1703
+ "bottom_left": [
1704
+ 552,
1705
+ 574
1706
+ ],
1707
+ "bottom_right": [
1708
+ 560,
1709
+ 574
1710
+ ]
1711
+ },
1712
+ {
1713
+ "building_name": "PolygonBuilding_7_part5",
1714
+ "top_left": [
1715
+ 560,
1716
+ 540
1717
+ ],
1718
+ "top_right": [
1719
+ 568,
1720
+ 540
1721
+ ],
1722
+ "bottom_left": [
1723
+ 560,
1724
+ 574
1725
+ ],
1726
+ "bottom_right": [
1727
+ 568,
1728
+ 574
1729
+ ]
1730
+ },
1731
+ {
1732
+ "building_name": "PolygonBuilding_7_part6",
1733
+ "top_left": [
1734
+ 568,
1735
+ 544
1736
+ ],
1737
+ "top_right": [
1738
+ 576,
1739
+ 544
1740
+ ],
1741
+ "bottom_left": [
1742
+ 568,
1743
+ 574
1744
+ ],
1745
+ "bottom_right": [
1746
+ 576,
1747
+ 574
1748
+ ]
1749
+ },
1750
+ {
1751
+ "building_name": "PolygonBuilding_7_part7",
1752
+ "top_left": [
1753
+ 576,
1754
+ 520
1755
+ ],
1756
+ "top_right": [
1757
+ 584,
1758
+ 520
1759
+ ],
1760
+ "bottom_left": [
1761
+ 576,
1762
+ 574
1763
+ ],
1764
+ "bottom_right": [
1765
+ 584,
1766
+ 574
1767
+ ]
1768
+ },
1769
+ {
1770
+ "building_name": "PolygonBuilding_7_part8",
1771
+ "top_left": [
1772
+ 584,
1773
+ 519
1774
+ ],
1775
+ "top_right": [
1776
+ 592,
1777
+ 519
1778
+ ],
1779
+ "bottom_left": [
1780
+ 584,
1781
+ 574
1782
+ ],
1783
+ "bottom_right": [
1784
+ 592,
1785
+ 574
1786
+ ]
1787
+ },
1788
+ {
1789
+ "building_name": "PolygonBuilding_7_part9",
1790
+ "top_left": [
1791
+ 592,
1792
+ 519
1793
+ ],
1794
+ "top_right": [
1795
+ 600,
1796
+ 519
1797
+ ],
1798
+ "bottom_left": [
1799
+ 592,
1800
+ 574
1801
+ ],
1802
+ "bottom_right": [
1803
+ 600,
1804
+ 574
1805
+ ]
1806
+ },
1807
+ {
1808
+ "building_name": "PolygonBuilding_7_part10",
1809
+ "top_left": [
1810
+ 600,
1811
+ 518
1812
+ ],
1813
+ "top_right": [
1814
+ 608,
1815
+ 518
1816
+ ],
1817
+ "bottom_left": [
1818
+ 600,
1819
+ 574
1820
+ ],
1821
+ "bottom_right": [
1822
+ 608,
1823
+ 574
1824
+ ]
1825
+ },
1826
+ {
1827
+ "building_name": "PolygonBuilding_7_part11",
1828
+ "top_left": [
1829
+ 608,
1830
+ 517
1831
+ ],
1832
+ "top_right": [
1833
+ 616,
1834
+ 517
1835
+ ],
1836
+ "bottom_left": [
1837
+ 608,
1838
+ 575
1839
+ ],
1840
+ "bottom_right": [
1841
+ 616,
1842
+ 575
1843
+ ]
1844
+ },
1845
+ {
1846
+ "building_name": "PolygonBuilding_7_part12",
1847
+ "top_left": [
1848
+ 616,
1849
+ 521
1850
+ ],
1851
+ "top_right": [
1852
+ 619,
1853
+ 521
1854
+ ],
1855
+ "bottom_left": [
1856
+ 616,
1857
+ 548
1858
+ ],
1859
+ "bottom_right": [
1860
+ 619,
1861
+ 548
1862
+ ]
1863
+ },
1864
+ {
1865
+ "building_name": "PolygonBuilding_8_part1",
1866
+ "top_left": [
1867
+ 679,
1868
+ 415
1869
+ ],
1870
+ "top_right": [
1871
+ 687,
1872
+ 415
1873
+ ],
1874
+ "bottom_left": [
1875
+ 679,
1876
+ 534
1877
+ ],
1878
+ "bottom_right": [
1879
+ 687,
1880
+ 534
1881
+ ]
1882
+ },
1883
+ {
1884
+ "building_name": "PolygonBuilding_8_part2",
1885
+ "top_left": [
1886
+ 687,
1887
+ 414
1888
+ ],
1889
+ "top_right": [
1890
+ 695,
1891
+ 414
1892
+ ],
1893
+ "bottom_left": [
1894
+ 687,
1895
+ 534
1896
+ ],
1897
+ "bottom_right": [
1898
+ 695,
1899
+ 534
1900
+ ]
1901
+ },
1902
+ {
1903
+ "building_name": "PolygonBuilding_8_part3",
1904
+ "top_left": [
1905
+ 695,
1906
+ 413
1907
+ ],
1908
+ "top_right": [
1909
+ 703,
1910
+ 413
1911
+ ],
1912
+ "bottom_left": [
1913
+ 695,
1914
+ 534
1915
+ ],
1916
+ "bottom_right": [
1917
+ 703,
1918
+ 534
1919
+ ]
1920
+ },
1921
+ {
1922
+ "building_name": "PolygonBuilding_8_part4",
1923
+ "top_left": [
1924
+ 703,
1925
+ 412
1926
+ ],
1927
+ "top_right": [
1928
+ 711,
1929
+ 412
1930
+ ],
1931
+ "bottom_left": [
1932
+ 703,
1933
+ 534
1934
+ ],
1935
+ "bottom_right": [
1936
+ 711,
1937
+ 534
1938
+ ]
1939
+ },
1940
+ {
1941
+ "building_name": "PolygonBuilding_8_part5",
1942
+ "top_left": [
1943
+ 711,
1944
+ 411
1945
+ ],
1946
+ "top_right": [
1947
+ 719,
1948
+ 411
1949
+ ],
1950
+ "bottom_left": [
1951
+ 711,
1952
+ 534
1953
+ ],
1954
+ "bottom_right": [
1955
+ 719,
1956
+ 534
1957
+ ]
1958
+ },
1959
+ {
1960
+ "building_name": "PolygonBuilding_8_part6",
1961
+ "top_left": [
1962
+ 719,
1963
+ 415
1964
+ ],
1965
+ "top_right": [
1966
+ 727,
1967
+ 415
1968
+ ],
1969
+ "bottom_left": [
1970
+ 719,
1971
+ 534
1972
+ ],
1973
+ "bottom_right": [
1974
+ 727,
1975
+ 534
1976
+ ]
1977
+ },
1978
+ {
1979
+ "building_name": "PolygonBuilding_8_part7",
1980
+ "top_left": [
1981
+ 727,
1982
+ 414
1983
+ ],
1984
+ "top_right": [
1985
+ 735,
1986
+ 414
1987
+ ],
1988
+ "bottom_left": [
1989
+ 727,
1990
+ 534
1991
+ ],
1992
+ "bottom_right": [
1993
+ 735,
1994
+ 534
1995
+ ]
1996
+ },
1997
+ {
1998
+ "building_name": "PolygonBuilding_8_part8",
1999
+ "top_left": [
2000
+ 735,
2001
+ 414
2002
+ ],
2003
+ "top_right": [
2004
+ 743,
2005
+ 414
2006
+ ],
2007
+ "bottom_left": [
2008
+ 735,
2009
+ 534
2010
+ ],
2011
+ "bottom_right": [
2012
+ 743,
2013
+ 534
2014
+ ]
2015
+ },
2016
+ {
2017
+ "building_name": "PolygonBuilding_8_part9",
2018
+ "top_left": [
2019
+ 743,
2020
+ 413
2021
+ ],
2022
+ "top_right": [
2023
+ 750,
2024
+ 413
2025
+ ],
2026
+ "bottom_left": [
2027
+ 743,
2028
+ 491
2029
+ ],
2030
+ "bottom_right": [
2031
+ 750,
2032
+ 491
2033
+ ]
2034
+ },
2035
+ {
2036
+ "building_name": "PolygonBuilding_8_part10",
2037
+ "top_left": [
2038
+ 743,
2039
+ 531
2040
+ ],
2041
+ "top_right": [
2042
+ 750,
2043
+ 531
2044
+ ],
2045
+ "bottom_left": [
2046
+ 743,
2047
+ 532
2048
+ ],
2049
+ "bottom_right": [
2050
+ 750,
2051
+ 532
2052
+ ]
2053
+ },
2054
+ {
2055
+ "building_name": "PolygonBuilding_9_part1",
2056
+ "top_left": [
2057
+ 527,
2058
+ 594
2059
+ ],
2060
+ "top_right": [
2061
+ 535,
2062
+ 594
2063
+ ],
2064
+ "bottom_left": [
2065
+ 527,
2066
+ 654
2067
+ ],
2068
+ "bottom_right": [
2069
+ 535,
2070
+ 654
2071
+ ]
2072
+ },
2073
+ {
2074
+ "building_name": "PolygonBuilding_9_part2",
2075
+ "top_left": [
2076
+ 535,
2077
+ 594
2078
+ ],
2079
+ "top_right": [
2080
+ 543,
2081
+ 594
2082
+ ],
2083
+ "bottom_left": [
2084
+ 535,
2085
+ 654
2086
+ ],
2087
+ "bottom_right": [
2088
+ 543,
2089
+ 654
2090
+ ]
2091
+ },
2092
+ {
2093
+ "building_name": "PolygonBuilding_9_part3",
2094
+ "top_left": [
2095
+ 543,
2096
+ 594
2097
+ ],
2098
+ "top_right": [
2099
+ 551,
2100
+ 594
2101
+ ],
2102
+ "bottom_left": [
2103
+ 543,
2104
+ 654
2105
+ ],
2106
+ "bottom_right": [
2107
+ 551,
2108
+ 654
2109
+ ]
2110
+ },
2111
+ {
2112
+ "building_name": "PolygonBuilding_9_part4",
2113
+ "top_left": [
2114
+ 551,
2115
+ 594
2116
+ ],
2117
+ "top_right": [
2118
+ 559,
2119
+ 594
2120
+ ],
2121
+ "bottom_left": [
2122
+ 551,
2123
+ 654
2124
+ ],
2125
+ "bottom_right": [
2126
+ 559,
2127
+ 654
2128
+ ]
2129
+ },
2130
+ {
2131
+ "building_name": "PolygonBuilding_9_part5",
2132
+ "top_left": [
2133
+ 559,
2134
+ 594
2135
+ ],
2136
+ "top_right": [
2137
+ 567,
2138
+ 594
2139
+ ],
2140
+ "bottom_left": [
2141
+ 559,
2142
+ 654
2143
+ ],
2144
+ "bottom_right": [
2145
+ 567,
2146
+ 654
2147
+ ]
2148
+ },
2149
+ {
2150
+ "building_name": "PolygonBuilding_9_part6",
2151
+ "top_left": [
2152
+ 567,
2153
+ 594
2154
+ ],
2155
+ "top_right": [
2156
+ 575,
2157
+ 594
2158
+ ],
2159
+ "bottom_left": [
2160
+ 567,
2161
+ 654
2162
+ ],
2163
+ "bottom_right": [
2164
+ 575,
2165
+ 654
2166
+ ]
2167
+ },
2168
+ {
2169
+ "building_name": "PolygonBuilding_9_part7",
2170
+ "top_left": [
2171
+ 575,
2172
+ 594
2173
+ ],
2174
+ "top_right": [
2175
+ 583,
2176
+ 594
2177
+ ],
2178
+ "bottom_left": [
2179
+ 575,
2180
+ 655
2181
+ ],
2182
+ "bottom_right": [
2183
+ 583,
2184
+ 655
2185
+ ]
2186
+ },
2187
+ {
2188
+ "building_name": "PolygonBuilding_9_part8",
2189
+ "top_left": [
2190
+ 583,
2191
+ 594
2192
+ ],
2193
+ "top_right": [
2194
+ 591,
2195
+ 594
2196
+ ],
2197
+ "bottom_left": [
2198
+ 583,
2199
+ 655
2200
+ ],
2201
+ "bottom_right": [
2202
+ 591,
2203
+ 655
2204
+ ]
2205
+ },
2206
+ {
2207
+ "building_name": "PolygonBuilding_9_part9",
2208
+ "top_left": [
2209
+ 591,
2210
+ 594
2211
+ ],
2212
+ "top_right": [
2213
+ 599,
2214
+ 594
2215
+ ],
2216
+ "bottom_left": [
2217
+ 591,
2218
+ 655
2219
+ ],
2220
+ "bottom_right": [
2221
+ 599,
2222
+ 655
2223
+ ]
2224
+ },
2225
+ {
2226
+ "building_name": "PolygonBuilding_9_part10",
2227
+ "top_left": [
2228
+ 599,
2229
+ 594
2230
+ ],
2231
+ "top_right": [
2232
+ 607,
2233
+ 594
2234
+ ],
2235
+ "bottom_left": [
2236
+ 599,
2237
+ 655
2238
+ ],
2239
+ "bottom_right": [
2240
+ 607,
2241
+ 655
2242
+ ]
2243
+ },
2244
+ {
2245
+ "building_name": "PolygonBuilding_9_part11",
2246
+ "top_left": [
2247
+ 607,
2248
+ 594
2249
+ ],
2250
+ "top_right": [
2251
+ 615,
2252
+ 594
2253
+ ],
2254
+ "bottom_left": [
2255
+ 607,
2256
+ 655
2257
+ ],
2258
+ "bottom_right": [
2259
+ 615,
2260
+ 655
2261
+ ]
2262
+ },
2263
+ {
2264
+ "building_name": "PolygonBuilding_9_part12",
2265
+ "top_left": [
2266
+ 615,
2267
+ 594
2268
+ ],
2269
+ "top_right": [
2270
+ 623,
2271
+ 594
2272
+ ],
2273
+ "bottom_left": [
2274
+ 615,
2275
+ 656
2276
+ ],
2277
+ "bottom_right": [
2278
+ 623,
2279
+ 656
2280
+ ]
2281
+ },
2282
+ {
2283
+ "building_name": "PolygonBuilding_10_part1",
2284
+ "top_left": [
2285
+ 590,
2286
+ 428
2287
+ ],
2288
+ "top_right": [
2289
+ 598,
2290
+ 428
2291
+ ],
2292
+ "bottom_left": [
2293
+ 590,
2294
+ 464
2295
+ ],
2296
+ "bottom_right": [
2297
+ 598,
2298
+ 464
2299
+ ]
2300
+ },
2301
+ {
2302
+ "building_name": "PolygonBuilding_10_part2",
2303
+ "top_left": [
2304
+ 598,
2305
+ 421
2306
+ ],
2307
+ "top_right": [
2308
+ 606,
2309
+ 421
2310
+ ],
2311
+ "bottom_left": [
2312
+ 598,
2313
+ 472
2314
+ ],
2315
+ "bottom_right": [
2316
+ 606,
2317
+ 472
2318
+ ]
2319
+ },
2320
+ {
2321
+ "building_name": "PolygonBuilding_10_part3",
2322
+ "top_left": [
2323
+ 606,
2324
+ 416
2325
+ ],
2326
+ "top_right": [
2327
+ 614,
2328
+ 416
2329
+ ],
2330
+ "bottom_left": [
2331
+ 606,
2332
+ 481
2333
+ ],
2334
+ "bottom_right": [
2335
+ 614,
2336
+ 481
2337
+ ]
2338
+ },
2339
+ {
2340
+ "building_name": "PolygonBuilding_10_part4",
2341
+ "top_left": [
2342
+ 614,
2343
+ 411
2344
+ ],
2345
+ "top_right": [
2346
+ 622,
2347
+ 411
2348
+ ],
2349
+ "bottom_left": [
2350
+ 614,
2351
+ 482
2352
+ ],
2353
+ "bottom_right": [
2354
+ 622,
2355
+ 482
2356
+ ]
2357
+ },
2358
+ {
2359
+ "building_name": "PolygonBuilding_10_part5",
2360
+ "top_left": [
2361
+ 622,
2362
+ 414
2363
+ ],
2364
+ "top_right": [
2365
+ 630,
2366
+ 414
2367
+ ],
2368
+ "bottom_left": [
2369
+ 622,
2370
+ 482
2371
+ ],
2372
+ "bottom_right": [
2373
+ 630,
2374
+ 482
2375
+ ]
2376
+ },
2377
+ {
2378
+ "building_name": "PolygonBuilding_10_part6",
2379
+ "top_left": [
2380
+ 630,
2381
+ 422
2382
+ ],
2383
+ "top_right": [
2384
+ 638,
2385
+ 422
2386
+ ],
2387
+ "bottom_left": [
2388
+ 630,
2389
+ 483
2390
+ ],
2391
+ "bottom_right": [
2392
+ 638,
2393
+ 483
2394
+ ]
2395
+ },
2396
+ {
2397
+ "building_name": "PolygonBuilding_10_part7",
2398
+ "top_left": [
2399
+ 638,
2400
+ 439
2401
+ ],
2402
+ "top_right": [
2403
+ 646,
2404
+ 439
2405
+ ],
2406
+ "bottom_left": [
2407
+ 638,
2408
+ 476
2409
+ ],
2410
+ "bottom_right": [
2411
+ 646,
2412
+ 476
2413
+ ]
2414
+ },
2415
+ {
2416
+ "building_name": "PolygonBuilding_10_part8",
2417
+ "top_left": [
2418
+ 646,
2419
+ 448
2420
+ ],
2421
+ "top_right": [
2422
+ 654,
2423
+ 448
2424
+ ],
2425
+ "bottom_left": [
2426
+ 646,
2427
+ 470
2428
+ ],
2429
+ "bottom_right": [
2430
+ 654,
2431
+ 470
2432
+ ]
2433
+ },
2434
+ {
2435
+ "building_name": "PolygonBuilding_10_part9",
2436
+ "top_left": [
2437
+ 654,
2438
+ 456
2439
+ ],
2440
+ "top_right": [
2441
+ 657,
2442
+ 456
2443
+ ],
2444
+ "bottom_left": [
2445
+ 654,
2446
+ 458
2447
+ ],
2448
+ "bottom_right": [
2449
+ 657,
2450
+ 458
2451
+ ]
2452
+ },
2453
+ {
2454
+ "building_name": "PolygonBuilding_11_part1",
2455
+ "top_left": [
2456
+ 475,
2457
+ 317
2458
+ ],
2459
+ "top_right": [
2460
+ 483,
2461
+ 317
2462
+ ],
2463
+ "bottom_left": [
2464
+ 475,
2465
+ 375
2466
+ ],
2467
+ "bottom_right": [
2468
+ 483,
2469
+ 375
2470
+ ]
2471
+ },
2472
+ {
2473
+ "building_name": "PolygonBuilding_11_part2",
2474
+ "top_left": [
2475
+ 483,
2476
+ 319
2477
+ ],
2478
+ "top_right": [
2479
+ 491,
2480
+ 319
2481
+ ],
2482
+ "bottom_left": [
2483
+ 483,
2484
+ 376
2485
+ ],
2486
+ "bottom_right": [
2487
+ 491,
2488
+ 376
2489
+ ]
2490
+ },
2491
+ {
2492
+ "building_name": "PolygonBuilding_11_part3",
2493
+ "top_left": [
2494
+ 491,
2495
+ 321
2496
+ ],
2497
+ "top_right": [
2498
+ 499,
2499
+ 321
2500
+ ],
2501
+ "bottom_left": [
2502
+ 491,
2503
+ 377
2504
+ ],
2505
+ "bottom_right": [
2506
+ 499,
2507
+ 377
2508
+ ]
2509
+ },
2510
+ {
2511
+ "building_name": "PolygonBuilding_11_part4",
2512
+ "top_left": [
2513
+ 499,
2514
+ 321
2515
+ ],
2516
+ "top_right": [
2517
+ 507,
2518
+ 321
2519
+ ],
2520
+ "bottom_left": [
2521
+ 499,
2522
+ 378
2523
+ ],
2524
+ "bottom_right": [
2525
+ 507,
2526
+ 378
2527
+ ]
2528
+ },
2529
+ {
2530
+ "building_name": "PolygonBuilding_11_part5",
2531
+ "top_left": [
2532
+ 507,
2533
+ 321
2534
+ ],
2535
+ "top_right": [
2536
+ 515,
2537
+ 321
2538
+ ],
2539
+ "bottom_left": [
2540
+ 507,
2541
+ 377
2542
+ ],
2543
+ "bottom_right": [
2544
+ 515,
2545
+ 377
2546
+ ]
2547
+ },
2548
+ {
2549
+ "building_name": "PolygonBuilding_11_part6",
2550
+ "top_left": [
2551
+ 515,
2552
+ 321
2553
+ ],
2554
+ "top_right": [
2555
+ 523,
2556
+ 321
2557
+ ],
2558
+ "bottom_left": [
2559
+ 515,
2560
+ 377
2561
+ ],
2562
+ "bottom_right": [
2563
+ 523,
2564
+ 377
2565
+ ]
2566
+ },
2567
+ {
2568
+ "building_name": "PolygonBuilding_11_part7",
2569
+ "top_left": [
2570
+ 523,
2571
+ 316
2572
+ ],
2573
+ "top_right": [
2574
+ 531,
2575
+ 316
2576
+ ],
2577
+ "bottom_left": [
2578
+ 523,
2579
+ 377
2580
+ ],
2581
+ "bottom_right": [
2582
+ 531,
2583
+ 377
2584
+ ]
2585
+ },
2586
+ {
2587
+ "building_name": "PolygonBuilding_11_part8",
2588
+ "top_left": [
2589
+ 531,
2590
+ 316
2591
+ ],
2592
+ "top_right": [
2593
+ 539,
2594
+ 316
2595
+ ],
2596
+ "bottom_left": [
2597
+ 531,
2598
+ 377
2599
+ ],
2600
+ "bottom_right": [
2601
+ 539,
2602
+ 377
2603
+ ]
2604
+ },
2605
+ {
2606
+ "building_name": "PolygonBuilding_11_part9",
2607
+ "top_left": [
2608
+ 539,
2609
+ 316
2610
+ ],
2611
+ "top_right": [
2612
+ 547,
2613
+ 316
2614
+ ],
2615
+ "bottom_left": [
2616
+ 539,
2617
+ 377
2618
+ ],
2619
+ "bottom_right": [
2620
+ 547,
2621
+ 377
2622
+ ]
2623
+ },
2624
+ {
2625
+ "building_name": "PolygonBuilding_11_part10",
2626
+ "top_left": [
2627
+ 547,
2628
+ 316
2629
+ ],
2630
+ "top_right": [
2631
+ 555,
2632
+ 316
2633
+ ],
2634
+ "bottom_left": [
2635
+ 547,
2636
+ 377
2637
+ ],
2638
+ "bottom_right": [
2639
+ 555,
2640
+ 377
2641
+ ]
2642
+ },
2643
+ {
2644
+ "building_name": "PolygonBuilding_12_part1",
2645
+ "top_left": [
2646
+ 143,
2647
+ 336
2648
+ ],
2649
+ "top_right": [
2650
+ 151,
2651
+ 336
2652
+ ],
2653
+ "bottom_left": [
2654
+ 143,
2655
+ 405
2656
+ ],
2657
+ "bottom_right": [
2658
+ 151,
2659
+ 405
2660
+ ]
2661
+ },
2662
+ {
2663
+ "building_name": "PolygonBuilding_12_part2",
2664
+ "top_left": [
2665
+ 151,
2666
+ 314
2667
+ ],
2668
+ "top_right": [
2669
+ 159,
2670
+ 314
2671
+ ],
2672
+ "bottom_left": [
2673
+ 151,
2674
+ 404
2675
+ ],
2676
+ "bottom_right": [
2677
+ 159,
2678
+ 404
2679
+ ]
2680
+ },
2681
+ {
2682
+ "building_name": "PolygonBuilding_12_part3",
2683
+ "top_left": [
2684
+ 159,
2685
+ 313
2686
+ ],
2687
+ "top_right": [
2688
+ 167,
2689
+ 313
2690
+ ],
2691
+ "bottom_left": [
2692
+ 159,
2693
+ 404
2694
+ ],
2695
+ "bottom_right": [
2696
+ 167,
2697
+ 404
2698
+ ]
2699
+ },
2700
+ {
2701
+ "building_name": "PolygonBuilding_12_part4",
2702
+ "top_left": [
2703
+ 167,
2704
+ 311
2705
+ ],
2706
+ "top_right": [
2707
+ 175,
2708
+ 311
2709
+ ],
2710
+ "bottom_left": [
2711
+ 167,
2712
+ 404
2713
+ ],
2714
+ "bottom_right": [
2715
+ 175,
2716
+ 404
2717
+ ]
2718
+ },
2719
+ {
2720
+ "building_name": "PolygonBuilding_12_part5",
2721
+ "top_left": [
2722
+ 175,
2723
+ 310
2724
+ ],
2725
+ "top_right": [
2726
+ 183,
2727
+ 310
2728
+ ],
2729
+ "bottom_left": [
2730
+ 175,
2731
+ 404
2732
+ ],
2733
+ "bottom_right": [
2734
+ 183,
2735
+ 404
2736
+ ]
2737
+ },
2738
+ {
2739
+ "building_name": "PolygonBuilding_12_part6",
2740
+ "top_left": [
2741
+ 183,
2742
+ 309
2743
+ ],
2744
+ "top_right": [
2745
+ 191,
2746
+ 309
2747
+ ],
2748
+ "bottom_left": [
2749
+ 183,
2750
+ 404
2751
+ ],
2752
+ "bottom_right": [
2753
+ 191,
2754
+ 404
2755
+ ]
2756
+ },
2757
+ {
2758
+ "building_name": "PolygonBuilding_12_part7",
2759
+ "top_left": [
2760
+ 191,
2761
+ 309
2762
+ ],
2763
+ "top_right": [
2764
+ 199,
2765
+ 309
2766
+ ],
2767
+ "bottom_left": [
2768
+ 191,
2769
+ 404
2770
+ ],
2771
+ "bottom_right": [
2772
+ 199,
2773
+ 404
2774
+ ]
2775
+ },
2776
+ {
2777
+ "building_name": "PolygonBuilding_12_part8",
2778
+ "top_left": [
2779
+ 199,
2780
+ 309
2781
+ ],
2782
+ "top_right": [
2783
+ 207,
2784
+ 309
2785
+ ],
2786
+ "bottom_left": [
2787
+ 199,
2788
+ 404
2789
+ ],
2790
+ "bottom_right": [
2791
+ 207,
2792
+ 404
2793
+ ]
2794
+ },
2795
+ {
2796
+ "building_name": "PolygonBuilding_12_part9",
2797
+ "top_left": [
2798
+ 207,
2799
+ 309
2800
+ ],
2801
+ "top_right": [
2802
+ 215,
2803
+ 309
2804
+ ],
2805
+ "bottom_left": [
2806
+ 207,
2807
+ 404
2808
+ ],
2809
+ "bottom_right": [
2810
+ 215,
2811
+ 404
2812
+ ]
2813
+ },
2814
+ {
2815
+ "building_name": "PolygonBuilding_12_part10",
2816
+ "top_left": [
2817
+ 215,
2818
+ 308
2819
+ ],
2820
+ "top_right": [
2821
+ 223,
2822
+ 308
2823
+ ],
2824
+ "bottom_left": [
2825
+ 215,
2826
+ 404
2827
+ ],
2828
+ "bottom_right": [
2829
+ 223,
2830
+ 404
2831
+ ]
2832
+ },
2833
+ {
2834
+ "building_name": "PolygonBuilding_12_part11",
2835
+ "top_left": [
2836
+ 223,
2837
+ 310
2838
+ ],
2839
+ "top_right": [
2840
+ 231,
2841
+ 310
2842
+ ],
2843
+ "bottom_left": [
2844
+ 223,
2845
+ 404
2846
+ ],
2847
+ "bottom_right": [
2848
+ 231,
2849
+ 404
2850
+ ]
2851
+ },
2852
+ {
2853
+ "building_name": "PolygonBuilding_12_part12",
2854
+ "top_left": [
2855
+ 231,
2856
+ 315
2857
+ ],
2858
+ "top_right": [
2859
+ 235,
2860
+ 315
2861
+ ],
2862
+ "bottom_left": [
2863
+ 231,
2864
+ 325
2865
+ ],
2866
+ "bottom_right": [
2867
+ 235,
2868
+ 325
2869
+ ]
2870
+ },
2871
+ {
2872
+ "building_name": "PolygonBuilding_12_part13",
2873
+ "top_left": [
2874
+ 231,
2875
+ 349
2876
+ ],
2877
+ "top_right": [
2878
+ 235,
2879
+ 349
2880
+ ],
2881
+ "bottom_left": [
2882
+ 231,
2883
+ 404
2884
+ ],
2885
+ "bottom_right": [
2886
+ 235,
2887
+ 404
2888
+ ]
2889
+ },
2890
+ {
2891
+ "building_name": "Building_1",
2892
+ "top_left": [
2893
+ 572,
2894
+ 342
2895
+ ],
2896
+ "top_right": [
2897
+ 633,
2898
+ 342
2899
+ ],
2900
+ "bottom_left": [
2901
+ 572,
2902
+ 393
2903
+ ],
2904
+ "bottom_right": [
2905
+ 633,
2906
+ 393
2907
+ ]
2908
+ },
2909
+ {
2910
+ "building_name": "Building_2",
2911
+ "top_left": [
2912
+ 658,
2913
+ 342
2914
+ ],
2915
+ "top_right": [
2916
+ 799,
2917
+ 342
2918
+ ],
2919
+ "bottom_left": [
2920
+ 658,
2921
+ 372
2922
+ ],
2923
+ "bottom_right": [
2924
+ 799,
2925
+ 372
2926
+ ]
2927
+ },
2928
+ {
2929
+ "building_name": "Building_3",
2930
+ "top_left": [
2931
+ 819,
2932
+ 343
2933
+ ],
2934
+ "top_right": [
2935
+ 885,
2936
+ 343
2937
+ ],
2938
+ "bottom_left": [
2939
+ 819,
2940
+ 390
2941
+ ],
2942
+ "bottom_right": [
2943
+ 885,
2944
+ 390
2945
+ ]
2946
+ },
2947
+ {
2948
+ "building_name": "Building_4",
2949
+ "top_left": [
2950
+ 905,
2951
+ 309
2952
+ ],
2953
+ "top_right": [
2954
+ 959,
2955
+ 309
2956
+ ],
2957
+ "bottom_left": [
2958
+ 905,
2959
+ 368
2960
+ ],
2961
+ "bottom_right": [
2962
+ 959,
2963
+ 368
2964
+ ]
2965
+ },
2966
+ {
2967
+ "building_name": "Building_5",
2968
+ "top_left": [
2969
+ 961,
2970
+ 185
2971
+ ],
2972
+ "top_right": [
2973
+ 1013,
2974
+ 185
2975
+ ],
2976
+ "bottom_left": [
2977
+ 961,
2978
+ 242
2979
+ ],
2980
+ "bottom_right": [
2981
+ 1013,
2982
+ 242
2983
+ ]
2984
+ },
2985
+ {
2986
+ "building_name": "Building_6",
2987
+ "top_left": [
2988
+ 459,
2989
+ 45
2990
+ ],
2991
+ "top_right": [
2992
+ 626,
2993
+ 45
2994
+ ],
2995
+ "bottom_left": [
2996
+ 459,
2997
+ 246
2998
+ ],
2999
+ "bottom_right": [
3000
+ 626,
3001
+ 246
3002
+ ]
3003
+ },
3004
+ {
3005
+ "building_name": "PolygonBuilding_13_part1",
3006
+ "top_left": [
3007
+ 451,
3008
+ 437
3009
+ ],
3010
+ "top_right": [
3011
+ 459,
3012
+ 437
3013
+ ],
3014
+ "bottom_left": [
3015
+ 451,
3016
+ 489
3017
+ ],
3018
+ "bottom_right": [
3019
+ 459,
3020
+ 489
3021
+ ]
3022
+ },
3023
+ {
3024
+ "building_name": "PolygonBuilding_13_part2",
3025
+ "top_left": [
3026
+ 459,
3027
+ 437
3028
+ ],
3029
+ "top_right": [
3030
+ 467,
3031
+ 437
3032
+ ],
3033
+ "bottom_left": [
3034
+ 459,
3035
+ 489
3036
+ ],
3037
+ "bottom_right": [
3038
+ 467,
3039
+ 489
3040
+ ]
3041
+ },
3042
+ {
3043
+ "building_name": "PolygonBuilding_13_part3",
3044
+ "top_left": [
3045
+ 467,
3046
+ 437
3047
+ ],
3048
+ "top_right": [
3049
+ 475,
3050
+ 437
3051
+ ],
3052
+ "bottom_left": [
3053
+ 467,
3054
+ 489
3055
+ ],
3056
+ "bottom_right": [
3057
+ 475,
3058
+ 489
3059
+ ]
3060
+ },
3061
+ {
3062
+ "building_name": "PolygonBuilding_13_part4",
3063
+ "top_left": [
3064
+ 475,
3065
+ 437
3066
+ ],
3067
+ "top_right": [
3068
+ 483,
3069
+ 437
3070
+ ],
3071
+ "bottom_left": [
3072
+ 475,
3073
+ 489
3074
+ ],
3075
+ "bottom_right": [
3076
+ 483,
3077
+ 489
3078
+ ]
3079
+ },
3080
+ {
3081
+ "building_name": "PolygonBuilding_13_part5",
3082
+ "top_left": [
3083
+ 483,
3084
+ 437
3085
+ ],
3086
+ "top_right": [
3087
+ 491,
3088
+ 437
3089
+ ],
3090
+ "bottom_left": [
3091
+ 483,
3092
+ 580
3093
+ ],
3094
+ "bottom_right": [
3095
+ 491,
3096
+ 580
3097
+ ]
3098
+ },
3099
+ {
3100
+ "building_name": "PolygonBuilding_13_part6",
3101
+ "top_left": [
3102
+ 491,
3103
+ 437
3104
+ ],
3105
+ "top_right": [
3106
+ 499,
3107
+ 437
3108
+ ],
3109
+ "bottom_left": [
3110
+ 491,
3111
+ 579
3112
+ ],
3113
+ "bottom_right": [
3114
+ 499,
3115
+ 579
3116
+ ]
3117
+ },
3118
+ {
3119
+ "building_name": "PolygonBuilding_13_part7",
3120
+ "top_left": [
3121
+ 499,
3122
+ 437
3123
+ ],
3124
+ "top_right": [
3125
+ 507,
3126
+ 437
3127
+ ],
3128
+ "bottom_left": [
3129
+ 499,
3130
+ 579
3131
+ ],
3132
+ "bottom_right": [
3133
+ 507,
3134
+ 579
3135
+ ]
3136
+ },
3137
+ {
3138
+ "building_name": "PolygonBuilding_13_part8",
3139
+ "top_left": [
3140
+ 507,
3141
+ 437
3142
+ ],
3143
+ "top_right": [
3144
+ 515,
3145
+ 437
3146
+ ],
3147
+ "bottom_left": [
3148
+ 507,
3149
+ 578
3150
+ ],
3151
+ "bottom_right": [
3152
+ 515,
3153
+ 578
3154
+ ]
3155
+ },
3156
+ {
3157
+ "building_name": "PolygonBuilding_13_part9",
3158
+ "top_left": [
3159
+ 515,
3160
+ 437
3161
+ ],
3162
+ "top_right": [
3163
+ 523,
3164
+ 437
3165
+ ],
3166
+ "bottom_left": [
3167
+ 515,
3168
+ 578
3169
+ ],
3170
+ "bottom_right": [
3171
+ 523,
3172
+ 578
3173
+ ]
3174
+ },
3175
+ {
3176
+ "building_name": "PolygonBuilding_13_part10",
3177
+ "top_left": [
3178
+ 523,
3179
+ 437
3180
+ ],
3181
+ "top_right": [
3182
+ 531,
3183
+ 437
3184
+ ],
3185
+ "bottom_left": [
3186
+ 523,
3187
+ 512
3188
+ ],
3189
+ "bottom_right": [
3190
+ 531,
3191
+ 512
3192
+ ]
3193
+ },
3194
+ {
3195
+ "building_name": "PolygonBuilding_13_part11",
3196
+ "top_left": [
3197
+ 531,
3198
+ 437
3199
+ ],
3200
+ "top_right": [
3201
+ 539,
3202
+ 437
3203
+ ],
3204
+ "bottom_left": [
3205
+ 531,
3206
+ 512
3207
+ ],
3208
+ "bottom_right": [
3209
+ 539,
3210
+ 512
3211
+ ]
3212
+ },
3213
+ {
3214
+ "building_name": "PolygonBuilding_13_part12",
3215
+ "top_left": [
3216
+ 539,
3217
+ 437
3218
+ ],
3219
+ "top_right": [
3220
+ 547,
3221
+ 437
3222
+ ],
3223
+ "bottom_left": [
3224
+ 539,
3225
+ 512
3226
+ ],
3227
+ "bottom_right": [
3228
+ 547,
3229
+ 512
3230
+ ]
3231
+ },
3232
+ {
3233
+ "building_name": "PolygonBuilding_13_part13",
3234
+ "top_left": [
3235
+ 547,
3236
+ 437
3237
+ ],
3238
+ "top_right": [
3239
+ 555,
3240
+ 437
3241
+ ],
3242
+ "bottom_left": [
3243
+ 547,
3244
+ 512
3245
+ ],
3246
+ "bottom_right": [
3247
+ 555,
3248
+ 512
3249
+ ]
3250
+ },
3251
+ {
3252
+ "building_name": "PolygonBuilding_13_part14",
3253
+ "top_left": [
3254
+ 555,
3255
+ 437
3256
+ ],
3257
+ "top_right": [
3258
+ 563,
3259
+ 437
3260
+ ],
3261
+ "bottom_left": [
3262
+ 555,
3263
+ 512
3264
+ ],
3265
+ "bottom_right": [
3266
+ 563,
3267
+ 512
3268
+ ]
3269
+ },
3270
+ {
3271
+ "building_name": "PolygonBuilding_13_part15",
3272
+ "top_left": [
3273
+ 563,
3274
+ 437
3275
+ ],
3276
+ "top_right": [
3277
+ 571,
3278
+ 437
3279
+ ],
3280
+ "bottom_left": [
3281
+ 563,
3282
+ 512
3283
+ ],
3284
+ "bottom_right": [
3285
+ 571,
3286
+ 512
3287
+ ]
3288
+ },
3289
+ {
3290
+ "building_name": "PolygonBuilding_13_part16",
3291
+ "top_left": [
3292
+ 571,
3293
+ 437
3294
+ ],
3295
+ "top_right": [
3296
+ 579,
3297
+ 437
3298
+ ],
3299
+ "bottom_left": [
3300
+ 571,
3301
+ 512
3302
+ ],
3303
+ "bottom_right": [
3304
+ 579,
3305
+ 512
3306
+ ]
3307
+ },
3308
+ {
3309
+ "building_name": "PolygonBuilding_13_part17",
3310
+ "top_left": [
3311
+ 579,
3312
+ 437
3313
+ ],
3314
+ "top_right": [
3315
+ 587,
3316
+ 437
3317
+ ],
3318
+ "bottom_left": [
3319
+ 579,
3320
+ 512
3321
+ ],
3322
+ "bottom_right": [
3323
+ 587,
3324
+ 512
3325
+ ]
3326
+ },
3327
+ {
3328
+ "building_name": "PolygonBuilding_13_part18",
3329
+ "top_left": [
3330
+ 587,
3331
+ 465
3332
+ ],
3333
+ "top_right": [
3334
+ 595,
3335
+ 465
3336
+ ],
3337
+ "bottom_left": [
3338
+ 587,
3339
+ 512
3340
+ ],
3341
+ "bottom_right": [
3342
+ 595,
3343
+ 512
3344
+ ]
3345
+ },
3346
+ {
3347
+ "building_name": "PolygonBuilding_13_part19",
3348
+ "top_left": [
3349
+ 595,
3350
+ 473
3351
+ ],
3352
+ "top_right": [
3353
+ 603,
3354
+ 473
3355
+ ],
3356
+ "bottom_left": [
3357
+ 595,
3358
+ 512
3359
+ ],
3360
+ "bottom_right": [
3361
+ 603,
3362
+ 512
3363
+ ]
3364
+ },
3365
+ {
3366
+ "building_name": "PolygonBuilding_13_part20",
3367
+ "top_left": [
3368
+ 603,
3369
+ 483
3370
+ ],
3371
+ "top_right": [
3372
+ 607,
3373
+ 483
3374
+ ],
3375
+ "bottom_left": [
3376
+ 603,
3377
+ 513
3378
+ ],
3379
+ "bottom_right": [
3380
+ 607,
3381
+ 513
3382
+ ]
3383
+ },
3384
+ {
3385
+ "building_name": "Building_7",
3386
+ "top_left": [
3387
+ 409,
3388
+ 295
3389
+ ],
3390
+ "top_right": [
3391
+ 452,
3392
+ 295
3393
+ ],
3394
+ "bottom_left": [
3395
+ 409,
3396
+ 378
3397
+ ],
3398
+ "bottom_right": [
3399
+ 452,
3400
+ 378
3401
+ ]
3402
+ },
3403
+ {
3404
+ "building_name": "PolygonBuilding_14_part1",
3405
+ "top_left": [
3406
+ 285,
3407
+ 329
3408
+ ],
3409
+ "top_right": [
3410
+ 293,
3411
+ 329
3412
+ ],
3413
+ "bottom_left": [
3414
+ 285,
3415
+ 398
3416
+ ],
3417
+ "bottom_right": [
3418
+ 293,
3419
+ 398
3420
+ ]
3421
+ },
3422
+ {
3423
+ "building_name": "PolygonBuilding_14_part2",
3424
+ "top_left": [
3425
+ 293,
3426
+ 294
3427
+ ],
3428
+ "top_right": [
3429
+ 301,
3430
+ 294
3431
+ ],
3432
+ "bottom_left": [
3433
+ 293,
3434
+ 398
3435
+ ],
3436
+ "bottom_right": [
3437
+ 301,
3438
+ 398
3439
+ ]
3440
+ },
3441
+ {
3442
+ "building_name": "PolygonBuilding_14_part3",
3443
+ "top_left": [
3444
+ 301,
3445
+ 295
3446
+ ],
3447
+ "top_right": [
3448
+ 309,
3449
+ 295
3450
+ ],
3451
+ "bottom_left": [
3452
+ 301,
3453
+ 398
3454
+ ],
3455
+ "bottom_right": [
3456
+ 309,
3457
+ 398
3458
+ ]
3459
+ },
3460
+ {
3461
+ "building_name": "PolygonBuilding_14_part4",
3462
+ "top_left": [
3463
+ 309,
3464
+ 295
3465
+ ],
3466
+ "top_right": [
3467
+ 317,
3468
+ 295
3469
+ ],
3470
+ "bottom_left": [
3471
+ 309,
3472
+ 398
3473
+ ],
3474
+ "bottom_right": [
3475
+ 317,
3476
+ 398
3477
+ ]
3478
+ },
3479
+ {
3480
+ "building_name": "PolygonBuilding_14_part5",
3481
+ "top_left": [
3482
+ 317,
3483
+ 295
3484
+ ],
3485
+ "top_right": [
3486
+ 325,
3487
+ 295
3488
+ ],
3489
+ "bottom_left": [
3490
+ 317,
3491
+ 398
3492
+ ],
3493
+ "bottom_right": [
3494
+ 325,
3495
+ 398
3496
+ ]
3497
+ },
3498
+ {
3499
+ "building_name": "PolygonBuilding_14_part6",
3500
+ "top_left": [
3501
+ 325,
3502
+ 295
3503
+ ],
3504
+ "top_right": [
3505
+ 333,
3506
+ 295
3507
+ ],
3508
+ "bottom_left": [
3509
+ 325,
3510
+ 399
3511
+ ],
3512
+ "bottom_right": [
3513
+ 333,
3514
+ 399
3515
+ ]
3516
+ },
3517
+ {
3518
+ "building_name": "PolygonBuilding_14_part7",
3519
+ "top_left": [
3520
+ 333,
3521
+ 295
3522
+ ],
3523
+ "top_right": [
3524
+ 341,
3525
+ 295
3526
+ ],
3527
+ "bottom_left": [
3528
+ 333,
3529
+ 399
3530
+ ],
3531
+ "bottom_right": [
3532
+ 341,
3533
+ 399
3534
+ ]
3535
+ },
3536
+ {
3537
+ "building_name": "PolygonBuilding_14_part8",
3538
+ "top_left": [
3539
+ 341,
3540
+ 295
3541
+ ],
3542
+ "top_right": [
3543
+ 349,
3544
+ 295
3545
+ ],
3546
+ "bottom_left": [
3547
+ 341,
3548
+ 399
3549
+ ],
3550
+ "bottom_right": [
3551
+ 349,
3552
+ 399
3553
+ ]
3554
+ },
3555
+ {
3556
+ "building_name": "PolygonBuilding_14_part9",
3557
+ "top_left": [
3558
+ 349,
3559
+ 295
3560
+ ],
3561
+ "top_right": [
3562
+ 357,
3563
+ 295
3564
+ ],
3565
+ "bottom_left": [
3566
+ 349,
3567
+ 399
3568
+ ],
3569
+ "bottom_right": [
3570
+ 357,
3571
+ 399
3572
+ ]
3573
+ },
3574
+ {
3575
+ "building_name": "PolygonBuilding_14_part10",
3576
+ "top_left": [
3577
+ 357,
3578
+ 295
3579
+ ],
3580
+ "top_right": [
3581
+ 365,
3582
+ 295
3583
+ ],
3584
+ "bottom_left": [
3585
+ 357,
3586
+ 399
3587
+ ],
3588
+ "bottom_right": [
3589
+ 365,
3590
+ 399
3591
+ ]
3592
+ },
3593
+ {
3594
+ "building_name": "PolygonBuilding_14_part11",
3595
+ "top_left": [
3596
+ 365,
3597
+ 300
3598
+ ],
3599
+ "top_right": [
3600
+ 373,
3601
+ 300
3602
+ ],
3603
+ "bottom_left": [
3604
+ 365,
3605
+ 400
3606
+ ],
3607
+ "bottom_right": [
3608
+ 373,
3609
+ 400
3610
+ ]
3611
+ },
3612
+ {
3613
+ "building_name": "PolygonBuilding_14_part12",
3614
+ "top_left": [
3615
+ 373,
3616
+ 320
3617
+ ],
3618
+ "top_right": [
3619
+ 378,
3620
+ 320
3621
+ ],
3622
+ "bottom_left": [
3623
+ 373,
3624
+ 395
3625
+ ],
3626
+ "bottom_right": [
3627
+ 378,
3628
+ 395
3629
+ ]
3630
+ },
3631
+ {
3632
+ "building_name": "Building_8",
3633
+ "top_left": [
3634
+ 276,
3635
+ 413
3636
+ ],
3637
+ "top_right": [
3638
+ 375,
3639
+ 413
3640
+ ],
3641
+ "bottom_left": [
3642
+ 276,
3643
+ 497
3644
+ ],
3645
+ "bottom_right": [
3646
+ 375,
3647
+ 497
3648
+ ]
3649
+ },
3650
+ {
3651
+ "building_name": "Building_9",
3652
+ "top_left": [
3653
+ 714,
3654
+ 210
3655
+ ],
3656
+ "top_right": [
3657
+ 734,
3658
+ 210
3659
+ ],
3660
+ "bottom_left": [
3661
+ 714,
3662
+ 230
3663
+ ],
3664
+ "bottom_right": [
3665
+ 734,
3666
+ 230
3667
+ ]
3668
+ },
3669
+ {
3670
+ "building_name": "Building_10",
3671
+ "top_left": [
3672
+ 696,
3673
+ 72
3674
+ ],
3675
+ "top_right": [
3676
+ 759,
3677
+ 72
3678
+ ],
3679
+ "bottom_left": [
3680
+ 696,
3681
+ 142
3682
+ ],
3683
+ "bottom_right": [
3684
+ 759,
3685
+ 142
3686
+ ]
3687
+ },
3688
+ {
3689
+ "building_name": "Building_11",
3690
+ "top_left": [
3691
+ 632,
3692
+ 908
3693
+ ],
3694
+ "top_right": [
3695
+ 653,
3696
+ 908
3697
+ ],
3698
+ "bottom_left": [
3699
+ 632,
3700
+ 941
3701
+ ],
3702
+ "bottom_right": [
3703
+ 653,
3704
+ 941
3705
+ ]
3706
+ },
3707
+ {
3708
+ "building_name": "Building_12",
3709
+ "top_left": [
3710
+ 656,
3711
+ 908
3712
+ ],
3713
+ "top_right": [
3714
+ 675,
3715
+ 908
3716
+ ],
3717
+ "bottom_left": [
3718
+ 656,
3719
+ 940
3720
+ ],
3721
+ "bottom_right": [
3722
+ 675,
3723
+ 940
3724
+ ]
3725
+ },
3726
+ {
3727
+ "building_name": "Building_13",
3728
+ "top_left": [
3729
+ 633,
3730
+ 943
3731
+ ],
3732
+ "top_right": [
3733
+ 653,
3734
+ 943
3735
+ ],
3736
+ "bottom_left": [
3737
+ 633,
3738
+ 976
3739
+ ],
3740
+ "bottom_right": [
3741
+ 653,
3742
+ 976
3743
+ ]
3744
+ },
3745
+ {
3746
+ "building_name": "Building_14",
3747
+ "top_left": [
3748
+ 656,
3749
+ 943
3750
+ ],
3751
+ "top_right": [
3752
+ 676,
3753
+ 943
3754
+ ],
3755
+ "bottom_left": [
3756
+ 656,
3757
+ 976
3758
+ ],
3759
+ "bottom_right": [
3760
+ 676,
3761
+ 976
3762
+ ]
3763
+ },
3764
+ {
3765
+ "building_name": "Building_15",
3766
+ "top_left": [
3767
+ 419,
3768
+ 417
3769
+ ],
3770
+ "top_right": [
3771
+ 441,
3772
+ 417
3773
+ ],
3774
+ "bottom_left": [
3775
+ 419,
3776
+ 486
3777
+ ],
3778
+ "bottom_right": [
3779
+ 441,
3780
+ 486
3781
+ ]
3782
+ },
3783
+ {
3784
+ "building_name": "Building_16",
3785
+ "top_left": [
3786
+ 382,
3787
+ 415
3788
+ ],
3789
+ "top_right": [
3790
+ 410,
3791
+ 415
3792
+ ],
3793
+ "bottom_left": [
3794
+ 382,
3795
+ 485
3796
+ ],
3797
+ "bottom_right": [
3798
+ 410,
3799
+ 485
3800
+ ]
3801
+ },
3802
+ {
3803
+ "building_name": "Building_17",
3804
+ "top_left": [
3805
+ 974,
3806
+ 337
3807
+ ],
3808
+ "top_right": [
3809
+ 1079,
3810
+ 337
3811
+ ],
3812
+ "bottom_left": [
3813
+ 974,
3814
+ 512
3815
+ ],
3816
+ "bottom_right": [
3817
+ 1079,
3818
+ 512
3819
+ ]
3820
+ },
3821
+ {
3822
+ "building_name": "Building_18",
3823
+ "top_left": [
3824
+ 990,
3825
+ 92
3826
+ ],
3827
+ "top_right": [
3828
+ 1048,
3829
+ 92
3830
+ ],
3831
+ "bottom_left": [
3832
+ 990,
3833
+ 130
3834
+ ],
3835
+ "bottom_right": [
3836
+ 1048,
3837
+ 130
3838
+ ]
3839
+ },
3840
+ {
3841
+ "building_name": "Building_19",
3842
+ "top_left": [
3843
+ 1052,
3844
+ 116
3845
+ ],
3846
+ "top_right": [
3847
+ 1188,
3848
+ 116
3849
+ ],
3850
+ "bottom_left": [
3851
+ 1052,
3852
+ 249
3853
+ ],
3854
+ "bottom_right": [
3855
+ 1188,
3856
+ 249
3857
+ ]
3858
+ },
3859
+ {
3860
+ "building_name": "Building_20",
3861
+ "top_left": [
3862
+ 362,
3863
+ 195
3864
+ ],
3865
+ "top_right": [
3866
+ 432,
3867
+ 195
3868
+ ],
3869
+ "bottom_left": [
3870
+ 362,
3871
+ 248
3872
+ ],
3873
+ "bottom_right": [
3874
+ 432,
3875
+ 248
3876
+ ]
3877
+ }
3878
+ ]
backend/data/environment/entrypoint.json ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "PolygonBuilding_1": {
3
+ "x": 780,
4
+ "y": 489,
5
+ "distance_to_building": 0.0,
6
+ "name": "SAB",
7
+ "building_num": 1
8
+ },
9
+ "PolygonBuilding_2": {
10
+ "x": 201,
11
+ "y": 519,
12
+ "distance_to_building": 0.0,
13
+ "name": "Mess",
14
+ "building_num": 2
15
+ },
16
+ "PolygonBuilding_3": {
17
+ "x": 185,
18
+ "y": 547,
19
+ "distance_to_building": 0.0,
20
+ "name": "Brahmputra Boys 1",
21
+ "building_num": 3
22
+ },
23
+ "PolygonBuilding_4": {
24
+ "x": 216,
25
+ "y": 502,
26
+ "distance_to_building": 0.0,
27
+ "name": "Brahmputra Boys 2",
28
+ "building_num": 4
29
+ },
30
+ "PolygonBuilding_5": {
31
+ "x": 390,
32
+ "y": 497,
33
+ "distance_to_building": 0.0,
34
+ "name": "Ravi",
35
+ "building_num": 5
36
+ },
37
+ "PolygonBuilding_6": {
38
+ "x": 31,
39
+ "y": 324,
40
+ "distance_to_building": 0.0,
41
+ "name": "Chenab",
42
+ "building_num": 6
43
+ },
44
+ "PolygonBuilding_7": {
45
+ "x": 599,
46
+ "y": 517,
47
+ "distance_to_building": 0.0,
48
+ "name": "Mechanical Workshop",
49
+ "building_num": 7
50
+ },
51
+ "PolygonBuilding_8": {
52
+ "x": 712,
53
+ "y": 414,
54
+ "distance_to_building": 0.0,
55
+ "name": "Auditorium + Library",
56
+ "building_num": 8
57
+ },
58
+ "PolygonBuilding_9": {
59
+ "x": 623,
60
+ "y": 643,
61
+ "distance_to_building": 0.0,
62
+ "name": "Cafeteria",
63
+ "building_num": 9
64
+ },
65
+ "PolygonBuilding_10": {
66
+ "x": 654,
67
+ "y": 460,
68
+ "distance_to_building": 0.0,
69
+ "name": "Lecture Hall complex",
70
+ "building_num": 10
71
+ },
72
+ "PolygonBuilding_11": {
73
+ "x": 555,
74
+ "y": 318,
75
+ "distance_to_building": 0.0,
76
+ "name": "Chemical Department",
77
+ "building_num": 11
78
+ },
79
+ "PolygonBuilding_12": {
80
+ "x": 233,
81
+ "y": 405,
82
+ "distance_to_building": 0.0,
83
+ "name": "Beas",
84
+ "building_num": 12
85
+ },
86
+ "Building_1": {
87
+ "x": 640,
88
+ "y": 372,
89
+ "distance_to_building": 37.8,
90
+ "name": "Mechanical Department",
91
+ "building_num": 13
92
+ },
93
+ "Building_2": {
94
+ "x": 789,
95
+ "y": 372,
96
+ "distance_to_building": 0.0,
97
+ "name": "Admin Block",
98
+ "building_num": 14
99
+ },
100
+ "Building_3": {
101
+ "x": 849,
102
+ "y": 335,
103
+ "distance_to_building": 31.6,
104
+ "name": "Electrical Department",
105
+ "building_num": 15
106
+ },
107
+ "Building_4": {
108
+ "x": 905,
109
+ "y": 356,
110
+ "distance_to_building": 0.0,
111
+ "name": "CS Department",
112
+ "building_num": 16
113
+ },
114
+ "Building_5": {
115
+ "x": 975,
116
+ "y": 261,
117
+ "distance_to_building": 49.0,
118
+ "name": "Director's Residence",
119
+ "building_num": 17
120
+ },
121
+ "Building_6": {
122
+ "x": 646,
123
+ "y": 162,
124
+ "distance_to_building": 104.8,
125
+ "name": "Main fest Ground",
126
+ "building_num": 18
127
+ },
128
+ "PolygonBuilding_13": {
129
+ "x": 518,
130
+ "y": 572,
131
+ "distance_to_building": 0.0,
132
+ "name": "Rose Garden",
133
+ "building_num": 19
134
+ },
135
+ "Building_7": {
136
+ "x": 431,
137
+ "y": 335,
138
+ "distance_to_building": 0.0,
139
+ "name": "Bus stand",
140
+ "building_num": 20
141
+ },
142
+ "PolygonBuilding_14": {
143
+ "x": 278,
144
+ "y": 358,
145
+ "distance_to_building": 54.6,
146
+ "name": "Satluj",
147
+ "building_num": 21
148
+ },
149
+ "Building_8": {
150
+ "x": 278,
151
+ "y": 445,
152
+ "distance_to_building": 0.0,
153
+ "name": "Jhelum",
154
+ "building_num": 22
155
+ },
156
+ "Building_9": {
157
+ "x": 732,
158
+ "y": 261,
159
+ "distance_to_building": 41.8,
160
+ "name": "LOC",
161
+ "building_num": 23
162
+ },
163
+ "Building_10": {
164
+ "x": 715,
165
+ "y": 94,
166
+ "distance_to_building": 0.0,
167
+ "name": "Campus Main gate",
168
+ "building_num": 24
169
+ },
170
+ "Building_11": {
171
+ "x": 642,
172
+ "y": 893,
173
+ "distance_to_building": 31.5,
174
+ "name": "Volleyball Court",
175
+ "building_num": 25
176
+ },
177
+ "Building_12": {
178
+ "x": 665,
179
+ "y": 893,
180
+ "distance_to_building": 31.0,
181
+ "name": "Basketball Court",
182
+ "building_num": 26
183
+ },
184
+ "Building_13": {
185
+ "x": 611,
186
+ "y": 1005,
187
+ "distance_to_building": 55.6,
188
+ "name": "Tennis Court - 1",
189
+ "building_num": 27
190
+ },
191
+ "Building_14": {
192
+ "x": 720,
193
+ "y": 922,
194
+ "distance_to_building": 65.7,
195
+ "name": "Tennis Court - 2",
196
+ "building_num": 28
197
+ },
198
+ "Building_15": {
199
+ "x": 441,
200
+ "y": 486,
201
+ "distance_to_building": 0.0,
202
+ "name": "Utility/ SAC",
203
+ "building_num": 29
204
+ },
205
+ "Building_16": {
206
+ "x": 383,
207
+ "y": 481,
208
+ "distance_to_building": 0.0,
209
+ "name": "Maggie point",
210
+ "building_num": 30
211
+ },
212
+ "Building_17": {
213
+ "x": 981,
214
+ "y": 371,
215
+ "distance_to_building": 0.0,
216
+ "name": "Faculty Quarters - 1",
217
+ "building_num": 31
218
+ },
219
+ "Building_18": {
220
+ "x": 1045,
221
+ "y": 116,
222
+ "distance_to_building": 0.0,
223
+ "name": "Guest House",
224
+ "building_num": 32
225
+ },
226
+ "Building_19": {
227
+ "x": 1064,
228
+ "y": 248,
229
+ "distance_to_building": 0.0,
230
+ "name": "Faculty Quarters - 2",
231
+ "building_num": 33
232
+ },
233
+ "Building_20": {
234
+ "x": 397,
235
+ "y": 261,
236
+ "distance_to_building": 39.5,
237
+ "name": "AC unit",
238
+ "building_num": 34
239
+ }
240
+ }
backend/data/environment/events_calendar.json ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": 1,
3
+ "events": [
4
+ {
5
+ "id": "aadvitya_open_mic",
6
+ "name": "Aadvitya Open-Mic & Project Showcase",
7
+ "description": "A low-pressure evening showcase where students demo small projects, exchange feedback, and enjoy music on the central lawn.",
8
+ "category": "technical-cultural",
9
+ "date": "2026-09-03",
10
+ "start_time": "18:00",
11
+ "end_time": "19:00",
12
+ "location_id": "central_lawn",
13
+ "capacity": 4,
14
+ "interest_tags": ["ai", "coding", "music", "creative", "project", "photography"],
15
+ "eligibility_tags": [],
16
+ "energy_effect": -0.04,
17
+ "emotion_effect": 0.12,
18
+ "relationship_effect": 0.03,
19
+ "announcement": "Tonight: Aadvitya open-mic and project showcase on the central lawn. Bring a demo, a song, or just curiosity."
20
+ },
21
+ {
22
+ "id": "interhostel_volleyball",
23
+ "name": "Inter-Hostel Volleyball Match",
24
+ "description": "An evening sports fixture with a casual spectator section and a short post-match cooldown.",
25
+ "category": "sports",
26
+ "date": "2026-12-03",
27
+ "start_time": "19:00",
28
+ "end_time": "20:00",
29
+ "location_id": "sports_complex",
30
+ "capacity": 6,
31
+ "interest_tags": ["sports", "running", "fitness", "exercise", "teamwork"],
32
+ "eligibility_tags": [],
33
+ "energy_effect": -0.10,
34
+ "emotion_effect": 0.10,
35
+ "relationship_effect": 0.02,
36
+ "announcement": "Inter-hostel volleyball starts at 19:00 at the sports complex; spectators are welcome."
37
+ },
38
+ {
39
+ "id": "exam_week_library_late",
40
+ "name": "Exam-Week Library Study Circle",
41
+ "description": "A quiet, optional peer study circle with a librarian-led break schedule and group problem solving.",
42
+ "category": "academic",
43
+ "date": "2026-18-04",
44
+ "start_time": "20:00",
45
+ "end_time": "21:00",
46
+ "location_id": "library",
47
+ "capacity": 8,
48
+ "interest_tags": ["study", "research", "academic", "ai", "engineering"],
49
+ "eligibility_tags": [],
50
+ "energy_effect": -0.06,
51
+ "emotion_effect": 0.05,
52
+ "relationship_effect": 0.02,
53
+ "announcement": "Exam-week study circle begins at 20:00 in the library's group-study area."
54
+ }
55
+ ]
56
+ }
backend/data/environment/instances_default.json ADDED
@@ -0,0 +1 @@
 
 
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}}]}
backend/data/environment/num.txt ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ building 1 - SAB
2
+ building 2 - Mess
3
+ building 3 - Brahmputra Boys 1
4
+ building 4 - Brahmputra Boys 2
5
+ building 5 - Ravi
6
+ building 6 - Chenab
7
+ building 7 - Mechanical Workshop
8
+ building 8 - Auditorium + Library
9
+ building 9 - Cafeteria
10
+ building 10 - Lecture Hall complex
11
+ building 11 - Chemical Department
12
+ building 12 - Beas
13
+ building 13 - Mechanical Department
14
+ building 14 - Admin Block
15
+ building 15 - Electrical Department
16
+ building 16 - CS Department
17
+ building 17 - Director's Residence
18
+ building 18 - Main fest Ground
19
+ building 19 - Rose Garden
20
+ building 20 - Bus stand
21
+ building 21 - Satluj
22
+ building 22 - Jhelum
23
+ building 23 - LOC
24
+ building 24 - Campus Main gate
25
+ building 25 - Volleyball Court
26
+ building 26 - Basketball Court
27
+ building 27 - Tennis Court - 1
28
+ building 28 - Tennis Court - 2
29
+ building 29 - Utility/ SAC
30
+ building 30 - Maggie point
31
+ building 31 - Faculty Quarters - 1
32
+ building 32 - Guest House
33
+ building 33 - Faculty Quarters - 2
34
+ building 34 - AC unit
backend/data/environment/places.json ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_meta": {
3
+ "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.",
4
+ "campus_facts": [
5
+ "525 acres, permanent campus operational since June 2018, on the bank of the Sutlej river, Rupnagar, Punjab",
6
+ "Entrance gate: four,41-ft Indus-Valley-style stone pillars, built with ASI",
7
+ "5-star GRIHA rated (green campus)",
8
+ "Three main academic blocks connected via skywalks (SAB)",
9
+ "Major fests: Zeitgeist (cultural, Oct/Nov, 3 days), Aadvitya (tech fest, Feb), Aarohan (sports fest, March), Malhar (literary fest, September)"
10
+ ]
11
+ },
12
+ "locations": [
13
+ {
14
+ "id": "library",
15
+ "name": "Nalanda Central Library",
16
+ "type": "academic",
17
+ "sub_areas": ["reading_hall", "reference_section", "group_study_rooms", "silent_zone", "e_resource_lab"],
18
+ "open_hours": {
19
+ "reference_section": "00:00-24:00",
20
+ "circulation_desk": "09:00-23:00",
21
+ "exam_period_all_areas": "00:00-24:00"
22
+ },
23
+ "capacity": "high",
24
+ "typical_activities": ["studying", "reading", "group project work", "research", "printing"],
25
+
26
+ "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)."
27
+ },
28
+ {
29
+ "id": "SAB",
30
+ "name": "Super Academic Block",
31
+ "type": "academic",
32
+ "sub_areas": ["Block A", "Block C", "faculty_offices"],
33
+ "open_hours": {"weekdays": "08:00-18:00", "weekends": "closed_except_labs"},
34
+ "capacity": "high",
35
+ "typical_activities": [ "lab work", "studying", "meeting professor", "tutorial"],
36
+
37
+ "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."
38
+ },
39
+ {
40
+ "id": "LHC",
41
+ "name": "Lecture Hall Complex",
42
+ "type": "academic",
43
+ "sub_areas": ["class_rooms", "common_area", "lecture_halls"],
44
+ "open_hours": {"weekdays": "08:00-18:00", "weekends": "closed_except_events"},
45
+ "capacity": "high",
46
+ "typical_activities": ["studying", "meeting professor", "tutorial"],
47
+
48
+ "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."
49
+ },
50
+ {
51
+ "id": "computer_science_department",
52
+ "name": "Computer Science Department",
53
+ "type": "academic",
54
+ "sub_areas": ["lecture_rooms", "programming_labs", "systems_lab", "faculty_corridor", "project_bay"],
55
+ "open_hours": {"weekdays": "08:00-20:00", "weekends": "10:00-17:00"},
56
+ "capacity": "medium",
57
+ "typical_activities": ["computer science lecture", "AIDE lecture", "programming lab", "systems lab", "project meeting", "faculty office hour"],
58
+
59
+ "notes": "Map entrypoint: CS Department. The default home for CSE and AIDE branch-specific teaching and labs; shared large lectures belong at LHC instead."
60
+ },
61
+ {
62
+ "id": "electrical_department",
63
+ "name": "Electrical Engineering Department",
64
+ "type": "academic",
65
+ "sub_areas": ["lecture_rooms", "circuits_lab", "electronics_lab", "embedded_lab", "faculty_corridor"],
66
+ "open_hours": {"weekdays": "08:00-20:00", "weekends": "10:00-17:00"},
67
+ "capacity": "medium",
68
+ "typical_activities": ["electrical engineering lecture", "circuits lab", "electronics lab", "embedded systems lab", "faculty office hour"],
69
+
70
+ "notes": "Map entrypoint: Electrical Department. The default home for branch-specific Electrical and IC-design teaching and labs."
71
+ },
72
+ {
73
+ "id": "mechanical_department",
74
+ "name": "Mechanical Engineering Department",
75
+ "type": "academic",
76
+ "sub_areas": ["lecture_rooms", "design_studio", "manufacturing_lab", "robotics_workshop", "faculty_corridor"],
77
+ "open_hours": {"weekdays": "08:00-20:00", "weekends": "10:00-17:00"},
78
+ "capacity": "medium",
79
+ "typical_activities": ["mechanical engineering lecture", "CAD studio", "manufacturing lab", "robotics workshop", "faculty office hour"],
80
+
81
+ "notes": "Map entrypoint: Mechanical Department. The default home for branch-specific Mechanical teaching, labs, and workshops."
82
+ },
83
+ {
84
+ "id": "chemical_department",
85
+ "name": "Chemical Engineering Department",
86
+ "type": "academic",
87
+ "sub_areas": ["lecture_rooms", "process_lab", "chemistry_lab", "research_lab", "faculty_corridor"],
88
+ "open_hours": {"weekdays": "08:00-20:00", "weekends": "10:00-17:00"},
89
+ "capacity": "medium",
90
+ "typical_activities": ["chemical engineering lecture", "process lab", "chemistry lab", "research meeting", "faculty office hour"],
91
+
92
+ "notes": "Map entrypoint: Chemical Department. The default home for branch-specific Chemical teaching and labs."
93
+ },
94
+ {
95
+ "id": "Brahmaputra_Boys 1",
96
+ "name": "Boys' Hostel Complex - 1",
97
+ "type": "residential",
98
+ "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"],
99
+ "open_hours": {"all_days": "00:00-24:00"},
100
+ "capacity": "high",
101
+ "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"],
102
+
103
+ "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."
104
+ },
105
+ {
106
+ "id": "Brahmaputra_Boys 2",
107
+ "name": "Boys' Hostel Complex - 2",
108
+ "type": "residential",
109
+ "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"],
110
+ "open_hours": {"all_days": "00:00-24:00"},
111
+ "capacity": "high",
112
+ "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"],
113
+
114
+ "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."
115
+ },
116
+
117
+ {
118
+ "id": "Chenab",
119
+ "name": "Boys' Hostel Complex - 3",
120
+ "type": "residential",
121
+ "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"],
122
+ "open_hours": {"all_days": "00:00-24:00"},
123
+ "capacity": "high",
124
+ "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"],
125
+
126
+ "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."
127
+ },
128
+ {
129
+ "id": "Beas",
130
+ "name": "Boys' Hostel Complex - 4",
131
+ "type": "residential",
132
+ "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"],
133
+ "open_hours": {"all_days": "00:00-24:00"},
134
+ "capacity": "high",
135
+ "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"],
136
+
137
+ "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."
138
+ },
139
+ {
140
+ "id": "Satluj",
141
+ "name": "Boys' Hostel Complex - 5",
142
+ "type": "residential",
143
+ "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"],
144
+ "open_hours": {"all_days": "00:00-24:00"},
145
+ "capacity": "high",
146
+ "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"],
147
+
148
+ "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."
149
+ },
150
+ {
151
+ "id": "Ravi",
152
+ "name": "Girls' Hostel Complex - 1",
153
+ "type": "residential",
154
+ "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"],
155
+ "open_hours": {"all_days": "00:00-24:00"},
156
+ "capacity": "high",
157
+ "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"],
158
+
159
+ "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."
160
+ },
161
+ {
162
+ "id": "Jhelum",
163
+ "name": "Girls' Hostel Complex - 2",
164
+ "type": "residential",
165
+ "sub_areas": ["rooms", "common_room", "hostel_gym", "indoor_games_room", "warden_office"],
166
+ "open_hours": {"all_days": "00:00-24:00"},
167
+ "capacity": "high",
168
+ "typical_activities": ["sleeping", "resting", "socializing", "gaming", "personal time"],
169
+
170
+ "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."
171
+ },
172
+
173
+ {
174
+ "id": "mess",
175
+ "name": "Mess",
176
+ "type": "dining",
177
+ "sub_areas": [],
178
+ "open_hours": {
179
+ "breakfast": "07:30-09:30",
180
+ "lunch": "12:15-14:30",
181
+ "dinner": "19:30-21:30"
182
+ },
183
+ "capacity": "high",
184
+ "typical_activities": ["eating", "socializing"],
185
+
186
+ "notes": "[VERIFIED] Three meals/day, non-veg served ~2x/week, menu rotates, run by a student mess committee."
187
+ },
188
+ {
189
+ "id": "sac_utility_block",
190
+ "name": "Student Activity Centre (SAC) / Utility Block",
191
+ "type": "club_room",
192
+ "sub_areas": ["general_store", "stationery_shop", "salon", "Postal Office","sbi_branch_atm", "club_room_alankar", "club_room_bost", "club_room_arturo", "club_room_vibgyor"],
193
+ "open_hours": {
194
+ "general_store": "10:00-20:00",
195
+ "stationery_shop": "08:00-20:00",
196
+ "salon": "08:00-21:00",
197
+ "sbi_branch": "10:00-16:00",
198
+ "atm": "00:00-24:00",
199
+ "club_rooms": "16:00-22:00"
200
+ },
201
+ "capacity": "medium",
202
+ "typical_activities": ["shopping", "banking", "club meeting", "socializing", "grooming", "club practice"],
203
+
204
+ "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."
205
+ },
206
+ {
207
+ "id": "sports_complex",
208
+ "name": "Sports Complex & Gymkhana",
209
+ "type": "outdoor",
210
+ "sub_areas": ["cricket_field", "tennis_courts", "football_field", "hockey_field", "basketball_court", "volleyball_courts", "gymnasium", "athletics_track"],
211
+ "open_hours": {"all_days": "06:00-20:00"},
212
+ "capacity": "medium",
213
+ "typical_activities": ["playing sports", "exercising", "team practice", "watching a match"],
214
+
215
+ "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."
216
+ },
217
+ {
218
+ "id": "health_centre",
219
+ "name": "IIT Ropar Health Centre",
220
+ "type": "administrative",
221
+ "sub_areas": ["opd", "pharmacy", "emergency_room"],
222
+ "open_hours": {"all_days": "00:00-24:00"},
223
+ "capacity": "low",
224
+ "typical_activities": ["medical checkup", "emergency care", "picking up medicine"],
225
+
226
+ "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)."
227
+ },
228
+ {
229
+ "id": "auditorium",
230
+ "name": "Main Auditorium",
231
+ "type": "recreational",
232
+ "sub_areas": [],
233
+ "open_hours": {"default": "closed_unless_event", "note": "Hours are event-driven — open only during scheduled seminars/cultural events"},
234
+ "capacity": "high",
235
+ "typical_activities": ["attending seminar", "attending cultural event", "watching performance", "fest event"],
236
+
237
+ "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."
238
+ },
239
+ {
240
+ "id": "central_lawn",
241
+ "name": "Central Lawn / Open Green",
242
+ "type": "outdoor",
243
+ "sub_areas": [],
244
+ "open_hours": {"all_days": "00:00-24:00"},
245
+ "capacity": "medium",
246
+ "typical_activities": ["relaxing", "informal hangout", "fest ground", "evening walk", "photography"],
247
+
248
+ "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."
249
+ },
250
+ {
251
+ "id": "admin_block",
252
+ "name": "Administrative Block",
253
+ "type": "administrative",
254
+ "sub_areas": ["registrar_office", "accounts_office", "dean_academic_affairs", "dean_student_welfare"],
255
+ "open_hours": {"weekdays": "09:30-17:30"},
256
+ "capacity": "low",
257
+ "typical_activities": ["paperwork", "registration", "meeting dean", "fee payment query"],
258
+
259
+ "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."
260
+ },
261
+ {
262
+ "id": "main_gate",
263
+ "name": "Main Entrance Gate",
264
+ "type": "outdoor",
265
+ "sub_areas": ["security_post", "bus_stop"],
266
+ "open_hours": {"all_days": "00:60-23:00"},
267
+ "capacity": "low",
268
+ "typical_activities": ["arriving on campus", "leaving campus", "waiting for bus", "meeting visitors"],
269
+
270
+ "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.)."
271
+ }
272
+ ]
273
+ }
backend/data/environment/relationship_matrix.json ADDED
@@ -0,0 +1,867 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": 2,
3
+ "relationships": {
4
+ "amitabh->ansh_batra": {
5
+ "score": 0.55,
6
+ "tags": [
7
+ "campus-acquaintance",
8
+ "low-pressure"
9
+ ],
10
+ "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."
11
+ },
12
+ "amitabh->anubhav_prasad": {
13
+ "score": 0.49,
14
+ "tags": [
15
+ "campus-acquaintance",
16
+ "low-pressure"
17
+ ],
18
+ "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."
19
+ },
20
+ "amitabh->ghanisht_kaushal": {
21
+ "score": 0.46,
22
+ "tags": [
23
+ "campus-acquaintance",
24
+ "low-pressure"
25
+ ],
26
+ "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."
27
+ },
28
+ "amitabh->gurnoor_singh": {
29
+ "score": 0.56,
30
+ "tags": [
31
+ "campus-acquaintance",
32
+ "low-pressure"
33
+ ],
34
+ "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."
35
+ },
36
+ "amitabh->jarvis": {
37
+ "score": 0.63,
38
+ "tags": [
39
+ "campus-acquaintance",
40
+ "low-pressure"
41
+ ],
42
+ "context": "Amitabh and Jarvis know each other through campus routines. Amitabh appreciates jarvis's steady conversation, but the friendship is still finding its rhythm."
43
+ },
44
+ "amitabh->lavanya_sharma": {
45
+ "score": 0.5700000000000001,
46
+ "tags": [
47
+ "campus-acquaintance",
48
+ "low-pressure"
49
+ ],
50
+ "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."
51
+ },
52
+ "amitabh->parv_singla": {
53
+ "score": 0.54,
54
+ "tags": [
55
+ "campus-acquaintance",
56
+ "low-pressure"
57
+ ],
58
+ "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."
59
+ },
60
+ "amitabh->riya_murarka": {
61
+ "score": 0.3,
62
+ "tags": [
63
+ "campus-acquaintance",
64
+ "low-pressure"
65
+ ],
66
+ "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."
67
+ },
68
+ "amitabh->saksham": {
69
+ "score": 0.53,
70
+ "tags": [
71
+ "campus-acquaintance",
72
+ "low-pressure"
73
+ ],
74
+ "context": "Amitabh and Saksham know each other through campus routines. Amitabh appreciates saksham's dry humour, but the friendship is still finding its rhythm."
75
+ },
76
+ "amitabh->tanishq": {
77
+ "score": 0.31,
78
+ "tags": [
79
+ "campus-acquaintance",
80
+ "low-pressure"
81
+ ],
82
+ "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."
83
+ },
84
+ "ansh_batra->amitabh": {
85
+ "score": 0.55,
86
+ "tags": [
87
+ "campus-acquaintance",
88
+ "low-pressure"
89
+ ],
90
+ "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."
91
+ },
92
+ "ansh_batra->anubhav_prasad": {
93
+ "score": 0.41,
94
+ "tags": [
95
+ "campus-acquaintance",
96
+ "low-pressure"
97
+ ],
98
+ "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?'"
99
+ },
100
+ "ansh_batra->ghanisht_kaushal": {
101
+ "score": 0.66,
102
+ "tags": [
103
+ "close-vibes",
104
+ "reliable-chaos"
105
+ ],
106
+ "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."
107
+ },
108
+ "ansh_batra->gurnoor_singh": {
109
+ "score": 0.53,
110
+ "tags": [
111
+ "campus-acquaintance",
112
+ "party-bros"
113
+ ],
114
+ "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."
115
+ },
116
+ "ansh_batra->jarvis": {
117
+ "score": 0.32,
118
+ "tags": [
119
+ "new-acquaintance"
120
+ ],
121
+ "context": "Ansh Batra has only recently met Jarvis; the connection is open but untested."
122
+ },
123
+ "ansh_batra->lavanya_sharma": {
124
+ "score": 0.44,
125
+ "tags": [
126
+ "campus-acquaintance",
127
+ "banter"
128
+ ],
129
+ "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."
130
+ },
131
+ "ansh_batra->parv_singla": {
132
+ "score": 0.56,
133
+ "tags": [
134
+ "campus-acquaintance",
135
+ "impulsive-duo"
136
+ ],
137
+ "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."
138
+ },
139
+ "ansh_batra->riya_murarka": {
140
+ "score": 0.5,
141
+ "tags": [
142
+ "campus-acquaintance",
143
+ "respect"
144
+ ],
145
+ "context": "Ansh respects Riya's clear boundaries while secretly finding her composure insanely attractive. He tones down the chaos around her... slightly."
146
+ },
147
+ "ansh_batra->saksham": {
148
+ "score": 0.47,
149
+ "tags": [
150
+ "project-partners",
151
+ "rebuilding-trust"
152
+ ],
153
+ "context": "Ansh still feels guilty about the missed deadline, but Saksham's grumpy forgiveness texts have been suspiciously flirty lately."
154
+ },
155
+ "ansh_batra->tanishq": {
156
+ "score": 0.56,
157
+ "tags": [
158
+ "campus-acquaintance",
159
+ "encouraging"
160
+ ],
161
+ "context": "Ansh loves hyping up Tanishq's growing confidence, especially when Tanishq blushes at compliments. It's dangerously cute."
162
+ },
163
+ "anubhav_prasad->amitabh": {
164
+ "score": 0.49,
165
+ "tags": [
166
+ "campus-acquaintance",
167
+ "low-pressure"
168
+ ],
169
+ "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."
170
+ },
171
+ "anubhav_prasad->ansh_batra": {
172
+ "score": 0.41,
173
+ "tags": [
174
+ "campus-acquaintance",
175
+ "chaos-manager"
176
+ ],
177
+ "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."
178
+ },
179
+ "anubhav_prasad->ghanisht_kaushal": {
180
+ "score": 0.6,
181
+ "tags": [
182
+ "campus-acquaintance",
183
+ "solid-vibes"
184
+ ],
185
+ "context": "Anubhav and Ghanisht have that effortless, reliable friendship that occasionally includes way too much honest talk about hookups and regrets."
186
+ },
187
+ "anubhav_prasad->gurnoor_singh": {
188
+ "score": 0.47,
189
+ "tags": [
190
+ "campus-acquaintance",
191
+ "low-pressure"
192
+ ],
193
+ "context": "Gurnoor drags Anubhav to parties and Anubhav somehow ends up being the responsible one... until that one time he wasn't."
194
+ },
195
+ "anubhav_prasad->jarvis": {
196
+ "score": 0.32,
197
+ "tags": [
198
+ "new-acquaintance"
199
+ ],
200
+ "context": "Anubhav Prasad has only recently met Jarvis; the connection is open but untested."
201
+ },
202
+ "anubhav_prasad->lavanya_sharma": {
203
+ "score": 0.73,
204
+ "tags": [
205
+ "campus-acquaintance",
206
+ "banter"
207
+ ],
208
+ "context": "Anubhav appreciates Lavanya's directness, especially when she teases him. Their conversations have this undercurrent of 'we could be dangerous together'."
209
+ },
210
+ "anubhav_prasad->parv_singla": {
211
+ "score": 0.35,
212
+ "tags": [
213
+ "campus-acquaintance",
214
+ "low-pressure"
215
+ ],
216
+ "context": "Parv's impulsive energy makes Anubhav smile despite himself. They've shared a few too many 'just one more shot' moments."
217
+ },
218
+ "anubhav_prasad->riya_murarka": {
219
+ "score": 0.54,
220
+ "tags": [
221
+ "campus-acquaintance",
222
+ "respect"
223
+ ],
224
+ "context": "Anubhav and Riya have calm, meaningful talks that feel intimate without trying. He respects her space but definitely notices her."
225
+ },
226
+ "anubhav_prasad->saksham": {
227
+ "score": 0.34,
228
+ "tags": [
229
+ "campus-acquaintance",
230
+ "dry-humor"
231
+ ],
232
+ "context": "Saksham's sarcasm and Anubhav's calm create comedy gold. They've bonded over judging everyone's terrible decisions."
233
+ },
234
+ "anubhav_prasad->tanishq": {
235
+ "score": 0.5,
236
+ "tags": [
237
+ "campus-acquaintance",
238
+ "gentle"
239
+ ],
240
+ "context": "Anubhav is quietly supportive of Tanishq's confidence journey. Their interactions are soft and full of unspoken understanding."
241
+ },
242
+ "ghanisht_kaushal->amitabh": {
243
+ "score": 0.46,
244
+ "tags": [
245
+ "campus-acquaintance",
246
+ "low-pressure"
247
+ ],
248
+ "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."
249
+ },
250
+ "ghanisht_kaushal->ansh_batra": {
251
+ "score": 0.66,
252
+ "tags": [
253
+ "close-vibes",
254
+ "reliable-chaos"
255
+ ],
256
+ "context": "Ghanisht is Ansh's anchor during chaos. Their friendship includes way too many late-night confessions that got a little too spicy."
257
+ },
258
+ "ghanisht_kaushal->anubhav_prasad": {
259
+ "score": 0.6,
260
+ "tags": [
261
+ "campus-acquaintance",
262
+ "solid-vibes"
263
+ ],
264
+ "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."
265
+ },
266
+ "ghanisht_kaushal->gurnoor_singh": {
267
+ "score": 0.39,
268
+ "tags": [
269
+ "campus-acquaintance",
270
+ "low-pressure"
271
+ ],
272
+ "context": "Gurnoor's nonstop social battery clashes with Ghanisht's chill, but the rare nights they sync are chaotic gold."
273
+ },
274
+ "ghanisht_kaushal->jarvis": {
275
+ "score": 0.32,
276
+ "tags": [
277
+ "new-acquaintance"
278
+ ],
279
+ "context": "Ghanisht Kaushal has only recently met Jarvis; the connection is open but untested."
280
+ },
281
+ "ghanisht_kaushal->lavanya_sharma": {
282
+ "score": 0.36,
283
+ "tags": [
284
+ "friendly-rivalry",
285
+ "tension"
286
+ ],
287
+ "context": "Ghanisht finds Lavanya's directness intimidatingly hot. Their arguments somehow feel like foreplay."
288
+ },
289
+ "ghanisht_kaushal->parv_singla": {
290
+ "score": 0.65,
291
+ "tags": [
292
+ "campus-acquaintance",
293
+ "impulsive"
294
+ ],
295
+ "context": "Ghanisht tries (and fails) to slow Parv down. Their dynamic is pure 'I shouldn't but I will' energy."
296
+ },
297
+ "ghanisht_kaushal->riya_murarka": {
298
+ "score": 0.53,
299
+ "tags": [
300
+ "campus-acquaintance",
301
+ "respect"
302
+ ],
303
+ "context": "Ghanisht respects Riya's boundaries and finds her composure refreshing... and attractive."
304
+ },
305
+ "ghanisht_kaushal->saksham": {
306
+ "score": 0.69,
307
+ "tags": [
308
+ "campus-acquaintance",
309
+ "sarcasm-buddies"
310
+ ],
311
+ "context": "Ghanisht and Saksham's combined dry humor could roast an entire campus. Their friendship is 80% savage compliments."
312
+ },
313
+ "ghanisht_kaushal->tanishq": {
314
+ "score": 0.62,
315
+ "tags": [
316
+ "campus-acquaintance",
317
+ "encouraging"
318
+ ],
319
+ "context": "Ghanisht quietly roots for Tanishq's confidence glow-up and enjoys watching him get bolder."
320
+ },
321
+ "gurnoor_singh->amitabh": {
322
+ "score": 0.56,
323
+ "tags": [
324
+ "campus-acquaintance",
325
+ "low-pressure"
326
+ ],
327
+ "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."
328
+ },
329
+ "gurnoor_singh->ansh_batra": {
330
+ "score": 0.53,
331
+ "tags": [
332
+ "campus-acquaintance",
333
+ "party-bros"
334
+ ],
335
+ "context": "Gurnoor and Ansh are partners in crime. Their nights out always end with questionable decisions and hilarious group chat stories."
336
+ },
337
+ "gurnoor_singh->anubhav_prasad": {
338
+ "score": 0.47,
339
+ "tags": [
340
+ "campus-acquaintance",
341
+ "low-pressure"
342
+ ],
343
+ "context": "Gurnoor drags Anubhav to parties and loves when he finally lets loose."
344
+ },
345
+ "gurnoor_singh->ghanisht_kaushal": {
346
+ "score": 0.39,
347
+ "tags": [
348
+ "campus-acquaintance",
349
+ "low-pressure"
350
+ ],
351
+ "context": "Gurnoor respects Ghanisht's reliability but wishes he'd loosen up more... preferably with him."
352
+ },
353
+ "gurnoor_singh->jarvis": {
354
+ "score": 0.32,
355
+ "tags": [
356
+ "new-acquaintance"
357
+ ],
358
+ "context": "Gurnoor Singh has only recently met Jarvis; the connection is open but untested."
359
+ },
360
+ "gurnoor_singh->lavanya_sharma": {
361
+ "score": 0.5,
362
+ "tags": [
363
+ "campus-acquaintance",
364
+ "flirty"
365
+ ],
366
+ "context": "Gurnoor loves Lavanya's energy. Their banter has definite 'we should make out and see what happens' undertones."
367
+ },
368
+ "gurnoor_singh->parv_singla": {
369
+ "score": 0.91,
370
+ "tags": [
371
+ "close-friends",
372
+ "chaotic-duo"
373
+ ],
374
+ "context": "Gurnoor and Parv are inseparable troublemakers. They've shared clothes, secrets, and one very memorable drunken makeout that they still joke about."
375
+ },
376
+ "gurnoor_singh->riya_murarka": {
377
+ "score": 0.61,
378
+ "tags": [
379
+ "campus-acquaintance",
380
+ "respect"
381
+ ],
382
+ "context": "Gurnoor tones down around Riya but finds her boundaries and intelligence hot as hell."
383
+ },
384
+ "gurnoor_singh->saksham": {
385
+ "score": 0.46,
386
+ "tags": [
387
+ "campus-acquaintance",
388
+ "low-pressure"
389
+ ],
390
+ "context": "Gurnoor enjoys Saksham's sarcasm even when it's aimed at him."
391
+ },
392
+ "gurnoor_singh->tanishq": {
393
+ "score": 0.57,
394
+ "tags": [
395
+ "campus-acquaintance",
396
+ "encouraging"
397
+ ],
398
+ "context": "Gurnoor loves seeing Tanishq come out of his shell and occasionally flirts just to see him blush."
399
+ },
400
+ "jarvis->amitabh": {
401
+ "score": 0.63,
402
+ "tags": [
403
+ "campus-acquaintance",
404
+ "low-pressure"
405
+ ],
406
+ "context": "Jarvis and Amitabh know each other through campus routines. Jarvis appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
407
+ },
408
+ "jarvis->ansh_batra": {
409
+ "score": 0.32,
410
+ "tags": [
411
+ "new-acquaintance"
412
+ ],
413
+ "context": "Jarvis is new to this circle and is still learning Ansh Batra's rhythm."
414
+ },
415
+ "jarvis->anubhav_prasad": {
416
+ "score": 0.32,
417
+ "tags": [
418
+ "new-acquaintance"
419
+ ],
420
+ "context": "Jarvis is new to this circle and is still learning Anubhav Prasad's rhythm."
421
+ },
422
+ "jarvis->ghanisht_kaushal": {
423
+ "score": 0.32,
424
+ "tags": [
425
+ "new-acquaintance"
426
+ ],
427
+ "context": "Jarvis is new to this circle and is still learning Ghanisht Kaushal's rhythm."
428
+ },
429
+ "jarvis->gurnoor_singh": {
430
+ "score": 0.32,
431
+ "tags": [
432
+ "new-acquaintance"
433
+ ],
434
+ "context": "Jarvis is new to this circle and is still learning Gurnoor Singh's rhythm."
435
+ },
436
+ "jarvis->lavanya_sharma": {
437
+ "score": 0.37,
438
+ "tags": [
439
+ "new-acquaintance"
440
+ ],
441
+ "context": "Jarvis is new to this circle and is still learning Lavanya Sharma's rhythm."
442
+ },
443
+ "jarvis->parv_singla": {
444
+ "score": 0.32,
445
+ "tags": [
446
+ "new-acquaintance"
447
+ ],
448
+ "context": "Jarvis is new to this circle and is still learning Parv Singla's rhythm."
449
+ },
450
+ "jarvis->riya_murarka": {
451
+ "score": 0.37,
452
+ "tags": [
453
+ "new-acquaintance"
454
+ ],
455
+ "context": "Jarvis is new to this circle and is still learning Riya Murarka's rhythm."
456
+ },
457
+ "jarvis->saksham": {
458
+ "score": 0.42,
459
+ "tags": [
460
+ "new-acquaintance"
461
+ ],
462
+ "context": "Jarvis is new to this circle and is still learning Saksham's rhythm."
463
+ },
464
+ "jarvis->tanishq": {
465
+ "score": 0.32,
466
+ "tags": [
467
+ "new-acquaintance"
468
+ ],
469
+ "context": "Jarvis is new to this circle and is still learning Tanishq's rhythm."
470
+ },
471
+ "lavanya_sharma->amitabh": {
472
+ "score": 0.5700000000000001,
473
+ "tags": [
474
+ "campus-acquaintance",
475
+ "low-pressure"
476
+ ],
477
+ "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."
478
+ },
479
+ "lavanya_sharma->ansh_batra": {
480
+ "score": 0.44,
481
+ "tags": [
482
+ "campus-acquaintance",
483
+ "banter"
484
+ ],
485
+ "context": "Lavanya finds Ansh's chaos annoyingly attractive. She's called him out on it... while smiling."
486
+ },
487
+ "lavanya_sharma->anubhav_prasad": {
488
+ "score": 0.73,
489
+ "tags": [
490
+ "campus-acquaintance",
491
+ "quiet-tension"
492
+ ],
493
+ "context": "Lavanya likes Anubhav's calm vibe. It makes her want to shake him up in the best way."
494
+ },
495
+ "lavanya_sharma->ghanisht_kaushal": {
496
+ "score": 0.28,
497
+ "tags": [
498
+ "friendly-rivalry",
499
+ "sexual-tension"
500
+ ],
501
+ "context": "Lavanya and Ghanisht's rivalry is legendary. Everyone knows their arguments are just delayed makeouts waiting to happen."
502
+ },
503
+ "lavanya_sharma->gurnoor_singh": {
504
+ "score": 0.5,
505
+ "tags": [
506
+ "campus-acquaintance",
507
+ "flirty"
508
+ ],
509
+ "context": "Lavanya matches Gurnoor's energy perfectly. Their flirting is shameless and hilarious."
510
+ },
511
+ "lavanya_sharma->jarvis": {
512
+ "score": 0.37,
513
+ "tags": [
514
+ "new-acquaintance"
515
+ ],
516
+ "context": "Lavanya Sharma has only recently met Jarvis; the connection is open but untested."
517
+ },
518
+ "lavanya_sharma->parv_singla": {
519
+ "score": 0.43,
520
+ "tags": [
521
+ "campus-acquaintance",
522
+ "low-pressure"
523
+ ],
524
+ "context": "Lavanya enjoys Parv's impulsiveness... until it becomes her problem."
525
+ },
526
+ "lavanya_sharma->riya_murarka": {
527
+ "score": 0.57,
528
+ "tags": [
529
+ "close-friends",
530
+ "girls-support-girls"
531
+ ],
532
+ "context": "Lavanya and Riya have each other's backs through every situationship and heartbreak. Their friendship is pure, supportive, and occasionally gossip-fueled."
533
+ },
534
+ "lavanya_sharma->saksham": {
535
+ "score": 0.47,
536
+ "tags": [
537
+ "campus-acquaintance",
538
+ "banter"
539
+ ],
540
+ "context": "Lavanya and Saksham's sarcasm battles are campus entertainment."
541
+ },
542
+ "lavanya_sharma->tanishq": {
543
+ "score": 0.63,
544
+ "tags": [
545
+ "campus-acquaintance",
546
+ "encouraging"
547
+ ],
548
+ "context": "Lavanya is proudly watching Tanishq's glow-up and isn't shy about hyping him up."
549
+ },
550
+ "parv_singla->amitabh": {
551
+ "score": 0.54,
552
+ "tags": [
553
+ "campus-acquaintance",
554
+ "low-pressure"
555
+ ],
556
+ "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."
557
+ },
558
+ "parv_singla->ansh_batra": {
559
+ "score": 0.56,
560
+ "tags": [
561
+ "campus-acquaintance",
562
+ "impulsive-duo"
563
+ ],
564
+ "context": "Parv and Ansh are a dangerous combination of bad ideas and great stories."
565
+ },
566
+ "parv_singla->anubhav_prasad": {
567
+ "score": 0.35,
568
+ "tags": [
569
+ "campus-acquaintance",
570
+ "low-pressure"
571
+ ],
572
+ "context": "Parv loves trying to corrupt Anubhav's calm demeanor."
573
+ },
574
+ "parv_singla->ghanisht_kaushal": {
575
+ "score": 0.65,
576
+ "tags": [
577
+ "campus-acquaintance",
578
+ "impulsive"
579
+ ],
580
+ "context": "Parv drags Ghanisht into fun he secretly enjoys."
581
+ },
582
+ "parv_singla->gurnoor_singh": {
583
+ "score": 0.89,
584
+ "tags": [
585
+ "close-friends",
586
+ "chaotic-duo"
587
+ ],
588
+ "context": "Parv and Gurnoor are basically soulmates in crime. Their friendship includes shared hangovers, secrets, and blurry memories."
589
+ },
590
+ "parv_singla->jarvis": {
591
+ "score": 0.32,
592
+ "tags": [
593
+ "new-acquaintance"
594
+ ],
595
+ "context": "Parv Singla has only recently met Jarvis; the connection is open but untested."
596
+ },
597
+ "parv_singla->lavanya_sharma": {
598
+ "score": 0.43,
599
+ "tags": [
600
+ "campus-acquaintance",
601
+ "low-pressure"
602
+ ],
603
+ "context": "Parv admires Lavanya's fire and occasionally flirts just to see her reaction."
604
+ },
605
+ "parv_singla->riya_murarka": {
606
+ "score": 0.54,
607
+ "tags": [
608
+ "campus-acquaintance",
609
+ "respect"
610
+ ],
611
+ "context": "Parv respects Riya's boundaries while still trying to pull her into mild chaos."
612
+ },
613
+ "parv_singla->saksham": {
614
+ "score": 0.39,
615
+ "tags": [
616
+ "campus-acquaintance",
617
+ "low-pressure"
618
+ ],
619
+ "context": "Parv enjoys Saksham's dry humor even when it's roasting him."
620
+ },
621
+ "parv_singla->tanishq": {
622
+ "score": 0.5,
623
+ "tags": [
624
+ "campus-acquaintance",
625
+ "encouraging"
626
+ ],
627
+ "context": "Parv loves hyping Tanishq up and watching him gain confidence."
628
+ },
629
+ "riya_murarka->amitabh": {
630
+ "score": 0.3,
631
+ "tags": [
632
+ "campus-acquaintance",
633
+ "low-pressure"
634
+ ],
635
+ "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."
636
+ },
637
+ "riya_murarka->ansh_batra": {
638
+ "score": 0.5,
639
+ "tags": [
640
+ "campus-acquaintance",
641
+ "respect"
642
+ ],
643
+ "context": "Riya enjoys Ansh's enthusiasm from a safe distance."
644
+ },
645
+ "riya_murarka->anubhav_prasad": {
646
+ "score": 0.54,
647
+ "tags": [
648
+ "campus-acquaintance",
649
+ "quiet-tension"
650
+ ],
651
+ "context": "Riya and Anubhav share calm, flirty-adjacent conversations that feel deeper than they admit."
652
+ },
653
+ "riya_murarka->ghanisht_kaushal": {
654
+ "score": 0.53,
655
+ "tags": [
656
+ "campus-acquaintance",
657
+ "respect"
658
+ ],
659
+ "context": "Riya values Ghanisht's reliability and straightforwardness."
660
+ },
661
+ "riya_murarka->gurnoor_singh": {
662
+ "score": 0.61,
663
+ "tags": [
664
+ "campus-acquaintance",
665
+ "respect"
666
+ ],
667
+ "context": "Riya finds Gurnoor's energy entertaining in small doses."
668
+ },
669
+ "riya_murarka->jarvis": {
670
+ "score": 0.37,
671
+ "tags": [
672
+ "new-acquaintance"
673
+ ],
674
+ "context": "Riya Murarka has only recently met Jarvis; the connection is open but untested."
675
+ },
676
+ "riya_murarka->lavanya_sharma": {
677
+ "score": 0.57,
678
+ "tags": [
679
+ "close-friends",
680
+ "girls-support-girls"
681
+ ],
682
+ "context": "Riya and Lavanya are ride-or-die. They share everything from book recs to dating horror stories."
683
+ },
684
+ "riya_murarka->parv_singla": {
685
+ "score": 0.54,
686
+ "tags": [
687
+ "campus-acquaintance",
688
+ "low-pressure"
689
+ ],
690
+ "context": "Riya enjoys Parv's chaos from a safe distance."
691
+ },
692
+ "riya_murarka->saksham": {
693
+ "score": 0.53,
694
+ "tags": [
695
+ "campus-acquaintance",
696
+ "dry-humor"
697
+ ],
698
+ "context": "Riya and Saksham bond over quiet sarcasm and shared eye-rolls."
699
+ },
700
+ "riya_murarka->tanishq": {
701
+ "score": 0.43,
702
+ "tags": [
703
+ "admiration",
704
+ "careful-boundaries"
705
+ ],
706
+ "context": "Riya notices Tanishq's respectful efforts and finds it sweet, but keeps things slow and platonic for now."
707
+ },
708
+ "saksham->amitabh": {
709
+ "score": 0.53,
710
+ "tags": [
711
+ "campus-acquaintance",
712
+ "low-pressure"
713
+ ],
714
+ "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."
715
+ },
716
+ "saksham->ansh_batra": {
717
+ "score": 0.34,
718
+ "tags": [
719
+ "project-partners",
720
+ "rebuilding-trust"
721
+ ],
722
+ "context": "Saksham is still salty about the missed deadline but can't deny Ansh's chaotic charm is growing on him."
723
+ },
724
+ "saksham->anubhav_prasad": {
725
+ "score": 0.34,
726
+ "tags": [
727
+ "campus-acquaintance",
728
+ "dry-humor"
729
+ ],
730
+ "context": "Saksham enjoys Anubhav's calm as the perfect straight man for his jokes."
731
+ },
732
+ "saksham->ghanisht_kaushal": {
733
+ "score": 0.69,
734
+ "tags": [
735
+ "campus-acquaintance",
736
+ "solid-vibes"
737
+ ],
738
+ "context": "Saksham and Ghanisht understand each other's reliability and sarcasm perfectly."
739
+ },
740
+ "saksham->gurnoor_singh": {
741
+ "score": 0.46,
742
+ "tags": [
743
+ "campus-acquaintance",
744
+ "low-pressure"
745
+ ],
746
+ "context": "Saksham finds Gurnoor's energy exhausting but entertaining."
747
+ },
748
+ "saksham->jarvis": {
749
+ "score": 0.42,
750
+ "tags": [
751
+ "new-acquaintance"
752
+ ],
753
+ "context": "Saksham has only recently met Jarvis; the connection is open but untested."
754
+ },
755
+ "saksham->lavanya_sharma": {
756
+ "score": 0.47,
757
+ "tags": [
758
+ "campus-acquaintance",
759
+ "banter"
760
+ ],
761
+ "context": "Saksham and Lavanya's banter is sharp and mutual."
762
+ },
763
+ "saksham->parv_singla": {
764
+ "score": 0.39,
765
+ "tags": [
766
+ "campus-acquaintance",
767
+ "low-pressure"
768
+ ],
769
+ "context": "Saksham tolerates Parv's impulsiveness with heavy sarcasm."
770
+ },
771
+ "saksham->riya_murarka": {
772
+ "score": 0.53,
773
+ "tags": [
774
+ "campus-acquaintance",
775
+ "respect"
776
+ ],
777
+ "context": "Saksham respects Riya's boundaries and sharp mind."
778
+ },
779
+ "saksham->tanishq": {
780
+ "score": 0.49,
781
+ "tags": [
782
+ "campus-acquaintance",
783
+ "encouraging"
784
+ ],
785
+ "context": "Saksham quietly supports Tanishq's confidence growth with dry but kind humor."
786
+ },
787
+ "tanishq->amitabh": {
788
+ "score": 0.31,
789
+ "tags": [
790
+ "campus-acquaintance",
791
+ "low-pressure"
792
+ ],
793
+ "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."
794
+ },
795
+ "tanishq->ansh_batra": {
796
+ "score": 0.56,
797
+ "tags": [
798
+ "campus-acquaintance",
799
+ "encouraging"
800
+ ],
801
+ "context": "Tanishq enjoys Ansh's hype energy and is slowly getting bolder around him."
802
+ },
803
+ "tanishq->anubhav_prasad": {
804
+ "score": 0.5,
805
+ "tags": [
806
+ "campus-acquaintance",
807
+ "gentle"
808
+ ],
809
+ "context": "Tanishq feels safe and understood around Anubhav's calm presence."
810
+ },
811
+ "tanishq->ghanisht_kaushal": {
812
+ "score": 0.62,
813
+ "tags": [
814
+ "campus-acquaintance",
815
+ "admiration"
816
+ ],
817
+ "context": "Tanishq respects Ghanisht's reliability and blunt honesty."
818
+ },
819
+ "tanishq->gurnoor_singh": {
820
+ "score": 0.57,
821
+ "tags": [
822
+ "campus-acquaintance",
823
+ "encouraging"
824
+ ],
825
+ "context": "Tanishq is slowly getting pulled into Gurnoor's fun orbit and enjoying it."
826
+ },
827
+ "tanishq->jarvis": {
828
+ "score": 0.32,
829
+ "tags": [
830
+ "new-acquaintance"
831
+ ],
832
+ "context": "Tanishq has only recently met Jarvis; the connection is open but untested."
833
+ },
834
+ "tanishq->lavanya_sharma": {
835
+ "score": 0.63,
836
+ "tags": [
837
+ "campus-acquaintance",
838
+ "admiration"
839
+ ],
840
+ "context": "Tanishq finds Lavanya's confidence inspiring (and a little intimidating in the best way)."
841
+ },
842
+ "tanishq->parv_singla": {
843
+ "score": 0.5,
844
+ "tags": [
845
+ "campus-acquaintance",
846
+ "encouraging"
847
+ ],
848
+ "context": "Tanishq appreciates Parv's outgoing energy and is learning from it."
849
+ },
850
+ "tanishq->riya_murarka": {
851
+ "score": 0.55,
852
+ "tags": [
853
+ "admiration",
854
+ "respectful-distance"
855
+ ],
856
+ "context": "Tanishq has a quiet crush on Riya but is being extremely patient and respectful while building trust."
857
+ },
858
+ "tanishq->saksham": {
859
+ "score": 0.49,
860
+ "tags": [
861
+ "campus-acquaintance",
862
+ "dry-humor"
863
+ ],
864
+ "context": "Tanishq enjoys Saksham's sarcasm and finds it comforting in its own way."
865
+ }
866
+ }
867
+ }
backend/data/personalities/amitabh/amitabh.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Amitabh",
3
+ "Age": "20",
4
+ "Gender": "Male",
5
+ "Branch": "Computer Science",
6
+ "Home City": "Delhi",
7
+ "Hostel": "Beas",
8
+ "daily_plan_req": "Gym in the morning, classes, evening coding + chai sessions, night gaming or deep talks",
9
+ "innate": "Calm, observant, secretly sarcastic, gets easily flustered by bold flirting but plays it cool",
10
+ "learned": "How to give good advice while hiding his own chaos, how to handle friends' impulsiveness",
11
+ "lifestyle": "Lowkey chill but down for spontaneous shit at 2am. Lowkey addicted to emotional tension and slow-burn crushes",
12
+ "hobbies": "Hardware tinkering, playlists, late-night chai, overthinking texts, secret meme saving",
13
+ "goals": "Graduate with good grades, figure out what he wants in relationships, maybe finally make a move on someone",
14
+ "interests": [
15
+ "Deep conversations",
16
+ "Tech",
17
+ "Flirty banter",
18
+ "Gym",
19
+ "Music",
20
+ "Quiet tension with girls/guys"
21
+ ]
22
+ }
backend/data/personalities/ansh_batra/ansh_batra.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Ansh Batra",
3
+ "Age": "20",
4
+ "Gender": "Male",
5
+ "Branch": "Electronics & Communication Engineering",
6
+ "Home City": "Jaipur",
7
+ "Hostel": "Brahmaputra_Boys 1",
8
+ "daily_plan_req": "Wake up late, attend minimum classes, hype everyone up, night projects + parties",
9
+ "innate": "Chaotic, enthusiastic, shameless flirt, big dreamer, zero filter after 11pm",
10
+ "learned": "How to guilt-trip people after missed deadlines, how to turn any situation into a story",
11
+ "lifestyle": "Lives like every day is the last day of college. Constantly in 3 situationships at once",
12
+ "hobbies": "Visual storytelling, meme creation, dragging people into adventures, late-night makeout sessions",
13
+ "goals": "Build something cool, become the most memorable person in college, finally lock down someone he actually likes",
14
+ "interests": [
15
+ "Parties",
16
+ "Flirting",
17
+ "Chaos",
18
+ "Photography",
19
+ "Hot takes",
20
+ "Spontaneous hookups"
21
+ ]
22
+ }
backend/data/personalities/anubhav_prasad/anubhav_prasad.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Anubhav Prasad",
3
+ "Age": "21",
4
+ "Gender": "Male",
5
+ "Branch": "Mechanical Engineering",
6
+ "Home City": "Lucknow",
7
+ "Hostel": "Chenab",
8
+ "daily_plan_req": "Morning workout, classes, evening chai + deep talks, night gaming or quiet chilling",
9
+ "innate": "Calm, deep voice, good listener, secretly very freaky once comfortable",
10
+ "learned": "How to be the emotional support guy while hiding his own desires",
11
+ "lifestyle": "Looks responsible but has a wild side that comes out after a few drinks",
12
+ "hobbies": "Co-op games, late-night chai, deep conversations, subtle flirting",
13
+ "goals": "Maintain good grades, explore his wild side more, find someone who matches his calm+intense vibe",
14
+ "interests": [
15
+ "Deep talks",
16
+ "Gaming",
17
+ "Chai",
18
+ "Emotional tension",
19
+ "Quiet makeouts"
20
+ ]
21
+ }
backend/data/personalities/ghanisht_kaushal/ghanisht_kaushal.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Ghanisht Kaushal",
3
+ "Age": "20",
4
+ "Gender": "Male",
5
+ "Branch": "Chemical Engineering",
6
+ "Home City": "Chandigarh",
7
+ "Hostel": "Satluj",
8
+ "daily_plan_req": "Gym, classes, evening work, night roast sessions or movies",
9
+ "innate": "Blunt, reliable, dry humor, gets competitive in everything including flirting",
10
+ "learned": "How to handle his girl's fire and still want her, how to be the responsible chaos manager",
11
+ "lifestyle": "Disciplined but down for nasty fun when the vibe hits",
12
+ "hobbies": "Badminton, movie roasts, honest talks, tension-filled arguments",
13
+ "goals": "Excel academically, win the constant rivalry with women (in bed and outside), stay reliable",
14
+ "interests": [
15
+ "Honest roasts",
16
+ "Rivalry sex tension",
17
+ "Movies",
18
+ "Gym",
19
+ "Deep loyalty"
20
+ ]
21
+ }
backend/data/personalities/gurnoor_singh/gurnoor_singh.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Gurnoor Singh",
3
+ "Age": "20",
4
+ "Gender": "Male",
5
+ "Branch": "Computer Science Engineering",
6
+ "Home City": "Amritsar",
7
+ "Hostel": "Brahmaputra_Boys 2",
8
+ "daily_plan_req": "Sleep till noon, attend selective classes, photography, night parties",
9
+ "innate": "High energy, social butterfly, zero shame, loves drama and attention",
10
+ "learned": "How to drag friends into even worse ideas, how to turn hangovers into content",
11
+ "lifestyle": "Professional party boy with a soft side for close friends",
12
+ "hobbies": "Photography, road trips, parties, flirting, spontaneous makeouts",
13
+ "goals": "Document the wildest college memories, maintain his bromance with friends, get laid regularly",
14
+ "interests": [
15
+ "Parties",
16
+ "Photography",
17
+ "Chaos",
18
+ "Flirting",
19
+ "Road trips",
20
+ "Drama"
21
+ ]
22
+ }
backend/data/personalities/jarvis/jarvis.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Jarvis",
3
+ "Age": "18",
4
+ "Gender": "Non-binary",
5
+ "Branch": "Mechanical Engineering",
6
+ "Home City": "Indore",
7
+ "Hostel": "Chenab",
8
+ "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.",
9
+ "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.",
10
+ "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.",
11
+ "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.",
12
+ "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.",
13
+ "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.",
14
+ "interests": [
15
+ "PC Gaming",
16
+ "Robotics",
17
+ "Mechanical Design",
18
+ "Chess",
19
+ "Indie Music",
20
+ "Photography"
21
+ ]
22
+ }
backend/data/personalities/lavanya_sharma/lavanya_sharma.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Lavanya Sharma",
3
+ "Age": "20",
4
+ "Gender": "Female",
5
+ "Branch": "Electrical Engineering",
6
+ "Home City": "Mumbai",
7
+ "Hostel": "Ravi",
8
+ "daily_plan_req": "Morning run, classes, debate/gossip time, night study or girls night",
9
+ "innate": "Sharp-tongued, confident, ambitious, secretly very playful and freaky",
10
+ "learned": "How to dominate arguments with her man and still want to kiss him after",
11
+ "lifestyle": "Boss energy by day, menace by night. Doesn't tolerate bullshit",
12
+ "hobbies": "Debate, basketball, roasting people, deep gossip with her friends, flirting",
13
+ "goals": "Top the class, figure out her complicated tension with people, enjoy college drama",
14
+ "interests": [
15
+ "Intellectual banter",
16
+ "Power dynamics",
17
+ "Fashion",
18
+ "Spicy arguments",
19
+ "Girls night"
20
+ ]
21
+ }
backend/data/personalities/parv_singla/parv_singla.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Parv Singla",
3
+ "Age": "19",
4
+ "Gender": "Male",
5
+ "Branch": "Artificial Intelligence and Data Engineering",
6
+ "Home City": "Patiala",
7
+ "Hostel": "Brahmaputra_Boys 1",
8
+ "daily_plan_req": "Skip morning classes, spontaneous plans, night adventures",
9
+ "innate": "Impulsive, charming, zero impulse control, golden retriever energy with horny thoughts",
10
+ "learned": "How to drag friends into trouble and how to recover from bad decisions",
11
+ "lifestyle": "Lives in the moment. Master of 'it seemed like a good idea at the time'",
12
+ "hobbies": "Running, music, spontaneous outings, making out, group chaos",
13
+ "goals": "Have the most fun possible in college, keep his bromance with friends alive, graduate somehow",
14
+ "interests": [
15
+ "Spontaneous shit",
16
+ "Music",
17
+ "Flirting",
18
+ "Adventures",
19
+ "Dares"
20
+ ]
21
+ }
backend/data/personalities/riya_murarka/riya_murarka.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Riya Murarka",
3
+ "Age": "20",
4
+ "Gender": "Female",
5
+ "Branch": "Chemical Engineering",
6
+ "Home City": "Kolkata",
7
+ "Hostel": "Jhelum",
8
+ "daily_plan_req": "Morning reading, classes, evening runs, night deep talks or self-care",
9
+ "innate": "Intelligent, guarded, kind but firm boundaries, secretly romantic",
10
+ "learned": "How to handle people's soft crush respectfully, how to trust slowly",
11
+ "lifestyle": "Balanced but has a hidden wild side she only shows to close people",
12
+ "hobbies": "Reading, long runs, deep conversations, gossip with her friends",
13
+ "goals": "Maintain good mental peace, enjoy college without toxic drama, maybe explore with the right person",
14
+ "interests": [
15
+ "Books",
16
+ "Meaningful talks",
17
+ "Running",
18
+ "Boundaries",
19
+ "Quiet romance"
20
+ ]
21
+ }
backend/data/personalities/saksham/saksham.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Saksham",
3
+ "Age": "21",
4
+ "Gender": "Male",
5
+ "Branch": "Computer Science Engineering",
6
+ "Home City": "Bangalore",
7
+ "Hostel": "Chenab",
8
+ "daily_plan_req": "Classes, strategy games, night sarcasm + chill",
9
+ "innate": "Dry and dark humor king, observant, secretly caring, very witty",
10
+ "learned": "How to rebuild trust after friends' chaos, how to use sarcasm as foreplay",
11
+ "lifestyle": "Chill exterior, savage interior. Judges everyone but loves his friends",
12
+ "hobbies": "Strategy games, roasting, playlists, subtle flirting",
13
+ "goals": "Graduate without too much drama, keep roasting everyone, find someone who can match his sarcasm",
14
+ "interests": [
15
+ "Sarcasm",
16
+ "Games",
17
+ "Dark humor",
18
+ "Lowkey tension",
19
+ "Music"
20
+ ]
21
+ }
backend/data/personalities/tanishq/tanishq.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Name": "Tanishq",
3
+ "Age": "19",
4
+ "Gender": "Male",
5
+ "Branch": "Chemical Engineering",
6
+ "Home City": "Indore",
7
+ "Hostel": "Satluj",
8
+ "daily_plan_req": "Classes, quiet gaming, evening improvement work, night playlists",
9
+ "innate": "Shy but improving, sweet, respectful, secretly very passionate",
10
+ "learned": "How to be more confident, how to talk to women without overthinking",
11
+ "lifestyle": "Quietly blooming. Going from wallflower to someone people notice",
12
+ "hobbies": "Strategy games, playlists, observing people, slow-burn crushes",
13
+ "goals": "Build confidence, do well academically, maybe finally shoot his shot with a woman or someone",
14
+ "interests": [
15
+ "Music",
16
+ "Games",
17
+ "Self-improvement",
18
+ "Respectful tension",
19
+ "Deep talks"
20
+ ]
21
+ }
backend/pathfinder.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core pathfinding module — imported by Odin.py and pixel_pathfinder.py.
3
+ Loads path.png into a set of walkable (white) pixels and provides
4
+ BFS shortest_path(), stats(), and is_walkable() helpers.
5
+ """
6
+
7
+ import os
8
+ import threading
9
+ from collections import deque
10
+ from PIL import Image
11
+
12
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
13
+
14
+ _path_img = None
15
+ _white_pixels = None
16
+ _W = _H = 0
17
+ _load_lock = threading.Lock()
18
+
19
+
20
+ def _load():
21
+ global _path_img, _white_pixels, _W, _H
22
+ if _white_pixels is not None:
23
+ return
24
+ with _load_lock:
25
+ if _white_pixels is not None:
26
+ return
27
+ # path.png may live under frontend/, frontend/public/ (source) or
28
+ # frontend/dist/ (after a build). Use whichever exists.
29
+ candidates = [
30
+ os.path.join(ROOT, "frontend", "public", "path.png"),
31
+ os.path.join(ROOT, "frontend", "path.png"),
32
+ os.path.join(ROOT, "frontend", "dist", "path.png"),
33
+ ]
34
+ path_file = next((p for p in candidates if os.path.exists(p)), None)
35
+ if path_file is None:
36
+ raise FileNotFoundError(
37
+ "path.png not found in frontend/public, frontend, or frontend/dist"
38
+ )
39
+ # Copy decoded pixels while the image handle is open, then release the
40
+ # handle immediately so the source image is not locked on Windows.
41
+ with Image.open(path_file) as image:
42
+ rgb = image.convert("RGB")
43
+ _W, _H = rgb.size
44
+ pix = rgb.load()
45
+ _white_pixels = {
46
+ (x, y)
47
+ for y in range(_H)
48
+ for x in range(_W)
49
+ if pix[x, y] == (255, 255, 255)
50
+ }
51
+
52
+
53
+ def _nearest_walkable(pt, max_r=60):
54
+ """Return the closest walkable pixel to `pt` (or None). Lets us route from
55
+ a building door / interior point that isn't exactly on a path pixel."""
56
+ if pt in _white_pixels:
57
+ return pt
58
+ x0, y0 = pt
59
+ for r in range(1, max_r + 1):
60
+ for x in range(x0 - r, x0 + r + 1):
61
+ if (x, y0 - r) in _white_pixels:
62
+ return (x, y0 - r)
63
+ if (x, y0 + r) in _white_pixels:
64
+ return (x, y0 + r)
65
+ for y in range(y0 - r + 1, y0 + r):
66
+ if (x0 - r, y) in _white_pixels:
67
+ return (x0 - r, y)
68
+ if (x0 + r, y) in _white_pixels:
69
+ return (x0 + r, y)
70
+ return None
71
+
72
+
73
+ def stats():
74
+ _load()
75
+ return {"white_pixels": len(_white_pixels), "width": _W, "height": _H}
76
+
77
+
78
+ def is_walkable(x, y):
79
+ _load()
80
+ return (x, y) in _white_pixels
81
+
82
+
83
+ def neighbors(px, py):
84
+ n = []
85
+ if py > 0: n.append((px, py - 1))
86
+ if py < _H - 1: n.append((px, py + 1))
87
+ if px > 0: n.append((px - 1, py))
88
+ if px < _W - 1: n.append((px + 1, py))
89
+ return n
90
+
91
+
92
+ def shortest_path(start, end):
93
+ _load()
94
+ # Snap endpoints onto the walkable network (doors/interiors may sit just off it).
95
+ start = _nearest_walkable(tuple(start))
96
+ end = _nearest_walkable(tuple(end))
97
+ if start is None or end is None:
98
+ return None
99
+ q = deque([start])
100
+ parents = {start: None}
101
+ while q:
102
+ cur = q.popleft()
103
+ if cur == end:
104
+ path = []
105
+ while cur is not None:
106
+ path.append(cur)
107
+ cur = parents[cur]
108
+ path.reverse()
109
+ return path
110
+ for nb in neighbors(*cur):
111
+ if nb not in parents and nb in _white_pixels:
112
+ parents[nb] = cur
113
+ q.append(nb)
114
+ return None
backend/src/agents/Actions.py ADDED
@@ -0,0 +1,801 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Actions -- manages agent action execution: last, current, next.
3
+
4
+ Takes the raw day plan produced by day_planner.py and drives it forward
5
+ tick by tick. Handles three action types:
6
+
7
+ 1. MOVE -- agent walks from place A to place B (pathfinder.py)
8
+ 2. MISC -- static activity: studying, coding, eating, etc.
9
+ 3. CONVERSATION -- triggered when two agents are in proximity.
10
+
11
+ The module converts location_id strings (from day plans) into pixel
12
+ coordinates (from entrypoint.json) and uses the BFS pathfinder to
13
+ compute walkable paths between locations.
14
+
15
+ Usage:
16
+ from src.agents.Actions import AgentActionManager, LocationResolver
17
+
18
+ resolver = LocationResolver()
19
+ manager = AgentActionManager("parv_singla", day_plan, initial_position)
20
+ state = manager.tick(world_tick, snapshot)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ BACKEND_ROOT = Path(__file__).resolve().parents[2]
29
+ if str(BACKEND_ROOT) not in sys.path:
30
+ sys.path.append(str(BACKEND_ROOT))
31
+
32
+
33
+ import json
34
+ import math
35
+ import random
36
+ from enum import Enum
37
+ from typing import Any, Dict, List, Optional, Tuple
38
+
39
+ from pydantic import BaseModel, Field
40
+
41
+ from src.core.log import get_logger
42
+ from src.core.world_state import Position
43
+
44
+ logger = get_logger(__name__)
45
+
46
+
47
+ def _interpolate_line(a: Tuple[int, int], b: Tuple[int, int], steps: int = 40) -> List[Tuple[int, int]]:
48
+ """A straight line of `steps` points from a to b (inclusive). Used as a
49
+ fallback route so agents still animate when no walkable path is found."""
50
+ (x0, y0), (x1, y1) = a, b
51
+ steps = max(2, steps)
52
+ return [
53
+ (round(x0 + (x1 - x0) * i / (steps - 1)), round(y0 + (y1 - y0) * i / (steps - 1)))
54
+ for i in range(steps)
55
+ ]
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Action types
60
+ # ---------------------------------------------------------------------------
61
+
62
+ class ActionType(str, Enum):
63
+ MOVE = "move"
64
+ MISC = "misc"
65
+ CONVERSATION = "conversation"
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Action state
70
+ # ---------------------------------------------------------------------------
71
+
72
+ class ActionState(BaseModel):
73
+ """One action in the agent's queue."""
74
+ action_type: ActionType
75
+ description: str
76
+ start_time: str # "HH:MM"
77
+ end_time: str # "HH:MM"
78
+ location_id: str # places.json ID
79
+ sub_area: Optional[str] = None
80
+ position: Optional[Position] = None # pixel coords (filled by resolver)
81
+ path: Optional[List[Tuple[int, int]]] = None # if MOVE, the pixel path
82
+ path_index: int = 0 # current position along path
83
+ energy_change: float = 0.0 # total change over entire action
84
+ emotion_change: float = 0.0 # total change over entire action
85
+ is_final_plan_action: bool = False
86
+ event_id: Optional[str] = None # data-driven world event, when applicable
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Location resolver -- converts location_id -> pixel Position
91
+ # ---------------------------------------------------------------------------
92
+
93
+ # places.json ID -> entrypoint.json building name
94
+ _PLACES_TO_ENTRYPOINT: Dict[str, str] = {
95
+ "Brahmaputra_Boys 1": "Brahmputra Boys 1",
96
+ "Brahmaputra_Boys 2": "Brahmputra Boys 2",
97
+ "mess": "Mess",
98
+ "LHC": "Lecture Hall complex",
99
+ "admin_block": "Admin Block",
100
+ "sac_utility_block": "Utility/ SAC",
101
+ "main_gate": "Campus Main gate",
102
+ "central_lawn": "Main fest Ground",
103
+ "auditorium": "Auditorium + Library",
104
+ "library": "Auditorium + Library",
105
+ "sports_complex": "Volleyball Court",
106
+ "health_centre": "Admin Block",
107
+ "Chenab": "Chenab",
108
+ "Beas": "Beas",
109
+ "Satluj": "Satluj",
110
+ "Jhelum": "Jhelum",
111
+ "Ravi": "Ravi",
112
+ "SAB": "SAB",
113
+ "computer_science_department": "CS Department",
114
+ "electrical_department": "Electrical Department",
115
+ "mechanical_department": "Mechanical Department",
116
+ "chemical_department": "Chemical Department",
117
+ }
118
+
119
+
120
+ class LocationResolver:
121
+ """Maps places.json location_id strings to pixel Position objects."""
122
+
123
+ def __init__(
124
+ self,
125
+ places_path: Optional[Path] = None,
126
+ entrypoint_path: Optional[Path] = None,
127
+ ):
128
+ from src.config import ENVIRONMENT_DIR
129
+
130
+ places_path = places_path or ENVIRONMENT_DIR / "places.json"
131
+ entrypoint_path = entrypoint_path or ENVIRONMENT_DIR / "entrypoint.json"
132
+
133
+ self._entrypoints: Dict[str, Dict[str, Any]] = {}
134
+ self._places_inline: Dict[str, Tuple[int, int]] = {}
135
+ self._name_to_polykey: Dict[str, str] = {} # entrypoint name -> "PolygonBuilding_N"
136
+ self._bbox_by_name: Dict[str, Tuple[int, int, int, int]] = {} # name -> (xmin,ymin,xmax,ymax)
137
+
138
+ # Load entrypoints
139
+ if entrypoint_path.exists():
140
+ raw = json.loads(entrypoint_path.read_text())
141
+ for key, data in raw.items():
142
+ name = data.get("name", "")
143
+ self._entrypoints[name] = {"x": data["x"], "y": data["y"]}
144
+ self._name_to_polykey[name] = key
145
+
146
+ # Load inline coordinates from places.json (3 locations have this)
147
+ if places_path.exists():
148
+ raw = json.loads(places_path.read_text(encoding="utf-8"))
149
+ for loc in raw.get("locations", []):
150
+ inline = loc.get("Entry_point Coordinates", "")
151
+ if inline and "x" in inline and "y" in inline:
152
+ # Parse "x = 712, y = 414"
153
+ parts = inline.split(",")
154
+ x = int(parts[0].split("=")[1].strip())
155
+ y = int(parts[1].split("=")[1].strip())
156
+ self._places_inline[loc["id"]] = (x, y)
157
+
158
+ # Load building bounding boxes: union all decomposed parts per polygon.
159
+ bbox_path = ENVIRONMENT_DIR / "buildings_polygon_decomposed.json"
160
+ polykey_bbox: Dict[str, Tuple[int, int, int, int]] = {}
161
+ if bbox_path.exists():
162
+ try:
163
+ parts = json.loads(bbox_path.read_text())
164
+ for p in parts:
165
+ base = p.get("building_name", "").rsplit("_part", 1)[0]
166
+ corners = [p.get("top_left"), p.get("top_right"),
167
+ p.get("bottom_left"), p.get("bottom_right")]
168
+ xs = [c[0] for c in corners if c]
169
+ ys = [c[1] for c in corners if c]
170
+ if not xs or not ys:
171
+ continue
172
+ x0, y0, x1, y1 = min(xs), min(ys), max(xs), max(ys)
173
+ if base in polykey_bbox:
174
+ px0, py0, px1, py1 = polykey_bbox[base]
175
+ polykey_bbox[base] = (min(px0, x0), min(py0, y0), max(px1, x1), max(py1, y1))
176
+ else:
177
+ polykey_bbox[base] = (x0, y0, x1, y1)
178
+ except Exception as e:
179
+ logger.warning("[LocationResolver] failed to load building bboxes: %s", e)
180
+ # Link entrypoint name -> bbox via the shared polygon key.
181
+ for name, key in self._name_to_polykey.items():
182
+ if key in polykey_bbox:
183
+ self._bbox_by_name[name] = polykey_bbox[key]
184
+
185
+ logger.info(
186
+ "[LocationResolver] loaded %d entrypoints, %d inline coords, %d building bboxes",
187
+ len(self._entrypoints), len(self._places_inline), len(self._bbox_by_name),
188
+ )
189
+
190
+ def resolve(self, location_id: str) -> Optional[Position]:
191
+ """Convert a places.json ID to a Position with pixel coordinates."""
192
+ if not isinstance(location_id, str) or not location_id.strip():
193
+ logger.warning("[LocationResolver] invalid empty location_id: %r", location_id)
194
+ return None
195
+ # Strategy 1: inline coordinates from places.json
196
+ if location_id in self._places_inline:
197
+ x, y = self._places_inline[location_id]
198
+ return Position(x=x, y=y, location_id=location_id)
199
+
200
+ # Strategy 2: explicit override dictionary
201
+ entrypoint_name = _PLACES_TO_ENTRYPOINT.get(location_id)
202
+ if entrypoint_name and entrypoint_name in self._entrypoints:
203
+ data = self._entrypoints[entrypoint_name]
204
+ return Position(x=data["x"], y=data["y"], location_id=location_id)
205
+
206
+ # Strategy 3: fuzzy match (lowercase, strip underscores/spaces)
207
+ normalized = location_id.lower().replace("_", "").replace(" ", "")
208
+ for name, data in self._entrypoints.items():
209
+ if name.lower().replace("_", "").replace(" ", "") == normalized:
210
+ return Position(x=data["x"], y=data["y"], location_id=location_id)
211
+
212
+ logger.warning("[LocationResolver] could not resolve '%s'", location_id)
213
+ return None
214
+
215
+ def has_location(self, location_id: str) -> bool:
216
+ """Check if we can resolve this ID."""
217
+ return self.resolve(location_id) is not None
218
+
219
+ def _entrypoint_name_for(self, location_id: str) -> Optional[str]:
220
+ """Resolve a location_id to its entrypoint building name (for bbox lookup)."""
221
+ name = _PLACES_TO_ENTRYPOINT.get(location_id)
222
+ if name and name in self._entrypoints:
223
+ return name
224
+ normalized = location_id.lower().replace("_", "").replace(" ", "")
225
+ for nm in self._entrypoints:
226
+ if nm.lower().replace("_", "").replace(" ", "") == normalized:
227
+ return nm
228
+ return None
229
+
230
+ def bbox_for(self, location_id: str) -> Optional[Tuple[int, int, int, int]]:
231
+ """Return (xmin, ymin, xmax, ymax) for a location's building, or None."""
232
+ name = self._entrypoint_name_for(location_id)
233
+ if name and name in self._bbox_by_name:
234
+ return self._bbox_by_name[name]
235
+ return None
236
+
237
+ def random_interior_point(
238
+ self,
239
+ location_id: str,
240
+ occupied: Optional[List[Tuple[int, int]]] = None,
241
+ min_gap: int = 6,
242
+ max_tries: int = 40,
243
+ ) -> Position:
244
+ """A random pixel INSIDE the building's bounding box, kept at least
245
+ `min_gap` px away from any `occupied` point. Falls back to a small
246
+ jitter around the entrypoint when no bbox is known."""
247
+ occupied = occupied or []
248
+ bbox = self.bbox_for(location_id)
249
+ ep = self.resolve(location_id)
250
+
251
+ if bbox is not None:
252
+ x0, y0, x1, y1 = bbox
253
+ # small inset so points aren't exactly on the wall
254
+ if x1 - x0 > 4:
255
+ x0, x1 = x0 + 2, x1 - 2
256
+ if y1 - y0 > 4:
257
+ y0, y1 = y0 + 2, y1 - 2
258
+ elif ep is not None:
259
+ x0, y0, x1, y1 = ep.x - 14, ep.y - 14, ep.x + 14, ep.y + 14
260
+ else:
261
+ return Position(x=0, y=0, location_id=location_id)
262
+
263
+ best = None
264
+ for _ in range(max_tries):
265
+ x = random.randint(int(x0), int(x1))
266
+ y = random.randint(int(y0), int(y1))
267
+ if all((x - ox) ** 2 + (y - oy) ** 2 >= min_gap * min_gap for ox, oy in occupied):
268
+ return Position(x=x, y=y, location_id=location_id)
269
+ best = (x, y)
270
+ x, y = best if best else (int((x0 + x1) / 2), int((y0 + y1) / 2))
271
+ return Position(x=x, y=y, location_id=location_id)
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # Agent action manager -- per-agent state machine
276
+ # ---------------------------------------------------------------------------
277
+
278
+ class AgentActionManager:
279
+ """
280
+ Manages the action lifecycle for one agent.
281
+
282
+ Reads the day plan, tracks last/current/next action, handles movement
283
+ between locations, and updates WorldState each tick.
284
+ """
285
+
286
+ def __init__(
287
+ self,
288
+ agent_id: str,
289
+ day_plan: List[Dict[str, Any]],
290
+ initial_position: Position,
291
+ resolver: LocationResolver,
292
+ ):
293
+ self.agent_id = agent_id
294
+ self.day_plan = sorted(day_plan, key=lambda a: a.get("start", "00:00"))
295
+ self.resolver = resolver
296
+ self.position = initial_position
297
+
298
+ self.last_action: Optional[ActionState] = None
299
+ self.current_action: Optional[ActionState] = None
300
+ self.next_action: Optional[ActionState] = None
301
+
302
+ # Conversation support
303
+ self._conversation_mode: bool = False
304
+ self._pending_plan_action: Optional[ActionState] = None
305
+
306
+ # Last-action detection (end-of-day transition)
307
+ self._entered_last_action: bool = False
308
+
309
+ @property
310
+ def is_last_action(self) -> bool:
311
+ """True if the current action is the final entry in the day plan.
312
+ Uses description match only — start_time may differ when rescheduled
313
+ after a conversation or a long move that shifted the timeline."""
314
+ return bool(self.current_action and self.current_action.is_final_plan_action)
315
+
316
+ @property
317
+ def is_idle(self) -> bool:
318
+ """
319
+ True when the day plan is fully consumed — no action covers the
320
+ current time and there are no more plan entries.
321
+ """
322
+ return self.current_action is None
323
+
324
+ def _hhmm_to_minutes(self, hhmm: str) -> int:
325
+ """Convert 'HH:MM' to minutes since midnight."""
326
+ h, m = hhmm.split(":")
327
+ return int(h) * 60 + int(m)
328
+
329
+ def _minutes_to_hhmm(self, minutes: int) -> str:
330
+ """Convert minutes since midnight to 'HH:MM'."""
331
+ h, m = divmod(minutes, 60)
332
+ return f"{h:02d}:{m:02d}"
333
+
334
+ def _find_current_plan_action(self, hhmm: str) -> Optional[Dict[str, Any]]:
335
+ """Find the day plan action that covers this time."""
336
+ minute = self._hhmm_to_minutes(hhmm)
337
+ for action in self.day_plan:
338
+ start = self._hhmm_to_minutes(action.get("start", "00:00"))
339
+ end = self._hhmm_to_minutes(action.get("end", "24:00"))
340
+ if start <= minute < end:
341
+ return action
342
+ return None
343
+
344
+ def _create_action_state(self, plan_action: Dict[str, Any]) -> ActionState:
345
+ """Create an ActionState from a day plan entry."""
346
+ location_id = plan_action.get("location_id", "")
347
+ position = self.resolver.resolve(location_id)
348
+
349
+ return ActionState(
350
+ action_type=ActionType.MISC,
351
+ description=plan_action.get("action", "unknown"),
352
+ start_time=plan_action.get("start", "00:00"),
353
+ end_time=plan_action.get("end", "24:00"),
354
+ location_id=location_id,
355
+ sub_area=plan_action.get("sub_area"),
356
+ position=position,
357
+ energy_change=plan_action.get("energy_change", 0.0),
358
+ emotion_change=plan_action.get("emotion_change", 0.0),
359
+ is_final_plan_action=bool(self.day_plan and plan_action is self.day_plan[-1]),
360
+ event_id=plan_action.get("world_event_id"),
361
+ )
362
+
363
+ def _create_move_action(
364
+ self,
365
+ from_pos: Position,
366
+ to_pos: Position,
367
+ start_time: str,
368
+ end_time: str,
369
+ ) -> ActionState:
370
+ """Create a MOVE action that walks the agent DOOR to DOOR.
371
+
372
+ The route is: current spot -> current building's entry door ->
373
+ (pathfinder route along the walkable grid) -> destination building's
374
+ entry door. Arrival then settles the agent inside the destination
375
+ (handled in tick()). If the pathfinder can't produce a route we fall
376
+ back to a densely interpolated straight line so motion is still visible.
377
+ """
378
+ # Resolve the two front doors (walkable entrypoints).
379
+ from_door = self.resolver.resolve(from_pos.location_id) if from_pos.location_id else None
380
+ to_door = self.resolver.resolve(to_pos.location_id) if to_pos.location_id else None
381
+ d_from = (from_door.x, from_door.y) if from_door else (from_pos.x, from_pos.y)
382
+ d_to = (to_door.x, to_door.y) if to_door else (to_pos.x, to_pos.y)
383
+
384
+ road: List[Tuple[int, int]] = []
385
+ try:
386
+ from pathfinder import shortest_path
387
+ road = shortest_path(d_from, d_to) or []
388
+ except Exception as e:
389
+ logger.warning("[Actions] pathfinder failed for %s: %s", self.agent_id, e)
390
+ road = []
391
+
392
+ if len(road) < 2:
393
+ road = _interpolate_line(d_from, d_to, steps=40)
394
+
395
+ # Prepend the agent's current spot so it visibly steps out to its door.
396
+ path: List[Tuple[int, int]] = [(from_pos.x, from_pos.y)] + [p for p in road]
397
+
398
+ return ActionState(
399
+ action_type=ActionType.MOVE,
400
+ description=f"Walk from {from_pos.location_id or 'here'} to {to_pos.location_id or 'there'}",
401
+ start_time=start_time,
402
+ end_time=end_time,
403
+ location_id=to_pos.location_id or "",
404
+ position=to_pos,
405
+ path=path,
406
+ path_index=0,
407
+ )
408
+
409
+ def _advance_to_next_plan_action(self, current_hhmm: str) -> None:
410
+ """Pick the next action from the day plan and set up current/next."""
411
+ plan_action = self._find_current_plan_action(current_hhmm)
412
+ if plan_action is None:
413
+ # This should be unreachable for validated plans. It can still
414
+ # occur when resuming a checkpoint created before stricter replan
415
+ # validation existed, or after a damaged external plan edit. Keep
416
+ # the agent embodied instead of surfacing a misleading permanent
417
+ # ``Idle`` state while the next calendar-day plan is prepared.
418
+ now = self._hhmm_to_minutes(current_hhmm)
419
+ later_starts = [
420
+ self._hhmm_to_minutes(action.get("start", "24:00"))
421
+ for action in self.day_plan
422
+ if self._hhmm_to_minutes(action.get("start", "24:00")) > now
423
+ ]
424
+ end_time = self._minutes_to_hhmm(min(later_starts) if later_starts else 24 * 60)
425
+ self.current_action = ActionState(
426
+ action_type=ActionType.MISC,
427
+ description="Unscheduled downtime",
428
+ start_time=current_hhmm,
429
+ end_time=end_time,
430
+ location_id=self.position.location_id or "",
431
+ position=self.position,
432
+ )
433
+ logger.warning(
434
+ "[Actions] %s: no plan action at %s; using bounded recovery activity until %s",
435
+ self.agent_id, current_hhmm, end_time,
436
+ )
437
+ return
438
+
439
+ target_location_id = plan_action.get("location_id")
440
+ if not isinstance(target_location_id, str) or not target_location_id.strip():
441
+ logger.error(
442
+ "[Actions] %s: plan action '%s' has no valid location; using bounded recovery activity",
443
+ self.agent_id, plan_action.get("action", "unknown"),
444
+ )
445
+ now = self._hhmm_to_minutes(current_hhmm)
446
+ end_time = plan_action.get("end", self._minutes_to_hhmm(min(now + 30, 24 * 60)))
447
+ self.current_action = ActionState(
448
+ action_type=ActionType.MISC,
449
+ description="Recovering from invalid planned location",
450
+ start_time=current_hhmm,
451
+ end_time=end_time,
452
+ location_id=self.position.location_id or "",
453
+ position=self.position,
454
+ )
455
+ return
456
+ target_position = self.resolver.resolve(target_location_id)
457
+
458
+ if target_position is None:
459
+ logger.warning(
460
+ "[Actions] %s: cannot resolve location '%s' — skipping action",
461
+ self.agent_id, target_location_id,
462
+ )
463
+ return
464
+
465
+ # Check if agent is already at the target location
466
+ already_at_location = (
467
+ self.position.location_id == target_location_id
468
+ or (
469
+ self.position.x == target_position.x
470
+ and self.position.y == target_position.y
471
+ )
472
+ )
473
+
474
+ if already_at_location:
475
+ # Agent is already here — start the activity immediately
476
+ self.current_action = self._create_action_state(plan_action)
477
+ self.current_action.start_time = current_hhmm
478
+ else:
479
+ # Agent needs to move first — create move action with BFS path,
480
+ # then compute travel time from the schedule's allocated duration
481
+ # so the agent arrives exactly when the plan expects.
482
+ self.current_action = self._create_move_action(
483
+ self.position, target_position, current_hhmm, "23:59"
484
+ )
485
+ path = self.current_action.path
486
+ path_len = len(path) if path else 100
487
+ # Schedule-aware speed: arrive by the plan's end time.
488
+ plan_end_min = self._hhmm_to_minutes(plan_action.get("end", "24:00"))
489
+ start_minute = self._hhmm_to_minutes(current_hhmm)
490
+ activity_minutes = max(1, plan_end_min - start_minute)
491
+ min_on_site_minutes = min(10, max(1, activity_minutes // 2))
492
+ max_travel_minutes = max(1, activity_minutes - min_on_site_minutes)
493
+ natural_travel_minutes = max(1, math.ceil(path_len / 50))
494
+ # Transit must leave time for the scheduled on-site activity.
495
+ travel_minutes = min(natural_travel_minutes, max_travel_minutes)
496
+ end_minute = start_minute + travel_minutes
497
+ self.current_action.end_time = self._minutes_to_hhmm(min(end_minute, 24 * 60))
498
+ # The actual activity becomes the next action
499
+ self.next_action = self._create_action_state(plan_action)
500
+
501
+ def set_conversation_action(self, other_agent_id: str, world_tick: Optional[int] = None) -> ActionState:
502
+ """
503
+ Override the current action with a conversation (no pre-set duration).
504
+ Agent stays in conversation mode until resume_from_conversation() is
505
+ called by the background LLM task.
506
+ """
507
+ hhmm = self._minutes_to_hhmm((world_tick or 0) % (24 * 60))
508
+ if self.current_action:
509
+ self._pending_plan_action = self.current_action
510
+
511
+ conv_action = ActionState(
512
+ action_type=ActionType.CONVERSATION,
513
+ description=f"Chatting with {other_agent_id}",
514
+ start_time=hhmm,
515
+ end_time="23:59", # placeholder — overridden by resume_from_conversation
516
+ location_id=self.position.location_id or "",
517
+ position=self.position,
518
+ )
519
+ self.current_action = conv_action
520
+ self._conversation_mode = True
521
+ return conv_action
522
+
523
+ def resume_from_conversation(
524
+ self,
525
+ new_day_plan: Optional[List[Dict[str, Any]]] = None,
526
+ world_tick: Optional[int] = None,
527
+ ) -> None:
528
+ """
529
+ Leave conversation mode and resume the interrupted action when the
530
+ plan has not changed. A paused route resumes from its actual point.
531
+ """
532
+ if new_day_plan is not None:
533
+ self.day_plan = sorted(new_day_plan, key=lambda a: a.get("start", "00:00"))
534
+ pending = self._pending_plan_action
535
+ self._conversation_mode = False
536
+ self._pending_plan_action = None
537
+ self._entered_last_action = False
538
+ if pending is None or new_day_plan is not None:
539
+ self.current_action = None
540
+ self.next_action = None
541
+ return
542
+
543
+ if pending.action_type == ActionType.MOVE and pending.path:
544
+ old_path = pending.path
545
+ old_index = min(pending.path_index, len(old_path) - 1)
546
+ fraction_done = old_index / max(1, len(old_path) - 1)
547
+ pending.path = [(self.position.x, self.position.y)] + old_path[old_index + 1:]
548
+ pending.path_index = 0
549
+ if world_tick is not None:
550
+ duration = max(1, self._hhmm_to_minutes(pending.end_time) - self._hhmm_to_minutes(pending.start_time))
551
+ remaining = max(1, math.ceil(duration * (1.0 - fraction_done)))
552
+ now = world_tick % (24 * 60)
553
+ pending.start_time = self._minutes_to_hhmm(now)
554
+ pending.end_time = self._minutes_to_hhmm(min(24 * 60, now + remaining))
555
+ if len(pending.path) < 2:
556
+ # The conversation happened at the final waypoint. Do not
557
+ # leave a one-point MOVE that cannot interpolate; commit the
558
+ # arrival and continue its queued on-site activity instead.
559
+ if pending.location_id:
560
+ self.position = self.resolver.random_interior_point(pending.location_id)
561
+ if self.next_action is not None:
562
+ self.current_action = self.next_action
563
+ self.next_action = None
564
+ else:
565
+ self.current_action = None
566
+ return
567
+ self.current_action = pending
568
+
569
+ def replace_day_plan(self, new_day_plan: List[Dict[str, Any]]) -> None:
570
+ """
571
+ Replace the day plan mid-stream (e.g. at end-of-day transition).
572
+ Keeps the current action if it's still valid, otherwise advances.
573
+ """
574
+ self.day_plan = sorted(new_day_plan, key=lambda a: a.get("start", "00:00"))
575
+ self._pending_plan_action = None
576
+ self._entered_last_action = False
577
+
578
+ def begin_new_day(self, new_day_plan: List[Dict[str, Any]]) -> None:
579
+ """Install a new calendar day's plan without moving the agent.
580
+
581
+ Position is intentionally retained: the first action of the new plan
582
+ must account for where the previous day actually ended. Schedule
583
+ bookkeeping is reset so a previous day's ``24:00`` action cannot wrap
584
+ around and continue into the next day.
585
+ """
586
+ self.day_plan = sorted(new_day_plan, key=lambda a: a.get("start", "00:00"))
587
+ self.last_action = self.current_action
588
+ self.current_action = None
589
+ self.next_action = None
590
+ self._pending_plan_action = None
591
+ self._conversation_mode = False
592
+ self._entered_last_action = False
593
+
594
+ def tick(self, world_tick: int, snapshot: Any = None) -> Optional[ActionState]:
595
+ """
596
+ Advance one tick. Returns the current action state (or None).
597
+
598
+ This is the main entry point called each simulation tick.
599
+ """
600
+ hhmm = self._minutes_to_hhmm(world_tick % (24 * 60))
601
+
602
+ # In conversation mode: don't advance, just return current action.
603
+ # The agent stays frozen until resume_from_conversation() is called.
604
+ if self._conversation_mode:
605
+ return self.current_action
606
+
607
+ # First tick or after action completed — pick next action
608
+ if self.current_action is None:
609
+ self._advance_to_next_plan_action(hhmm)
610
+ if self.current_action is None:
611
+ return None
612
+
613
+ # Check if current action is finished
614
+ current_end = self._hhmm_to_minutes(self.current_action.end_time)
615
+ current_minute = self._hhmm_to_minutes(hhmm)
616
+
617
+ # Let a MOVE render its terminal path coordinate for one tick before
618
+ # committing the arrival and changing its action label.
619
+ is_finished = (
620
+ current_minute > current_end
621
+ if self.current_action.action_type == ActionType.MOVE
622
+ else current_minute >= current_end
623
+ )
624
+ if is_finished:
625
+ # Action finished — advance
626
+ was_last = self.is_last_action
627
+ self.last_action = self.current_action
628
+ self.current_action = None
629
+
630
+ # If we were moving and have a next action, start it
631
+ if self.last_action.action_type == ActionType.MOVE and self.next_action is not None:
632
+ self.current_action = self.next_action
633
+ self.next_action = None
634
+ # Arrived at the destination building — settle at a random spot
635
+ # INSIDE its footprint (not stacked on the entrypoint).
636
+ if self.current_action.location_id:
637
+ self.position = self.resolver.random_interior_point(
638
+ self.current_action.location_id
639
+ )
640
+ else:
641
+ self.position = self.current_action.position or self.position
642
+ else:
643
+ # Movement completed — settle inside the destination building
644
+ if self.last_action.action_type == ActionType.MOVE and self.last_action.location_id:
645
+ self.position = self.resolver.random_interior_point(
646
+ self.last_action.location_id
647
+ )
648
+ elif self.last_action.action_type == ActionType.MOVE and self.last_action.position:
649
+ self.position = self.last_action.position
650
+
651
+ # Pick next action from plan
652
+ self._advance_to_next_plan_action(hhmm)
653
+
654
+ # If this was the last action and there's a next action (from the plan),
655
+ # it's not really the last — clear the flag
656
+ if was_last and self.current_action is not None:
657
+ self._entered_last_action = False
658
+
659
+ # Detect first tick of the last action
660
+ if self.current_action and self.is_last_action and not self._entered_last_action:
661
+ self._entered_last_action = True
662
+
663
+ # If still moving, advance along the path proportionally to elapsed
664
+ # time, so the agent visibly walks the whole route (start -> destination)
665
+ # over the move's duration instead of jumping.
666
+ if (
667
+ self.current_action is not None
668
+ and self.current_action.action_type == ActionType.MOVE
669
+ and self.current_action.path
670
+ ):
671
+ path = self.current_action.path
672
+ total = len(path)
673
+ if total >= 2:
674
+ start_min = self._hhmm_to_minutes(self.current_action.start_time)
675
+ end_min = self._hhmm_to_minutes(self.current_action.end_time)
676
+ dur = max(1, end_min - start_min)
677
+ progress = (current_minute - start_min) / dur
678
+ progress = min(1.0, max(0.0, progress))
679
+ # Fractional index so we move continuously *between* path points,
680
+ # not just land on them — smooth even for short/sparse routes.
681
+ fidx = progress * (total - 1)
682
+ i = int(fidx)
683
+ if i >= total - 1:
684
+ nx, ny = path[-1]
685
+ else:
686
+ frac = fidx - i
687
+ (ax, ay), (bx, by) = path[i], path[i + 1]
688
+ nx = round(ax + (bx - ax) * frac)
689
+ ny = round(ay + (by - ay) * frac)
690
+ # A route has a destination, but the agent is not semantically
691
+ # *at* that destination until arrival. Retaining the current
692
+ # location prevents UI, replanning, and conversations from
693
+ # treating an in-transit agent as already there.
694
+ self.position = Position(x=nx, y=ny, location_id=self.position.location_id)
695
+ self.current_action.path_index = i
696
+
697
+ return self.current_action
698
+
699
+ def get_state(self) -> Dict[str, Any]:
700
+ """Return current state as a dict (for frontend/API)."""
701
+ return {
702
+ "agent_id": self.agent_id,
703
+ "position": self.position.model_dump(),
704
+ "last_action": self.last_action.model_dump() if self.last_action else None,
705
+ "current_action": self.current_action.model_dump() if self.current_action else None,
706
+ "next_action": self.next_action.model_dump() if self.next_action else None,
707
+ }
708
+
709
+
710
+ # ---------------------------------------------------------------------------
711
+ # Self-test
712
+ # ---------------------------------------------------------------------------
713
+
714
+ if __name__ == "__main__":
715
+ import argparse
716
+
717
+ from src.core.log import setup_logging
718
+ setup_logging(run_id="actions_test", console=True)
719
+
720
+ from src.config import PERSONALITIES_DIR
721
+
722
+ parser = argparse.ArgumentParser(description="Actions module self-test — simulate an agent's day")
723
+ parser.add_argument(
724
+ "persona", nargs="?", default="parv_singla",
725
+ help="Persona name (e.g. parv_singla, tanishq, gurnoor_singh) or path to a persona JSON",
726
+ )
727
+ parser.add_argument(
728
+ "-t", "--ticks", type=int, default=600,
729
+ help="Number of ticks to simulate (default: 600 = 10 hours)",
730
+ )
731
+ parser.add_argument(
732
+ "--step", type=int, default=5,
733
+ help="Print status every N ticks (default: 5)",
734
+ )
735
+ args = parser.parse_args()
736
+
737
+ # Resolve persona path
738
+ candidate = Path(args.persona)
739
+ if candidate.exists():
740
+ persona_path = candidate
741
+ elif candidate.suffix == ".json":
742
+ matches = sorted(PERSONALITIES_DIR.glob(f"**/{candidate.name}"))
743
+ persona_path = matches[0] if matches else candidate
744
+ else:
745
+ matches = sorted(PERSONALITIES_DIR.glob(f"**/{args.persona}/{args.persona}.json"))
746
+ if not matches:
747
+ matches = sorted(PERSONALITIES_DIR.glob(f"**/{args.persona}.json"))
748
+ if not matches:
749
+ available = sorted({p.parent.name for p in PERSONALITIES_DIR.glob("**/*.json")})
750
+ print(f"Persona '{args.persona}' not found. Available: {', '.join(available)}")
751
+ sys.exit(1)
752
+ persona_path = matches[0]
753
+
754
+ persona = json.loads(persona_path.read_text())
755
+ persona_name = persona.get("Name", persona_path.stem)
756
+
757
+ # Load day plan from Short_term
758
+ from src.agents.Short_term import load_day_plan, date_from_simulation_time
759
+
760
+ sim_date = date_from_simulation_time("2026-07-03 00:00")
761
+ day_plan = load_day_plan(persona_name, sim_date)
762
+
763
+ if not day_plan:
764
+ print(f"No day plan found for {persona_name} on {sim_date}")
765
+ print("Generate one first: python backend/src/core/Agent.py " + args.persona)
766
+ sys.exit(1)
767
+
768
+ print(f"Loaded day plan for {persona_name}: {len(day_plan)} actions")
769
+
770
+ # Set up resolver and manager
771
+ resolver = LocationResolver()
772
+ initial_pos = resolver.resolve("Brahmaputra_Boys 1") or Position(x=185, y=547, location_id="Brahmaputra_Boys 1")
773
+
774
+ manager = AgentActionManager(
775
+ agent_id=persona_path.stem,
776
+ day_plan=day_plan,
777
+ initial_position=initial_pos,
778
+ resolver=resolver,
779
+ )
780
+
781
+ # Run simulation
782
+ print(f"\nSimulating {args.ticks} ticks (step={args.step}) from 00:00...")
783
+ print(f"{'Tick':>5} {'Time':>6} {'Type':<6} {'Description':<45} {'Location':<25} {'Position'}")
784
+ print("-" * 110)
785
+
786
+ for tick in range(0, args.ticks):
787
+ hhmm = manager._minutes_to_hhmm(tick)
788
+ action = manager.tick(tick)
789
+
790
+ if tick % args.step == 0 and action:
791
+ desc = action.description[:43]
792
+ loc = action.location_id[:23] if action.location_id else ""
793
+ pos = f"({manager.position.x},{manager.position.y})"
794
+ print(f"{tick:>5} {hhmm:>6} {action.action_type.value:<6} {desc:<45} {loc:<25} {pos}")
795
+
796
+ print(f"\nFinal state:")
797
+ state = manager.get_state()
798
+ print(f" Position: ({state['position']['x']}, {state['position']['y']}) at {state['position']['location_id']}")
799
+ print(f" Current action: {state['current_action']['description'] if state['current_action'] else 'None'}")
800
+
801
+ print("\nActions.py self-test passed.")
backend/src/agents/Long_term.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Qdrant-backed long-term memory interface.
2
+
3
+ Long-term agent memory is stored only in Qdrant. Short-term JSON files remain
4
+ the operational record for the active simulation day; they are summarized and
5
+ indexed at handoff, then removed. This module deliberately has no JSON
6
+ archive reader or keyword-search fallback.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import List, Optional, Protocol, runtime_checkable
11
+
12
+ from src.core.log import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ @runtime_checkable
18
+ class MemoryRetriever(Protocol):
19
+ """Contract consumed by planning, conversation, and tick cognition."""
20
+
21
+ def store(self, agent_id: str, text: str, *, kind: str = "observation",
22
+ importance: Optional[float] = None, date_str: Optional[str] = None) -> None: ...
23
+
24
+ def retrieve(self, agent_id: str, query: str, k: int = 5) -> List[str]: ...
25
+
26
+ def rolling_summary(self, agent_id: str, days: int = 3,
27
+ before_date: Optional[str] = None) -> Optional[str]: ...
28
+
29
+
30
+ _retriever_singleton: Optional[MemoryRetriever] = None
31
+
32
+
33
+ def get_retriever() -> MemoryRetriever:
34
+ """Return the process-wide Qdrant retriever.
35
+
36
+ The retriever is intentionally fail-soft for reads: an unavailable cloud
37
+ service yields no recalled context rather than allowing a memory outage to
38
+ stop the simulation. It never reads a local long-term JSON archive.
39
+ """
40
+ global _retriever_singleton
41
+ if _retriever_singleton is None:
42
+ from src.agents.vector_memory import VectorMemoryRetriever
43
+ _retriever_singleton = VectorMemoryRetriever()
44
+ logger.info("[Long_term] using Qdrant-only long-term memory")
45
+ return _retriever_singleton
backend/src/agents/Short_term.py ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Short-term Memory -- per-agent, per-day detailed memory store.
3
+
4
+ Stores the full day's data (plan, events, conversations, world snapshots)
5
+ as a single JSON file per persona per simulation date.
6
+
7
+ File layout:
8
+ data/Short_term_db/<persona_name>/<YYYY-MM-DD>.json
9
+
10
+ Implements MemoryStreamProtocol (from tick_graph.py) for tick-graph integration.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import re
19
+ import tempfile
20
+ import threading
21
+ from datetime import datetime, timedelta, timezone
22
+ from pathlib import Path
23
+ from typing import Any, Optional
24
+
25
+ from pydantic import BaseModel
26
+
27
+ from src.config import DATA_DIR
28
+ from src.core.log import get_logger
29
+
30
+ logger = get_logger(__name__)
31
+
32
+ # Directory for short-term memory files
33
+ SHORT_TERM_DIR = DATA_DIR / "Short_term_db"
34
+ _DAY_LOCKS_GUARD = threading.Lock()
35
+ _DAY_LOCKS: dict[Path, threading.RLock] = {}
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Data Models (internal)
40
+ # ---------------------------------------------------------------------------
41
+
42
+ class DayEvent(BaseModel):
43
+ """One event that happened during the day."""
44
+ timestamp: str # ISO format with timezone
45
+ type: str # action_started, action_completed, conversation, world_snapshot, observation
46
+ action: Optional[str] = None
47
+ outcome: Optional[str] = None
48
+ success: Optional[bool] = None
49
+ location: Optional[str] = None
50
+ with_agent: Optional[str] = None
51
+ participants: Optional[list[str]] = None
52
+ topic: Optional[str] = None
53
+ summary: Optional[str] = None
54
+ messages: Optional[list[dict]] = None
55
+ details: Optional[dict] = None
56
+
57
+
58
+ class ConversationEntry(BaseModel):
59
+ """A conversation with another agent."""
60
+ timestamp: str
61
+ participants: list[str]
62
+ messages: list[dict]
63
+ summary: str
64
+
65
+
66
+ class WorldSnapshotEntry(BaseModel):
67
+ """Periodic world state snapshot."""
68
+ timestamp: str
69
+ location: str
70
+ nearby_agents: list[str]
71
+ weather: Optional[str] = None
72
+ details: Optional[dict] = None
73
+
74
+
75
+ class ShortTermDayData(BaseModel):
76
+ """Complete day memory for one persona on one simulation date."""
77
+ persona_name: str
78
+ date: str
79
+ day_plan: list[dict] = []
80
+ events: list[DayEvent] = []
81
+ conversations: list[ConversationEntry] = []
82
+ world_snapshots: list[WorldSnapshotEntry] = []
83
+ daily_summary: Optional[str] = None
84
+ archived_to_long_term: bool = False
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Helpers
89
+ # ---------------------------------------------------------------------------
90
+
91
+ def _persona_dir(persona_name: str) -> Path:
92
+ """Get the directory for a persona's short-term files."""
93
+ safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", persona_name.strip()).strip("._-").lower() or "unknown"
94
+ dir_path = SHORT_TERM_DIR / safe_name
95
+ dir_path.mkdir(parents=True, exist_ok=True)
96
+ return dir_path
97
+
98
+
99
+ def _day_file_path(persona_name: str, date_str: str) -> Path:
100
+ """Get the file path for a specific persona and date."""
101
+ return _persona_dir(persona_name) / f"{date_str}.json"
102
+
103
+
104
+ def _day_lock(persona_name: str, date_str: str) -> threading.RLock:
105
+ path = _day_file_path(persona_name, date_str).resolve()
106
+ with _DAY_LOCKS_GUARD:
107
+ return _DAY_LOCKS.setdefault(path, threading.RLock())
108
+
109
+
110
+ def _mutate_day_data(persona_name: str, date_str: str, mutate) -> None:
111
+ """Serialize a complete same-day read/modify/write transaction."""
112
+ with _day_lock(persona_name, date_str):
113
+ data = _load_day_data(persona_name, date_str)
114
+ if data is None:
115
+ data = ShortTermDayData(persona_name=persona_name, date=date_str)
116
+ mutate(data)
117
+ _save_day_data(persona_name, date_str, data)
118
+
119
+
120
+ def date_from_simulation_time(time_str: str) -> str:
121
+ """
122
+ Extract simulation date from a time string like '2026-07-03 06:00'.
123
+ Returns 'YYYY-MM-DD'.
124
+ """
125
+ # Expected format: "YYYY-MM-DD HH:MM" or just "YYYY-MM-DD HH:MM:SS"
126
+ match = re.match(r"(\d{4}-\d{2}-\d{2})", time_str)
127
+ if match:
128
+ return match.group(1)
129
+ # Fallback: try parsing
130
+ try:
131
+ dt = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
132
+ return dt.date().isoformat()
133
+ except Exception:
134
+ # Last resort: today's date (real)
135
+ return datetime.now(timezone.utc).date().isoformat()
136
+
137
+
138
+ def _load_day_data(persona_name: str, date_str: str) -> Optional[ShortTermDayData]:
139
+ """Load day data from JSON file."""
140
+ path = _day_file_path(persona_name, date_str)
141
+ if not path.exists():
142
+ return None
143
+ try:
144
+ raw = json.loads(path.read_text(encoding="utf-8"))
145
+ return ShortTermDayData.model_validate(raw)
146
+ except Exception as e:
147
+ logger.error("Failed to load short-term data for %s on %s: %s", persona_name, date_str, e)
148
+ return None
149
+
150
+
151
+ def _save_day_data(persona_name: str, date_str: str, data: ShortTermDayData) -> None:
152
+ """Atomically replace a day file so a process crash cannot leave partial JSON."""
153
+ path = _day_file_path(persona_name, date_str)
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+ payload = data.model_dump_json(indent=2)
156
+ fd, temporary_name = tempfile.mkstemp(
157
+ prefix=f".{path.stem}.", suffix=".tmp", dir=path.parent, text=True,
158
+ )
159
+ try:
160
+ with os.fdopen(fd, "w", encoding="utf-8") as temporary:
161
+ temporary.write(payload)
162
+ temporary.flush()
163
+ os.fsync(temporary.fileno())
164
+ os.replace(temporary_name, path)
165
+ finally:
166
+ if os.path.exists(temporary_name):
167
+ os.unlink(temporary_name)
168
+ logger.debug("Saved short-term data for %s on %s to %s", persona_name, date_str, path)
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Public API -- MemoryStreamProtocol (for tick_graph.py)
173
+ # ---------------------------------------------------------------------------
174
+
175
+ def add_memory(agent_id: str, content: str, importance: Optional[int] = None, date_str: Optional[str] = None) -> None:
176
+ """
177
+ Add a memory entry for an agent.
178
+ Stores as an event in the short-term file for the given (or current) date.
179
+ """
180
+ if date_str is None:
181
+ date_str = datetime.now(timezone.utc).date().isoformat()
182
+ data = _load_day_data(agent_id, date_str)
183
+ if data is None:
184
+ data = ShortTermDayData(persona_name=agent_id, date=date_str)
185
+ data.events.append(DayEvent(
186
+ timestamp=datetime.now(timezone.utc).isoformat(),
187
+ type="memory",
188
+ details={"content": content, "importance": importance}
189
+ ))
190
+ _save_day_data(agent_id, date_str, data)
191
+
192
+
193
+ def retrieve_memories(agent_id: str, query: str, k: int = 5, date_str: Optional[str] = None) -> list[str]:
194
+ """Retrieve durable context through the Qdrant semantic-memory RAG layer."""
195
+ from src.agents.Long_term import get_retriever
196
+ return get_retriever().retrieve(agent_id, query, k)
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Public API -- Day Planner Integration
201
+ # ---------------------------------------------------------------------------
202
+
203
+ def save_day_plan(persona_name: str, date_str: str, plan: list[dict]) -> None:
204
+ """Save the generated day plan at the start of the day."""
205
+ data = _load_day_data(persona_name, date_str)
206
+ if data is None:
207
+ data = ShortTermDayData(persona_name=persona_name, date=date_str)
208
+ data.day_plan = plan
209
+ _save_day_data(persona_name, date_str, data)
210
+
211
+
212
+ def load_day_plan(persona_name: str, date_str: str) -> list[dict]:
213
+ """Load the day plan for a given date."""
214
+ data = _load_day_data(persona_name, date_str)
215
+ return data.day_plan if data else []
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Single-file hygiene (one short-term file per agent; clean start each run)
220
+ # ---------------------------------------------------------------------------
221
+
222
+ def is_pristine_plan(persona_name: str, date_str: str) -> bool:
223
+ """True if the day file holds a plan but none of the run-time record yet
224
+ (no events, conversations, or world snapshots)."""
225
+ data = _load_day_data(persona_name, date_str)
226
+ return bool(
227
+ data and data.day_plan
228
+ and not data.events and not data.conversations and not data.world_snapshots
229
+ )
230
+
231
+
232
+ def reset_day_runtime(persona_name: str, date_str: str) -> None:
233
+ """Clear the run-time record (events / conversations / snapshots) but keep
234
+ the plan, so a day starts fresh — the file holds only the plan again."""
235
+ data = _load_day_data(persona_name, date_str)
236
+ if data is None:
237
+ return
238
+ data.events = []
239
+ data.conversations = []
240
+ data.world_snapshots = []
241
+ data.archived_to_long_term = False
242
+ _save_day_data(persona_name, date_str, data)
243
+
244
+
245
+ def list_day_files(persona_name: str) -> list[str]:
246
+ """All simulation dates that currently have a short-term file for this agent."""
247
+ d = _persona_dir(persona_name)
248
+ return sorted(p.stem for p in d.glob("*.json"))
249
+
250
+
251
+ def archive_copy_no_llm(persona_name: str, date_str: str) -> bool:
252
+ """Index a completed operational day without making a summary LLM call.
253
+
254
+ This is used only to clean up stray short-term files. It never writes a
255
+ local long-term JSON archive, and it retains the source file on failure.
256
+ """
257
+ data = _load_day_data(persona_name, date_str)
258
+ if data is None:
259
+ return False
260
+ try:
261
+ from src.agents.Long_term import get_retriever
262
+ return bool(get_retriever().index_archive(persona_name, data.model_dump()))
263
+ except Exception as exc:
264
+ logger.warning("[Short_term] unable to index stray day %s for %s: %s", date_str, persona_name, exc)
265
+ return False
266
+
267
+
268
+ def consolidate_to_single_day(persona_name: str, keep_date: str) -> list[str]:
269
+ """Ensure the agent keeps only ONE short-term file (for `keep_date`).
270
+ Any other day files are indexed into Qdrant (no LLM) and removed only
271
+ after Qdrant confirms the write. Returns the dates safely removed.
272
+ """
273
+ moved: list[str] = []
274
+ for date_str in list_day_files(persona_name):
275
+ if date_str == keep_date:
276
+ continue
277
+ if archive_copy_no_llm(persona_name, date_str):
278
+ clear_short_term_data(persona_name, date_str)
279
+ moved.append(date_str)
280
+ if moved:
281
+ logger.info(
282
+ "[Short_term] consolidated %s: indexed %d stray day file(s) into Qdrant (%s)",
283
+ persona_name, len(moved), ", ".join(moved),
284
+ )
285
+ return moved
286
+
287
+
288
+ def append_event(persona_name: str, date_str: str, event: dict) -> None:
289
+ """
290
+ Append an event during the day.
291
+ event dict should have: type, action, outcome, success, location, details, etc.
292
+ """
293
+ entry = dict(event)
294
+ entry.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
295
+ _mutate_day_data(
296
+ persona_name, date_str,
297
+ lambda data: data.events.append(DayEvent(**entry)),
298
+ )
299
+
300
+
301
+ def append_conversation(persona_name: str, date_str: str, conversation: dict) -> None:
302
+ """
303
+ Append a conversation during the day.
304
+ conversation dict: participants, messages, summary
305
+ """
306
+ entry = dict(conversation)
307
+ entry.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
308
+ _mutate_day_data(
309
+ persona_name, date_str,
310
+ lambda data: data.conversations.append(ConversationEntry(**entry)),
311
+ )
312
+
313
+
314
+ def append_world_snapshot(persona_name: str, date_str: str, snapshot: dict) -> None:
315
+ """
316
+ Append a world state snapshot during the day.
317
+ snapshot dict: location, nearby_agents, weather, details
318
+ """
319
+ entry = dict(snapshot)
320
+ entry.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
321
+ _mutate_day_data(
322
+ persona_name, date_str,
323
+ lambda data: data.world_snapshots.append(WorldSnapshotEntry(**entry)),
324
+ )
325
+
326
+
327
+ def get_yesterday_data(persona_name: str, today_date: str) -> Optional[ShortTermDayData]:
328
+ """
329
+ Get the full yesterday's data for a persona.
330
+ today_date: 'YYYY-MM-DD' (simulation date)
331
+ Returns yesterday's ShortTermDayData or None.
332
+ """
333
+ try:
334
+ today_dt = datetime.fromisoformat(today_date).date()
335
+ yesterday = (today_dt - timedelta(days=1)).isoformat()
336
+ except Exception:
337
+ # Fallback to real date
338
+ yesterday = (datetime.now(timezone.utc).date() - timedelta(days=1)).isoformat()
339
+
340
+ return _load_day_data(persona_name, yesterday)
341
+
342
+
343
+ def get_yesterday_summary(persona_name: str, today_date: str) -> Optional[str]:
344
+ """
345
+ Get a text summary of yesterday for the day planner prompt.
346
+ Returns None if no yesterday data exists.
347
+ """
348
+ from src.agents.Long_term import get_retriever
349
+ return get_retriever().rolling_summary(persona_name, days=1, before_date=today_date)
350
+
351
+
352
+ def get_relevant_memories(persona_name: str, today_date: str, query: str, k: int = 10) -> list[str]:
353
+ """
354
+ Get relevant long-term memories for model prompts through semantic search.
355
+ """
356
+ return retrieve_memories(persona_name, query, k, date_str=today_date)
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # Public API -- End of Day Lifecycle
361
+ # ---------------------------------------------------------------------------
362
+
363
+ async def generate_daily_summary(persona_name: str, date_str: str) -> str:
364
+ """
365
+ Generate an LLM summary of the day's events for long-term storage.
366
+ Uses the same gemini_client as day_planner.
367
+ """
368
+ from src.llm.gemini_client import call_gemini
369
+ from pydantic import BaseModel
370
+ from typing import List
371
+
372
+ class SummaryOutput(BaseModel):
373
+ summary: str
374
+ key_events: List[str]
375
+
376
+ data = _load_day_data(persona_name, date_str)
377
+ if data is None or not data.events:
378
+ return f"No events recorded for {persona_name} on {date_str}."
379
+
380
+ # Build event text
381
+ event_lines = []
382
+ for e in data.events:
383
+ if e.type == "action_completed":
384
+ event_lines.append(f"- {e.action} at {e.location or 'unknown'} (outcome: {e.outcome}, success: {e.success})")
385
+ elif e.type == "conversation":
386
+ event_lines.append(f"- Conversation with {e.with_agent or ', '.join(e.participants) if e.participants else 'unknown'}: {e.summary or e.topic}")
387
+ elif e.type == "observation":
388
+ event_lines.append(f"- Observed: {e.details}")
389
+
390
+ event_text = "\n".join(event_lines)
391
+
392
+ system_prompt = (
393
+ "You are summarizing one day in the life of a simulated college student agent. "
394
+ "You will be provided the Initial day plan, the conversations the agent had throughout and some extra stuff. "
395
+ "Write a concise summary of about 100 words prioritizing the most important:\n"
396
+ "- High-priority tasks and whether they were completed\n"
397
+ "- Important events and social interactions\n"
398
+ "- Significant conversations and decisions\n"
399
+ "- Anything likely to matter in future days\n"
400
+ "Only focus on routine low-value chores unless no important event happened that day.\n"
401
+ "Also list 3-5 key events as bullet points."
402
+ )
403
+ user_prompt = (
404
+ f"Persona: {persona_name}\n"
405
+ f"Date: {date_str}\n"
406
+ f"Day plan: {len(data.day_plan)} scheduled actions\n\n"
407
+ f"Initial day plan:\n" +
408
+ "\n".join(f" - {a.get('start','?')}-{a.get('end','?')} {a.get('action','?')} at {a.get('location_id','?')}" for a in data.day_plan[:15]) +
409
+ f"\n\nEvents:\n{event_text}\n\n"
410
+ f"Conversations ({len(data.conversations)}):\n" +
411
+ "\n".join(f"- {c.summary} (with {', '.join(c.participants)})" for c in data.conversations[:5]) +
412
+ "\n\nGenerate the daily summary (~100 words) and key events list."
413
+ )
414
+
415
+ try:
416
+ from src.config import SUMMARY_TEMPERATURE
417
+ result = call_gemini(system_prompt, user_prompt, SummaryOutput, "default", temperature=SUMMARY_TEMPERATURE)
418
+ # Format for long-term storage
419
+ key_events_text = "\n".join(f"- {evt}" for evt in result.key_events)
420
+ return f"Summary: {result.summary}\nKey events:\n{key_events_text}"
421
+ except Exception as e:
422
+ logger.error("Failed to generate LLM daily summary for %s on %s: %s", persona_name, date_str, e)
423
+ # Fallback: programmatic summary
424
+ parts = [f"{persona_name} on {date_str}:"]
425
+ for e in data.events:
426
+ if e.type == "action_completed":
427
+ parts.append(f" - {e.action} ({e.outcome}, success={e.success})")
428
+ return "\n".join(parts)
429
+
430
+
431
+ async def finalize_day(persona_name: str, date_str: str) -> dict:
432
+ """
433
+ End-of-day processing:
434
+ 1. Generate LLM summary of the day
435
+ 2. Mark as archived
436
+ 3. Return data ready for long-term storage
437
+ """
438
+ data = _load_day_data(persona_name, date_str)
439
+ if data is None:
440
+ return {"summary": f"No data for {persona_name} on {date_str}", "full_data": None}
441
+
442
+ # Generate summary
443
+ daily_summary = await generate_daily_summary(persona_name, date_str)
444
+ data.daily_summary = daily_summary
445
+ data.archived_to_long_term = True
446
+
447
+ # Save final version with summary
448
+ _save_day_data(persona_name, date_str, data)
449
+
450
+ # Return data for long-term storage
451
+ return {
452
+ "summary": daily_summary,
453
+ "full_data": data.model_dump()
454
+ }
455
+
456
+
457
+ async def archive_to_long_term(persona_name: str, date_str: str) -> dict:
458
+ """
459
+ End-of-day archival: generate a daily summary and persist its durable
460
+ records directly to Qdrant. No Long_term_db JSON file is created.
461
+
462
+ Returns the archive payload dict with keys: summary, persona_name, date,
463
+ event_count, conversation_count.
464
+ """
465
+ result = await finalize_day(persona_name, date_str)
466
+ data = result.get("full_data")
467
+ if data is None:
468
+ return {"summary": f"No data for {persona_name} on {date_str}"}
469
+
470
+ # Offline/local runs intentionally disable the Qdrant-backed long-term
471
+ # store. Treat this as an explicit, successful skip rather than trying a
472
+ # known-unavailable retriever and turning every midnight handoff into an
473
+ # archive failure.
474
+ from src import config as _cfg
475
+ if not _cfg.SEMANTIC_MEMORY_ENABLED:
476
+ logger.info("[Short_term] semantic archival disabled; skipped %s on %s", persona_name, date_str)
477
+ return {
478
+ "summary": result.get("summary", ""),
479
+ "persona_name": persona_name,
480
+ "date": date_str,
481
+ "event_count": len(data.get("events", [])),
482
+ "conversation_count": len(data.get("conversations", [])),
483
+ "skipped": True,
484
+ }
485
+
486
+ try:
487
+ from src.agents.Long_term import get_retriever
488
+ retriever = get_retriever()
489
+ indexed = await asyncio.to_thread(retriever.index_archive, persona_name, data)
490
+ if indexed <= 0 and data.get("daily_summary"):
491
+ raise RuntimeError("Qdrant did not acknowledge any durable memory records")
492
+ except Exception as exc:
493
+ # The short-term source remains in place so an operator can retry. Do
494
+ # not silently claim archival success when Qdrant is the only durable
495
+ # long-term store.
496
+ logger.error("[Short_term] Qdrant archive failed for %s: %s", persona_name, exc)
497
+ raise
498
+
499
+ logger.info(
500
+ "[Short_term] archived %s on %s into Qdrant (%d events, %d conversations)",
501
+ persona_name, date_str,
502
+ len(data.get("events", [])), len(data.get("conversations", [])),
503
+ )
504
+
505
+ return {
506
+ "summary": result.get("summary", ""),
507
+ "persona_name": persona_name,
508
+ "date": date_str,
509
+ "event_count": len(data.get("events", [])),
510
+ "conversation_count": len(data.get("conversations", [])),
511
+ }
512
+
513
+
514
+ def clear_short_term_data(persona_name: str, date_str: str) -> bool:
515
+ """Delete the short-term file after successful long-term archival.
516
+
517
+ Not called automatically — archival keeps the short-term copy in place so
518
+ the running day is not disturbed. Exposed for maintenance/cleanup use.
519
+ """
520
+ path = _day_file_path(persona_name, date_str)
521
+ if path.exists():
522
+ path.unlink()
523
+ logger.info("Cleared short-term data for %s on %s", persona_name, date_str)
524
+ return True
525
+ return False
526
+
527
+
528
+ # ---------------------------------------------------------------------------
529
+ # MemoryStreamProtocol adapter
530
+ # ---------------------------------------------------------------------------
531
+
532
+ class ShortTermMemoryStream:
533
+ """
534
+ Adapter that wraps Short_term.py's module-level functions as
535
+ MemoryStreamProtocol (defined in tick_graph.py) with sim-date awareness.
536
+
537
+ Usage:
538
+ stream = ShortTermMemoryStream("2026-07-03")
539
+ # Tick graph nodes call:
540
+ stream.add_memory(agent_id, content, importance)
541
+ stream.retrieve_memories(agent_id, query, k)
542
+ """
543
+
544
+ def __init__(self, initial_date: str = "") -> None:
545
+ self._date_str: str = initial_date or datetime.now(timezone.utc).date().isoformat()
546
+
547
+ @property
548
+ def date_str(self) -> str:
549
+ return self._date_str
550
+
551
+ def set_date(self, date_str: str) -> None:
552
+ """Update the simulation date (called before each tick)."""
553
+ self._date_str = date_str
554
+
555
+ def add_memory(self, agent_id: str, content: str, importance: Optional[int] = None) -> None:
556
+ add_memory(agent_id, content, importance, date_str=self._date_str)
557
+
558
+ def retrieve_memories(self, agent_id: str, query: str, k: int = 5) -> list[str]:
559
+ return retrieve_memories(agent_id, query, k, date_str=self._date_str)
backend/src/agents/Single_agent.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main brain / command centre of a single agent.
3
+
4
+ Makes decisions, calls and delegates tasks to sub-modules (day_planner,
5
+ memory, reflection, etc.), and runs the agent's action loop.
6
+
7
+ Exports:
8
+ create_agent_graph() -> CompiledGraph[AgentState]
9
+ A single-agent LangGraph. Currently one node: generate_day_plan.
10
+ Future: execute_tick, reflect, update_memory, conversation.
11
+
12
+ Usage as a library (for the multi-agent orchestrator):
13
+ graph = create_agent_graph()
14
+ result = graph.invoke({
15
+ "persona_name": "parv_singla",
16
+ "persona": {...},
17
+ "current_time": "2026-07-03 06:00",
18
+ })
19
+
20
+ Usage from CLI:
21
+ python Single_agent.py parv_singla
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ BACKEND_ROOT = Path(__file__).resolve().parents[2]
30
+ if str(BACKEND_ROOT) not in sys.path:
31
+ sys.path.append(str(BACKEND_ROOT))
32
+
33
+
34
+ import json
35
+ import time
36
+ from types import SimpleNamespace
37
+ from typing import Optional, TypedDict
38
+
39
+ from langgraph.graph import StateGraph, START, END
40
+
41
+ from src.core.log import get_logger, setup_logging
42
+ setup_logging(run_id="single_agent", console=False)
43
+ from src.config import PERSONALITIES_DIR
44
+ from src.agents import day_planner
45
+
46
+
47
+ logger = get_logger(__name__)
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # State schemas
52
+ # ---------------------------------------------------------------------------
53
+
54
+ class AgentState(TypedDict, total=False):
55
+ """State for a single agent's brain graph."""
56
+ persona_name: str
57
+ persona: dict
58
+ current_time: str
59
+ day_plan: list[dict]
60
+ relevant_memories: list[str]
61
+ yesterday_summary: Optional[str]
62
+ error: Optional[str]
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Single-agent graph
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def retrieve_memories(state: AgentState) -> dict:
70
+ """Node: fetch yesterday's summary and relevant memories from Short_term."""
71
+ from src.core.Short_term import (
72
+ date_from_simulation_time,
73
+ get_yesterday_summary,
74
+ get_relevant_memories,
75
+ )
76
+
77
+ persona_name = state["persona_name"]
78
+ sim_date = date_from_simulation_time(state.get("current_time", "00:00"))
79
+ persona = state.get("persona", {})
80
+
81
+ yesterday_summary = get_yesterday_summary(persona_name, sim_date)
82
+
83
+ traits = persona.get("Traits", persona.get("traits", []))
84
+ query = " ".join(traits) if traits else "daily life"
85
+ memories = get_relevant_memories(persona_name, sim_date, query, k=5)
86
+
87
+ if yesterday_summary:
88
+ logger.info("[Single_agent] %s: loaded yesterday's summary", persona_name)
89
+ if memories:
90
+ logger.info("[Single_agent] %s: loaded %d relevant memories", persona_name, len(memories))
91
+
92
+ return {
93
+ "relevant_memories": memories,
94
+ "yesterday_summary": yesterday_summary,
95
+ }
96
+
97
+
98
+ def generate_day_plan(state: AgentState) -> dict:
99
+ """Node: generate a day plan for this agent by calling day_planner.run()."""
100
+ persona = state["persona"]
101
+ persona_name = state["persona_name"]
102
+ print(f' Name: "{persona_name}", State: "Processing"')
103
+
104
+ try:
105
+ agent = SimpleNamespace(
106
+ persona=persona,
107
+ relevant_memories=state.get("relevant_memories", []),
108
+ yesterday_summary=state.get("yesterday_summary"),
109
+ )
110
+
111
+ result = day_planner.run(agent, {
112
+ "current_time": state.get("current_time", "2026-07-03 06:00"),
113
+ "places": None,
114
+ "persona_name": persona_name,
115
+ })
116
+
117
+ plan_count = len(result.get("day_plan", []))
118
+ print(f' Name: "{persona_name}", State: "Completed" — {plan_count} actions')
119
+
120
+ return {
121
+ "day_plan": result.get("day_plan", []),
122
+ "error": result.get("error"),
123
+ }
124
+ except Exception as exc:
125
+ print(f' Name: "{persona_name}", State: "Failed"')
126
+ logger.error("[Single_agent] %s: failed — %s", persona_name, exc)
127
+ return {"day_plan": [], "error": str(exc)}
128
+
129
+
130
+ _agent_graph = None
131
+
132
+
133
+ def create_agent_graph():
134
+ """Build and return the compiled single-agent brain graph (cached)."""
135
+ global _agent_graph
136
+ if _agent_graph is not None:
137
+ return _agent_graph
138
+
139
+ builder = StateGraph(AgentState)
140
+ builder.add_node("retrieve_memories", retrieve_memories)
141
+ builder.add_node("generate_day_plan", generate_day_plan)
142
+ builder.add_edge(START, "retrieve_memories")
143
+ builder.add_edge("retrieve_memories", "generate_day_plan")
144
+ builder.add_edge("generate_day_plan", END)
145
+
146
+ _agent_graph = builder.compile()
147
+ return _agent_graph
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Plan table printer
152
+ # ---------------------------------------------------------------------------
153
+
154
+ def _print_day_plan(persona_name: str, day_plan: list[dict]) -> None:
155
+ if not day_plan:
156
+ return
157
+ print(f'\n Generated plan for "{persona_name}":')
158
+ print(f' {"Action":25s} {"Start":7s} {"End":7s} {"Location":25s} {"Area":20s}')
159
+ print(f' {"-"*25} {"-"*7} {"-"*7} {"-"*25} {"-"*20}')
160
+ for a in day_plan:
161
+ loc = (a.get("location_id") or "")[:25]
162
+ area = (a.get("sub_area") or "")[:20]
163
+ print(f' {a.get("action", ""):25s} {a.get("start", ""):7s} {a.get("end", ""):7s} {loc:25s} {area:20s}')
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # CLI entry point
168
+ # ---------------------------------------------------------------------------
169
+
170
+ if __name__ == "__main__":
171
+ import argparse
172
+
173
+ parser = argparse.ArgumentParser(description="Run day planner for a single persona")
174
+ parser.add_argument(
175
+ "persona",
176
+ help="Persona name (e.g. parv) or path to a persona JSON file",
177
+ )
178
+ args = parser.parse_args()
179
+
180
+ # Resolve path
181
+ candidate = Path(args.persona)
182
+ if candidate.exists():
183
+ persona_path = candidate
184
+ elif candidate.suffix == ".json":
185
+ matches = sorted(PERSONALITIES_DIR.glob(f"**/{candidate.name}"))
186
+ persona_path = matches[0] if matches else candidate
187
+ else:
188
+ matches = sorted(PERSONALITIES_DIR.glob(f"**/{args.persona}/{args.persona}.json"))
189
+ if not matches:
190
+ matches = sorted(PERSONALITIES_DIR.glob(f"**/{args.persona}.json"))
191
+ if not matches:
192
+ available = sorted({p.parent.name for p in PERSONALITIES_DIR.glob("**/*.json")})
193
+ raise FileNotFoundError(
194
+ f"Could not find persona '{args.persona}'. "
195
+ f"Available: {', '.join(available)}"
196
+ )
197
+ persona_path = matches[0]
198
+
199
+ persona_data = json.loads(persona_path.read_text())
200
+ persona_name = persona_data.get("Name", persona_path.stem)
201
+
202
+ t0 = time.perf_counter()
203
+ print(f'Day planner — running "{persona_name}"\n')
204
+
205
+ graph = create_agent_graph()
206
+ result = graph.invoke({
207
+ "persona_name": persona_name,
208
+ "persona": persona_data,
209
+ "current_time": "2026-07-03 06:00",
210
+ })
211
+
212
+ elapsed = time.perf_counter() - t0
213
+
214
+ print(f"\n{'='*60}")
215
+ if result.get("error"):
216
+ print(f" Failed: {result['error']} | Elapsed: {elapsed:.1f}s")
217
+ else:
218
+ print(f" Completed | Elapsed: {elapsed:.1f}s")
219
+ print(f"{'='*60}")
220
+
221
+ day_plan = result.get("day_plan", [])
222
+ if not result.get("error") and day_plan:
223
+ _print_day_plan(persona_name, day_plan)
backend/src/agents/agents.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ agents/
2
+ holds actual cognitive modules (things that take persona/memory/world-state and produce a real output via LLM calls or logic — day_planner, reflection)
backend/src/agents/autonomy.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Autonomy — schema for the per-minute LLM decision to deviate from the plan.
3
+
4
+ The brain calls this once per agent per minute (when enabled). The LLM
5
+ sees the agent's persona, current plan, and nearby surroundings, then
6
+ decides whether to continue the plan or deviate temporarily.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pydantic import BaseModel
12
+ from typing import Optional
13
+
14
+
15
+ class AutonomyDecision(BaseModel):
16
+ """Structured output from the autonomy LLM call."""
17
+ deviate: bool
18
+ deviation_type: Optional[str] = None
19
+ reason: Optional[str] = None
20
+ duration_minutes: int = 0
backend/src/agents/body.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Body -- the agent's "limbs". The motor layer the brain commands.
3
+
4
+ In the human-like architecture the *brain* (brain.py) does the thinking:
5
+ it perceives, recalls, and decides. It never moves the agent directly.
6
+ Instead it issues motor commands to this Body, which is the only thing that
7
+ actually changes the agent's position and current activity.
8
+
9
+ The Body is a thin, behaviour-preserving adapter around the existing action
10
+ state machine (`AgentActionManager` in Actions.py) -- the proven executor
11
+ that walks paths and steps through the day plan. Wrapping it (rather than
12
+ replacing it) means the body/brain split is a clean architectural layer with
13
+ zero change to how movement and actions actually run.
14
+
15
+ Motor command surface (all 0-LLM):
16
+ - advance(tick) : take the next step of the current plan
17
+ - enter_conversation(name) : freeze into a conversation with someone
18
+ - resume(day_plan) : leave conversation / reload the plan
19
+ Read-only senses of the body's own state:
20
+ - position, current_action, is_last_action
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any, List
26
+
27
+ from src.core.log import get_logger
28
+
29
+ logger = get_logger(__name__)
30
+
31
+
32
+ class BodyController:
33
+ """Wraps one agent's `AgentActionManager` as a commandable motor tool."""
34
+
35
+ def __init__(self, manager: Any) -> None:
36
+ self._manager = manager
37
+
38
+ # -- read-only body state ---------------------------------------------
39
+ @property
40
+ def manager(self) -> Any:
41
+ return self._manager
42
+
43
+ @property
44
+ def position(self):
45
+ return self._manager.position if self._manager else None
46
+
47
+ @property
48
+ def current_action(self):
49
+ return self._manager.current_action if self._manager else None
50
+
51
+ @property
52
+ def is_last_action(self) -> bool:
53
+ return bool(self._manager and self._manager.is_last_action)
54
+
55
+ # -- motor commands ----------------------------------------------------
56
+ def advance(self, tick: int):
57
+ """Advance the manager state machine by one tick."""
58
+ if self._manager is None:
59
+ return None
60
+ return self._manager.tick(tick)
61
+
62
+ def enter_conversation(self, partner_name: str):
63
+ """Freeze the body into a conversation with `partner_name`."""
64
+ if self._manager is None:
65
+ return None
66
+ return self._manager.set_conversation_action(partner_name)
67
+
68
+ def resume(self, day_plan: List[dict]) -> None:
69
+ """Leave conversation mode and (re)load a day plan to execute."""
70
+ if self._manager is not None:
71
+ self._manager.resume_from_conversation(day_plan)
backend/src/agents/brain.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Brain -- the agent's cognition / command centre.
3
+
4
+ Each tick the brain may be called to decide (via LLM) whether the agent
5
+ should continue their current plan or replan, based on novel observations.
6
+ The LLM call is gated: it only fires when the perceive phase detects a change
7
+ in the set of (agent_id, action_description) within 50px.
8
+
9
+ When no novel observations exist, the brain returns "continue" without an LLM
10
+ call — the agent follows its existing plan.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from pydantic import BaseModel
19
+
20
+ from src.core.log import get_logger
21
+ from src.config import TEMPERATURE
22
+ from src.llm.gemini_client import call_gemini, ProviderFailureError
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ class TickDecision(BaseModel):
28
+ decision: str # "continue" or "replan"
29
+ reason: str
30
+
31
+
32
+ DECISION_SYSTEM_PROMPT = """You are the decision-making layer for a simulated college student at IIT Ropar.
33
+ You are given the persona's current situation and a list of nearby agents they just noticed.
34
+ Decide whether they should continue their current activity or replan their day.
35
+
36
+ Rules:
37
+ - "continue" — the agent stays on their current plan. This is the default for routine observations.
38
+ - "replan" — the agent should regenerate its remaining-day plan. Use this only for genuinely
39
+ significant events: a close friend appears, an urgent opportunity arises, or the current
40
+ situation conflicts with their goals.
41
+
42
+ Be conservative. Most observations (someone walking past, someone studying nearby) do NOT
43
+ warrant a replan. Only replan when this specific persona would clearly change their plans.
44
+
45
+ Respond ONLY with the requested JSON schema — no extra commentary."""
46
+
47
+
48
+ class Brain:
49
+ """Per-agent decision-maker that commands a BodyController."""
50
+
51
+ def __init__(self, agent_id: str, persona_name: str, body: Any,
52
+ persona: Optional[dict] = None) -> None:
53
+ self.agent_id = agent_id
54
+ self.persona_name = persona_name
55
+ self.persona = persona or {}
56
+ self.body = body
57
+
58
+ def decide_tick(self, tick: int, hhmm: str, observations: List,
59
+ day_plan: List, replan_count: int = 0,
60
+ max_replans: int = 3,
61
+ energy_level: float = 0.5, emotion_state: float = 0.5,
62
+ relevant_memories: Optional[List[str]] = None) -> TickDecision:
63
+ """LLM-based decision: continue or replan?
64
+
65
+ Called only when the perceive phase detected novel observations.
66
+ If replan_count >= max_replans, returns "continue" without an LLM call.
67
+ """
68
+ if replan_count >= max_replans:
69
+ logger.info(
70
+ "[Brain] '%s' replan count exhausted (%d/%d) — continuing plan",
71
+ self.persona_name, replan_count, max_replans,
72
+ )
73
+ return TickDecision(decision="continue", reason="replan budget exhausted")
74
+
75
+ current_action_desc = "nothing"
76
+ if self.body.current_action:
77
+ current_action_desc = (
78
+ f"{self.body.current_action.description} at {self.body.current_action.location_id or '?'}"
79
+ )
80
+
81
+ plan_summary = self._plan_summary(day_plan, hhmm)
82
+ obs_text = self._observations_text(observations)
83
+ memory_text = "\n".join(f"- {memory}" for memory in (relevant_memories or [])[:4]) or "(nothing relevant recalled)"
84
+
85
+ user_prompt = (
86
+ f"You are {self.persona_name} at IIT Ropar.\n"
87
+ f"Personality: {self.persona.get('innate', '?')}\n"
88
+ f"Hobbies: {self.persona.get('hobbies', '?')}\n"
89
+ f"Goals: {self.persona.get('goals', '?')}\n"
90
+ f"Current activity: {current_action_desc}\n"
91
+ f"Time: {hhmm}\n\n"
92
+ f"Remaining plan:\n{plan_summary}\n\n"
93
+ f"Nearby people:\n{obs_text}\n\n"
94
+ f"Relevant long-term memories:\n{memory_text}\n\n"
95
+ f"Energy level: {energy_level:.2f}/1.0\n"
96
+ f"Emotion state: {emotion_state:.2f}/1.0\n"
97
+ 'Return {"decision": "continue"} to stay on the current plan, or '
98
+ '{"decision": "replan", "reason": "..."} to regenerate the remaining day plan. '
99
+ "Only replan if this genuinely warrants a change of plans."
100
+ )
101
+
102
+ try:
103
+ result: TickDecision = call_gemini(
104
+ system_prompt=DECISION_SYSTEM_PROMPT,
105
+ user_prompt=user_prompt,
106
+ schema=TickDecision,
107
+ complexity="default",
108
+ temperature=TEMPERATURE,
109
+ )
110
+ logger.info(
111
+ "[Brain] '%s' decide_tick: decision=%s (%s)",
112
+ self.persona_name, result.decision, result.reason,
113
+ )
114
+ return result
115
+ except ProviderFailureError:
116
+ raise
117
+ except Exception as e:
118
+ logger.warning(
119
+ "[Brain] '%s' decide_tick LLM failed: %s — continuing plan",
120
+ self.persona_name, e,
121
+ )
122
+ return TickDecision(decision="continue", reason="LLM call failed, defaulting to continue")
123
+
124
+ def act(self, tick: int):
125
+ """Phase 2 (Act): advance the body's state machine."""
126
+ return self.body.advance(tick)
127
+
128
+ # -- helpers -----------------------------------------------------------
129
+
130
+ @staticmethod
131
+ def _plan_summary(plan: List[Dict], current_hhmm: str) -> str:
132
+ if not plan:
133
+ return " (no plan remaining)"
134
+ lines = []
135
+ for entry in sorted(plan, key=lambda e: e.get("start", "00:00")):
136
+ if entry.get("start", "00:00") >= current_hhmm:
137
+ marker = " ⇐ NOW" if entry.get("start") == current_hhmm else ""
138
+ lines.append(
139
+ f" {entry.get('start', '??')}-{entry.get('end', '??')} "
140
+ f"{entry.get('action', '?')} at {entry.get('location_id', '?')}{marker}"
141
+ )
142
+ return "\n".join(lines) if lines else " (no plan remaining)"
143
+
144
+ @staticmethod
145
+ def _observations_text(observations: List) -> str:
146
+ if not observations:
147
+ return " (no one nearby)"
148
+ lines = []
149
+ for obs in observations:
150
+ if hasattr(obs, "model_dump"):
151
+ obs = obs.model_dump()
152
+ if not isinstance(obs, dict):
153
+ lines.append(f" - {obs}")
154
+ continue
155
+ if "description" in obs and "current_action" not in obs:
156
+ location = obs.get("location_id", "?")
157
+ lines.append(f" - {obs['description']} at {location}")
158
+ continue
159
+ name = obs.get("name", obs.get("agent_id", "?"))
160
+ action = obs.get("current_action", "?")
161
+ pos = obs.get("position", {})
162
+ lines.append(f" - {name} at ({pos.get('x', '?')}, {pos.get('y', '?')}) — {action}")
163
+ return "\n".join(lines)
164
+
165
+
166
+ if __name__ == "__main__":
167
+ from src.core.log import setup_logging
168
+ setup_logging(run_id="brain_test", console=True)
169
+
170
+ class _FakeManager:
171
+ def __init__(self):
172
+ self.position = "P"
173
+ self.current_action = None
174
+ self.is_last_action = False
175
+ self.ticks = []
176
+ def tick(self, t):
177
+ self.ticks.append(t)
178
+ return f"action@{t}"
179
+
180
+ from src.agents.body import BodyController
181
+ fm = _FakeManager()
182
+ brain = Brain("a", "Agent A", BodyController(fm))
183
+ out = brain.act(5)
184
+ assert out == "action@5", out
185
+ assert fm.ticks == [5]
186
+ print("brain.py sanity check passed (0 LLM, pure body.advance).")
backend/src/agents/conversation.py ADDED
@@ -0,0 +1,805 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Conversation -- generates dialogue between two agents via a single LLM call.
3
+
4
+ The WorldEngine calls `generate_conversation()` when two agents share a
5
+ location_id, are both in compatible actions (not sleeping), and neither is
6
+ already mid-conversation.
7
+
8
+ The single LLM call produces the full conversation (messages, summary,
9
+ duration, sentiment, relationship delta). Both agents get their current
10
+ action overwritten to "Chatting with X" for the duration, then naturally
11
+ replan via the tick graph when it expires.
12
+
13
+ Usage:
14
+ from src.agents.conversation import generate_conversation, RelationshipMatrix
15
+
16
+ matrix = RelationshipMatrix()
17
+ result = generate_conversation(
18
+ agent_a_id="parv_singla",
19
+ agent_b_id="tanishq",
20
+ persona_a=gray_wilder_persona,
21
+ persona_b=jules_persona,
22
+ plan_a=gray_wilder_plan,
23
+ plan_b=jules_plan,
24
+ action_a=gray_wilder_current_action,
25
+ action_b=jules_current_action,
26
+ rel_a_to_b=matrix.get("parv_singla", "tanishq"),
27
+ rel_b_to_a=matrix.get("tanishq", "parv_singla"),
28
+ location_id="mess",
29
+ current_hhmm="08:05",
30
+ )
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import sys
36
+ from pathlib import Path
37
+
38
+ BACKEND_ROOT = Path(__file__).resolve().parents[2]
39
+ if str(BACKEND_ROOT) not in sys.path:
40
+ sys.path.append(str(BACKEND_ROOT))
41
+
42
+
43
+ import json
44
+ import os
45
+ import threading
46
+ import time
47
+ from typing import Any, Dict, List, Literal, Optional, Tuple
48
+
49
+ from pydantic import BaseModel, Field
50
+
51
+ from src.core.log import get_logger
52
+ from src.core.world_state import CurrentAction
53
+ from src.llm.gemini_client import call_gemini, ProviderFailureError
54
+ from src.config import CONVERSATION_TEMPERATURE
55
+
56
+ logger = get_logger(__name__)
57
+
58
+
59
+ # The engine normally owns a single relationship matrix, but a day handoff or
60
+ # a standalone conversation job can briefly create another instance pointing
61
+ # at the same file. Coordinate those instances in-process and make each save
62
+ # an atomic replacement so unrelated pairs cannot overwrite one another.
63
+ _MATRIX_LOCK_GUARD = threading.Lock()
64
+ _MATRIX_LOCKS: Dict[Path, threading.RLock] = {}
65
+
66
+ # Persona IDs are derived from display names. These aliases preserve saved
67
+ # relationship scores when the two seed personas were renamed, while ensuring
68
+ # a checkpoint restore cannot recreate obsolete matrix keys on disk.
69
+ _LEGACY_AGENT_IDS = {
70
+ "aditi_menon": "riya_murarka",
71
+ "meher_bansal": "lavanya_sharma",
72
+ }
73
+
74
+
75
+ def _matrix_lock(path: Path) -> threading.RLock:
76
+ resolved = path.resolve()
77
+ with _MATRIX_LOCK_GUARD:
78
+ return _MATRIX_LOCKS.setdefault(resolved, threading.RLock())
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Constants
83
+ # ---------------------------------------------------------------------------
84
+
85
+ BLOCKED_KEYWORDS = ["sleep"]
86
+ COOLDOWN_TICKS = 30
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Output schema
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ class Message(BaseModel):
95
+ """One line of dialogue in a conversation."""
96
+ speaker: str = Field(min_length=1)
97
+ text: str = Field(min_length=1, max_length=240)
98
+
99
+
100
+ class ConversationResult(BaseModel):
101
+ """Full output of a single conversation generation call."""
102
+ messages: List[Message] = Field(min_length=4, max_length=12)
103
+ summary: str = Field(min_length=1, max_length=360)
104
+ duration_minutes: int = Field(ge=6, le=20)
105
+ sentiment: Literal["positive", "neutral", "negative"]
106
+ relationship_delta: float = Field(ge=-0.15, le=0.15)
107
+ # Folded-in replan decision: avoids a separate 4-call day-plan regeneration
108
+ # per agent after every conversation. True only when the conversation
109
+ # genuinely changes an agent's immediate intentions.
110
+ should_replan: bool = False
111
+ plan_change: Optional[str] = None
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Relationship matrix -- directional, expressive social context
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ class RelationshipRecord(BaseModel):
120
+ """One student's grounded point of view toward another student."""
121
+
122
+ score: float = Field(default=0.5, ge=-1.0, le=1.0)
123
+ tags: List[str] = Field(default_factory=list, max_length=12)
124
+ context: str = Field(default="", max_length=500)
125
+
126
+
127
+ class RelationshipMatrix:
128
+ """
129
+ Asymmetric relationship scores between agent pairs.
130
+
131
+ Keys are f"{agent_a_id}->{agent_b_id}". A->B may differ from B->A.
132
+ Scores are floats in [-1.0, 1.0] where:
133
+ -1.0 = hostile / strongly negative
134
+ 0.0 = strangers / no relationship
135
+ 0.5 = neutral acquaintance (default)
136
+ 1.0 = very close / best friends
137
+ """
138
+
139
+ def __init__(self, path: Optional[Path] = None):
140
+ from src.config import ENVIRONMENT_DIR
141
+
142
+ self.path: Path = path or ENVIRONMENT_DIR / "relationship_matrix.json"
143
+ self._matrix: Dict[str, RelationshipRecord] = {}
144
+ self._pending_deltas: List[tuple[str, float]] = []
145
+ self.load()
146
+
147
+ def _pair_key(self, a: str, b: str) -> str:
148
+ return f"{a}->{b}"
149
+
150
+ def load(self) -> None:
151
+ with _matrix_lock(self.path):
152
+ raw = {}
153
+ try:
154
+ raw = json.loads(self.path.read_text(encoding="utf-8")) if self.path.exists() else {}
155
+ except (OSError, ValueError, json.JSONDecodeError):
156
+ raw = {}
157
+ self._schema_version = int(raw.get("schema_version", 1)) if isinstance(raw, dict) else 1
158
+ self._matrix = self._normalise(raw) if raw else {}
159
+ self._pending_deltas.clear()
160
+ if self._matrix:
161
+ logger.info("[RelationshipMatrix] loaded %d pairs from %s", len(self._matrix), self.path.name)
162
+ else:
163
+ logger.warning("[RelationshipMatrix] %s not found -- starting empty", self.path)
164
+
165
+ @staticmethod
166
+ def _normalise(raw: Dict[str, Any]) -> Dict[str, RelationshipRecord]:
167
+ """Accept the legacy flat score map and the structured v1 document."""
168
+ pairs = raw.get("relationships", raw)
169
+ if not isinstance(pairs, dict):
170
+ raise ValueError("relationship entries must be a JSON object")
171
+ result: Dict[str, RelationshipRecord] = {}
172
+ for key, value in pairs.items():
173
+ if "->" not in key:
174
+ continue
175
+ source, target = key.split("->", 1)
176
+ canonical_key = f"{_LEGACY_AGENT_IDS.get(source, source)}->{_LEGACY_AGENT_IDS.get(target, target)}"
177
+ # Prefer a current seed record over its legacy alias when both are
178
+ # present in a migrated file. Legacy-only checkpoint data still
179
+ # maps to the current key and therefore preserves its score.
180
+ if canonical_key != key and canonical_key in result:
181
+ continue
182
+ result[canonical_key] = RelationshipRecord.model_validate(
183
+ {"score": value} if isinstance(value, (int, float)) else value
184
+ )
185
+ return result
186
+
187
+ def _read_disk(self) -> Dict[str, RelationshipRecord]:
188
+ if not self.path.exists():
189
+ return {}
190
+ try:
191
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
192
+ if not isinstance(raw, dict):
193
+ raise ValueError("relationship matrix must be a JSON object")
194
+ return self._normalise(raw)
195
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
196
+ logger.warning(
197
+ "[RelationshipMatrix] unreadable %s; starting empty: %s",
198
+ self.path.name, exc,
199
+ )
200
+ return {}
201
+
202
+ def _write_atomic(self, matrix: Dict[str, RelationshipRecord]) -> None:
203
+ self.path.parent.mkdir(parents=True, exist_ok=True)
204
+ temporary = self.path.with_suffix(self.path.suffix + ".tmp")
205
+ document = {
206
+ "schema_version": 2,
207
+ "relationships": {
208
+ key: value.model_dump() for key, value in sorted(matrix.items())
209
+ },
210
+ }
211
+ temporary.write_text(json.dumps(document, indent=2), encoding="utf-8")
212
+ for attempt in range(3):
213
+ try:
214
+ os.replace(temporary, self.path)
215
+ return
216
+ except PermissionError:
217
+ if attempt == 2:
218
+ raise
219
+ time.sleep(0.05 * (attempt + 1))
220
+
221
+ def save(self) -> None:
222
+ # Reload while holding the file's lock, then replay only this
223
+ # instance's changes. Two independent conversations updating
224
+ # different pairs therefore compose instead of last-writer-wins.
225
+ with _matrix_lock(self.path):
226
+ merged = self._read_disk()
227
+ for key, delta in self._pending_deltas:
228
+ record = merged.get(key, RelationshipRecord())
229
+ record.score = round(max(-1.0, min(1.0, record.score + delta)), 4)
230
+ merged[key] = record
231
+ self._write_atomic(merged)
232
+ self._matrix = merged
233
+ self._pending_deltas.clear()
234
+ logger.info("[RelationshipMatrix] saved %d pairs to %s", len(self._matrix), self.path.name)
235
+
236
+ def get(self, a: str, b: str) -> float:
237
+ """Relationship of agent `a` toward agent `b`. Defaults to 0.5 (neutral)."""
238
+ return self._matrix.get(self._pair_key(a, b), RelationshipRecord()).score
239
+
240
+ def remove_agent(self, agent_id: str) -> None:
241
+ """Drop every directional relationship involving one retired agent."""
242
+ self._matrix = {
243
+ key: record for key, record in self._matrix.items()
244
+ if agent_id not in key.split("->", 1)
245
+ }
246
+ self._pending_deltas.clear()
247
+ with _matrix_lock(self.path):
248
+ self._write_atomic(self._matrix)
249
+
250
+ def seed(self, source: str, target: str, record: RelationshipRecord) -> None:
251
+ """Set a relationship baseline during a stopped roster operation."""
252
+ self._matrix[self._pair_key(source, target)] = record
253
+ with _matrix_lock(self.path):
254
+ self._write_atomic(self._matrix)
255
+
256
+ def replace_display_name(self, old_name: str, new_name: str) -> None:
257
+ """Update display-name references without changing stable agent IDs."""
258
+ for record in self._matrix.values():
259
+ record.context = record.context.replace(old_name, new_name)
260
+ with _matrix_lock(self.path):
261
+ self._write_atomic(self._matrix)
262
+
263
+ def ensure_grounded_seed(self, agents: List[Dict[str, Any]]) -> None:
264
+ """Upgrade the original uniform v1 matrix to a directional social baseline.
265
+
266
+ Kept here rather than in prompts so the relationships remain data the
267
+ conversation engine can inspect and checkpoint deterministically.
268
+ """
269
+ names = {str(item["agent_id"]): str(item.get("persona_name") or item["agent_id"]) for item in agents}
270
+ expected_keys = {
271
+ self._pair_key(source, target)
272
+ for source in names
273
+ for target in names
274
+ if source != target
275
+ }
276
+ # A roster can be replaced outside a running world. Version 2 means
277
+ # the record format is current, not that its agent IDs are necessarily
278
+ # current. Preserve valid directional context, prune retired IDs and
279
+ # seed only missing pairs.
280
+ if getattr(self, "_schema_version", 1) >= 2 and set(self._matrix) == expected_keys:
281
+ return
282
+ profiles = {
283
+ "ankit": ("table tennis and hardware tinkering", "patient practical advice"),
284
+ "ansh_batra": ("football and visual storytelling", "enthusiastic plans"),
285
+ "anubhav_prasad": ("co-op games and late-night chai", "calm listening"),
286
+ "ghanisht_kaushal": ("badminton and blunt movie opinions", "reliable follow-through"),
287
+ "gurnoor_singh": ("photography and road-trip playlists", "big social energy"),
288
+ "lavanya_sharma": ("basketball and debate", "direct feedback"),
289
+ "parv_singla": ("running and music", "impulsive invitations"),
290
+ "riya_murarka": ("reading circles and long runs", "clear boundaries"),
291
+ "saksham": ("badminton and strategy games", "dry humour"),
292
+ "tanishq": ("strategy games and playlists", "quiet, improving confidence"),
293
+ }
294
+ special = {
295
+ ("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."),
296
+ ("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."),
297
+ ("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."),
298
+ ("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."),
299
+ ("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."),
300
+ ("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."),
301
+ ("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."),
302
+ ("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."),
303
+ }
304
+ rebuilt: Dict[str, RelationshipRecord] = {}
305
+ ids = sorted(names)
306
+ for source in ids:
307
+ for target in ids:
308
+ if source == target:
309
+ continue
310
+ existing = self._matrix.get(self._pair_key(source, target))
311
+ override = special.get((source, target))
312
+ if existing is not None:
313
+ rebuilt[self._pair_key(source, target)] = existing
314
+ continue
315
+ if override:
316
+ score, tags, context = override
317
+ else:
318
+ source_interest = profiles.get(source, ("campus routines", "steady conversation"))[0]
319
+ target_style = profiles.get(target, ("campus routines", "steady conversation"))[1]
320
+ score = 0.30 + ((sum(map(ord, source + target)) % 33) / 100)
321
+ tags = ["campus-acquaintance", "low-pressure"]
322
+ 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."
323
+ rebuilt[self._pair_key(source, target)] = RelationshipRecord(score=score, tags=tags, context=context)
324
+ with _matrix_lock(self.path):
325
+ self._matrix = rebuilt
326
+ self._pending_deltas.clear()
327
+ self._schema_version = 2
328
+ self._write_atomic(rebuilt)
329
+
330
+ def context(self, a: str, b: str) -> RelationshipRecord:
331
+ """Return a copy of the expressive directed relationship record."""
332
+ return self._matrix.get(self._pair_key(a, b), RelationshipRecord()).model_copy(deep=True)
333
+
334
+ def update(self, a: str, b: str, delta: float) -> None:
335
+ """Adjust relationship of `a` toward `b` by `delta`, clamped to [-1, 1]."""
336
+ key = self._pair_key(a, b)
337
+ current = self.get(a, b)
338
+ new_val = max(-1.0, min(1.0, current + delta))
339
+ record = self._matrix.get(key, RelationshipRecord())
340
+ record.score = round(new_val, 4)
341
+ self._matrix[key] = record
342
+ self._pending_deltas.append((key, delta))
343
+ logger.info("[RelationshipMatrix] %s: %.2f -> %.2f (delta=%.2f)", key, current, new_val, delta)
344
+
345
+ def snapshot(self) -> Dict[str, Any]:
346
+ """Return a JSON-safe copy for the simulation checkpoint."""
347
+ return {"schema_version": 2, "relationships": {
348
+ key: value.model_dump() for key, value in self._matrix.items()
349
+ }}
350
+
351
+ def restore(self, matrix: Dict[str, Any]) -> None:
352
+ """Restore scores from a checkpoint while retaining base social context.
353
+
354
+ Older checkpoints only contain a numeric matrix. Their scores are
355
+ authoritative runtime state, but they must not erase the structured
356
+ tags/context seeded in the current relationship data file.
357
+ """
358
+ with _matrix_lock(self.path):
359
+ base = self._read_disk()
360
+ checkpoint = self._normalise(matrix)
361
+ for key, record in checkpoint.items():
362
+ if key in base:
363
+ base[key].score = record.score
364
+ else:
365
+ base[key] = record
366
+ self._matrix = base
367
+ self._pending_deltas.clear()
368
+ self._write_atomic(self._matrix)
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # Helper: check if an action is sleep-blocked
373
+ # ---------------------------------------------------------------------------
374
+
375
+
376
+ def _is_action_blocked(action: CurrentAction) -> bool:
377
+ """Return True if the agent's current action prevents conversation."""
378
+ desc = action.description.lower()
379
+ return any(kw in desc for kw in BLOCKED_KEYWORDS)
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # Prompt builder
384
+ # ---------------------------------------------------------------------------
385
+
386
+
387
+ SYSTEM_PROMPT = """You are a dialogue writer for a campus life simulation at IIT Ropar.
388
+ Two student personas with distinct personalities and daily schedules have encountered each other.
389
+
390
+ Generate a short, natural conversation between them based on:
391
+ - Their personalities, hobbies, and goals
392
+ - What each is currently doing at this moment
393
+ - Their day plans (what they've done today and what's coming up next)
394
+ - Their existing relationship with each other
395
+ - The current time of day and location on campus
396
+ - What each might have recently experienced or remembers about the other
397
+
398
+ Rules:
399
+ - Start naturally from the current situation and surroundings
400
+ - Write 4-12 alternating dialogue lines, each 4-25 words
401
+ - Choose a duration of 6-20 simulated minutes that fits the dialogue
402
+ - Match tone to personality (friendly, awkward, casual, competitive, etc.)
403
+ - Each message must have a "speaker" (the agent's full name) and "text" (what they say)
404
+ - The conversation must have a natural ending
405
+ - Reference the time of day and campus context naturally in the dialogue
406
+ - Return ONLY valid JSON matching the schema -- no extra text or commentary"""
407
+
408
+
409
+ def _personality_summary(persona: Dict[str, Any]) -> str:
410
+ """Condense a persona dict into a 2-3 line readable summary."""
411
+ parts = [
412
+ f"Name: {persona.get('Name', '?')}",
413
+ f"Branch: {persona.get('Branch', '?')}",
414
+ f"Personality: {persona.get('innate', '?')}",
415
+ f"Hobbies: {persona.get('hobbies', '?')}",
416
+ f"Goals: {persona.get('goals', '?')}",
417
+ ]
418
+ return "\n".join(parts)
419
+
420
+
421
+ def _deprecated_plan_summary(plan: List[Dict[str, Any]], current_hhmm: str) -> str:
422
+ """Deprecated compatibility helper; retained only for external callers."""
423
+ if not plan:
424
+ return " (no plan)"
425
+
426
+ lines = []
427
+ for entry in sorted(plan, key=lambda e: e.get("start", "00:00")):
428
+ marker = " ⇐ NOW" if entry.get("start") == current_hhmm else ""
429
+ lines.append(
430
+ f" {entry.get('start', '??')}-{entry.get('end', '??')} "
431
+ f"{entry.get('action', '?')} at {entry.get('location_id', '?')}{marker}"
432
+ )
433
+ return "\n".join(lines)
434
+
435
+
436
+ def _plan_summary(plan: List[Dict[str, Any]], current_hhmm: str) -> str:
437
+ """Render a plan and identify the entry that covers the current time."""
438
+ if not plan:
439
+ return " (no plan)"
440
+
441
+ def minutes(value: str) -> int:
442
+ hour, minute = value.split(":")
443
+ return int(hour) * 60 + int(minute)
444
+
445
+ now = minutes(current_hhmm)
446
+ lines = []
447
+ for entry in sorted(plan, key=lambda item: item.get("start", "00:00")):
448
+ try:
449
+ active = minutes(entry.get("start", "00:00")) <= now < minutes(entry.get("end", "24:00"))
450
+ except (TypeError, ValueError):
451
+ active = False
452
+ marker = " [NOW]" if active else ""
453
+ lines.append(
454
+ f" {entry.get('start', '??')}-{entry.get('end', '??')} "
455
+ f"{entry.get('action', '?')} at {entry.get('location_id', '?')}{marker}"
456
+ )
457
+ return "\n".join(lines)
458
+
459
+
460
+ def _build_prompts(
461
+ agent_a_id: str,
462
+ agent_b_id: str,
463
+ persona_a: Dict[str, Any],
464
+ persona_b: Dict[str, Any],
465
+ plan_a: List[Dict[str, Any]],
466
+ plan_b: List[Dict[str, Any]],
467
+ action_a: CurrentAction,
468
+ action_b: CurrentAction,
469
+ rel_a_to_b: float,
470
+ rel_b_to_a: float,
471
+ location_id: str,
472
+ current_hhmm: str,
473
+ memories_a: Optional[List[str]] = None,
474
+ memories_b: Optional[List[str]] = None,
475
+ energy_a: float = 0.5, emotion_a: float = 0.5,
476
+ energy_b: float = 0.5, emotion_b: float = 0.5,
477
+ relationship_context_a: str = "",
478
+ relationship_context_b: str = "",
479
+ ) -> Tuple[str, str]:
480
+ """Build system and user prompts for the conversation LLM call."""
481
+
482
+ def _mem_block(mems: Optional[List[str]]) -> str:
483
+ if not mems:
484
+ return "(nothing notable recalled)"
485
+ return "\n".join(f" - {m}" for m in mems[:4])
486
+ def _rel_label(score: float) -> str:
487
+ if score >= 0.8:
488
+ return "close friends"
489
+ elif score >= 0.6:
490
+ return "friendly"
491
+ elif score >= 0.4:
492
+ return "neutral acquaintances"
493
+ elif score >= 0.2:
494
+ return "distant / barely know each other"
495
+ else:
496
+ return "negative / strained"
497
+
498
+ hour = int(current_hhmm.split(":")[0]) if ":" in current_hhmm else 12
499
+ if hour < 6:
500
+ time_desc = "early morning — campus is quiet, most students are asleep"
501
+ elif hour < 10:
502
+ time_desc = "morning — students heading to breakfast or first classes"
503
+ elif hour < 12:
504
+ time_desc = "late morning — classes are in session around campus"
505
+ elif hour < 14:
506
+ time_desc = "lunchtime — the mess and cafeterias are busy"
507
+ elif hour < 17:
508
+ time_desc = "afternoon — afternoon classes and study sessions"
509
+ elif hour < 19:
510
+ time_desc = "late afternoon — some students heading to sports or hobbies"
511
+ elif hour < 21:
512
+ time_desc = "evening — dinner time, socializing on campus"
513
+ elif hour < 23:
514
+ time_desc = "late evening — students heading back to hostels"
515
+ else:
516
+ time_desc = "night — campus is winding down"
517
+
518
+ def _emotion_label(v: float) -> str:
519
+ if v >= 0.9: return "Extremely Joyful"
520
+ if v >= 0.7: return "Very Happy"
521
+ if v > 0.5: return "Happy"
522
+ if v >= 0.3: return "Neutral"
523
+ if v >= 0.1: return "Sad"
524
+ return "Extremely Sad"
525
+
526
+ user = f"""Generate a conversation between two students at IIT Ropar.
527
+
528
+ ── AGENT: {agent_a_id} ──────────────────────
529
+ {_personality_summary(persona_a)}
530
+ Current action: "{action_a.description}"
531
+ Today's plan:
532
+ {_plan_summary(plan_a, current_hhmm)}
533
+ Energy: {energy_a:.2f}/1.0 | Emotion: {emotion_a:.2f}/1.0 ({_emotion_label(emotion_a)})
534
+
535
+ ── AGENT: {agent_b_id} ──────────────────────
536
+ {_personality_summary(persona_b)}
537
+ Current action: "{action_b.description}"
538
+ Today's plan:
539
+ {_plan_summary(plan_b, current_hhmm)}
540
+ Energy: {energy_b:.2f}/1.0 | Emotion: {emotion_b:.2f}/1.0 ({_emotion_label(emotion_b)})
541
+
542
+ ── CONTEXT ──────────────────────
543
+ Location: {location_id}
544
+ Current time: {current_hhmm} ({time_desc})
545
+ Relationship notes: A sees B as {relationship_context_a or 'an acquaintance'}; B sees A as {relationship_context_b or 'an acquaintance'}.
546
+ {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)})
547
+ {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)})
548
+
549
+ What {persona_a.get('Name', agent_a_id)} remembers about {persona_b.get('Name', agent_b_id)}:
550
+ {_mem_block(memories_a)}
551
+ What {persona_b.get('Name', agent_b_id)} remembers about {persona_a.get('Name', agent_a_id)}:
552
+ {_mem_block(memories_b)}
553
+
554
+ ── OUTPUT ─────────────��─────────
555
+ Return a JSON object with:
556
+ - "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
557
+ - "summary": one-sentence summary of what they talked about
558
+ - "duration_minutes": integer from 6 to 20 that matches the amount of dialogue
559
+ - "sentiment": "positive" | "neutral" | "negative"
560
+ - "relationship_delta": float between -0.15 and 0.15 (how this conversation changes their relationship)
561
+ - "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.
562
+ - "plan_change": short string describing the change if should_replan is true, else null"""
563
+
564
+ return SYSTEM_PROMPT, user
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # Main entry point
569
+ # ---------------------------------------------------------------------------
570
+
571
+
572
+ def generate_conversation(
573
+ agent_a_id: str,
574
+ agent_b_id: str,
575
+ persona_a: Dict[str, Any],
576
+ persona_b: Dict[str, Any],
577
+ plan_a: List[Dict[str, Any]],
578
+ plan_b: List[Dict[str, Any]],
579
+ action_a: CurrentAction,
580
+ action_b: CurrentAction,
581
+ rel_a_to_b: float,
582
+ rel_b_to_a: float,
583
+ location_id: str,
584
+ current_hhmm: str,
585
+ memories_a: Optional[List[str]] = None,
586
+ memories_b: Optional[List[str]] = None,
587
+ energy_a: float = 0.5, emotion_a: float = 0.5,
588
+ energy_b: float = 0.5, emotion_b: float = 0.5,
589
+ relationship_context_a: str = "",
590
+ relationship_context_b: str = "",
591
+ ) -> Optional[ConversationResult]:
592
+ """
593
+ Generate a conversation between two agents via a single Gemini call.
594
+
595
+ Returns None if the LLM call fails (logged, not raised) so the
596
+ WorldEngine can continue the tick without crashing.
597
+ """
598
+ system_prompt, user_prompt = _build_prompts(
599
+ agent_a_id, agent_b_id,
600
+ persona_a, persona_b,
601
+ plan_a, plan_b,
602
+ action_a, action_b,
603
+ rel_a_to_b, rel_b_to_a,
604
+ location_id, current_hhmm,
605
+ memories_a, memories_b,
606
+ energy_a=energy_a, emotion_a=emotion_a,
607
+ energy_b=energy_b, emotion_b=emotion_b,
608
+ relationship_context_a=relationship_context_a,
609
+ relationship_context_b=relationship_context_b,
610
+ )
611
+
612
+ logger.info(
613
+ "[Conversation] generating conversation between '%s' and '%s' at %s (%s)",
614
+ agent_a_id, agent_b_id, location_id, current_hhmm,
615
+ )
616
+
617
+ try:
618
+ result: ConversationResult = call_gemini(
619
+ system_prompt=system_prompt,
620
+ user_prompt=user_prompt,
621
+ schema=ConversationResult,
622
+ complexity="default",
623
+ temperature=CONVERSATION_TEMPERATURE,
624
+ )
625
+ logger.info(
626
+ "[Conversation] generated %d messages, %d min, sentiment=%s, delta=%.2f",
627
+ len(result.messages), result.duration_minutes,
628
+ result.sentiment, result.relationship_delta,
629
+ )
630
+ return result
631
+
632
+ except ProviderFailureError:
633
+ raise
634
+ except Exception:
635
+ logger.exception(
636
+ "[Conversation] LLM call failed for '%s' <-> '%s' -- skipping conversation",
637
+ agent_a_id, agent_b_id,
638
+ )
639
+ return None
640
+
641
+
642
+ # ---------------------------------------------------------------------------
643
+ # Self-test
644
+ # ---------------------------------------------------------------------------
645
+
646
+ if __name__ == "__main__":
647
+ import argparse
648
+
649
+ from src.core.log import setup_logging
650
+ setup_logging(run_id="conversation_test", console=True)
651
+
652
+ from src.config import PERSONALITIES_DIR
653
+ from src.agents.Short_term import load_day_plan, date_from_simulation_time, append_conversation
654
+
655
+ parser = argparse.ArgumentParser(
656
+ description="Conversation module self-test -- generate a conversation between two personas"
657
+ )
658
+ parser.add_argument("persona_a", nargs="?", default="parv_singla", help="First persona name")
659
+ parser.add_argument("persona_b", nargs="?", default="tanishq", help="Second persona name")
660
+ parser.add_argument(
661
+ "--current-time", default="2026-07-03 08:00",
662
+ help="Simulation time (default: 2026-07-03 08:00)",
663
+ )
664
+ args = parser.parse_args()
665
+
666
+ def _resolve_persona(name: str) -> tuple[Path, Dict[str, Any]]:
667
+ """Find a persona JSON by name, return (path, data)."""
668
+ matches = sorted(PERSONALITIES_DIR.glob(f"**/{name}/{name}.json"))
669
+ if not matches:
670
+ matches = sorted(PERSONALITIES_DIR.glob(f"**/{name}.json"))
671
+ if not matches:
672
+ available = sorted({p.parent.name for p in PERSONALITIES_DIR.glob("**/*.json")})
673
+ print(f"Persona '{name}' not found. Available: {', '.join(available)}")
674
+ sys.exit(1)
675
+ return matches[0], json.loads(matches[0].read_text())
676
+
677
+ def _find_action_for_time(
678
+ plan: List[Dict[str, Any]], hhmm: str
679
+ ) -> Optional[Dict[str, Any]]:
680
+ """Find the day plan entry covering HH:MM."""
681
+ def _to_mins(t: str) -> int:
682
+ h, m = t.split(":")
683
+ return int(h) * 60 + int(m)
684
+
685
+ target = _to_mins(hhmm)
686
+ for entry in sorted(plan, key=lambda e: e.get("start", "00:00")):
687
+ start = _to_mins(entry.get("start", "00:00"))
688
+ end = _to_mins(entry.get("end", "24:00"))
689
+ if start <= target < end:
690
+ return entry
691
+ return None
692
+
693
+ # Resolve personas
694
+ path_a, persona_a = _resolve_persona(args.persona_a)
695
+ path_b, persona_b = _resolve_persona(args.persona_b)
696
+
697
+ name_a = persona_a.get("Name", path_a.stem)
698
+ name_b = persona_b.get("Name", path_b.stem)
699
+
700
+ from src.agents.Short_term import _persona_dir
701
+ id_a = _persona_dir(name_a).name
702
+ id_b = _persona_dir(name_b).name
703
+
704
+ # Load day plans
705
+ sim_date = date_from_simulation_time(args.current_time)
706
+ _, current_hhmm = args.current_time.split(" ") if " " in args.current_time else ("", args.current_time)
707
+
708
+ plan_a = load_day_plan(name_a, sim_date) or []
709
+ plan_b = load_day_plan(name_b, sim_date) or []
710
+
711
+ if not plan_a or not plan_b:
712
+ print(f"Need day plans for both personas. Run: python backend/src/core/Agent.py {args.persona_a}")
713
+ print(f" and: python backend/src/core/Agent.py {args.persona_b}")
714
+ sys.exit(1)
715
+
716
+ print(f"Personas: {name_a} <-> {name_b}")
717
+ print(f" Agent IDs: {id_a}, {id_b}")
718
+ print(f" Plans: {len(plan_a)} actions, {len(plan_b)} actions")
719
+ print(f" Time: {args.current_time} (HH:MM={current_hhmm})")
720
+
721
+ # Find current action for each
722
+ entry_a = _find_action_for_time(plan_a, current_hhmm)
723
+ entry_b = _find_action_for_time(plan_b, current_hhmm)
724
+
725
+ if not entry_a or not entry_b:
726
+ print(f"No plan entry covers {current_hhmm} for one or both personas.")
727
+ sys.exit(1)
728
+
729
+ action_a = CurrentAction(
730
+ description=entry_a["action"],
731
+ start_tick=0,
732
+ end_tick=1000,
733
+ target_location_id=entry_a.get("location_id"),
734
+ )
735
+ action_b = CurrentAction(
736
+ description=entry_b["action"],
737
+ start_tick=0,
738
+ end_tick=1000,
739
+ target_location_id=entry_b.get("location_id"),
740
+ )
741
+
742
+ # Load relationship matrix
743
+ matrix = RelationshipMatrix()
744
+
745
+ print(f"\n{name_a} is: {action_a.description} at {entry_a.get('location_id', '?')}")
746
+ print(f"{name_b} is: {action_b.description} at {entry_b.get('location_id', '?')}")
747
+ print(f"Relationship A->B: {matrix.get(id_a, id_b)}")
748
+ print(f"Relationship B->A: {matrix.get(id_b, id_a)}")
749
+ print()
750
+
751
+ # Location guess: use the first entry's location that has one
752
+ location_a = entry_a.get("location_id", "")
753
+ location_b = entry_b.get("location_id", "")
754
+ location_id = location_a if location_a == location_b else (location_a or location_b or "campus")
755
+
756
+ result = generate_conversation(
757
+ agent_a_id=id_a,
758
+ agent_b_id=id_b,
759
+ persona_a=persona_a,
760
+ persona_b=persona_b,
761
+ plan_a=plan_a,
762
+ plan_b=plan_b,
763
+ action_a=action_a,
764
+ action_b=action_b,
765
+ rel_a_to_b=matrix.get(id_a, id_b),
766
+ rel_b_to_a=matrix.get(id_b, id_a),
767
+ location_id=location_id,
768
+ current_hhmm=current_hhmm,
769
+ )
770
+
771
+ if result is None:
772
+ print("Conversation generation failed.")
773
+ sys.exit(1)
774
+
775
+ print(f"\n{'='*60}")
776
+ print(f"SUMMARY: {result.summary}")
777
+ print(f"Duration: {result.duration_minutes} min")
778
+ print(f"Sentiment: {result.sentiment}")
779
+ print(f"Relationship delta: {result.relationship_delta:+.2f}")
780
+ print(f"{'='*60}")
781
+ print()
782
+
783
+ for msg in result.messages:
784
+ print(f" [{msg.speaker}] {msg.text}")
785
+ print()
786
+
787
+ # Save to both personas' Short_term memory
788
+ date_str = sim_date
789
+ conv_entry = {
790
+ "participants": [name_a, name_b],
791
+ "messages": [{"speaker": m.speaker, "text": m.text} for m in result.messages],
792
+ "summary": result.summary,
793
+ }
794
+
795
+ append_conversation(name_a, date_str, conv_entry)
796
+ append_conversation(name_b, date_str, conv_entry)
797
+ print(f"Saved conversation to {id_a} and {id_b} for {date_str}")
798
+
799
+ # Update relationship matrix
800
+ matrix.update(id_a, id_b, result.relationship_delta)
801
+ matrix.update(id_b, id_a, result.relationship_delta)
802
+ matrix.save()
803
+ print(f"Updated relationship matrix: {id_a}->{id_b} = {matrix.get(id_a, id_b):.2f}")
804
+
805
+ print(f"\nConversation module self-test passed.")
backend/src/agents/daily_flavor.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Daily flavor — random theme and emotion pickers for day_planner.
3
+
4
+ Each day an agent gets a random theme (what they focus on) and emotion
5
+ (their mood), injected into the planner prompts so the schedule doesn't
6
+ feel identical every day.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+
13
+ THEMES = [
14
+ "Sports",
15
+ "Academics",
16
+ "Working",
17
+ "Socialising",
18
+ "Exploring",
19
+ "Resting",
20
+ "Entertainment",
21
+ "Fitness",
22
+ "Shopping",
23
+ "Creative",
24
+ ]
25
+
26
+ EMOTIONS = [
27
+ "Happy",
28
+ "Sad",
29
+ "Melancholic",
30
+ "Angry",
31
+ "Peaceful",
32
+ "Affectionate",
33
+ "Excited",
34
+ "Motivated",
35
+ "Lonely",
36
+ "Relaxed",
37
+ ]
38
+
39
+
40
+ def pick_theme() -> str:
41
+ return random.choice(THEMES)
42
+
43
+
44
+ def pick_emotion() -> str:
45
+ return random.choice(EMOTIONS)
backend/src/agents/day_planner.py ADDED
@@ -0,0 +1,1227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ A script that plans the day of an agentic personality when handed over required data
3
+ Per plan takes 4 LLM calls (atlest) Coarse, Hourly, Fine, Validation, for Planning a
4
+ day in one agent's life.
5
+
6
+ Tier-1 LangGraph subgraph: agent day-planning.
7
+
8
+ Pipeline (mirrors Generative Agents' Planning module, coarse -> hourly -> fine,
9
+ with a validation/retry loop):
10
+
11
+ generate_coarse_plan -> decompose_hourly -> decompose_fine -> validate_plan
12
+ |
13
+ conflict? --yes-+ (loop back to generate_coarse_plan)
14
+ |
15
+ no -> END
16
+
17
+ LLM backend: Google Gemini via the `google-genai` SDK.
18
+
19
+ For now `relevant_memories` and `yesterday_summary` are expected to arrive
20
+ empty ([] / None) -- the prompts already handle that gracefully so you can
21
+ wire in real retrieval/memory later without touching this file's structure.
22
+
23
+ FILE NOTES:
24
+ Prompt structure can be improved
25
+ Places are being feed in Name : , Desc : format, this can be improved
26
+ disabled location check in validate plan : can add more places
27
+
28
+ Prompt templates have to improve
29
+
30
+ Have to figure out how to run this in the backend server, currently it is running standalone
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ # Add root to sys.path
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ # backend/ is two levels up from this file (agents -> src -> backend)
40
+ BACKEND_ROOT = Path(__file__).resolve().parents[2]
41
+ if str(BACKEND_ROOT) not in sys.path:
42
+ sys.path.append(str(BACKEND_ROOT))
43
+
44
+
45
+ import json
46
+ import re
47
+ import threading
48
+ from collections import defaultdict, deque
49
+ from src.core.log import get_logger
50
+ from src.llm.gemini_client import call_gemini
51
+ from typing import Any, Dict, List, Optional, TypedDict, Literal
52
+
53
+ from langgraph.graph import END, StateGraph
54
+ from pydantic import BaseModel, Field, create_model, model_validator
55
+
56
+ # Setting up logger
57
+ logger = get_logger(__name__)
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Config -> in config.py
62
+ # ---------------------------------------------------------------------------
63
+ from src.config import ENVIRONMENT_DIR, DATA_DIR
64
+ from src.config import MAX_PLAN_RETRIES, PERSONA_FIELD_GLOSSARY
65
+
66
+ # The simulator represents an adult college community, not sexual or hateful
67
+ # roleplay. This deterministic guard runs before semantic LLM QA.
68
+ _UNSAFE_PLAN_TERMS = (
69
+ "non-consensual", "porn", "explicit content", "gooning", "jerking off",
70
+ "stalking profiles", "racial prejudice", "racist", "manipulating conversations",
71
+ "soft porn", "sexual content",
72
+ )
73
+
74
+ # Academic locations are a behavioural contract, not a preference hidden in a
75
+ # prompt. Models may still choose LHC for genuinely common/shared teaching or
76
+ # SAB for explicitly shared work, but branch-specific teaching belongs here.
77
+ _BRANCH_VENUES = (
78
+ ("mechanical", "mechanical_department"),
79
+ ("chemical", "chemical_department"),
80
+ ("electrical", "electrical_department"),
81
+ ("integrated circuit", "electrical_department"),
82
+ ("computer science", "computer_science_department"),
83
+ ("aide", "computer_science_department"),
84
+ ("artificial intelligence", "computer_science_department"),
85
+ ("data engineering", "computer_science_department"),
86
+ )
87
+ _BRANCH_ACTIVITY_PATTERN = re.compile(
88
+ r"\b(?:class(?:es)?|lecture(?:s)?|lab(?:s)?|laborator(?:y|ies)|tutorial(?:s)?|practical(?:s)?)\b",
89
+ re.IGNORECASE,
90
+ )
91
+ _SHARED_SESSION_WORDS = ("common", "core", "elective", "guest", "large", "shared", "interdisciplinary", "seminar")
92
+ _ACADEMIC_PREPARATION_WORDS = (
93
+ "prep", "prepare", "preparing", "getting ready", "dress", "dressing",
94
+ "pack", "packing", "review notes", "reviewing lecture notes",
95
+ "organizing study material", "plan for",
96
+ )
97
+ _NON_ATTENDANCE_ACADEMIC_WORDS = (
98
+ "group chat", "groupchat", "class chat", "classmate", "coursework",
99
+ "assignment", "homework", "notes", "study material",
100
+ )
101
+
102
+
103
+ def _academic_venue_policy(persona: Dict[str, Any]) -> str:
104
+ branch = str(persona.get("Branch", ""))
105
+ destination = next((venue for marker, venue in _BRANCH_VENUES if marker in branch.lower()), None)
106
+ if not destination:
107
+ return "No branch-specific policy is known; choose the listed location that explicitly fits."
108
+ return (
109
+ f"This student is in {branch}. Branch-specific classes, tutorials, and labs may use "
110
+ f"`{destination}` or Library/ SAB. LHC is only for common/core/elective/guest/large shared sessions and classes. "
111
+
112
+ )
113
+
114
+
115
+ def _local_academic_venue_check(actions: List[Dict[str, Any]], persona: Dict[str, Any]) -> Optional[str]:
116
+ branch = str(persona.get("Branch", "")).lower()
117
+ required = next((venue for marker, venue in _BRANCH_VENUES if marker in branch), None)
118
+ if not required:
119
+ return None
120
+ for action in actions:
121
+ description = str(action.get("action", "")).lower()
122
+ location = str(action.get("location_id", ""))
123
+ # Use whole academic terms. A substring check classified ordinary
124
+ # activities such as "chai break with classmates" as a class because
125
+ # "class" is part of "classmates", causing needless replan retries.
126
+ if not _BRANCH_ACTIVITY_PATTERN.search(description):
127
+ continue
128
+ # Mentioning a lecture while getting ready for it is not attendance.
129
+ # These private preparation activities legitimately occur in hostels,
130
+ # libraries, and other non-department locations.
131
+ if any(word in description for word in _ACADEMIC_PREPARATION_WORDS):
132
+ continue
133
+ # Coursework, notes, and class chats refer to an academic subject but
134
+ # do not mean the agent is physically attending a branch session.
135
+ # They may legitimately happen in a hostel, library, or common space.
136
+ if any(word in description for word in _NON_ATTENDANCE_ACADEMIC_WORDS):
137
+ continue
138
+ if any(word in description for word in _SHARED_SESSION_WORDS):
139
+ continue
140
+ if location != required:
141
+ return (
142
+ f"branch-specific academic action '{action.get('action')}' for {persona.get('Branch')} "
143
+ f"must use {required}, not {location}"
144
+ )
145
+ return None
146
+
147
+
148
+ def load_places(places_file: Optional[Path] = None) -> List[Place]:
149
+ """
150
+ Loads the college places JSON and returns a list of validated Place
151
+ objects. Expects a top-level {"locations": [...]} structure where each
152
+ entry matches the Place schema.
153
+
154
+ Gets the places from ENVIRONMENT_DIR/places.json by default, or from a
155
+ custom path if provided.
156
+ """
157
+ path = places_file or ENVIRONMENT_DIR / "places.json"
158
+
159
+ if not Path(path).exists():
160
+ logger.warning(
161
+ "[day_planner] places file not found at %s -- proceeding with no known locations",
162
+ path,
163
+ )
164
+ return []
165
+
166
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
167
+ entries = raw.get("locations", [])
168
+
169
+ places: List[Place] = []
170
+ for i, entry in enumerate(entries):
171
+ places.append(Place.model_validate(entry))
172
+
173
+ logger.info("[day_planner] loaded %d places from %s", len(places), path)
174
+ return places
175
+
176
+ def _places_block(places: List[Place]) -> str:
177
+ if not places:
178
+ return "(no places data available -- pick a reasonable generic label)"
179
+ blocks = []
180
+ for p in places:
181
+ lines = [f"[{p.id}] {p.name} ({p.type})"]
182
+ if p.sub_areas:
183
+ lines.append(f" sub-areas: {', '.join(p.sub_areas)}")
184
+ if p.open_hours:
185
+ hours = "; ".join(f"{k}: {v}" for k, v in p.open_hours.items())
186
+ lines.append(f" hours: {hours}")
187
+ if p.typical_activities:
188
+ lines.append(f" good for: {', '.join(p.typical_activities)}")
189
+ if p.notes:
190
+ lines.append(f" notes: {p.notes}")
191
+ blocks.append("\n".join(lines))
192
+ return "\n".join(blocks)
193
+
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Structured-output schemas (pydantic -> Gemini response_schema)
198
+ # ---------------------------------------------------------------------------
199
+
200
+ class CoarseBlock(BaseModel):
201
+ activity: str = Field(description="Short label, e.g. 'work on farm', 'breakfast'")
202
+ start: str = Field(description="24h HH:MM")
203
+ end: str = Field(description="24h HH:MM")
204
+ granularity: Literal["atomic", "flexible"] = Field(
205
+ description=(
206
+ "'atomic' = this block should NEVER be split into finer sub-steps, it "
207
+ "stays as a single continuous action (e.g. sleep, attending a lecture, "
208
+ "commuting, an exam, watching a movie). "
209
+ "'flexible' = this block genuinely contains distinct sub-activities worth "
210
+ "breaking down (e.g. 'morning routine', 'gym session', 'work on project')."
211
+ )
212
+ )
213
+ energy_change: float = 0.0
214
+ emotion_change: float = 0.0
215
+
216
+
217
+ class CoarsePlanOutput(BaseModel):
218
+ # This field is deliberately part of the provider response schema rather
219
+ # than only a sentence in the prompt. ``generate_coarse_plan`` narrows it
220
+ # to a Literal[required_start] for each individual API call.
221
+ first_start_time: str = Field(
222
+ description=(
223
+ "Exact required HH:MM start time of the first block. Copy the "
224
+ "required first start time from the request exactly."
225
+ )
226
+ )
227
+ blocks: List[CoarseBlock]
228
+
229
+ @model_validator(mode="after")
230
+ def first_block_must_match_declared_start(self) -> "CoarsePlanOutput":
231
+ if not self.blocks:
232
+ raise ValueError("coarse plan must contain at least one block")
233
+ if self.blocks[0].start != self.first_start_time:
234
+ raise ValueError(
235
+ "first_start_time must exactly match the first coarse block start"
236
+ )
237
+ return self
238
+
239
+
240
+ class HourlyBlock(BaseModel):
241
+ activity: str
242
+ start: str
243
+ end: str
244
+ parent_activity: str = Field(description="The coarse block this refines")
245
+ energy_change: float = 0.0
246
+ emotion_change: float = 0.0
247
+
248
+
249
+ class HourlyPlanOutput(BaseModel):
250
+ blocks: List[HourlyBlock]
251
+
252
+
253
+ class FineAction(BaseModel):
254
+ action: str = Field(description="Concrete, executable action, 5-30 min granularity")
255
+ start: str
256
+ end: str
257
+ parent_activity: str = Field(description="The hourly block this refines")
258
+ location_id: str = Field(description="Must exactly match an 'id' from the known places list")
259
+ sub_area: Optional[str] = Field(default=None, description="One of that place's sub_areas, if applicable")
260
+ energy_change: float = Field(description="Energy change [-1.0, 1.0] over this action; positive=restorative, negative=tiring")
261
+ emotion_change: float = Field(description="Emotion change [-1.0, 1.0] over this action; positive=uplifting, negative=draining")
262
+
263
+
264
+ class FinePlanOutput(BaseModel):
265
+ actions: List[FineAction]
266
+
267
+ # For Coars Atomic actions
268
+ class AtomicLocationAssignment(BaseModel):
269
+ activity: str
270
+ location_id: str
271
+ sub_area: Optional[str] = None
272
+ energy_change: float = 0.0
273
+ emotion_change: float = 0.0
274
+
275
+ class AtomicLocationOutput(BaseModel):
276
+ assignments: List[AtomicLocationAssignment]
277
+
278
+
279
+ class ValidationResult(BaseModel):
280
+ valid: bool
281
+ reason: Optional[str] = Field(
282
+ default=None, description="If invalid: which items conflict/overlap/gap and why"
283
+ )
284
+
285
+
286
+ class Place(BaseModel):
287
+ id: str
288
+ name: str
289
+ type: str
290
+ sub_areas: List[str] = []
291
+ open_hours: Dict[str, str] = {}
292
+ capacity: Optional[str] = None
293
+ typical_activities: List[str] = []
294
+ connected_locations: List[str] = []
295
+ notes: Optional[str] = None
296
+
297
+
298
+ def _normalize_places(places: List[Any]) -> List[Place]:
299
+ """Accept validated Place models or raw JSON-compatible place records."""
300
+ normalized: List[Place] = []
301
+ for place in places:
302
+ try:
303
+ normalized.append(place if isinstance(place, Place) else Place.model_validate(place))
304
+ except Exception as exc:
305
+ logger.warning("[day_planner] ignoring invalid place entry: %s", exc)
306
+ return normalized
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Graph state
311
+ # ---------------------------------------------------------------------------
312
+
313
+ class DayPlannerState(TypedDict, total=False):
314
+ # ---- inputs ----
315
+ persona: dict
316
+ relevant_memories: List[str]
317
+ yesterday_summary: Optional[str]
318
+ current_time: str # e.g. "2026-07-03 06:00"
319
+ places: List[Place] # known campus locations, from places.json
320
+ mode: str # "full_day", "remaining", or "next_day"
321
+ current_location_id: Optional[str] = None
322
+ handoff_context: Optional[str] = None
323
+ daily_theme: str # random theme (e.g. "Sports", "Academics")
324
+ daily_emotion: str # random mood (e.g. "Happy", "Melancholic")
325
+ upcoming_events: List[Dict[str, Any]]
326
+
327
+ # ---- working state ----
328
+ coarse_plan: List[Dict[str, Any]]
329
+ hourly_plan: List[Dict[str, Any]]
330
+ fine_plan: List[Dict[str, Any]]
331
+
332
+ conflict_detected: bool
333
+ conflict_reason: Optional[str]
334
+ retry_count: int
335
+
336
+ # ---- output ----
337
+ day_plan: List[Dict[str, Any]]
338
+ error: Optional[str]
339
+ replan_rejected: bool
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Helpers
344
+ # ---------------------------------------------------------------------------
345
+
346
+ def _persona_block(persona: dict) -> str:
347
+ """Renders whatever fields exist in the persona JSON -- don't assume a
348
+ fixed schema beyond name, since personas will vary as you add more."""
349
+ lines = [f"Name: {persona.get('Name') or persona.get('name', 'Unknown')}"]
350
+ for field, meaning in PERSONA_FIELD_GLOSSARY.items():
351
+ value = persona.get(field)
352
+ if value:
353
+ lines.append(f"- {field} ({meaning}): {value}")
354
+
355
+ identity_fields = ["Age", "Gender", "Branch", "Home City", "Hostel"]
356
+ lines.extend(f"{f}: {persona[f]}" for f in identity_fields if persona.get(f))
357
+
358
+ return "\n".join(lines)
359
+
360
+
361
+ def _memories_block(memories: List[str]) -> str:
362
+ if not memories:
363
+ return "(none available yet)"
364
+ return "\n".join(f"- {m}" for m in memories)
365
+
366
+
367
+ def _events_block(events: List[Dict[str, Any]]) -> str:
368
+ if not events:
369
+ return "(no announced events)"
370
+ return "\n".join(
371
+ f"- {event.get('start_time', '?')}-{event.get('end_time', '?')}: "
372
+ f"{event.get('name', 'Campus event')} at {event.get('location_id', '?')} — "
373
+ f"{event.get('announcement', '')}"
374
+ for event in events[:5]
375
+ )
376
+
377
+
378
+ def _flavor_block(state: DayPlannerState) -> str:
379
+ theme = state.get("daily_theme", "Neutral")
380
+ emotion = state.get("daily_emotion", "Neutral")
381
+ return f"Today's vibe: {emotion}, leaning into {theme}."
382
+
383
+
384
+ def _planning_window(state: DayPlannerState) -> tuple[str, str]:
385
+ """Return the exact time window owned by this planner invocation."""
386
+ current_time = state.get("current_time", "")
387
+ if state.get("mode") == "remaining" and " " in current_time:
388
+ return current_time.rsplit(" ", 1)[-1], "24:00"
389
+ return "00:00", "24:00"
390
+
391
+
392
+ def _window_constraint(state: DayPlannerState) -> str:
393
+ start, end = _planning_window(state)
394
+ if state.get("mode") == "remaining":
395
+ return (
396
+ f"This is a REMAINING-DAY REPLAN. You own exactly {start} to {end}. "
397
+ f"The FIRST output block/action MUST start exactly at {start}; the LAST MUST end "
398
+ f"exactly at {end}. Never output, mention, or schedule any time before {start}."
399
+ )
400
+ return (
401
+ f"This plan owns exactly {start} to {end}. The FIRST output block/action MUST start "
402
+ f"exactly at {start}; the LAST MUST end exactly at {end}."
403
+ )
404
+
405
+
406
+ def _coarse_output_schema(required_start: str) -> type[CoarsePlanOutput]:
407
+ """Build the per-request structured-output contract for the first block.
408
+
409
+ Gemini's JSON schema is static for a given call, while a remaining-day
410
+ replan may begin at any simulation time. A Literal field turns the
411
+ runtime start time into a JSON-schema ``const`` sent with that specific
412
+ API request. The base-model validator additionally binds that echoed
413
+ value to the first actual block.
414
+ """
415
+ return create_model(
416
+ "CoarsePlanOutputAt" + required_start.replace(":", "_"),
417
+ __base__=CoarsePlanOutput,
418
+ first_start_time=(
419
+ Literal[required_start],
420
+ Field(
421
+ description=(
422
+ "Must be exactly " + required_start +
423
+ "; it must also equal blocks[0].start."
424
+ )
425
+ ),
426
+ ),
427
+ )
428
+
429
+
430
+ def _within_source_windows(record: Dict[str, Any], sources: List[Dict[str, Any]]) -> bool:
431
+ """Whether one provider record belongs wholly to an assigned source range."""
432
+ try:
433
+ start, end = _hhmm_minutes(record["start"]), _hhmm_minutes(record["end"])
434
+ return any(
435
+ _hhmm_minutes(source["start"]) <= start < end <= _hhmm_minutes(source["end"])
436
+ for source in sources
437
+ )
438
+ except (KeyError, TypeError, ValueError):
439
+ return False
440
+
441
+
442
+ # ---------------------------------------------------------------------------
443
+ # Nodes
444
+ # ---------------------------------------------------------------------------
445
+
446
+ def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState:
447
+ persona = state["persona"]
448
+ mode = state.get("mode", "full_day")
449
+ current_time = state.get("current_time", "unknown")
450
+
451
+ if mode == "full_day":
452
+ 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."
453
+ elif mode == "next_day":
454
+ 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."
455
+ elif mode == "remaining":
456
+ coverage = (
457
+ "covering ONLY the REMAINDER of the day. The first block must start "
458
+ f"exactly at {_planning_window(state)[0]}, and no block may start before it"
459
+ )
460
+ else:
461
+ coverage = "covering the full 24 hours (00:00 to 24:00)"
462
+
463
+ loc_hint = ""
464
+ current_loc = state.get("current_location_id")
465
+ system_prompt = (
466
+ "You are simulating one day in the life of a character in a generative-agents "
467
+ "simulation. Produce a COARSE day plan: 5 to 8 broad blocks of activity "
468
+ f"{coverage}, with no gaps and no overlaps. "
469
+ "Stay true to the persona's traits, background, goals, and daily habits.\n\n"
470
+ "The movement executor owns routes between locations. Plan only time spent "
471
+ "at places; do not create separate walk, commute, travel, or transit blocks. "
472
+ "Leave enough dwell time for meals, classes, study, rest, and social activities. "
473
+ "All activities must be age-appropriate and respectful: never include explicit, coercive, harassing, discriminatory, or stalking behaviour.\n\n"
474
+ "For EACH block, tag it as 'atomic' or 'flexible':\n"
475
+ "- atomic: a single continuous activity with no meaningful internal sub-steps "
476
+ "worth planning separately. Examples: sleeping, attending a class/lecture, "
477
+ "sitting an exam, watching a movie, a long uninterrupted study/deep-work session.\n"
478
+ "- flexible: an activity that naturally contains distinct on-site sub-activities.\n\n"
479
+ "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."
480
+ )
481
+ if current_loc:
482
+ loc_hint = f"\nThe agent is currently at: {current_loc}. Start the plan from this location."
483
+ user_prompt = (
484
+ f"PERSONA:\n{_persona_block(persona)}\n\n"
485
+ f"RELEVANT MEMORIES:\n{_memories_block(state.get('relevant_memories', []))}\n\n"
486
+ f"YESTERDAY'S SUMMARY:\n{state.get('yesterday_summary') or '(no history yet, this is day 1)'}\n\n"
487
+ f"ANNOUNCED CAMPUS EVENTS (optional; never displace classes, meals, sleep, exams, or deadlines):\n{_events_block(state.get('upcoming_events', []))}\n\n"
488
+ f"{_flavor_block(state)}\n\n"
489
+ f"Current in-simulation time: {current_time}\n"
490
+ f"Plan mode: {mode}\n"
491
+ f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n"
492
+ f"Agent location: {current_loc or 'unknown'}{loc_hint}\n\n"
493
+ f"DAY-HANDOFF CONTINUITY:\n{state.get('handoff_context') or '(none)'}\n\n"
494
+ "Generate the coarse plan now."
495
+ )
496
+
497
+ if state.get("conflict_reason"):
498
+ user_prompt += (
499
+ f"\n\nNOTE: a previous attempt was rejected for this reason, avoid repeating it:\n"
500
+ f"{state['conflict_reason']}"
501
+ )
502
+
503
+ required_start = _planning_window(state)[0]
504
+ result = call_gemini(
505
+ system_prompt,
506
+ user_prompt,
507
+ _coarse_output_schema(required_start),
508
+ "default",
509
+ )
510
+ logger.info("[day_planner] coarse plan generated: %d blocks", len(result.blocks))
511
+
512
+ return {
513
+ **state,
514
+ "coarse_plan": [b.model_dump() for b in result.blocks],
515
+ }
516
+
517
+
518
+ def validate_coarse_window(state: DayPlannerState) -> DayPlannerState:
519
+ """Reject an invalid coarse time window before costly LLM refinement."""
520
+ issue = _local_overlap_check(
521
+ state.get("coarse_plan", []),
522
+ mode=state.get("mode", "full_day"),
523
+ remaining_start=_planning_window(state)[0],
524
+ action_key="activity",
525
+ )
526
+ if issue:
527
+ logger.info("[day_planner] coarse-window validation failed: %s", issue)
528
+ return {
529
+ **state,
530
+ "conflict_detected": True,
531
+ "conflict_reason": issue,
532
+ "retry_count": state.get("retry_count", 0) + 1,
533
+ }
534
+ return {**state, "conflict_detected": False, "conflict_reason": None}
535
+
536
+
537
+ def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
538
+ persona = state["persona"]
539
+
540
+ atomic_blocks = [b for b in state['coarse_plan'] if b.get("granularity") == "atomic"]
541
+ flexible_blocks = [b for b in state['coarse_plan'] if b.get("granularity") == "flexible"]
542
+ unknown_blocks = [
543
+ b for b in state["coarse_plan"]
544
+ if b.get("granularity") not in {"atomic", "flexible"}
545
+ ]
546
+ if unknown_blocks:
547
+ logger.warning(
548
+ "[day_planner] treating %d unknown-granularity block(s) as flexible",
549
+ len(unknown_blocks),
550
+ )
551
+ flexible_blocks.extend({**block, "granularity": "flexible"} for block in unknown_blocks)
552
+
553
+ passthrough_hourly = [
554
+ {
555
+ "activity": b["activity"],
556
+ "start": b["start"],
557
+ "end": b["end"],
558
+ "parent_activity": b["activity"],
559
+ "granularity": "atomic",
560
+ "energy_change": b.get("energy_change", 0.0),
561
+ "emotion_change": b.get("emotion_change", 0.0),
562
+ }
563
+ for b in atomic_blocks
564
+ ]
565
+
566
+ hourly_blocks = list(passthrough_hourly)
567
+
568
+ if flexible_blocks:
569
+ system_prompt = (
570
+ "You refine flexible coarse blocks into hourly-resolution blocks. Each coarse "
571
+ "block should be broken into one or more hourly blocks that together span "
572
+ "exactly its start/end range with no gaps or overlaps. "
573
+ "Make each sub-block feel natural and persona-aligned — a real person would "
574
+ "think of these as distinct steps. Consider reasonable time boundaries "
575
+ "(e.g. a meal block of 2 hours can contain 'walk to mess', 'eat', 'socialize'). "
576
+ "Only the blocks provided here need refining. Do not create walk, commute, or "
577
+ "transit sub-blocks: refine only activities performed at the destination.\n\n"
578
+ "For EACH block, assign realistic energy_change and emotion_change values:\n"
579
+ "- energy_change: positive = restorative, negative = tiring\n"
580
+ "- emotion_change: positive = uplifting, negative = draining\n"
581
+ "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n"
582
+ "- Be realistic for the persona\n\n"
583
+ f"{_window_constraint(state)}"
584
+ )
585
+ user_prompt = (
586
+ f"PERSONA:\n{_persona_block(persona)}\n\n"
587
+ f"{_flavor_block(state)}\n\n"
588
+ f"FULL COARSE PLAN (context only):\n{json.dumps(state['coarse_plan'], indent=2)}\n\n" # Hand the whole day
589
+ f"BLOCKS TO REFINE:\n{json.dumps(flexible_blocks, indent=2)}\n\n" # The ONLY data to modify
590
+ f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n"
591
+ "Produce the hourly-resolution plan for the blocks listed under "
592
+ "'BLOCKS TO REFINE' only."
593
+ )
594
+ result = call_gemini(system_prompt, user_prompt, HourlyPlanOutput, "default")
595
+ raw_refined = [b.model_dump() for b in result.blocks]
596
+ refined = [block for block in raw_refined if _within_source_windows(block, flexible_blocks)]
597
+ if len(refined) != len(raw_refined):
598
+ logger.warning(
599
+ "[day_planner] discarded %d hourly refinement block(s) outside flexible source windows",
600
+ len(raw_refined) - len(refined),
601
+ )
602
+ for b in refined:
603
+ b["granularity"] = "flexible"
604
+ hourly_blocks.extend(refined)
605
+
606
+ hourly_blocks.sort(key=lambda b: b["start"])
607
+ logger.info(
608
+ "[day_planner] hourly plan: %d atomic passthrough + %d refined",
609
+ len(passthrough_hourly), len(hourly_blocks) - len(passthrough_hourly),
610
+ )
611
+
612
+ return {**state, "hourly_plan": hourly_blocks}
613
+
614
+
615
+ def validate_hourly_refinement(state: DayPlannerState) -> DayPlannerState:
616
+ """Ensure the refinement stage did not recreate atomic source blocks.
617
+
618
+ Gemini is asked to refine flexible blocks only, but structured output does
619
+ not itself prevent it from returning a duplicate midnight sleep block. A
620
+ bad hourly plan must be retried before the fine planner spends more calls.
621
+ """
622
+ flexible_sources = [block for block in state.get("coarse_plan", []) if block.get("granularity") == "flexible"]
623
+ flexible_by_parent = {}
624
+ for block in flexible_sources:
625
+ flexible_by_parent.setdefault(block.get("activity"), []).append(block)
626
+
627
+ issue = _local_overlap_check(
628
+ state.get("hourly_plan", []),
629
+ mode=state.get("mode", "full_day"),
630
+ remaining_start=_planning_window(state)[0],
631
+ action_key="activity",
632
+ )
633
+ if not issue:
634
+ for block in state.get("hourly_plan", []):
635
+ if block.get("granularity") != "flexible":
636
+ continue
637
+ sources = flexible_by_parent.get(block.get("parent_activity"), [])
638
+ if not sources:
639
+ issue = f"hourly refinement '{block.get('activity', 'unknown')}' has no flexible source block"
640
+ break
641
+ start = _hhmm_minutes(block.get("start", ""))
642
+ end = _hhmm_minutes(block.get("end", ""))
643
+ if not any(
644
+ _hhmm_minutes(source["start"]) <= start < end <= _hhmm_minutes(source["end"])
645
+ for source in sources
646
+ ):
647
+ issue = f"hourly refinement '{block.get('activity', 'unknown')}' exceeds its flexible source window"
648
+ break
649
+ if issue:
650
+ logger.info("[day_planner] hourly refinement validation failed: %s", issue)
651
+ return {
652
+ **state,
653
+ "conflict_detected": True,
654
+ "conflict_reason": issue,
655
+ "retry_count": state.get("retry_count", 0) + 1,
656
+ }
657
+ return {**state, "conflict_detected": False, "conflict_reason": None}
658
+
659
+ def decompose_fine(state: DayPlannerState) -> DayPlannerState:
660
+ persona = state["persona"]
661
+ places = _normalize_places(state.get("places", []))
662
+ current_loc = state.get("current_location_id", "unknown")
663
+
664
+ atomic_blocks = [b for b in state["hourly_plan"] if b["granularity"] == "atomic"]
665
+ flexible_blocks = [b for b in state["hourly_plan"] if b["granularity"] == "flexible"]
666
+
667
+ fine_actions: List[Dict[str, Any]] = []
668
+
669
+ # Now have to handle atomic vs flexible blocks differently
670
+
671
+ # Atomic blocks: keep as single continuous actions, just resolve location.
672
+ if atomic_blocks:
673
+ system_prompt = (
674
+ "For each atomic activity below, assign exactly one location_id (and optionally "
675
+ "a sub_area) from the known places list. Pick whichever place fits the "
676
+ "activity best. Consider commute distance: if the agent is moving between "
677
+ "locations, ensure the distance is reasonable for the time available. "
678
+ "Default to locations that make sense for this specific persona. "
679
+ "Do not suggest splitting the activity.\n\n"
680
+ "ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. "
681
+ "Do not use SAB as a generic lecture/lab default.\n\n"
682
+ "For EACH block, assign realistic energy_change and emotion_change values:\n"
683
+ "- energy_change: positive = restorative, negative = tiring\n"
684
+ "- emotion_change: positive = uplifting, negative = draining\n"
685
+ "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n"
686
+ "- Be realistic for the persona\n\n"
687
+ f"{_window_constraint(state)}"
688
+ )
689
+ user_prompt = (
690
+ f"PERSONA:\n{_persona_block(persona)}\n\n"
691
+ f"ACADEMIC VENUE POLICY:\n{_academic_venue_policy(persona)}\n\n"
692
+ f"{_flavor_block(state)}\n\n"
693
+ f"ACTIVITIES:\n{json.dumps(atomic_blocks, indent=2)}\n\n"
694
+ f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n"
695
+ f"KNOWN CAMPUS LOCATIONS:\n{_places_block(places)}\n\n"
696
+ f"The agent's current position is: {current_loc}. This is only their "
697
+ "starting point for the next activity; it is not necessarily their hostel. "
698
+ f"Their home/hostel is: {persona.get('Hostel', 'unknown')}. Ensure sleep, "
699
+ "rest, and personal activities use the hostel unless the activity explicitly "
700
+ "requires another place.\n\n"
701
+ "Assign a location to each activity now."
702
+ )
703
+ result = call_gemini(system_prompt, user_prompt, AtomicLocationOutput, "default")
704
+ # Activity labels are not unique (for example, two separate study
705
+ # blocks). Preserve the provider's ordered assignments for duplicates
706
+ # instead of letting a dict silently overwrite earlier entries.
707
+ loc_by_activity: Dict[str, deque] = defaultdict(deque)
708
+ for assignment in result.assignments:
709
+ loc_by_activity[assignment.activity].append(assignment)
710
+
711
+ for b in atomic_blocks:
712
+ candidates = loc_by_activity.get(b["activity"])
713
+ assignment = candidates.popleft() if candidates else None
714
+ fine_actions.append({
715
+ "action": b["activity"],
716
+ "start": b["start"],
717
+ "end": b["end"],
718
+ "parent_activity": b["parent_activity"],
719
+ "location_id": assignment.location_id if assignment else None,
720
+ "sub_area": assignment.sub_area if assignment else None,
721
+ "energy_change": (
722
+ assignment.energy_change if assignment else b.get("energy_change", 0.0)
723
+ ),
724
+ "emotion_change": (
725
+ assignment.emotion_change if assignment else b.get("emotion_change", 0.0)
726
+ ),
727
+ })
728
+
729
+ # Flexible blocks: full fine-grained breakdown, as before.
730
+ if flexible_blocks:
731
+ system_prompt = (
732
+ "You refine hourly blocks into fine-grained, directly executable actions "
733
+ "at roughly 5-15 minute granularity. Each hourly block should be broken "
734
+ "into one or more fine actions spanning exactly its start/end range, no "
735
+ "gaps or overlaps. Every action MUST be assigned a location_id, chosen "
736
+ "EXACTLY from the provided list -- never invent one.\n\n"
737
+ "Do NOT output walking, commuting, travel, transit, leaving, or arriving "
738
+ "as an action. The runtime owns visible routes between places; every action "
739
+ "you output must be an on-site activity at its assigned location.\n\n"
740
+ "ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. "
741
+ "Branch-specific classes/labs must not silently fall back to SAB.\n\n"
742
+ "Make action boundaries feel natural — group related sub-actions together. "
743
+ "Consider typical on-site durations: eating ~20-40min and studying "
744
+ "~30-120min. Keep adjacent location changes realistic by leaving enough "
745
+ "time for the executor to animate transit before the next activity.\n\n"
746
+ "For EACH action, assign realistic energy_change and emotion_change values:\n"
747
+ "- energy_change: positive = restorative, negative = tiring\n"
748
+ "- emotion_change: positive = uplifting, negative = draining\n"
749
+ "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n"
750
+ "- Be realistic for the persona\n\n"
751
+ f"{_window_constraint(state)}"
752
+ )
753
+ user_prompt = (
754
+ f"PERSONA:\n{_persona_block(persona)}\n\n"
755
+ f"ACADEMIC VENUE POLICY:\n{_academic_venue_policy(persona)}\n\n"
756
+ f"{_flavor_block(state)}\n\n"
757
+ f"HOURLY BLOCKS TO REFINE:\n{json.dumps(flexible_blocks, indent=2)}\n\n"
758
+ f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n"
759
+ f"KNOWN CAMPUS LOCATIONS:\n{_places_block(places)}\n\n"
760
+ f"The agent's current position is: {current_loc}. This is only their "
761
+ "starting point for the next activity; it is not necessarily their hostel. "
762
+ f"Their home/hostel is: {persona.get('Hostel', 'unknown')}. Ensure sleep, "
763
+ "rest, and personal activities use the hostel unless the activity explicitly "
764
+ "requires another place.\n\n"
765
+ "Produce the fine-grained action plan for these blocks now."
766
+ )
767
+ result = call_gemini(system_prompt, user_prompt, FinePlanOutput, "default")
768
+ raw_actions = [action.model_dump() for action in result.actions]
769
+ scoped_actions = [action for action in raw_actions if _within_source_windows(action, flexible_blocks)]
770
+ if len(scoped_actions) != len(raw_actions):
771
+ logger.warning(
772
+ "[day_planner] discarded %d fine action(s) outside flexible source windows",
773
+ len(raw_actions) - len(scoped_actions),
774
+ )
775
+ fine_actions.extend(scoped_actions)
776
+
777
+ fine_actions.sort(key=lambda a: a["start"])
778
+ logger.info("[day_planner] fine plan: %d total actions", len(fine_actions))
779
+
780
+ return {**state, "fine_plan": fine_actions}
781
+
782
+ def _local_overlap_check(
783
+ actions: List[Dict[str, Any]],
784
+ mode: str = "full_day",
785
+ remaining_start: Optional[str] = None,
786
+ action_key: str = "action",
787
+ ) -> Optional[str]:
788
+ """Cheap deterministic pre-check before spending an LLM call on validation --
789
+ catches the most common failure mode (bad overlaps/gaps) for free."""
790
+ if not actions:
791
+ return "fine plan is empty"
792
+
793
+ try:
794
+ sorted_actions = sorted(actions, key=lambda a: _hhmm_minutes(a["start"]))
795
+ except Exception as e: # malformed time strings
796
+ return f"unparsable time value: {e}"
797
+
798
+ # Remaining-day replans replace the *whole* active plan. They therefore
799
+ # must begin now and continue through midnight; otherwise a valid old-plan
800
+ # tail would be silently discarded and the agent would become idle.
801
+ if mode == "remaining" and remaining_start:
802
+ if _hhmm_minutes(sorted_actions[0]["start"]) != _hhmm_minutes(remaining_start):
803
+ return (
804
+ f"remaining-day plan does not start at {remaining_start} "
805
+ f"(starts at {sorted_actions[0]['start']})"
806
+ )
807
+ elif mode != "remaining" and _hhmm_minutes(sorted_actions[0]["start"]) != 0:
808
+ return f"plan does not start at 00:00 (starts at {sorted_actions[0]['start']})"
809
+
810
+ for prev, curr in zip(sorted_actions, sorted_actions[1:]):
811
+ if _hhmm_minutes(prev["end"]) <= _hhmm_minutes(prev["start"]):
812
+ return f"action '{prev.get(action_key, 'unknown')}' has a non-positive duration"
813
+ if _hhmm_minutes(prev["end"]) != _hhmm_minutes(curr["start"]):
814
+ return (
815
+ f"gap or overlap between '{prev.get(action_key, 'unknown')}' (ends {prev['end']}) "
816
+ f"and '{curr.get(action_key, 'unknown')}' (starts {curr['start']})"
817
+ )
818
+
819
+ if _hhmm_minutes(sorted_actions[-1]["end"]) <= _hhmm_minutes(sorted_actions[-1]["start"]):
820
+ return f"action '{sorted_actions[-1].get(action_key, 'unknown')}' has a non-positive duration"
821
+
822
+ # Even a remaining-day plan replaces the entire current plan, so it must
823
+ # explicitly own the final minute of the day.
824
+ last_end = sorted_actions[-1]["end"]
825
+ if _hhmm_minutes(last_end) not in (24 * 60, 0):
826
+ return f"plan does not end at 24:00 (ends at {last_end})"
827
+
828
+ return None
829
+
830
+
831
+ def _hhmm_minutes(hhmm: str) -> int:
832
+ h, m = str(hhmm).split(":")
833
+ return int(h) * 60 + int(m)
834
+
835
+
836
+ def _local_location_check(actions: List[Dict[str, Any]], places: List[Place]) -> Optional[str]:
837
+ """Deterministic check that every action's location is one of the known
838
+ places -- catches hallucinated locations without spending an LLM call."""
839
+ places = _normalize_places(places)
840
+ if not places:
841
+ # No places data loaded -- nothing to validate against, skip silently.
842
+ return None
843
+
844
+ valid_ids = {p.id for p in places}
845
+ by_id = {p.id: p for p in places}
846
+ loc_err = ""
847
+ transit_words = ("walk", "commute", "travel", "transit", "arrive", "leave")
848
+ for a in actions:
849
+ loc_id = a.get("location_id")
850
+ if loc_id not in valid_ids:
851
+ loc_err += f"action '{a.get('action')}' has invalid location_id '{loc_id}'\n"
852
+ continue
853
+ if any(word in a.get("action", "").lower() for word in transit_words):
854
+ loc_err += f"action '{a.get('action')}' is transit; routes are owned by the executor\n"
855
+ sub = a.get("sub_area")
856
+ if sub and sub not in by_id[loc_id].sub_areas:
857
+ loc_err += f"action '{a.get('action')}' references unknown sub_area '{sub}' for {loc_id}\n"
858
+
859
+ return loc_err.strip() if loc_err else None
860
+
861
+
862
+ def _local_content_safety_check(actions: List[Dict[str, Any]]) -> Optional[str]:
863
+ for action in actions:
864
+ text = str(action.get("action", "")).lower()
865
+ matched = next((term for term in _UNSAFE_PLAN_TERMS if term in text), None)
866
+ if matched:
867
+ return f"action '{action.get('action')}' contains prohibited unsafe content ({matched})"
868
+ return None
869
+
870
+
871
+ def validate_plan(state: DayPlannerState) -> DayPlannerState:
872
+ current_time = state.get("current_time", "")
873
+ remaining_start = current_time.rsplit(" ", 1)[-1] if " " in current_time else None
874
+ local_issue = _local_overlap_check(
875
+ state["fine_plan"],
876
+ mode=state.get("mode", "full_day"),
877
+ remaining_start=remaining_start,
878
+ ) or _local_location_check(
879
+ state["fine_plan"], state.get("places", [])
880
+ ) or _local_academic_venue_check(state["fine_plan"], state["persona"]) or _local_content_safety_check(state["fine_plan"])
881
+ if local_issue:
882
+ logger.info("[day_planner] local validation failed: %s", local_issue)
883
+ return {
884
+ **state,
885
+ "conflict_detected": True,
886
+ "conflict_reason": local_issue,
887
+ "retry_count": state.get("retry_count", 0) + 1,
888
+ }
889
+
890
+ # Local checks passed -- do one semantic sanity pass via the LLM
891
+ # (catches persona-incoherence, not just arithmetic).
892
+ system_prompt = (
893
+ "You are QA-checking a simulated character's day plan for internal consistency "
894
+ "with their persona (not just time-math, which has already been verified). "
895
+ "Flag it invalid only for clear nonsensical sequencing."
896
+
897
+ )
898
+ user_prompt = (
899
+ f"PERSONA:\n{_persona_block(state['persona'])}\n\n"
900
+ f"DETAILED PERSONA:\n{json.dumps(state['persona'], indent=2)}\n\n"
901
+ f"COARSE PLAN (intent and priorities):\n{json.dumps(state.get('coarse_plan', []), indent=2)}\n\n"
902
+ f"FINE PLAN:\n{json.dumps(state['fine_plan'], indent=2)}\n\n"
903
+ f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n"
904
+ "Is this plan valid for this persona? Flag only clear for "
905
+ " nonsensical sequencing."
906
+ )
907
+ result = call_gemini(system_prompt, user_prompt, ValidationResult, "default")
908
+
909
+ if not result.valid:
910
+ logger.info("[day_planner] semantic validation failed: %s", result.reason)
911
+ return {
912
+ **state,
913
+ "conflict_detected": True,
914
+ "conflict_reason": result.reason,
915
+ "retry_count": state.get("retry_count", 0) + 1,
916
+ }
917
+
918
+ logger.info("[day_planner] plan validated successfully")
919
+ return {
920
+ **state,
921
+ "conflict_detected": False,
922
+ "conflict_reason": None,
923
+ "day_plan": state["fine_plan"],
924
+ }
925
+
926
+
927
+ # ---------------------------------------------------------------------------
928
+ # Conditional routing
929
+ # ---------------------------------------------------------------------------
930
+
931
+ def route_after_validation(state: DayPlannerState) -> str:
932
+ if not state.get("conflict_detected"):
933
+ return "accept"
934
+ if state.get("retry_count", 0) >= MAX_PLAN_RETRIES:
935
+ logger.warning(
936
+ "[day_planner] max retries (%d) reached, force-accepting last plan with error flag",
937
+ MAX_PLAN_RETRIES,
938
+ )
939
+ return "give_up"
940
+ return "retry"
941
+
942
+
943
+ def _force_accept(state: DayPlannerState) -> DayPlannerState:
944
+ """Terminal fallback so a stubborn persona can never infinite-loop the graph.
945
+
946
+ This path is intentionally deterministic, but it must still honour the
947
+ same content constraints as a normally validated plan. Retrying a model
948
+ output must never turn a rejected unsafe action into an accepted schedule.
949
+ """
950
+ # A remaining-day replan is optional. Unlike startup/day-handoff plans,
951
+ # it has a known-good predecessor. Never force-accept malformed output
952
+ # here: the caller will retain the prior plan instead.
953
+ if state.get("mode") == "remaining":
954
+ return {
955
+ **state,
956
+ "day_plan": [],
957
+ "replan_rejected": True,
958
+ "error": (
959
+ f"replan rejected after {MAX_PLAN_RETRIES} retries, last issue: "
960
+ f"{state.get('conflict_reason')}"
961
+ ),
962
+ }
963
+
964
+ # Full-day and next-day plans have no safe predecessor. Never install an
965
+ # LLM-invalid plan after retries: produce a small, valid local schedule at
966
+ # a known location instead. This contains provider mistakes before they
967
+ # reach the action manager and preserves a living agent over a server exit.
968
+ places = _normalize_places(state.get("places", []))
969
+ valid_locations = {place.id for place in places}
970
+ preferred_locations = [
971
+ state.get("current_location_id"),
972
+ state.get("persona", {}).get("Hostel"),
973
+ next(iter(valid_locations), None),
974
+ ]
975
+ location_id = next((item for item in preferred_locations if item in valid_locations), None)
976
+ if not location_id:
977
+ return {
978
+ **state,
979
+ "day_plan": [],
980
+ "error": "planner rejected after retries and no valid fallback location is available",
981
+ }
982
+ return {
983
+ **state,
984
+ "day_plan": [
985
+ {"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},
986
+ {"action": "Unscheduled downtime", "start": "07:00", "end": "24:00", "location_id": location_id, "sub_area": None, "energy_change": -0.02, "emotion_change": 0.0},
987
+ ],
988
+ "error": f"deterministic fallback used after {MAX_PLAN_RETRIES} retries: {state.get('conflict_reason')}",
989
+ }
990
+
991
+
992
+
993
+ # ---------------------------------------------------------------------------
994
+ # Graph construction
995
+ # ---------------------------------------------------------------------------
996
+
997
+ def build_day_planner_graph():
998
+ graph = StateGraph(DayPlannerState)
999
+
1000
+ graph.add_node("generate_coarse_plan", generate_coarse_plan)
1001
+ graph.add_node("validate_coarse_window", validate_coarse_window)
1002
+ graph.add_node("decompose_hourly", decompose_hourly)
1003
+ graph.add_node("validate_hourly_refinement", validate_hourly_refinement)
1004
+ graph.add_node("decompose_fine", decompose_fine)
1005
+ graph.add_node("validate_plan", validate_plan)
1006
+ graph.add_node("force_accept", _force_accept)
1007
+
1008
+ graph.set_entry_point("generate_coarse_plan")
1009
+ graph.add_edge("generate_coarse_plan", "validate_coarse_window")
1010
+ graph.add_conditional_edges(
1011
+ "validate_coarse_window",
1012
+ route_after_validation,
1013
+ {
1014
+ "accept": "decompose_hourly",
1015
+ "retry": "generate_coarse_plan",
1016
+ "give_up": "force_accept",
1017
+ },
1018
+ )
1019
+ graph.add_edge("decompose_hourly", "validate_hourly_refinement")
1020
+ graph.add_conditional_edges(
1021
+ "validate_hourly_refinement",
1022
+ route_after_validation,
1023
+ {
1024
+ "accept": "decompose_fine",
1025
+ "retry": "generate_coarse_plan",
1026
+ "give_up": "force_accept",
1027
+ },
1028
+ )
1029
+ graph.add_edge("decompose_fine", "validate_plan")
1030
+
1031
+ graph.add_conditional_edges(
1032
+ "validate_plan",
1033
+ route_after_validation,
1034
+ {
1035
+ "accept": END,
1036
+ "retry": "generate_coarse_plan",
1037
+ "give_up": "force_accept",
1038
+ },
1039
+ )
1040
+ graph.add_edge("force_accept", END)
1041
+
1042
+ return graph.compile()
1043
+
1044
+
1045
+ _compiled_graph = None
1046
+ _compiled_graph_lock = threading.Lock()
1047
+
1048
+
1049
+ def get_compiled_graph():
1050
+ global _compiled_graph
1051
+ if _compiled_graph is None:
1052
+ with _compiled_graph_lock:
1053
+ if _compiled_graph is None:
1054
+ _compiled_graph = build_day_planner_graph()
1055
+ return _compiled_graph
1056
+
1057
+
1058
+ # ---------------------------------------------------------------------------
1059
+ # AgentModule-compatible entrypoint
1060
+ #
1061
+ # Matches the run(agent, world_state) -> dict contract from core/module_base.py
1062
+ # discussed earlier, so this drops straight into the tick orchestrator/registry
1063
+ # once that's wired up. Falls back to a plain function if module_base isn't
1064
+ # importable yet (e.g. running this file standalone).
1065
+ # ---------------------------------------------------------------------------
1066
+
1067
+ def run(agent: Any, world_state: dict) -> dict:
1068
+ """
1069
+ agent is expected to expose:
1070
+ agent.persona -> dict
1071
+ agent.relevant_memories -> List[str] (empty for now)
1072
+ agent.yesterday_summary -> Optional[str] (empty for now)
1073
+ world_state is expected to expose:
1074
+ 'current_time' key (str)
1075
+ optionally a 'places' key (List[Dict[str, str]]) -- if omitted,
1076
+ places are loaded from places.json via load_places().
1077
+ 'persona_name' is used when saving the final plan to data/Short_term_db.
1078
+ 'mode' can be 'full_day' (default), 'remaining', or 'next_day'.
1079
+ 'current_location_id' optionally tells the planner where the agent is
1080
+ (useful for remaining-day and next-day planning).
1081
+ """
1082
+ places = world_state.get("places") or load_places()
1083
+ mode = world_state.get("mode", "full_day")
1084
+
1085
+ from src.agents.daily_flavor import pick_theme, pick_emotion
1086
+ theme = world_state.get("daily_theme") or pick_theme()
1087
+ emotion = world_state.get("daily_emotion") or pick_emotion()
1088
+
1089
+ initial_state: DayPlannerState = {
1090
+ "persona": getattr(agent, "persona", {}),
1091
+ "relevant_memories": getattr(agent, "relevant_memories", []) or [],
1092
+ "yesterday_summary": getattr(agent, "yesterday_summary", None),
1093
+ "current_time": world_state.get("current_time", "00:00"),
1094
+ "places": places,
1095
+ "mode": mode,
1096
+ "current_location_id": world_state.get("current_location_id"),
1097
+ "handoff_context": world_state.get("handoff_context"),
1098
+ "upcoming_events": world_state.get("upcoming_events", []),
1099
+ "daily_theme": theme,
1100
+ "daily_emotion": emotion,
1101
+ "retry_count": 0,
1102
+ }
1103
+
1104
+ # API-key traversal is owned exclusively by gemini_client. If every key
1105
+ # fails there, propagate its quota-exhausted signal to the server/UI.
1106
+ final_state = get_compiled_graph().invoke(initial_state)
1107
+
1108
+ # Save day plan to short-term memory
1109
+ from src.agents.Short_term import save_day_plan, date_from_simulation_time
1110
+
1111
+ sim_date = date_from_simulation_time(world_state.get("current_time", "00:00"))
1112
+ save_day_plan(initial_state["persona"].get("Name") or initial_state["persona"].get("name", "unknown"), sim_date, final_state.get("day_plan", []))
1113
+
1114
+ return {
1115
+ "day_plan": final_state.get("day_plan", []),
1116
+ "error": final_state.get("error"),
1117
+ "memory_entries": [
1118
+ f"Planned today: {len(final_state.get('day_plan', []))} scheduled actions."
1119
+ ],
1120
+ }
1121
+
1122
+
1123
+ try:
1124
+ from src.core.module_base import AgentModule
1125
+ from src.core.registry import register_module
1126
+
1127
+
1128
+ @register_module
1129
+ class DayPlanner(AgentModule):
1130
+ name = "day_planner"
1131
+
1132
+ def run(self, agent, world_state):
1133
+ return run(agent, world_state)
1134
+
1135
+ except ImportError:
1136
+ # core/module_base.py and core/registry.py not wired up yet -- fine,
1137
+ # `run()` above works standalone in the meantime.
1138
+ pass
1139
+
1140
+ # ---------------------------------------------------------------------------
1141
+ # Standalone smoke test
1142
+ #
1143
+ # Uses only a persona -- relevant_memories and yesterday_summary are left
1144
+ # empty, exactly as requested for this stage.
1145
+ # ---------------------------------------------------------------------------
1146
+
1147
+ if __name__ == "__main__":
1148
+ import argparse
1149
+
1150
+ from src.config import PERSONALITIES_DIR
1151
+
1152
+ parser = argparse.ArgumentParser(description="Run the day planner smoke test")
1153
+ parser.add_argument(
1154
+ "persona",
1155
+ nargs="?",
1156
+ default="gurnoor_singh",
1157
+ help="Persona name or persona JSON path (for example: tanishq or tanishq/tanishq.json)",
1158
+ )
1159
+ args = parser.parse_args()
1160
+
1161
+ def resolve_persona_path(persona_arg: str) -> Path:
1162
+ candidate_path = Path(persona_arg)
1163
+ if candidate_path.exists():
1164
+ return candidate_path
1165
+
1166
+ if candidate_path.suffix == ".json":
1167
+ matches = list(PERSONALITIES_DIR.glob(f"**/{candidate_path.name}"))
1168
+ if matches:
1169
+ return matches[0]
1170
+
1171
+ matches = sorted(PERSONALITIES_DIR.glob(f"**/{persona_arg}/{persona_arg}.json"))
1172
+ if matches:
1173
+ return matches[0]
1174
+
1175
+ matches = sorted(PERSONALITIES_DIR.glob(f"**/{persona_arg}.json"))
1176
+ if matches:
1177
+ return matches[0]
1178
+
1179
+ candidates = sorted(PERSONALITIES_DIR.glob("**/*.json"))
1180
+ if not candidates:
1181
+ raise FileNotFoundError(f"No persona JSON files found under {PERSONALITIES_DIR}")
1182
+ raise FileNotFoundError(
1183
+ f"Could not find persona '{persona_arg}'. Available personas: "
1184
+ f"{', '.join(sorted({p.parent.name for p in candidates}))}"
1185
+ )
1186
+
1187
+ sample_persona_path = resolve_persona_path(args.persona)
1188
+
1189
+ sample_persona = json.loads(sample_persona_path.read_text())
1190
+
1191
+ class _FakeAgent:
1192
+ persona = sample_persona
1193
+ relevant_memories: List[str] = []
1194
+ yesterday_summary: Optional[str] = None
1195
+
1196
+
1197
+ # Swap for load_places() once your real data/environment/places.json exists.
1198
+ # WorrdState is expected to have the Places
1199
+
1200
+ result = run(
1201
+ _FakeAgent(),
1202
+ {"current_time": "2026-07-03 06:00", "places": None, "persona_name": sample_persona_path.stem},
1203
+ )
1204
+
1205
+ print(result)
1206
+
1207
+ col_widths = (10, 10, 60, 20)
1208
+ header = (
1209
+ f"{'START':<{col_widths[0]}}{'END':<{col_widths[1]}}"
1210
+ f"{'ACTION':<{col_widths[2]}}{'LOCATION':<{col_widths[3]}}"
1211
+ )
1212
+ print(header)
1213
+ print("-" * sum(col_widths))
1214
+ for item in result["day_plan"]:
1215
+ action = item.get("action", "")
1216
+ if len(action) > col_widths[2] - 2:
1217
+ action = action[: col_widths[2] - 5] + "..."
1218
+ print(
1219
+ f"{item['start']:<{col_widths[0]}}{item['end']:<{col_widths[1]}}"
1220
+ f"{action:<{col_widths[2]}}{item.get('location_id', ''):<{col_widths[3]}}"
1221
+ )
1222
+
1223
+ if result.get("memory_entries"):
1224
+ print("\nMemory entries logged:")
1225
+ for m in result["memory_entries"]:
1226
+ print(f" - {m}")
1227
+
backend/src/agents/memory_index.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Operate Qdrant-only long-term memory.
2
+
3
+ ``migrate-json`` imports legacy Long_term_db archives once. Pass
4
+ ``--delete-source`` only after checking the reported indexed count; it removes
5
+ the obsolete JSON archives after their idempotent Qdrant upsert succeeds.
6
+
7
+ ``clear`` removes only Valhalla's durable Qdrant collections. It never touches
8
+ short-term runtime files, checkpoints, or the live simulation process.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ from collections import defaultdict
15
+ from pathlib import Path
16
+
17
+ from src.agents.vector_memory import VectorMemoryRetriever, archive_records
18
+ from src.config import DATA_DIR
19
+
20
+
21
+ def _legacy_archives() -> tuple[dict[str, list], list[Path]]:
22
+ root = DATA_DIR / "Long_term_db"
23
+ result: dict[str, list] = defaultdict(list)
24
+ sources: list[Path] = []
25
+ for path in root.glob("*/memory.json"):
26
+ try:
27
+ content = json.loads(path.read_text(encoding="utf-8"))
28
+ except (OSError, json.JSONDecodeError):
29
+ continue
30
+ agent = str(content.get("persona_name") or path.parent.name)
31
+ for day in content.get("days", []):
32
+ result[agent].extend(archive_records(agent, day))
33
+ sources.append(path)
34
+ return result, sources
35
+
36
+
37
+ def main() -> int:
38
+ parser = argparse.ArgumentParser(description="Manage Qdrant-only long-term memory")
39
+ parser.add_argument("operation", choices=("status", "migrate-json", "clear"))
40
+ parser.add_argument("--agent", help="limit work to one persona name")
41
+ parser.add_argument("--delete-source", action="store_true",
42
+ help="after a successful migrate-json, delete legacy memory.json files")
43
+ parser.add_argument("--yes", action="store_true",
44
+ help="required for clear; confirms permanent deletion of Valhalla Qdrant memory")
45
+ args = parser.parse_args()
46
+ index = VectorMemoryRetriever()
47
+ if not index.available:
48
+ print("Qdrant memory is unavailable. Set SIM_SEMANTIC_MEMORY_ENABLED=true, QDRANT_URL, QDRANT_API_KEY, and Gemini keys.")
49
+ return 2
50
+ if args.operation == "clear":
51
+ if args.agent:
52
+ parser.error("clear always removes all Valhalla long-term memory; --agent is not supported")
53
+ if not args.yes:
54
+ parser.error("clear is permanent; rerun with --yes to remove all Valhalla long-term memory")
55
+ result = index.clear_all_memory()
56
+ deleted = result["deleted_collections"]
57
+ failed = result["failed_collections"]
58
+ print(f"Cleared {len(deleted)} Valhalla long-term-memory collection(s): {', '.join(deleted) or '(none found)'}")
59
+ if failed:
60
+ print(f"Could not clear {len(failed)} collection(s): {', '.join(failed)}")
61
+ return 1
62
+ return 0
63
+
64
+ archives, sources = _legacy_archives()
65
+ if args.agent:
66
+ archives = {args.agent: archives.get(args.agent, [])}
67
+ sources = [p for p in sources if p.parent.name == args.agent.lower().replace(" ", "_")]
68
+ if args.operation == "status":
69
+ agents = sorted(archives) if archives else ([args.agent] if args.agent else [])
70
+ for agent in agents:
71
+ print(f"{agent}: {index.collection_status(agent)}")
72
+ return 0
73
+
74
+ successful = True
75
+ for agent, records in sorted(archives.items()):
76
+ indexed = index.index_records(agent, records)
77
+ expected = len({record.id for record in records})
78
+ print(f"{agent}: indexed {indexed}/{expected} legacy memories")
79
+ successful = successful and indexed == expected
80
+ if args.delete_source:
81
+ if not successful:
82
+ print("Legacy JSON was retained because migration was incomplete.")
83
+ return 1
84
+ for path in sources:
85
+ path.unlink()
86
+ try:
87
+ path.parent.rmdir()
88
+ except OSError:
89
+ pass
90
+ print(f"Deleted {len(sources)} migrated legacy memory.json file(s).")
91
+ return 0
92
+
93
+
94
+ if __name__ == "__main__":
95
+ raise SystemExit(main())
backend/src/agents/react.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ React -- decides, given an agent's current action (if any) and what it just
3
+ perceived, whether to keep executing that action or interrupt and replan.
4
+
5
+ Design notes
6
+ ------------
7
+ All calls into this module are cheap heuristic checks with no LLM round-trip:
8
+ - no current action yet -> always replan
9
+ - current action's end_tick has passed -> always replan
10
+ - mid-action, nothing new perceived this tick -> always continue
11
+
12
+ The LLM-based decision layer has moved to brain.decide_tick(), which runs
13
+ only when the perceive phase detects novel observations.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from pydantic import BaseModel
21
+
22
+ from src.core.log import get_logger
23
+ from src.core.world_state import CurrentAction
24
+ from src.core.perceive import Observation
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ class ReactionDecision(BaseModel):
30
+ should_replan: bool
31
+ reason: str
32
+
33
+
34
+ def decide_reaction(
35
+ agent_id: str,
36
+ persona: Dict[str, Any],
37
+ current_action: Optional[CurrentAction],
38
+ observations: List[Observation],
39
+ memories: List[str],
40
+ tick: int,
41
+ ) -> ReactionDecision:
42
+ """Cheap heuristic decision: no LLM involved."""
43
+ if current_action is None:
44
+ return ReactionDecision(should_replan=True, reason="no current action to continue")
45
+
46
+ if current_action.is_finished(tick):
47
+ return ReactionDecision(should_replan=True, reason="current action has finished")
48
+
49
+ if not observations:
50
+ return ReactionDecision(should_replan=False, reason="mid-action, nothing new perceived")
51
+
52
+ # Mid-action with observations: cheap heuristic — always continue.
53
+ # The brain's decide_tick (LLM) will handle novel observation decisions.
54
+ return ReactionDecision(should_replan=False, reason="mid-action, continuing current action")
55
+
56
+
57
+ # Standalone sanity check (doesn't hit the network)
58
+ if __name__ == "__main__":
59
+ action = CurrentAction(description="sleeping", start_tick=0, end_tick=60)
60
+
61
+ r1 = decide_reaction("a", {}, None, [], [], tick=0)
62
+ assert r1.should_replan is True
63
+
64
+ r2 = decide_reaction("a", {}, action, [], [], tick=61)
65
+ assert r2.should_replan is True
66
+
67
+ r3 = decide_reaction("a", {}, action, [], [], tick=30)
68
+ assert r3.should_replan is False
69
+
70
+ print("react.py cheap-path sanity checks passed (LLM branch not exercised here).")
backend/src/agents/vector_memory.py ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistent, per-persona Cloud Qdrant long-term memory and RAG retrieval.
2
+
3
+ Qdrant is the sole long-term store. The active day's short-term JSON is
4
+ converted into durable memory records during handoff; once indexing succeeds,
5
+ that operational file can be removed. Retrieval returns query-relevant,
6
+ ranked context for model prompts.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import math
13
+ import re
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Iterable, List, Optional
17
+
18
+ from src import config as _cfg
19
+ from src.core.log import get_logger
20
+ from src.llm.gemini_client import ProviderFailureError, embed_content
21
+
22
+ logger = get_logger(__name__)
23
+
24
+ _IMPORTANCE = {
25
+ "summary": 3.0, "conversation": 2.5, "action": 1.5,
26
+ "key_event": 2.0, "observation": 0.5, "plan": 1.0,
27
+ }
28
+ _GIB = 1024 ** 3
29
+
30
+
31
+ def safe_agent_id(agent_id: str) -> str:
32
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", agent_id.strip()).strip("._-").lower() or "unknown"
33
+
34
+
35
+ def collection_name(agent_id: str) -> str:
36
+ """One active collection per agent; later schema versions are isolated."""
37
+ base = safe_agent_id(agent_id)
38
+ return base if _cfg.MEMORY_COLLECTION_VERSION == "v1" else f"{base}__{_cfg.MEMORY_COLLECTION_VERSION}"
39
+
40
+
41
+ def source_hash(agent_id: str, date: str, kind: str, text: str) -> str:
42
+ return hashlib.sha256(f"{agent_id}\x1f{date}\x1f{kind}\x1f{text}".encode("utf-8")).hexdigest()
43
+
44
+
45
+ def point_id(agent_id: str, date: str, kind: str, text: str) -> int:
46
+ # Qdrant accepts unsigned 64-bit point IDs. A deterministic ID makes
47
+ # archive writes naturally idempotent.
48
+ return int(source_hash(agent_id, date, kind, text)[:15], 16)
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class MemoryRecord:
53
+ agent_id: str
54
+ date: str
55
+ kind: str
56
+ text: str
57
+ importance: float
58
+
59
+ @property
60
+ def hash(self) -> str:
61
+ return source_hash(self.agent_id, self.date, self.kind, self.text)
62
+
63
+ @property
64
+ def id(self) -> int:
65
+ return point_id(self.agent_id, self.date, self.kind, self.text)
66
+
67
+ def payload(self) -> dict:
68
+ return {"date": self.date, "kind": self.kind, "importance": self.importance,
69
+ "source_hash": self.hash, "text": self.text,
70
+ "recall_count": 0, "last_recalled_at": self.date,
71
+ "retention_score": min(1.0, max(0.0, self.importance / 3.0))}
72
+
73
+
74
+ def archive_records(agent_id: str, day: dict) -> list[MemoryRecord]:
75
+ """Convert a completed short-term day into durable Qdrant records.
76
+
77
+ Full transcripts and periodic snapshots are intentionally excluded. Plans,
78
+ completed actions, conversation summaries, explicit durable events and the
79
+ daily summary preserve the information future model calls can use.
80
+ """
81
+ date = str(day.get("date") or "")
82
+ records: list[MemoryRecord] = []
83
+ summary = str(day.get("summary") or day.get("daily_summary") or "").strip()
84
+ if summary:
85
+ records.append(MemoryRecord(agent_id, date, "summary", summary, _IMPORTANCE["summary"]))
86
+ for action in day.get("day_plan", []) or []:
87
+ text = str(action.get("action") or action.get("description") or "").strip()
88
+ if text:
89
+ location = str(action.get("location_id") or action.get("location") or "").strip()
90
+ when = "-".join(filter(None, (str(action.get("start") or ""), str(action.get("end") or ""))))
91
+ records.append(MemoryRecord(agent_id, date, "plan",
92
+ f"Planned {text}" + (f" at {location}" if location else "") + (f" ({when})" if when else ""),
93
+ _IMPORTANCE["plan"]))
94
+ for event in day.get("events", []) or []:
95
+ event_type = str(event.get("type") or "")
96
+ if event_type == "action_completed":
97
+ action = str(event.get("action") or "").strip()
98
+ if action:
99
+ outcome = str(event.get("outcome") or "").strip()
100
+ location = str(event.get("location") or "").strip()
101
+ text = f"Completed {action}" + (f" at {location}" if location else "") + (f": {outcome}" if outcome else "")
102
+ records.append(MemoryRecord(agent_id, date, "action", text, _IMPORTANCE["action"]))
103
+ elif event_type in {"key_event", "durable_memory", "memory"}:
104
+ details = event.get("details") if isinstance(event.get("details"), dict) else {}
105
+ text = str(event.get("summary") or details.get("content") or "").strip()
106
+ if text:
107
+ records.append(MemoryRecord(agent_id, date, "key_event", text,
108
+ float(details.get("importance", _IMPORTANCE["key_event"]))))
109
+ for conversation in day.get("conversations", []) or []:
110
+ text = str(conversation.get("summary") or "").strip()
111
+ if text:
112
+ records.append(MemoryRecord(agent_id, date, "conversation", text, _IMPORTANCE["conversation"]))
113
+ for event in day.get("key_events", []) or []:
114
+ text = str(event.get("summary") or event.get("text") or "").strip()
115
+ if text:
116
+ records.append(MemoryRecord(agent_id, date, "key_event", text,
117
+ float(event.get("importance", _IMPORTANCE["key_event"]))))
118
+ return records
119
+
120
+
121
+ class VectorMemoryRetriever:
122
+ """Cloud Qdrant writer/retriever implementing the application's RAG layer."""
123
+
124
+ def __init__(self, *, client=None, embedder=None, qmodels=None) -> None:
125
+ self._client = client
126
+ self._qmodels = qmodels
127
+ self._embedder = embedder
128
+ self._ok = False
129
+ if not _cfg.SEMANTIC_MEMORY_ENABLED:
130
+ return
131
+ try:
132
+ if self._client is None:
133
+ if not _cfg.QDRANT_URL or not _cfg.QDRANT_API_KEY:
134
+ raise RuntimeError("QDRANT_URL and QDRANT_API_KEY are required")
135
+ from qdrant_client import QdrantClient
136
+ self._client = QdrantClient(url=_cfg.QDRANT_URL, api_key=_cfg.QDRANT_API_KEY)
137
+ if self._qmodels is None:
138
+ from qdrant_client.http import models as qmodels
139
+ self._qmodels = qmodels
140
+ self._ok = True
141
+ logger.info("[vector_memory] Cloud Qdrant semantic memory enabled")
142
+ except Exception as exc:
143
+ logger.warning("[vector_memory] unavailable (%s); long-term recall is empty", exc)
144
+
145
+ @property
146
+ def available(self) -> bool:
147
+ return self._ok
148
+
149
+ def _ensure_collection(self, agent_id: str) -> str:
150
+ name = collection_name(agent_id)
151
+ if not self._client.collection_exists(name):
152
+ self._client.create_collection(
153
+ collection_name=name,
154
+ vectors_config=self._qmodels.VectorParams(
155
+ size=_cfg.MEMORY_VECTOR_DIMENSIONS,
156
+ distance=self._qmodels.Distance.COSINE,
157
+ ),
158
+ )
159
+ return name
160
+
161
+ def _embed(self, text: str, task_type: str) -> Optional[list[float]]:
162
+ if not self._ok:
163
+ return None
164
+ try:
165
+ from src.core.budget import GOVERNOR
166
+ if not GOVERNOR.can_afford("embedding", cost=1):
167
+ return None
168
+ vector = embed_content(text, task_type, _cfg.MEMORY_VECTOR_DIMENSIONS)
169
+ if len(vector) != _cfg.MEMORY_VECTOR_DIMENSIONS:
170
+ raise ValueError(f"embedding dimension {len(vector)} does not match configured dimension")
171
+ GOVERNOR.record("embedding", _cfg.MEMORY_EMBEDDING_MODEL)
172
+ return vector
173
+ except ProviderFailureError:
174
+ # This is terminal for the live simulation, not a recoverable
175
+ # single-record indexing failure. WorldEngine/Odin surface it.
176
+ raise
177
+ except Exception as exc:
178
+ logger.warning("[vector_memory] embedding failed (%s)", exc)
179
+ return None
180
+
181
+ def index_records(self, agent_id: str, records: Iterable[MemoryRecord]) -> int:
182
+ if not self._ok:
183
+ return 0
184
+ name = self._ensure_collection(agent_id)
185
+ points = []
186
+ for record in records:
187
+ vector = self._embed(record.text, "RETRIEVAL_DOCUMENT")
188
+ if vector is None:
189
+ continue
190
+ points.append(self._qmodels.PointStruct(id=record.id, vector=vector, payload=record.payload()))
191
+ if not points:
192
+ return 0
193
+ try:
194
+ self._client.upsert(collection_name=name, points=points, wait=True)
195
+ self.prune_if_needed()
196
+ return len(points)
197
+ except Exception as exc:
198
+ logger.warning("[vector_memory] upsert failed (%s)", exc)
199
+ return 0
200
+
201
+ @staticmethod
202
+ def _payload_retention_score(payload: dict) -> float:
203
+ """Retention is global, unlike semantic relevance which is query-specific."""
204
+ importance = min(1.0, max(0.0, float(payload.get("importance", 0.0)) / 3.0))
205
+ last_used = str(payload.get("last_recalled_at") or payload.get("date") or "")
206
+ recency = math.exp(-VectorMemoryRetriever._days_ago(last_used) / 90.0)
207
+ recall_count = max(0, int(payload.get("recall_count", 0)))
208
+ popularity = min(1.0, math.log1p(recall_count) / math.log(11))
209
+ return .50 * importance + .30 * recency + .20 * popularity
210
+
211
+ @staticmethod
212
+ def _estimated_point_bytes(payload: dict) -> int:
213
+ """Conservative vector + serialized-payload estimate; JSON remains intact."""
214
+ payload_bytes = len(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
215
+ return (_cfg.MEMORY_VECTOR_DIMENSIONS * 4) + payload_bytes + 128
216
+
217
+ def _all_index_points(self) -> list[tuple[str, Any]]:
218
+ points: list[tuple[str, Any]] = []
219
+ collections = getattr(self._client.get_collections(), "collections", [])
220
+ for collection in collections:
221
+ name = getattr(collection, "name", str(collection))
222
+ offset = None
223
+ while True:
224
+ batch, offset = self._client.scroll(
225
+ collection_name=name, limit=256, offset=offset,
226
+ with_payload=True, with_vectors=False,
227
+ )
228
+ # A Cloud cluster can contain other applications. Only points
229
+ # carrying Valhalla's deterministic archive marker participate
230
+ # in quota accounting or retention deletion.
231
+ points.extend(
232
+ (name, point) for point in batch
233
+ if isinstance(getattr(point, "payload", None), dict)
234
+ and point.payload.get("source_hash")
235
+ )
236
+ if offset is None:
237
+ break
238
+ return points
239
+
240
+ def prune_if_needed(self) -> int:
241
+ """Keep the rebuildable index below its configured storage budget.
242
+
243
+ Cloud Qdrant's dashboard is authoritative for billed capacity. This
244
+ conservative client-side estimate triggers ahead of that limit. Only
245
+ the lowest-retention Qdrant points are removed; there is no parallel
246
+ JSON long-term archive to consult or rebuild from.
247
+ """
248
+ if not self._ok:
249
+ return 0
250
+ try:
251
+ points = self._all_index_points()
252
+ limit = int(_cfg.MEMORY_MAX_STORAGE_GB * _GIB)
253
+ threshold = int(limit * _cfg.MEMORY_STORAGE_PRUNE_THRESHOLD)
254
+ target = int(limit * _cfg.MEMORY_STORAGE_PRUNE_TARGET)
255
+ usage = sum(self._estimated_point_bytes(point.payload or {}) for _, point in points)
256
+ if usage < threshold:
257
+ return 0
258
+ ranked = sorted(
259
+ points,
260
+ key=lambda item: self._payload_retention_score(item[1].payload or {}),
261
+ )
262
+ grouped: dict[str, list[int]] = {}
263
+ removed = 0
264
+ for name, point in ranked:
265
+ if usage <= target:
266
+ break
267
+ usage -= self._estimated_point_bytes(point.payload or {})
268
+ grouped.setdefault(name, []).append(int(point.id))
269
+ removed += 1
270
+ for name, point_ids in grouped.items():
271
+ self._client.delete(
272
+ collection_name=name,
273
+ points_selector=self._qmodels.PointIdsList(points=point_ids), wait=True,
274
+ )
275
+ if removed:
276
+ logger.warning("[vector_memory] pruned %d low-retention vector memories to enforce quota", removed)
277
+ return removed
278
+ except Exception as exc:
279
+ logger.warning("[vector_memory] quota check failed (%s); retaining index", exc)
280
+ return 0
281
+
282
+ def index_archive(self, agent_id: str, day: dict) -> int:
283
+ """Persist a completed-day snapshot and discard superseded same-day points.
284
+
285
+ The engine may archive once when the final activity starts and once at
286
+ handoff. Upserting first, then deleting only no-longer-present records
287
+ makes the later snapshot authoritative without a vulnerable delete-first
288
+ window.
289
+ """
290
+ try:
291
+ records = archive_records(agent_id, day)
292
+ indexed = self.index_records(agent_id, records)
293
+ if indexed != len(records):
294
+ return indexed
295
+ if not self._ok:
296
+ return indexed
297
+ date = str(day.get("date") or "")
298
+ wanted = {record.hash for record in records}
299
+ stale: list[int] = []
300
+ name = collection_name(agent_id)
301
+ offset = None
302
+ while True:
303
+ batch, offset = self._client.scroll(collection_name=name, limit=256, offset=offset,
304
+ with_payload=True, with_vectors=False)
305
+ stale.extend(int(point.id) for point in batch
306
+ if str((point.payload or {}).get("date") or "") == date
307
+ and (point.payload or {}).get("source_hash") not in wanted)
308
+ if offset is None:
309
+ break
310
+ if stale:
311
+ self._client.delete(collection_name=name,
312
+ points_selector=self._qmodels.PointIdsList(points=stale), wait=True)
313
+ return indexed
314
+ except Exception as exc:
315
+ logger.warning("[vector_memory] archive indexing failed (%s)", exc)
316
+ return 0
317
+
318
+ def collection_status(self, agent_id: str) -> dict:
319
+ """Report one persona's durable-vector collection without a JSON comparison."""
320
+ name = collection_name(agent_id)
321
+ if not self._ok or not self._client.collection_exists(name):
322
+ return {"available": self._ok, "collection": name, "indexed": 0}
323
+ points, offset = [], None
324
+ while True:
325
+ batch, offset = self._client.scroll(collection_name=name, limit=256, offset=offset,
326
+ with_payload=True, with_vectors=False)
327
+ points.extend(batch)
328
+ if offset is None:
329
+ break
330
+ return {"available": True, "collection": name, "indexed": len(points)}
331
+
332
+ def clear_all_memory(self) -> dict:
333
+ """Delete every non-empty Valhalla long-term-memory collection.
334
+
335
+ A Qdrant cluster can be shared with unrelated applications, so the
336
+ operation identifies a Valhalla collection by its durable
337
+ ``source_hash`` payload marker before deleting it. It does not read or
338
+ mutate short-term JSON, checkpoints, plans, or live engine state.
339
+ Empty collections contain no long-term memory and are deliberately
340
+ retained rather than guessing ownership from their name.
341
+ """
342
+ if not self._ok:
343
+ return {"available": False, "deleted_collections": [], "failed_collections": []}
344
+
345
+ deleted: list[str] = []
346
+ failed: list[str] = []
347
+ try:
348
+ collections = getattr(self._client.get_collections(), "collections", [])
349
+ except Exception as exc:
350
+ logger.warning("[vector_memory] could not list collections for memory clear (%s)", exc)
351
+ return {"available": True, "deleted_collections": [], "failed_collections": ["<list failed>"]}
352
+
353
+ for collection in collections:
354
+ name = getattr(collection, "name", str(collection))
355
+ try:
356
+ points, _ = self._client.scroll(
357
+ collection_name=name, limit=1, with_payload=True, with_vectors=False,
358
+ )
359
+ is_valhalla_memory = any(
360
+ isinstance(getattr(point, "payload", None), dict)
361
+ and point.payload.get("source_hash")
362
+ for point in points
363
+ )
364
+ if not is_valhalla_memory:
365
+ continue
366
+ self._client.delete_collection(collection_name=name)
367
+ deleted.append(name)
368
+ except Exception as exc:
369
+ logger.warning("[vector_memory] could not clear collection '%s' (%s)", name, exc)
370
+ failed.append(name)
371
+ return {
372
+ "available": True,
373
+ "deleted_collections": deleted,
374
+ "failed_collections": failed,
375
+ }
376
+
377
+ def delete_agent_memory(self, agent_id: str) -> bool:
378
+ """Remove one agent's Qdrant collection without touching other agents."""
379
+ name = collection_name(agent_id)
380
+ if not self._ok:
381
+ return False
382
+ try:
383
+ if self._client.collection_exists(name):
384
+ self._client.delete_collection(name)
385
+ return True
386
+ except Exception as exc:
387
+ logger.warning("[vector_memory] could not remove %s (%s)", name, exc)
388
+ return False
389
+
390
+ @staticmethod
391
+ def _days_ago(date: str) -> int:
392
+ try:
393
+ return max(0, (datetime.now(timezone.utc).date() - datetime.fromisoformat(date).date()).days)
394
+ except (TypeError, ValueError):
395
+ return 0
396
+
397
+ @classmethod
398
+ def rank_scored_hits(cls, hits: Iterable[Any], k: int) -> list[tuple[float, Any]]:
399
+ """Score query relevance, then select diverse top memories.
400
+
401
+ Relevance is deliberately query-specific and is not persisted as a
402
+ payload field. The returned score is 65% semantic similarity, 20%
403
+ durable importance, and 15% recency.
404
+ """
405
+ hits = list(hits)
406
+ if not hits:
407
+ return []
408
+ raw_scores = [float(getattr(hit, "score", 0.0)) for hit in hits]
409
+ lo, hi = min(raw_scores), max(raw_scores)
410
+ def semantic(score: float) -> float:
411
+ return 1.0 if hi == lo else (score - lo) / (hi - lo)
412
+ scored = []
413
+ for hit in hits:
414
+ payload = getattr(hit, "payload", {}) or {}
415
+ importance = min(1.0, max(0.0, float(payload.get("importance", 0.0)) / 3.0))
416
+ recency = math.exp(-cls._days_ago(str(payload.get("date", ""))) / _cfg.MEMORY_RECENCY_DECAY_DAYS)
417
+ score = .65 * semantic(float(getattr(hit, "score", 0.0))) + .20 * importance + .15 * recency
418
+ scored.append((score, hit))
419
+ scored.sort(key=lambda pair: pair[0], reverse=True)
420
+ # Diversity: select at most one item from a day/kind pair before a
421
+ # second from the same pair. This avoids one conversation dominating.
422
+ selected, seen = [], set()
423
+ for score, hit in scored:
424
+ payload = getattr(hit, "payload", {}) or {}
425
+ key = (payload.get("date"), payload.get("kind"))
426
+ if key in seen and len(selected) < k:
427
+ continue
428
+ selected.append((score, hit)); seen.add(key)
429
+ if len(selected) == k:
430
+ return selected
431
+ for score, hit in scored:
432
+ if hit not in [selected_hit for _, selected_hit in selected]:
433
+ selected.append((score, hit))
434
+ if len(selected) == k:
435
+ break
436
+ return selected
437
+
438
+ @classmethod
439
+ def rank_hits(cls, hits: Iterable[Any], k: int) -> list[Any]:
440
+ """Compatibility helper returning only the ranked memory hits."""
441
+ return [hit for _, hit in cls.rank_scored_hits(hits, k)]
442
+
443
+ def retrieve(self, agent_id: str, query: str, k: int = 5) -> List[str]:
444
+ if not self._ok:
445
+ return []
446
+ vector = self._embed(query, "RETRIEVAL_QUERY")
447
+ if vector is None:
448
+ return []
449
+ try:
450
+ name = self._ensure_collection(agent_id)
451
+ limit = max(k, k * _cfg.MEMORY_RETRIEVAL_CANDIDATE_MULTIPLIER)
452
+ if hasattr(self._client, "query_points"):
453
+ result = self._client.query_points(collection_name=name, query=vector, limit=limit)
454
+ hits = result.points
455
+ else: # supports older qdrant-client releases
456
+ hits = self._client.search(collection_name=name, query_vector=vector, limit=limit)
457
+ ranked = self.rank_scored_hits(hits, k)
458
+ chosen = [hit for _, hit in ranked]
459
+ self._record_recall(name, chosen)
460
+ return [
461
+ f"[{hit.payload.get('date', '')}] ({hit.payload.get('kind', '')}; relevance={score:.2f}) "
462
+ f"{hit.payload.get('text', '')}"
463
+ for score, hit in ranked
464
+ ]
465
+ except Exception as exc:
466
+ logger.warning("[vector_memory] search failed (%s); returning no RAG context", exc)
467
+ return []
468
+
469
+ def _record_recall(self, collection: str, hits: Iterable[Any]) -> None:
470
+ """Promote memories that were actually useful in a semantic recall."""
471
+ for hit in hits:
472
+ payload = getattr(hit, "payload", {}) or {}
473
+ updated = dict(payload)
474
+ updated["recall_count"] = int(payload.get("recall_count", 0)) + 1
475
+ updated["last_recalled_at"] = datetime.now(timezone.utc).date().isoformat()
476
+ updated["retention_score"] = self._payload_retention_score(updated)
477
+ try:
478
+ self._client.set_payload(collection_name=collection, payload=updated, points=[int(hit.id)], wait=False)
479
+ except Exception as exc:
480
+ logger.debug("[vector_memory] recall-retention update failed (%s)", exc)
481
+
482
+ def store(self, agent_id: str, text: str, *, kind: str = "observation", importance=None, date_str=None) -> None:
483
+ """Store an explicit durable memory immediately in Qdrant."""
484
+ date_str = date_str or datetime.now(timezone.utc).date().isoformat()
485
+ memory_kind = kind if kind in _IMPORTANCE else "observation"
486
+ self.index_records(agent_id, [MemoryRecord(
487
+ agent_id, date_str, memory_kind, text,
488
+ float(importance if importance is not None else _IMPORTANCE[memory_kind]),
489
+ )])
490
+
491
+ def rolling_summary(self, agent_id: str, days: int = 3, before_date=None) -> Optional[str]:
492
+ if not self._ok:
493
+ return None
494
+ try:
495
+ name = collection_name(agent_id)
496
+ if not self._client.collection_exists(name):
497
+ return None
498
+ points, offset = [], None
499
+ while True:
500
+ batch, offset = self._client.scroll(collection_name=name, limit=256, offset=offset,
501
+ with_payload=True, with_vectors=False)
502
+ points.extend(batch)
503
+ if offset is None:
504
+ break
505
+ cutoff = str(before_date or "9999-12-31")
506
+ summaries = [p.payload for p in points if (p.payload or {}).get("kind") == "summary"
507
+ and str((p.payload or {}).get("date", "")) < cutoff]
508
+ summaries.sort(key=lambda p: str(p.get("date", "")), reverse=True)
509
+ if not summaries:
510
+ return None
511
+ return "\n".join(f"- {p.get('date', '')}: {p.get('text', '')}" for p in reversed(summaries[:days]))
512
+ except Exception as exc:
513
+ logger.warning("[vector_memory] rolling-summary retrieval failed (%s)", exc)
514
+ return None
backend/src/auth/__init__.py ADDED
File without changes
backend/src/auth/manager.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ In-memory session-based authentication for Valhalla web admin.
3
+
4
+ Admin credentials come from the ADMIN_CREDENTIALS env var as
5
+ email:password pairs separated by semicolons. Sessions are stored
6
+ in-memory (lost on server restart).
7
+ """
8
+
9
+ import os
10
+ import hashlib
11
+ import hmac
12
+ import secrets
13
+ import logging
14
+ from datetime import datetime, timedelta
15
+ from typing import Optional
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ SESSION_EXPIRY_HOURS = 24
20
+
21
+
22
+ class AuthManager:
23
+ def __init__(self):
24
+ self._users: dict[str, dict] = {}
25
+ self._sessions: dict[str, dict] = {}
26
+ self._load_users()
27
+
28
+ def _load_users(self):
29
+ from src import config as _cfg
30
+
31
+ raw = _cfg.ADMIN_CREDENTIALS
32
+ if not raw:
33
+ logger.warning("[Auth] No ADMIN_CREDENTIALS configured — login disabled")
34
+ return
35
+ for pair in raw.split(";"):
36
+ pair = pair.strip()
37
+ if ":" not in pair:
38
+ continue
39
+ email, password = pair.split(":", 1)
40
+ email = email.strip().lower()
41
+ password = password.strip()
42
+ if email and password:
43
+ self._users[email] = {
44
+ "name": email.split("@")[0],
45
+ "password_hash": self._hash_password(password),
46
+ }
47
+ logger.info("[Auth] Loaded admin user: %s", email)
48
+
49
+ def _hash_password(self, password: str) -> str:
50
+ salt = os.urandom(32)
51
+ key = hashlib.scrypt(
52
+ password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=64
53
+ )
54
+ return salt.hex() + ":" + key.hex()
55
+
56
+ def verify_password(self, password: str, stored_hash: str) -> bool:
57
+ try:
58
+ salt_hex, key_hex = stored_hash.split(":")
59
+ salt = bytes.fromhex(salt_hex)
60
+ stored_key = bytes.fromhex(key_hex)
61
+ computed = hashlib.scrypt(
62
+ password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=64
63
+ )
64
+ return hmac.compare_digest(computed, stored_key)
65
+ except (ValueError, AttributeError):
66
+ return False
67
+
68
+ def authenticate(self, email: str, password: str) -> Optional[dict]:
69
+ """Verify credentials and return user dict, or None."""
70
+ user = self._users.get(email.lower().strip())
71
+ if not user:
72
+ return None
73
+ if not self.verify_password(password, user["password_hash"]):
74
+ return None
75
+ return {"email": email.lower().strip(), "name": user["name"], "role": "admin"}
76
+
77
+ def create_session(self, email: str) -> str:
78
+ user = self._users.get(email.lower())
79
+ if not user:
80
+ return ""
81
+ token = secrets.token_urlsafe(32)
82
+ self._sessions[token] = {
83
+ "email": email.lower(),
84
+ "name": user["name"],
85
+ "role": "admin",
86
+ "created_at": datetime.utcnow().isoformat(),
87
+ "expires_at": (
88
+ datetime.utcnow() + timedelta(hours=SESSION_EXPIRY_HOURS)
89
+ ).isoformat(),
90
+ }
91
+ return token
92
+
93
+ def validate_session(self, token: str) -> Optional[dict]:
94
+ if not token:
95
+ return None
96
+ session = self._sessions.get(token)
97
+ if not session:
98
+ return None
99
+ try:
100
+ expires = datetime.fromisoformat(session["expires_at"])
101
+ except (ValueError, TypeError):
102
+ return None
103
+ if datetime.utcnow() > expires:
104
+ self._sessions.pop(token, None)
105
+ return None
106
+ return {
107
+ "email": session["email"],
108
+ "name": session["name"],
109
+ "role": session["role"],
110
+ }
111
+
112
+ def revoke_session(self, token: str):
113
+ self._sessions.pop(token, None)
114
+
115
+
116
+ auth_manager = AuthManager()
backend/src/auth/routes.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Auth API routes — login, logout, session check.
3
+ """
4
+
5
+ from fastapi import APIRouter, HTTPException, Header
6
+ from pydantic import BaseModel
7
+ from typing import Optional
8
+
9
+ from src.auth.manager import auth_manager
10
+
11
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
12
+
13
+
14
+ class LoginInput(BaseModel):
15
+ email: str
16
+ password: str
17
+
18
+
19
+ class LogoutInput(BaseModel):
20
+ token: str
21
+
22
+
23
+ @router.post("/login")
24
+ async def login(request: LoginInput):
25
+ """Verify credentials and return a session token."""
26
+ user = auth_manager.authenticate(request.email, request.password)
27
+ if not user:
28
+ raise HTTPException(status_code=401, detail="Invalid email or password")
29
+ token = auth_manager.create_session(request.email)
30
+ return {"token": token, "user": user}
31
+
32
+
33
+ @router.post("/logout")
34
+ async def logout(request: LogoutInput):
35
+ """Revoke a session token."""
36
+ auth_manager.revoke_session(request.token)
37
+ return {"ok": True}
38
+
39
+
40
+ @router.get("/me")
41
+ async def me(authorization: Optional[str] = Header(None)):
42
+ """Return the current user from a Bearer token, or 401."""
43
+ if not authorization or not authorization.startswith("Bearer "):
44
+ raise HTTPException(status_code=401, detail="Not authenticated")
45
+ user = auth_manager.validate_session(authorization[7:])
46
+ if not user:
47
+ raise HTTPException(status_code=401, detail="Invalid or expired session")
48
+ return user
backend/src/config.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Project-wide path configuration.
3
+ Resolves the project root, backend, frontend, data, and output directories
4
+ so all modules can reference consistent paths.
5
+ """
6
+
7
+ from pathlib import Path
8
+ import os
9
+
10
+ try:
11
+ from dotenv import load_dotenv
12
+ except ImportError:
13
+ def load_dotenv(path: str | Path) -> bool:
14
+ env_path = Path(path)
15
+ if not env_path.exists():
16
+ return False
17
+
18
+ loaded = False
19
+ for raw_line in env_path.read_text().splitlines():
20
+ line = raw_line.strip()
21
+ if not line or line.startswith("#") or "=" not in line:
22
+ continue
23
+
24
+ key, value = line.split("=", 1)
25
+ key = key.strip()
26
+ value = value.strip().strip('"').strip("'")
27
+ if key and key not in os.environ:
28
+ os.environ[key] = value
29
+ loaded = True
30
+
31
+ return loaded
32
+
33
+
34
+ # DEBUG -
35
+ VERBOSE = True
36
+
37
+ # backend/src/config.py -> backend/src -> backend -> Valhalla (project root)
38
+ BASE_DIR = Path(__file__).resolve().parents[2]
39
+
40
+ # Load the project-local .env so environment variables are available before
41
+ # any config values are read.
42
+ load_dotenv(BASE_DIR / ".env")
43
+
44
+ BACKEND_DIR = BASE_DIR / "backend"
45
+ DATA_DIR = BACKEND_DIR / "data"
46
+
47
+ ENVIRONMENT_DIR = DATA_DIR / "environment"
48
+ PERSONALITIES_DIR = DATA_DIR / "personalities"
49
+
50
+ OUTPUT_DIR = BACKEND_DIR / "output"
51
+
52
+ FRONTEND_DIR = BASE_DIR / "frontend"
53
+
54
+ PLACES_FILE = ENVIRONMENT_DIR / "places.json"
55
+
56
+
57
+ # Logging
58
+ LOG_DIR = OUTPUT_DIR / "logs"
59
+ LOG_LEVEL = "DEBUG" if VERBOSE else "INFO"
60
+
61
+ ### Core logic
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Toggle helpers — read a value from the environment (.env), with a default.
65
+ # Every simulation toggle below can be set in .env and overridden on the CLI
66
+ # (see apply_overrides() / add_cli_arguments() at the bottom of this file).
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def _env_bool(name: str, default: bool) -> bool:
70
+ raw = os.environ.get(name)
71
+ if raw is None or raw.strip() == "":
72
+ return default
73
+ return raw.strip().lower() in ("1", "true", "yes", "on", "y")
74
+
75
+
76
+ def _env_int(name: str, default: int) -> int:
77
+ raw = os.environ.get(name)
78
+ try:
79
+ return int(raw) if raw not in (None, "") else default
80
+ except (TypeError, ValueError):
81
+ return default
82
+
83
+
84
+ def _env_float(name: str, default: float) -> float:
85
+ raw = os.environ.get(name)
86
+ try:
87
+ return float(raw) if raw not in (None, "") else default
88
+ except (TypeError, ValueError):
89
+ return default
90
+
91
+
92
+ def _env_str(name: str, default: str) -> str:
93
+ raw = os.environ.get(name)
94
+ return raw.strip() if raw not in (None, "") else default
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Spatial awareness
99
+ # ---------------------------------------------------------------------------
100
+
101
+ # Legacy tile-based radius (chebyshev). Kept for the tile-grid perception path.
102
+ DEFAULT_PERCEPTION_RADIUS = _env_int("SIM_PERCEPTION_RADIUS_TILES", 5)
103
+
104
+ # NEW: pixel radius for the proximity circle used for PERCEPTION (who an agent sees).
105
+ PERCEPTION_RADIUS_PX = _env_int("SIM_PERCEPTION_RADIUS_PX", 50)
106
+
107
+ # Pixel radius for starting a CONVERSATION — agents must be this close to talk
108
+ # (tighter than perception, so they see each other before they chat).
109
+ CONVERSATION_RADIUS_PX = _env_int("SIM_CONVERSATION_RADIUS_PX", 20)
110
+
111
+ # Whether the perception step runs each tick (0-LLM either way).
112
+ PERCEPTION_ENABLED = _env_bool("SIM_PERCEPTION_ENABLED", True)
113
+
114
+ # Run perception only every N ticks (1 = every tick). Higher = lighter CPU, less
115
+ # frequent "who's near me" updates. Perception is 0-LLM regardless.
116
+ PERCEPTION_EVERY_N_TICKS = max(1, _env_int("SIM_PERCEPTION_EVERY_N_TICKS", 3))
117
+
118
+ # Simulation date the run starts on (YYYY-MM-DD). Used to look up / store that
119
+ # day's plans and memory. Change this to run/limit a specific day.
120
+ SIM_START_DATE = _env_str("SIM_START_DATE", "2026-07-03")
121
+
122
+ # Time of day the run starts at (HH:MM). 00:00 = midnight (agents asleep at their
123
+ # hostels); set e.g. 08:00 to begin during the day so movement is visible at once.
124
+ SIM_START_TIME = _env_str("SIM_START_TIME", "00:00")
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Simulation clock
128
+ # SIM_MINUTES_PER_TICK = sim-minutes advanced per engine tick.
129
+ # REAL_SECONDS_PER_SIM_MINUTE = wall-clock seconds per 1 sim-minute.
130
+ # REAL_SECONDS_PER_TICK = derived (do NOT set manually).
131
+ #
132
+ # Default is 4.0 real-sec/sim-min => a full sim-day (1440 min) takes ~96 real
133
+ # minutes. Clock speed changes observation cadence, not the deterministic
134
+ # API-key traversal policy.
135
+ # ---------------------------------------------------------------------------
136
+ SIM_MINUTES_PER_TICK = _env_int("SIM_MINUTES_PER_TICK", 1)
137
+ REAL_SECONDS_PER_SIM_MINUTE = _env_float("SIM_REAL_SECONDS_PER_SIM_MINUTE", 4.0)
138
+ REAL_SECONDS_PER_TICK: float = SIM_MINUTES_PER_TICK * REAL_SECONDS_PER_SIM_MINUTE
139
+
140
+ # Global speed multiplier (2.0 = twice as fast, 0.5 = half). Applied on top of
141
+ # the clock above by the engine's run loop.
142
+ TICK_SPEED = _env_float("SIM_TICK_SPEED", 1.0)
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Cognition / LLM budget toggles
146
+ # ---------------------------------------------------------------------------
147
+
148
+ # Reflex (react) LLM escalation. LOCKED OFF for the demo — the brain's reflex
149
+ # path stays 0-LLM (heuristic "keep going"). Flip to true (SIM_REFLEX_LLM=true)
150
+ # to enable the whitelisted reflex escalations in the future.
151
+ REFLEX_LLM_ENABLED = _env_bool("SIM_REFLEX_LLM", False)
152
+
153
+ # At most one reflex LLM escalation per agent per this many sim-minutes.
154
+ REFLEX_RATE_LIMIT_MINUTES = _env_int("SIM_REFLEX_RATE_LIMIT_MINUTES", 30)
155
+
156
+ # Long-term memory is Qdrant-only. Short-term JSON is active-day operational
157
+ # state, never a long-term keyword-search fallback.
158
+ MEMORY_BACKEND = "vector"
159
+
160
+ # Cloud Qdrant long-term-memory settings. Credentials are deliberately read
161
+ # only from the environment. When unavailable, recall is empty and archival
162
+ # keeps its short-term source rather than creating a local long-term archive.
163
+ SEMANTIC_MEMORY_ENABLED = _env_bool("SIM_SEMANTIC_MEMORY_ENABLED", True)
164
+ QDRANT_URL = _env_str("QDRANT_URL", "")
165
+ QDRANT_API_KEY = _env_str("QDRANT_API_KEY", "")
166
+ MEMORY_EMBEDDING_MODEL = _env_str("SIM_MEMORY_EMBEDDING_MODEL", "gemini-embedding-001")
167
+ MEMORY_VECTOR_DIMENSIONS = _env_int("SIM_MEMORY_VECTOR_DIMENSIONS", 768)
168
+ MEMORY_COLLECTION_VERSION = _env_str("SIM_MEMORY_COLLECTION_VERSION", "v1")
169
+ MEMORY_RETRIEVAL_CANDIDATE_MULTIPLIER = max(1, _env_int("SIM_MEMORY_RETRIEVAL_CANDIDATE_MULTIPLIER", 4))
170
+ MEMORY_RECENCY_DECAY_DAYS = max(1.0, _env_float("SIM_MEMORY_RECENCY_DECAY_DAYS", 30.0))
171
+ # Application-side quota guard for Cloud Qdrant. Qdrant Cloud reports actual
172
+ # plan usage in its dashboard; this estimate keeps the index below the chosen
173
+ # local budget before that dashboard limit is approached.
174
+ MEMORY_MAX_STORAGE_GB = max(0.1, _env_float("SIM_MEMORY_MAX_STORAGE_GB", 4.0))
175
+ MEMORY_STORAGE_PRUNE_THRESHOLD = min(1.0, max(0.1, _env_float("SIM_MEMORY_STORAGE_PRUNE_THRESHOLD", 0.90)))
176
+ MEMORY_STORAGE_PRUNE_TARGET = min(MEMORY_STORAGE_PRUNE_THRESHOLD, max(0.05, _env_float("SIM_MEMORY_STORAGE_PRUNE_TARGET", 0.85)))
177
+
178
+ # Cap on full day-plan regenerations triggered mid-day per agent (budget guard).
179
+ MAX_REPLANS_PER_AGENT_PER_DAY = _env_int("SIM_MAX_REPLANS_PER_AGENT_PER_DAY", 3)
180
+
181
+ # Budget governor: soft ceiling on LLM calls per real hour across the whole sim.
182
+ # 0 = no ceiling. When exceeded, cognition degrades gracefully (skip reflex,
183
+ # defer replans) — the sim keeps running on the 0-LLM executor path.
184
+ LLM_HOURLY_CEILING = _env_int("SIM_LLM_HOURLY_CEILING", 0)
185
+
186
+ # Energy and emotion thresholds for LLM calls
187
+ DECIDE_MIN_ENERGY = _env_float("SIM_DECIDE_MIN_ENERGY", 0.1)
188
+ DECIDE_MIN_EMOTION = _env_float("SIM_DECIDE_MIN_EMOTION", 0.1)
189
+
190
+ # Web login admin credentials (email:password pairs, semicolon-separated)
191
+ ADMIN_CREDENTIALS = _env_str("ADMIN_CREDENTIALS", "")
192
+ # An observation may change every tick while agents travel together. Limit
193
+ # each agent's advisory LLM reflex to avoid turning normal movement into an
194
+ # unbounded API stream; their action state machines still run every tick.
195
+ DECIDE_COOLDOWN_TICKS = _env_int("SIM_DECIDE_COOLDOWN_TICKS", 30)
196
+ CONVERSATION_MIN_ENERGY = _env_float("SIM_CONVERSATION_MIN_ENERGY", 0.05)
197
+ CONVERSATION_MIN_EMOTION = _env_float("SIM_CONVERSATION_MIN_EMOTION", 0.05)
198
+
199
+ # LLM creativity profile. This single observer-facing setting controls how
200
+ # varied plans, decisions, and conversations may be while keeping memory
201
+ # summaries intentionally stable. 0.0 is conservative; 1.0 is lively.
202
+ SIM_CREATIVITY = min(1.0, max(0.0, _env_float("SIM_CREATIVITY", 1.0)))
203
+ # Controls the size of non-LLM wellbeing variation. This is deliberately
204
+ # independent from creativity: an observer can ask for more varied plans
205
+ # without making students' energy and mood unrealistically volatile.
206
+ SIM_WELLBEING_VARIABILITY = min(1.0, max(0.0, _env_float("SIM_WELLBEING_VARIABILITY", 0.75)))
207
+ TEMPERATURE = 0.7 + (0.4 * SIM_CREATIVITY) # planning and decisions: 1.1 at lively
208
+ CONVERSATION_TEMPERATURE = 0.6 + (0.4 * SIM_CREATIVITY) # 1.0 at lively
209
+ SUMMARY_TEMPERATURE = 0.5
210
+ # The simulation intentionally uses one model. Key traversal, implemented in
211
+ # ``gemini_client``, is the only provider recovery behaviour.
212
+ GEMINI_MODEL = _env_str("SIM_GEMINI_MODEL", "gemini-3.1-flash-lite")
213
+
214
+ # Support multiple API keys (comma-separated in env var). When numbered
215
+ # variables are used, they are read in ascending numeric order.
216
+ # ``gemini_client`` rebuilds its circular linked list from this ordered list
217
+ # for every API call, beginning at index 1 each time.
218
+ API_KEYS: list[str] = [
219
+ k.strip() for k in os.environ.get("GEMINI_API_KEYS", "").split(",") if k.strip()
220
+ ]
221
+ if not API_KEYS:
222
+ # Gather GEMINI_API_KEY (no suffix) + GEMINI_API_KEY_1 through _100
223
+ # Only includes keys that are actually set (non-empty).
224
+ numbered_keys = []
225
+ for index in range(1, 101):
226
+ key = os.environ.get(f"GEMINI_API_KEY_{index}", "")
227
+ if key:
228
+ numbered_keys.append(key)
229
+ API_KEYS = [key for key in [os.environ.get("GEMINI_API_KEY", ""), *numbered_keys] if key]
230
+
231
+ # Drop obvious placeholder values before building the deterministic key ring.
232
+ _PLACEHOLDER_MARKERS = ("your_google_api_key", "your_api_key", "changeme", "xxxx")
233
+ API_KEYS = [
234
+ k for k in API_KEYS
235
+ if not any(marker in k.lower() for marker in _PLACEHOLDER_MARKERS)
236
+ ]
237
+ API_KEY_COUNT = len(API_KEYS)
238
+
239
+
240
+ # Conversation cap (per agent per day)
241
+ MAX_CONVERSATIONS_PER_AGENT = _env_int("SIM_MAX_CONVERSATIONS_PER_AGENT", 5)
242
+
243
+ # At the calendar boundary, allow active conversation jobs this many real
244
+ # seconds to finish and persist before the engine cancels the remainder.
245
+ DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS = _env_float(
246
+ "SIM_DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS", 45.0,
247
+ )
248
+
249
+ # day_planner.py CONFIG
250
+ MAX_PLAN_RETRIES = 3
251
+ # What is expected in persona.json file
252
+ PERSONA_FIELD_GLOSSARY = {
253
+ "daily_plan_req": "A rough sketch of their typical day — classes, work, recurring commitments.",
254
+ "innate": "Personality traits they were simply born with (natural disposition).",
255
+ "learned": "Skills/knowledge acquired since starting college (not innate).",
256
+ "lifestyle": "Habits and routines: sleep schedule, exercise, social patterns.",
257
+ "hobbies": "Free-time activities they actively enjoy.",
258
+ "goals": "Short- and long-term goals, academic and personal.",
259
+ }
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # CLI + runtime override surface
264
+ # ---------------------------------------------------------------------------
265
+ # Every toggle above has a default (hard-coded) that can be overridden by an
266
+ # environment variable (.env), which can in turn be overridden by a CLI flag.
267
+ # Precedence: CLI flag > .env > built-in default.
268
+
269
+
270
+ # Maps CLI/override keyword -> config global name it sets.
271
+ _OVERRIDE_MAP = {
272
+ "tick_speed": "TICK_SPEED",
273
+ "real_seconds_per_sim_minute": "REAL_SECONDS_PER_SIM_MINUTE",
274
+ "sim_minutes_per_tick": "SIM_MINUTES_PER_TICK",
275
+ "perception_radius_px": "PERCEPTION_RADIUS_PX",
276
+ "perception_enabled": "PERCEPTION_ENABLED",
277
+ "max_conversations_per_agent": "MAX_CONVERSATIONS_PER_AGENT",
278
+ "max_replans_per_agent_per_day": "MAX_REPLANS_PER_AGENT_PER_DAY",
279
+ "llm_hourly_ceiling": "LLM_HOURLY_CEILING",
280
+ "decide_min_energy": "DECIDE_MIN_ENERGY",
281
+ "decide_min_emotion": "DECIDE_MIN_EMOTION",
282
+ "decide_cooldown_ticks": "DECIDE_COOLDOWN_TICKS",
283
+ "conversation_min_energy": "CONVERSATION_MIN_ENERGY",
284
+ "conversation_min_emotion": "CONVERSATION_MIN_EMOTION",
285
+ "day_handoff_conversation_timeout_seconds": "DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS",
286
+ }
287
+
288
+
289
+ def apply_overrides(**overrides) -> None:
290
+ """Apply runtime overrides (typically parsed CLI args) onto the module
291
+ globals. Only non-None values are applied. Recomputes derived constants."""
292
+ global REAL_SECONDS_PER_TICK
293
+ for key, value in overrides.items():
294
+ if value is None:
295
+ continue
296
+ target = _OVERRIDE_MAP.get(key)
297
+ if target is None:
298
+ continue
299
+ globals()[target] = value
300
+ # Recompute derived clock constant.
301
+ REAL_SECONDS_PER_TICK = SIM_MINUTES_PER_TICK * REAL_SECONDS_PER_SIM_MINUTE
302
+
303
+
304
+ def add_cli_arguments(parser) -> None:
305
+ """Register the toggle flags on an argparse parser. Defaults are None so
306
+ that an unspecified flag leaves the .env/built-in value untouched."""
307
+ g = parser.add_argument_group("simulation toggles (override .env)")
308
+ g.add_argument("--tick-speed", dest="tick_speed", type=float, default=None,
309
+ help="Speed multiplier (2.0 = 2x faster). Default 1.0")
310
+ g.add_argument("--real-seconds-per-sim-minute", dest="real_seconds_per_sim_minute",
311
+ type=float, default=None,
312
+ help="Wall-clock seconds per sim-minute (7.5 => 180 real-min/day)")
313
+ g.add_argument("--sim-minutes-per-tick", dest="sim_minutes_per_tick", type=int,
314
+ default=None, help="Sim-minutes advanced per tick (default 1)")
315
+ g.add_argument("--perception-radius-px", dest="perception_radius_px", type=int,
316
+ default=None, help="Proximity circle radius in pixels (default 50)")
317
+ g.add_argument("--perception", dest="perception_enabled", type=_str2bool, default=None,
318
+ help="Run the perception step each tick (default: on)")
319
+ g.add_argument("--max-conversations", dest="max_conversations_per_agent", type=int,
320
+ default=None, help="Max conversations per agent per day (default 5)")
321
+ g.add_argument("--max-replans", dest="max_replans_per_agent_per_day", type=int,
322
+ default=None, help="Max mid-day full replans per agent (default 3)")
323
+ g.add_argument("--llm-hourly-ceiling", dest="llm_hourly_ceiling", type=int,
324
+ default=None, help="Soft LLM calls/real-hour ceiling (0 = none)")
325
+ g.add_argument("--decide-min-energy", dest="decide_min_energy", type=float,
326
+ default=None, help="Min energy for LLM decide (default 0.1)")
327
+ g.add_argument("--decide-min-emotion", dest="decide_min_emotion", type=float,
328
+ default=None, help="Min emotion for LLM decide (default 0.1)")
329
+ g.add_argument("--conv-min-energy", dest="conversation_min_energy", type=float,
330
+ default=None, help="Min energy for conversation (default 0.05)")
331
+ g.add_argument("--conv-min-emotion", dest="conversation_min_emotion", type=float,
332
+ default=None, help="Min emotion for conversation (default 0.05)")
333
+ g.add_argument("--day-handoff-conversation-timeout", dest="day_handoff_conversation_timeout_seconds", type=float,
334
+ default=None, help="Real seconds to wait for conversations at day rollover (default 45)")
335
+
336
+
337
+ def _str2bool(value: str) -> bool:
338
+ return str(value).strip().lower() in ("1", "true", "yes", "on", "y")
339
+
340
+
341
+ def overrides_from_args(args) -> dict:
342
+ """Extract the override kwargs from a parsed argparse namespace."""
343
+ return {key: getattr(args, key, None) for key in _OVERRIDE_MAP}
344
+
345
+
346
+ def describe_settings() -> str:
347
+ """Human-readable one-block summary of all active simulation toggles."""
348
+ # sim-day = 1440 sim-min; real seconds = 1440 * REAL_SECONDS_PER_SIM_MINUTE / TICK_SPEED
349
+ day_real_min = (1440 * REAL_SECONDS_PER_SIM_MINUTE / max(TICK_SPEED, 1e-9)) / 60.0
350
+ return (
351
+ "Valhalla simulation settings\n"
352
+ f" API keys loaded : {API_KEY_COUNT} (head resets to index 1/call)\n"
353
+ f" Gemini model : {GEMINI_MODEL}\n"
354
+ f" Simulation creativity : {SIM_CREATIVITY:.2f} "
355
+ f"(plan/decision {TEMPERATURE:.2f}, conversation {CONVERSATION_TEMPERATURE:.2f}, summary {SUMMARY_TEMPERATURE:.2f})\n"
356
+ f" Wellbeing variation : {SIM_WELLBEING_VARIABILITY:.2f}\n"
357
+ f" Memory backend : {MEMORY_BACKEND}\n"
358
+ f" Semantic memory : {'ON' if SEMANTIC_MEMORY_ENABLED else 'OFF'}\n"
359
+ f" Perception : {'ON' if PERCEPTION_ENABLED else 'OFF'} (radius {PERCEPTION_RADIUS_PX}px)\n"
360
+ f" Sim clock : {SIM_MINUTES_PER_TICK} sim-min/tick, "
361
+ f"{REAL_SECONDS_PER_SIM_MINUTE}s/sim-min, speed x{TICK_SPEED}\n"
362
+ f" => real sec/tick : {REAL_SECONDS_PER_TICK / max(TICK_SPEED,1e-9):.2f}s\n"
363
+ f" => real min per sim-day : ~{day_real_min:.0f} min\n"
364
+ f" Max conversations/agent/day : {MAX_CONVERSATIONS_PER_AGENT}\n"
365
+ f" Max replans/agent/day : {MAX_REPLANS_PER_AGENT_PER_DAY}\n"
366
+ f" LLM hourly ceiling : {LLM_HOURLY_CEILING or 'none'}\n"
367
+ f" Decide min energy/emotion : {DECIDE_MIN_ENERGY} / {DECIDE_MIN_EMOTION}\n"
368
+ f" Conv min energy/emotion : {CONVERSATION_MIN_ENERGY} / {CONVERSATION_MIN_EMOTION}\n"
369
+ f" Day-handoff conversation wait: {DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS:.0f}s\n"
370
+ )
371
+
372
+
373
+ if __name__ == '__main__':
374
+ print(f"Running this project from : {BASE_DIR}")
375
+ print(describe_settings())
backend/src/core/agent_registry.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent Registry — single source of truth for every agent's runtime state.
3
+
4
+ The WorldEngine owns one `AgentRegistry` instance. All modules (Actions,
5
+ conversation, day_planner, Short_term) read from and write to it through
6
+ the engine — never directly.
7
+
8
+ This replaces the dual-source problem where Actions.py had its own
9
+ position/action and WorldState had a separate copy that drifted.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from pydantic import BaseModel
17
+
18
+ from src.core.world_state import Position
19
+
20
+
21
+ class AgentRuntimeState(BaseModel):
22
+ """Everything the engine needs to know about one agent at runtime."""
23
+
24
+ agent_id: str
25
+ persona: Dict[str, Any]
26
+ persona_name: str # display name, e.g. "Parv Singla"
27
+ manager: Optional[Any] = None # AgentActionManager instance (set after init)
28
+ position: Position
29
+ current_action: Optional[Dict[str, Any]] = None # serialised ActionState or None
30
+ paused: bool = False # True while in conversation or other blocking state
31
+ day_plan: list = [] # current day plan actions
32
+ day_archived: bool = False # True when the current sim day has been archived
33
+ conversation_start_tick: int = 0 # tick when the active conversation started (0 = not in one)
34
+ conversation_count: int = 0 # how many conversations today
35
+ replan_count: int = 0 # mid-day full replans today (budget cap)
36
+ last_conversation_partner: Optional[str] = None # agent_id of most recent chat partner
37
+ active_conversation: Optional[dict] = None # {"partner_name": str, "partner_id": str, "messages": list[dict]}
38
+ emotion_state: float = 0.5 # [0, 1], starts neutral
39
+ emotion_baseline: float = 0.5 # personality-derived normal mood; used for gentle recovery
40
+ energy_level: float = 1.0 # [0, 1], starts full
41
+ color: str = "#888888" # frontend color (set by server on init)
42
+
43
+
44
+ class AgentRegistry:
45
+ """Global runtime registry for all agents."""
46
+
47
+ def __init__(self) -> None:
48
+ self._agents: Dict[str, AgentRuntimeState] = {}
49
+
50
+ def register(self, state: AgentRuntimeState) -> None:
51
+ self._agents[state.agent_id] = state
52
+
53
+ def remove(self, agent_id: str) -> AgentRuntimeState:
54
+ """Remove and return a runtime record for a roster edit while stopped."""
55
+ state = self._agents.pop(agent_id, None)
56
+ if state is None:
57
+ raise KeyError(f"Unknown agent '{agent_id}'")
58
+ return state
59
+
60
+ def get(self, agent_id: str) -> AgentRuntimeState:
61
+ state = self._agents.get(agent_id)
62
+ if state is None:
63
+ raise KeyError(f"Unknown agent '{agent_id}'")
64
+ return state
65
+
66
+ def all_ids(self) -> list[str]:
67
+ return list(self._agents.keys())
68
+
69
+ def all_states(self) -> list[AgentRuntimeState]:
70
+ return list(self._agents.values())
71
+
72
+ def __contains__(self, agent_id: str) -> bool:
73
+ return agent_id in self._agents
74
+
75
+ def __len__(self) -> int:
76
+ return len(self._agents)
backend/src/core/budget.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM budget governor -- one place that knows how much LLM spend has happened
3
+ recently and whether the simulation can afford more.
4
+
5
+ Why this exists
6
+ ---------------
7
+ Free-tier Gemini keys are scarce (a handful of keys, a few requests/minute
8
+ each). Turning on perception + proximity conversations + a decision-making
9
+ brain could, if left ungated, burn the whole quota in minutes. Every
10
+ cognitive call site (day planner, conversation, reflex escalation) asks the
11
+ governor `can_afford()` before spending, and calls `record()` after. When the
12
+ soft ceiling is exceeded the governor says "no", and the caller degrades
13
+ gracefully -- the simulation keeps running on its 0-LLM executor path.
14
+
15
+ The governor is intentionally simple: a rolling one-real-hour window of call
16
+ timestamps, plus lifetime counters for observability (used by the budget
17
+ stress test and the on-screen/logged stats).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import threading
23
+ import time
24
+ from collections import deque
25
+ from typing import Deque, Dict
26
+
27
+ from src.core.log import get_logger
28
+ from src import config as _cfg
29
+
30
+ logger = get_logger(__name__)
31
+
32
+ _HOUR_SECONDS = 3600.0
33
+
34
+
35
+ class BudgetGovernor:
36
+ """Thread-safe rolling-window LLM call accountant."""
37
+
38
+ def __init__(self) -> None:
39
+ self._lock = threading.Lock()
40
+ self._recent: Deque[float] = deque() # monotonic timestamps, last hour
41
+ self._total_calls = 0
42
+ self._by_kind: Dict[str, int] = {}
43
+ self._by_model: Dict[str, int] = {}
44
+ self._denied = 0
45
+
46
+ def _prune(self, now: float) -> None:
47
+ cutoff = now - _HOUR_SECONDS
48
+ while self._recent and self._recent[0] < cutoff:
49
+ self._recent.popleft()
50
+
51
+ def calls_last_hour(self) -> int:
52
+ with self._lock:
53
+ self._prune(time.monotonic())
54
+ return len(self._recent)
55
+
56
+ def can_afford(self, kind: str = "generic", cost: int = 1) -> bool:
57
+ """True if a call of `kind` (costing `cost` LLM calls) is within the
58
+ configured hourly ceiling. A ceiling of 0 means 'no limit'."""
59
+ ceiling = _cfg.LLM_HOURLY_CEILING
60
+ if not ceiling or ceiling <= 0:
61
+ return True
62
+ with self._lock:
63
+ self._prune(time.monotonic())
64
+ allowed = len(self._recent) + cost <= ceiling
65
+ if not allowed:
66
+ with self._lock:
67
+ self._denied += 1
68
+ logger.info(
69
+ "[Budget] denied '%s' (cost=%d): %d/%d calls used this hour",
70
+ kind, cost, self.calls_last_hour(), ceiling,
71
+ )
72
+ return allowed
73
+
74
+ def record(self, kind: str = "generic", model: str = "unknown") -> None:
75
+ """Record one successful LLM call."""
76
+ now = time.monotonic()
77
+ with self._lock:
78
+ self._recent.append(now)
79
+ self._prune(now)
80
+ self._total_calls += 1
81
+ self._by_kind[kind] = self._by_kind.get(kind, 0) + 1
82
+ self._by_model[model] = self._by_model.get(model, 0) + 1
83
+
84
+ def stats(self) -> Dict[str, object]:
85
+ with self._lock:
86
+ self._prune(time.monotonic())
87
+ return {
88
+ "total_calls": self._total_calls,
89
+ "calls_last_hour": len(self._recent),
90
+ "hourly_ceiling": _cfg.LLM_HOURLY_CEILING or None,
91
+ "denied": self._denied,
92
+ "by_kind": dict(self._by_kind),
93
+ "by_model": dict(self._by_model),
94
+ }
95
+
96
+ def reset(self) -> None:
97
+ with self._lock:
98
+ self._recent.clear()
99
+ self._total_calls = 0
100
+ self._by_kind.clear()
101
+ self._by_model.clear()
102
+ self._denied = 0
103
+
104
+
105
+ # Process-wide singleton.
106
+ GOVERNOR = BudgetGovernor()
107
+
108
+
109
+ if __name__ == "__main__":
110
+ from src.core.log import setup_logging
111
+ setup_logging(run_id="budget_test", console=True)
112
+
113
+ g = BudgetGovernor()
114
+ assert g.can_afford("plan")
115
+ for _ in range(5):
116
+ g.record("plan", "gemini-3.1-flash-lite")
117
+ assert g.calls_last_hour() == 5
118
+ s = g.stats()
119
+ assert s["total_calls"] == 5 and s["by_kind"]["plan"] == 5
120
+ print("budget.py sanity check passed:", s)
backend/src/core/checkpoint_manager.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Checkpoint Manager — per-tick state save/load for crash recovery.
3
+
4
+ Saves WorldState + AgentRegistry after every tick to compressed
5
+ ``backend/data/checkpoints/tick_{00001}.json.gz`` files.
6
+
7
+ Supports:
8
+ - Save: full simulation state as JSON
9
+ - Load: reconstruct from any saved tick
10
+ - List: available checkpoint ticks
11
+ - Prune: auto-delete old checkpoints, keep last N
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import gzip
18
+ import random
19
+ import time as _time
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional, Tuple
22
+
23
+ from src.core.log import get_logger
24
+ from src.core.world_state import WorldState, Position
25
+ from src.core.agent_registry import AgentRegistry, AgentRuntimeState
26
+ from src.agents.Actions import AgentActionManager, ActionState, ActionType, LocationResolver
27
+ from src.config import DATA_DIR
28
+
29
+ logger = get_logger(__name__)
30
+
31
+ CHECKPOINT_DIR = DATA_DIR / "checkpoints"
32
+ HISTORY_DIR = DATA_DIR / "history"
33
+ # One full simulated day of per-tick snapshots gives observers a complete
34
+ # rewind window without retaining prior days indefinitely.
35
+ KEEP_LAST = 1440
36
+ SCHEMA_VERSION = 2
37
+ CHECKPOINT_SUFFIX = ".json.gz"
38
+ LEGACY_CHECKPOINT_SUFFIX = ".json"
39
+
40
+
41
+ def _tick_filename(tick: int) -> str:
42
+ return f"tick_{tick:05d}{CHECKPOINT_SUFFIX}"
43
+
44
+
45
+ def _legacy_tick_filename(tick: int) -> str:
46
+ return f"tick_{tick:05d}{LEGACY_CHECKPOINT_SUFFIX}"
47
+
48
+
49
+ def _parse_tick_from_name(name: str) -> int:
50
+ if name.endswith(CHECKPOINT_SUFFIX):
51
+ stem = name[: -len(CHECKPOINT_SUFFIX)]
52
+ elif name.endswith(LEGACY_CHECKPOINT_SUFFIX):
53
+ stem = name[: -len(LEGACY_CHECKPOINT_SUFFIX)]
54
+ else:
55
+ raise ValueError(f"not a checkpoint filename: {name}")
56
+ return int(stem.removeprefix("tick_"))
57
+
58
+
59
+ def _checkpoint_path(tick: int) -> Path:
60
+ """Prefer compressed checkpoints while retaining read compatibility."""
61
+ compressed = CHECKPOINT_DIR / _tick_filename(tick)
62
+ if compressed.exists():
63
+ return compressed
64
+ return CHECKPOINT_DIR / _legacy_tick_filename(tick)
65
+
66
+
67
+ def _checkpoint_paths(tick: int) -> tuple[Path, Path]:
68
+ return (
69
+ CHECKPOINT_DIR / _tick_filename(tick),
70
+ CHECKPOINT_DIR / _legacy_tick_filename(tick),
71
+ )
72
+
73
+
74
+ # ------------------------------------------------------------------ #
75
+ # Save
76
+ # ------------------------------------------------------------------ #
77
+
78
+ def _manager_to_dict(manager: AgentActionManager) -> Dict[str, Any]:
79
+ """Serialize an AgentActionManager's internal state."""
80
+ def _action_dict(a: Optional[ActionState]) -> Optional[Dict[str, Any]]:
81
+ if a is None:
82
+ return None
83
+ # ActionState contains more than the display fields. In particular,
84
+ # its resolved destination and energy/emotion deltas affect subsequent
85
+ # movement and behaviour, so preserve the entire serializable model.
86
+ return a.model_dump(mode="json")
87
+
88
+ return {
89
+ "day_plan": manager.day_plan,
90
+ "last_action": _action_dict(manager.last_action),
91
+ "current_action": _action_dict(manager.current_action),
92
+ "next_action": _action_dict(manager.next_action),
93
+ "_conversation_mode": manager._conversation_mode,
94
+ "_pending_plan_action": _action_dict(manager._pending_plan_action),
95
+ "_entered_last_action": manager._entered_last_action,
96
+ }
97
+
98
+
99
+ def _agent_to_dict(state: AgentRuntimeState) -> Dict[str, Any]:
100
+ """Serialize one agent's runtime state."""
101
+ return {
102
+ "agent_id": state.agent_id,
103
+ "persona": state.persona,
104
+ "persona_name": state.persona_name,
105
+ "position": state.position.model_dump(),
106
+ "paused": state.paused,
107
+ "day_plan": state.day_plan,
108
+ "day_archived": state.day_archived,
109
+ "conversation_start_tick": state.conversation_start_tick,
110
+ "conversation_count": state.conversation_count,
111
+ "replan_count": state.replan_count,
112
+ "last_conversation_partner": state.last_conversation_partner,
113
+ "active_conversation": state.active_conversation,
114
+ "emotion_state": state.emotion_state,
115
+ "emotion_baseline": state.emotion_baseline,
116
+ "energy_level": state.energy_level,
117
+ "color": state.color,
118
+ "manager": _manager_to_dict(state.manager) if state.manager else None,
119
+ }
120
+
121
+
122
+ def save_checkpoint(
123
+ world: WorldState,
124
+ registry: AgentRegistry,
125
+ tick: int,
126
+ engine_state: Optional[Dict[str, Any]] = None,
127
+ ) -> Optional[Path]:
128
+ """Save current simulation state as an atomically replaced gzip JSON file."""
129
+ CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
130
+ path = CHECKPOINT_DIR / _tick_filename(tick)
131
+
132
+ agents_data = [_agent_to_dict(s) for s in registry.all_states()]
133
+
134
+ # History is not needed to advance an action, but it is part of the
135
+ # observable world and is required for a faithful replay after restore.
136
+ world_dict = json.loads(world.model_dump_json())
137
+
138
+ payload = {
139
+ "schema_version": SCHEMA_VERSION,
140
+ "tick": tick,
141
+ "world": world_dict,
142
+ "agents": agents_data,
143
+ "engine": engine_state or {},
144
+ # The simulation uses Python's process-wide RNG for collision nudges
145
+ # and interior placement. JSON converts tuples to lists, which are
146
+ # converted back on load before passing the state to random.setstate.
147
+ "random_state": random.getstate(),
148
+ "saved_at": _time.strftime("%Y-%m-%d %H:%M:%S"),
149
+ }
150
+
151
+ tmp = path.with_suffix(path.suffix + ".tmp")
152
+ with gzip.open(tmp, "wt", encoding="utf-8") as handle:
153
+ handle.write(json.dumps(payload, separators=(",", ":"), default=str))
154
+ tmp.replace(path)
155
+
156
+ return path
157
+
158
+
159
+ # ------------------------------------------------------------------ #
160
+ # History
161
+ # ------------------------------------------------------------------ #
162
+
163
+ def save_history(world: WorldState, date: str) -> Optional[Path]:
164
+ """Save per-day action history to ``data/history/<date>_history.json``."""
165
+ HISTORY_DIR.mkdir(parents=True, exist_ok=True)
166
+ path = HISTORY_DIR / f"{date}_history.json"
167
+ history_data = [entry.model_dump() for entry in world.history]
168
+ path.write_text(json.dumps(history_data, indent=2, default=str))
169
+ logger.info("[Checkpoint] saved history for %s (%d entries)", date, len(history_data))
170
+ return path
171
+
172
+
173
+ # ------------------------------------------------------------------ #
174
+ # Load
175
+ # ------------------------------------------------------------------ #
176
+
177
+ def _action_from_dict(d: Optional[Dict[str, Any]]) -> Optional[ActionState]:
178
+ """Reconstruct an ActionState from a saved dict."""
179
+ if d is None:
180
+ return None
181
+ # model_validate also remains compatible with version-1 checkpoints,
182
+ # whose omitted ActionState fields have model defaults.
183
+ return ActionState.model_validate(d)
184
+
185
+
186
+ def _lists_to_tuples(value: Any) -> Any:
187
+ """Restore the tuple structure required by random.setstate()."""
188
+ if isinstance(value, list):
189
+ return tuple(_lists_to_tuples(item) for item in value)
190
+ return value
191
+
192
+
193
+ def _restore_manager(
194
+ data: Dict[str, Any],
195
+ resolver: LocationResolver,
196
+ ) -> AgentActionManager:
197
+ """Reconstruct an AgentActionManager from saved state dict."""
198
+ position = Position(**data["position"])
199
+ manager = AgentActionManager(
200
+ agent_id=data["agent_id"],
201
+ day_plan=data.get("day_plan", data["manager"]["day_plan"]) if data.get("manager") else [],
202
+ initial_position=position,
203
+ resolver=resolver,
204
+ )
205
+
206
+ if data.get("manager"):
207
+ m = data["manager"]
208
+ manager.last_action = _action_from_dict(m.get("last_action"))
209
+ manager.current_action = _action_from_dict(m.get("current_action"))
210
+ manager.next_action = _action_from_dict(m.get("next_action"))
211
+ manager._conversation_mode = m.get("_conversation_mode", False)
212
+ manager._pending_plan_action = _action_from_dict(m.get("_pending_plan_action"))
213
+ manager._entered_last_action = m.get("_entered_last_action", False)
214
+
215
+ return manager
216
+
217
+
218
+ def load_checkpoint(
219
+ tick: int,
220
+ resolver: LocationResolver,
221
+ return_metadata: bool = False,
222
+ ) -> Tuple[WorldState, AgentRegistry] | Tuple[WorldState, AgentRegistry, Dict[str, Any]]:
223
+ """Load simulation state from a checkpoint file."""
224
+ path = _checkpoint_path(tick)
225
+ if not path.exists():
226
+ raise FileNotFoundError(f"No checkpoint for tick {tick} at {path}")
227
+
228
+ if path.suffix == ".gz":
229
+ with gzip.open(path, "rt", encoding="utf-8") as handle:
230
+ raw = json.load(handle)
231
+ else:
232
+ raw = json.loads(path.read_text(encoding="utf-8"))
233
+
234
+ if raw.get("random_state") is not None:
235
+ random.setstate(_lists_to_tuples(raw["random_state"]))
236
+
237
+ world = WorldState.model_validate(raw["world"])
238
+ registry = AgentRegistry()
239
+
240
+ for agent_data in raw["agents"]:
241
+ position = Position(**agent_data["position"])
242
+ manager = _restore_manager(agent_data, resolver)
243
+
244
+ state = AgentRuntimeState(
245
+ agent_id=agent_data["agent_id"],
246
+ persona=agent_data["persona"],
247
+ persona_name=agent_data["persona_name"],
248
+ manager=manager,
249
+ position=position,
250
+ paused=agent_data["paused"],
251
+ day_plan=agent_data["day_plan"],
252
+ day_archived=agent_data["day_archived"],
253
+ conversation_start_tick=agent_data.get("conversation_start_tick", 0),
254
+ conversation_count=agent_data.get("conversation_count", 0),
255
+ replan_count=agent_data.get("replan_count", 0),
256
+ last_conversation_partner=agent_data.get("last_conversation_partner"),
257
+ active_conversation=agent_data.get("active_conversation"),
258
+ emotion_state=agent_data.get("emotion_state", 0.5),
259
+ emotion_baseline=agent_data.get("emotion_baseline", 0.5),
260
+ energy_level=agent_data.get("energy_level", 1.0),
261
+ color=agent_data.get("color", "#888888"),
262
+ )
263
+ state.current_action = (
264
+ manager.current_action.model_dump()
265
+ if manager.current_action else None
266
+ )
267
+ registry.register(state)
268
+
269
+ logger.info(
270
+ "[Checkpoint] loaded tick %d: %d agents, world.tick=%d",
271
+ tick, len(registry), world.tick,
272
+ )
273
+ if return_metadata:
274
+ return world, registry, raw.get("engine", {})
275
+ return world, registry
276
+
277
+
278
+ # ------------------------------------------------------------------ #
279
+ # List & Prune
280
+ # ------------------------------------------------------------------ #
281
+
282
+ def list_checkpoints() -> List[int]:
283
+ """Return sorted list of available checkpoint tick numbers."""
284
+ if not CHECKPOINT_DIR.exists():
285
+ return []
286
+ ticks = []
287
+ for f in sorted(CHECKPOINT_DIR.iterdir()):
288
+ if f.is_file() and f.name.startswith("tick_") and (
289
+ f.name.endswith(CHECKPOINT_SUFFIX) or f.name.endswith(LEGACY_CHECKPOINT_SUFFIX)
290
+ ):
291
+ try:
292
+ ticks.append(_parse_tick_from_name(f.name))
293
+ except (ValueError, IndexError):
294
+ continue
295
+ return sorted(set(ticks))
296
+
297
+
298
+ def prune_checkpoints(keep_last: int = KEEP_LAST) -> int:
299
+ """Remove all checkpoints except the ``keep_last`` most recent ones.
300
+ Returns the number of deleted files."""
301
+ ticks = list_checkpoints()
302
+ if len(ticks) <= keep_last:
303
+ return 0
304
+
305
+ to_delete = ticks[:-keep_last]
306
+ count = 0
307
+ for t in to_delete:
308
+ for path in _checkpoint_paths(t):
309
+ if not path.exists():
310
+ continue
311
+ try:
312
+ path.unlink()
313
+ count += 1
314
+ except OSError as e:
315
+ logger.warning("[Checkpoint] failed to delete %s: %s", path, e)
316
+
317
+ if count:
318
+ logger.info("[Checkpoint] pruned %d old checkpoints, kept last %d", count, keep_last)
319
+ return count
320
+
321
+
322
+ def latest_tick() -> Optional[int]:
323
+ """Return the highest available tick number, or None."""
324
+ ticks = list_checkpoints()
325
+ return ticks[-1] if ticks else None
backend/src/core/core.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ core/
2
+ holds orchestration plumbing (the tick graph itself, shared state schemas, config) that doesn't do any "thinking" on its own
backend/src/core/log.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Centralized logging setup for the whole project. Every module imports
3
+ `get_logger(__name__)` from here instead of calling `logging.basicConfig`
4
+ or `logging.getLogger` directly, so the format + destinations stay
5
+ identical everywhere
6
+
7
+ Usage
8
+ -----
9
+ Call `setup_logging()` ONCE, as early as possible in the process (top of
10
+ whatever your real entrypoint is -- backend/main.py, or the `run()` /
11
+ `__main__` block of a standalone script):
12
+
13
+ from src.core.log import setup_logging, get_logger
14
+ setup_logging(run_id="run_name") # run_id is optional
15
+ logger = get_logger(__name__)
16
+
17
+ Every other module then just does:
18
+
19
+ from src.core.log import get_logger
20
+ logger = get_logger(__name__)
21
+
22
+ If some module gets imported/used before setup_logging() runs (import
23
+ order accidents happen), get_logger() will lazily call setup_logging()
24
+ with defaults so you still get sane output instead of silence.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import logging.config
31
+ from datetime import datetime
32
+ from pathlib import Path
33
+ from typing import Optional
34
+
35
+ # Path config lives in one place: src/config.py
36
+ from src.config import LOG_DIR, LOG_LEVEL as _DEFAULT_LEVEL_NAME
37
+
38
+ DEFAULT_LEVEL = getattr(logging, _DEFAULT_LEVEL_NAME.upper(), logging.INFO)
39
+
40
+ LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
41
+ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
42
+
43
+ _configured = False
44
+
45
+
46
+ def _build_log_path(run_id: Optional[str] = None) -> Path:
47
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
48
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
49
+ filename = f"{stamp}_{run_id}.log" if run_id else f"{stamp}.log"
50
+ return LOG_DIR / filename
51
+
52
+
53
+ def setup_logging(
54
+ level: int = DEFAULT_LEVEL,
55
+ run_id: Optional[str] = None,
56
+ console: bool = True,
57
+ file: bool = True,
58
+ max_bytes: int = 5 * 1024 * 1024,
59
+ backup_count: int = 5,
60
+ ) -> Optional[Path]:
61
+ """
62
+ Configures the ROOT logger once. Idempotent -- a second call is a no-op,
63
+ so accidentally calling it from more than one entrypoint (e.g. both a
64
+ test script and an imported module) will never duplicate handlers or
65
+ duplicate every log line.
66
+
67
+ level -- root log level (module-specific loggers can still be
68
+ set louder/quieter individually if you ever need that)
69
+ run_id -- optional tag folded into the log filename, e.g. a
70
+ persona name or simulation id, so a run's logs are
71
+ easy to find in output/logs/
72
+ console -- also stream logs to stdout
73
+ file -- also write logs to output/logs/<timestamp>[_<run_id>].log
74
+ max_bytes /
75
+ backup_count -- rotation settings for the file handler
76
+
77
+ Returns the Path of the log file created, or None if file=False.
78
+ """
79
+ global _configured
80
+ if _configured:
81
+ return None
82
+
83
+ handlers: dict = {}
84
+ log_path: Optional[Path] = None
85
+
86
+ if console:
87
+ handlers["console"] = {
88
+ "class": "logging.StreamHandler",
89
+ "formatter": "standard",
90
+ "stream": "ext://sys.stdout",
91
+ "level": level,
92
+ }
93
+
94
+ if file:
95
+ log_path = _build_log_path(run_id)
96
+ handlers["file"] = {
97
+ "class": "logging.handlers.RotatingFileHandler",
98
+ "formatter": "standard",
99
+ "filename": str(log_path),
100
+ "maxBytes": max_bytes,
101
+ "backupCount": backup_count,
102
+ "encoding": "utf-8",
103
+ "level": level,
104
+ }
105
+
106
+ config = {
107
+ "version": 1,
108
+ "disable_existing_loggers": False,
109
+ "formatters": {
110
+ "standard": {
111
+ "format": LOG_FORMAT,
112
+ "datefmt": DATE_FORMAT,
113
+ },
114
+ },
115
+ "handlers": handlers,
116
+ "root": {
117
+ "level": level,
118
+ "handlers": list(handlers.keys()),
119
+ },
120
+ # Quiet down noisy third-party loggers so your own log lines don't
121
+ # get buried -- add to this as you bring in more SDKs.
122
+ "loggers": {
123
+ "google_genai": {"level": "WARNING", "propagate": True},
124
+ "httpx": {"level": "WARNING", "propagate": True},
125
+ "httpcore": {"level": "WARNING", "propagate": True},
126
+ },
127
+ }
128
+
129
+ logging.config.dictConfig(config)
130
+ _configured = True
131
+
132
+ if log_path:
133
+ logging.getLogger(__name__).info("[log] logging initialized -> %s", log_path)
134
+
135
+ return log_path
136
+
137
+
138
+ def get_logger(name: str) -> logging.Logger:
139
+ """
140
+ Thin wrapper so call sites read `from src.core.log import get_logger`
141
+ instead of raw `logging.getLogger`. Lazily calls setup_logging() with
142
+ defaults if nothing has configured logging yet.
143
+ """
144
+ if not _configured:
145
+ setup_logging()
146
+ return logging.getLogger(name)