Spaces:
Sleeping
Sleeping
Commit ·
1bb18a0
0
Parent(s):
first commit
Browse files- .dockerignore +13 -0
- .gitignore +11 -0
- Dockerfile +44 -0
- README.md +276 -0
- app.py +168 -0
- env/__init__.py +5 -0
- env/environment.py +214 -0
- env/grader.py +60 -0
- env/models.py +56 -0
- env/reward.py +75 -0
- env/tasks.py +495 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +22 -0
- frontend/public/index.html +18 -0
- frontend/src/App.css +504 -0
- frontend/src/App.js +289 -0
- frontend/src/api.js +23 -0
- frontend/src/components/ActionPanel.js +118 -0
- frontend/src/components/InboxPanel.js +110 -0
- frontend/src/components/ScorePanel.js +119 -0
- frontend/src/components/SimulationControls.js +118 -0
- frontend/src/components/TimelinePanel.js +79 -0
- frontend/src/index.js +7 -0
- inference.py +246 -0
- openenv.yaml +85 -0
- pyproject.toml +21 -0
- requirements.txt +6 -0
- server/__init__.py +0 -0
- server/app.py +20 -0
- uv.lock +0 -0
.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
|
|
|