Spaces:
Sleeping
Sleeping
Valhalla
#1
by Dontcryx - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- .dockerignore +60 -0
- .env.local +80 -0
- .gitattributes +0 -35
- .gitignore +54 -1
- Dockerfile +14 -20
- README.md +165 -109
- backend/Odin.py +1037 -0
- backend/Pathfinder_test.py +80 -0
- backend/data/environment/buildings_polygon_decomposed.json +3878 -0
- backend/data/environment/entrypoint.json +240 -0
- backend/data/environment/events_calendar.json +56 -0
- backend/data/environment/instances_default.json +1 -0
- backend/data/environment/num.txt +34 -0
- backend/data/environment/places.json +273 -0
- backend/data/environment/relationship_matrix.json +867 -0
- backend/data/personalities/amitabh/amitabh.json +22 -0
- backend/data/personalities/ansh_batra/ansh_batra.json +22 -0
- backend/data/personalities/anubhav_prasad/anubhav_prasad.json +21 -0
- backend/data/personalities/ghanisht_kaushal/ghanisht_kaushal.json +21 -0
- backend/data/personalities/gurnoor_singh/gurnoor_singh.json +22 -0
- backend/data/personalities/jarvis/jarvis.json +22 -0
- backend/data/personalities/lavanya_sharma/lavanya_sharma.json +21 -0
- backend/data/personalities/parv_singla/parv_singla.json +21 -0
- backend/data/personalities/riya_murarka/riya_murarka.json +21 -0
- backend/data/personalities/saksham/saksham.json +21 -0
- backend/data/personalities/tanishq/tanishq.json +21 -0
- backend/pathfinder.py +114 -0
- backend/src/agents/Actions.py +801 -0
- backend/src/agents/Long_term.py +45 -0
- backend/src/agents/Short_term.py +559 -0
- backend/src/agents/Single_agent.py +223 -0
- backend/src/agents/agents.txt +2 -0
- backend/src/agents/autonomy.py +20 -0
- backend/src/agents/body.py +71 -0
- backend/src/agents/brain.py +186 -0
- backend/src/agents/conversation.py +805 -0
- backend/src/agents/daily_flavor.py +45 -0
- backend/src/agents/day_planner.py +1227 -0
- backend/src/agents/memory_index.py +95 -0
- backend/src/agents/react.py +70 -0
- backend/src/agents/vector_memory.py +514 -0
- backend/src/auth/__init__.py +0 -0
- backend/src/auth/manager.py +116 -0
- backend/src/auth/routes.py +48 -0
- backend/src/config.py +375 -0
- backend/src/core/agent_registry.py +76 -0
- backend/src/core/budget.py +120 -0
- backend/src/core/checkpoint_manager.py +325 -0
- backend/src/core/core.txt +2 -0
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
#
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
#
|
| 16 |
-
|
|
|
|
| 17 |
|
| 18 |
-
#
|
| 19 |
-
COPY
|
|
|
|
| 20 |
|
| 21 |
-
#
|
| 22 |
-
|
| 23 |
-
chown -R node:node .wwebjs_auth .wwebjs_cache
|
| 24 |
|
| 25 |
-
# Expose port for web interface
|
| 26 |
EXPOSE 7860
|
| 27 |
|
| 28 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
-
##
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
-
|
| 21 |
-
|
| 22 |
-
- 🔗 QR code scanning via web interface
|
| 23 |
-
- 🧪 API testing interface
|
| 24 |
|
| 25 |
-
#
|
|
|
|
| 26 |
|
| 27 |
-
#
|
| 28 |
-
|
| 29 |
-
- Docker (optional)
|
| 30 |
|
| 31 |
-
#
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
```bash
|
| 36 |
-
npm install
|
| 37 |
```
|
| 38 |
|
| 39 |
-
|
| 40 |
-
```bash
|
| 41 |
-
npm start
|
| 42 |
-
```
|
| 43 |
|
| 44 |
-
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
##
|
| 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 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
|
| 59 |
-
|
| 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 |
-
##
|
| 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 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
```
|
| 93 |
|
| 94 |
-
##
|
| 95 |
|
| 96 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
- `POST /api/test` - Test API endpoint
|
| 105 |
-
- `GET /api/stats` - Bot statistics
|
| 106 |
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
-
##
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
- Formats responses for WhatsApp (bold, italic, etc.)
|
| 118 |
-
- Handles `\n` line breaks properly
|
| 119 |
|
| 120 |
-
#
|
| 121 |
-
|
| 122 |
-
- Extracts Final Answer from JSON responses
|
| 123 |
-
- Converts markdown formatting to WhatsApp format
|
| 124 |
|
| 125 |
-
#
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
|
| 133 |
-
##
|
| 134 |
-
|
| 135 |
-
```
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
```
|
| 138 |
|
| 139 |
-
## Security
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
-
|
| 142 |
-
-
|
| 143 |
-
-
|
|
|
|
| 144 |
|
| 145 |
-
##
|
| 146 |
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|