Janesh commited on
Commit
73608eb
·
verified ·
1 Parent(s): 11b0902

Upload folder using huggingface_hub

Browse files
.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
- title: Email Triage Env
3
- emoji: 🐢
4
- colorFrom: indigo
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}"