saaheerpurav commited on
Commit
1bb18a0
·
0 Parent(s):

first commit

Browse files
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv
2
+ .env
3
+ frontend/node_modules
4
+ frontend/.cache
5
+ frontend/build
6
+ **/__pycache__
7
+ **/*.pyc
8
+ **/*.pyo
9
+ .git
10
+ .gitignore
11
+ pyproject.toml
12
+ uv.lock
13
+ server/
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ **/__pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ frontend/node_modules/
8
+ frontend/.cache/
9
+ frontend/build/
10
+ inference_stderr.txt
11
+ *.log
Dockerfile ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =====================================================
2
+ # AI Executive Operations Manager
3
+ # Single-container build: React frontend + FastAPI backend
4
+ # Port: 7860
5
+ # =====================================================
6
+
7
+ # Stage 1: Build React frontend
8
+ FROM node:18-slim AS frontend-builder
9
+ WORKDIR /app/frontend
10
+
11
+ # Install dependencies first (cached layer)
12
+ COPY frontend/package.json frontend/package-lock.json* ./
13
+ RUN npm install --legacy-peer-deps
14
+
15
+ # Copy source and build
16
+ COPY frontend/ ./
17
+ ENV GENERATE_SOURCEMAP=false
18
+ RUN npm run build
19
+
20
+ # Stage 2: Python runtime
21
+ FROM python:3.11-slim AS runtime
22
+
23
+ WORKDIR /app
24
+
25
+ # Install Python dependencies
26
+ COPY requirements.txt .
27
+ RUN pip install --no-cache-dir -r requirements.txt
28
+
29
+ # Copy application code
30
+ COPY env/ ./env/
31
+ COPY app.py inference.py openenv.yaml ./
32
+
33
+ # Copy pre-built React frontend from Stage 1
34
+ COPY --from=frontend-builder /app/frontend/build ./frontend/build
35
+
36
+ # Expose port
37
+ EXPOSE 7860
38
+
39
+ # Health check
40
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
41
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/reset')" || exit 1
42
+
43
+ # Start FastAPI server
44
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AI Executive Operations Manager
3
+ emoji: ⚡
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ tags:
10
+ - openenv
11
+ ---
12
+
13
+ # AI Executive Operations Manager
14
+
15
+ An OpenEnv-compliant reinforcement learning environment where an AI agent plays the role of **Alex Rivera**, CEO of **NovaTech AI** - a fictional Series B AI infrastructure startup with ~80 employees. The agent manages a realistic executive inbox under time pressure, making triage decisions with real business consequences.
16
+
17
+ ---
18
+
19
+ ## What is this?
20
+
21
+ The agent's job is to read emails, decide what to act on, and choose *how* to act. Every choice has consequences. The clock is ticking and there is always more to do than time allows.
22
+
23
+ Each email has a **priority** (1-5) and a **decaying urgency** score. The agent picks one of four actions per step:
24
+
25
+ | Action | When to use |
26
+ |--------|-------------|
27
+ | `reply` | CEO must personally handle - investor relations, legal deadlines, critical incidents |
28
+ | `schedule` | Needs a meeting - strategic discussions, relationship-building |
29
+ | `delegate` | Can be handled by the team - operational, low-stakes items |
30
+ | `ignore` | Truly trivial - only safe when no critical items are unhandled |
31
+
32
+ **Format:** `{"type": "reply|schedule|delegate|ignore", "email_id": "<id>"}`
33
+
34
+ ---
35
+
36
+ ## The Core Challenge
37
+
38
+ Good decisions require real prioritization - not just doing the most urgent thing first, but understanding *what matters most* and *what can be handed off*.
39
+
40
+ **You cannot do everything.** Every step costs time. The hardest scenario is designed so a perfect score is mathematically impossible - the agent must triage ruthlessly and decide what to sacrifice.
41
+
42
+ ### Why it's hard
43
+
44
+ - **Conflicting priorities** - Two fires at once, one CEO
45
+ - **Urgency vs. importance** - Something loud is not always something critical
46
+ - **Delegation trade-offs** - Some things only you can do; others you shouldn't touch
47
+ - **Time decay** - Urgency drops 0.15 per step; waiting on critical items costs you
48
+ - **Impossible situations** - The hard scenario cannot be perfectly solved. The score reflects *which* crises the agent chose to address
49
+
50
+ ---
51
+
52
+ ## The Three Scenarios
53
+
54
+ ### Easy - "Monday Morning Catchup"
55
+ 4 emails, 6 steps. Priorities are clear. Designed to establish a baseline - a competent agent handles everything well.
56
+
57
+ Key decisions:
58
+ - Approve a production deploy to fix a live memory leak costing $3K/hour
59
+ - Sign off on a $1.2M GlobalBank enterprise contract before a competitor swoops in
60
+ - Delegate new hire onboarding paperwork to HR
61
+ - Ignore the office snack order
62
+
63
+ **Expected score:** 0.70 - 1.00
64
+
65
+ ---
66
+
67
+ ### Medium - "Investor Demo Day Prep"
68
+ 7 emails, 8 steps. NovaTech's Series B pitch is today. Multiple high-priority items compete for the same limited time. Requires smart scheduling and knowing what to delegate.
69
+
70
+ Key decisions:
71
+ - Schedule a pre-demo call with the lead Sequoia investor
72
+ - Align with the CTO on architecture before investors ask about scaling
73
+ - Sign an NDA legally required before the presentation
74
+ - Approve an offer letter for a senior ML engineer before they go to OpenAI
75
+ - Manage the CFO's runway concerns (8.5 months left, down from 11)
76
+
77
+ **Expected score:** 0.45 - 0.85
78
+
79
+ ---
80
+
81
+ ### Hard - "Series B Crisis Day"
82
+ 10 emails, 8 steps, 7 goals. Four simultaneous P5 crises. **A perfect score is intentionally impossible.** A top agent scores around 0.65-0.75.
83
+
84
+ The crises hitting all at once:
85
+ - Lead investor threatening to pull an $18M term sheet before 4PM
86
+ - Production database corruption affecting 23 enterprise customers ($85K/hour revenue impact)
87
+ - TechCrunch publishing a false security breach story in 90 minutes
88
+ - Key engineer resigning to join Anthropic - mid Series B
89
+ - GDPR data deletion deadline expiring at 5PM (20M euro fine risk)
90
+ - AWS contract expiring at midnight (cost jumps from $180K to $340K/month)
91
+ - Co-founder publicly disagreeing on strategy before the board meeting
92
+ - Microsoft acquisition inquiry ($400-600M range)
93
+
94
+ The agent must decide what to sacrifice.
95
+
96
+ **Expected score:** 0.20 - 0.75
97
+
98
+ ---
99
+
100
+ ## Observation Space
101
+
102
+ Each observation is a JSON dict:
103
+
104
+ ```json
105
+ {
106
+ "time": "10:00 AM",
107
+ "step": 2,
108
+ "max_steps": 8,
109
+ "steps_remaining": 6,
110
+ "inbox": [
111
+ {
112
+ "id": "e1",
113
+ "sender": "Sarah Chen <s.chen@novatech.ai>",
114
+ "subject": "URGENT: Production deploy approval",
115
+ "body": "...",
116
+ "priority": 5,
117
+ "urgency": 0.65,
118
+ "handled": false,
119
+ "action_taken": null
120
+ }
121
+ ],
122
+ "calendar": [
123
+ {"time_slot": 3, "title": "Board Sync", "attendee": "Board of Directors", "locked": true}
124
+ ],
125
+ "goals": [
126
+ {
127
+ "id": "g1",
128
+ "description": "Approve production deploy to fix memory leak",
129
+ "priority": 5,
130
+ "required_action": "reply",
131
+ "target_email_id": "e1",
132
+ "completed": false
133
+ }
134
+ ],
135
+ "pending_goals": [
136
+ {
137
+ "id": "g1",
138
+ "description": "Approve production deploy to fix memory leak",
139
+ "priority": 5,
140
+ "required_action": "reply",
141
+ "target_email_id": "e1",
142
+ "completed": false
143
+ }
144
+ ],
145
+ "total_reward": 0.498,
146
+ "done": false
147
+ }
148
+ ```
149
+
150
+ - `priority`: 1 (minimal) to 5 (critical)
151
+ - `urgency`: 0.0-1.0, decays by 0.15 each step - act fast on high-urgency items
152
+ - `goals`: all goals with completion status
153
+ - `pending_goals`: only incomplete goals
154
+
155
+ ---
156
+
157
+ ## Reward Function
158
+
159
+ Per-step continuous reward, clamped to `[-0.3, 0.5]`:
160
+
161
+ | Signal | Value |
162
+ |--------|-------|
163
+ | Goal completion | `+0.30 x (priority / 5)` |
164
+ | Urgency bonus | `+0.15 x urgency` (if urgency > 0.5, not ignoring) |
165
+ | Correct action type | `+0.10` (matches goal's required action) |
166
+ | Smart delegation (P1-P2) | `+0.05` |
167
+ | Ignore critical (P4-P5) | `-0.25` |
168
+ | Waste step on trivial when crises pending | `-0.10` |
169
+ | Per-step cost | `-0.02` |
170
+
171
+ ---
172
+
173
+ ## Grader
174
+
175
+ Deterministic. Score in [0.0, 1.0]:
176
+
177
+ ```
178
+ score = 0.60 x goal_completion_rate (priority-weighted)
179
+ + 0.25 x email_handling_rate (priority-weighted)
180
+ + 0.15 x efficiency_bonus (only if all high-priority goals done)
181
+ ```
182
+
183
+ Goals carry 60% of the weight because completing the right goal (saving an $18M term sheet) matters far more than handling any random email. Efficiency only rewards speed *after* critical goals are met - it never trumps correctness.
184
+
185
+ ---
186
+
187
+ ## What makes a good agent?
188
+
189
+ 1. **Triage instinct** - Identify the two or three things that absolutely cannot wait
190
+ 2. **Delegation confidence** - Know what doesn't need the CEO's personal attention
191
+ 3. **Urgency sensitivity** - Act on time-decaying items before they expire
192
+ 4. **Sacrifice awareness** - In the hard scenario, explicitly choose what to drop
193
+
194
+ The environment rewards agents that think like an actual executive under pressure - not agents that just process emails in order.
195
+
196
+ ---
197
+
198
+ ## Setup & Running
199
+
200
+ ### Docker (Recommended)
201
+
202
+ ```bash
203
+ docker build -t exec-ops-manager .
204
+ docker run -p 7860:7860 exec-ops-manager
205
+ # Open http://localhost:7860
206
+ ```
207
+
208
+ ### Local Development
209
+
210
+ ```bash
211
+ pip install -r requirements.txt
212
+ uvicorn app:app --port 7860 --reload
213
+ ```
214
+
215
+ ### Running Inference / Demo Script
216
+
217
+ `inference.py` runs an LLM agent through all 3 tasks and prints structured logs.
218
+
219
+ ```bash
220
+ export API_BASE_URL="https://api.openai.com/v1"
221
+ export MODEL_NAME="gpt-4o-mini"
222
+ export HF_TOKEN="your-key"
223
+ python inference.py
224
+ ```
225
+
226
+ Output format:
227
+ ```
228
+ [START] task=easy env=exec-ops model=gpt-4o-mini
229
+ [STEP] step=1 action=reply('e1') reward=0.50 done=false error=null
230
+ [STEP] step=2 action=reply('e2') reward=0.41 done=false error=null
231
+ [STEP] step=3 action=delegate('e3') reward=0.26 done=true error=null
232
+ [END] success=true steps=3 score=0.906 rewards=0.50,0.41,0.26
233
+ ```
234
+
235
+ ---
236
+
237
+ ## API Endpoints
238
+
239
+ | Endpoint | Method | Description |
240
+ |----------|--------|-------------|
241
+ | `/` | GET | Serves React dashboard (200 OK) |
242
+ | `/reset?task=easy` | GET/POST | Reset environment, returns initial observation |
243
+ | `/step` | POST | Execute one action, returns observation + reward |
244
+ | `/state` | GET | Full current environment state |
245
+ | `/grade` | GET | Current score (0.0-1.0) |
246
+ | `/tasks` | GET | List all available tasks |
247
+
248
+ ---
249
+
250
+ ## Baseline Results
251
+
252
+ *Model: `gpt-4o-mini` via OpenAI API*
253
+
254
+ | Task | Score | Steps used |
255
+ |------|-------|------------|
256
+ | easy | 0.906 | 3 / 6 |
257
+ | medium | 0.866 | 5 / 8 |
258
+ | hard | 0.681 | 8 / 8 |
259
+ | **Average** | **0.818** | |
260
+
261
+ ---
262
+
263
+ ## Architecture
264
+
265
+ ```
266
+ Single Docker container (port 7860)
267
+ ├── FastAPI (app.py) - API + static file serving
268
+ ├── env/ - Pure Python RL environment
269
+ │ ├── models.py - Pydantic data models
270
+ │ ├── tasks.py - 3 task definitions
271
+ │ ├── reward.py - Per-step reward function
272
+ │ ├── grader.py - Deterministic final grader
273
+ │ └── environment.py - ExecOpsEnv state machine
274
+ ├── frontend/build/ - Pre-built React dashboard
275
+ └── inference.py - LLM agent baseline script
276
+ ```
app.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from fastapi import FastAPI, Query, HTTPException
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.responses import JSONResponse
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+
7
+ from env import ExecOpsEnv, Action, grade
8
+
9
+ app = FastAPI(title="AI Executive Operations Manager", version="1.0.0")
10
+
11
+ app.add_middleware(
12
+ CORSMiddleware,
13
+ allow_origins=["*"],
14
+ allow_methods=["*"],
15
+ allow_headers=["*"],
16
+ )
17
+
18
+ # Global environment instance (single session)
19
+ env = ExecOpsEnv("easy")
20
+
21
+
22
+ @app.api_route("/reset", methods=["GET", "POST"])
23
+ def reset(task: str = Query("easy")):
24
+ """Reset the environment to a fresh state for the given task."""
25
+ global env
26
+ valid_tasks = ["easy", "medium", "hard"]
27
+ if task not in valid_tasks:
28
+ raise HTTPException(status_code=400, detail=f"Invalid task. Choose from: {valid_tasks}")
29
+ env = ExecOpsEnv(task)
30
+ return env.reset()
31
+
32
+
33
+ @app.post("/step")
34
+ def step(action: Action):
35
+ """Execute one action step in the environment."""
36
+ global env
37
+ try:
38
+ result = env.step(action)
39
+ return result
40
+ except (ValueError, RuntimeError) as e:
41
+ raise HTTPException(status_code=400, detail=str(e))
42
+
43
+
44
+ @app.get("/state")
45
+ def get_state():
46
+ """Return the full current environment state."""
47
+ global env
48
+ return env.get_state()
49
+
50
+
51
+ @app.get("/grade")
52
+ def get_grade():
53
+ """Return the current grade score (0.0-1.0)."""
54
+ global env
55
+ return {"score": grade(env._state), "task_id": env.task_id}
56
+
57
+
58
+ @app.get("/health")
59
+ def health():
60
+ """OpenEnv runtime health check."""
61
+ return {"status": "healthy"}
62
+
63
+
64
+ @app.get("/metadata")
65
+ def metadata():
66
+ """OpenEnv runtime metadata."""
67
+ return {
68
+ "name": "ai-executive-ops-manager",
69
+ "description": (
70
+ "Simulates a startup CEO's operational day. An AI agent manages a realistic "
71
+ "executive inbox under time pressure, making triage decisions - reply, schedule, "
72
+ "delegate, or ignore - to handle conflicting priorities and cascading crises."
73
+ ),
74
+ "version": "1.0.0",
75
+ "tasks": ["easy", "medium", "hard"],
76
+ }
77
+
78
+
79
+ @app.get("/schema")
80
+ def schema():
81
+ """OpenEnv runtime schema for action, observation, and state."""
82
+ return {
83
+ "action": {
84
+ "type": "object",
85
+ "properties": {
86
+ "type": {"type": "string", "enum": ["reply", "schedule", "delegate", "ignore"]},
87
+ "email_id": {"type": "string", "description": "ID of the email to act on (e.g. 'e1')"},
88
+ },
89
+ "required": ["type", "email_id"],
90
+ },
91
+ "observation": {
92
+ "type": "object",
93
+ "properties": {
94
+ "time": {"type": "string"},
95
+ "step": {"type": "integer"},
96
+ "max_steps": {"type": "integer"},
97
+ "steps_remaining": {"type": "integer"},
98
+ "inbox": {"type": "array", "items": {"type": "object"}},
99
+ "calendar": {"type": "array", "items": {"type": "object"}},
100
+ "goals": {"type": "array", "items": {"type": "object"}},
101
+ "pending_goals": {"type": "array", "items": {"type": "object"}},
102
+ "total_reward": {"type": "number"},
103
+ "done": {"type": "boolean"},
104
+ },
105
+ },
106
+ "state": {
107
+ "type": "object",
108
+ "properties": {
109
+ "current_step": {"type": "integer"},
110
+ "max_steps": {"type": "integer"},
111
+ "current_time": {"type": "string"},
112
+ "inbox": {"type": "array"},
113
+ "calendar": {"type": "array"},
114
+ "goals": {"type": "array"},
115
+ "history": {"type": "array"},
116
+ "total_reward": {"type": "number"},
117
+ "done": {"type": "boolean"},
118
+ "task_id": {"type": "string"},
119
+ },
120
+ },
121
+ }
122
+
123
+
124
+ @app.post("/mcp")
125
+ def mcp(request: dict = None):
126
+ """Minimal MCP/JSON-RPC endpoint for OpenEnv runtime compliance."""
127
+ return {"jsonrpc": "2.0", "result": {"tools": []}, "id": None}
128
+
129
+
130
+ @app.get("/tasks")
131
+ def list_tasks():
132
+ """Return available task descriptions."""
133
+ return {
134
+ "tasks": [
135
+ {
136
+ "id": "easy",
137
+ "name": "Monday Morning Catchup",
138
+ "difficulty": "Easy",
139
+ "description": "A manageable Monday inbox. Priorities are clear.",
140
+ "max_steps": 6,
141
+ },
142
+ {
143
+ "id": "medium",
144
+ "name": "Investor Demo Day Prep",
145
+ "difficulty": "Medium",
146
+ "description": "Conflicting priorities on demo day. Scheduling required.",
147
+ "max_steps": 8,
148
+ },
149
+ {
150
+ "id": "hard",
151
+ "name": "Series B Crisis Day",
152
+ "difficulty": "Hard",
153
+ "description": "Cascading crises. Impossible to handle everything.",
154
+ "max_steps": 8,
155
+ },
156
+ ]
157
+ }
158
+
159
+
160
+ # Serve React frontend - MUST come after all API routes
161
+ FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "frontend", "build")
162
+ if os.path.exists(FRONTEND_DIR):
163
+ app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
164
+ else:
165
+ # Fallback: return health check at root if frontend not built yet
166
+ @app.get("/")
167
+ def root():
168
+ return {"status": "ok", "message": "AI Executive Operations Manager API", "frontend": "not built"}
env/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from .environment import ExecOpsEnv
2
+ from .models import Action, EnvState, StepResult, Email, Goal, CalendarEvent
3
+ from .grader import grade
4
+
5
+ __all__ = ["ExecOpsEnv", "Action", "EnvState", "StepResult", "Email", "Goal", "CalendarEvent", "grade"]
env/environment.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core environment: ExecOpsEnv
3
+ Manages the CEO simulation state machine.
4
+ """
5
+
6
+ from typing import Optional
7
+ from .models import Action, Email, CalendarEvent, Goal, EnvState, StepResult
8
+ from .tasks import get_task
9
+ from .reward import compute_reward
10
+
11
+ # 30-minute time slots starting at 9:00 AM
12
+ TIME_SLOTS = [
13
+ "9:00 AM", "9:30 AM", "10:00 AM", "10:30 AM",
14
+ "11:00 AM", "11:30 AM", "12:00 PM", "12:30 PM",
15
+ "1:00 PM", "1:30 PM", "2:00 PM", "2:30 PM",
16
+ "3:00 PM", "3:30 PM", "4:00 PM", "4:30 PM",
17
+ "5:00 PM",
18
+ ]
19
+
20
+ URGENCY_DECAY = 0.15 # Urgency drops by this amount each step
21
+
22
+
23
+ class ExecOpsEnv:
24
+ def __init__(self, task_id: str = "easy"):
25
+ self.task_id = task_id
26
+ self._task_data = get_task(task_id)
27
+ self._state: Optional[EnvState] = None
28
+ self.reset()
29
+
30
+ def reset(self) -> dict:
31
+ """Reinitialize state from task definition. Returns initial observation."""
32
+ task = get_task(self.task_id)
33
+
34
+ emails = [Email(**e) for e in task["emails"]]
35
+ calendar = [CalendarEvent(**c) for c in task["calendar"]]
36
+ goals = [Goal(**g) for g in task["goals"]]
37
+
38
+ self._state = EnvState(
39
+ current_step=0,
40
+ max_steps=task["max_steps"],
41
+ current_time=TIME_SLOTS[task.get("start_time_slot", 0)],
42
+ inbox=emails,
43
+ calendar=calendar,
44
+ goals=goals,
45
+ history=[],
46
+ total_reward=0.0,
47
+ done=False,
48
+ )
49
+ return self._build_observation()
50
+
51
+ def step(self, action: Action) -> dict:
52
+ """
53
+ Execute one step of the environment.
54
+ Order: validate → apply → update state → advance time → decay urgency
55
+ → compute reward → check done → return StepResult
56
+ """
57
+ if self._state is None:
58
+ raise RuntimeError("Environment not initialized. Call reset() first.")
59
+ if self._state.done:
60
+ raise RuntimeError("Environment is done. Call reset() to restart.")
61
+
62
+ # 1. Validate action
63
+ valid_action_types = {"reply", "schedule", "delegate", "ignore"}
64
+ if action.type not in valid_action_types:
65
+ raise ValueError(
66
+ f"Invalid action type '{action.type}'. Must be one of: {sorted(valid_action_types)}"
67
+ )
68
+ if not action.email_id:
69
+ raise ValueError("Action is missing required field 'email_id'.")
70
+ email = self._find_email(action.email_id)
71
+ if email is None:
72
+ raise ValueError(f"Email '{action.email_id}' not found in inbox.")
73
+ if email.handled:
74
+ raise ValueError(f"Email '{action.email_id}' is already handled.")
75
+
76
+ # 2. Apply action
77
+ email.handled = True
78
+ email.action_taken = action.type
79
+
80
+ # Update calendar if scheduling
81
+ if action.type == "schedule":
82
+ self._add_to_calendar(email)
83
+
84
+ # 3. Record action in history
85
+ step_entry = {
86
+ "step": self._state.current_step,
87
+ "time": self._state.current_time,
88
+ "action": action.type,
89
+ "email_id": action.email_id,
90
+ "email_subject": email.subject,
91
+ "email_priority": email.priority,
92
+ }
93
+
94
+ # 6. Compute reward BEFORE marking goals complete (so reward fn sees pending goals)
95
+ reward = compute_reward(action, email, self._state.goals, self._state)
96
+
97
+ # Now mark goals completed
98
+ for goal in self._state.goals:
99
+ if goal.target_email_id == action.email_id and not goal.completed:
100
+ if goal.required_action == action.type:
101
+ goal.completed = True
102
+ step_entry["reward"] = reward
103
+ self._state.total_reward += reward
104
+ self._state.history.append(step_entry)
105
+
106
+ # 4. Advance time
107
+ self._advance_time()
108
+
109
+ # 5. Decay urgency on unhandled emails
110
+ self._decay_urgency()
111
+
112
+ # 7. Check termination
113
+ self._state.current_step += 1
114
+ self._check_done()
115
+
116
+ obs = self._build_observation()
117
+ return {
118
+ "observation": obs,
119
+ "reward": reward,
120
+ "done": self._state.done,
121
+ "info": {
122
+ "step": self._state.current_step,
123
+ "total_reward": self._state.total_reward,
124
+ "action_applied": action.type,
125
+ "email_subject": email.subject,
126
+ },
127
+ }
128
+
129
+ def get_state(self) -> dict:
130
+ """Return full serialized state."""
131
+ if self._state is None:
132
+ raise RuntimeError("Environment not initialized.")
133
+ return {
134
+ "current_step": self._state.current_step,
135
+ "max_steps": self._state.max_steps,
136
+ "current_time": self._state.current_time,
137
+ "inbox": [e.model_dump() for e in self._state.inbox],
138
+ "calendar": [c.model_dump() for c in self._state.calendar],
139
+ "goals": [g.model_dump() for g in self._state.goals],
140
+ "history": self._state.history,
141
+ "total_reward": self._state.total_reward,
142
+ "done": self._state.done,
143
+ "task_id": self.task_id,
144
+ "task_name": self._task_data.get("task_name", ""),
145
+ "task_description": self._task_data.get("description", ""),
146
+ }
147
+
148
+ # -------------------------
149
+ # Private helpers
150
+ # -------------------------
151
+
152
+ def _build_observation(self) -> dict:
153
+ """Build the observation dict that the agent sees."""
154
+ unhandled = [e for e in self._state.inbox if not e.handled]
155
+ pending_goals = [g for g in self._state.goals if not g.completed]
156
+ return {
157
+ "time": self._state.current_time,
158
+ "step": self._state.current_step,
159
+ "max_steps": self._state.max_steps,
160
+ "steps_remaining": self._state.max_steps - self._state.current_step,
161
+ "inbox": [e.model_dump() for e in self._state.inbox],
162
+ "unhandled_count": len(unhandled),
163
+ "calendar": [c.model_dump() for c in self._state.calendar],
164
+ "goals": [g.model_dump() for g in self._state.goals],
165
+ "pending_goals": [g.model_dump() for g in pending_goals],
166
+ "total_reward": self._state.total_reward,
167
+ "done": self._state.done,
168
+ "task_id": self.task_id,
169
+ "task_name": self._task_data.get("task_name", ""),
170
+ }
171
+
172
+ def _find_email(self, email_id: str) -> Optional[Email]:
173
+ for email in self._state.inbox:
174
+ if email.id == email_id:
175
+ return email
176
+ return None
177
+
178
+ def _advance_time(self):
179
+ """Move to the next 30-minute time slot."""
180
+ try:
181
+ idx = TIME_SLOTS.index(self._state.current_time)
182
+ if idx + 1 < len(TIME_SLOTS):
183
+ self._state.current_time = TIME_SLOTS[idx + 1]
184
+ except ValueError:
185
+ pass
186
+
187
+ def _decay_urgency(self):
188
+ """Reduce urgency of all unhandled emails."""
189
+ for email in self._state.inbox:
190
+ if not email.handled:
191
+ email.urgency = max(0.0, email.urgency - URGENCY_DECAY)
192
+
193
+ def _check_done(self):
194
+ """Mark done if max steps reached or all goals completed."""
195
+ all_goals_done = all(g.completed for g in self._state.goals)
196
+ if self._state.current_step >= self._state.max_steps or all_goals_done:
197
+ self._state.done = True
198
+
199
+ def _add_to_calendar(self, email: Email):
200
+ """Add a scheduling event to the calendar for scheduled emails."""
201
+ # Find next available slot
202
+ locked_slots = {c.time_slot for c in self._state.calendar if c.locked}
203
+ used_slots = {c.time_slot for c in self._state.calendar}
204
+ for slot in range(len(TIME_SLOTS)):
205
+ if slot not in used_slots and slot not in locked_slots:
206
+ self._state.calendar.append(
207
+ CalendarEvent(
208
+ time_slot=slot,
209
+ title=f"Scheduled: {email.subject[:40]}",
210
+ attendee=email.sender.split("<")[0].strip(),
211
+ locked=False,
212
+ )
213
+ )
214
+ break
env/grader.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deterministic final grader.
3
+ Returns a score in [0.0, 1.0] based on the terminal state.
4
+ """
5
+
6
+ from .models import EnvState
7
+
8
+
9
+ def grade(state: EnvState) -> float:
10
+ """
11
+ Deterministic final score for the environment state.
12
+
13
+ Score = weighted combination:
14
+ 60% - goal completion rate (weighted by goal priority)
15
+ Goals are the explicit objectives defined per task. They carry the
16
+ highest weight because completing the right goal (e.g. saving a $18M
17
+ term sheet) matters far more than handling any random email.
18
+ 25% - priority-weighted email handling rate
19
+ Measures breadth of coverage across the inbox. Ensures the agent
20
+ doesn't only cherry-pick goal emails while ignoring other important items.
21
+ 15% - efficiency bonus (only awarded if all high-priority goals are done)
22
+ Rewards faster completion, but only after critical goals are met -
23
+ efficiency never trumps correctness.
24
+
25
+ Returns: float in [0.0, 1.0]
26
+ """
27
+ goals = state.goals
28
+ inbox = state.inbox
29
+
30
+ # --- 60%: Goal completion (priority-weighted) ---
31
+ goal_score = 0.0
32
+ if goals:
33
+ total_priority = sum(g.priority for g in goals)
34
+ completed_priority = sum(g.priority for g in goals if g.completed)
35
+ goal_score = completed_priority / total_priority if total_priority > 0 else 0.0
36
+
37
+ # --- 25%: Priority-weighted email handling ---
38
+ email_score = 0.0
39
+ if inbox:
40
+ total_email_priority = sum(e.priority for e in inbox)
41
+ handled_email_priority = sum(e.priority for e in inbox if e.handled)
42
+ email_score = handled_email_priority / total_email_priority if total_email_priority > 0 else 0.0
43
+
44
+ # --- 15%: Efficiency bonus ---
45
+ efficiency_score = 0.0
46
+ high_priority_goals = [g for g in goals if g.priority >= 4]
47
+ all_high_priority_done = all(g.completed for g in high_priority_goals)
48
+ if all_high_priority_done and high_priority_goals:
49
+ steps_used = state.current_step
50
+ steps_ratio = steps_used / state.max_steps if state.max_steps > 0 else 1.0
51
+ efficiency_score = max(0.0, 1.0 - steps_ratio)
52
+
53
+ # --- Weighted combination ---
54
+ final_score = (
55
+ 0.60 * goal_score +
56
+ 0.25 * email_score +
57
+ 0.15 * efficiency_score
58
+ )
59
+
60
+ return max(0.0, min(1.0, final_score))
env/models.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import List, Optional, Literal
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class Email(BaseModel):
7
+ id: str
8
+ sender: str
9
+ subject: str
10
+ body: str
11
+ priority: int = Field(..., ge=1, le=5) # 1=low, 5=critical
12
+ urgency: float = Field(default=1.0, ge=0.0, le=1.0) # decays each step
13
+ received_at: int = 0 # step number when received
14
+ handled: bool = False
15
+ action_taken: Optional[str] = None
16
+
17
+
18
+ class CalendarEvent(BaseModel):
19
+ time_slot: int # slot number (0-based)
20
+ title: str
21
+ attendee: Optional[str] = None
22
+ locked: bool = False # locked = CEO must attend, can't move
23
+
24
+
25
+ class Goal(BaseModel):
26
+ id: str
27
+ description: str
28
+ completed: bool = False
29
+ priority: int = Field(..., ge=1, le=5)
30
+ required_action: str # "reply", "schedule", "delegate", "ignore"
31
+ target_email_id: str
32
+
33
+
34
+ class Action(BaseModel):
35
+ type: Literal["reply", "schedule", "delegate", "ignore"]
36
+ email_id: str
37
+ note: Optional[str] = None
38
+
39
+
40
+ class StepResult(BaseModel):
41
+ observation: dict
42
+ reward: float
43
+ done: bool
44
+ info: dict
45
+
46
+
47
+ class EnvState(BaseModel):
48
+ current_step: int = 0
49
+ max_steps: int = 6
50
+ current_time: str = "9:00 AM"
51
+ inbox: List[Email] = Field(default_factory=list)
52
+ calendar: List[CalendarEvent] = Field(default_factory=list)
53
+ goals: List[Goal] = Field(default_factory=list)
54
+ history: List[dict] = Field(default_factory=list)
55
+ total_reward: float = 0.0
56
+ done: bool = False
env/reward.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Per-step continuous reward function.
3
+ Rewards are given each step based on the quality of the action taken.
4
+ """
5
+
6
+ from typing import List, Optional
7
+ from .models import Action, Email, Goal, EnvState
8
+
9
+
10
+ def compute_reward(
11
+ action: Action,
12
+ email: Email,
13
+ goals: List[Goal],
14
+ state: EnvState,
15
+ ) -> float:
16
+ """
17
+ Compute per-step reward for taking `action` on `email`.
18
+
19
+ Component breakdown (for interpretability):
20
+ ┌─ POSITIVE ──────────────────────────────────────────────────────────┐
21
+ │ goal_completion +0.30 * (priority/5) Completing a defined goal │
22
+ │ action_match +0.10 Correct action type chosen │
23
+ │ urgency_bonus +0.15 * urgency Acting fast on urgent items │
24
+ │ delegation_bonus +0.05 Smart delegation of P1-P2 │
25
+ ├─ NEGATIVE ──────────────────────────────────────────────────────────┤
26
+ │ step_cost -0.02 Baseline cost per step │
27
+ │ ignore_critical -0.25 Ignoring P4-P5 emails │
28
+ │ wasted_step -0.10 Replying to P1 over P4+ │
29
+ └─────────────────────────────────────────────────────────────────────┘
30
+
31
+ Returns: reward clamped to [-0.3, 0.5]
32
+ """
33
+ reward = 0.0
34
+
35
+ # --- Per-step cost (encourages efficiency) ---
36
+ reward -= 0.02
37
+
38
+ # --- Find if this action completes a goal ---
39
+ completed_goal: Optional[Goal] = None
40
+ for goal in goals:
41
+ if goal.target_email_id == email.id and not goal.completed:
42
+ if goal.required_action == action.type:
43
+ completed_goal = goal
44
+ break
45
+
46
+ # --- Goal completion reward ---
47
+ if completed_goal is not None:
48
+ reward += 0.30 * (completed_goal.priority / 5.0)
49
+
50
+ # --- Urgency bonus: act fast on high-urgency items ---
51
+ if action.type != "ignore" and email.urgency > 0.5:
52
+ reward += 0.15 * email.urgency
53
+
54
+ # --- Efficiency: delegating low-priority items is smart ---
55
+ if action.type == "delegate" and email.priority <= 2:
56
+ reward += 0.05
57
+
58
+ # --- Appropriate action bonus ---
59
+ if completed_goal is not None:
60
+ reward += 0.10
61
+
62
+ # --- Penalty: ignoring critical emails ---
63
+ if action.type == "ignore" and email.priority >= 4:
64
+ reward -= 0.25
65
+
66
+ # --- Penalty: wasting steps on low-priority when high-priority unhandled ---
67
+ if email.priority <= 2:
68
+ unhandled_critical = [
69
+ e for e in state.inbox
70
+ if not e.handled and e.priority >= 4 and e.id != email.id
71
+ ]
72
+ if unhandled_critical and action.type == "reply":
73
+ reward -= 0.10
74
+
75
+ return max(-0.3, min(0.5, reward))
env/tasks.py ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task definitions for NovaTech AI - Series B AI infrastructure startup.
3
+ CEO: Alex Rivera. Company: NovaTech AI (AI infrastructure, ~80 employees).
4
+ """
5
+
6
+ from typing import Dict, Any
7
+
8
+
9
+ def get_task_easy() -> Dict[str, Any]:
10
+ """
11
+ Monday Morning Catchup
12
+ A calm Monday with a few clear priorities. Good agent handles everything well.
13
+ max_steps: 6
14
+ """
15
+ return {
16
+ "task_id": "easy",
17
+ "task_name": "Monday Morning Catchup",
18
+ "description": "A manageable Monday inbox. Priorities are clear - handle critical items first.",
19
+ "objective": "Handle all 3 critical goals: approve production deploy, respond to GlobalBank contract, delegate onboarding signatures.",
20
+ "constraints": "6 steps maximum. One trivial email can safely be ignored.",
21
+ "success_condition": "All 3 goals completed. Score >= 0.85 indicates strong performance.",
22
+ "max_steps": 6,
23
+ "start_time_slot": 0,
24
+ "emails": [
25
+ {
26
+ "id": "e1",
27
+ "sender": "Sarah Chen <s.chen@novatech.ai>",
28
+ "subject": "URGENT: Production deploy approval needed",
29
+ "body": (
30
+ "Alex, we need your sign-off on the production deploy for v2.4.1. "
31
+ "It fixes the memory leak that's been degrading API latency by 40% since Friday. "
32
+ "Engineering is standing by - every hour of delay costs us $3K in compute waste. "
33
+ "Please reply with approval ASAP so we can push before the morning traffic spike."
34
+ ),
35
+ "priority": 5,
36
+ "urgency": 0.95,
37
+ },
38
+ {
39
+ "id": "e2",
40
+ "sender": "David Park <d.park@novatech.ai>",
41
+ "subject": "Enterprise contract - GlobalBank review needed",
42
+ "body": (
43
+ "Alex, the GlobalBank contract ($1.2M ARR) is ready for your review. "
44
+ "Legal has signed off. Their procurement team needs a response by EOD Tuesday "
45
+ "or they go to a competitor. I've attached the redlines - just need your go-ahead "
46
+ "to execute. This would be our largest enterprise customer."
47
+ ),
48
+ "priority": 4,
49
+ "urgency": 0.75,
50
+ },
51
+ {
52
+ "id": "e3",
53
+ "sender": "Priya Nair <p.nair@novatech.ai>",
54
+ "subject": "New hire onboarding docs - your signature needed",
55
+ "body": (
56
+ "Hi Alex, we have 3 new engineers starting next Monday. "
57
+ "I just need your digital signature on the offer letters and NDA templates "
58
+ "to complete their onboarding packets. No rush today, but would love to have "
59
+ "these by Wednesday so HR can send welcome packages in time."
60
+ ),
61
+ "priority": 3,
62
+ "urgency": 0.50,
63
+ },
64
+ {
65
+ "id": "e4",
66
+ "sender": "Jake Martinez <j.martinez@novatech.ai>",
67
+ "subject": "Office snack order - preferences?",
68
+ "body": (
69
+ "Hey Alex! Planning the monthly office snack order. "
70
+ "Do you have any preferences for the break room this month? "
71
+ "I was thinking maybe some healthier options alongside the usual stuff. "
72
+ "Let me know by Friday, totally non-urgent!"
73
+ ),
74
+ "priority": 1,
75
+ "urgency": 0.10,
76
+ },
77
+ ],
78
+ "calendar": [
79
+ {"time_slot": 3, "title": "Board Sync", "attendee": "Board of Directors", "locked": True},
80
+ ],
81
+ "goals": [
82
+ {
83
+ "id": "g1",
84
+ "description": "Approve production deploy to fix memory leak",
85
+ "priority": 5,
86
+ "required_action": "reply",
87
+ "target_email_id": "e1",
88
+ },
89
+ {
90
+ "id": "g2",
91
+ "description": "Review and approve GlobalBank enterprise contract",
92
+ "priority": 4,
93
+ "required_action": "reply",
94
+ "target_email_id": "e2",
95
+ },
96
+ {
97
+ "id": "g3",
98
+ "description": "Handle new hire onboarding signatures",
99
+ "priority": 3,
100
+ "required_action": "delegate",
101
+ "target_email_id": "e3",
102
+ },
103
+ ],
104
+ }
105
+
106
+
107
+ def get_task_medium() -> Dict[str, Any]:
108
+ """
109
+ Investor Demo Day Prep
110
+ Conflicting high-priority items on demo day. Requires triage and smart delegation.
111
+ max_steps: 8
112
+ """
113
+ return {
114
+ "task_id": "medium",
115
+ "task_name": "Investor Demo Day Prep",
116
+ "description": (
117
+ "NovaTech's Series B investor demo is today. Multiple P5 items are competing "
118
+ "for your attention. Smart scheduling and delegation required."
119
+ ),
120
+ "objective": "Balance 5 competing goals on investor demo day: schedule investor pre-call, resolve architecture with CTO, align with CFO on runway, sign Sequoia NDA, and approve ML engineer offer.",
121
+ "constraints": "8 steps for 7 emails. Two locked calendar events limit scheduling windows. Conflicts between P5 items force tradeoffs.",
122
+ "success_condition": "All 5 goals completed within 8 steps. Score >= 0.80 indicates strong prioritization.",
123
+ "max_steps": 8,
124
+ "start_time_slot": 0,
125
+ "emails": [
126
+ {
127
+ "id": "e1",
128
+ "sender": "Marcus Williams <m.williams@sequoiacap.com>",
129
+ "subject": "URGENT: Pre-demo call - need 30 min before 2PM",
130
+ "body": (
131
+ "Alex, before today's full demo I need a quick 30-min call with you. "
132
+ "Specifically about the competitive landscape - one of your direct competitors "
133
+ "just announced a major funding round this morning. I want to hear your positioning "
134
+ "before I brief my partners. This is important for the partnership discussion."
135
+ ),
136
+ "priority": 5,
137
+ "urgency": 0.95,
138
+ },
139
+ {
140
+ "id": "e2",
141
+ "sender": "Sarah Chen <s.chen@novatech.ai>",
142
+ "subject": "CRITICAL: Architecture review - infra decisions before demo",
143
+ "body": (
144
+ "Alex, I've flagged this twice already - we need a decision on the multi-region "
145
+ "architecture BEFORE the demo. Investors will ask about our scaling story. "
146
+ "If we say the wrong thing, it'll contradict the technical deck. "
147
+ "Need 45 minutes with you today. Can we slot this in the morning?"
148
+ ),
149
+ "priority": 5,
150
+ "urgency": 0.90,
151
+ },
152
+ {
153
+ "id": "e3",
154
+ "sender": "Rachel Torres <r.torres@novatech.ai>",
155
+ "subject": "CFO: Runway concern - cash position update",
156
+ "body": (
157
+ "Alex, reviewed our burn rate with the updated headcount projections. "
158
+ "We have 8.5 months of runway at current rate, down from 11 months last quarter. "
159
+ "Before you take on any new commitments in the investor conversation today, "
160
+ "I need to align with you on the bridge financing contingency. Can we connect?"
161
+ ),
162
+ "priority": 4,
163
+ "urgency": 0.80,
164
+ },
165
+ {
166
+ "id": "e4",
167
+ "sender": "Legal <legal@novatech.ai>",
168
+ "subject": "NDA with Sequoia - needs signature today",
169
+ "body": (
170
+ "Hi Alex, standard NDA with Sequoia Capital needs your signature before "
171
+ "the demo meeting. This is required before we can share our technical roadmap "
172
+ "and customer data in the presentation. Please sign via DocuSign - link below. "
173
+ "Takes 2 minutes and is legally necessary."
174
+ ),
175
+ "priority": 4,
176
+ "urgency": 0.85,
177
+ },
178
+ {
179
+ "id": "e5",
180
+ "sender": "Tom Bradley <t.bradley@novatech.ai>",
181
+ "subject": "Designer needs brand approval for pitch deck",
182
+ "body": (
183
+ "Hey, our external designer has the final pitch deck revisions ready. "
184
+ "She needs your approval on the updated brand treatment - specifically the "
185
+ "new color palette and logo placement on slides 3, 7, and 12. "
186
+ "Can someone on your team give a thumbs up? I can handle if you delegate."
187
+ ),
188
+ "priority": 3,
189
+ "urgency": 0.60,
190
+ },
191
+ {
192
+ "id": "e6",
193
+ "sender": "Amy Liu <a.liu@novatech.ai>",
194
+ "subject": "Offer letter approval - senior ML engineer",
195
+ "body": (
196
+ "Alex, I have a verbal offer pending for Dr. James Okafor (senior ML engineer, "
197
+ "ex-Google Brain). He has a competing offer from OpenAI and needs our written "
198
+ "offer by tomorrow morning. Compensation is within approved bands. "
199
+ "Just need your approval to proceed - HR can handle the paperwork."
200
+ ),
201
+ "priority": 3,
202
+ "urgency": 0.65,
203
+ },
204
+ {
205
+ "id": "e7",
206
+ "sender": "Office Manager <office@novatech.ai>",
207
+ "subject": "Catering confirmation for demo - allergies list",
208
+ "body": (
209
+ "Hi Alex, just need to confirm the catering order for today's investor visit. "
210
+ "We have the standard setup but wanted to check if Sequoia team has dietary restrictions. "
211
+ "I'll just go ahead with the usual if I don't hear back. No action needed from you!"
212
+ ),
213
+ "priority": 1,
214
+ "urgency": 0.15,
215
+ },
216
+ ],
217
+ "calendar": [
218
+ {"time_slot": 1, "title": "Team Standup", "attendee": "Engineering Team", "locked": True},
219
+ {"time_slot": 4, "title": "Sequoia Demo Presentation", "attendee": "Marcus Williams", "locked": True},
220
+ ],
221
+ "goals": [
222
+ {
223
+ "id": "g1",
224
+ "description": "Schedule pre-demo call with Marcus Williams (Sequoia)",
225
+ "priority": 5,
226
+ "required_action": "schedule",
227
+ "target_email_id": "e1",
228
+ },
229
+ {
230
+ "id": "g2",
231
+ "description": "Address architecture decision with CTO before demo",
232
+ "priority": 5,
233
+ "required_action": "reply",
234
+ "target_email_id": "e2",
235
+ },
236
+ {
237
+ "id": "g3",
238
+ "description": "Align with CFO on runway and bridge contingency",
239
+ "priority": 4,
240
+ "required_action": "delegate",
241
+ "target_email_id": "e3",
242
+ },
243
+ {
244
+ "id": "g4",
245
+ "description": "Sign NDA with Sequoia Capital",
246
+ "priority": 4,
247
+ "required_action": "reply",
248
+ "target_email_id": "e4",
249
+ },
250
+ {
251
+ "id": "g5",
252
+ "description": "Approve offer letter for Dr. James Okafor",
253
+ "priority": 3,
254
+ "required_action": "delegate",
255
+ "target_email_id": "e6",
256
+ },
257
+ ],
258
+ }
259
+
260
+
261
+ def get_task_hard() -> Dict[str, Any]:
262
+ """
263
+ Series B Crisis Day
264
+ Cascading crises. Multiple P5 items. Impossible to handle everything perfectly.
265
+ A great agent scores ~0.65-0.75. Perfect score is intentionally unachievable.
266
+ max_steps: 8
267
+
268
+ DESIGN NOTE: This task is intentionally unsolvable to a perfect score.
269
+ With 7 goals across 10 emails in only 8 steps, no agent can complete everything.
270
+ This forces meaningful prioritization decisions - the score reflects *which* crises
271
+ the agent chose to address, not whether it completed all tasks.
272
+ A score of 0.65-0.75 represents excellent triage under impossible constraints.
273
+ """
274
+ return {
275
+ "task_id": "hard",
276
+ "task_name": "Series B Crisis Day",
277
+ "description": (
278
+ "Everything is happening at once. Four P5 crises are competing for your "
279
+ "8 available steps. Ruthless triage is required. You CANNOT handle everything."
280
+ ),
281
+ "objective": "Triage 7 competing goals across 10 emails in 8 steps. Prioritize by impact: investor term sheet > DB rollback > PR crisis > GDPR deadline.",
282
+ "constraints": (
283
+ "8 steps for 10 emails with 7 goals - mathematically impossible to complete all. "
284
+ "This is intentional: the task measures ruthless prioritization, not completion rate. "
285
+ "Perfect score is not achievable by design. Score 0.65-0.75 = excellent performance."
286
+ ),
287
+ "success_condition": "Handle the 4 highest-priority P5 goals (investor, DB, PR, GDPR). Score >= 0.65 indicates strong triage.",
288
+ "max_steps": 8,
289
+ "start_time_slot": 0,
290
+ "emails": [
291
+ {
292
+ "id": "e1",
293
+ "sender": "Marcus Williams <m.williams@sequoiacap.com>",
294
+ "subject": "URGENT: Term sheet - pulling out unless we talk TODAY",
295
+ "body": (
296
+ "Alex, I'm going to be direct. After this morning's news about Vertex AI's "
297
+ "competitive product launch, my partners are getting cold feet on NovaTech. "
298
+ "I need to speak with you TODAY - before 4PM - to understand your differentiation "
299
+ "strategy or we are pulling the $18M term sheet. This is not a negotiating tactic. "
300
+ "Please call me immediately: +1-415-555-0142."
301
+ ),
302
+ "priority": 5,
303
+ "urgency": 0.98,
304
+ },
305
+ {
306
+ "id": "e2",
307
+ "sender": "Sarah Chen <s.chen@novatech.ai>",
308
+ "subject": "CRITICAL: Production database corruption - customers affected",
309
+ "body": (
310
+ "Alex, we have a P0 incident. PostgreSQL replication lag caused data inconsistency "
311
+ "on our primary cluster. 23 enterprise customers are experiencing incorrect API responses. "
312
+ "We've isolated the issue but need your authorization to execute the emergency rollback "
313
+ "procedure - it will take the platform offline for ~45 minutes. Revenue impact: ~$85K/hour. "
314
+ "Awaiting your go-ahead. The longer we wait, the worse the customer impact."
315
+ ),
316
+ "priority": 5,
317
+ "urgency": 0.97,
318
+ },
319
+ {
320
+ "id": "e3",
321
+ "sender": "Jordan Kim <j.kim@novatech.ai>",
322
+ "subject": "I'm resigning - effective in 2 weeks",
323
+ "body": (
324
+ "Alex, I've decided to accept an offer at Anthropic. I know the timing is terrible "
325
+ "with the Series B, but I need to do what's right for my career. I'll give you a "
326
+ "full 2 weeks and do everything I can to transition my work - I'm leading the "
327
+ "inference optimization project and the enterprise API roadmap. I'm happy to discuss "
328
+ "how to minimize impact. Please let me know when you can talk."
329
+ ),
330
+ "priority": 5,
331
+ "urgency": 0.85,
332
+ },
333
+ {
334
+ "id": "e4",
335
+ "sender": "PR Crisis Line <pr@novatech.ai>",
336
+ "subject": "TechCrunch article going live in 2 hours - security breach allegation",
337
+ "body": (
338
+ "Alex - TechCrunch is publishing an article in 2 hours alleging NovaTech had "
339
+ "a security breach exposing customer model weights. This appears to be based on "
340
+ "a disgruntled former contractor's claims, which are false. However, without a "
341
+ "statement from you, they will publish uncontested. We need a quote from the CEO "
342
+ "within 90 minutes. Legal is on standby. Please respond immediately."
343
+ ),
344
+ "priority": 5,
345
+ "urgency": 0.96,
346
+ },
347
+ {
348
+ "id": "e5",
349
+ "sender": "Elena Vasquez <e.vasquez@novatech.ai>",
350
+ "subject": "Co-founder disagreement - need to resolve before board meeting",
351
+ "body": (
352
+ "Alex, I need to talk before the board meeting Thursday. I fundamentally disagree "
353
+ "with the enterprise pivot - I think we're abandoning the developer community that "
354
+ "got us here for short-term ARR. I've prepared a counter-proposal. If we can't align, "
355
+ "I may need to raise this in front of the board. Can we get 30 minutes this week?"
356
+ ),
357
+ "priority": 4,
358
+ "urgency": 0.70,
359
+ },
360
+ {
361
+ "id": "e6",
362
+ "sender": "Legal <legal@novatech.ai>",
363
+ "subject": "GDPR data deletion request - 72-hour deadline expires at 5PM",
364
+ "body": (
365
+ "Alex, we have a GDPR Article 17 data deletion request from EU customer Hoffmann GmbH. "
366
+ "The 72-hour response clock expires at 5PM today. Failure to respond opens us to "
367
+ "a €20M fine or 4% of annual revenue. Engineering needs your authorization to "
368
+ "execute the deletion script in production. This is a legal obligation - not optional."
369
+ ),
370
+ "priority": 4,
371
+ "urgency": 0.92,
372
+ },
373
+ {
374
+ "id": "e7",
375
+ "sender": "Vendor Relations <vendor@novatech.ai>",
376
+ "subject": "AWS contract renewal - expires midnight tonight",
377
+ "body": (
378
+ "Alex, our AWS Enterprise Agreement expires tonight at midnight. If we don't renew, "
379
+ "we revert to on-demand pricing - cost jumps from $180K/mo to $340K/mo immediately. "
380
+ "Procurement needs a signed PO before 6PM. I've reviewed the terms and they're "
381
+ "acceptable. Just need your authorization to proceed."
382
+ ),
383
+ "priority": 3,
384
+ "urgency": 0.80,
385
+ },
386
+ {
387
+ "id": "e8",
388
+ "sender": "BD Team <bd@novatech.ai>",
389
+ "subject": "Acquisition offer from Microsoft - NDA required to review",
390
+ "body": (
391
+ "Alex, Microsoft's Corp Dev team has reached out about an acquisition conversation. "
392
+ "They've indicated interest in the $400-600M range based on public info. "
393
+ "They want us to sign an NDA to share more details. I know the timing with Series B "
394
+ "is complex, but wanted you to know this exists. Your call on how to proceed."
395
+ ),
396
+ "priority": 3,
397
+ "urgency": 0.40,
398
+ },
399
+ {
400
+ "id": "e9",
401
+ "sender": "People Ops <hr@novatech.ai>",
402
+ "subject": "Employee complaint escalation - needs response by EOW",
403
+ "body": (
404
+ "Alex, we've received a formal complaint from two engineers about management practices "
405
+ "in the infrastructure team. This has been escalated past the manager level and now "
406
+ "requires executive acknowledgment. I can handle the investigation entirely - "
407
+ "just need you to sign off that it's being addressed officially. No urgency today."
408
+ ),
409
+ "priority": 2,
410
+ "urgency": 0.30,
411
+ },
412
+ {
413
+ "id": "e10",
414
+ "sender": "Office Manager <office@novatech.ai>",
415
+ "subject": "Office lease renewal - decision needed this month",
416
+ "body": (
417
+ "Hi Alex, our SF office lease is up for renewal in 6 weeks. Landlord wants a "
418
+ "decision on whether we're renewing (3-year term, 8% increase) or vacating. "
419
+ "With the Series B timeline, I'm not sure what our headcount plans are. "
420
+ "Can we schedule time this week to discuss? No rush today."
421
+ ),
422
+ "priority": 1,
423
+ "urgency": 0.10,
424
+ },
425
+ ],
426
+ "calendar": [
427
+ {"time_slot": 1, "title": "Incident Review (P0)", "attendee": "Engineering", "locked": True},
428
+ {"time_slot": 3, "title": "Investor Check-in", "attendee": "Marcus Williams", "locked": True},
429
+ {"time_slot": 5, "title": "Board Prep Call", "attendee": "Board", "locked": True},
430
+ ],
431
+ "goals": [
432
+ {
433
+ "id": "g1",
434
+ "description": "Respond to Sequoia investor to save the $18M term sheet",
435
+ "priority": 5,
436
+ "required_action": "reply",
437
+ "target_email_id": "e1",
438
+ },
439
+ {
440
+ "id": "g2",
441
+ "description": "Authorize emergency DB rollback to stop customer impact",
442
+ "priority": 5,
443
+ "required_action": "reply",
444
+ "target_email_id": "e2",
445
+ },
446
+ {
447
+ "id": "g3",
448
+ "description": "Provide PR statement to stop TechCrunch breach article",
449
+ "priority": 5,
450
+ "required_action": "reply",
451
+ "target_email_id": "e4",
452
+ },
453
+ {
454
+ "id": "g4",
455
+ "description": "Authorize GDPR deletion before 5PM legal deadline",
456
+ "priority": 4,
457
+ "required_action": "reply",
458
+ "target_email_id": "e6",
459
+ },
460
+ {
461
+ "id": "g5",
462
+ "description": "Authorize AWS contract renewal before midnight",
463
+ "priority": 3,
464
+ "required_action": "delegate",
465
+ "target_email_id": "e7",
466
+ },
467
+ {
468
+ "id": "g6",
469
+ "description": "Acknowledge co-founder disagreement before board meeting",
470
+ "priority": 4,
471
+ "required_action": "schedule",
472
+ "target_email_id": "e5",
473
+ },
474
+ {
475
+ "id": "g7",
476
+ "description": "Respond to key engineer resignation",
477
+ "priority": 5,
478
+ "required_action": "reply",
479
+ "target_email_id": "e3",
480
+ },
481
+ ],
482
+ }
483
+
484
+
485
+ TASKS = {
486
+ "easy": get_task_easy,
487
+ "medium": get_task_medium,
488
+ "hard": get_task_hard,
489
+ }
490
+
491
+
492
+ def get_task(task_id: str) -> Dict[str, Any]:
493
+ if task_id not in TASKS:
494
+ raise ValueError(f"Unknown task: {task_id}. Valid: {list(TASKS.keys())}")
495
+ return TASKS[task_id]()
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "exec-ops-dashboard",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "react": "^18.2.0",
7
+ "react-dom": "^18.2.0",
8
+ "react-scripts": "5.0.1"
9
+ },
10
+ "scripts": {
11
+ "start": "react-scripts start",
12
+ "build": "react-scripts build",
13
+ "test": "react-scripts test"
14
+ },
15
+ "eslintConfig": {
16
+ "extends": ["react-app"]
17
+ },
18
+ "browserslist": {
19
+ "production": [">0.2%", "not dead", "not op_mini all"],
20
+ "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"]
21
+ }
22
+ }
frontend/public/index.html ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="theme-color" content="#0f172a" />
7
+ <meta name="description" content="AI Executive Operations Manager — CEO Simulation Dashboard" />
8
+ <title>AI Exec Ops Manager</title>
9
+ <style>
10
+ body { margin: 0; background: #0f172a; }
11
+ #root { min-height: 100vh; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <noscript>You need to enable JavaScript to run this app.</noscript>
16
+ <div id="root"></div>
17
+ </body>
18
+ </html>
frontend/src/App.css ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =====================================================
2
+ AI Executive Operations Manager — Dark Executive Theme
3
+ ===================================================== */
4
+
5
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
6
+
7
+ :root {
8
+ --bg: #0b1120;
9
+ --bg2: #0f172a;
10
+ --panel-bg: #1a2540;
11
+ --panel-border: #2d3f5e;
12
+ --text-primary: #f1f5f9;
13
+ --text-secondary: #94a3b8;
14
+ --text-muted: #4b6180;
15
+ --accent: #3b82f6;
16
+ --success: #22c55e;
17
+ --warning: #f59e0b;
18
+ --danger: #ef4444;
19
+ --purple: #8b5cf6;
20
+ --orange: #f97316;
21
+ }
22
+
23
+ html, body, #root {
24
+ height: 100%;
25
+ background: var(--bg);
26
+ color: var(--text-primary);
27
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
28
+ font-size: 14px;
29
+ line-height: 1.5;
30
+ }
31
+
32
+ /* ─── Scrollbars ──────────────────────────────────── */
33
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
34
+ ::-webkit-scrollbar-track { background: transparent; }
35
+ ::-webkit-scrollbar-thumb { background: rgba(148,163,184,0.18); border-radius: 3px; }
36
+ ::-webkit-scrollbar-thumb:hover { background: rgba(148,163,184,0.32); }
37
+
38
+ /* ─── Loading ─────────────────────────────────────── */
39
+ .loading-screen {
40
+ display: flex; flex-direction: column; align-items: center;
41
+ justify-content: center; height: 100vh; gap: 18px;
42
+ color: var(--text-secondary);
43
+ background: radial-gradient(ellipse at center, #0f1e38 0%, var(--bg) 70%);
44
+ }
45
+ .loading-spinner {
46
+ width: 44px; height: 44px;
47
+ border: 3px solid rgba(59,130,246,0.15);
48
+ border-top-color: var(--accent);
49
+ border-radius: 50%;
50
+ animation: spin 0.75s linear infinite;
51
+ }
52
+ @keyframes spin { to { transform: rotate(360deg); } }
53
+ .loading-text { font-size: 15px; letter-spacing: 0.06em; color: var(--text-secondary); }
54
+
55
+ /* ─── App layout ──────────────────────────────────── */
56
+ .app {
57
+ display: flex; flex-direction: column;
58
+ min-height: 100vh; background: var(--bg);
59
+ }
60
+ .app--autorunning { --panel-border: #2d4a7a; }
61
+ .app--autorunning .panel { box-shadow: 0 0 0 1px rgba(59,130,246,0.12); }
62
+
63
+ .main-grid {
64
+ display: grid;
65
+ grid-template-columns: 1fr 290px 270px;
66
+ gap: 8px;
67
+ padding: 8px;
68
+ flex: 1;
69
+ min-height: 0;
70
+ }
71
+ .left-col, .center-col, .right-col {
72
+ display: flex; flex-direction: column; gap: 8px; min-width: 0;
73
+ }
74
+ .bottom-row {
75
+ padding: 0 8px 8px;
76
+ height: 230px;
77
+ flex-shrink: 0;
78
+ }
79
+
80
+ /* ─── Panel base ──────────────────────────────────── */
81
+ .panel {
82
+ background: var(--panel-bg);
83
+ border: 1px solid var(--panel-border);
84
+ border-radius: 10px;
85
+ display: flex; flex-direction: column;
86
+ overflow: hidden;
87
+ transition: border-color 0.3s, box-shadow 0.3s;
88
+ }
89
+ .panel-header {
90
+ display: flex; align-items: center; justify-content: space-between;
91
+ padding: 10px 14px;
92
+ border-bottom: 1px solid var(--panel-border);
93
+ flex-shrink: 0;
94
+ background: rgba(255,255,255,0.018);
95
+ }
96
+ .panel-title {
97
+ font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
98
+ text-transform: uppercase; color: var(--text-secondary);
99
+ }
100
+ .panel-badge {
101
+ font-size: 11px; background: rgba(148,163,184,0.1);
102
+ color: var(--text-secondary); padding: 2px 9px;
103
+ border-radius: 100px; border: 1px solid rgba(148,163,184,0.12);
104
+ }
105
+
106
+ /* ─── Simulation controls (top bar) ──────────────── */
107
+ .sim-controls {
108
+ display: flex; align-items: center; justify-content: space-between;
109
+ padding: 10px 18px;
110
+ background: linear-gradient(180deg, #101d36 0%, #0d1829 100%);
111
+ border-bottom: 1px solid #1e3352;
112
+ gap: 20px; flex-shrink: 0;
113
+ box-shadow: 0 2px 12px rgba(0,0,0,0.4);
114
+ }
115
+ .sim-controls-left { display: flex; align-items: center; gap: 14px; }
116
+ .sim-controls-center { display: flex; align-items: center; gap: 28px; }
117
+ .sim-controls-right { display: flex; align-items: center; gap: 16px; }
118
+
119
+ .brand { display: flex; align-items: center; gap: 9px; }
120
+ .brand-icon { font-size: 20px; filter: drop-shadow(0 0 6px rgba(59,130,246,0.7)); }
121
+ .brand-text { display: flex; flex-direction: column; }
122
+ .brand-name { font-size: 15px; font-weight: 800; color: var(--text-primary); letter-spacing: -0.01em; line-height: 1.1; }
123
+ .brand-sub { font-size: 10px; color: var(--text-muted); letter-spacing: 0.05em; text-transform: uppercase; }
124
+
125
+ .divider-v { width: 1px; height: 28px; background: var(--panel-border); }
126
+
127
+ .task-select {
128
+ background: rgba(15,23,42,0.7);
129
+ border: 1px solid var(--panel-border);
130
+ color: var(--text-primary);
131
+ border-radius: 7px; padding: 6px 11px;
132
+ font-size: 13px; cursor: pointer; outline: none;
133
+ transition: border-color 0.2s;
134
+ }
135
+ .task-select:hover:not(:disabled) { border-color: var(--accent); }
136
+ .task-select option { background: #1a2540; }
137
+
138
+ .btn {
139
+ border: none; border-radius: 7px; padding: 7px 14px;
140
+ font-size: 12px; font-weight: 600; cursor: pointer;
141
+ transition: all 0.15s; font-family: inherit;
142
+ }
143
+ .btn:disabled { opacity: 0.38; cursor: not-allowed; }
144
+ .btn-reset {
145
+ background: rgba(59,130,246,0.1); color: #93c5fd;
146
+ border: 1px solid rgba(59,130,246,0.25);
147
+ }
148
+ .btn-reset:hover:not(:disabled) { background: rgba(59,130,246,0.2); border-color: rgba(59,130,246,0.5); }
149
+
150
+ /* stat blocks */
151
+ .stat-label {
152
+ font-size: 9px; letter-spacing: 0.12em;
153
+ color: var(--text-muted); text-transform: uppercase;
154
+ display: block; margin-bottom: 3px;
155
+ }
156
+
157
+ .time-display { display: flex; flex-direction: column; align-items: center; }
158
+ .time-value {
159
+ font-size: 22px; font-weight: 800; color: var(--text-primary);
160
+ font-variant-numeric: tabular-nums; letter-spacing: -0.02em; line-height: 1;
161
+ }
162
+
163
+ .step-display { display: flex; flex-direction: column; align-items: center; gap: 4px; }
164
+ .step-value { font-size: 14px; font-weight: 700; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
165
+ .step-sep { color: var(--text-muted); }
166
+ .step-bar { width: 90px; height: 3px; background: rgba(255,255,255,0.07); border-radius: 2px; overflow: hidden; }
167
+ .step-bar-fill { height: 100%; border-radius: 2px; transition: width 0.5s ease; }
168
+
169
+ .reward-display { display: flex; flex-direction: column; align-items: center; }
170
+ .reward-value {
171
+ font-size: 18px; font-weight: 800;
172
+ font-variant-numeric: tabular-nums; line-height: 1;
173
+ transition: color 0.4s;
174
+ }
175
+
176
+ /* speed group */
177
+ .speed-group { display: flex; flex-direction: column; align-items: center; }
178
+ .speed-btns { display: flex; gap: 3px; }
179
+ .speed-btn {
180
+ padding: 4px 10px; font-size: 11px; font-weight: 600;
181
+ background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
182
+ color: var(--text-muted); border-radius: 5px; cursor: pointer;
183
+ transition: all 0.15s; font-family: inherit;
184
+ }
185
+ .speed-btn:hover:not(:disabled) { background: rgba(59,130,246,0.15); color: #93c5fd; border-color: rgba(59,130,246,0.3); }
186
+ .speed-btn.active { background: rgba(59,130,246,0.2); color: var(--accent); border-color: rgba(59,130,246,0.5); }
187
+ .speed-btn:disabled { opacity: 0.35; cursor: not-allowed; }
188
+
189
+ /* auto-run button */
190
+ .btn-autorun {
191
+ display: flex; align-items: center; gap: 8px;
192
+ padding: 9px 20px; border-radius: 8px; cursor: pointer;
193
+ font-size: 13px; font-weight: 700; font-family: inherit;
194
+ border: none; transition: all 0.2s;
195
+ background: linear-gradient(135deg, #1d4ed8, #2563eb);
196
+ color: #fff;
197
+ box-shadow: 0 2px 12px rgba(37,99,235,0.4);
198
+ }
199
+ .btn-autorun:hover:not(:disabled) {
200
+ background: linear-gradient(135deg, #2563eb, #3b82f6);
201
+ box-shadow: 0 4px 20px rgba(59,130,246,0.5);
202
+ transform: translateY(-1px);
203
+ }
204
+ .btn-autorun:active:not(:disabled) { transform: translateY(0); }
205
+ .btn-autorun.running {
206
+ background: linear-gradient(135deg, #991b1b, #dc2626);
207
+ box-shadow: 0 2px 12px rgba(220,38,38,0.4);
208
+ animation: pulse-run 1.8s ease-in-out infinite;
209
+ }
210
+ .btn-autorun.running:hover {
211
+ background: linear-gradient(135deg, #dc2626, #ef4444);
212
+ box-shadow: 0 4px 20px rgba(239,68,68,0.5);
213
+ }
214
+ .btn-autorun:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
215
+ @keyframes pulse-run {
216
+ 0%, 100% { box-shadow: 0 2px 12px rgba(220,38,38,0.4); }
217
+ 50% { box-shadow: 0 2px 24px rgba(220,38,38,0.7); }
218
+ }
219
+ .autorun-icon { font-size: 12px; }
220
+
221
+ /* status badge */
222
+ .status-badge {
223
+ font-size: 11px; font-weight: 700; padding: 5px 11px;
224
+ border-radius: 100px; letter-spacing: 0.05em;
225
+ white-space: nowrap;
226
+ }
227
+ .status-badge.active { background: rgba(34,197,94,0.1); color: #86efac; border: 1px solid rgba(34,197,94,0.2); }
228
+ .status-badge.done { background: rgba(59,130,246,0.1); color: #93c5fd; border: 1px solid rgba(59,130,246,0.2); }
229
+ .status-badge.auto {
230
+ background: rgba(59,130,246,0.15); color: var(--accent);
231
+ border: 1px solid rgba(59,130,246,0.35);
232
+ animation: badge-pulse 1.5s ease-in-out infinite;
233
+ }
234
+ @keyframes badge-pulse {
235
+ 0%, 100% { opacity: 1; }
236
+ 50% { opacity: 0.65; }
237
+ }
238
+
239
+ /* ─── Inbox panel ─────────────────────────────────── */
240
+ .inbox-panel { flex: 1; min-height: 0; }
241
+ .email-list {
242
+ flex: 1; overflow-y: auto; padding: 8px;
243
+ display: flex; flex-direction: column; gap: 6px;
244
+ }
245
+ .email-card {
246
+ border-radius: 8px; padding: 11px 13px;
247
+ transition: all 0.2s; cursor: pointer;
248
+ animation: fadeSlideIn 0.3s ease both;
249
+ }
250
+ @keyframes fadeSlideIn {
251
+ from { opacity: 0; transform: translateY(-5px); }
252
+ to { opacity: 1; transform: translateY(0); }
253
+ }
254
+ .email-card:hover:not(.handled):not(.no-pointer) { filter: brightness(1.12); transform: translateX(2px); }
255
+ .email-card.selected { outline: 2px solid rgba(59,130,246,0.6); outline-offset: -1px; }
256
+ .email-card.handled { opacity: 0.45; cursor: default; }
257
+ .email-card.no-pointer { cursor: default; }
258
+
259
+ .email-card.ai-focus {
260
+ outline: 2px solid rgba(59,130,246,0.8);
261
+ box-shadow: 0 0 18px rgba(59,130,246,0.25);
262
+ animation: ai-selecting 0.5s ease;
263
+ }
264
+ @keyframes ai-selecting {
265
+ 0% { box-shadow: 0 0 0 rgba(59,130,246,0); }
266
+ 50% { box-shadow: 0 0 24px rgba(59,130,246,0.45); }
267
+ 100% { box-shadow: 0 0 18px rgba(59,130,246,0.25); }
268
+ }
269
+
270
+ .email-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 5px; }
271
+ .email-meta { display: flex; align-items: center; gap: 6px; }
272
+
273
+ .priority-badge {
274
+ font-size: 10px; font-weight: 800; padding: 2px 8px;
275
+ border-radius: 100px; letter-spacing: 0.05em;
276
+ }
277
+ .action-taken-badge {
278
+ font-size: 10px; color: var(--text-muted);
279
+ background: rgba(148,163,184,0.08); padding: 2px 7px; border-radius: 100px;
280
+ }
281
+ .handled-check { color: var(--success); font-size: 15px; }
282
+
283
+ .urgency-ring-wrapper {
284
+ position: relative; width: 38px; height: 38px;
285
+ display: flex; align-items: center; justify-content: center; flex-shrink: 0;
286
+ }
287
+ .urgency-pct {
288
+ position: absolute; font-size: 9px; font-weight: 800;
289
+ font-variant-numeric: tabular-nums;
290
+ }
291
+ .email-sender { font-size: 12px; font-weight: 700; margin-bottom: 2px; }
292
+ .email-subject { font-size: 13px; line-height: 1.4; }
293
+ .email-body {
294
+ margin-top: 9px; font-size: 12px; color: var(--text-secondary);
295
+ line-height: 1.6; border-top: 1px solid rgba(148,163,184,0.12);
296
+ padding-top: 9px; animation: fadeSlideIn 0.2s ease;
297
+ }
298
+
299
+ /* ─── Action panel ────────────────────────────────── */
300
+ .action-panel { flex-shrink: 0; }
301
+ .selected-email-preview {
302
+ padding: 11px 14px; border-bottom: 1px solid var(--panel-border);
303
+ }
304
+ .selected-label { font-size: 9px; letter-spacing: 0.1em; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px; }
305
+ .selected-subject { font-size: 13px; font-weight: 700; color: var(--text-primary); line-height: 1.3; margin-bottom: 2px; }
306
+ .selected-sender { font-size: 11px; color: var(--text-secondary); }
307
+
308
+ .no-selection {
309
+ display: flex; flex-direction: column; align-items: center;
310
+ justify-content: center; padding: 18px 14px; gap: 7px;
311
+ color: var(--text-muted); text-align: center; font-size: 13px;
312
+ line-height: 1.6; border-bottom: 1px solid var(--panel-border);
313
+ }
314
+ .no-selection-icon { font-size: 22px; }
315
+
316
+ .action-buttons { display: flex; flex-direction: column; gap: 6px; padding: 8px; }
317
+ .action-btn {
318
+ display: flex; align-items: center; gap: 11px;
319
+ padding: 11px 13px; border-radius: 7px; border: 1px solid;
320
+ transition: all 0.15s; font-family: inherit; cursor: pointer;
321
+ }
322
+ .action-btn-icon { font-size: 17px; flex-shrink: 0; width: 22px; text-align: center; }
323
+ .action-btn-text { display: flex; flex-direction: column; align-items: flex-start; }
324
+ .action-btn-label { font-size: 13px; font-weight: 700; }
325
+ .action-btn-desc { font-size: 10px; opacity: 0.65; }
326
+ .action-btn.active { animation: btn-pulse 0.28s ease; }
327
+ @keyframes btn-pulse {
328
+ 0% { transform: scale(1); }
329
+ 40% { transform: scale(0.94); }
330
+ 100% { transform: scale(1); }
331
+ }
332
+ .done-overlay {
333
+ display: flex; flex-direction: column; align-items: center;
334
+ padding: 14px; gap: 5px;
335
+ background: rgba(34,197,94,0.07); border-top: 1px solid rgba(34,197,94,0.2);
336
+ }
337
+ .done-icon { font-size: 22px; color: var(--success); }
338
+ .done-text { font-size: 12px; color: var(--success); font-weight: 700; }
339
+
340
+ /* ─── Calendar panel ──────────────────────────────── */
341
+ .calendar-panel { flex-shrink: 0; }
342
+ .calendar-list { padding: 8px; display: flex; flex-direction: column; gap: 4px; }
343
+ .calendar-event {
344
+ display: flex; align-items: center; gap: 8px;
345
+ padding: 7px 10px; border-radius: 6px; font-size: 12px;
346
+ }
347
+ .calendar-event.locked { background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2); }
348
+ .calendar-event.scheduled { background: rgba(139,92,246,0.08); border: 1px solid rgba(139,92,246,0.2); }
349
+ .cal-time { color: var(--text-muted); font-size: 10px; min-width: 44px; }
350
+ .cal-title { flex: 1; color: var(--text-secondary); font-size: 11px; }
351
+ .cal-locked { font-size: 10px; }
352
+
353
+ /* ─── Score panel ─────────────────────────────────── */
354
+ .score-panel { flex: 1; overflow-y: auto; min-height: 0; }
355
+ .score-section {
356
+ display: flex; align-items: center; gap: 14px;
357
+ padding: 13px 14px; border-bottom: 1px solid var(--panel-border);
358
+ }
359
+ .score-gauge {
360
+ position: relative; flex-shrink: 0;
361
+ display: flex; align-items: center; justify-content: center;
362
+ }
363
+ .score-gauge-text {
364
+ position: absolute;
365
+ display: flex; flex-direction: column; align-items: center;
366
+ }
367
+ .score-value { font-size: 22px; font-weight: 900; font-variant-numeric: tabular-nums; }
368
+ .score-pct-label { font-size: 9px; color: var(--text-muted); }
369
+ .score-meta { display: flex; flex-direction: column; gap: 5px; }
370
+ .score-label-main { font-size: 9px; letter-spacing: 0.1em; color: var(--text-muted); text-transform: uppercase; }
371
+ .score-completion { font-size: 14px; font-weight: 700; color: var(--text-primary); }
372
+
373
+ .completion-bar-wrapper { display: flex; align-items: center; gap: 7px; }
374
+ .completion-bar { width: 84px; height: 5px; background: rgba(255,255,255,0.07); border-radius: 3px; overflow: hidden; }
375
+ .completion-bar-fill { height: 100%; border-radius: 3px; transition: width 0.6s ease; }
376
+ .completion-pct { font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
377
+
378
+ .goals-list { padding: 8px; display: flex; flex-direction: column; gap: 4px; }
379
+ .goals-header {
380
+ font-size: 9px; letter-spacing: 0.1em; color: var(--text-muted);
381
+ text-transform: uppercase; margin-bottom: 3px; padding: 0 6px;
382
+ }
383
+ .goal-item {
384
+ display: flex; align-items: flex-start; gap: 9px;
385
+ padding: 8px 9px; border-radius: 7px;
386
+ background: rgba(255,255,255,0.025); transition: all 0.35s;
387
+ }
388
+ .goal-item.goal-done { background: rgba(34,197,94,0.06); }
389
+ .goal-checkbox {
390
+ width: 17px; height: 17px; border-radius: 4px; border: 2px solid;
391
+ flex-shrink: 0; margin-top: 1px;
392
+ display: flex; align-items: center; justify-content: center; transition: all 0.3s;
393
+ }
394
+ .goal-check { font-size: 10px; color: #fff; font-weight: 900; }
395
+ .goal-text { display: flex; flex-direction: column; gap: 2px; }
396
+ .goal-desc { font-size: 11px; line-height: 1.45; transition: color 0.3s; }
397
+ .goal-priority-tag { font-size: 10px; font-weight: 700; }
398
+
399
+ .final-score-banner {
400
+ margin: 10px; padding: 14px; border-radius: 8px; border: 1px solid;
401
+ display: flex; flex-direction: column; align-items: center; gap: 5px;
402
+ animation: fadeSlideIn 0.5s ease;
403
+ }
404
+ .final-score-title { font-size: 14px; font-weight: 700; color: var(--text-primary); }
405
+ .final-score-num { font-size: 28px; font-weight: 900; font-variant-numeric: tabular-nums; }
406
+
407
+ /* ─── Timeline panel ──────────────────────────────── */
408
+ .timeline-panel { height: 100%; }
409
+
410
+ .timeline-scroll {
411
+ flex: 1; overflow-x: auto; overflow-y: hidden;
412
+ padding: 10px 14px 10px;
413
+ display: flex; align-items: flex-start;
414
+ }
415
+
416
+ .timeline-empty {
417
+ display: flex; align-items: center; gap: 10px;
418
+ color: var(--text-muted); font-size: 13px;
419
+ width: 100%; justify-content: center; padding: 16px 0;
420
+ }
421
+ .timeline-empty-icon { font-size: 20px; }
422
+
423
+ .timeline-track {
424
+ display: flex; align-items: flex-start; gap: 0;
425
+ position: relative; padding-bottom: 4px;
426
+ }
427
+
428
+ .timeline-entry {
429
+ display: flex; align-items: flex-start; flex-shrink: 0;
430
+ animation: fadeSlideIn 0.3s ease both;
431
+ }
432
+ .timeline-entry.latest .timeline-card {
433
+ border-color: rgba(59,130,246,0.5);
434
+ box-shadow: 0 0 12px rgba(59,130,246,0.15);
435
+ }
436
+
437
+ .timeline-line {
438
+ width: 20px; height: 2px;
439
+ align-self: center; flex-shrink: 0; margin-top: -38px;
440
+ border-radius: 1px;
441
+ }
442
+
443
+ .timeline-step {
444
+ width: 24px; height: 24px; border-radius: 50%;
445
+ display: flex; align-items: center; justify-content: center;
446
+ font-size: 11px; font-weight: 800; color: #fff;
447
+ flex-shrink: 0; align-self: flex-start; margin-top: 8px;
448
+ box-shadow: 0 2px 8px rgba(0,0,0,0.4);
449
+ z-index: 1;
450
+ }
451
+
452
+ .timeline-card {
453
+ background: rgba(255,255,255,0.04);
454
+ border: 1px solid var(--panel-border);
455
+ border-radius: 7px; padding: 8px 11px;
456
+ min-width: 170px; max-width: 210px;
457
+ margin-left: 6px; flex-shrink: 0;
458
+ }
459
+ .timeline-card-top {
460
+ display: flex; align-items: center; gap: 6px;
461
+ margin-bottom: 5px; flex-wrap: wrap;
462
+ }
463
+ .tl-time { font-size: 10px; color: var(--text-muted); }
464
+ .tl-action { font-size: 12px; font-weight: 700; }
465
+ .tl-reward { font-size: 12px; font-weight: 800; margin-left: auto; font-variant-numeric: tabular-nums; }
466
+ .timeline-card-body {
467
+ display: flex; align-items: flex-start; gap: 6px;
468
+ }
469
+ .tl-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
470
+ .tl-subject { font-size: 11px; color: var(--text-secondary); line-height: 1.4; }
471
+
472
+ /* ─── Reward popups ───────────────────────────────── */
473
+ .reward-popups {
474
+ position: fixed; top: 82px; right: 20px;
475
+ display: flex; flex-direction: column; gap: 6px;
476
+ z-index: 1000; pointer-events: none;
477
+ }
478
+ .reward-popup {
479
+ display: flex; flex-direction: column; align-items: flex-end;
480
+ background: rgba(26,37,64,0.96);
481
+ border: 1px solid var(--panel-border);
482
+ border-radius: 9px; padding: 9px 15px;
483
+ animation: floatUp 2.2s ease forwards;
484
+ box-shadow: 0 6px 24px rgba(0,0,0,0.5);
485
+ backdrop-filter: blur(8px);
486
+ }
487
+ @keyframes floatUp {
488
+ 0% { opacity: 0; transform: translateY(8px) scale(0.92); }
489
+ 18% { opacity: 1; transform: translateY(0) scale(1); }
490
+ 72% { opacity: 1; transform: translateY(-12px); }
491
+ 100% { opacity: 0; transform: translateY(-22px); }
492
+ }
493
+ .popup-reward { font-size: 20px; font-weight: 900; font-variant-numeric: tabular-nums; line-height: 1; }
494
+ .reward-popup.positive .popup-reward { color: var(--success); }
495
+ .reward-popup.negative .popup-reward { color: var(--danger); }
496
+ .popup-label { font-size: 10px; color: var(--text-muted); max-width: 160px; text-align: right; margin-top: 2px; }
497
+
498
+ /* ─── Error banner ────────────────────────────────── */
499
+ .error-banner {
500
+ background: rgba(239,68,68,0.12); border-bottom: 1px solid rgba(239,68,68,0.25);
501
+ color: #fca5a5; padding: 9px 18px;
502
+ display: flex; align-items: center; justify-content: space-between; font-size: 13px;
503
+ }
504
+ .error-banner button { background: none; border: none; color: #fca5a5; cursor: pointer; font-size: 15px; padding: 0 4px; }
frontend/src/App.js ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
2
+ import './App.css';
3
+ import SimulationControls from './components/SimulationControls';
4
+ import InboxPanel from './components/InboxPanel';
5
+ import ActionPanel from './components/ActionPanel';
6
+ import TimelinePanel from './components/TimelinePanel';
7
+ import ScorePanel from './components/ScorePanel';
8
+ import { resetEnv, stepEnv, getGrade } from './api';
9
+
10
+ const STEP_DELAY = 1400; // ~0.5× — realistic LLM response cadence
11
+
12
+ /* Greedy agent: match pending goals first, then highest priority*urgency */
13
+ function pickBestAction(obs) {
14
+ if (!obs) return null;
15
+ const unhandled = (obs.inbox || []).filter(e => !e.handled);
16
+ if (!unhandled.length) return null;
17
+
18
+ const pendingGoals = [...(obs.pending_goals || [])].sort((a, b) => b.priority - a.priority);
19
+ for (const goal of pendingGoals) {
20
+ const email = unhandled.find(e => e.id === goal.target_email_id);
21
+ if (email) return { type: goal.required_action, email_id: email.id };
22
+ }
23
+
24
+ const best = [...unhandled].sort(
25
+ (a, b) => b.priority * b.urgency - a.priority * a.urgency
26
+ )[0];
27
+ return { type: best.priority <= 2 ? 'delegate' : 'reply', email_id: best.id };
28
+ }
29
+
30
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
31
+
32
+ export default function App() {
33
+ const [obs, setObs] = useState(null);
34
+ const [history, setHistory] = useState([]);
35
+ const [selectedEmail, setSelectedEmail] = useState(null);
36
+ const [taskId, setTaskId] = useState('easy');
37
+ const [score, setScore] = useState(0);
38
+ const [loading, setLoading] = useState(false);
39
+ const [error, setError] = useState(null);
40
+ const [isAutoRunning, setIsAutoRunning] = useState(false);
41
+ const [rewardPopups, setRewardPopups] = useState([]);
42
+ const [firstLoad, setFirstLoad] = useState(true); // eslint-disable-line no-unused-vars
43
+
44
+ const obsRef = useRef(obs);
45
+ const autoRunRef = useRef(false);
46
+ const popupIdRef = useRef(0);
47
+
48
+ useEffect(() => { obsRef.current = obs; }, [obs]);
49
+
50
+ /* ── helpers ─────────────────────────────────────── */
51
+ const showRewardPopup = (reward, subject) => {
52
+ const id = ++popupIdRef.current;
53
+ setRewardPopups(prev => [...prev.slice(-4), { id, reward, label: subject?.slice(0, 32) }]);
54
+ setTimeout(() => setRewardPopups(prev => prev.filter(p => p.id !== id)), 2200);
55
+ };
56
+
57
+ /* ── reset ───────────────────────────────────────── */
58
+ const handleReset = useCallback(async (task = taskId) => {
59
+ autoRunRef.current = false;
60
+ setIsAutoRunning(false);
61
+ setLoading(true);
62
+ setError(null);
63
+ try {
64
+ const fresh = await resetEnv(task);
65
+ setObs(fresh);
66
+ setHistory([]);
67
+ setSelectedEmail(null);
68
+ setScore(0);
69
+ setRewardPopups([]);
70
+ setFirstLoad(false);
71
+ } catch {
72
+ setError('Cannot connect to server.');
73
+ } finally {
74
+ setLoading(false);
75
+ }
76
+ }, [taskId]);
77
+
78
+ const handleTaskChange = useCallback((t) => {
79
+ setTaskId(t);
80
+ handleReset(t);
81
+ }, [handleReset]);
82
+
83
+ useEffect(() => { handleReset('easy'); }, []); // eslint-disable-line
84
+
85
+ /* ── single step (returns new obs or null) ────────── */
86
+ const executeStep = useCallback(async (action, currentObs) => {
87
+ try {
88
+ const result = await stepEnv(action);
89
+ const newObs = result.observation || result;
90
+ if (!('done' in newObs)) newObs.done = result.done ?? false;
91
+
92
+ const entry = {
93
+ step: currentObs.step ?? 0,
94
+ time: currentObs.time,
95
+ action: action.type,
96
+ email_id: action.email_id,
97
+ email_subject: result.info?.email_subject ?? action.email_id,
98
+ email_priority: (currentObs.inbox || []).find(e => e.id === action.email_id)?.priority,
99
+ reward: result.reward ?? 0,
100
+ };
101
+
102
+ setObs(newObs);
103
+ setHistory(h => [...h, entry]);
104
+ showRewardPopup(result.reward, entry.email_subject);
105
+
106
+ if (newObs.done) {
107
+ const g = await getGrade().catch(() => null);
108
+ if (g) setScore(g.score);
109
+ }
110
+
111
+ return newObs;
112
+ } catch (e) {
113
+ setError(e?.detail || 'Step failed.');
114
+ return null;
115
+ }
116
+ }, []);
117
+
118
+ /* ── manual action ────────────────────────────────── */
119
+ const handleAction = useCallback(async (action) => {
120
+ if (!obs || obs.done || loading || isAutoRunning) return;
121
+ setLoading(true);
122
+ setError(null);
123
+ await executeStep(action, obs);
124
+ setSelectedEmail(null);
125
+ setLoading(false);
126
+ }, [obs, loading, isAutoRunning, executeStep]);
127
+
128
+ /* ── auto-run ─────────────────────────────────────── */
129
+ const handleAutoRun = useCallback(async () => {
130
+ if (autoRunRef.current) {
131
+ autoRunRef.current = false;
132
+ setIsAutoRunning(false);
133
+ return;
134
+ }
135
+
136
+ autoRunRef.current = true;
137
+ setIsAutoRunning(true);
138
+ setError(null);
139
+
140
+
141
+ let current = obsRef.current;
142
+
143
+ // Auto-reset if episode already finished
144
+ if (current?.done) {
145
+ setLoading(true);
146
+ try {
147
+ const fresh = await resetEnv(taskId);
148
+ setObs(fresh);
149
+ setHistory([]);
150
+ setScore(0);
151
+ setRewardPopups([]);
152
+ setSelectedEmail(null);
153
+ obsRef.current = fresh;
154
+ current = fresh;
155
+ await sleep(600);
156
+ } finally {
157
+ setLoading(false);
158
+ }
159
+ }
160
+
161
+ while (autoRunRef.current && current && !current.done) {
162
+ const action = pickBestAction(current);
163
+ if (!action) break;
164
+
165
+ // Highlight email briefly — looks like AI "thinking"
166
+ const email = (current.inbox || []).find(e => e.id === action.email_id);
167
+ if (email) {
168
+ setSelectedEmail(email);
169
+ await sleep(STEP_DELAY * 0.4);
170
+ }
171
+ if (!autoRunRef.current) break;
172
+
173
+ current = await executeStep(action, current);
174
+ if (!current) break;
175
+
176
+ setSelectedEmail(null);
177
+ await sleep(STEP_DELAY * 0.6);
178
+ }
179
+
180
+ autoRunRef.current = false;
181
+ setIsAutoRunning(false);
182
+ setSelectedEmail(null);
183
+ }, [taskId, executeStep]);
184
+
185
+ /* ── render ───────────────────────────────────────── */
186
+ if (!obs && !error) {
187
+ return (
188
+ <div className="loading-screen">
189
+ <div className="loading-spinner" />
190
+ <div className="loading-text">Initializing NovaTech AI — CEO Simulation</div>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ const inbox = obs?.inbox || [];
196
+ const goals = obs?.goals || [];
197
+ const calendar = obs?.calendar || [];
198
+
199
+ return (
200
+ <div className={`app ${isAutoRunning ? 'app--autorunning' : ''}`}>
201
+
202
+ {/* Floating reward popups */}
203
+ <div className="reward-popups">
204
+ {rewardPopups.map(p => (
205
+ <div key={p.id} className={`reward-popup ${p.reward >= 0 ? 'positive' : 'negative'}`}>
206
+ <span className="popup-reward">{p.reward >= 0 ? '+' : ''}{p.reward.toFixed(3)}</span>
207
+ <span className="popup-label">{p.label}</span>
208
+ </div>
209
+ ))}
210
+ </div>
211
+
212
+ {error && (
213
+ <div className="error-banner">
214
+ ⚠ {error}
215
+ <button onClick={() => setError(null)}>✕</button>
216
+ </div>
217
+ )}
218
+
219
+ <SimulationControls
220
+ taskId={taskId}
221
+ currentTime={obs?.time || '9:00 AM'}
222
+ currentStep={obs?.step || 0}
223
+ maxSteps={obs?.max_steps || 6}
224
+ totalReward={obs?.total_reward || 0}
225
+ done={obs?.done || false}
226
+ taskName={obs?.task_name || ''}
227
+ onReset={() => handleReset(taskId)}
228
+ onTaskChange={handleTaskChange}
229
+ loading={loading}
230
+ isAutoRunning={isAutoRunning}
231
+ onAutoRun={handleAutoRun}
232
+ />
233
+
234
+ <div className="main-grid">
235
+ <div className="left-col">
236
+ <InboxPanel
237
+ emails={inbox}
238
+ selectedEmailId={selectedEmail?.id}
239
+ onSelect={setSelectedEmail}
240
+ isAutoRunning={isAutoRunning}
241
+ />
242
+ </div>
243
+
244
+ <div className="center-col">
245
+ <ActionPanel
246
+ selectedEmail={selectedEmail}
247
+ onAction={handleAction}
248
+ done={obs?.done || false}
249
+ loading={loading || isAutoRunning}
250
+ />
251
+
252
+ {calendar.length > 0 && (
253
+ <div className="panel calendar-panel">
254
+ <div className="panel-header">
255
+ <span className="panel-title">📅 Calendar</span>
256
+ </div>
257
+ <div className="calendar-list">
258
+ {[...calendar]
259
+ .sort((a, b) => a.time_slot - b.time_slot)
260
+ .map((ev, i) => (
261
+ <div key={i} className={`calendar-event ${ev.locked ? 'locked' : 'scheduled'}`}>
262
+ <span className="cal-time">Slot {ev.time_slot + 1}</span>
263
+ <span className="cal-title">{ev.title}</span>
264
+ {ev.locked && <span className="cal-locked">🔒</span>}
265
+ </div>
266
+ ))}
267
+ </div>
268
+ </div>
269
+ )}
270
+ </div>
271
+
272
+ <div className="right-col">
273
+ <ScorePanel
274
+ goals={goals}
275
+ score={score}
276
+ done={obs?.done || false}
277
+ totalReward={obs?.total_reward || 0}
278
+ currentStep={obs?.step || 0}
279
+ maxSteps={obs?.max_steps || 6}
280
+ />
281
+ </div>
282
+ </div>
283
+
284
+ <div className="bottom-row">
285
+ <TimelinePanel history={history} />
286
+ </div>
287
+ </div>
288
+ );
289
+ }
frontend/src/api.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const BASE = ''; // Same origin — FastAPI serves both API and frontend
2
+
3
+ export const resetEnv = (task = 'easy') =>
4
+ fetch(`${BASE}/reset?task=${task}`).then(r => r.json());
5
+
6
+ export const stepEnv = (action) =>
7
+ fetch(`${BASE}/step`, {
8
+ method: 'POST',
9
+ headers: { 'Content-Type': 'application/json' },
10
+ body: JSON.stringify(action),
11
+ }).then(r => {
12
+ if (!r.ok) return r.json().then(e => Promise.reject(e));
13
+ return r.json();
14
+ });
15
+
16
+ export const getState = () =>
17
+ fetch(`${BASE}/state`).then(r => r.json());
18
+
19
+ export const getGrade = () =>
20
+ fetch(`${BASE}/grade`).then(r => r.json());
21
+
22
+ export const getTasks = () =>
23
+ fetch(`${BASE}/tasks`).then(r => r.json());
frontend/src/components/ActionPanel.js ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+
3
+ const ACTIONS = [
4
+ {
5
+ type: 'reply',
6
+ icon: '↩',
7
+ label: 'Reply',
8
+ desc: 'Personally respond and handle',
9
+ color: '#3b82f6',
10
+ bg: 'rgba(59,130,246,0.15)',
11
+ hoverBg: 'rgba(59,130,246,0.25)',
12
+ },
13
+ {
14
+ type: 'schedule',
15
+ icon: '📅',
16
+ label: 'Schedule',
17
+ desc: 'Book a meeting slot',
18
+ color: '#8b5cf6',
19
+ bg: 'rgba(139,92,246,0.15)',
20
+ hoverBg: 'rgba(139,92,246,0.25)',
21
+ },
22
+ {
23
+ type: 'delegate',
24
+ icon: '→',
25
+ label: 'Delegate',
26
+ desc: 'Assign to team member',
27
+ color: '#f97316',
28
+ bg: 'rgba(249,115,22,0.15)',
29
+ hoverBg: 'rgba(249,115,22,0.25)',
30
+ },
31
+ {
32
+ type: 'ignore',
33
+ icon: '✕',
34
+ label: 'Ignore',
35
+ desc: 'Skip this item',
36
+ color: '#64748b',
37
+ bg: 'rgba(100,116,139,0.15)',
38
+ hoverBg: 'rgba(100,116,139,0.25)',
39
+ },
40
+ ];
41
+
42
+ export default function ActionPanel({ selectedEmail, onAction, done, loading }) {
43
+ const [hoveredAction, setHoveredAction] = useState(null);
44
+ const [lastAction, setLastAction] = useState(null);
45
+
46
+ const handleAction = (actionType) => {
47
+ if (!selectedEmail || done || loading) return;
48
+ setLastAction(actionType);
49
+ onAction({ type: actionType, email_id: selectedEmail.id });
50
+ setTimeout(() => setLastAction(null), 600);
51
+ };
52
+
53
+ const isDisabled = !selectedEmail || done || loading;
54
+
55
+ return (
56
+ <div className="panel action-panel">
57
+ <div className="panel-header">
58
+ <span className="panel-title">⚡ Actions</span>
59
+ </div>
60
+
61
+ {selectedEmail ? (
62
+ <div className="selected-email-preview">
63
+ <div className="selected-label">SELECTED</div>
64
+ <div className="selected-subject">{selectedEmail.subject}</div>
65
+ <div className="selected-sender">{selectedEmail.sender.split('<')[0].trim()}</div>
66
+ </div>
67
+ ) : (
68
+ <div className="no-selection">
69
+ <span className="no-selection-icon">👆</span>
70
+ <span>Select an email<br />from the inbox</span>
71
+ </div>
72
+ )}
73
+
74
+ <div className="action-buttons">
75
+ {ACTIONS.map(action => {
76
+ const isActive = lastAction === action.type;
77
+ const isHovered = hoveredAction === action.type;
78
+ return (
79
+ <button
80
+ key={action.type}
81
+ className={`action-btn ${isActive ? 'active' : ''}`}
82
+ style={{
83
+ borderColor: isDisabled ? 'rgba(148,163,184,0.15)' : action.color,
84
+ background: isDisabled
85
+ ? 'rgba(148,163,184,0.05)'
86
+ : isActive
87
+ ? action.color
88
+ : isHovered
89
+ ? action.hoverBg
90
+ : action.bg,
91
+ color: isDisabled ? '#475569' : isActive ? '#fff' : action.color,
92
+ transform: isActive ? 'scale(0.96)' : 'scale(1)',
93
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
94
+ }}
95
+ disabled={isDisabled}
96
+ onClick={() => handleAction(action.type)}
97
+ onMouseEnter={() => setHoveredAction(action.type)}
98
+ onMouseLeave={() => setHoveredAction(null)}
99
+ >
100
+ <span className="action-btn-icon">{action.icon}</span>
101
+ <div className="action-btn-text">
102
+ <span className="action-btn-label">{action.label}</span>
103
+ <span className="action-btn-desc">{action.desc}</span>
104
+ </div>
105
+ </button>
106
+ );
107
+ })}
108
+ </div>
109
+
110
+ {done && (
111
+ <div className="done-overlay">
112
+ <span className="done-icon">✓</span>
113
+ <span className="done-text">Simulation Complete</span>
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ }
frontend/src/components/InboxPanel.js ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const PRIORITY_CONFIG = {
4
+ 5: { color: '#ef4444', bg: 'rgba(239,68,68,0.12)', label: 'CRITICAL', textColor: '#fca5a5' },
5
+ 4: { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', label: 'HIGH', textColor: '#fcd34d' },
6
+ 3: { color: '#eab308', bg: 'rgba(234,179,8,0.10)', label: 'MEDIUM', textColor: '#fde047' },
7
+ 2: { color: '#22c55e', bg: 'rgba(34,197,94,0.10)', label: 'LOW', textColor: '#86efac' },
8
+ 1: { color: '#94a3b8', bg: 'rgba(148,163,184,0.08)', label: 'MINIMAL', textColor: '#cbd5e1' },
9
+ };
10
+
11
+ const ACTION_ICONS = {
12
+ reply: '↩',
13
+ schedule: '📅',
14
+ delegate: '→',
15
+ ignore: '✕',
16
+ };
17
+
18
+ function UrgencyRing({ urgency, priority }) {
19
+ const pct = Math.max(0, Math.min(1, urgency));
20
+ const size = 36;
21
+ const stroke = 3;
22
+ const r = (size - stroke * 2) / 2;
23
+ const circ = 2 * Math.PI * r;
24
+ const dash = circ * pct;
25
+ const color = PRIORITY_CONFIG[priority]?.color || '#94a3b8';
26
+
27
+ return (
28
+ <div className="urgency-ring-wrapper" title={`Urgency: ${(urgency * 100).toFixed(0)}%`}>
29
+ <svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
30
+ <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={stroke} />
31
+ <circle
32
+ cx={size / 2} cy={size / 2} r={r}
33
+ fill="none"
34
+ stroke={color}
35
+ strokeWidth={stroke}
36
+ strokeDasharray={`${dash} ${circ}`}
37
+ strokeLinecap="round"
38
+ style={{ transition: 'stroke-dasharray 0.6s ease, stroke 0.3s' }}
39
+ opacity={0.3 + pct * 0.7}
40
+ />
41
+ </svg>
42
+ <span className="urgency-pct" style={{ color }}>{(urgency * 100).toFixed(0)}</span>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ export default function InboxPanel({ emails, selectedEmailId, onSelect, isAutoRunning }) {
48
+ const sorted = [...emails].sort((a, b) => {
49
+ if (a.handled !== b.handled) return a.handled ? 1 : -1;
50
+ if (b.priority !== a.priority) return b.priority - a.priority;
51
+ return b.urgency - a.urgency;
52
+ });
53
+
54
+ return (
55
+ <div className="panel inbox-panel">
56
+ <div className="panel-header">
57
+ <span className="panel-title">📥 Inbox</span>
58
+ <span className="panel-badge">{emails.filter(e => !e.handled).length} unhandled</span>
59
+ </div>
60
+ <div className="email-list">
61
+ {sorted.map(email => {
62
+ const cfg = PRIORITY_CONFIG[email.priority] || PRIORITY_CONFIG[1];
63
+ const isSelected = email.id === selectedEmailId;
64
+ const isHandled = email.handled;
65
+ const isAiFocus = isSelected && isAutoRunning;
66
+
67
+ return (
68
+ <div
69
+ key={email.id}
70
+ className={`email-card ${isSelected ? 'selected' : ''} ${isHandled ? 'handled' : ''} ${isAiFocus ? 'ai-focus' : ''}`}
71
+ style={{
72
+ borderLeft: `3px solid ${isHandled ? 'rgba(148,163,184,0.3)' : cfg.color}`,
73
+ background: isSelected ? `${cfg.bg}` : isHandled ? 'rgba(15,23,42,0.4)' : cfg.bg,
74
+ cursor: isHandled || isAutoRunning ? 'default' : 'pointer',
75
+ }}
76
+ onClick={() => !isHandled && !isAutoRunning && onSelect && onSelect(email)}
77
+ >
78
+ <div className="email-header">
79
+ <div className="email-meta">
80
+ <span
81
+ className="priority-badge"
82
+ style={{ background: isHandled ? 'rgba(148,163,184,0.15)' : cfg.color, color: isHandled ? '#64748b' : '#fff' }}
83
+ >
84
+ P{email.priority} {cfg.label}
85
+ </span>
86
+ {isHandled && (
87
+ <span className="action-taken-badge">
88
+ {ACTION_ICONS[email.action_taken]} {email.action_taken}
89
+ </span>
90
+ )}
91
+ </div>
92
+ {!isHandled && <UrgencyRing urgency={email.urgency} priority={email.priority} />}
93
+ {isHandled && <span className="handled-check">✓</span>}
94
+ </div>
95
+ <div className="email-sender" style={{ color: isHandled ? '#475569' : cfg.textColor }}>
96
+ {email.sender.split('<')[0].trim()}
97
+ </div>
98
+ <div className="email-subject" style={{ color: isHandled ? '#475569' : '#f1f5f9' }}>
99
+ {email.subject}
100
+ </div>
101
+ {isSelected && !isHandled && (
102
+ <div className="email-body">{email.body}</div>
103
+ )}
104
+ </div>
105
+ );
106
+ })}
107
+ </div>
108
+ </div>
109
+ );
110
+ }
frontend/src/components/ScorePanel.js ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const PRIORITY_CONFIG = {
4
+ 5: { color: '#ef4444', label: 'CRITICAL' },
5
+ 4: { color: '#f59e0b', label: 'HIGH' },
6
+ 3: { color: '#eab308', label: 'MEDIUM' },
7
+ 2: { color: '#22c55e', label: 'LOW' },
8
+ 1: { color: '#94a3b8', label: 'MINIMAL' },
9
+ };
10
+
11
+ function ScoreGauge({ score }) {
12
+ const pct = Math.max(0, Math.min(1, score));
13
+ const color = pct >= 0.75 ? '#22c55e' : pct >= 0.5 ? '#f59e0b' : pct >= 0.25 ? '#f97316' : '#ef4444';
14
+ const size = 100;
15
+ const stroke = 8;
16
+ const r = (size - stroke * 2) / 2;
17
+ const circ = 2 * Math.PI * r;
18
+ const dash = circ * pct;
19
+
20
+ return (
21
+ <div className="score-gauge">
22
+ <svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
23
+ <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="rgba(255,255,255,0.07)" strokeWidth={stroke} />
24
+ <circle
25
+ cx={size / 2} cy={size / 2} r={r}
26
+ fill="none"
27
+ stroke={color}
28
+ strokeWidth={stroke}
29
+ strokeDasharray={`${dash} ${circ}`}
30
+ strokeLinecap="round"
31
+ style={{ transition: 'stroke-dasharray 0.8s ease' }}
32
+ />
33
+ </svg>
34
+ <div className="score-gauge-text">
35
+ <span className="score-value" style={{ color }}>{(score * 100).toFixed(0)}</span>
36
+ <span className="score-pct-label">/ 100</span>
37
+ </div>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ export default function ScorePanel({ goals, score, done, totalReward, currentStep, maxSteps }) {
43
+ const completedGoals = goals.filter(g => g.completed);
44
+ const completionPct = goals.length > 0 ? (completedGoals.length / goals.length) * 100 : 0;
45
+
46
+ return (
47
+ <div className="panel score-panel">
48
+ <div className="panel-header">
49
+ <span className="panel-title">🎯 Goals & Score</span>
50
+ </div>
51
+
52
+ {/* Score gauge */}
53
+ <div className="score-section">
54
+ <ScoreGauge score={score} />
55
+ <div className="score-meta">
56
+ <div className="score-label-main">{done ? 'FINAL SCORE' : 'LIVE SCORE'}</div>
57
+ <div className="score-completion">
58
+ {completedGoals.length}/{goals.length} goals done
59
+ </div>
60
+ <div className="completion-bar-wrapper">
61
+ <div className="completion-bar">
62
+ <div
63
+ className="completion-bar-fill"
64
+ style={{
65
+ width: `${completionPct}%`,
66
+ background: completionPct >= 75 ? '#22c55e' : completionPct >= 50 ? '#f59e0b' : '#ef4444'
67
+ }}
68
+ />
69
+ </div>
70
+ <span className="completion-pct">{completionPct.toFixed(0)}%</span>
71
+ </div>
72
+ </div>
73
+ </div>
74
+
75
+ {/* Goal checklist */}
76
+ <div className="goals-list">
77
+ <div className="goals-header">OBJECTIVES</div>
78
+ {goals.map(goal => {
79
+ const cfg = PRIORITY_CONFIG[goal.priority] || PRIORITY_CONFIG[1];
80
+ return (
81
+ <div key={goal.id} className={`goal-item ${goal.completed ? 'goal-done' : ''}`}>
82
+ <div
83
+ className="goal-checkbox"
84
+ style={{
85
+ borderColor: goal.completed ? cfg.color : 'rgba(148,163,184,0.3)',
86
+ background: goal.completed ? cfg.color : 'transparent',
87
+ }}
88
+ >
89
+ {goal.completed && <span className="goal-check">✓</span>}
90
+ </div>
91
+ <div className="goal-text">
92
+ <span className="goal-desc" style={{ color: goal.completed ? '#64748b' : '#e2e8f0' }}>
93
+ {goal.description}
94
+ </span>
95
+ <span className="goal-priority-tag" style={{ color: cfg.color }}>
96
+ P{goal.priority} • {cfg.label}
97
+ </span>
98
+ </div>
99
+ </div>
100
+ );
101
+ })}
102
+ </div>
103
+
104
+ {done && (
105
+ <div className="final-score-banner" style={{
106
+ background: score >= 0.7 ? 'rgba(34,197,94,0.1)' : score >= 0.4 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)',
107
+ borderColor: score >= 0.7 ? '#22c55e' : score >= 0.4 ? '#f59e0b' : '#ef4444',
108
+ }}>
109
+ <div className="final-score-title">
110
+ {score >= 0.8 ? '🏆 Excellent' : score >= 0.6 ? '✅ Good' : score >= 0.4 ? '⚠️ Adequate' : '❌ Poor'}
111
+ </div>
112
+ <div className="final-score-num" style={{ color: score >= 0.7 ? '#22c55e' : score >= 0.4 ? '#f59e0b' : '#ef4444' }}>
113
+ {score.toFixed(4)}
114
+ </div>
115
+ </div>
116
+ )}
117
+ </div>
118
+ );
119
+ }
frontend/src/components/SimulationControls.js ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const TASK_OPTIONS = [
4
+ { value: 'easy', label: 'Easy — Monday Morning', color: '#22c55e' },
5
+ { value: 'medium', label: 'Medium — Demo Day Prep', color: '#f59e0b' },
6
+ { value: 'hard', label: 'Hard — Series B Crisis', color: '#ef4444' },
7
+ ];
8
+
9
+ export default function SimulationControls({
10
+ taskId, currentTime, currentStep, maxSteps, totalReward,
11
+ done, onReset, onTaskChange, loading,
12
+ isAutoRunning, onAutoRun,
13
+ }) {
14
+ const progressPct = maxSteps > 0 ? (currentStep / maxSteps) * 100 : 0;
15
+ const task = TASK_OPTIONS.find(t => t.value === taskId) || TASK_OPTIONS[0];
16
+
17
+ return (
18
+ <div className="sim-controls">
19
+
20
+ {/* ── Left: brand + task ── */}
21
+ <div className="sim-controls-left">
22
+ <div className="brand">
23
+ <span className="brand-icon">⚡</span>
24
+ <div className="brand-text">
25
+ <span className="brand-name">ExecOps AI</span>
26
+ <span className="brand-sub">CEO Simulation</span>
27
+ </div>
28
+ </div>
29
+
30
+ <div className="divider-v" />
31
+
32
+ <select
33
+ className="task-select"
34
+ value={taskId}
35
+ onChange={e => onTaskChange(e.target.value)}
36
+ disabled={isAutoRunning || loading}
37
+ style={{ borderColor: `${task.color}55` }}
38
+ >
39
+ {TASK_OPTIONS.map(t => (
40
+ <option key={t.value} value={t.value}>{t.label}</option>
41
+ ))}
42
+ </select>
43
+
44
+ <button
45
+ className="btn btn-reset"
46
+ onClick={onReset}
47
+ disabled={isAutoRunning || loading}
48
+ title="Reset to initial state"
49
+ >
50
+ ↺ Reset
51
+ </button>
52
+ </div>
53
+
54
+ {/* ── Center: time + steps ── */}
55
+ <div className="sim-controls-center">
56
+ <div className="time-display">
57
+ <span className="stat-label">CEO TIME</span>
58
+ <span className="time-value">{currentTime}</span>
59
+ </div>
60
+
61
+ <div className="step-display">
62
+ <span className="stat-label">STEPS</span>
63
+ <span className="step-value">
64
+ {currentStep} <span className="step-sep">/</span> {maxSteps}
65
+ </span>
66
+ <div className="step-bar">
67
+ <div
68
+ className="step-bar-fill"
69
+ style={{
70
+ width: `${progressPct}%`,
71
+ background: progressPct >= 100
72
+ ? '#22c55e'
73
+ : `linear-gradient(90deg, #3b82f6, #8b5cf6)`,
74
+ }}
75
+ />
76
+ </div>
77
+ </div>
78
+
79
+ <div className="reward-display">
80
+ <span className="stat-label">REWARD</span>
81
+ <span
82
+ className="reward-value"
83
+ style={{ color: totalReward >= 0 ? '#22c55e' : '#ef4444' }}
84
+ >
85
+ {totalReward >= 0 ? '+' : ''}{totalReward.toFixed(3)}
86
+ </span>
87
+ </div>
88
+ </div>
89
+
90
+ {/* ── Right: auto-run + status ── */}
91
+ <div className="sim-controls-right">
92
+ {/* Auto-run button */}
93
+ <button
94
+ className={`btn-autorun ${isAutoRunning ? 'running' : ''}`}
95
+ onClick={onAutoRun}
96
+ disabled={loading && !isAutoRunning}
97
+ title={isAutoRunning ? 'Stop simulation' : 'Watch AI play through this scenario'}
98
+ >
99
+ {isAutoRunning ? (
100
+ <>
101
+ <span className="autorun-icon">■</span>
102
+ <span>Stop</span>
103
+ </>
104
+ ) : (
105
+ <>
106
+ <span className="autorun-icon">▶</span>
107
+ <span>Watch AI Play</span>
108
+ </>
109
+ )}
110
+ </button>
111
+
112
+ <div className={`status-badge ${done ? 'done' : isAutoRunning ? 'auto' : 'active'}`}>
113
+ {done ? '✓ COMPLETE' : isAutoRunning ? '◈ AI RUNNING' : '● READY'}
114
+ </div>
115
+ </div>
116
+ </div>
117
+ );
118
+ }
frontend/src/components/TimelinePanel.js ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef } from 'react';
2
+
3
+ const ACTION_CONFIG = {
4
+ reply: { icon: '↩', color: '#3b82f6', label: 'Reply' },
5
+ schedule: { icon: '📅', color: '#8b5cf6', label: 'Schedule' },
6
+ delegate: { icon: '→', color: '#f97316', label: 'Delegate' },
7
+ ignore: { icon: '✕', color: '#64748b', label: 'Ignore' },
8
+ };
9
+
10
+ const PRIORITY_COLORS = { 5: '#ef4444', 4: '#f59e0b', 3: '#eab308', 2: '#22c55e', 1: '#94a3b8' };
11
+
12
+ export default function TimelinePanel({ history }) {
13
+ const endRef = useRef(null);
14
+
15
+ useEffect(() => {
16
+ endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'end' });
17
+ }, [history.length]);
18
+
19
+ return (
20
+ <div className="panel timeline-panel">
21
+ <div className="panel-header">
22
+ <span className="panel-title">📊 Action Timeline</span>
23
+ <span className="panel-badge">{history.length} actions</span>
24
+ </div>
25
+
26
+ <div className="timeline-scroll">
27
+ {history.length === 0 ? (
28
+ <div className="timeline-empty">
29
+ <span className="timeline-empty-icon">⏱</span>
30
+ <span>No actions yet — click <strong>Watch AI Play</strong> or select an email above</span>
31
+ </div>
32
+ ) : (
33
+ <div className="timeline-track">
34
+ {history.map((entry, i) => {
35
+ const cfg = ACTION_CONFIG[entry.action] || ACTION_CONFIG.ignore;
36
+ const positive = entry.reward >= 0;
37
+ const isLast = i === history.length - 1;
38
+ return (
39
+ <div key={i} className={`timeline-entry ${isLast ? 'latest' : ''}`}>
40
+ {/* Connector line */}
41
+ {i > 0 && <div className="timeline-line" style={{ background: `${cfg.color}40` }} />}
42
+
43
+ {/* Step badge */}
44
+ <div className="timeline-step" style={{ background: cfg.color }}>
45
+ {entry.step + 1}
46
+ </div>
47
+
48
+ {/* Card */}
49
+ <div className="timeline-card" style={{ borderColor: `${cfg.color}40` }}>
50
+ <div className="timeline-card-top">
51
+ <span className="tl-time">{entry.time}</span>
52
+ <span className="tl-action" style={{ color: cfg.color }}>
53
+ {cfg.icon} {cfg.label}
54
+ </span>
55
+ <span
56
+ className="tl-reward"
57
+ style={{ color: positive ? '#22c55e' : '#ef4444' }}
58
+ >
59
+ {positive ? '+' : ''}{entry.reward.toFixed(3)}
60
+ </span>
61
+ </div>
62
+ <div className="timeline-card-body">
63
+ <span
64
+ className="tl-dot"
65
+ style={{ background: PRIORITY_COLORS[entry.email_priority] || '#94a3b8' }}
66
+ />
67
+ <span className="tl-subject">{entry.email_subject}</span>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ );
72
+ })}
73
+ <div ref={endRef} />
74
+ </div>
75
+ )}
76
+ </div>
77
+ </div>
78
+ );
79
+ }
frontend/src/index.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import './App.css';
4
+ import App from './App';
5
+
6
+ const root = ReactDOM.createRoot(document.getElementById('root'));
7
+ root.render(<App />);
inference.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inference.py - AI Executive Operations Manager
3
+ Runs all 3 tasks using an LLM agent and prints structured logs.
4
+
5
+ Environment variables:
6
+ API_BASE_URL - OpenAI-compatible API base URL
7
+ MODEL_NAME - Model to use (e.g., "gpt-4o-mini", "meta-llama/Llama-3.1-8B-Instruct")
8
+ HF_TOKEN - API key / HuggingFace token
9
+
10
+ STDOUT format (strict):
11
+ [START] task=<task_id> env=exec-ops model=<model>
12
+ [STEP] step=<n> action=<type>('<id>') reward=<0.00> done=<true|false> error=<msg|null>
13
+ [END] success=<true|false> steps=<n> score=<0.00> rewards=<r1,r2,...>
14
+ """
15
+
16
+ import os
17
+ import json
18
+ import sys
19
+ import dotenv
20
+
21
+ # Force UTF-8 output on Windows
22
+ if sys.stdout.encoding != "utf-8":
23
+ sys.stdout.reconfigure(encoding="utf-8")
24
+
25
+ from openai import OpenAI
26
+ from env import ExecOpsEnv, Action
27
+ from env.grader import grade
28
+
29
+ # -------------------------------------------------------
30
+ # Configuration from environment
31
+ # -------------------------------------------------------
32
+ dotenv.load_dotenv()
33
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
34
+ MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini")
35
+ HF_TOKEN = os.getenv("HF_TOKEN")
36
+ LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
37
+
38
+ client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
39
+
40
+ SYSTEM_PROMPT = """You are an expert AI assistant helping a startup CEO (Alex Rivera at NovaTech AI) manage their inbox efficiently.
41
+
42
+ Your job: Given the current environment state, choose ONE action for ONE email.
43
+
44
+ Triage principles:
45
+ 1. Handle the highest PRIORITY * URGENCY emails first
46
+ 2. Reply to items requiring personal CEO attention (investor relations, critical incidents, legal deadlines)
47
+ 3. Delegate items that can be handled by team members (HR, finance, operations)
48
+ 4. Schedule items requiring a meeting
49
+ 5. Ignore truly trivial items only when critical items remain
50
+
51
+ You MUST respond with ONLY valid JSON, no explanation:
52
+ {"type": "reply|schedule|delegate|ignore", "email_id": "<id>"}
53
+
54
+ IMPORTANT: email_id MUST be one of the ids from the "unhandled_emails" list (e.g. "e1", "e2").
55
+ Do NOT use goal_id values (e.g. "g1", "g2") as the email_id.
56
+
57
+ Choose wisely - you have limited steps. Ignoring P4-P5 items is heavily penalized."""
58
+
59
+
60
+ def _log(msg: str) -> None:
61
+ """Print a structured log line to stdout."""
62
+ print(msg, flush=True)
63
+
64
+
65
+ def _err(msg: str) -> None:
66
+ """Print diagnostic info to stderr - never pollutes stdout."""
67
+ print(msg, file=sys.stderr, flush=True)
68
+
69
+
70
+ def _clean_error(msg: str) -> str:
71
+ """Collapse error message to a single line safe for the log format."""
72
+ return msg.replace("\n", " ").replace("\r", "").strip()[:120]
73
+
74
+
75
+ def parse_action_from_response(text: str) -> dict:
76
+ """Extract JSON action from LLM response, handling markdown code blocks."""
77
+ text = text.strip()
78
+ if "```json" in text:
79
+ text = text.split("```json")[1].split("```")[0].strip()
80
+ elif "```" in text:
81
+ parts = text.split("```")
82
+ for part in parts[1::2]:
83
+ part = part.strip()
84
+ if part.startswith("{"):
85
+ text = part
86
+ break
87
+ start = text.find("{")
88
+ end = text.rfind("}") + 1
89
+ if start >= 0 and end > start:
90
+ text = text[start:end]
91
+ return json.loads(text)
92
+
93
+
94
+ def get_fallback_action(obs: dict) -> Action:
95
+ """Fallback: pick highest priority * urgency unhandled email and reply or delegate."""
96
+ inbox = obs.get("inbox", [])
97
+ unhandled = [e for e in inbox if not e.get("handled", False)]
98
+ if not unhandled:
99
+ return None
100
+ best = max(unhandled, key=lambda e: e.get("priority", 1) * e.get("urgency", 0.5))
101
+ action_type = "delegate" if best.get("priority", 1) <= 2 else "reply"
102
+ return Action(type=action_type, email_id=best["id"])
103
+
104
+
105
+ def run_task(task_id: str) -> float:
106
+ """Run a single task with the LLM agent. Returns final grade in [0, 1]."""
107
+ _log(f"[START] task={task_id} env=exec-ops model={MODEL_NAME}")
108
+
109
+ step_count = 0
110
+ rewards: list[float] = []
111
+ pending_step_log = None # deferred so we can force done=true on the last step
112
+ final_score = 0.0
113
+ env = None
114
+
115
+ try:
116
+ env = ExecOpsEnv(task_id)
117
+ obs = env.reset()
118
+ max_steps = obs.get("max_steps", 10)
119
+
120
+ while not obs.get("done", False) and step_count < max_steps:
121
+ unhandled = [e for e in obs.get("inbox", []) if not e.get("handled", False)]
122
+ if not unhandled:
123
+ break
124
+
125
+ # Flush previous buffered step - it is not the last, so done=false
126
+ if pending_step_log is not None:
127
+ _log(pending_step_log.replace("_DONE_", "false"))
128
+ pending_step_log = None
129
+
130
+ prompt_state = {
131
+ "time": obs.get("time"),
132
+ "steps_remaining": obs.get("steps_remaining", max_steps - step_count),
133
+ "unhandled_emails": [
134
+ {
135
+ "id": e["id"],
136
+ "sender": e["sender"].split("<")[0].strip(),
137
+ "subject": e["subject"],
138
+ "priority": e["priority"],
139
+ "urgency": round(e["urgency"], 2),
140
+ }
141
+ for e in sorted(unhandled, key=lambda x: -(x["priority"] * x["urgency"]))
142
+ ],
143
+ "pending_goals": [
144
+ {"goal_id": g["id"], "description": g["description"], "priority": g["priority"]}
145
+ for g in obs.get("pending_goals", [])
146
+ ],
147
+ }
148
+
149
+ # --- Get action from LLM (with fallback) ---
150
+ step_error = None
151
+ action = None
152
+ try:
153
+ response = client.chat.completions.create(
154
+ model=MODEL_NAME,
155
+ messages=[
156
+ {"role": "system", "content": SYSTEM_PROMPT},
157
+ {"role": "user", "content": f"Current state:\n{json.dumps(prompt_state, indent=2)}\n\nChoose your action (JSON only):"}
158
+ ],
159
+ temperature=0.1,
160
+ max_tokens=80,
161
+ )
162
+ action_text = response.choices[0].message.content
163
+ action_data = parse_action_from_response(action_text)
164
+ action = Action(type=action_data["type"], email_id=action_data["email_id"])
165
+
166
+ valid_ids = {e["id"] for e in unhandled}
167
+ if action.email_id not in valid_ids:
168
+ raise ValueError(f"Invalid email_id: {action.email_id}")
169
+
170
+ except Exception as e:
171
+ step_error = _clean_error(str(e))
172
+ _err(f"[fallback] LLM error ({task_id} step {step_count+1}): {type(e).__name__}: {e}")
173
+ action = get_fallback_action(obs)
174
+ if action is None:
175
+ break
176
+
177
+ # --- Execute step; retry with fallback on failure ---
178
+ candidates = [action]
179
+ fb = get_fallback_action(obs)
180
+ if fb is not None and (action is None or fb.email_id != action.email_id):
181
+ candidates.append(fb)
182
+
183
+ executed = False
184
+ for attempt in candidates:
185
+ try:
186
+ result = env.step(attempt)
187
+ reward = result.get("reward", 0.0)
188
+ done = result.get("done", False)
189
+ obs = result.get("observation", result)
190
+ if "done" not in obs:
191
+ obs["done"] = done
192
+ step_count += 1
193
+ rewards.append(reward)
194
+ action_str = f"{attempt.type}('{attempt.email_id}')"
195
+ error_field = step_error if step_error else "null"
196
+ pending_step_log = (
197
+ f"[STEP] step={step_count} action={action_str} "
198
+ f"reward={reward:.2f} done=_DONE_ error={error_field}"
199
+ )
200
+ executed = True
201
+ break
202
+ except Exception as e:
203
+ step_error = _clean_error(str(e))
204
+ _err(f"[fallback] Step error ({task_id} step {step_count+1}): {type(e).__name__}: {e}")
205
+
206
+ if not executed:
207
+ break
208
+
209
+ except Exception as e:
210
+ _err(f"[error] Unexpected error in task '{task_id}': {e}")
211
+
212
+ finally:
213
+ # Flush the final buffered step - always done=true
214
+ if pending_step_log is not None:
215
+ _log(pending_step_log.replace("_DONE_", "true"))
216
+
217
+ try:
218
+ final_score = grade(env._state) if env is not None else 0.0
219
+ except Exception:
220
+ final_score = 0.0
221
+ success = "true" if final_score >= 0.5 else "false"
222
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards) if rewards else "0.00"
223
+ _log(f"[END] success={success} steps={step_count} score={final_score:.3f} rewards={rewards_str}")
224
+
225
+ return final_score
226
+
227
+
228
+ def main():
229
+ tasks = ["easy", "medium", "hard"]
230
+ scores = {}
231
+
232
+ for task_id in tasks:
233
+ try:
234
+ score = run_task(task_id)
235
+ scores[task_id] = score
236
+ except Exception as e:
237
+ _err(f"[error] Task '{task_id}' failed: {e}")
238
+ scores[task_id] = 0.0
239
+ _log(f"[END] success=false steps=0 score=0.000 rewards=0.00")
240
+
241
+ return sum(scores.values()) / len(scores) if scores else 0.0
242
+
243
+
244
+ if __name__ == "__main__":
245
+ avg = main()
246
+ sys.exit(0)
openenv.yaml ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: ai-executive-ops-manager
2
+ version: "1.0.0"
3
+ description: >
4
+ Simulates a startup CEO's (Alex Rivera, NovaTech AI) operational day.
5
+ An AI agent must manage a realistic executive inbox under time pressure,
6
+ making triage decisions - reply, schedule, delegate, or ignore - to
7
+ handle conflicting priorities, cascading crises, and stakeholder demands.
8
+ Tests prioritization, urgency sensitivity, and decision quality.
9
+
10
+ entrypoint: inference.py
11
+
12
+ tasks:
13
+ - id: easy
14
+ name: Monday Morning Catchup
15
+ difficulty: easy
16
+ description: >
17
+ A manageable Monday inbox with 4 emails and clear priority separation.
18
+ Optimal play: approve production deploy (P5), handle enterprise contract (P4),
19
+ delegate onboarding (P3), ignore snack order (P1).
20
+ max_steps: 6
21
+ expected_score_range: [0.7, 1.0]
22
+
23
+ - id: medium
24
+ name: Investor Demo Day Prep
25
+ difficulty: medium
26
+ description: >
27
+ Series B investor demo day. 7 emails with genuine scheduling conflicts -
28
+ CTO architecture crisis competes with investor prep. Requires smart
29
+ delegation and scheduling around locked calendar slots.
30
+ max_steps: 8
31
+ expected_score_range: [0.45, 0.85]
32
+
33
+ - id: hard
34
+ name: Series B Crisis Day
35
+ difficulty: hard
36
+ description: >
37
+ Cascading crises: investor pulling term sheet, production DB corruption,
38
+ key engineer resignation, TechCrunch security breach article going live,
39
+ GDPR deadline, and more. 10 emails, 4 P5 items, 8 steps - ruthless
40
+ triage required. Perfect score is intentionally unachievable.
41
+ max_steps: 8
42
+ expected_score_range: [0.2, 0.75]
43
+
44
+ observation_space:
45
+ description: >
46
+ Dict with time (str), step (int), max_steps (int), steps_remaining (int),
47
+ inbox (list of Email objects), calendar (list of CalendarEvent objects),
48
+ goals (list of all Goal objects with completion status),
49
+ pending_goals (list of incomplete Goal objects), total_reward (float), done (bool).
50
+ email_fields:
51
+ - id: str
52
+ - sender: str
53
+ - subject: str
54
+ - body: str
55
+ - priority: "int 1-5 (1=minimal, 5=critical)"
56
+ - urgency: "float 0.0-1.0, decays by 0.15 each step"
57
+ - handled: bool
58
+ - action_taken: "str or null"
59
+
60
+ action_space:
61
+ type: discrete
62
+ actions:
63
+ - reply: "Personally respond to the email (best for high-priority items requiring CEO attention)"
64
+ - schedule: "Book a meeting slot for this item (good for relationship/strategic items)"
65
+ - delegate: "Assign to a team member (good for operational P1-P3 items)"
66
+ - ignore: "Skip this item (only appropriate for P1 items when critical items are unhandled)"
67
+ format: '{"type": "reply|schedule|delegate|ignore", "email_id": "<id>"}'
68
+
69
+ reward:
70
+ type: continuous
71
+ per_step: true
72
+ range: "[-0.3, 0.5] per step"
73
+ components:
74
+ - goal_completion: "+0.30 * (goal_priority / 5) for completing a goal"
75
+ - urgency_bonus: "+0.15 * urgency if urgency > 0.5 and not ignoring"
76
+ - efficiency: "+0.05 for delegating P1-P2 items"
77
+ - action_match: "+0.10 if action matches goal's required action"
78
+ - ignore_penalty: "-0.25 for ignoring P4-P5 emails"
79
+ - waste_penalty: "-0.10 for replying to P1 when P4+ unhandled"
80
+ - step_cost: "-0.02 per step (encourages efficiency)"
81
+
82
+ grader:
83
+ type: deterministic
84
+ score_range: [0.0, 1.0]
85
+ formula: "0.60 * goal_completion_rate + 0.25 * email_handling_rate + 0.15 * efficiency_bonus"
pyproject.toml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "exec-ops-env"
3
+ version = "1.0.0"
4
+ description = "AI Executive Operations Manager - OpenEnv environment simulating a startup CEO inbox"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "fastapi>=0.109.0",
8
+ "uvicorn[standard]>=0.27.0",
9
+ "pydantic>=2.6.0",
10
+ "openai>=1.12.0",
11
+ "python-multipart>=0.0.9",
12
+ "python-dotenv>=1.0.0",
13
+ "openenv-core>=0.2.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ server = "server.app:main"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.109.2
2
+ uvicorn[standard]==0.27.1
3
+ pydantic==2.6.1
4
+ openai==1.12.0
5
+ python-multipart==0.0.9
6
+ python-dotenv==1.0.1
server/__init__.py ADDED
File without changes
server/app.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server/app.py - Entry point for `uv run server` and openenv multi-mode deployment.
3
+ Delegates to the root app.py FastAPI application.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+
9
+ # Ensure project root is on the Python path
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ import uvicorn
13
+
14
+
15
+ def main():
16
+ uvicorn.run("app:app", host="0.0.0.0", port=7860, workers=1)
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
uv.lock ADDED
The diff for this file is too large to render. See raw diff