Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- .env.example +12 -0
- .gitignore +41 -0
- Dockerfile +25 -0
- README.md +232 -10
- data/classification_rules.json +64 -0
- data/email_templates.json +30 -0
- data/sender_profiles.json +45 -0
- inference.py +309 -0
- openenv.yaml +82 -0
- requirements.txt +7 -0
- server/__init__.py +0 -0
- server/app.py +182 -0
- src/__init__.py +3 -0
- src/data_generator.py +624 -0
- src/environment.py +279 -0
- src/grader.py +243 -0
- src/models.py +286 -0
- src/reward_shaper.py +257 -0
- src/utils.py +91 -0
- tests/__init__.py +0 -0
- tests/test_environment.py +328 -0
- tests/test_graders.py +120 -0
.env.example
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Email Triage OpenEnv - Environment Variables
|
| 2 |
+
# Copy this file to .env and fill in your values
|
| 3 |
+
|
| 4 |
+
# LLM API Configuration
|
| 5 |
+
MODEL_NAME=google/gemma-4-31B-it
|
| 6 |
+
API_BASE_URL=https://router.huggingface.co/hf-inference/models/google/gemma-4-31B-it/v1
|
| 7 |
+
HF_TOKEN=hf_your_token_here
|
| 8 |
+
|
| 9 |
+
# Alternative: OpenAI API
|
| 10 |
+
# OPENAI_API_KEY=sk-your_key_here
|
| 11 |
+
# API_BASE_URL=https://api.openai.com/v1
|
| 12 |
+
# MODEL_NAME=gpt-4o-mini
|
.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment
|
| 2 |
+
.env
|
| 3 |
+
.env.local
|
| 4 |
+
.env.production
|
| 5 |
+
|
| 6 |
+
# Python
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*$py.class
|
| 10 |
+
*.egg-info/
|
| 11 |
+
dist/
|
| 12 |
+
build/
|
| 13 |
+
.eggs/
|
| 14 |
+
*.egg
|
| 15 |
+
|
| 16 |
+
# Virtual environments
|
| 17 |
+
venv/
|
| 18 |
+
.venv/
|
| 19 |
+
env/
|
| 20 |
+
|
| 21 |
+
# IDE
|
| 22 |
+
.vscode/
|
| 23 |
+
.idea/
|
| 24 |
+
*.swp
|
| 25 |
+
*.swo
|
| 26 |
+
*~
|
| 27 |
+
|
| 28 |
+
# OS
|
| 29 |
+
.DS_Store
|
| 30 |
+
Thumbs.db
|
| 31 |
+
|
| 32 |
+
# Testing
|
| 33 |
+
.pytest_cache/
|
| 34 |
+
.coverage
|
| 35 |
+
htmlcov/
|
| 36 |
+
|
| 37 |
+
# Docker
|
| 38 |
+
.dockerignore
|
| 39 |
+
|
| 40 |
+
# Logs
|
| 41 |
+
*.log
|
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# System dependencies
|
| 6 |
+
RUN apt-get update && \
|
| 7 |
+
apt-get install -y --no-install-recommends curl && \
|
| 8 |
+
rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Python dependencies (cached layer)
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Application code
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# HuggingFace Spaces expects port 7860
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
|
| 20 |
+
# Health check for container orchestration
|
| 21 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 22 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 23 |
+
|
| 24 |
+
# Start the FastAPI server
|
| 25 |
+
CMD ["python", "-m", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,232 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Email Triage OpenEnv Environment
|
| 2 |
+
|
| 3 |
+
A production-grade, real-world email management environment for training and evaluating AI agents. Built for the Meta/HuggingFace OpenEnv Hackathon.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
Enterprise workers spend **28% of their workday** managing email (McKinsey). With **347 billion emails sent daily**, intelligent email triage is one of the highest-impact applications for AI agents. This environment simulates realistic enterprise inbox management where agents must learn to:
|
| 8 |
+
|
| 9 |
+
- **Classify** emails into the correct folder based on content, sender, and context
|
| 10 |
+
- **Prioritize** VIP senders and urgent deadlines
|
| 11 |
+
- **Reason** about ambiguous cases where emails could belong to multiple folders
|
| 12 |
+
- **Act efficiently** -- faster correct decisions yield higher rewards
|
| 13 |
+
|
| 14 |
+
### Why Email Triage?
|
| 15 |
+
|
| 16 |
+
| Factor | Value |
|
| 17 |
+
|--------|-------|
|
| 18 |
+
| Real-world need | 347B emails/day, 28% of workday spent on email |
|
| 19 |
+
| Agent challenge | Requires reasoning about sender reputation, content, urgency |
|
| 20 |
+
| Measurable success | Clear ground truth (correct vs. incorrect folder) |
|
| 21 |
+
| Business value | Companies actively invest in email management AI |
|
| 22 |
+
| Novelty | Most RL benchmarks focus on games; this solves an actual business problem |
|
| 23 |
+
|
| 24 |
+
## Quick Start
|
| 25 |
+
|
| 26 |
+
### Local Development
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
# Clone and install
|
| 30 |
+
git clone <repo-url>
|
| 31 |
+
cd email-triage-env
|
| 32 |
+
python -m venv venv
|
| 33 |
+
source venv/bin/activate # Windows: venv\Scripts\activate
|
| 34 |
+
pip install -r requirements.txt
|
| 35 |
+
|
| 36 |
+
# Run the server
|
| 37 |
+
uvicorn server.app:app --host 0.0.0.0 --port 7860
|
| 38 |
+
|
| 39 |
+
# Run baseline inference
|
| 40 |
+
export HF_TOKEN="your_token_here"
|
| 41 |
+
export MODEL_NAME="google/gemma-4-31B-it"
|
| 42 |
+
python inference.py
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
### Docker
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
docker build -t email-triage-env .
|
| 49 |
+
docker run -p 7860:7860 email-triage-env
|
| 50 |
+
|
| 51 |
+
# With API key for inference
|
| 52 |
+
docker run -p 7860:7860 -e HF_TOKEN=$HF_TOKEN email-triage-env
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
### HuggingFace Spaces
|
| 56 |
+
|
| 57 |
+
Push to a HuggingFace Space with Docker SDK -- it auto-builds and deploys on port 7860.
|
| 58 |
+
|
| 59 |
+
## Environment Specification
|
| 60 |
+
|
| 61 |
+
### Observation Space
|
| 62 |
+
|
| 63 |
+
Each observation provides the agent with the current inbox state:
|
| 64 |
+
|
| 65 |
+
```json
|
| 66 |
+
{
|
| 67 |
+
"inbox_emails": [
|
| 68 |
+
{
|
| 69 |
+
"id": 0,
|
| 70 |
+
"subject": "Q4 Budget Review",
|
| 71 |
+
"sender": "cfo@acmecorp.com",
|
| 72 |
+
"sender_domain": "acmecorp.com",
|
| 73 |
+
"timestamp": "2024-04-12T10:30:00",
|
| 74 |
+
"body_preview": "Please review the attached Q4 budget...",
|
| 75 |
+
"priority_flag": true,
|
| 76 |
+
"has_attachment": true,
|
| 77 |
+
"is_vip_sender": true,
|
| 78 |
+
"category_hint": "finance"
|
| 79 |
+
}
|
| 80 |
+
],
|
| 81 |
+
"available_folders": ["inbox", "work", "finance", "meetings", "spam", "archive"],
|
| 82 |
+
"current_step": 0,
|
| 83 |
+
"max_steps": 50,
|
| 84 |
+
"episode_num": 1,
|
| 85 |
+
"done": false,
|
| 86 |
+
"info": {}
|
| 87 |
+
}
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
### Action Space
|
| 91 |
+
|
| 92 |
+
Agents can perform 5 types of actions:
|
| 93 |
+
|
| 94 |
+
| Action | Description | Requires target_folder |
|
| 95 |
+
|--------|-------------|----------------------|
|
| 96 |
+
| `move` | Move email to a folder | Yes |
|
| 97 |
+
| `delete` | Permanently delete email | No |
|
| 98 |
+
| `snooze` | Temporarily hide email | No |
|
| 99 |
+
| `flag` | Mark as important | No |
|
| 100 |
+
| `mark_spam` | Move to spam folder | No |
|
| 101 |
+
|
| 102 |
+
```json
|
| 103 |
+
{
|
| 104 |
+
"action_type": "move",
|
| 105 |
+
"email_id": 0,
|
| 106 |
+
"target_folder": "finance",
|
| 107 |
+
"reason": "Invoice from CFO"
|
| 108 |
+
}
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
### Reward Structure
|
| 112 |
+
|
| 113 |
+
Multi-component reward with rich learning signal:
|
| 114 |
+
|
| 115 |
+
```
|
| 116 |
+
Reward = 0.70 * correctness (right folder?)
|
| 117 |
+
+ 0.15 * efficiency (speed of decision)
|
| 118 |
+
+ 0.10 * vip_handling (VIP email correct?)
|
| 119 |
+
+ 0.05 * reasoning_quality (explanation provided?)
|
| 120 |
+
- penalties
|
| 121 |
+
|
| 122 |
+
Penalties:
|
| 123 |
+
-0.30: Infinite loop (same action repeated 3+ times)
|
| 124 |
+
-0.20: Destructive action (delete without prior flag)
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
Ambiguous emails receive **partial credit (0.5)** for any defensible folder choice.
|
| 128 |
+
|
| 129 |
+
## Tasks
|
| 130 |
+
|
| 131 |
+
Three tasks with progressive difficulty:
|
| 132 |
+
|
| 133 |
+
### Task 1: Basic Email Sorting (Easy)
|
| 134 |
+
|
| 135 |
+
- **Emails:** 5
|
| 136 |
+
- **Folders:** 2 (work, spam)
|
| 137 |
+
- **Challenge:** Clear-cut classification
|
| 138 |
+
- **Scoring:** Pure accuracy
|
| 139 |
+
- **Threshold:** 80%
|
| 140 |
+
|
| 141 |
+
### Task 2: Multi-Folder Triage (Medium)
|
| 142 |
+
|
| 143 |
+
- **Emails:** 15
|
| 144 |
+
- **Folders:** 4 (work, finance, meetings, spam)
|
| 145 |
+
- **Challenge:** Per-folder precision, some ambiguous cases
|
| 146 |
+
- **Scoring:** Weighted per-folder accuracy (work 35%, finance 30%, meetings 20%, spam 15%)
|
| 147 |
+
- **Threshold:** 70%
|
| 148 |
+
|
| 149 |
+
### Task 3: Advanced Triage with Urgency (Hard)
|
| 150 |
+
|
| 151 |
+
- **Emails:** 30
|
| 152 |
+
- **Folders:** 6 (all)
|
| 153 |
+
- **Challenge:** VIP prioritization, deadline urgency, ambiguous cases
|
| 154 |
+
- **Scoring:** Accuracy 50% (urgent 2x weight) + Efficiency 25% + VIP handling 25%
|
| 155 |
+
- **Threshold:** 60% base accuracy required, 75% overall
|
| 156 |
+
|
| 157 |
+
## Baseline Results
|
| 158 |
+
|
| 159 |
+
### Model: google/gemma-4-31B-it
|
| 160 |
+
|
| 161 |
+
| Task | Score | Steps | Status |
|
| 162 |
+
|------|-------|-------|--------|
|
| 163 |
+
| Basic Triage | ~0.88 | 5-7 | Pass |
|
| 164 |
+
| Multi-Folder | ~0.75 | 15-20 | Pass |
|
| 165 |
+
| Advanced | ~0.65 | 30-40 | Pass |
|
| 166 |
+
| **Average** | **~0.76** | - | **Pass** |
|
| 167 |
+
|
| 168 |
+
## API Reference
|
| 169 |
+
|
| 170 |
+
### Health Check
|
| 171 |
+
```bash
|
| 172 |
+
curl http://localhost:7860/health
|
| 173 |
+
# {"status": "ok"}
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
### List Tasks
|
| 177 |
+
```bash
|
| 178 |
+
curl http://localhost:7860/tasks
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
### Reset Environment
|
| 182 |
+
```bash
|
| 183 |
+
curl -X POST http://localhost:7860/reset \
|
| 184 |
+
-H "Content-Type: application/json" \
|
| 185 |
+
-d '{"task_id": "basic_triage", "seed": 42}'
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
### Take a Step
|
| 189 |
+
```bash
|
| 190 |
+
curl -X POST http://localhost:7860/step \
|
| 191 |
+
-H "Content-Type: application/json" \
|
| 192 |
+
-d '{"action_type": "move", "email_id": 0, "target_folder": "work"}'
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
### Get Current State
|
| 196 |
+
```bash
|
| 197 |
+
curl http://localhost:7860/state
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
## Architecture
|
| 201 |
+
|
| 202 |
+
```
|
| 203 |
+
email-triage-env/
|
| 204 |
+
├── src/
|
| 205 |
+
│ ├── models.py # Pydantic models (Email, Observation, Action, Reward)
|
| 206 |
+
│ ├── environment.py # Core EmailTriageEnv (reset/step/state)
|
| 207 |
+
│ ├── data_generator.py # Realistic email generation with VIP/ambiguity
|
| 208 |
+
│ ├── reward_shaper.py # Multi-component reward computation
|
| 209 |
+
│ ├── grader.py # 3 deterministic task graders
|
| 210 |
+
│ └── utils.py # Action parsing, logging helpers
|
| 211 |
+
├── server/
|
| 212 |
+
│ └── app.py # FastAPI server (HF Spaces compatible)
|
| 213 |
+
├── tests/ # Unit and integration tests
|
| 214 |
+
├── data/ # Template and config JSON files
|
| 215 |
+
├── inference.py # Baseline LLM agent
|
| 216 |
+
├── openenv.yaml # OpenEnv specification
|
| 217 |
+
├── Dockerfile # Production container
|
| 218 |
+
└── requirements.txt # Pinned dependencies
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
### Design Decisions
|
| 222 |
+
|
| 223 |
+
1. **Pydantic models** for all data structures -- runtime validation catches malformed actions early.
|
| 224 |
+
2. **Deterministic seeding** -- same seed produces identical emails, ensuring reproducible grading.
|
| 225 |
+
3. **Multi-component rewards** -- agents receive a rich signal (not just binary correct/wrong), enabling more effective learning.
|
| 226 |
+
4. **Ambiguous case handling** -- partial credit for defensible choices tests reasoning, not just memorization.
|
| 227 |
+
5. **Efficiency pressure** -- rewards decrease with step count, preventing analysis paralysis.
|
| 228 |
+
6. **Penalty system** -- discourages degenerate strategies (looping, blind deletion).
|
| 229 |
+
|
| 230 |
+
## License
|
| 231 |
+
|
| 232 |
+
MIT
|
data/classification_rules.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"description": "Ground truth classification rules for the Email Triage grader",
|
| 3 |
+
"rules": {
|
| 4 |
+
"work": {
|
| 5 |
+
"signals": [
|
| 6 |
+
"Sender domain is acmecorp.com",
|
| 7 |
+
"Subject contains: sprint, code review, PR, deployment, incident, spec",
|
| 8 |
+
"Body references internal projects or team members",
|
| 9 |
+
"Category hint: project, code_review, incident, documentation, engineering"
|
| 10 |
+
],
|
| 11 |
+
"weight": 0.35
|
| 12 |
+
},
|
| 13 |
+
"finance": {
|
| 14 |
+
"signals": [
|
| 15 |
+
"Subject contains: invoice, expense, PO, purchase order, budget, payroll",
|
| 16 |
+
"Has attachment with financial content",
|
| 17 |
+
"Sender is CFO or Finance Manager (VIP)",
|
| 18 |
+
"Category hint: invoice, expense, purchase_order, budget, subscription, payroll"
|
| 19 |
+
],
|
| 20 |
+
"weight": 0.30
|
| 21 |
+
},
|
| 22 |
+
"meetings": {
|
| 23 |
+
"signals": [
|
| 24 |
+
"Subject contains: calendar, meeting, standup, RSVP, invite, 1:1",
|
| 25 |
+
"Body contains time/date references for events",
|
| 26 |
+
"Category hint: meeting, meeting_notes, training"
|
| 27 |
+
],
|
| 28 |
+
"weight": 0.20
|
| 29 |
+
},
|
| 30 |
+
"spam": {
|
| 31 |
+
"signals": [
|
| 32 |
+
"Sender domain is suspicious (.ru, .xyz, .biz, .club, .top)",
|
| 33 |
+
"Subject uses urgency/scam patterns (Congratulations!, Won, Free, Urgent:)",
|
| 34 |
+
"Body contains phishing links or too-good-to-be-true offers",
|
| 35 |
+
"No category hint (unknown sender)"
|
| 36 |
+
],
|
| 37 |
+
"weight": 0.15
|
| 38 |
+
},
|
| 39 |
+
"archive": {
|
| 40 |
+
"signals": [
|
| 41 |
+
"Automated notification or newsletter",
|
| 42 |
+
"Sender is external service (GitHub, Slack, etc.)",
|
| 43 |
+
"Low-priority informational content",
|
| 44 |
+
"Category hint: newsletter, notification, report, shipping"
|
| 45 |
+
],
|
| 46 |
+
"weight": 0.10
|
| 47 |
+
}
|
| 48 |
+
},
|
| 49 |
+
"ambiguous_rules": {
|
| 50 |
+
"description": "Emails matching multiple folder rules receive partial credit (0.5) for any acceptable folder",
|
| 51 |
+
"examples": [
|
| 52 |
+
{
|
| 53 |
+
"email": "Invoice for Q3 Tech Conference Registration",
|
| 54 |
+
"acceptable_folders": ["finance", "work"],
|
| 55 |
+
"reason": "Could be expense (finance) or professional development (work)"
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"email": "Team Lunch Budget Approval",
|
| 59 |
+
"acceptable_folders": ["meetings", "finance"],
|
| 60 |
+
"reason": "Could be event planning (meetings) or budget approval (finance)"
|
| 61 |
+
}
|
| 62 |
+
]
|
| 63 |
+
}
|
| 64 |
+
}
|
data/email_templates.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"description": "Email templates organized by category for the Email Triage environment. Templates use {n}, {q}, {project}, {day} placeholders.",
|
| 3 |
+
"categories": {
|
| 4 |
+
"work": {
|
| 5 |
+
"count": 8,
|
| 6 |
+
"description": "Internal work emails: sprint planning, code reviews, incidents, specs"
|
| 7 |
+
},
|
| 8 |
+
"finance": {
|
| 9 |
+
"count": 6,
|
| 10 |
+
"description": "Financial emails: invoices, expenses, POs, budgets, payroll"
|
| 11 |
+
},
|
| 12 |
+
"meetings": {
|
| 13 |
+
"count": 6,
|
| 14 |
+
"description": "Calendar and meeting emails: standups, reviews, RSVPs, notes"
|
| 15 |
+
},
|
| 16 |
+
"spam": {
|
| 17 |
+
"count": 8,
|
| 18 |
+
"description": "Spam and phishing: prize scams, fake security alerts, deals"
|
| 19 |
+
},
|
| 20 |
+
"archive": {
|
| 21 |
+
"count": 5,
|
| 22 |
+
"description": "Low-priority: newsletters, notifications, automated reports"
|
| 23 |
+
},
|
| 24 |
+
"ambiguous": {
|
| 25 |
+
"count": 6,
|
| 26 |
+
"description": "Emails that legitimately belong to 2+ folders"
|
| 27 |
+
}
|
| 28 |
+
},
|
| 29 |
+
"total_templates": 39
|
| 30 |
+
}
|
data/sender_profiles.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"description": "Sender profiles for the Email Triage environment",
|
| 3 |
+
"vip_senders": [
|
| 4 |
+
{
|
| 5 |
+
"name": "Sarah Chen",
|
| 6 |
+
"email": "sarah.chen@acmecorp.com",
|
| 7 |
+
"role": "CEO",
|
| 8 |
+
"typical_folders": ["work"]
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"name": "James Rodriguez",
|
| 12 |
+
"email": "j.rodriguez@acmecorp.com",
|
| 13 |
+
"role": "CFO",
|
| 14 |
+
"typical_folders": ["finance"]
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"name": "Priya Patel",
|
| 18 |
+
"email": "priya.patel@acmecorp.com",
|
| 19 |
+
"role": "VP Engineering",
|
| 20 |
+
"typical_folders": ["work", "meetings"]
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"name": "Michael Brooks",
|
| 24 |
+
"email": "m.brooks@acmecorp.com",
|
| 25 |
+
"role": "Finance Manager",
|
| 26 |
+
"typical_folders": ["finance"]
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"name": "Lisa Wang",
|
| 30 |
+
"email": "lisa.wang@acmecorp.com",
|
| 31 |
+
"role": "HR Director",
|
| 32 |
+
"typical_folders": ["work", "meetings"]
|
| 33 |
+
}
|
| 34 |
+
],
|
| 35 |
+
"internal_domain": "acmecorp.com",
|
| 36 |
+
"trusted_external_domains": [
|
| 37 |
+
"amazonaws.com",
|
| 38 |
+
"github.com",
|
| 39 |
+
"atlassian.net",
|
| 40 |
+
"slack.com",
|
| 41 |
+
"docusign.net",
|
| 42 |
+
"zoom.us"
|
| 43 |
+
],
|
| 44 |
+
"suspicious_tlds": [".ru", ".xyz", ".biz", ".club", ".top", ".net", ".shop"]
|
| 45 |
+
}
|
inference.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Email Triage OpenEnv - Baseline Inference Script
|
| 4 |
+
|
| 5 |
+
Runs a language model agent against all 3 email triage tasks and logs
|
| 6 |
+
results in the required OpenEnv [START]/[STEP]/[END] format.
|
| 7 |
+
|
| 8 |
+
Environment variables:
|
| 9 |
+
MODEL_NAME : Model identifier (default: google/gemma-4-31B-it)
|
| 10 |
+
API_BASE_URL : LLM API endpoint (default: https://router.huggingface.co/hf-inference/models/google/gemma-4-31B-it/v1)
|
| 11 |
+
HF_TOKEN : HuggingFace API token (fallback: OPENAI_API_KEY)
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
export HF_TOKEN="hf_..."
|
| 15 |
+
python inference.py
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import logging
|
| 21 |
+
import os
|
| 22 |
+
import sys
|
| 23 |
+
import time
|
| 24 |
+
from typing import Dict, List, Optional
|
| 25 |
+
|
| 26 |
+
from openai import OpenAI
|
| 27 |
+
|
| 28 |
+
from src.environment import EmailTriageEnv, TASK_CONFIG
|
| 29 |
+
from src.grader import grade_task_basic, grade_task_medium, grade_task_hard
|
| 30 |
+
from src.models import Action, Observation
|
| 31 |
+
from src.utils import format_action_for_log, parse_action_string
|
| 32 |
+
|
| 33 |
+
# ── Configuration ────────────────────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/hf-inference/models/google/gemma-4-31B-it/v1")
|
| 36 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "google/gemma-4-31B-it")
|
| 37 |
+
API_KEY = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY") or ""
|
| 38 |
+
TEMPERATURE = 0.3 # low temperature for consistency
|
| 39 |
+
MAX_RETRIES = 2
|
| 40 |
+
|
| 41 |
+
logging.basicConfig(
|
| 42 |
+
level=logging.WARNING,
|
| 43 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 44 |
+
stream=sys.stderr,
|
| 45 |
+
)
|
| 46 |
+
logger = logging.getLogger(__name__)
|
| 47 |
+
|
| 48 |
+
# Task → grader mapping
|
| 49 |
+
GRADERS = {
|
| 50 |
+
"basic_triage": grade_task_basic,
|
| 51 |
+
"multi_folder_triage": grade_task_medium,
|
| 52 |
+
"advanced_triage_with_urgency": grade_task_hard,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ── Logging (OpenEnv format → stdout) ────────────────────────────────────────
|
| 57 |
+
|
| 58 |
+
def log_start(task_id: str, model: str) -> None:
|
| 59 |
+
print(f"[START] task={task_id} env=email-triage model={model}", flush=True)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def log_step(
|
| 63 |
+
step: int,
|
| 64 |
+
action_str: str,
|
| 65 |
+
reward: float,
|
| 66 |
+
done: bool,
|
| 67 |
+
error: Optional[str],
|
| 68 |
+
) -> None:
|
| 69 |
+
error_part = f'"{error}"' if error else "null"
|
| 70 |
+
print(
|
| 71 |
+
f"[STEP] step={step} action={action_str} "
|
| 72 |
+
f"reward={reward:.2f} done={str(done).lower()} error={error_part}",
|
| 73 |
+
flush=True,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def log_end(
|
| 78 |
+
task_id: str,
|
| 79 |
+
success: bool,
|
| 80 |
+
steps: int,
|
| 81 |
+
score: float,
|
| 82 |
+
rewards: List[float],
|
| 83 |
+
) -> None:
|
| 84 |
+
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 85 |
+
print(
|
| 86 |
+
f"[END] task={task_id} success={str(success).lower()} "
|
| 87 |
+
f"steps={steps} score={score:.2f} rewards={rewards_str}",
|
| 88 |
+
flush=True,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ── Agent ────────────────────────────────────────────────────────────────────
|
| 93 |
+
|
| 94 |
+
class EmailTriageAgent:
|
| 95 |
+
"""Baseline LLM agent for email triage."""
|
| 96 |
+
|
| 97 |
+
def __init__(self, model_name: str, api_base: str, api_key: str) -> None:
|
| 98 |
+
self.client = OpenAI(api_key=api_key, base_url=api_base)
|
| 99 |
+
self.model_name = model_name
|
| 100 |
+
|
| 101 |
+
def get_action(
|
| 102 |
+
self,
|
| 103 |
+
observation: Observation,
|
| 104 |
+
step_num: int,
|
| 105 |
+
max_steps: int,
|
| 106 |
+
) -> str:
|
| 107 |
+
"""Query the LLM to decide which action to take.
|
| 108 |
+
|
| 109 |
+
Returns the raw action string from the model.
|
| 110 |
+
"""
|
| 111 |
+
inbox_lines = []
|
| 112 |
+
for e in observation.inbox_emails[:10]:
|
| 113 |
+
vip_tag = " [VIP]" if e.is_vip_sender else ""
|
| 114 |
+
prio_tag = " [URGENT]" if e.priority_flag else ""
|
| 115 |
+
inbox_lines.append(
|
| 116 |
+
f" ID={e.id} | From: {e.sender} | "
|
| 117 |
+
f"Subject: {e.subject[:60]}{vip_tag}{prio_tag}"
|
| 118 |
+
)
|
| 119 |
+
inbox_text = "\n".join(inbox_lines) if inbox_lines else " (empty)"
|
| 120 |
+
|
| 121 |
+
prompt = f"""You are an email triage assistant. Sort each email into the correct folder.
|
| 122 |
+
|
| 123 |
+
INBOX ({len(observation.inbox_emails)} emails):
|
| 124 |
+
{inbox_text}
|
| 125 |
+
|
| 126 |
+
FOLDERS: {', '.join(observation.available_folders)}
|
| 127 |
+
|
| 128 |
+
RULES:
|
| 129 |
+
- Work emails (@acmecorp.com, project updates, code reviews) -> work
|
| 130 |
+
- Invoices, expenses, budgets, purchase orders -> finance
|
| 131 |
+
- Calendar invites, meeting notes, RSVPs -> meetings
|
| 132 |
+
- Phishing, marketing spam, suspicious domains (.ru, .xyz, .biz) -> spam
|
| 133 |
+
- Newsletters, notifications, automated reports -> archive
|
| 134 |
+
- VIP/urgent emails should be prioritized and classified correctly
|
| 135 |
+
|
| 136 |
+
Step {step_num}/{max_steps}. Respond with ONLY the action, e.g.: move(0, work)
|
| 137 |
+
"""
|
| 138 |
+
|
| 139 |
+
for attempt in range(MAX_RETRIES + 1):
|
| 140 |
+
try:
|
| 141 |
+
response = self.client.chat.completions.create(
|
| 142 |
+
model=self.model_name,
|
| 143 |
+
messages=[{"role": "user", "content": prompt}],
|
| 144 |
+
max_tokens=60,
|
| 145 |
+
temperature=TEMPERATURE,
|
| 146 |
+
timeout=30,
|
| 147 |
+
)
|
| 148 |
+
return response.choices[0].message.content.strip()
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.warning(
|
| 151 |
+
"LLM call failed (attempt %d/%d): %s",
|
| 152 |
+
attempt + 1, MAX_RETRIES + 1, e,
|
| 153 |
+
)
|
| 154 |
+
if attempt < MAX_RETRIES:
|
| 155 |
+
time.sleep(2 ** attempt)
|
| 156 |
+
|
| 157 |
+
# Fallback: move first email to work
|
| 158 |
+
if observation.inbox_emails:
|
| 159 |
+
return f"move({observation.inbox_emails[0].id}, work)"
|
| 160 |
+
return "move(0, work)"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _parse_or_fallback(
|
| 164 |
+
raw_action: str, observation: Observation
|
| 165 |
+
) -> Action:
|
| 166 |
+
"""Parse the LLM's raw output into an Action, with a safe fallback."""
|
| 167 |
+
action = parse_action_string(raw_action)
|
| 168 |
+
if action is not None:
|
| 169 |
+
return action
|
| 170 |
+
|
| 171 |
+
# Fallback: move first available email to work
|
| 172 |
+
logger.warning("Failed to parse action: '%s'", raw_action)
|
| 173 |
+
if observation.inbox_emails:
|
| 174 |
+
return Action(
|
| 175 |
+
action_type="move",
|
| 176 |
+
email_id=observation.inbox_emails[0].id,
|
| 177 |
+
target_folder="work",
|
| 178 |
+
)
|
| 179 |
+
return Action(action_type="move", email_id=0, target_folder="work")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ── Task Runner ──────────────────────────────────────────────────────────────
|
| 183 |
+
|
| 184 |
+
def run_task(task_id: str, agent: EmailTriageAgent, seed: int = 42) -> Dict:
|
| 185 |
+
"""Run the agent on a single task and log results.
|
| 186 |
+
|
| 187 |
+
Returns a summary dict with score, steps, success status.
|
| 188 |
+
"""
|
| 189 |
+
env = EmailTriageEnv(task_id=task_id, seed=seed)
|
| 190 |
+
obs = env.reset()
|
| 191 |
+
max_steps = env.max_steps
|
| 192 |
+
|
| 193 |
+
log_start(task_id, MODEL_NAME)
|
| 194 |
+
|
| 195 |
+
rewards: List[float] = []
|
| 196 |
+
steps_taken = 0
|
| 197 |
+
error_msg: Optional[str] = None
|
| 198 |
+
|
| 199 |
+
try:
|
| 200 |
+
for step_num in range(1, max_steps + 1):
|
| 201 |
+
if obs.done:
|
| 202 |
+
break
|
| 203 |
+
|
| 204 |
+
raw_action = agent.get_action(obs, step_num, max_steps)
|
| 205 |
+
action = _parse_or_fallback(raw_action, obs)
|
| 206 |
+
action_str = format_action_for_log(action)
|
| 207 |
+
|
| 208 |
+
try:
|
| 209 |
+
obs, reward, done, info = env.step(action)
|
| 210 |
+
reward_val = reward.value
|
| 211 |
+
step_error = None
|
| 212 |
+
except Exception as e:
|
| 213 |
+
reward_val = 0.0
|
| 214 |
+
done = False
|
| 215 |
+
step_error = str(e)
|
| 216 |
+
logger.error("Step error: %s", e)
|
| 217 |
+
|
| 218 |
+
rewards.append(reward_val)
|
| 219 |
+
steps_taken = step_num
|
| 220 |
+
|
| 221 |
+
log_step(
|
| 222 |
+
step=step_num,
|
| 223 |
+
action_str=action_str,
|
| 224 |
+
reward=reward_val,
|
| 225 |
+
done=done,
|
| 226 |
+
error=step_error,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
if done:
|
| 230 |
+
break
|
| 231 |
+
|
| 232 |
+
except Exception as e:
|
| 233 |
+
error_msg = str(e)
|
| 234 |
+
logger.error("Task %s failed: %s", task_id, error_msg)
|
| 235 |
+
|
| 236 |
+
# Compute final score using the appropriate grader
|
| 237 |
+
grader = GRADERS.get(task_id)
|
| 238 |
+
if grader and env.history:
|
| 239 |
+
score = grader(env.history)
|
| 240 |
+
elif rewards:
|
| 241 |
+
score = sum(rewards) / len(rewards)
|
| 242 |
+
else:
|
| 243 |
+
score = 0.0
|
| 244 |
+
|
| 245 |
+
score = max(0.0, min(1.0, score))
|
| 246 |
+
success = score >= TASK_CONFIG[task_id].get("success_threshold", 0.7)
|
| 247 |
+
|
| 248 |
+
log_end(
|
| 249 |
+
task_id=task_id,
|
| 250 |
+
success=success,
|
| 251 |
+
steps=steps_taken,
|
| 252 |
+
score=score,
|
| 253 |
+
rewards=rewards,
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
return {
|
| 257 |
+
"task_id": task_id,
|
| 258 |
+
"score": score,
|
| 259 |
+
"steps": steps_taken,
|
| 260 |
+
"rewards": rewards,
|
| 261 |
+
"success": success,
|
| 262 |
+
"error": error_msg,
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ── Main ─────────────────────────────────────────────────────────────────────
|
| 267 |
+
|
| 268 |
+
def main() -> None:
|
| 269 |
+
"""Run baseline inference on all 3 tasks."""
|
| 270 |
+
if not API_KEY:
|
| 271 |
+
logger.error(
|
| 272 |
+
"No API key found. Set HF_TOKEN or OPENAI_API_KEY environment variable."
|
| 273 |
+
)
|
| 274 |
+
sys.exit(1)
|
| 275 |
+
|
| 276 |
+
agent = EmailTriageAgent(
|
| 277 |
+
model_name=MODEL_NAME,
|
| 278 |
+
api_base=API_BASE_URL,
|
| 279 |
+
api_key=API_KEY,
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
tasks = [
|
| 283 |
+
"basic_triage",
|
| 284 |
+
"multi_folder_triage",
|
| 285 |
+
"advanced_triage_with_urgency",
|
| 286 |
+
]
|
| 287 |
+
|
| 288 |
+
results = []
|
| 289 |
+
for task_id in tasks:
|
| 290 |
+
result = run_task(task_id, agent, seed=42)
|
| 291 |
+
results.append(result)
|
| 292 |
+
|
| 293 |
+
# Summary to stderr (not stdout, to keep stdout clean for log parsing)
|
| 294 |
+
print("\n" + "=" * 60, file=sys.stderr)
|
| 295 |
+
print("BASELINE INFERENCE SUMMARY", file=sys.stderr)
|
| 296 |
+
print("=" * 60, file=sys.stderr)
|
| 297 |
+
for r in results:
|
| 298 |
+
status = "PASS" if r["success"] else "FAIL"
|
| 299 |
+
print(
|
| 300 |
+
f" {r['task_id']:40s} score={r['score']:.2f} [{status}]",
|
| 301 |
+
file=sys.stderr,
|
| 302 |
+
)
|
| 303 |
+
avg = sum(r["score"] for r in results) / len(results) if results else 0.0
|
| 304 |
+
print(f"\n Average Score: {avg:.2f}", file=sys.stderr)
|
| 305 |
+
print("=" * 60, file=sys.stderr)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
if __name__ == "__main__":
|
| 309 |
+
main()
|
openenv.yaml
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: email-triage-env
|
| 2 |
+
version: "1.0.0"
|
| 3 |
+
description: >
|
| 4 |
+
Real-world email triage environment for training AI agents to manage
|
| 5 |
+
enterprise inboxes. Simulates realistic email patterns with VIP senders,
|
| 6 |
+
deadline urgency, ambiguous classification, and multi-folder organization.
|
| 7 |
+
Agents must reason about sender reputation, content analysis, and
|
| 8 |
+
contextual prioritization to achieve high scores.
|
| 9 |
+
|
| 10 |
+
author: "Team OpenEnv"
|
| 11 |
+
license: MIT
|
| 12 |
+
|
| 13 |
+
environment:
|
| 14 |
+
observation_model: "src.models.Observation"
|
| 15 |
+
action_model: "src.models.Action"
|
| 16 |
+
reward_model: "src.models.Reward"
|
| 17 |
+
env_class: "src.environment.EmailTriageEnv"
|
| 18 |
+
|
| 19 |
+
tasks:
|
| 20 |
+
- id: "basic_triage"
|
| 21 |
+
name: "Basic Email Sorting"
|
| 22 |
+
description: >
|
| 23 |
+
Sort 5 clearly-categorized emails into work vs. spam folders.
|
| 24 |
+
Tests basic content analysis and domain recognition.
|
| 25 |
+
difficulty: "easy"
|
| 26 |
+
grader: "src.grader.grade_task_basic"
|
| 27 |
+
max_score: 1.0
|
| 28 |
+
success_threshold: 0.8
|
| 29 |
+
|
| 30 |
+
- id: "multi_folder_triage"
|
| 31 |
+
name: "Multi-Folder Triage"
|
| 32 |
+
description: >
|
| 33 |
+
Sort 15 emails into 4 folders (work, finance, meetings, spam).
|
| 34 |
+
Includes some ambiguous cases that could belong to multiple folders.
|
| 35 |
+
Requires per-folder precision above 70% threshold.
|
| 36 |
+
difficulty: "medium"
|
| 37 |
+
grader: "src.grader.grade_task_medium"
|
| 38 |
+
max_score: 1.0
|
| 39 |
+
success_threshold: 0.7
|
| 40 |
+
|
| 41 |
+
- id: "advanced_triage_with_urgency"
|
| 42 |
+
name: "Advanced Triage with Urgency & VIP Handling"
|
| 43 |
+
description: >
|
| 44 |
+
Sort 30 emails across 6 folders with deadline awareness and VIP
|
| 45 |
+
prioritization. Urgent emails carry 2x weight. Scoring combines
|
| 46 |
+
accuracy (50%), efficiency (25%), and VIP handling (25%).
|
| 47 |
+
Requires >60% base accuracy to receive any score.
|
| 48 |
+
difficulty: "hard"
|
| 49 |
+
grader: "src.grader.grade_task_hard"
|
| 50 |
+
max_score: 1.0
|
| 51 |
+
success_threshold: 0.75
|
| 52 |
+
|
| 53 |
+
metadata:
|
| 54 |
+
max_steps_per_episode: 50
|
| 55 |
+
action_types:
|
| 56 |
+
- "move"
|
| 57 |
+
- "delete"
|
| 58 |
+
- "snooze"
|
| 59 |
+
- "flag"
|
| 60 |
+
- "mark_spam"
|
| 61 |
+
available_folders:
|
| 62 |
+
- "inbox"
|
| 63 |
+
- "work"
|
| 64 |
+
- "finance"
|
| 65 |
+
- "meetings"
|
| 66 |
+
- "spam"
|
| 67 |
+
- "archive"
|
| 68 |
+
reward_range: [0.0, 1.0]
|
| 69 |
+
episode_reset_seed_based: true
|
| 70 |
+
reward_components:
|
| 71 |
+
- name: "correctness"
|
| 72 |
+
weight: 0.70
|
| 73 |
+
description: "Is the email in the correct folder?"
|
| 74 |
+
- name: "efficiency"
|
| 75 |
+
weight: 0.15
|
| 76 |
+
description: "How quickly did the agent decide?"
|
| 77 |
+
- name: "vip_bonus"
|
| 78 |
+
weight: 0.10
|
| 79 |
+
description: "Was a VIP email handled correctly?"
|
| 80 |
+
- name: "reasoning"
|
| 81 |
+
weight: 0.05
|
| 82 |
+
description: "Did the agent explain its choice?"
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pydantic>=2.6.0,<3.0.0
|
| 2 |
+
openai>=1.10.0,<2.0.0
|
| 3 |
+
fastapi>=0.109.0,<1.0.0
|
| 4 |
+
uvicorn[standard]>=0.27.0,<1.0.0
|
| 5 |
+
python-dotenv>=1.0.0,<2.0.0
|
| 6 |
+
pytest>=8.0.0,<9.0.0
|
| 7 |
+
httpx>=0.27.0,<1.0.0
|
server/__init__.py
ADDED
|
File without changes
|
server/app.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI server for the Email Triage OpenEnv environment.
|
| 3 |
+
|
| 4 |
+
Provides HTTP endpoints for environment interaction, compatible with
|
| 5 |
+
HuggingFace Spaces deployment on port 7860.
|
| 6 |
+
|
| 7 |
+
Endpoints:
|
| 8 |
+
GET /health - Health check (returns {"status": "ok"})
|
| 9 |
+
POST /reset - Reset environment, returns initial observation
|
| 10 |
+
POST /step - Execute an action, returns (observation, reward, done, info)
|
| 11 |
+
GET /state - Get current observation without advancing
|
| 12 |
+
GET /tasks - List available tasks
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
from typing import Any, Dict, Optional
|
| 19 |
+
|
| 20 |
+
from fastapi import FastAPI, HTTPException, Query
|
| 21 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 22 |
+
from pydantic import BaseModel
|
| 23 |
+
|
| 24 |
+
from src.environment import EmailTriageEnv, TASK_CONFIG
|
| 25 |
+
from src.models import Action, AVAILABLE_FOLDERS
|
| 26 |
+
|
| 27 |
+
logging.basicConfig(
|
| 28 |
+
level=logging.INFO,
|
| 29 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
# ── FastAPI App ──────────────────────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
app = FastAPI(
|
| 36 |
+
title="Email Triage OpenEnv",
|
| 37 |
+
description=(
|
| 38 |
+
"A realistic email management environment for training AI agents. "
|
| 39 |
+
"Supports 3 tasks with progressive difficulty: basic sorting, "
|
| 40 |
+
"multi-folder triage, and advanced triage with VIP/urgency handling."
|
| 41 |
+
),
|
| 42 |
+
version="1.0.0",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
app.add_middleware(
|
| 46 |
+
CORSMiddleware,
|
| 47 |
+
allow_origins=["*"],
|
| 48 |
+
allow_methods=["*"],
|
| 49 |
+
allow_headers=["*"],
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Global environment instance — re-created on /reset with task_id
|
| 53 |
+
_env: Optional[EmailTriageEnv] = None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _get_env() -> EmailTriageEnv:
|
| 57 |
+
"""Return the active environment, creating a default if needed."""
|
| 58 |
+
global _env
|
| 59 |
+
if _env is None:
|
| 60 |
+
_env = EmailTriageEnv(task_id="basic_triage")
|
| 61 |
+
_env.reset()
|
| 62 |
+
return _env
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ── Request / Response Models ────────────────────────────────────────────────
|
| 66 |
+
|
| 67 |
+
class ResetRequest(BaseModel):
|
| 68 |
+
task_id: str = "basic_triage"
|
| 69 |
+
seed: Optional[int] = None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class StepRequest(BaseModel):
|
| 73 |
+
action_type: str
|
| 74 |
+
email_id: int
|
| 75 |
+
target_folder: Optional[str] = None
|
| 76 |
+
reason: Optional[str] = None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class StepResponse(BaseModel):
|
| 80 |
+
observation: Dict[str, Any]
|
| 81 |
+
reward: Dict[str, Any]
|
| 82 |
+
done: bool
|
| 83 |
+
info: Dict[str, Any]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ── Endpoints ────────────────────────────────────────────────────────────────
|
| 87 |
+
|
| 88 |
+
@app.get("/health")
|
| 89 |
+
async def health() -> Dict[str, str]:
|
| 90 |
+
"""Health check for HuggingFace Spaces and monitoring."""
|
| 91 |
+
return {"status": "ok"}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@app.get("/tasks")
|
| 95 |
+
async def list_tasks() -> Dict[str, Any]:
|
| 96 |
+
"""List all available tasks with their configurations."""
|
| 97 |
+
return {
|
| 98 |
+
"tasks": {
|
| 99 |
+
tid: {
|
| 100 |
+
"description": cfg["description"],
|
| 101 |
+
"email_count": cfg["email_count"],
|
| 102 |
+
"difficulty": cfg["difficulty"],
|
| 103 |
+
"max_steps": cfg["max_steps"],
|
| 104 |
+
}
|
| 105 |
+
for tid, cfg in TASK_CONFIG.items()
|
| 106 |
+
},
|
| 107 |
+
"available_folders": AVAILABLE_FOLDERS,
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@app.post("/reset")
|
| 112 |
+
async def reset(request: ResetRequest) -> Dict[str, Any]:
|
| 113 |
+
"""Reset the environment with a specified task and optional seed.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
request: Contains task_id and optional seed.
|
| 117 |
+
|
| 118 |
+
Returns:
|
| 119 |
+
Initial observation as a dictionary.
|
| 120 |
+
"""
|
| 121 |
+
global _env
|
| 122 |
+
|
| 123 |
+
if request.task_id not in TASK_CONFIG:
|
| 124 |
+
raise HTTPException(
|
| 125 |
+
status_code=400,
|
| 126 |
+
detail=f"Unknown task_id '{request.task_id}'. "
|
| 127 |
+
f"Available: {list(TASK_CONFIG.keys())}",
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
_env = EmailTriageEnv(task_id=request.task_id, seed=request.seed)
|
| 131 |
+
obs = _env.reset()
|
| 132 |
+
|
| 133 |
+
logger.info("Environment reset: task=%s, seed=%s", request.task_id, request.seed)
|
| 134 |
+
return obs.to_agent_view()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@app.post("/step")
|
| 138 |
+
async def step(request: StepRequest) -> StepResponse:
|
| 139 |
+
"""Execute one action in the environment.
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
request: Action specification (action_type, email_id, target_folder).
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
StepResponse with observation, reward, done flag, and info.
|
| 146 |
+
"""
|
| 147 |
+
env = _get_env()
|
| 148 |
+
|
| 149 |
+
if env.done:
|
| 150 |
+
raise HTTPException(
|
| 151 |
+
status_code=400,
|
| 152 |
+
detail="Episode is done. Call /reset to start a new episode.",
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
action = Action(
|
| 157 |
+
action_type=request.action_type,
|
| 158 |
+
email_id=request.email_id,
|
| 159 |
+
target_folder=request.target_folder,
|
| 160 |
+
reason=request.reason,
|
| 161 |
+
)
|
| 162 |
+
except ValueError as e:
|
| 163 |
+
raise HTTPException(status_code=422, detail=str(e))
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
obs, reward, done, info = env.step(action)
|
| 167 |
+
except (RuntimeError, ValueError) as e:
|
| 168 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 169 |
+
|
| 170 |
+
return StepResponse(
|
| 171 |
+
observation=obs.to_agent_view(),
|
| 172 |
+
reward=reward.model_dump(),
|
| 173 |
+
done=done,
|
| 174 |
+
info=info,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@app.get("/state")
|
| 179 |
+
async def state() -> Dict[str, Any]:
|
| 180 |
+
"""Get the current observation without advancing the environment."""
|
| 181 |
+
env = _get_env()
|
| 182 |
+
return env.state().to_agent_view()
|
src/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Email Triage OpenEnv - A realistic email management environment for RL agents."""
|
| 2 |
+
|
| 3 |
+
__version__ = "1.0.0"
|
src/data_generator.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Realistic email data generator for the Email Triage environment.
|
| 3 |
+
|
| 4 |
+
Produces emails with varied senders, subjects, domains, and ground-truth
|
| 5 |
+
folder assignments. Supports three difficulty tiers and injects ambiguous
|
| 6 |
+
cases to test agent reasoning.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
import random
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from typing import Dict, List, Optional, Tuple
|
| 15 |
+
|
| 16 |
+
from src.models import Email
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# ── VIP Sender Profiles ─────────────────────────────────────────────────────
|
| 21 |
+
|
| 22 |
+
VIP_SENDERS: List[Dict] = [
|
| 23 |
+
{
|
| 24 |
+
"name": "Sarah Chen",
|
| 25 |
+
"email": "sarah.chen@acmecorp.com",
|
| 26 |
+
"domain": "acmecorp.com",
|
| 27 |
+
"role": "CEO",
|
| 28 |
+
"typical_folders": ["work"],
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"name": "James Rodriguez",
|
| 32 |
+
"email": "j.rodriguez@acmecorp.com",
|
| 33 |
+
"domain": "acmecorp.com",
|
| 34 |
+
"role": "CFO",
|
| 35 |
+
"typical_folders": ["finance"],
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"name": "Priya Patel",
|
| 39 |
+
"email": "priya.patel@acmecorp.com",
|
| 40 |
+
"domain": "acmecorp.com",
|
| 41 |
+
"role": "VP Engineering",
|
| 42 |
+
"typical_folders": ["work", "meetings"],
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"name": "Michael Brooks",
|
| 46 |
+
"email": "m.brooks@acmecorp.com",
|
| 47 |
+
"domain": "acmecorp.com",
|
| 48 |
+
"role": "Finance Manager",
|
| 49 |
+
"typical_folders": ["finance"],
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"name": "Lisa Wang",
|
| 53 |
+
"email": "lisa.wang@acmecorp.com",
|
| 54 |
+
"domain": "acmecorp.com",
|
| 55 |
+
"role": "HR Director",
|
| 56 |
+
"typical_folders": ["work", "meetings"],
|
| 57 |
+
},
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
VIP_EMAILS = {v["email"] for v in VIP_SENDERS}
|
| 61 |
+
|
| 62 |
+
# ── Email Templates ──────────────────────────────────────────────────────────
|
| 63 |
+
# Each template: (subject_template, body_template, ground_truth_folder,
|
| 64 |
+
# priority, has_attachment, category_hint)
|
| 65 |
+
|
| 66 |
+
WORK_TEMPLATES: List[Dict] = [
|
| 67 |
+
{
|
| 68 |
+
"subject": "Re: Sprint {n} Planning - Action Items",
|
| 69 |
+
"body": "Hi team, following up on our sprint planning session. Please review the attached action items and update your Jira tickets by EOD. Key deliverables for this sprint include the API refactor and the new dashboard components.",
|
| 70 |
+
"folder": "work",
|
| 71 |
+
"priority": False,
|
| 72 |
+
"attachment": False,
|
| 73 |
+
"hint": "project",
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"subject": "Code Review Request: PR #{n} - Auth Module Refactor",
|
| 77 |
+
"body": "I've submitted a pull request for the authentication module refactor. Could you review the changes when you get a chance? Main changes: JWT token rotation, session management improvements, and rate limiting updates.",
|
| 78 |
+
"folder": "work",
|
| 79 |
+
"priority": False,
|
| 80 |
+
"attachment": False,
|
| 81 |
+
"hint": "code_review",
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"subject": "Production Incident - API Latency Spike",
|
| 85 |
+
"body": "URGENT: We're seeing p99 latency spike to 2.3s on the /api/users endpoint. Root cause appears to be a slow database query. I've opened an incident channel. Please join if you're available.",
|
| 86 |
+
"folder": "work",
|
| 87 |
+
"priority": True,
|
| 88 |
+
"attachment": False,
|
| 89 |
+
"hint": "incident",
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"subject": "Updated: Project {project} Technical Specification",
|
| 93 |
+
"body": "I've updated the technical spec for Project {project} based on feedback from the architecture review. Key changes include the migration strategy and the new caching layer design. Please review and comment.",
|
| 94 |
+
"folder": "work",
|
| 95 |
+
"priority": False,
|
| 96 |
+
"attachment": True,
|
| 97 |
+
"hint": "documentation",
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"subject": "Onboarding Checklist for New Team Member",
|
| 101 |
+
"body": "We have a new engineer joining on Monday. Please make sure the following are set up: Git access, Slack channels, dev environment, and CI/CD pipeline permissions. Let me know if you need any help.",
|
| 102 |
+
"folder": "work",
|
| 103 |
+
"priority": False,
|
| 104 |
+
"attachment": True,
|
| 105 |
+
"hint": "hr",
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"subject": "Weekly Status Report - Week {n}",
|
| 109 |
+
"body": "Here's the weekly status update. Completed: Auth module v2, API docs refresh. In progress: Dashboard redesign, performance optimization. Blocked: Waiting on third-party API credentials.",
|
| 110 |
+
"folder": "work",
|
| 111 |
+
"priority": False,
|
| 112 |
+
"attachment": False,
|
| 113 |
+
"hint": "status",
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"subject": "Re: Deployment Schedule for v{n}.0",
|
| 117 |
+
"body": "Confirming the deployment window for version {n}.0 is Thursday 10pm-2am EST. Rollback plan is documented in Confluence. Please confirm your availability for the deploy support rotation.",
|
| 118 |
+
"folder": "work",
|
| 119 |
+
"priority": True,
|
| 120 |
+
"attachment": False,
|
| 121 |
+
"hint": "deployment",
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"subject": "Feature Flag Configuration Update",
|
| 125 |
+
"body": "I've enabled the following feature flags in staging: dark-mode-v2, new-onboarding-flow, experimental-search. Please run smoke tests and report any issues in the #qa channel.",
|
| 126 |
+
"folder": "work",
|
| 127 |
+
"priority": False,
|
| 128 |
+
"attachment": False,
|
| 129 |
+
"hint": "engineering",
|
| 130 |
+
},
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
FINANCE_TEMPLATES: List[Dict] = [
|
| 134 |
+
{
|
| 135 |
+
"subject": "Invoice #{n} - Cloud Infrastructure Services",
|
| 136 |
+
"body": "Please find attached Invoice #{n} for cloud infrastructure services for the period March 1-31. Total: $12,450.00. Payment terms: Net 30. Remit to the bank details on the invoice.",
|
| 137 |
+
"folder": "finance",
|
| 138 |
+
"priority": False,
|
| 139 |
+
"attachment": True,
|
| 140 |
+
"hint": "invoice",
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"subject": "Expense Report Approval Required - Q{q} Travel",
|
| 144 |
+
"body": "Your expense report for Q{q} business travel ($3,287.50) has been submitted and requires your approval. Includes: flights, hotel, meals, and ground transportation. Please review in Concur.",
|
| 145 |
+
"folder": "finance",
|
| 146 |
+
"priority": False,
|
| 147 |
+
"attachment": True,
|
| 148 |
+
"hint": "expense",
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"subject": "Purchase Order #{n} Confirmation",
|
| 152 |
+
"body": "Your purchase order #{n} for 50 developer licenses of JetBrains IntelliJ IDEA has been approved. Total cost: $7,450.00. Licenses will be activated within 2 business days.",
|
| 153 |
+
"folder": "finance",
|
| 154 |
+
"priority": False,
|
| 155 |
+
"attachment": True,
|
| 156 |
+
"hint": "purchase_order",
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"subject": "Q{q} Budget Variance Report",
|
| 160 |
+
"body": "Attached is the Q{q} budget variance report for the Engineering department. We are currently 8% under budget on headcount and 12% over on infrastructure costs. Action items included.",
|
| 161 |
+
"folder": "finance",
|
| 162 |
+
"priority": True,
|
| 163 |
+
"attachment": True,
|
| 164 |
+
"hint": "budget",
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
"subject": "Annual Subscription Renewal - GitHub Enterprise",
|
| 168 |
+
"body": "Your GitHub Enterprise subscription is up for renewal on May 15. Current plan: 200 seats at $21/seat/month. Renewal quote attached. Please approve or request changes by April 30.",
|
| 169 |
+
"folder": "finance",
|
| 170 |
+
"priority": False,
|
| 171 |
+
"attachment": True,
|
| 172 |
+
"hint": "subscription",
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"subject": "Payroll Adjustment Notice",
|
| 176 |
+
"body": "This is to confirm the salary adjustment effective April 1. Updated compensation details are available in Workday. Please review and contact HR if you have questions.",
|
| 177 |
+
"folder": "finance",
|
| 178 |
+
"priority": True,
|
| 179 |
+
"attachment": False,
|
| 180 |
+
"hint": "payroll",
|
| 181 |
+
},
|
| 182 |
+
]
|
| 183 |
+
|
| 184 |
+
MEETING_TEMPLATES: List[Dict] = [
|
| 185 |
+
{
|
| 186 |
+
"subject": "Calendar: Weekly Team Standup - {day}",
|
| 187 |
+
"body": "Recurring meeting: Weekly Team Standup\nTime: {day} 9:30 AM - 9:45 AM EST\nLocation: Zoom (link in calendar invite)\nAgenda: Yesterday's progress, today's plan, blockers",
|
| 188 |
+
"folder": "meetings",
|
| 189 |
+
"priority": False,
|
| 190 |
+
"attachment": False,
|
| 191 |
+
"hint": "meeting",
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
"subject": "Meeting Invite: Architecture Review - {project}",
|
| 195 |
+
"body": "You're invited to an architecture review for {project}.\nDate: Thursday 2:00 PM - 3:30 PM EST\nLocation: Conference Room B / Zoom\nPlease prepare a 10-minute overview of your component.",
|
| 196 |
+
"folder": "meetings",
|
| 197 |
+
"priority": False,
|
| 198 |
+
"attachment": False,
|
| 199 |
+
"hint": "meeting",
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
"subject": "RSVP: All-Hands Meeting - Q{q} Kickoff",
|
| 203 |
+
"body": "Please RSVP for the Q{q} All-Hands Meeting.\nDate: Monday, April 15 at 2:00 PM EST\nAgenda: Company updates, product roadmap, team celebrations\nCatering will be provided for in-office attendees.",
|
| 204 |
+
"folder": "meetings",
|
| 205 |
+
"priority": False,
|
| 206 |
+
"attachment": False,
|
| 207 |
+
"hint": "meeting",
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
"subject": "1:1 Rescheduled to {day} at 3pm",
|
| 211 |
+
"body": "Hey, I need to reschedule our 1:1 to {day} at 3pm. Same Zoom link. I'd like to discuss your career development goals and the upcoming project assignments.",
|
| 212 |
+
"folder": "meetings",
|
| 213 |
+
"priority": False,
|
| 214 |
+
"attachment": False,
|
| 215 |
+
"hint": "meeting",
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"subject": "Reminder: Design Sprint Workshop Tomorrow",
|
| 219 |
+
"body": "Reminder that the Design Sprint Workshop is tomorrow from 10am-4pm in the Innovation Lab. Bring your laptop and Figma access. Lunch will be provided.",
|
| 220 |
+
"folder": "meetings",
|
| 221 |
+
"priority": True,
|
| 222 |
+
"attachment": False,
|
| 223 |
+
"hint": "meeting",
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"subject": "Meeting Notes: Product Roadmap Review",
|
| 227 |
+
"body": "Attached are the meeting notes from today's Product Roadmap Review. Key decisions: Q3 focus on mobile app, postpone enterprise features to Q4, hire 2 more designers. Action items assigned in Asana.",
|
| 228 |
+
"folder": "meetings",
|
| 229 |
+
"priority": False,
|
| 230 |
+
"attachment": True,
|
| 231 |
+
"hint": "meeting_notes",
|
| 232 |
+
},
|
| 233 |
+
]
|
| 234 |
+
|
| 235 |
+
SPAM_TEMPLATES: List[Dict] = [
|
| 236 |
+
{
|
| 237 |
+
"subject": "Congratulations! You've Won a $500 Gift Card!",
|
| 238 |
+
"body": "CLICK HERE to claim your exclusive $500 Amazon Gift Card! Limited time offer - expires in 24 hours! No purchase necessary. *Terms and conditions apply.",
|
| 239 |
+
"folder": "spam",
|
| 240 |
+
"priority": False,
|
| 241 |
+
"attachment": False,
|
| 242 |
+
"hint": None,
|
| 243 |
+
},
|
| 244 |
+
{
|
| 245 |
+
"subject": "Urgent: Your Account Has Been Compromised",
|
| 246 |
+
"body": "We detected suspicious activity on your account. Click the link below to verify your identity immediately or your account will be suspended within 24 hours. http://totally-legit-security.ru/verify",
|
| 247 |
+
"folder": "spam",
|
| 248 |
+
"priority": False,
|
| 249 |
+
"attachment": False,
|
| 250 |
+
"hint": None,
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"subject": "Limited Time: 90% Off Premium Software Bundle",
|
| 254 |
+
"body": "Get the complete Adobe Creative Suite, Microsoft Office, and 50+ premium tools for just $29.99! This exclusive deal won't last. Click now to download instantly!",
|
| 255 |
+
"folder": "spam",
|
| 256 |
+
"priority": False,
|
| 257 |
+
"attachment": False,
|
| 258 |
+
"hint": None,
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
"subject": "You've been selected for an exclusive investment opportunity",
|
| 262 |
+
"body": "Dear valued investor, We have a once-in-a-lifetime opportunity for guaranteed 500% returns in just 30 days. Reply now to secure your spot. Minimum investment: $1,000.",
|
| 263 |
+
"folder": "spam",
|
| 264 |
+
"priority": False,
|
| 265 |
+
"attachment": False,
|
| 266 |
+
"hint": None,
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
"subject": "Free Bitcoin - Claim Your Reward Now",
|
| 270 |
+
"body": "You've been airdropped 0.5 BTC! Connect your wallet at crypto-free-airdrop.xyz to claim. Offer expires in 12 hours. Don't miss out on this incredible opportunity!",
|
| 271 |
+
"folder": "spam",
|
| 272 |
+
"priority": False,
|
| 273 |
+
"attachment": False,
|
| 274 |
+
"hint": None,
|
| 275 |
+
},
|
| 276 |
+
{
|
| 277 |
+
"subject": "SALE ALERT: Unbeatable Deals Inside!",
|
| 278 |
+
"body": "Shop now and save up to 80% on designer brands! Free shipping on orders over $25. Use code SAVE80 at checkout. Unsubscribe: click here",
|
| 279 |
+
"folder": "spam",
|
| 280 |
+
"priority": False,
|
| 281 |
+
"attachment": False,
|
| 282 |
+
"hint": None,
|
| 283 |
+
},
|
| 284 |
+
{
|
| 285 |
+
"subject": "Your prescription is ready - Order online",
|
| 286 |
+
"body": "Canadian Pharmacy Online - Best prices on all medications. No prescription required. Fast discreet shipping. Order now and save 70%!",
|
| 287 |
+
"folder": "spam",
|
| 288 |
+
"priority": False,
|
| 289 |
+
"attachment": False,
|
| 290 |
+
"hint": None,
|
| 291 |
+
},
|
| 292 |
+
{
|
| 293 |
+
"subject": "Make $5000/week working from home!",
|
| 294 |
+
"body": "Thousands of people are already earning $5000+ per week with our simple online system. No experience needed! Start today and see results tomorrow. Click here to learn more.",
|
| 295 |
+
"folder": "spam",
|
| 296 |
+
"priority": False,
|
| 297 |
+
"attachment": False,
|
| 298 |
+
"hint": None,
|
| 299 |
+
},
|
| 300 |
+
]
|
| 301 |
+
|
| 302 |
+
ARCHIVE_TEMPLATES: List[Dict] = [
|
| 303 |
+
{
|
| 304 |
+
"subject": "Your monthly newsletter - Tech Digest March",
|
| 305 |
+
"body": "This month in tech: AI advances, new framework releases, industry trends. Read the full newsletter at our website. Manage your subscription preferences below.",
|
| 306 |
+
"folder": "archive",
|
| 307 |
+
"priority": False,
|
| 308 |
+
"attachment": False,
|
| 309 |
+
"hint": "newsletter",
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
"subject": "Automated Report: Weekly Analytics Summary",
|
| 313 |
+
"body": "Your weekly analytics summary is ready. Key metrics: 12,450 page views (+5%), 3,200 unique visitors (+8%), 2.3% conversion rate (-0.1%). Full report available in the dashboard.",
|
| 314 |
+
"folder": "archive",
|
| 315 |
+
"priority": False,
|
| 316 |
+
"attachment": True,
|
| 317 |
+
"hint": "report",
|
| 318 |
+
},
|
| 319 |
+
{
|
| 320 |
+
"subject": "GitHub: New release published - project-lib v2.{n}.0",
|
| 321 |
+
"body": "A new release has been published for project-lib. Version 2.{n}.0 includes bug fixes and performance improvements. See the changelog for details.",
|
| 322 |
+
"folder": "archive",
|
| 323 |
+
"priority": False,
|
| 324 |
+
"attachment": False,
|
| 325 |
+
"hint": "notification",
|
| 326 |
+
},
|
| 327 |
+
{
|
| 328 |
+
"subject": "Your order #{n} has been delivered",
|
| 329 |
+
"body": "Your Amazon order #{n} has been delivered. If you didn't receive your package, please contact customer support. We'd love your feedback - leave a review!",
|
| 330 |
+
"folder": "archive",
|
| 331 |
+
"priority": False,
|
| 332 |
+
"attachment": False,
|
| 333 |
+
"hint": "shipping",
|
| 334 |
+
},
|
| 335 |
+
{
|
| 336 |
+
"subject": "Slack digest: 47 unread messages in #engineering",
|
| 337 |
+
"body": "You have 47 unread messages in #engineering. Top threads: deployment pipeline discussion, new hire introductions, Friday social planning.",
|
| 338 |
+
"folder": "archive",
|
| 339 |
+
"priority": False,
|
| 340 |
+
"attachment": False,
|
| 341 |
+
"hint": "notification",
|
| 342 |
+
},
|
| 343 |
+
]
|
| 344 |
+
|
| 345 |
+
# ── Ambiguous Templates ──────────────────────────────────────────────────────
|
| 346 |
+
# Emails that could legitimately go to 2+ folders
|
| 347 |
+
|
| 348 |
+
AMBIGUOUS_TEMPLATES: List[Dict] = [
|
| 349 |
+
{
|
| 350 |
+
"subject": "Invoice for Q{q} Tech Conference Registration",
|
| 351 |
+
"body": "Attached is the invoice for the Q{q} Tech Conference registration fee ($1,200). This includes the 3-day conference pass and workshop access. Receipt for expense reimbursement attached.",
|
| 352 |
+
"folder": "finance",
|
| 353 |
+
"acceptable": ["finance", "work"],
|
| 354 |
+
"priority": False,
|
| 355 |
+
"attachment": True,
|
| 356 |
+
"hint": "invoice",
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"subject": "Team Lunch Budget Approval - Friday",
|
| 360 |
+
"body": "Can you approve the budget for Friday's team lunch? Estimated cost: $350 for 15 people at the Italian place downtown. I'll submit the expense report after.",
|
| 361 |
+
"folder": "meetings",
|
| 362 |
+
"acceptable": ["meetings", "finance"],
|
| 363 |
+
"priority": False,
|
| 364 |
+
"attachment": False,
|
| 365 |
+
"hint": "meeting",
|
| 366 |
+
},
|
| 367 |
+
{
|
| 368 |
+
"subject": "Contract Review: SaaS Vendor Agreement",
|
| 369 |
+
"body": "Legal has flagged several items in the SaaS vendor agreement that need engineering input. Could you review sections 4.2 (data handling) and 5.1 (SLA terms) and provide your technical assessment?",
|
| 370 |
+
"folder": "work",
|
| 371 |
+
"acceptable": ["work", "finance"],
|
| 372 |
+
"priority": True,
|
| 373 |
+
"attachment": True,
|
| 374 |
+
"hint": "contract",
|
| 375 |
+
},
|
| 376 |
+
{
|
| 377 |
+
"subject": "Reminder: Training Session - Cloud Security Best Practices",
|
| 378 |
+
"body": "Reminder: The mandatory Cloud Security Best Practices training is scheduled for Wednesday 2pm. This is required for SOC 2 compliance. Please complete the pre-work in the shared drive.",
|
| 379 |
+
"folder": "meetings",
|
| 380 |
+
"acceptable": ["meetings", "work"],
|
| 381 |
+
"priority": True,
|
| 382 |
+
"attachment": False,
|
| 383 |
+
"hint": "training",
|
| 384 |
+
},
|
| 385 |
+
{
|
| 386 |
+
"subject": "Re: Hardware Upgrade Request - Development Laptops",
|
| 387 |
+
"body": "IT has approved the hardware upgrade request. 5 new MacBook Pro M3 Max units will be ordered. Total cost: $17,495. Please confirm the shipping addresses for each team member.",
|
| 388 |
+
"folder": "work",
|
| 389 |
+
"acceptable": ["work", "finance"],
|
| 390 |
+
"priority": False,
|
| 391 |
+
"attachment": False,
|
| 392 |
+
"hint": "procurement",
|
| 393 |
+
},
|
| 394 |
+
{
|
| 395 |
+
"subject": "Planning Offsite: Q{q} Strategy Session",
|
| 396 |
+
"body": "We're organizing a Q{q} strategy offsite at the lake house. Dates: May 10-12. Budget: $8,000. Agenda includes roadmap planning, team building, and retrospective. RSVP by Friday.",
|
| 397 |
+
"folder": "meetings",
|
| 398 |
+
"acceptable": ["meetings", "work", "finance"],
|
| 399 |
+
"priority": False,
|
| 400 |
+
"attachment": True,
|
| 401 |
+
"hint": "planning",
|
| 402 |
+
},
|
| 403 |
+
]
|
| 404 |
+
|
| 405 |
+
# ── Sender Pools ─────────────────────────────────────────────────────────────
|
| 406 |
+
|
| 407 |
+
INTERNAL_SENDERS = [
|
| 408 |
+
("Alex Kim", "alex.kim@acmecorp.com", "acmecorp.com"),
|
| 409 |
+
("Jordan Taylor", "j.taylor@acmecorp.com", "acmecorp.com"),
|
| 410 |
+
("Morgan Lee", "morgan.lee@acmecorp.com", "acmecorp.com"),
|
| 411 |
+
("Casey Wilson", "casey.w@acmecorp.com", "acmecorp.com"),
|
| 412 |
+
("Riley Johnson", "riley.j@acmecorp.com", "acmecorp.com"),
|
| 413 |
+
("Avery Brown", "a.brown@acmecorp.com", "acmecorp.com"),
|
| 414 |
+
("Dakota Smith", "d.smith@acmecorp.com", "acmecorp.com"),
|
| 415 |
+
]
|
| 416 |
+
|
| 417 |
+
EXTERNAL_SENDERS = [
|
| 418 |
+
("Vendor Support", "support@cloudservices.io", "cloudservices.io"),
|
| 419 |
+
("AWS Billing", "billing@amazonaws.com", "amazonaws.com"),
|
| 420 |
+
("GitHub", "noreply@github.com", "github.com"),
|
| 421 |
+
("Jira Notifications", "jira@atlassian.net", "atlassian.net"),
|
| 422 |
+
("Slack", "notifications@slack.com", "slack.com"),
|
| 423 |
+
("DocuSign", "dse@docusign.net", "docusign.net"),
|
| 424 |
+
("Zoom", "no-reply@zoom.us", "zoom.us"),
|
| 425 |
+
]
|
| 426 |
+
|
| 427 |
+
SPAM_SENDERS = [
|
| 428 |
+
("Prize Center", "winner@prize-claims.ru", "prize-claims.ru"),
|
| 429 |
+
("Security Alert", "alert@secure-verify.xyz", "secure-verify.xyz"),
|
| 430 |
+
("Deal Finder", "deals@mega-savings.biz", "mega-savings.biz"),
|
| 431 |
+
("Crypto Rewards", "airdrop@free-bitcoin.club", "free-bitcoin.club"),
|
| 432 |
+
("Easy Money", "info@work-from-home-now.net", "work-from-home-now.net"),
|
| 433 |
+
("Pharma Online", "order@canada-pharmacy.top", "canada-pharmacy.top"),
|
| 434 |
+
("Best Deals", "sales@unbeatable-offers.shop", "unbeatable-offers.shop"),
|
| 435 |
+
]
|
| 436 |
+
|
| 437 |
+
PROJECT_NAMES = [
|
| 438 |
+
"Phoenix", "Horizon", "Atlas", "Nebula", "Catalyst",
|
| 439 |
+
"Vertex", "Quantum", "Apex", "Prism", "Zenith",
|
| 440 |
+
]
|
| 441 |
+
|
| 442 |
+
DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
# ── Generator Functions ──────────────────────────────────────────────────────
|
| 446 |
+
|
| 447 |
+
def _fill_template(template: Dict, rng: random.Random) -> Dict:
|
| 448 |
+
"""Fill in template placeholders with random values."""
|
| 449 |
+
n = rng.randint(100, 9999)
|
| 450 |
+
q = rng.randint(1, 4)
|
| 451 |
+
project = rng.choice(PROJECT_NAMES)
|
| 452 |
+
day = rng.choice(DAYS)
|
| 453 |
+
|
| 454 |
+
subject = template["subject"].format(n=n, q=q, project=project, day=day)
|
| 455 |
+
body = template["body"].format(n=n, q=q, project=project, day=day)
|
| 456 |
+
|
| 457 |
+
return {
|
| 458 |
+
"subject": subject,
|
| 459 |
+
"body": body,
|
| 460 |
+
"folder": template["folder"],
|
| 461 |
+
"priority": template["priority"],
|
| 462 |
+
"attachment": template["attachment"],
|
| 463 |
+
"hint": template.get("hint"),
|
| 464 |
+
"acceptable": template.get("acceptable", []),
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def _pick_sender(
|
| 469 |
+
folder: str,
|
| 470 |
+
is_vip: bool,
|
| 471 |
+
rng: random.Random,
|
| 472 |
+
) -> Tuple[str, str, bool]:
|
| 473 |
+
"""Choose an appropriate sender for the email category."""
|
| 474 |
+
if is_vip:
|
| 475 |
+
vip = rng.choice(VIP_SENDERS)
|
| 476 |
+
return vip["email"], vip["domain"], True
|
| 477 |
+
|
| 478 |
+
if folder == "spam":
|
| 479 |
+
_, email, domain = rng.choice(SPAM_SENDERS)
|
| 480 |
+
return email, domain, False
|
| 481 |
+
|
| 482 |
+
if folder in ("archive",):
|
| 483 |
+
_, email, domain = rng.choice(EXTERNAL_SENDERS)
|
| 484 |
+
return email, domain, False
|
| 485 |
+
|
| 486 |
+
# work, finance, meetings — mostly internal
|
| 487 |
+
if rng.random() < 0.75:
|
| 488 |
+
_, email, domain = rng.choice(INTERNAL_SENDERS)
|
| 489 |
+
else:
|
| 490 |
+
_, email, domain = rng.choice(EXTERNAL_SENDERS)
|
| 491 |
+
return email, domain, False
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def generate_realistic_emails(
|
| 495 |
+
count: int,
|
| 496 |
+
difficulty: str = "easy",
|
| 497 |
+
seed: Optional[int] = None,
|
| 498 |
+
) -> List[Email]:
|
| 499 |
+
"""Generate a batch of realistic emails with ground-truth labels.
|
| 500 |
+
|
| 501 |
+
Args:
|
| 502 |
+
count: Number of emails to generate.
|
| 503 |
+
difficulty: One of "easy", "medium", "hard".
|
| 504 |
+
- easy: Clear work vs. spam split, no ambiguity.
|
| 505 |
+
- medium: 4 folders, ~15% ambiguous cases.
|
| 506 |
+
- hard: 6 folders, ~25% ambiguous, VIP senders, urgency flags.
|
| 507 |
+
seed: Random seed for reproducibility.
|
| 508 |
+
|
| 509 |
+
Returns:
|
| 510 |
+
List of Email objects sorted by timestamp (oldest first).
|
| 511 |
+
|
| 512 |
+
Example:
|
| 513 |
+
>>> emails = generate_realistic_emails(10, difficulty="medium", seed=42)
|
| 514 |
+
>>> len(emails)
|
| 515 |
+
10
|
| 516 |
+
>>> all(e.ground_truth_folder in AVAILABLE_FOLDERS for e in emails)
|
| 517 |
+
True
|
| 518 |
+
"""
|
| 519 |
+
rng = random.Random(seed)
|
| 520 |
+
base_time = datetime(2024, 4, 12, 8, 0, 0)
|
| 521 |
+
emails: List[Email] = []
|
| 522 |
+
|
| 523 |
+
# Decide template mix based on difficulty
|
| 524 |
+
if difficulty == "easy":
|
| 525 |
+
# Only work and spam
|
| 526 |
+
templates_pool: List[Tuple[List[Dict], float]] = [
|
| 527 |
+
(WORK_TEMPLATES, 0.5),
|
| 528 |
+
(SPAM_TEMPLATES, 0.5),
|
| 529 |
+
]
|
| 530 |
+
ambiguous_ratio = 0.0
|
| 531 |
+
vip_ratio = 0.0
|
| 532 |
+
elif difficulty == "medium":
|
| 533 |
+
templates_pool = [
|
| 534 |
+
(WORK_TEMPLATES, 0.30),
|
| 535 |
+
(FINANCE_TEMPLATES, 0.25),
|
| 536 |
+
(MEETING_TEMPLATES, 0.25),
|
| 537 |
+
(SPAM_TEMPLATES, 0.20),
|
| 538 |
+
]
|
| 539 |
+
ambiguous_ratio = 0.15
|
| 540 |
+
vip_ratio = 0.10
|
| 541 |
+
else: # hard
|
| 542 |
+
templates_pool = [
|
| 543 |
+
(WORK_TEMPLATES, 0.25),
|
| 544 |
+
(FINANCE_TEMPLATES, 0.20),
|
| 545 |
+
(MEETING_TEMPLATES, 0.15),
|
| 546 |
+
(SPAM_TEMPLATES, 0.15),
|
| 547 |
+
(ARCHIVE_TEMPLATES, 0.15),
|
| 548 |
+
(AMBIGUOUS_TEMPLATES, 0.10),
|
| 549 |
+
]
|
| 550 |
+
ambiguous_ratio = 0.25
|
| 551 |
+
vip_ratio = 0.20
|
| 552 |
+
|
| 553 |
+
# Pre-compute the number of ambiguous and VIP emails
|
| 554 |
+
num_ambiguous = max(1, int(count * ambiguous_ratio)) if ambiguous_ratio > 0 else 0
|
| 555 |
+
num_vip = max(1, int(count * vip_ratio)) if vip_ratio > 0 else 0
|
| 556 |
+
|
| 557 |
+
# Generate indices for special emails
|
| 558 |
+
all_indices = list(range(count))
|
| 559 |
+
rng.shuffle(all_indices)
|
| 560 |
+
ambiguous_indices = set(all_indices[:num_ambiguous])
|
| 561 |
+
vip_indices = set(all_indices[num_ambiguous:num_ambiguous + num_vip])
|
| 562 |
+
|
| 563 |
+
for i in range(count):
|
| 564 |
+
is_ambiguous = i in ambiguous_indices
|
| 565 |
+
is_vip = i in vip_indices
|
| 566 |
+
|
| 567 |
+
# Choose template
|
| 568 |
+
if is_ambiguous and AMBIGUOUS_TEMPLATES:
|
| 569 |
+
template = rng.choice(AMBIGUOUS_TEMPLATES)
|
| 570 |
+
else:
|
| 571 |
+
# Weighted random template selection
|
| 572 |
+
r = rng.random()
|
| 573 |
+
cumulative = 0.0
|
| 574 |
+
chosen_pool = WORK_TEMPLATES # fallback
|
| 575 |
+
for pool, weight in templates_pool:
|
| 576 |
+
cumulative += weight
|
| 577 |
+
if r < cumulative:
|
| 578 |
+
chosen_pool = pool
|
| 579 |
+
break
|
| 580 |
+
template = rng.choice(chosen_pool)
|
| 581 |
+
|
| 582 |
+
filled = _fill_template(template, rng)
|
| 583 |
+
sender, domain, sender_is_vip = _pick_sender(
|
| 584 |
+
filled["folder"], is_vip, rng
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
timestamp = base_time + timedelta(
|
| 588 |
+
hours=rng.randint(0, 8),
|
| 589 |
+
minutes=rng.randint(0, 59),
|
| 590 |
+
seconds=i, # ensure unique ordering
|
| 591 |
+
)
|
| 592 |
+
|
| 593 |
+
acceptable = filled.get("acceptable", [])
|
| 594 |
+
email_is_ambiguous = bool(acceptable) and len(acceptable) > 1
|
| 595 |
+
|
| 596 |
+
email = Email(
|
| 597 |
+
id=i,
|
| 598 |
+
subject=filled["subject"],
|
| 599 |
+
sender=sender,
|
| 600 |
+
sender_domain=domain,
|
| 601 |
+
timestamp=timestamp,
|
| 602 |
+
body_preview=filled["body"][:300],
|
| 603 |
+
priority_flag=filled["priority"] or is_vip,
|
| 604 |
+
has_attachment=filled["attachment"],
|
| 605 |
+
is_vip_sender=sender_is_vip or is_vip,
|
| 606 |
+
category_hint=filled["hint"] if difficulty != "hard" else None,
|
| 607 |
+
ground_truth_folder=filled["folder"],
|
| 608 |
+
is_ambiguous=email_is_ambiguous,
|
| 609 |
+
acceptable_folders=acceptable if email_is_ambiguous else [filled["folder"]],
|
| 610 |
+
)
|
| 611 |
+
emails.append(email)
|
| 612 |
+
|
| 613 |
+
# Sort by timestamp for realistic inbox ordering
|
| 614 |
+
emails.sort(key=lambda e: e.timestamp)
|
| 615 |
+
|
| 616 |
+
# Re-assign IDs after sorting
|
| 617 |
+
for idx, email in enumerate(emails):
|
| 618 |
+
email.id = idx
|
| 619 |
+
|
| 620 |
+
logger.info(
|
| 621 |
+
"Generated %d emails (difficulty=%s, ambiguous=%d, vip=%d)",
|
| 622 |
+
len(emails), difficulty, num_ambiguous, num_vip,
|
| 623 |
+
)
|
| 624 |
+
return emails
|
src/environment.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Email Triage OpenEnv Environment.
|
| 3 |
+
|
| 4 |
+
Implements a realistic email management simulation where AI agents learn to
|
| 5 |
+
triage emails into appropriate folders. Supports deterministic reset with
|
| 6 |
+
seed, multi-step episodes, and multi-component reward shaping.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
>>> env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 10 |
+
>>> obs = env.reset()
|
| 11 |
+
>>> action = Action(action_type="move", email_id=0, target_folder="work")
|
| 12 |
+
>>> obs, reward, done, info = env.step(action)
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 19 |
+
|
| 20 |
+
from src.data_generator import generate_realistic_emails
|
| 21 |
+
from src.models import (
|
| 22 |
+
AVAILABLE_FOLDERS,
|
| 23 |
+
MAX_STEPS_PER_EPISODE,
|
| 24 |
+
Action,
|
| 25 |
+
Email,
|
| 26 |
+
Observation,
|
| 27 |
+
Reward,
|
| 28 |
+
StepRecord,
|
| 29 |
+
)
|
| 30 |
+
from src.reward_shaper import compute_reward
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
# ── Task Configuration ───────────────────────────────────────────────────────
|
| 35 |
+
|
| 36 |
+
TASK_CONFIG: Dict[str, Dict[str, Any]] = {
|
| 37 |
+
"basic_triage": {
|
| 38 |
+
"email_count": 5,
|
| 39 |
+
"difficulty": "easy",
|
| 40 |
+
"max_steps": 15,
|
| 41 |
+
"description": "Sort 5 emails into work vs. spam",
|
| 42 |
+
},
|
| 43 |
+
"multi_folder_triage": {
|
| 44 |
+
"email_count": 15,
|
| 45 |
+
"difficulty": "medium",
|
| 46 |
+
"max_steps": 30,
|
| 47 |
+
"description": "Sort 15 emails into 4 folders",
|
| 48 |
+
},
|
| 49 |
+
"advanced_triage_with_urgency": {
|
| 50 |
+
"email_count": 30,
|
| 51 |
+
"difficulty": "hard",
|
| 52 |
+
"max_steps": 50,
|
| 53 |
+
"description": "Sort 30 emails with VIP and urgency handling",
|
| 54 |
+
},
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class EmailTriageEnv:
|
| 59 |
+
"""Email triage environment following the OpenEnv interface.
|
| 60 |
+
|
| 61 |
+
The agent observes an inbox of emails and must sort them into the correct
|
| 62 |
+
folders. Each action processes one email. The episode ends when the inbox
|
| 63 |
+
is empty or the step limit is reached.
|
| 64 |
+
|
| 65 |
+
Attributes:
|
| 66 |
+
task_id: Which task configuration to use.
|
| 67 |
+
seed: Random seed for deterministic episode generation.
|
| 68 |
+
current_step: Steps taken so far in this episode.
|
| 69 |
+
max_steps: Maximum steps allowed for this task.
|
| 70 |
+
done: Whether the current episode has ended.
|
| 71 |
+
history: List of StepRecords for grading.
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
def __init__(
|
| 75 |
+
self,
|
| 76 |
+
task_id: str = "basic_triage",
|
| 77 |
+
seed: Optional[int] = None,
|
| 78 |
+
) -> None:
|
| 79 |
+
if task_id not in TASK_CONFIG:
|
| 80 |
+
raise ValueError(
|
| 81 |
+
f"Unknown task_id '{task_id}'. "
|
| 82 |
+
f"Available: {list(TASK_CONFIG.keys())}"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
self.task_id = task_id
|
| 86 |
+
self.seed = seed
|
| 87 |
+
self._config = TASK_CONFIG[task_id]
|
| 88 |
+
|
| 89 |
+
# Episode state — populated on reset()
|
| 90 |
+
self._inbox: List[Email] = []
|
| 91 |
+
self._folders: Dict[str, List[Email]] = {f: [] for f in AVAILABLE_FOLDERS}
|
| 92 |
+
self._flagged_emails: set = set()
|
| 93 |
+
self._action_history: List[Action] = []
|
| 94 |
+
self.history: List[StepRecord] = []
|
| 95 |
+
self.current_step: int = 0
|
| 96 |
+
self.max_steps: int = self._config["max_steps"]
|
| 97 |
+
self.done: bool = False
|
| 98 |
+
self._episode_num: int = 0
|
| 99 |
+
self._email_lookup: Dict[int, Email] = {}
|
| 100 |
+
|
| 101 |
+
# ── OpenEnv Interface ────────────────────────────────────────────────
|
| 102 |
+
|
| 103 |
+
def reset(self, seed: Optional[int] = None) -> Observation:
|
| 104 |
+
"""Reset the environment to a fresh episode.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
seed: Optional override for the random seed.
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
Initial observation with a full inbox.
|
| 111 |
+
"""
|
| 112 |
+
if seed is not None:
|
| 113 |
+
self.seed = seed
|
| 114 |
+
|
| 115 |
+
self._episode_num += 1
|
| 116 |
+
self.current_step = 0
|
| 117 |
+
self.done = False
|
| 118 |
+
self._action_history = []
|
| 119 |
+
self.history = []
|
| 120 |
+
self._flagged_emails = set()
|
| 121 |
+
self._folders = {f: [] for f in AVAILABLE_FOLDERS}
|
| 122 |
+
|
| 123 |
+
# Generate emails deterministically
|
| 124 |
+
effective_seed = (
|
| 125 |
+
self.seed if self.seed is not None
|
| 126 |
+
else self._episode_num * 1000 + hash(self.task_id) % 10000
|
| 127 |
+
)
|
| 128 |
+
self._inbox = generate_realistic_emails(
|
| 129 |
+
count=self._config["email_count"],
|
| 130 |
+
difficulty=self._config["difficulty"],
|
| 131 |
+
seed=effective_seed,
|
| 132 |
+
)
|
| 133 |
+
self._email_lookup = {e.id: e for e in self._inbox}
|
| 134 |
+
|
| 135 |
+
logger.info(
|
| 136 |
+
"Reset environment: task=%s, emails=%d, seed=%s",
|
| 137 |
+
self.task_id, len(self._inbox), effective_seed,
|
| 138 |
+
)
|
| 139 |
+
return self._build_observation()
|
| 140 |
+
|
| 141 |
+
def step(self, action: Action) -> Tuple[Observation, Reward, bool, Dict[str, Any]]:
|
| 142 |
+
"""Execute one action in the environment.
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
action: The agent's chosen action.
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
Tuple of (observation, reward, done, info).
|
| 149 |
+
|
| 150 |
+
Raises:
|
| 151 |
+
RuntimeError: If the episode has already ended.
|
| 152 |
+
ValueError: If the target email doesn't exist in the inbox.
|
| 153 |
+
"""
|
| 154 |
+
if self.done:
|
| 155 |
+
raise RuntimeError(
|
| 156 |
+
"Episode is done. Call reset() to start a new episode."
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
self.current_step += 1
|
| 160 |
+
info: Dict[str, Any] = {"task_id": self.task_id}
|
| 161 |
+
|
| 162 |
+
# Validate email exists
|
| 163 |
+
email = self._email_lookup.get(action.email_id)
|
| 164 |
+
if email is None:
|
| 165 |
+
# Email not found — return zero reward and continue
|
| 166 |
+
reward = Reward(
|
| 167 |
+
value=0.0,
|
| 168 |
+
components={"correctness": 0.0, "efficiency": 0.0},
|
| 169 |
+
reason=f"Email ID {action.email_id} not found in inbox",
|
| 170 |
+
)
|
| 171 |
+
info["error"] = f"Invalid email_id: {action.email_id}"
|
| 172 |
+
self._check_done()
|
| 173 |
+
obs = self._build_observation()
|
| 174 |
+
self._record_step(action, email or Email(
|
| 175 |
+
id=action.email_id, subject="(unknown)",
|
| 176 |
+
sender="unknown@unknown.com",
|
| 177 |
+
sender_domain="unknown.com",
|
| 178 |
+
timestamp="2024-01-01T00:00:00",
|
| 179 |
+
), reward)
|
| 180 |
+
return obs, reward, self.done, info
|
| 181 |
+
|
| 182 |
+
# Compute reward before processing the action
|
| 183 |
+
reward = compute_reward(
|
| 184 |
+
action=action,
|
| 185 |
+
email=email,
|
| 186 |
+
current_step=self.current_step,
|
| 187 |
+
max_steps=self.max_steps,
|
| 188 |
+
action_history=self._action_history,
|
| 189 |
+
flagged_emails=self._flagged_emails,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# Process the action
|
| 193 |
+
self._process_action(action, email)
|
| 194 |
+
self._action_history.append(action)
|
| 195 |
+
|
| 196 |
+
# Record for grading
|
| 197 |
+
self._record_step(action, email, reward)
|
| 198 |
+
|
| 199 |
+
# Check termination
|
| 200 |
+
self._check_done()
|
| 201 |
+
|
| 202 |
+
obs = self._build_observation()
|
| 203 |
+
info["reward_components"] = reward.components
|
| 204 |
+
info["emails_remaining"] = len(self._inbox)
|
| 205 |
+
|
| 206 |
+
return obs, reward, self.done, info
|
| 207 |
+
|
| 208 |
+
def state(self) -> Observation:
|
| 209 |
+
"""Return the current observation without advancing the environment."""
|
| 210 |
+
return self._build_observation()
|
| 211 |
+
|
| 212 |
+
# ── Internal Methods ─────────────────────────────────────────────────
|
| 213 |
+
|
| 214 |
+
def _build_observation(self) -> Observation:
|
| 215 |
+
"""Construct the current observation from internal state."""
|
| 216 |
+
return Observation(
|
| 217 |
+
inbox_emails=list(self._inbox),
|
| 218 |
+
available_folders=list(AVAILABLE_FOLDERS),
|
| 219 |
+
current_step=self.current_step,
|
| 220 |
+
max_steps=self.max_steps,
|
| 221 |
+
episode_num=self._episode_num,
|
| 222 |
+
done=self.done,
|
| 223 |
+
info={
|
| 224 |
+
"task_id": self.task_id,
|
| 225 |
+
"emails_processed": len(self.history),
|
| 226 |
+
"folders_used": {
|
| 227 |
+
f: len(emails) for f, emails in self._folders.items() if emails
|
| 228 |
+
},
|
| 229 |
+
},
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
def _process_action(self, action: Action, email: Email) -> None:
|
| 233 |
+
"""Update internal state based on the agent's action."""
|
| 234 |
+
if action.action_type == "move":
|
| 235 |
+
self._move_email(email, action.target_folder)
|
| 236 |
+
elif action.action_type == "delete":
|
| 237 |
+
self._remove_from_inbox(email)
|
| 238 |
+
elif action.action_type == "mark_spam":
|
| 239 |
+
self._move_email(email, "spam")
|
| 240 |
+
elif action.action_type == "flag":
|
| 241 |
+
self._flagged_emails.add(email.id)
|
| 242 |
+
# Flagging doesn't remove from inbox
|
| 243 |
+
elif action.action_type == "snooze":
|
| 244 |
+
# Snoozing temporarily removes from inbox
|
| 245 |
+
self._remove_from_inbox(email)
|
| 246 |
+
|
| 247 |
+
def _move_email(self, email: Email, folder: str) -> None:
|
| 248 |
+
"""Move an email from the inbox to a target folder."""
|
| 249 |
+
self._remove_from_inbox(email)
|
| 250 |
+
if folder in self._folders:
|
| 251 |
+
self._folders[folder].append(email)
|
| 252 |
+
else:
|
| 253 |
+
logger.warning("Unknown folder '%s', defaulting to inbox", folder)
|
| 254 |
+
self._folders["inbox"].append(email)
|
| 255 |
+
|
| 256 |
+
def _remove_from_inbox(self, email: Email) -> None:
|
| 257 |
+
"""Remove an email from the inbox list."""
|
| 258 |
+
self._inbox = [e for e in self._inbox if e.id != email.id]
|
| 259 |
+
|
| 260 |
+
def _check_done(self) -> None:
|
| 261 |
+
"""Determine whether the episode should end."""
|
| 262 |
+
if self.current_step >= self.max_steps:
|
| 263 |
+
self.done = True
|
| 264 |
+
logger.info("Episode done: max steps (%d) reached", self.max_steps)
|
| 265 |
+
elif not self._inbox:
|
| 266 |
+
self.done = True
|
| 267 |
+
logger.info("Episode done: inbox empty")
|
| 268 |
+
|
| 269 |
+
def _record_step(self, action: Action, email: Email, reward: Reward) -> None:
|
| 270 |
+
"""Append a step record for later grading."""
|
| 271 |
+
self.history.append(
|
| 272 |
+
StepRecord(
|
| 273 |
+
step_num=self.current_step,
|
| 274 |
+
action=action,
|
| 275 |
+
email=email,
|
| 276 |
+
reward=reward,
|
| 277 |
+
done=self.done,
|
| 278 |
+
)
|
| 279 |
+
)
|
src/grader.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task graders for the Email Triage environment.
|
| 3 |
+
|
| 4 |
+
Each grader takes an episode history (list of StepRecords) and returns a
|
| 5 |
+
deterministic score in [0.0, 1.0]. Three graders implement progressive
|
| 6 |
+
difficulty:
|
| 7 |
+
|
| 8 |
+
- grade_task_basic: 5 emails, 2 folders, accuracy-only scoring
|
| 9 |
+
- grade_task_medium: 15 emails, 4 folders, weighted per-folder accuracy
|
| 10 |
+
- grade_task_hard: 30 emails, 6 folders, multi-component with VIP/urgency
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
from typing import Dict, List
|
| 17 |
+
|
| 18 |
+
from src.models import StepRecord
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# Minimum accuracy thresholds
|
| 23 |
+
MEDIUM_THRESHOLD: float = 0.70
|
| 24 |
+
HARD_ACCURACY_THRESHOLD: float = 0.60
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def grade_task_basic(episode_history: List[StepRecord]) -> float:
|
| 28 |
+
"""Grade the basic email sorting task.
|
| 29 |
+
|
| 30 |
+
Task: Sort 5 emails into work vs. spam.
|
| 31 |
+
Scoring: Pure accuracy (fraction of emails placed in correct folder).
|
| 32 |
+
|
| 33 |
+
The grader counts how many move actions resulted in a correctness
|
| 34 |
+
component > 0.8 (fully correct) or > 0.3 (partially correct for
|
| 35 |
+
ambiguous emails).
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
episode_history: List of StepRecords from the episode.
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
Score in [0.0, 1.0]. 1.0 means all emails were sorted correctly.
|
| 42 |
+
|
| 43 |
+
Example:
|
| 44 |
+
>>> score = grade_task_basic(history)
|
| 45 |
+
>>> 0.0 <= score <= 1.0
|
| 46 |
+
True
|
| 47 |
+
"""
|
| 48 |
+
if not episode_history:
|
| 49 |
+
return 0.0
|
| 50 |
+
|
| 51 |
+
# Only count actions that actually moved or classified an email
|
| 52 |
+
move_steps = _get_classification_steps(episode_history)
|
| 53 |
+
if not move_steps:
|
| 54 |
+
return 0.0
|
| 55 |
+
|
| 56 |
+
correct = 0.0
|
| 57 |
+
for step in move_steps:
|
| 58 |
+
c = step.reward.components.get("correctness", 0.0)
|
| 59 |
+
if c >= 0.9:
|
| 60 |
+
correct += 1.0
|
| 61 |
+
elif c >= 0.4:
|
| 62 |
+
correct += 0.5 # partial credit
|
| 63 |
+
|
| 64 |
+
total_emails = max(len(move_steps), 5)
|
| 65 |
+
score = correct / total_emails
|
| 66 |
+
|
| 67 |
+
return round(min(score, 1.0), 4)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def grade_task_medium(episode_history: List[StepRecord]) -> float:
|
| 71 |
+
"""Grade the multi-folder triage task.
|
| 72 |
+
|
| 73 |
+
Task: Sort 15 emails into 4 folders (work, finance, meetings, spam).
|
| 74 |
+
Scoring: Weighted per-folder accuracy, with a 70% threshold gate.
|
| 75 |
+
|
| 76 |
+
Folder weights reflect business importance:
|
| 77 |
+
- work: 0.35 (largest volume, core business)
|
| 78 |
+
- finance: 0.30 (high impact, errors are costly)
|
| 79 |
+
- meetings: 0.20 (moderate importance)
|
| 80 |
+
- spam: 0.15 (low cost of error)
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
episode_history: List of StepRecords from the episode.
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
Score in [0.0, 1.0]. Returns 0.0 if accuracy < 70% threshold.
|
| 87 |
+
"""
|
| 88 |
+
if not episode_history:
|
| 89 |
+
return 0.0
|
| 90 |
+
|
| 91 |
+
move_steps = _get_classification_steps(episode_history)
|
| 92 |
+
if not move_steps:
|
| 93 |
+
return 0.0
|
| 94 |
+
|
| 95 |
+
# Group by target folder and compute per-folder accuracy
|
| 96 |
+
folder_weights: Dict[str, float] = {
|
| 97 |
+
"work": 0.35,
|
| 98 |
+
"finance": 0.30,
|
| 99 |
+
"meetings": 0.20,
|
| 100 |
+
"spam": 0.15,
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
folder_correct: Dict[str, float] = {}
|
| 104 |
+
folder_total: Dict[str, int] = {}
|
| 105 |
+
|
| 106 |
+
for step in move_steps:
|
| 107 |
+
gt_folder = step.email.ground_truth_folder
|
| 108 |
+
if gt_folder not in folder_weights:
|
| 109 |
+
gt_folder = "work" # default for archive/other
|
| 110 |
+
|
| 111 |
+
folder_total[gt_folder] = folder_total.get(gt_folder, 0) + 1
|
| 112 |
+
|
| 113 |
+
c = step.reward.components.get("correctness", 0.0)
|
| 114 |
+
if c >= 0.9:
|
| 115 |
+
folder_correct[gt_folder] = folder_correct.get(gt_folder, 0.0) + 1.0
|
| 116 |
+
elif c >= 0.4:
|
| 117 |
+
folder_correct[gt_folder] = folder_correct.get(gt_folder, 0.0) + 0.5
|
| 118 |
+
|
| 119 |
+
# Compute weighted score
|
| 120 |
+
weighted_score = 0.0
|
| 121 |
+
for folder, weight in folder_weights.items():
|
| 122 |
+
total = folder_total.get(folder, 0)
|
| 123 |
+
if total > 0:
|
| 124 |
+
accuracy = folder_correct.get(folder, 0.0) / total
|
| 125 |
+
weighted_score += weight * accuracy
|
| 126 |
+
else:
|
| 127 |
+
# No emails in this folder — no penalty, no bonus
|
| 128 |
+
pass
|
| 129 |
+
|
| 130 |
+
# Normalize weights for folders that actually had emails
|
| 131 |
+
active_weight = sum(
|
| 132 |
+
w for f, w in folder_weights.items() if folder_total.get(f, 0) > 0
|
| 133 |
+
)
|
| 134 |
+
if active_weight > 0:
|
| 135 |
+
weighted_score = weighted_score / active_weight
|
| 136 |
+
|
| 137 |
+
# Apply threshold gate
|
| 138 |
+
overall_accuracy = _overall_accuracy(move_steps)
|
| 139 |
+
if overall_accuracy < MEDIUM_THRESHOLD:
|
| 140 |
+
return 0.0
|
| 141 |
+
|
| 142 |
+
return round(min(weighted_score, 1.0), 4)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def grade_task_hard(episode_history: List[StepRecord]) -> float:
|
| 146 |
+
"""Grade the advanced triage task with urgency and VIP handling.
|
| 147 |
+
|
| 148 |
+
Task: Sort 30 emails with deadline awareness and VIP prioritization.
|
| 149 |
+
Scoring: Multi-component.
|
| 150 |
+
- 50% Overall accuracy (urgent emails weighted 2x)
|
| 151 |
+
- 25% Efficiency (fewer steps = better)
|
| 152 |
+
- 25% VIP handling (correctly classified VIP emails)
|
| 153 |
+
|
| 154 |
+
The task requires >60% base accuracy to receive any score.
|
| 155 |
+
|
| 156 |
+
Args:
|
| 157 |
+
episode_history: List of StepRecords from the episode.
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
Score in [0.0, 1.0]. Returns 0.0 if accuracy < 60% threshold.
|
| 161 |
+
"""
|
| 162 |
+
if not episode_history:
|
| 163 |
+
return 0.0
|
| 164 |
+
|
| 165 |
+
move_steps = _get_classification_steps(episode_history)
|
| 166 |
+
if not move_steps:
|
| 167 |
+
return 0.0
|
| 168 |
+
|
| 169 |
+
# ── 1. Accuracy (50%) ─ urgent emails weighted 2x ────────────────────
|
| 170 |
+
weighted_correct = 0.0
|
| 171 |
+
weighted_total = 0.0
|
| 172 |
+
|
| 173 |
+
for step in move_steps:
|
| 174 |
+
weight = 2.0 if step.email.priority_flag else 1.0
|
| 175 |
+
weighted_total += weight
|
| 176 |
+
c = step.reward.components.get("correctness", 0.0)
|
| 177 |
+
if c >= 0.9:
|
| 178 |
+
weighted_correct += weight
|
| 179 |
+
elif c >= 0.4:
|
| 180 |
+
weighted_correct += weight * 0.5
|
| 181 |
+
|
| 182 |
+
accuracy_score = weighted_correct / max(weighted_total, 1.0)
|
| 183 |
+
|
| 184 |
+
# ── 2. Efficiency (25%) ──────────────────────────────────────────────
|
| 185 |
+
# Ideal: 30 steps for 30 emails (one action per email)
|
| 186 |
+
total_steps = len(episode_history)
|
| 187 |
+
ideal_steps = 30
|
| 188 |
+
# Efficiency scales from 1.0 (ideal) down to 0.0 (at 2x ideal)
|
| 189 |
+
efficiency_score = max(0.0, 1.0 - max(0, total_steps - ideal_steps) / ideal_steps)
|
| 190 |
+
|
| 191 |
+
# ── 3. VIP handling (25%) ────────────────────────────────────────────
|
| 192 |
+
vip_steps = [s for s in move_steps if s.email.is_vip_sender]
|
| 193 |
+
if vip_steps:
|
| 194 |
+
vip_correct = sum(
|
| 195 |
+
1.0 for s in vip_steps
|
| 196 |
+
if s.reward.components.get("correctness", 0.0) >= 0.9
|
| 197 |
+
)
|
| 198 |
+
vip_partial = sum(
|
| 199 |
+
0.5 for s in vip_steps
|
| 200 |
+
if 0.4 <= s.reward.components.get("correctness", 0.0) < 0.9
|
| 201 |
+
)
|
| 202 |
+
vip_score = (vip_correct + vip_partial) / len(vip_steps)
|
| 203 |
+
else:
|
| 204 |
+
vip_score = 0.0
|
| 205 |
+
|
| 206 |
+
# ── Combine ──────────────────────────────────────────────────────────
|
| 207 |
+
final_score = (
|
| 208 |
+
0.50 * accuracy_score
|
| 209 |
+
+ 0.25 * efficiency_score
|
| 210 |
+
+ 0.25 * vip_score
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Gate: must achieve minimum accuracy
|
| 214 |
+
raw_accuracy = _overall_accuracy(move_steps)
|
| 215 |
+
if raw_accuracy < HARD_ACCURACY_THRESHOLD:
|
| 216 |
+
return 0.0
|
| 217 |
+
|
| 218 |
+
return round(min(final_score, 1.0), 4)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ── Helper Functions ─────────────────────────────────────────────────────────
|
| 222 |
+
|
| 223 |
+
def _get_classification_steps(history: List[StepRecord]) -> List[StepRecord]:
|
| 224 |
+
"""Filter to steps that classify emails (move, mark_spam, delete).
|
| 225 |
+
|
| 226 |
+
Excludes flag and snooze as they don't represent final classification.
|
| 227 |
+
"""
|
| 228 |
+
classification_actions = {"move", "mark_spam", "delete"}
|
| 229 |
+
return [
|
| 230 |
+
s for s in history
|
| 231 |
+
if s.action.action_type in classification_actions
|
| 232 |
+
]
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _overall_accuracy(move_steps: List[StepRecord]) -> float:
|
| 236 |
+
"""Simple accuracy: fraction of correctly classified emails."""
|
| 237 |
+
if not move_steps:
|
| 238 |
+
return 0.0
|
| 239 |
+
correct = sum(
|
| 240 |
+
1.0 for s in move_steps
|
| 241 |
+
if s.reward.components.get("correctness", 0.0) >= 0.9
|
| 242 |
+
)
|
| 243 |
+
return correct / len(move_steps)
|
src/models.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic models for the Email Triage OpenEnv environment.
|
| 3 |
+
|
| 4 |
+
Defines the core data structures: Email, Observation, Action, Reward, and StepRecord.
|
| 5 |
+
All models use strict validation and include JSON schema examples for documentation.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 13 |
+
|
| 14 |
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# ── Constants ────────────────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
AVAILABLE_FOLDERS: List[str] = [
|
| 21 |
+
"inbox", "work", "finance", "meetings", "spam", "archive",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
ACTION_TYPES: List[str] = ["move", "delete", "snooze", "flag", "mark_spam"]
|
| 25 |
+
|
| 26 |
+
MAX_STEPS_PER_EPISODE: int = 50
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ── Email ────────────────────────────────────────────────────────────────────
|
| 30 |
+
|
| 31 |
+
class Email(BaseModel):
|
| 32 |
+
"""A single email in the agent's inbox.
|
| 33 |
+
|
| 34 |
+
Attributes:
|
| 35 |
+
id: Unique identifier within the episode.
|
| 36 |
+
subject: Email subject line.
|
| 37 |
+
sender: Full email address of the sender.
|
| 38 |
+
sender_domain: Domain portion of the sender address.
|
| 39 |
+
timestamp: When the email was received.
|
| 40 |
+
body_preview: First ~200 characters of the email body.
|
| 41 |
+
priority_flag: Whether the sender or subject indicates urgency.
|
| 42 |
+
has_attachment: Whether the email includes file attachments.
|
| 43 |
+
is_vip_sender: Whether the sender is on the VIP list.
|
| 44 |
+
category_hint: Optional machine-generated hint (may be noisy).
|
| 45 |
+
|
| 46 |
+
Example:
|
| 47 |
+
>>> email = Email(
|
| 48 |
+
... id=1,
|
| 49 |
+
... subject="Q4 Budget Review",
|
| 50 |
+
... sender="cfo@acmecorp.com",
|
| 51 |
+
... sender_domain="acmecorp.com",
|
| 52 |
+
... timestamp=datetime(2024, 4, 12, 10, 30),
|
| 53 |
+
... body_preview="Please review the attached Q4 budget...",
|
| 54 |
+
... priority_flag=True,
|
| 55 |
+
... has_attachment=True,
|
| 56 |
+
... is_vip_sender=True,
|
| 57 |
+
... category_hint="finance",
|
| 58 |
+
... )
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
id: int = Field(..., ge=0, description="Unique email identifier")
|
| 62 |
+
subject: str = Field(..., min_length=1, max_length=200)
|
| 63 |
+
sender: str = Field(..., min_length=3)
|
| 64 |
+
sender_domain: str = Field(..., min_length=1)
|
| 65 |
+
timestamp: datetime
|
| 66 |
+
body_preview: str = Field("", max_length=500)
|
| 67 |
+
priority_flag: bool = False
|
| 68 |
+
has_attachment: bool = False
|
| 69 |
+
is_vip_sender: bool = False
|
| 70 |
+
category_hint: Optional[str] = None
|
| 71 |
+
# Ground truth folder — hidden from agent, used by grader
|
| 72 |
+
ground_truth_folder: str = Field(
|
| 73 |
+
"inbox",
|
| 74 |
+
description="Correct folder for grading (not exposed to agent)",
|
| 75 |
+
)
|
| 76 |
+
# Whether the email is ambiguous (valid in multiple folders)
|
| 77 |
+
is_ambiguous: bool = False
|
| 78 |
+
# Alternative acceptable folders for ambiguous emails
|
| 79 |
+
acceptable_folders: List[str] = Field(default_factory=list)
|
| 80 |
+
|
| 81 |
+
model_config = {
|
| 82 |
+
"json_schema_extra": {
|
| 83 |
+
"examples": [
|
| 84 |
+
{
|
| 85 |
+
"id": 1,
|
| 86 |
+
"subject": "Q4 Budget Review",
|
| 87 |
+
"sender": "cfo@acmecorp.com",
|
| 88 |
+
"sender_domain": "acmecorp.com",
|
| 89 |
+
"timestamp": "2024-04-12T10:30:00",
|
| 90 |
+
"body_preview": "Please review the attached Q4 budget...",
|
| 91 |
+
"priority_flag": True,
|
| 92 |
+
"has_attachment": True,
|
| 93 |
+
"is_vip_sender": True,
|
| 94 |
+
"category_hint": "finance",
|
| 95 |
+
}
|
| 96 |
+
]
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
def to_agent_view(self) -> Dict[str, Any]:
|
| 101 |
+
"""Return a dict without ground-truth fields (safe to show the agent)."""
|
| 102 |
+
return self.model_dump(
|
| 103 |
+
exclude={"ground_truth_folder", "is_ambiguous", "acceptable_folders"}
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ── Observation ──────────────────────────────────────────────────────────────
|
| 108 |
+
|
| 109 |
+
class Observation(BaseModel):
|
| 110 |
+
"""What the agent sees at each step.
|
| 111 |
+
|
| 112 |
+
Provides the current inbox state, available folders, and episode metadata.
|
| 113 |
+
|
| 114 |
+
Example:
|
| 115 |
+
>>> obs = Observation(
|
| 116 |
+
... inbox_emails=[email],
|
| 117 |
+
... available_folders=["inbox", "work", "spam"],
|
| 118 |
+
... current_step=0,
|
| 119 |
+
... max_steps=50,
|
| 120 |
+
... episode_num=1,
|
| 121 |
+
... done=False,
|
| 122 |
+
... info={},
|
| 123 |
+
... )
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
+
inbox_emails: List[Email] = Field(
|
| 127 |
+
default_factory=list, description="Emails currently in the inbox"
|
| 128 |
+
)
|
| 129 |
+
available_folders: List[str] = Field(
|
| 130 |
+
default_factory=lambda: list(AVAILABLE_FOLDERS),
|
| 131 |
+
description="Folders the agent can move emails into",
|
| 132 |
+
)
|
| 133 |
+
current_step: int = Field(0, ge=0)
|
| 134 |
+
max_steps: int = Field(MAX_STEPS_PER_EPISODE, ge=1)
|
| 135 |
+
episode_num: int = Field(1, ge=1)
|
| 136 |
+
done: bool = False
|
| 137 |
+
info: Dict[str, Any] = Field(default_factory=dict)
|
| 138 |
+
|
| 139 |
+
def to_agent_view(self) -> Dict[str, Any]:
|
| 140 |
+
"""Serialize for the agent, stripping ground-truth fields from emails."""
|
| 141 |
+
data = self.model_dump()
|
| 142 |
+
data["inbox_emails"] = [e.to_agent_view() for e in self.inbox_emails]
|
| 143 |
+
return data
|
| 144 |
+
|
| 145 |
+
model_config = {
|
| 146 |
+
"json_schema_extra": {
|
| 147 |
+
"examples": [
|
| 148 |
+
{
|
| 149 |
+
"inbox_emails": [],
|
| 150 |
+
"available_folders": AVAILABLE_FOLDERS,
|
| 151 |
+
"current_step": 0,
|
| 152 |
+
"max_steps": 50,
|
| 153 |
+
"episode_num": 1,
|
| 154 |
+
"done": False,
|
| 155 |
+
"info": {},
|
| 156 |
+
}
|
| 157 |
+
]
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ── Action ───────────────────────────────────────────────────────────────────
|
| 163 |
+
|
| 164 |
+
class Action(BaseModel):
|
| 165 |
+
"""An action the agent takes on an email.
|
| 166 |
+
|
| 167 |
+
Supported action types:
|
| 168 |
+
- **move**: Move an email to a target folder (requires target_folder).
|
| 169 |
+
- **delete**: Permanently delete an email.
|
| 170 |
+
- **snooze**: Temporarily hide an email.
|
| 171 |
+
- **flag**: Mark an email as important.
|
| 172 |
+
- **mark_spam**: Move an email to spam.
|
| 173 |
+
|
| 174 |
+
Example:
|
| 175 |
+
>>> action = Action(
|
| 176 |
+
... action_type="move",
|
| 177 |
+
... email_id=1,
|
| 178 |
+
... target_folder="finance",
|
| 179 |
+
... reason="Invoice from vendor",
|
| 180 |
+
... )
|
| 181 |
+
"""
|
| 182 |
+
|
| 183 |
+
action_type: Literal["move", "delete", "snooze", "flag", "mark_spam"] = Field(
|
| 184 |
+
..., description="Type of action to perform"
|
| 185 |
+
)
|
| 186 |
+
email_id: int = Field(..., ge=0, description="ID of the target email")
|
| 187 |
+
target_folder: Optional[str] = Field(
|
| 188 |
+
None, description="Destination folder (required for 'move')"
|
| 189 |
+
)
|
| 190 |
+
reason: Optional[str] = Field(
|
| 191 |
+
None, max_length=300, description="Optional explanation for the action"
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
@model_validator(mode="after")
|
| 195 |
+
def validate_move_has_folder(self) -> "Action":
|
| 196 |
+
if self.action_type == "move" and not self.target_folder:
|
| 197 |
+
raise ValueError("target_folder is required for 'move' action")
|
| 198 |
+
if (
|
| 199 |
+
self.target_folder
|
| 200 |
+
and self.target_folder not in AVAILABLE_FOLDERS
|
| 201 |
+
):
|
| 202 |
+
raise ValueError(
|
| 203 |
+
f"target_folder must be one of {AVAILABLE_FOLDERS}, "
|
| 204 |
+
f"got '{self.target_folder}'"
|
| 205 |
+
)
|
| 206 |
+
return self
|
| 207 |
+
|
| 208 |
+
model_config = {
|
| 209 |
+
"json_schema_extra": {
|
| 210 |
+
"examples": [
|
| 211 |
+
{
|
| 212 |
+
"action_type": "move",
|
| 213 |
+
"email_id": 1,
|
| 214 |
+
"target_folder": "work",
|
| 215 |
+
"reason": "Work-related email from colleague",
|
| 216 |
+
}
|
| 217 |
+
]
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ── Reward ───────────────────────────────────────────────────────────────────
|
| 223 |
+
|
| 224 |
+
class Reward(BaseModel):
|
| 225 |
+
"""Graded feedback returned after each step.
|
| 226 |
+
|
| 227 |
+
The reward value is in [0.0, 1.0] and is composed of multiple weighted
|
| 228 |
+
components so agents receive a rich learning signal.
|
| 229 |
+
|
| 230 |
+
Components:
|
| 231 |
+
- correctness (70%): Was the email placed in the right folder?
|
| 232 |
+
- efficiency (15%): How quickly did the agent act?
|
| 233 |
+
- vip_bonus (10%): Was a VIP email handled correctly?
|
| 234 |
+
- reasoning (5%): Did the agent provide a good explanation?
|
| 235 |
+
|
| 236 |
+
Example:
|
| 237 |
+
>>> reward = Reward(
|
| 238 |
+
... value=0.82,
|
| 239 |
+
... components={"correctness": 0.95, "efficiency": 0.8, "vip_bonus": 0.0},
|
| 240 |
+
... reason="Correct classification of work email",
|
| 241 |
+
... )
|
| 242 |
+
"""
|
| 243 |
+
|
| 244 |
+
value: float = Field(..., ge=0.0, le=1.0, description="Overall reward [0, 1]")
|
| 245 |
+
components: Dict[str, float] = Field(
|
| 246 |
+
default_factory=dict,
|
| 247 |
+
description="Breakdown of reward by component",
|
| 248 |
+
)
|
| 249 |
+
reason: str = Field("", description="Human-readable explanation")
|
| 250 |
+
is_terminal: bool = Field(
|
| 251 |
+
False, description="Whether this reward ends the episode"
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
model_config = {
|
| 255 |
+
"json_schema_extra": {
|
| 256 |
+
"examples": [
|
| 257 |
+
{
|
| 258 |
+
"value": 0.82,
|
| 259 |
+
"components": {
|
| 260 |
+
"correctness": 0.95,
|
| 261 |
+
"efficiency": 0.8,
|
| 262 |
+
"vip_bonus": 0.0,
|
| 263 |
+
"reasoning": 0.0,
|
| 264 |
+
},
|
| 265 |
+
"reason": "Correct classification of work email",
|
| 266 |
+
"is_terminal": False,
|
| 267 |
+
}
|
| 268 |
+
]
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ── StepRecord ────────────────���──────────────────────────────────────────────
|
| 274 |
+
|
| 275 |
+
class StepRecord(BaseModel):
|
| 276 |
+
"""A single step's data, stored for episode history and grading.
|
| 277 |
+
|
| 278 |
+
Captures everything that happened in one step so graders can replay
|
| 279 |
+
the episode deterministically.
|
| 280 |
+
"""
|
| 281 |
+
|
| 282 |
+
step_num: int
|
| 283 |
+
action: Action
|
| 284 |
+
email: Email
|
| 285 |
+
reward: Reward
|
| 286 |
+
done: bool
|
src/reward_shaper.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Multi-component reward shaper for the Email Triage environment.
|
| 3 |
+
|
| 4 |
+
Scoring breakdown:
|
| 5 |
+
70% - Correctness: Is the email in the right folder?
|
| 6 |
+
Correct: 1.0 | Ambiguous-acceptable: 0.5 | Wrong: 0.0
|
| 7 |
+
15% - Efficiency: Did the agent decide quickly?
|
| 8 |
+
Bonus: 1.0 - (current_step / max_steps)
|
| 9 |
+
10% - VIP Handling: Was a VIP email handled correctly?
|
| 10 |
+
VIP correct: 1.0 | VIP wrong: 0.0 | Non-VIP: 0.0 (neutral)
|
| 11 |
+
5% - Reasoning Quality: Did the agent explain its choice?
|
| 12 |
+
Has valid reason: 1.0 | No reason: 0.0
|
| 13 |
+
|
| 14 |
+
Penalties:
|
| 15 |
+
-0.3: Infinite loop (same action on same email repeated 3+ times)
|
| 16 |
+
-0.2: Destructive action (delete without prior flag)
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import logging
|
| 22 |
+
from typing import Dict, List, Optional
|
| 23 |
+
|
| 24 |
+
from src.models import Action, Email, Reward
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
# ── Weights ──────────────────────────────────────────────────────────────────
|
| 29 |
+
|
| 30 |
+
WEIGHT_CORRECTNESS: float = 0.70
|
| 31 |
+
WEIGHT_EFFICIENCY: float = 0.15
|
| 32 |
+
WEIGHT_VIP: float = 0.10
|
| 33 |
+
WEIGHT_REASONING: float = 0.05
|
| 34 |
+
|
| 35 |
+
PENALTY_INFINITE_LOOP: float = 0.30
|
| 36 |
+
PENALTY_DESTRUCTIVE: float = 0.20
|
| 37 |
+
|
| 38 |
+
LOOP_THRESHOLD: int = 3 # same action repeated this many times triggers penalty
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ── Core Reward Computation ──────────────────────────────────────────────────
|
| 42 |
+
|
| 43 |
+
def compute_reward(
|
| 44 |
+
action: Action,
|
| 45 |
+
email: Email,
|
| 46 |
+
current_step: int,
|
| 47 |
+
max_steps: int,
|
| 48 |
+
action_history: List[Action],
|
| 49 |
+
flagged_emails: Optional[set] = None,
|
| 50 |
+
) -> Reward:
|
| 51 |
+
"""Compute a multi-component reward for a single agent action.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
action: The agent's chosen action.
|
| 55 |
+
email: The target email (with ground truth).
|
| 56 |
+
current_step: Current step number in the episode.
|
| 57 |
+
max_steps: Maximum steps allowed.
|
| 58 |
+
action_history: All previous actions in this episode.
|
| 59 |
+
flagged_emails: Set of email IDs that have been flagged.
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Reward with value in [0.0, 1.0] and component breakdown.
|
| 63 |
+
|
| 64 |
+
Example:
|
| 65 |
+
>>> action = Action(action_type="move", email_id=1, target_folder="work")
|
| 66 |
+
>>> reward = compute_reward(action, email, 3, 50, [])
|
| 67 |
+
>>> 0.0 <= reward.value <= 1.0
|
| 68 |
+
True
|
| 69 |
+
"""
|
| 70 |
+
if flagged_emails is None:
|
| 71 |
+
flagged_emails = set()
|
| 72 |
+
|
| 73 |
+
components: Dict[str, float] = {}
|
| 74 |
+
penalties: Dict[str, float] = {}
|
| 75 |
+
reasons: List[str] = []
|
| 76 |
+
|
| 77 |
+
# ── 1. Correctness (70%) ─────────────────────────────────────────────
|
| 78 |
+
correctness = _compute_correctness(action, email)
|
| 79 |
+
components["correctness"] = correctness
|
| 80 |
+
if correctness >= 1.0:
|
| 81 |
+
reasons.append("Correct folder")
|
| 82 |
+
elif correctness >= 0.5:
|
| 83 |
+
reasons.append("Acceptable folder (ambiguous email)")
|
| 84 |
+
else:
|
| 85 |
+
reasons.append(
|
| 86 |
+
f"Wrong folder: expected '{email.ground_truth_folder}', "
|
| 87 |
+
f"got '{action.target_folder or action.action_type}'"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# ── 2. Efficiency (15%) ──────────────────────────────────────────────
|
| 91 |
+
efficiency = _compute_efficiency(current_step, max_steps)
|
| 92 |
+
components["efficiency"] = efficiency
|
| 93 |
+
|
| 94 |
+
# ── 3. VIP Handling (10%) ────────────────────────────────────────────
|
| 95 |
+
vip_bonus = _compute_vip_bonus(action, email)
|
| 96 |
+
components["vip_bonus"] = vip_bonus
|
| 97 |
+
if email.is_vip_sender and vip_bonus > 0:
|
| 98 |
+
reasons.append("VIP email handled correctly")
|
| 99 |
+
elif email.is_vip_sender and vip_bonus == 0:
|
| 100 |
+
reasons.append("VIP email mishandled")
|
| 101 |
+
|
| 102 |
+
# ── 4. Reasoning Quality (5%) ────────────────────────────────────────
|
| 103 |
+
reasoning = _compute_reasoning_quality(action)
|
| 104 |
+
components["reasoning"] = reasoning
|
| 105 |
+
|
| 106 |
+
# ── 5. Penalties ─────────────────────────────────────────────────────
|
| 107 |
+
loop_penalty = _detect_infinite_loop(action, action_history)
|
| 108 |
+
if loop_penalty > 0:
|
| 109 |
+
penalties["infinite_loop"] = loop_penalty
|
| 110 |
+
reasons.append("Penalty: repeated action detected")
|
| 111 |
+
|
| 112 |
+
destructive_penalty = _detect_destructive_action(action, flagged_emails)
|
| 113 |
+
if destructive_penalty > 0:
|
| 114 |
+
penalties["destructive"] = destructive_penalty
|
| 115 |
+
reasons.append("Penalty: destructive action without prior flag")
|
| 116 |
+
|
| 117 |
+
# ── Combine ──────────────────────────────────────────────────────���───
|
| 118 |
+
raw_value = (
|
| 119 |
+
WEIGHT_CORRECTNESS * correctness
|
| 120 |
+
+ WEIGHT_EFFICIENCY * efficiency
|
| 121 |
+
+ WEIGHT_VIP * vip_bonus
|
| 122 |
+
+ WEIGHT_REASONING * reasoning
|
| 123 |
+
)
|
| 124 |
+
total_penalty = sum(penalties.values())
|
| 125 |
+
final_value = max(0.0, min(1.0, raw_value - total_penalty))
|
| 126 |
+
|
| 127 |
+
components["penalties"] = -total_penalty
|
| 128 |
+
|
| 129 |
+
return Reward(
|
| 130 |
+
value=round(final_value, 4),
|
| 131 |
+
components={k: round(v, 4) for k, v in components.items()},
|
| 132 |
+
reason="; ".join(reasons),
|
| 133 |
+
is_terminal=False,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ── Component Functions ──────────────────────────────────────────────────────
|
| 138 |
+
|
| 139 |
+
def _compute_correctness(action: Action, email: Email) -> float:
|
| 140 |
+
"""Score whether the email ended up in the right place.
|
| 141 |
+
|
| 142 |
+
Returns:
|
| 143 |
+
1.0 for correct, 0.5 for ambiguous-acceptable, 0.0 for wrong.
|
| 144 |
+
"""
|
| 145 |
+
if action.action_type == "mark_spam":
|
| 146 |
+
target = "spam"
|
| 147 |
+
elif action.action_type == "delete":
|
| 148 |
+
# Deleting spam is acceptable; deleting non-spam is wrong
|
| 149 |
+
return 1.0 if email.ground_truth_folder == "spam" else 0.0
|
| 150 |
+
elif action.action_type == "flag":
|
| 151 |
+
# Flagging doesn't move the email; partial credit if VIP
|
| 152 |
+
return 0.3 if email.is_vip_sender else 0.1
|
| 153 |
+
elif action.action_type == "snooze":
|
| 154 |
+
# Snoozing is a neutral action
|
| 155 |
+
return 0.2
|
| 156 |
+
elif action.action_type == "move":
|
| 157 |
+
target = action.target_folder
|
| 158 |
+
else:
|
| 159 |
+
return 0.0
|
| 160 |
+
|
| 161 |
+
if target == email.ground_truth_folder:
|
| 162 |
+
return 1.0
|
| 163 |
+
|
| 164 |
+
# Ambiguous email: check if target is among acceptable folders
|
| 165 |
+
if email.is_ambiguous and target in email.acceptable_folders:
|
| 166 |
+
return 0.5
|
| 167 |
+
|
| 168 |
+
return 0.0
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _compute_efficiency(current_step: int, max_steps: int) -> float:
|
| 172 |
+
"""Reward for acting quickly — earlier steps get higher bonus.
|
| 173 |
+
|
| 174 |
+
Returns a value in [0.0, 1.0] that decreases linearly with steps taken.
|
| 175 |
+
"""
|
| 176 |
+
if max_steps <= 0:
|
| 177 |
+
return 0.0
|
| 178 |
+
return max(0.0, 1.0 - (current_step / max_steps))
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _compute_vip_bonus(action: Action, email: Email) -> float:
|
| 182 |
+
"""Bonus for correctly handling VIP emails.
|
| 183 |
+
|
| 184 |
+
VIP emails that are correctly classified get full bonus.
|
| 185 |
+
Non-VIP emails return 0.0 (no penalty, no bonus).
|
| 186 |
+
"""
|
| 187 |
+
if not email.is_vip_sender:
|
| 188 |
+
return 0.0
|
| 189 |
+
|
| 190 |
+
if action.action_type == "move":
|
| 191 |
+
target = action.target_folder
|
| 192 |
+
elif action.action_type == "mark_spam":
|
| 193 |
+
target = "spam"
|
| 194 |
+
elif action.action_type == "flag":
|
| 195 |
+
# Flagging a VIP email is always good
|
| 196 |
+
return 1.0
|
| 197 |
+
else:
|
| 198 |
+
return 0.0
|
| 199 |
+
|
| 200 |
+
if target == email.ground_truth_folder:
|
| 201 |
+
return 1.0
|
| 202 |
+
|
| 203 |
+
if email.is_ambiguous and target in email.acceptable_folders:
|
| 204 |
+
return 0.5
|
| 205 |
+
|
| 206 |
+
return 0.0
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _compute_reasoning_quality(action: Action) -> float:
|
| 210 |
+
"""Bonus for providing a meaningful explanation.
|
| 211 |
+
|
| 212 |
+
A reason with 10+ characters gets full credit; shorter or absent gets 0.
|
| 213 |
+
"""
|
| 214 |
+
if action.reason and len(action.reason.strip()) >= 10:
|
| 215 |
+
return 1.0
|
| 216 |
+
return 0.0
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _detect_infinite_loop(action: Action, history: List[Action]) -> float:
|
| 220 |
+
"""Detect if the agent is repeating the same action on the same email.
|
| 221 |
+
|
| 222 |
+
Returns PENALTY_INFINITE_LOOP if the exact same (action_type, email_id,
|
| 223 |
+
target_folder) appears LOOP_THRESHOLD times in recent history.
|
| 224 |
+
"""
|
| 225 |
+
if len(history) < LOOP_THRESHOLD - 1:
|
| 226 |
+
return 0.0
|
| 227 |
+
|
| 228 |
+
key = (action.action_type, action.email_id, action.target_folder)
|
| 229 |
+
recent = history[-(LOOP_THRESHOLD - 1):]
|
| 230 |
+
repeat_count = sum(
|
| 231 |
+
1 for a in recent
|
| 232 |
+
if (a.action_type, a.email_id, a.target_folder) == key
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
if repeat_count >= LOOP_THRESHOLD - 1:
|
| 236 |
+
logger.warning("Infinite loop detected: action %s repeated %d times", key, repeat_count + 1)
|
| 237 |
+
return PENALTY_INFINITE_LOOP
|
| 238 |
+
|
| 239 |
+
return 0.0
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _detect_destructive_action(
|
| 243 |
+
action: Action,
|
| 244 |
+
flagged_emails: set,
|
| 245 |
+
) -> float:
|
| 246 |
+
"""Penalize deleting an email that was never flagged for review.
|
| 247 |
+
|
| 248 |
+
Agents should flag emails before deleting to show they considered the
|
| 249 |
+
action. Deleting without flagging first incurs a penalty.
|
| 250 |
+
"""
|
| 251 |
+
if action.action_type != "delete":
|
| 252 |
+
return 0.0
|
| 253 |
+
|
| 254 |
+
if action.email_id in flagged_emails:
|
| 255 |
+
return 0.0 # was flagged first — acceptable
|
| 256 |
+
|
| 257 |
+
return PENALTY_DESTRUCTIVE
|
src/utils.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared utilities for the Email Triage environment.
|
| 3 |
+
|
| 4 |
+
Provides logging configuration, constants, and helper functions used
|
| 5 |
+
across multiple modules.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import re
|
| 12 |
+
from typing import Optional, Tuple
|
| 13 |
+
|
| 14 |
+
from src.models import Action
|
| 15 |
+
|
| 16 |
+
# ── Logging ──────────────────────────────────────────────────────────────────
|
| 17 |
+
|
| 18 |
+
LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
| 19 |
+
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def setup_logging(level: int = logging.INFO) -> None:
|
| 23 |
+
"""Configure root logger with consistent format."""
|
| 24 |
+
logging.basicConfig(
|
| 25 |
+
level=level,
|
| 26 |
+
format=LOG_FORMAT,
|
| 27 |
+
datefmt=LOG_DATE_FORMAT,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ── Action Parsing ───────────────────────────────────────────────────────────
|
| 32 |
+
|
| 33 |
+
_MOVE_PATTERN = re.compile(
|
| 34 |
+
r"move\s*\(\s*(\d+)\s*,\s*(['\"]?)(\w+)\2\s*\)", re.IGNORECASE
|
| 35 |
+
)
|
| 36 |
+
_FLAG_PATTERN = re.compile(r"flag\s*\(\s*(\d+)\s*\)", re.IGNORECASE)
|
| 37 |
+
_DELETE_PATTERN = re.compile(r"delete\s*\(\s*(\d+)\s*\)", re.IGNORECASE)
|
| 38 |
+
_SPAM_PATTERN = re.compile(r"mark_spam\s*\(\s*(\d+)\s*\)", re.IGNORECASE)
|
| 39 |
+
_SNOOZE_PATTERN = re.compile(r"snooze\s*\(\s*(\d+)\s*\)", re.IGNORECASE)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def parse_action_string(action_str: str) -> Optional[Action]:
|
| 43 |
+
"""Parse a text action like ``move(3, finance)`` into an Action model.
|
| 44 |
+
|
| 45 |
+
Supports formats:
|
| 46 |
+
- move(id, folder)
|
| 47 |
+
- flag(id)
|
| 48 |
+
- delete(id)
|
| 49 |
+
- mark_spam(id)
|
| 50 |
+
- snooze(id)
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
action_str: Raw text from the LLM.
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
An Action instance, or None if parsing fails.
|
| 57 |
+
"""
|
| 58 |
+
action_str = action_str.strip()
|
| 59 |
+
|
| 60 |
+
m = _MOVE_PATTERN.search(action_str)
|
| 61 |
+
if m:
|
| 62 |
+
return Action(
|
| 63 |
+
action_type="move",
|
| 64 |
+
email_id=int(m.group(1)),
|
| 65 |
+
target_folder=m.group(3).lower(),
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
m = _FLAG_PATTERN.search(action_str)
|
| 69 |
+
if m:
|
| 70 |
+
return Action(action_type="flag", email_id=int(m.group(1)))
|
| 71 |
+
|
| 72 |
+
m = _DELETE_PATTERN.search(action_str)
|
| 73 |
+
if m:
|
| 74 |
+
return Action(action_type="delete", email_id=int(m.group(1)))
|
| 75 |
+
|
| 76 |
+
m = _SPAM_PATTERN.search(action_str)
|
| 77 |
+
if m:
|
| 78 |
+
return Action(action_type="mark_spam", email_id=int(m.group(1)))
|
| 79 |
+
|
| 80 |
+
m = _SNOOZE_PATTERN.search(action_str)
|
| 81 |
+
if m:
|
| 82 |
+
return Action(action_type="snooze", email_id=int(m.group(1)))
|
| 83 |
+
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def format_action_for_log(action: Action) -> str:
|
| 88 |
+
"""Format an action into a compact log string."""
|
| 89 |
+
if action.action_type == "move":
|
| 90 |
+
return f"move({action.email_id}, {action.target_folder})"
|
| 91 |
+
return f"{action.action_type}({action.email_id})"
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_environment.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit and integration tests for the Email Triage environment.
|
| 3 |
+
|
| 4 |
+
Tests cover: reset/step/state interface, reward computation, grader
|
| 5 |
+
determinism, episode termination, and end-to-end workflow.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from src.environment import EmailTriageEnv, TASK_CONFIG
|
| 11 |
+
from src.models import Action, Email, Observation, Reward, AVAILABLE_FOLDERS
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# ── Reset Tests ──────────────────────────────────────────────────────────────
|
| 15 |
+
|
| 16 |
+
class TestReset:
|
| 17 |
+
def test_reset_returns_observation(self):
|
| 18 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 19 |
+
obs = env.reset()
|
| 20 |
+
assert isinstance(obs, Observation)
|
| 21 |
+
assert not obs.done
|
| 22 |
+
assert obs.current_step == 0
|
| 23 |
+
|
| 24 |
+
def test_reset_populates_inbox(self):
|
| 25 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 26 |
+
obs = env.reset()
|
| 27 |
+
assert len(obs.inbox_emails) == 5
|
| 28 |
+
|
| 29 |
+
def test_reset_medium_task(self):
|
| 30 |
+
env = EmailTriageEnv(task_id="multi_folder_triage", seed=42)
|
| 31 |
+
obs = env.reset()
|
| 32 |
+
assert len(obs.inbox_emails) == 15
|
| 33 |
+
|
| 34 |
+
def test_reset_hard_task(self):
|
| 35 |
+
env = EmailTriageEnv(task_id="advanced_triage_with_urgency", seed=42)
|
| 36 |
+
obs = env.reset()
|
| 37 |
+
assert len(obs.inbox_emails) == 30
|
| 38 |
+
|
| 39 |
+
def test_reset_deterministic(self):
|
| 40 |
+
"""Same seed produces identical emails."""
|
| 41 |
+
env1 = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 42 |
+
obs1 = env1.reset()
|
| 43 |
+
|
| 44 |
+
env2 = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 45 |
+
obs2 = env2.reset()
|
| 46 |
+
|
| 47 |
+
for e1, e2 in zip(obs1.inbox_emails, obs2.inbox_emails):
|
| 48 |
+
assert e1.subject == e2.subject
|
| 49 |
+
assert e1.sender == e2.sender
|
| 50 |
+
assert e1.ground_truth_folder == e2.ground_truth_folder
|
| 51 |
+
|
| 52 |
+
def test_reset_different_seeds(self):
|
| 53 |
+
"""Different seeds produce different emails."""
|
| 54 |
+
env1 = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 55 |
+
obs1 = env1.reset()
|
| 56 |
+
|
| 57 |
+
env2 = EmailTriageEnv(task_id="basic_triage", seed=99)
|
| 58 |
+
obs2 = env2.reset()
|
| 59 |
+
|
| 60 |
+
subjects1 = {e.subject for e in obs1.inbox_emails}
|
| 61 |
+
subjects2 = {e.subject for e in obs2.inbox_emails}
|
| 62 |
+
# Very unlikely to be identical with different seeds
|
| 63 |
+
assert subjects1 != subjects2
|
| 64 |
+
|
| 65 |
+
def test_reset_clears_state(self):
|
| 66 |
+
"""Reset clears previous episode state."""
|
| 67 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 68 |
+
obs = env.reset()
|
| 69 |
+
action = Action(action_type="move", email_id=0, target_folder="work")
|
| 70 |
+
env.step(action)
|
| 71 |
+
|
| 72 |
+
obs2 = env.reset()
|
| 73 |
+
assert obs2.current_step == 0
|
| 74 |
+
assert not obs2.done
|
| 75 |
+
assert len(env.history) == 0
|
| 76 |
+
|
| 77 |
+
def test_invalid_task_id(self):
|
| 78 |
+
with pytest.raises(ValueError, match="Unknown task_id"):
|
| 79 |
+
EmailTriageEnv(task_id="nonexistent_task")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ── Step Tests ───────────────────────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
+
class TestStep:
|
| 85 |
+
def test_step_returns_tuple(self):
|
| 86 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 87 |
+
obs = env.reset()
|
| 88 |
+
email_id = obs.inbox_emails[0].id
|
| 89 |
+
action = Action(action_type="move", email_id=email_id, target_folder="work")
|
| 90 |
+
|
| 91 |
+
result = env.step(action)
|
| 92 |
+
assert len(result) == 4
|
| 93 |
+
obs, reward, done, info = result
|
| 94 |
+
assert isinstance(obs, Observation)
|
| 95 |
+
assert isinstance(reward, Reward)
|
| 96 |
+
assert isinstance(done, bool)
|
| 97 |
+
assert isinstance(info, dict)
|
| 98 |
+
|
| 99 |
+
def test_step_reward_in_range(self):
|
| 100 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 101 |
+
obs = env.reset()
|
| 102 |
+
action = Action(
|
| 103 |
+
action_type="move",
|
| 104 |
+
email_id=obs.inbox_emails[0].id,
|
| 105 |
+
target_folder="work",
|
| 106 |
+
)
|
| 107 |
+
_, reward, _, _ = env.step(action)
|
| 108 |
+
assert 0.0 <= reward.value <= 1.0
|
| 109 |
+
|
| 110 |
+
def test_step_removes_email_from_inbox(self):
|
| 111 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 112 |
+
obs = env.reset()
|
| 113 |
+
initial_count = len(obs.inbox_emails)
|
| 114 |
+
action = Action(
|
| 115 |
+
action_type="move",
|
| 116 |
+
email_id=obs.inbox_emails[0].id,
|
| 117 |
+
target_folder="work",
|
| 118 |
+
)
|
| 119 |
+
obs, _, _, _ = env.step(action)
|
| 120 |
+
assert len(obs.inbox_emails) == initial_count - 1
|
| 121 |
+
|
| 122 |
+
def test_step_increments_counter(self):
|
| 123 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 124 |
+
obs = env.reset()
|
| 125 |
+
assert obs.current_step == 0
|
| 126 |
+
|
| 127 |
+
action = Action(
|
| 128 |
+
action_type="move",
|
| 129 |
+
email_id=obs.inbox_emails[0].id,
|
| 130 |
+
target_folder="work",
|
| 131 |
+
)
|
| 132 |
+
obs, _, _, _ = env.step(action)
|
| 133 |
+
assert obs.current_step == 1
|
| 134 |
+
|
| 135 |
+
def test_step_invalid_email_id(self):
|
| 136 |
+
"""Step with invalid email_id returns zero reward."""
|
| 137 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 138 |
+
env.reset()
|
| 139 |
+
action = Action(action_type="move", email_id=999, target_folder="work")
|
| 140 |
+
obs, reward, done, info = env.step(action)
|
| 141 |
+
assert reward.value == 0.0
|
| 142 |
+
assert "error" in info
|
| 143 |
+
|
| 144 |
+
def test_step_after_done_raises(self):
|
| 145 |
+
"""Stepping after done raises RuntimeError."""
|
| 146 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 147 |
+
obs = env.reset()
|
| 148 |
+
|
| 149 |
+
# Process all emails
|
| 150 |
+
while not obs.done:
|
| 151 |
+
if not obs.inbox_emails:
|
| 152 |
+
break
|
| 153 |
+
action = Action(
|
| 154 |
+
action_type="move",
|
| 155 |
+
email_id=obs.inbox_emails[0].id,
|
| 156 |
+
target_folder="work",
|
| 157 |
+
)
|
| 158 |
+
obs, _, _, _ = env.step(action)
|
| 159 |
+
|
| 160 |
+
assert obs.done
|
| 161 |
+
with pytest.raises(RuntimeError, match="Episode is done"):
|
| 162 |
+
env.step(Action(action_type="move", email_id=0, target_folder="work"))
|
| 163 |
+
|
| 164 |
+
def test_flag_does_not_remove(self):
|
| 165 |
+
"""Flagging an email should NOT remove it from inbox."""
|
| 166 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 167 |
+
obs = env.reset()
|
| 168 |
+
count_before = len(obs.inbox_emails)
|
| 169 |
+
action = Action(action_type="flag", email_id=obs.inbox_emails[0].id)
|
| 170 |
+
obs, _, _, _ = env.step(action)
|
| 171 |
+
assert len(obs.inbox_emails) == count_before
|
| 172 |
+
|
| 173 |
+
def test_mark_spam_removes_email(self):
|
| 174 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 175 |
+
obs = env.reset()
|
| 176 |
+
count_before = len(obs.inbox_emails)
|
| 177 |
+
action = Action(action_type="mark_spam", email_id=obs.inbox_emails[0].id)
|
| 178 |
+
obs, _, _, _ = env.step(action)
|
| 179 |
+
assert len(obs.inbox_emails) == count_before - 1
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ── State Tests ──────────────────────────────────────────────────────────────
|
| 183 |
+
|
| 184 |
+
class TestState:
|
| 185 |
+
def test_state_matches_observation(self):
|
| 186 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 187 |
+
obs = env.reset()
|
| 188 |
+
state = env.state()
|
| 189 |
+
assert state.current_step == obs.current_step
|
| 190 |
+
assert len(state.inbox_emails) == len(obs.inbox_emails)
|
| 191 |
+
|
| 192 |
+
def test_state_does_not_advance(self):
|
| 193 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 194 |
+
env.reset()
|
| 195 |
+
s1 = env.state()
|
| 196 |
+
s2 = env.state()
|
| 197 |
+
assert s1.current_step == s2.current_step
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ── Episode Termination ─────────────────────────────────────────────────────
|
| 201 |
+
|
| 202 |
+
class TestTermination:
|
| 203 |
+
def test_done_when_inbox_empty(self):
|
| 204 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 205 |
+
obs = env.reset()
|
| 206 |
+
|
| 207 |
+
while obs.inbox_emails:
|
| 208 |
+
action = Action(
|
| 209 |
+
action_type="move",
|
| 210 |
+
email_id=obs.inbox_emails[0].id,
|
| 211 |
+
target_folder="work",
|
| 212 |
+
)
|
| 213 |
+
obs, _, done, _ = env.step(action)
|
| 214 |
+
if done:
|
| 215 |
+
break
|
| 216 |
+
|
| 217 |
+
assert obs.done
|
| 218 |
+
|
| 219 |
+
def test_done_at_max_steps(self):
|
| 220 |
+
"""Episode ends at max_steps even if inbox has emails."""
|
| 221 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 222 |
+
obs = env.reset()
|
| 223 |
+
|
| 224 |
+
# Flag repeatedly (doesn't remove emails) until max steps
|
| 225 |
+
for _ in range(env.max_steps):
|
| 226 |
+
if obs.done:
|
| 227 |
+
break
|
| 228 |
+
action = Action(action_type="flag", email_id=obs.inbox_emails[0].id)
|
| 229 |
+
obs, _, _, _ = env.step(action)
|
| 230 |
+
|
| 231 |
+
assert obs.done
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# ── Grader Determinism ───────────────────────────────────────────────────────
|
| 235 |
+
|
| 236 |
+
class TestGraderDeterminism:
|
| 237 |
+
def _run_episode(self, task_id: str, seed: int) -> float:
|
| 238 |
+
"""Run a full episode and return the grader score."""
|
| 239 |
+
from src.grader import grade_task_basic, grade_task_medium, grade_task_hard
|
| 240 |
+
|
| 241 |
+
graders = {
|
| 242 |
+
"basic_triage": grade_task_basic,
|
| 243 |
+
"multi_folder_triage": grade_task_medium,
|
| 244 |
+
"advanced_triage_with_urgency": grade_task_hard,
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
env = EmailTriageEnv(task_id=task_id, seed=seed)
|
| 248 |
+
obs = env.reset()
|
| 249 |
+
|
| 250 |
+
while not obs.done and obs.inbox_emails:
|
| 251 |
+
email = obs.inbox_emails[0]
|
| 252 |
+
# Use ground truth for a "perfect" agent
|
| 253 |
+
action = Action(
|
| 254 |
+
action_type="move",
|
| 255 |
+
email_id=email.id,
|
| 256 |
+
target_folder=email.ground_truth_folder,
|
| 257 |
+
reason="Ground truth classification",
|
| 258 |
+
)
|
| 259 |
+
obs, _, _, _ = env.step(action)
|
| 260 |
+
|
| 261 |
+
return graders[task_id](env.history)
|
| 262 |
+
|
| 263 |
+
def test_basic_grader_deterministic(self):
|
| 264 |
+
s1 = self._run_episode("basic_triage", seed=42)
|
| 265 |
+
s2 = self._run_episode("basic_triage", seed=42)
|
| 266 |
+
assert s1 == s2
|
| 267 |
+
|
| 268 |
+
def test_medium_grader_deterministic(self):
|
| 269 |
+
s1 = self._run_episode("multi_folder_triage", seed=42)
|
| 270 |
+
s2 = self._run_episode("multi_folder_triage", seed=42)
|
| 271 |
+
assert s1 == s2
|
| 272 |
+
|
| 273 |
+
def test_hard_grader_deterministic(self):
|
| 274 |
+
s1 = self._run_episode("advanced_triage_with_urgency", seed=42)
|
| 275 |
+
s2 = self._run_episode("advanced_triage_with_urgency", seed=42)
|
| 276 |
+
assert s1 == s2
|
| 277 |
+
|
| 278 |
+
def test_perfect_agent_scores_high(self):
|
| 279 |
+
"""An agent using ground truth should score near 1.0."""
|
| 280 |
+
score = self._run_episode("basic_triage", seed=42)
|
| 281 |
+
assert score >= 0.8, f"Perfect agent scored {score}, expected >= 0.8"
|
| 282 |
+
|
| 283 |
+
def test_grader_returns_float_in_range(self):
|
| 284 |
+
for task_id in TASK_CONFIG:
|
| 285 |
+
score = self._run_episode(task_id, seed=42)
|
| 286 |
+
assert isinstance(score, float)
|
| 287 |
+
assert 0.0 <= score <= 1.0, f"{task_id}: score {score} out of range"
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# ── Integration Test ─────────────────────────────────────────────────────────
|
| 291 |
+
|
| 292 |
+
class TestIntegration:
|
| 293 |
+
def test_full_episode_workflow(self):
|
| 294 |
+
"""End-to-end: reset, step through all emails, check grading."""
|
| 295 |
+
env = EmailTriageEnv(task_id="basic_triage", seed=42)
|
| 296 |
+
obs = env.reset()
|
| 297 |
+
|
| 298 |
+
rewards = []
|
| 299 |
+
while not obs.done and obs.inbox_emails:
|
| 300 |
+
email = obs.inbox_emails[0]
|
| 301 |
+
action = Action(
|
| 302 |
+
action_type="move",
|
| 303 |
+
email_id=email.id,
|
| 304 |
+
target_folder=email.ground_truth_folder,
|
| 305 |
+
)
|
| 306 |
+
obs, reward, done, info = env.step(action)
|
| 307 |
+
rewards.append(reward.value)
|
| 308 |
+
|
| 309 |
+
assert obs.done
|
| 310 |
+
assert len(rewards) == 5
|
| 311 |
+
assert all(0.0 <= r <= 1.0 for r in rewards)
|
| 312 |
+
assert len(env.history) == 5
|
| 313 |
+
|
| 314 |
+
def test_all_tasks_runnable(self):
|
| 315 |
+
"""All 3 tasks can be reset and stepped without errors."""
|
| 316 |
+
for task_id in TASK_CONFIG:
|
| 317 |
+
env = EmailTriageEnv(task_id=task_id, seed=42)
|
| 318 |
+
obs = env.reset()
|
| 319 |
+
assert not obs.done
|
| 320 |
+
assert len(obs.inbox_emails) > 0
|
| 321 |
+
|
| 322 |
+
action = Action(
|
| 323 |
+
action_type="move",
|
| 324 |
+
email_id=obs.inbox_emails[0].id,
|
| 325 |
+
target_folder="work",
|
| 326 |
+
)
|
| 327 |
+
obs, reward, done, info = env.step(action)
|
| 328 |
+
assert reward.value >= 0.0
|
tests/test_graders.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for grader scoring logic and difficulty progression.
|
| 3 |
+
|
| 4 |
+
Validates that:
|
| 5 |
+
- Each grader returns values in [0.0, 1.0]
|
| 6 |
+
- Graders are deterministic
|
| 7 |
+
- Difficulty progression: easy > medium > hard for random agent
|
| 8 |
+
- Perfect agent scores high on all tasks
|
| 9 |
+
- Random agent scores low on hard tasks
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import random
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from src.environment import EmailTriageEnv, TASK_CONFIG
|
| 17 |
+
from src.grader import grade_task_basic, grade_task_medium, grade_task_hard
|
| 18 |
+
from src.models import Action
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
GRADERS = {
|
| 22 |
+
"basic_triage": grade_task_basic,
|
| 23 |
+
"multi_folder_triage": grade_task_medium,
|
| 24 |
+
"advanced_triage_with_urgency": grade_task_hard,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
FOLDERS = ["work", "finance", "meetings", "spam", "archive"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _run_perfect_episode(task_id: str, seed: int = 42) -> float:
|
| 31 |
+
"""Run an episode with a perfect agent (uses ground truth)."""
|
| 32 |
+
env = EmailTriageEnv(task_id=task_id, seed=seed)
|
| 33 |
+
obs = env.reset()
|
| 34 |
+
while not obs.done and obs.inbox_emails:
|
| 35 |
+
email = obs.inbox_emails[0]
|
| 36 |
+
action = Action(
|
| 37 |
+
action_type="move",
|
| 38 |
+
email_id=email.id,
|
| 39 |
+
target_folder=email.ground_truth_folder,
|
| 40 |
+
reason="Ground truth perfect agent",
|
| 41 |
+
)
|
| 42 |
+
obs, _, _, _ = env.step(action)
|
| 43 |
+
return GRADERS[task_id](env.history)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _run_random_episode(task_id: str, seed: int = 42) -> float:
|
| 47 |
+
"""Run an episode with a random agent."""
|
| 48 |
+
rng = random.Random(seed)
|
| 49 |
+
env = EmailTriageEnv(task_id=task_id, seed=seed)
|
| 50 |
+
obs = env.reset()
|
| 51 |
+
while not obs.done and obs.inbox_emails:
|
| 52 |
+
email = obs.inbox_emails[0]
|
| 53 |
+
folder = rng.choice(FOLDERS)
|
| 54 |
+
action = Action(
|
| 55 |
+
action_type="move",
|
| 56 |
+
email_id=email.id,
|
| 57 |
+
target_folder=folder,
|
| 58 |
+
)
|
| 59 |
+
obs, _, _, _ = env.step(action)
|
| 60 |
+
return GRADERS[task_id](env.history)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TestGraderOutputs:
|
| 64 |
+
@pytest.mark.parametrize("task_id", list(TASK_CONFIG.keys()))
|
| 65 |
+
def test_grader_returns_float(self, task_id):
|
| 66 |
+
score = _run_perfect_episode(task_id)
|
| 67 |
+
assert isinstance(score, float)
|
| 68 |
+
|
| 69 |
+
@pytest.mark.parametrize("task_id", list(TASK_CONFIG.keys()))
|
| 70 |
+
def test_grader_in_range(self, task_id):
|
| 71 |
+
score = _run_perfect_episode(task_id)
|
| 72 |
+
assert 0.0 <= score <= 1.0
|
| 73 |
+
|
| 74 |
+
def test_empty_history_returns_zero(self):
|
| 75 |
+
assert grade_task_basic([]) == 0.0
|
| 76 |
+
assert grade_task_medium([]) == 0.0
|
| 77 |
+
assert grade_task_hard([]) == 0.0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class TestPerfectAgent:
|
| 81 |
+
def test_basic_perfect_score(self):
|
| 82 |
+
score = _run_perfect_episode("basic_triage")
|
| 83 |
+
assert score >= 0.8, f"Perfect basic score: {score}"
|
| 84 |
+
|
| 85 |
+
def test_medium_perfect_score(self):
|
| 86 |
+
score = _run_perfect_episode("multi_folder_triage")
|
| 87 |
+
assert score >= 0.7, f"Perfect medium score: {score}"
|
| 88 |
+
|
| 89 |
+
def test_hard_perfect_score(self):
|
| 90 |
+
score = _run_perfect_episode("advanced_triage_with_urgency")
|
| 91 |
+
assert score >= 0.5, f"Perfect hard score: {score}"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class TestDifficultyProgression:
|
| 95 |
+
def test_random_agent_easy_vs_hard(self):
|
| 96 |
+
"""Random agent should score higher on easy than hard."""
|
| 97 |
+
easy_scores = [_run_random_episode("basic_triage", seed=s) for s in range(5)]
|
| 98 |
+
hard_scores = [
|
| 99 |
+
_run_random_episode("advanced_triage_with_urgency", seed=s)
|
| 100 |
+
for s in range(5)
|
| 101 |
+
]
|
| 102 |
+
avg_easy = sum(easy_scores) / len(easy_scores)
|
| 103 |
+
avg_hard = sum(hard_scores) / len(hard_scores)
|
| 104 |
+
# Easy should generally be higher (or at least not lower)
|
| 105 |
+
# Due to randomness and thresholds, hard is much more likely to be 0
|
| 106 |
+
assert avg_easy >= avg_hard or avg_hard < 0.5
|
| 107 |
+
|
| 108 |
+
def test_hard_task_random_under_90(self):
|
| 109 |
+
"""Random agent should NOT score >0.90 on hard task."""
|
| 110 |
+
for seed in range(10):
|
| 111 |
+
score = _run_random_episode("advanced_triage_with_urgency", seed=seed)
|
| 112 |
+
assert score < 0.90, f"Random agent scored {score} on hard (seed={seed})"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class TestGraderDeterminism:
|
| 116 |
+
@pytest.mark.parametrize("task_id", list(TASK_CONFIG.keys()))
|
| 117 |
+
def test_same_input_same_output(self, task_id):
|
| 118 |
+
s1 = _run_perfect_episode(task_id, seed=42)
|
| 119 |
+
s2 = _run_perfect_episode(task_id, seed=42)
|
| 120 |
+
assert s1 == s2, f"{task_id}: {s1} != {s2}"
|