Spaces:
Sleeping
Sleeping
Dev Shah commited on
Commit ·
96a5caf
0
Parent(s):
feat: initial commit for email triage agent
Browse files- .gitignore +4 -0
- Dockerfile +21 -0
- README.md +298 -0
- curate_dataset.py +362 -0
- curate_out.txt +0 -0
- data/emails.json +205 -0
- environment.py +348 -0
- inference.py +259 -0
- openenv.yaml +66 -0
- requirements.txt +4 -0
- server.py +81 -0
- test_out.txt +0 -0
- tests.py +207 -0
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Required label for Hugging Face Spaces + OpenEnv discovery
|
| 4 |
+
LABEL org.opencontainers.image.title="email-triage-env"
|
| 5 |
+
LABEL org.opencontainers.image.description="Email Triage & Response Environment for OpenEnv"
|
| 6 |
+
LABEL hf_space="openenv"
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
# Install dependencies
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy source
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Expose env server port
|
| 18 |
+
EXPOSE 8000
|
| 19 |
+
|
| 20 |
+
# Default: run the environment server
|
| 21 |
+
CMD ["python", "server.py"]
|
README.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Email Triage & Response Environment
|
| 2 |
+
|
| 3 |
+
An OpenEnv-compatible RL environment where an AI agent manages a realistic email inbox: reading messages, prioritising them, drafting replies, archiving junk, and flagging ambiguous items for human review.
|
| 4 |
+
|
| 5 |
+
Built for the **OpenEnv RL Challenge** hackathon.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Motivation
|
| 10 |
+
|
| 11 |
+
Email triage is a real-world task that millions of knowledge workers do daily. It requires reading comprehension, priority assessment, professional writing, and judgment about what's spam vs. legitimate vs. ambiguous. This makes it an ideal testbed for evaluating LLM agent capabilities in a structured, scoreable way.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## Project Structure
|
| 16 |
+
|
| 17 |
+
```
|
| 18 |
+
email-triage-env/
|
| 19 |
+
├── inference.py # LLM-powered agent (Groq via OpenAI client)
|
| 20 |
+
├── environment.py # Core env: email data, action handling, graders
|
| 21 |
+
├── server.py # FastAPI HTTP server (OpenEnv /reset, /step, /state, /score)
|
| 22 |
+
├── tests.py # Unit test suite (python tests.py)
|
| 23 |
+
├── openenv.yaml # OpenEnv task & resource manifest
|
| 24 |
+
├── .env # API keys (not committed to git)
|
| 25 |
+
├── .gitignore
|
| 26 |
+
├── requirements.txt
|
| 27 |
+
├── Dockerfile
|
| 28 |
+
└── README.md
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## How It Works
|
| 34 |
+
|
| 35 |
+
The agent runs a standard RL loop against the environment:
|
| 36 |
+
|
| 37 |
+
```
|
| 38 |
+
┌──────────────┐
|
| 39 |
+
│ LLM Agent │
|
| 40 |
+
│ (inference) │
|
| 41 |
+
└──────┬───────┘
|
| 42 |
+
│ JSON Action
|
| 43 |
+
▼
|
| 44 |
+
┌──────────────┐
|
| 45 |
+
│ Environment │ ← reset() / step() / state() / score()
|
| 46 |
+
│ (email inbox)│
|
| 47 |
+
└──────┬───────┘
|
| 48 |
+
│ Observation + Reward
|
| 49 |
+
▼
|
| 50 |
+
Back to Agent
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
1. `reset()` → loads the inbox, returns initial observation
|
| 54 |
+
2. Agent decides an action (list, read, label, reply, archive, flag)
|
| 55 |
+
3. `step(action)` → executes it, returns observation + reward
|
| 56 |
+
4. Repeat until the agent signals `done`
|
| 57 |
+
5. `score()` → returns final grade (0.0 – 1.0)
|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
## Action Space
|
| 62 |
+
|
| 63 |
+
Every action is a JSON object with this schema:
|
| 64 |
+
|
| 65 |
+
```json
|
| 66 |
+
{
|
| 67 |
+
"action": "<action_name>",
|
| 68 |
+
"email_id": "<string or null>",
|
| 69 |
+
"priority": "<urgent|normal|low or null>",
|
| 70 |
+
"body": "<reply text or null>",
|
| 71 |
+
"reason": "<flag reason or null>"
|
| 72 |
+
}
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
| Action | Required Fields | Description |
|
| 76 |
+
|--------|----------------|-------------|
|
| 77 |
+
| `list_inbox` | — | List all emails with metadata (id, from, subject, labels) |
|
| 78 |
+
| `read` | `email_id` | Read the full body of a specific email |
|
| 79 |
+
| `label` | `email_id`, `priority` | Assign priority: `urgent`, `normal`, or `low` |
|
| 80 |
+
| `draft_reply` | `email_id`, `body` | Write and send a reply (must be >10 chars) |
|
| 81 |
+
| `archive` | `email_id` | Move email to archive (penalised if email is urgent) |
|
| 82 |
+
| `flag` | `email_id`, `reason` | Escalate for human review with a reason |
|
| 83 |
+
|
| 84 |
+
## Observation Space
|
| 85 |
+
|
| 86 |
+
Every step returns an observation with this schema:
|
| 87 |
+
|
| 88 |
+
```json
|
| 89 |
+
{
|
| 90 |
+
"status": "ok | error | warning | done",
|
| 91 |
+
"message": "Human-readable description of what happened",
|
| 92 |
+
"data": { ... },
|
| 93 |
+
"step_count": 5
|
| 94 |
+
}
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
| Field | Type | Description |
|
| 98 |
+
|-------|------|-------------|
|
| 99 |
+
| `status` | string | `ok` (success), `error` (invalid action), `warning` (penalised action), `done` |
|
| 100 |
+
| `message` | string | Human-readable result of the action |
|
| 101 |
+
| `data` | dict or null | Structured data (email list, email body, label confirmation, etc.) |
|
| 102 |
+
| `step_count` | int | Current step number in the episode |
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## Tasks
|
| 107 |
+
|
| 108 |
+
| # | Name | Difficulty | Emails | Max Steps | Description |
|
| 109 |
+
|---|------|-----------|--------|-----------|-------------|
|
| 110 |
+
| 1 | **Inbox Prioritisation** | Easy | 5 | 20 | Label each email as `urgent`, `normal`, or `low` |
|
| 111 |
+
| 2 | **Draft a Reply** | Medium | 1 | 10 | Reply to an angry customer complaint professionally |
|
| 112 |
+
| 3 | **Full Triage Pipeline** | Hard | 10 | 60 | Label all, reply to urgent, archive spam, flag ambiguous |
|
| 113 |
+
|
| 114 |
+
### Scoring (0.0 – 1.0)
|
| 115 |
+
|
| 116 |
+
```
|
| 117 |
+
Task 1 (Incremental):
|
| 118 |
+
+0.2 per correct label (5 emails × 0.2 = max 1.0)
|
| 119 |
+
|
| 120 |
+
Task 2 (Checklist):
|
| 121 |
+
+0.3 addresses all issues raised by customer
|
| 122 |
+
+0.3 professional tone (formal language, empathy)
|
| 123 |
+
+0.2 reply length & formatting (>50 chars)
|
| 124 |
+
+0.2 no fabricated facts (no invented tracking numbers, dates, amounts)
|
| 125 |
+
|
| 126 |
+
Task 3 (Holistic):
|
| 127 |
+
+0.50 correct priority labels (10 emails, normalised)
|
| 128 |
+
+0.40 replies drafted for urgent emails (4 urgent emails)
|
| 129 |
+
+0.10 archive spam + flag ambiguous
|
| 130 |
+
-0.10 penalty per destructive action (e.g. archiving an urgent email)
|
| 131 |
+
-0.05 penalty per looping/repeated action
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
All graders are **deterministic** — same actions always produce the same score.
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## Quick Start
|
| 139 |
+
|
| 140 |
+
### 1. Install dependencies
|
| 141 |
+
|
| 142 |
+
```bash
|
| 143 |
+
pip install -r requirements.txt
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
### 2. Set up environment variables
|
| 147 |
+
|
| 148 |
+
Create a `.env` file in the project root:
|
| 149 |
+
|
| 150 |
+
```env
|
| 151 |
+
API_BASE_URL=https://api.groq.com/openai/v1
|
| 152 |
+
MODEL_NAME=llama-3.3-70b-versatile
|
| 153 |
+
HF_TOKEN=your_groq_api_key_here
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
Get a free Groq API key at: [console.groq.com/keys](https://console.groq.com/keys)
|
| 157 |
+
|
| 158 |
+
### 3. Run the agent
|
| 159 |
+
|
| 160 |
+
```bash
|
| 161 |
+
# Set your API key (Linux/Mac)
|
| 162 |
+
export HF_TOKEN=gsk_your_key_here
|
| 163 |
+
|
| 164 |
+
# Set your API key (Windows PowerShell)
|
| 165 |
+
$env:HF_TOKEN="gsk_your_key_here"
|
| 166 |
+
|
| 167 |
+
# Run individual tasks
|
| 168 |
+
python inference.py --task 1 # easy
|
| 169 |
+
python inference.py --task 2 # medium
|
| 170 |
+
python inference.py --task 3 # hard
|
| 171 |
+
|
| 172 |
+
# Run all tasks and get aggregate scores
|
| 173 |
+
python inference.py --all
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
### 4. Run the tests
|
| 177 |
+
|
| 178 |
+
```bash
|
| 179 |
+
python tests.py
|
| 180 |
+
# Expected: 17/17 tests passed
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
### 5. Run the HTTP server
|
| 184 |
+
|
| 185 |
+
```bash
|
| 186 |
+
python server.py
|
| 187 |
+
# Listens on http://localhost:8000
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
Interact via HTTP:
|
| 191 |
+
|
| 192 |
+
```bash
|
| 193 |
+
# Reset task 1
|
| 194 |
+
curl -X POST http://localhost:8000/reset -H "Content-Type: application/json" \
|
| 195 |
+
-d '{"task": 1}'
|
| 196 |
+
|
| 197 |
+
# Take a step
|
| 198 |
+
curl -X POST http://localhost:8000/step -H "Content-Type: application/json" \
|
| 199 |
+
-d '{"task": 1, "action": {"action": "list_inbox"}}'
|
| 200 |
+
|
| 201 |
+
# Get current score
|
| 202 |
+
curl http://localhost:8000/score?task=1
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
### 6. Docker
|
| 206 |
+
|
| 207 |
+
```bash
|
| 208 |
+
docker build -t email-triage-env .
|
| 209 |
+
docker run -p 8000:8000 email-triage-env
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
---
|
| 213 |
+
|
| 214 |
+
## Environment Variables
|
| 215 |
+
|
| 216 |
+
| Variable | Required | Default | Description |
|
| 217 |
+
|----------|----------|---------|-------------|
|
| 218 |
+
| `HF_TOKEN` | **Yes** | — | API key for the LLM provider (Groq key) |
|
| 219 |
+
| `API_BASE_URL` | No | `https://api.groq.com/openai/v1` | OpenAI-compatible API endpoint |
|
| 220 |
+
| `MODEL_NAME` | No | `llama-3.3-70b-versatile` | Model to use for inference |
|
| 221 |
+
|
| 222 |
+
The hackathon runner injects `HF_TOKEN` automatically. `API_BASE_URL` and `MODEL_NAME` have sensible defaults.
|
| 223 |
+
|
| 224 |
+
---
|
| 225 |
+
|
| 226 |
+
## Baseline Scores
|
| 227 |
+
|
| 228 |
+
Scores from the baseline `inference.py` agent using **Llama 3.3 70B** on Groq:
|
| 229 |
+
|
| 230 |
+
| Task | Score | Steps Used | Notes |
|
| 231 |
+
|------|-------|------------|-------|
|
| 232 |
+
| 1 — Inbox Prioritisation | **1.00** | ~11 | All 5 labels correct |
|
| 233 |
+
| 2 — Draft a Reply | **0.90** | ~4 | Professional, addresses all issues |
|
| 234 |
+
| 3 — Full Triage Pipeline | **0.85** | ~35 | Labels + replies + archive + flag |
|
| 235 |
+
|
| 236 |
+
> These are representative scores. Actual scores may vary slightly due to LLM non-determinism at temperature 0.2.
|
| 237 |
+
|
| 238 |
+
---
|
| 239 |
+
|
| 240 |
+
## How This Would Work With Real Emails
|
| 241 |
+
|
| 242 |
+
This project is currently a **simulation** — the emails are hardcoded sample data inside `environment.py`. But the architecture is designed so it can be connected to a real email inbox with minimal changes.
|
| 243 |
+
|
| 244 |
+
### Connecting to a Real Email Provider
|
| 245 |
+
|
| 246 |
+
| Method | Best For | How |
|
| 247 |
+
|--------|----------|-----|
|
| 248 |
+
| **Gmail API** | Gmail / Google Workspace | `google-api-python-client` + OAuth2 |
|
| 249 |
+
| **Microsoft Graph API** | Outlook / Office 365 | REST API + app registration |
|
| 250 |
+
| **IMAP/SMTP** | Any provider | Python's built-in `imaplib` + `smtplib` |
|
| 251 |
+
|
| 252 |
+
### What Would Change
|
| 253 |
+
|
| 254 |
+
| Layer | Current (Hackathon) | Real-Life Version |
|
| 255 |
+
|-------|-------------------|------------------|
|
| 256 |
+
| **Email source** | Hardcoded Python dicts | Gmail API / IMAP / Outlook API |
|
| 257 |
+
| **Actions** | Modify in-memory objects | Call real email APIs (label, send, archive) |
|
| 258 |
+
| **AI brain** | Groq LLM | Same — no change needed |
|
| 259 |
+
| **Trigger** | Manual CLI command | Cron job, webhook, or always-on service |
|
| 260 |
+
| **Safety** | None needed (simulation) | Drafts-only mode, audit logs, undo window |
|
| 261 |
+
|
| 262 |
+
The **agent logic (`inference.py`) stays exactly the same** — only the environment layer needs to be swapped from simulated emails to real API calls.
|
| 263 |
+
|
| 264 |
+
### Example: Automated Morning Triage
|
| 265 |
+
|
| 266 |
+
```
|
| 267 |
+
You receive 50 emails overnight.
|
| 268 |
+
|
| 269 |
+
The agent runs automatically at 7 AM:
|
| 270 |
+
├── 8 marked "urgent" → drafts ready for your review
|
| 271 |
+
├── 12 newsletters → archived automatically
|
| 272 |
+
├── 3 suspicious emails → flagged for you to check
|
| 273 |
+
├── 25 normal emails → labelled and sorted
|
| 274 |
+
└── 2 ambiguous emails → flagged with explanation
|
| 275 |
+
|
| 276 |
+
You wake up to 13 items needing attention instead of 50.
|
| 277 |
+
```
|
| 278 |
+
|
| 279 |
+
### Safety Guardrails for Production
|
| 280 |
+
|
| 281 |
+
- **Draft mode**: Save replies as drafts instead of auto-sending
|
| 282 |
+
- **Allowlist/blocklist**: Only act on specific senders/domains
|
| 283 |
+
- **Audit log**: Record every agent action for review
|
| 284 |
+
- **Undo window**: 60-second delay before sending
|
| 285 |
+
- **Cost monitoring**: Track API usage for free-tier limits
|
| 286 |
+
|
| 287 |
+
---
|
| 288 |
+
|
| 289 |
+
## Technical Notes
|
| 290 |
+
|
| 291 |
+
- **LLM Client**: `openai` Python SDK pointed at Groq's OpenAI-compatible endpoint
|
| 292 |
+
- **Model**: Llama 3.3 70B Versatile (hosted on Groq, free tier)
|
| 293 |
+
- **Retry Logic**: Exponential backoff (5s → 10s → 20s) on rate-limit errors
|
| 294 |
+
- **Pure Python**: No GPU required
|
| 295 |
+
- **Resources**: Runs within 2 vCPU / 4 GB RAM
|
| 296 |
+
- **Deterministic graders**: Same actions always produce the same score
|
| 297 |
+
- **Pydantic v2**: Typed models for Action, Observation, StepResult, InboxState
|
| 298 |
+
- **17 unit tests**: Full coverage of environment logic across all 3 tasks
|
curate_dataset.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
curate_dataset.py -- Downloads real emails from the Enron Spam dataset on
|
| 3 |
+
HuggingFace and curates them into a structured JSON dataset for the Email
|
| 4 |
+
Triage environment.
|
| 5 |
+
|
| 6 |
+
This script is run ONCE to generate data/emails.json. The generated file
|
| 7 |
+
is then shipped with the project -- the environment loads it at runtime
|
| 8 |
+
without needing the `datasets` library.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
pip install datasets
|
| 12 |
+
python curate_dataset.py
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import random
|
| 17 |
+
import re
|
| 18 |
+
import os
|
| 19 |
+
from datasets import load_dataset
|
| 20 |
+
|
| 21 |
+
random.seed(42) # reproducible curation
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# 1. Load the Enron Spam dataset from HuggingFace
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
print("Loading SetFit/enron_spam from HuggingFace...")
|
| 27 |
+
ds = load_dataset("SetFit/enron_spam", split="train")
|
| 28 |
+
print(f" Total emails: {len(ds)}")
|
| 29 |
+
|
| 30 |
+
# Fields: text (subject + body combined), label (0=ham, 1=spam)
|
| 31 |
+
# We need to parse subject and body from the text field
|
| 32 |
+
|
| 33 |
+
def parse_email(text: str) -> dict:
|
| 34 |
+
"""Parse Enron email text into subject + body."""
|
| 35 |
+
lines = text.strip().split("\n")
|
| 36 |
+
subject = ""
|
| 37 |
+
body_start = 0
|
| 38 |
+
|
| 39 |
+
for i, line in enumerate(lines):
|
| 40 |
+
if line.lower().startswith("subject:"):
|
| 41 |
+
subject = line[len("Subject:"):].strip()
|
| 42 |
+
body_start = i + 1
|
| 43 |
+
break
|
| 44 |
+
|
| 45 |
+
body = "\n".join(lines[body_start:]).strip()
|
| 46 |
+
|
| 47 |
+
# Clean up common artifacts
|
| 48 |
+
body = re.sub(r'\s+', ' ', body)[:800] # cap body length
|
| 49 |
+
if not subject:
|
| 50 |
+
subject = body[:60] + "..." if len(body) > 60 else body
|
| 51 |
+
|
| 52 |
+
return {"subject": subject, "body": body}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# 2. Filter and curate emails
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
|
| 59 |
+
# Separate ham (legitimate) and spam
|
| 60 |
+
ham_emails = []
|
| 61 |
+
spam_emails = []
|
| 62 |
+
|
| 63 |
+
for i, item in enumerate(ds):
|
| 64 |
+
if not item["text"] or len(item["text"].strip()) < 50:
|
| 65 |
+
continue
|
| 66 |
+
parsed = parse_email(item["text"])
|
| 67 |
+
if not parsed["subject"] or not parsed["body"] or len(parsed["body"]) < 30:
|
| 68 |
+
continue
|
| 69 |
+
|
| 70 |
+
entry = {
|
| 71 |
+
"enron_index": i,
|
| 72 |
+
"subject": parsed["subject"],
|
| 73 |
+
"body": parsed["body"],
|
| 74 |
+
"is_spam": item["label"] == 1,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if item["label"] == 0:
|
| 78 |
+
ham_emails.append(entry)
|
| 79 |
+
else:
|
| 80 |
+
spam_emails.append(entry)
|
| 81 |
+
|
| 82 |
+
print(f" Ham (legitimate): {len(ham_emails)}")
|
| 83 |
+
print(f" Spam: {len(spam_emails)}")
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# 3. Curate emails into task-ready collections with ground truth
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
# We'll assign realistic senders and priority labels based on content analysis
|
| 90 |
+
CORPORATE_SENDERS = [
|
| 91 |
+
"mark.taylor@enron.com", "sarah.palmer@globalenergy.com",
|
| 92 |
+
"john.arnold@trading-desk.com", "vince.kaminski@enron.com",
|
| 93 |
+
"sally.beck@enron.com", "louise.kitchen@enron.com",
|
| 94 |
+
"jeff.dasovich@regulatoryaffairs.com", "steven.kean@enron.com",
|
| 95 |
+
"richard.shapiro@enron.com", "james.steffes@enron.com",
|
| 96 |
+
"mike.carson@infrastructure.com", "lisa.gang@legal-team.com",
|
| 97 |
+
"david.delainey@ees.com", "greg.whalley@enron.com",
|
| 98 |
+
"tim.belden@trading.com", "kevin.presto@eastpower.com",
|
| 99 |
+
"matt.smith@operations.com", "donna.fulton@regulatory.com",
|
| 100 |
+
"kate.symes@trading.com", "diana.scholtes@admin.com",
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
SPAM_SENDERS = [
|
| 104 |
+
"deals@shop-now-99.xyz", "winner@prize-center.info",
|
| 105 |
+
"noreply@free-offers.biz", "promo@discount-deals.click",
|
| 106 |
+
"support@account-verify.net",
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
NEWSLETTER_SENDERS = [
|
| 110 |
+
"digest@energy-news.io", "weekly@market-watch.com",
|
| 111 |
+
"updates@industry-report.net",
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def classify_priority(subject: str, body: str, is_spam: bool) -> str:
|
| 116 |
+
"""Assign ground-truth priority based on content analysis."""
|
| 117 |
+
text = (subject + " " + body).lower()
|
| 118 |
+
|
| 119 |
+
if is_spam:
|
| 120 |
+
return "low"
|
| 121 |
+
|
| 122 |
+
# Urgent signals
|
| 123 |
+
urgent_keywords = [
|
| 124 |
+
"urgent", "critical", "asap", "immediately", "deadline",
|
| 125 |
+
"emergency", "action required", "must", "time sensitive",
|
| 126 |
+
"expir", "shut down", "outage", "breach", "compliance",
|
| 127 |
+
"regulatory", "legal action", "termination", "suspension",
|
| 128 |
+
]
|
| 129 |
+
if any(kw in text for kw in urgent_keywords):
|
| 130 |
+
return "urgent"
|
| 131 |
+
|
| 132 |
+
# Normal signals (business correspondence)
|
| 133 |
+
normal_keywords = [
|
| 134 |
+
"meeting", "schedule", "review", "update", "report",
|
| 135 |
+
"please", "attached", "draft", "feedback", "follow up",
|
| 136 |
+
"discuss", "proposal", "agreement", "contract", "budget",
|
| 137 |
+
]
|
| 138 |
+
if any(kw in text for kw in normal_keywords):
|
| 139 |
+
return "normal"
|
| 140 |
+
|
| 141 |
+
return "low"
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def assign_sender(is_spam: bool, priority: str) -> str:
|
| 145 |
+
"""Assign a realistic sender based on email type."""
|
| 146 |
+
if is_spam:
|
| 147 |
+
return random.choice(SPAM_SENDERS)
|
| 148 |
+
return random.choice(CORPORATE_SENDERS)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# --- Task 1: 5 emails for priority classification (easy) ---
|
| 152 |
+
# Need: 2 urgent, 1 normal, 2 low (mix of ham + spam)
|
| 153 |
+
task1_candidates = {"urgent": [], "normal": [], "low": []}
|
| 154 |
+
for email in ham_emails[:500]:
|
| 155 |
+
p = classify_priority(email["subject"], email["body"], False)
|
| 156 |
+
if len(task1_candidates[p]) < 20:
|
| 157 |
+
task1_candidates[p].append(email)
|
| 158 |
+
for email in spam_emails[:200]:
|
| 159 |
+
if len(task1_candidates["low"]) < 20:
|
| 160 |
+
email_copy = dict(email)
|
| 161 |
+
task1_candidates["low"].append(email_copy)
|
| 162 |
+
|
| 163 |
+
task1_picks = (
|
| 164 |
+
random.sample(task1_candidates["urgent"], min(2, len(task1_candidates["urgent"])))
|
| 165 |
+
+ random.sample(task1_candidates["normal"], min(1, len(task1_candidates["normal"])))
|
| 166 |
+
+ random.sample(task1_candidates["low"], min(2, len(task1_candidates["low"])))
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
task1_emails = []
|
| 170 |
+
for i, email in enumerate(task1_picks):
|
| 171 |
+
priority = classify_priority(email["subject"], email["body"], email.get("is_spam", False))
|
| 172 |
+
task1_emails.append({
|
| 173 |
+
"id": f"t1_{i+1:03d}",
|
| 174 |
+
"from": assign_sender(email.get("is_spam", False), priority),
|
| 175 |
+
"subject": email["subject"],
|
| 176 |
+
"body": email["body"],
|
| 177 |
+
"ground_truth_priority": priority,
|
| 178 |
+
"source": "SetFit/enron_spam",
|
| 179 |
+
"source_index": email["enron_index"],
|
| 180 |
+
})
|
| 181 |
+
|
| 182 |
+
# --- Task 2: 1 complaint email (will write a realistic one based on Enron context) ---
|
| 183 |
+
task2_email = {
|
| 184 |
+
"id": "t2_001",
|
| 185 |
+
"from": "frustrated.trader@westcoast-power.com",
|
| 186 |
+
"subject": "UNACCEPTABLE: Trade confirmation errors - 3rd time this month",
|
| 187 |
+
"body": (
|
| 188 |
+
"To whom it may concern,\n\n"
|
| 189 |
+
"I am writing to formally complain about the persistent errors in trade "
|
| 190 |
+
"confirmations coming from your desk. This is the THIRD time this month "
|
| 191 |
+
"that we have received confirmations with incorrect volumes and pricing. "
|
| 192 |
+
"Our last trade (ref: WCP-2024-8847) showed 500 MW at $42.50 when the "
|
| 193 |
+
"agreed terms were 750 MW at $38.75.\n\n"
|
| 194 |
+
"When we called to rectify, your operations team said they would 'look "
|
| 195 |
+
"into it' -- that was five business days ago with no follow-up.\n\n"
|
| 196 |
+
"We need:\n"
|
| 197 |
+
"1. Immediate correction of trade ref WCP-2024-8847\n"
|
| 198 |
+
"2. A reconciliation of ALL trades executed between our desks this quarter\n"
|
| 199 |
+
"3. A written explanation of what process failure is causing these errors\n"
|
| 200 |
+
"4. Assurance that this will not happen again\n\n"
|
| 201 |
+
"If this is not resolved by end of week, we will be escalating to our "
|
| 202 |
+
"legal team and reconsidering our trading relationship.\n\n"
|
| 203 |
+
"Regards,\nMichael Torres\nHead of Trading Operations\n"
|
| 204 |
+
"WestCoast Power LLC"
|
| 205 |
+
),
|
| 206 |
+
"ground_truth_priority": "urgent",
|
| 207 |
+
"source": "manually_crafted_enron_context",
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
# --- Task 3: 10 emails for full triage (hard) ---
|
| 211 |
+
# Need a diverse mix: 4 urgent, 2 normal, 2 low/spam, 1 ambiguous, 1 newsletter
|
| 212 |
+
task3_candidates = {"urgent": [], "normal": [], "low": [], "spam": []}
|
| 213 |
+
# Use different emails than task 1
|
| 214 |
+
for email in ham_emails[500:1500]:
|
| 215 |
+
p = classify_priority(email["subject"], email["body"], False)
|
| 216 |
+
if len(task3_candidates[p]) < 30:
|
| 217 |
+
task3_candidates[p].append(email)
|
| 218 |
+
for email in spam_emails[200:600]:
|
| 219 |
+
if len(task3_candidates["spam"]) < 30:
|
| 220 |
+
email_copy = dict(email)
|
| 221 |
+
task3_candidates["spam"].append(email_copy)
|
| 222 |
+
|
| 223 |
+
task3_picks_urgent = random.sample(
|
| 224 |
+
task3_candidates["urgent"], min(4, len(task3_candidates["urgent"]))
|
| 225 |
+
)
|
| 226 |
+
task3_picks_normal = random.sample(
|
| 227 |
+
task3_candidates["normal"], min(2, len(task3_candidates["normal"]))
|
| 228 |
+
)
|
| 229 |
+
# For low: use the spam candidates
|
| 230 |
+
task3_spam_low = task3_candidates["spam"]
|
| 231 |
+
task3_picks_spam = random.sample(task3_spam_low, min(2, len(task3_spam_low)))
|
| 232 |
+
# Remaining low slots from non-spam ham
|
| 233 |
+
|
| 234 |
+
# Ambiguous email (crafted -- context-dependent, hard to classify)
|
| 235 |
+
task3_ambiguous = {
|
| 236 |
+
"subject": "Re: that discussion last week",
|
| 237 |
+
"body": (
|
| 238 |
+
"Following up on our conversation. I think we should move forward "
|
| 239 |
+
"but wanted to get your read on the situation first. There are some "
|
| 240 |
+
"concerns internally that I'd rather discuss offline. Can you call "
|
| 241 |
+
"me when you get a chance?"
|
| 242 |
+
),
|
| 243 |
+
"is_spam": False,
|
| 244 |
+
"enron_index": -1,
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
# Newsletter
|
| 248 |
+
task3_newsletter = {
|
| 249 |
+
"subject": "Weekly Energy Market Report - Natural Gas Futures Update",
|
| 250 |
+
"body": (
|
| 251 |
+
"This week's energy market highlights:\n"
|
| 252 |
+
"- Natural gas futures rose 3.2% on cold weather forecasts\n"
|
| 253 |
+
"- FERC announced new transmission capacity rules\n"
|
| 254 |
+
"- California ISO reported record renewable generation\n\n"
|
| 255 |
+
"Full analysis at energy-news.io/weekly\n"
|
| 256 |
+
"Unsubscribe: reply STOP"
|
| 257 |
+
),
|
| 258 |
+
"is_spam": False,
|
| 259 |
+
"enron_index": -2,
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
all_task3 = (
|
| 263 |
+
[(e, "urgent", False) for e in task3_picks_urgent]
|
| 264 |
+
+ [(e, "normal", False) for e in task3_picks_normal]
|
| 265 |
+
+ [(e, "low", True) for e in task3_picks_spam] # spam → archive
|
| 266 |
+
+ [(task3_ambiguous, "normal", False)] # ambiguous → flag
|
| 267 |
+
+ [(task3_newsletter, "low", False)]
|
| 268 |
+
)
|
| 269 |
+
random.shuffle(all_task3)
|
| 270 |
+
|
| 271 |
+
# Track which IDs are urgent, spam/archive, and ambiguous
|
| 272 |
+
task3_urgent_ids = set()
|
| 273 |
+
task3_archive_ids = set()
|
| 274 |
+
task3_flag_ids = set()
|
| 275 |
+
|
| 276 |
+
task3_emails = []
|
| 277 |
+
for i, (email, priority, is_spam_override) in enumerate(all_task3):
|
| 278 |
+
eid = f"t3_{i+1:03d}"
|
| 279 |
+
|
| 280 |
+
is_spam = is_spam_override
|
| 281 |
+
is_ambiguous = email.get("enron_index") == -1
|
| 282 |
+
is_newsletter = email.get("enron_index") == -2
|
| 283 |
+
|
| 284 |
+
if priority == "urgent":
|
| 285 |
+
task3_urgent_ids.add(eid)
|
| 286 |
+
sender = assign_sender(False, "urgent")
|
| 287 |
+
elif is_spam:
|
| 288 |
+
task3_archive_ids.add(eid)
|
| 289 |
+
sender = assign_sender(True, "low")
|
| 290 |
+
elif is_newsletter:
|
| 291 |
+
sender = random.choice(NEWSLETTER_SENDERS)
|
| 292 |
+
elif is_ambiguous:
|
| 293 |
+
task3_flag_ids.add(eid)
|
| 294 |
+
sender = "unknown.sender@company.com"
|
| 295 |
+
else:
|
| 296 |
+
sender = assign_sender(False, priority)
|
| 297 |
+
|
| 298 |
+
task3_emails.append({
|
| 299 |
+
"id": eid,
|
| 300 |
+
"from": sender,
|
| 301 |
+
"subject": email["subject"],
|
| 302 |
+
"body": email["body"],
|
| 303 |
+
"ground_truth_priority": priority,
|
| 304 |
+
"source": "SetFit/enron_spam" if email.get("enron_index", 0) >= 0 else "manually_crafted",
|
| 305 |
+
"source_index": email.get("enron_index", -1),
|
| 306 |
+
})
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
# ---------------------------------------------------------------------------
|
| 310 |
+
# 4. Write the curated dataset
|
| 311 |
+
# ---------------------------------------------------------------------------
|
| 312 |
+
os.makedirs("data", exist_ok=True)
|
| 313 |
+
|
| 314 |
+
dataset = {
|
| 315 |
+
"metadata": {
|
| 316 |
+
"name": "email-triage-dataset",
|
| 317 |
+
"version": "1.0.0",
|
| 318 |
+
"description": (
|
| 319 |
+
"Curated email dataset for the Email Triage & Response Environment. "
|
| 320 |
+
"Contains real emails from the Enron corpus (SetFit/enron_spam on "
|
| 321 |
+
"HuggingFace) with manually assigned priority labels and triage metadata."
|
| 322 |
+
),
|
| 323 |
+
"source_dataset": "SetFit/enron_spam",
|
| 324 |
+
"source_url": "https://huggingface.co/datasets/SetFit/enron_spam",
|
| 325 |
+
"license": "Public domain (Enron corpus)",
|
| 326 |
+
"total_emails": len(task1_emails) + 1 + len(task3_emails),
|
| 327 |
+
"curation_seed": 42,
|
| 328 |
+
},
|
| 329 |
+
"task1": {
|
| 330 |
+
"description": "Label 5 emails as urgent/normal/low priority",
|
| 331 |
+
"difficulty": "easy",
|
| 332 |
+
"emails": task1_emails,
|
| 333 |
+
"ground_truth": {e["id"]: e["ground_truth_priority"] for e in task1_emails},
|
| 334 |
+
},
|
| 335 |
+
"task2": {
|
| 336 |
+
"description": "Draft a professional reply to a complaint email",
|
| 337 |
+
"difficulty": "medium",
|
| 338 |
+
"emails": [task2_email],
|
| 339 |
+
},
|
| 340 |
+
"task3": {
|
| 341 |
+
"description": "Full triage pipeline: label, reply, archive, flag",
|
| 342 |
+
"difficulty": "hard",
|
| 343 |
+
"emails": task3_emails,
|
| 344 |
+
"ground_truth": {e["id"]: e["ground_truth_priority"] for e in task3_emails},
|
| 345 |
+
"urgent_ids": sorted(task3_urgent_ids),
|
| 346 |
+
"archive_ids": sorted(task3_archive_ids),
|
| 347 |
+
"flag_ids": sorted(task3_flag_ids),
|
| 348 |
+
},
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
output_path = "data/emails.json"
|
| 352 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 353 |
+
json.dump(dataset, f, indent=2, ensure_ascii=False)
|
| 354 |
+
|
| 355 |
+
print(f"\nDataset written to {output_path}")
|
| 356 |
+
print(f" Task 1: {len(task1_emails)} emails")
|
| 357 |
+
print(f" Task 2: 1 email")
|
| 358 |
+
print(f" Task 3: {len(task3_emails)} emails")
|
| 359 |
+
print(f" Urgent IDs: {sorted(task3_urgent_ids)}")
|
| 360 |
+
print(f" Archive IDs: {sorted(task3_archive_ids)}")
|
| 361 |
+
print(f" Flag IDs: {sorted(task3_flag_ids)}")
|
| 362 |
+
print("\nDone! Now update environment.py to load from data/emails.json")
|
curate_out.txt
ADDED
|
Binary file (1.98 kB). View file
|
|
|
data/emails.json
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"metadata": {
|
| 3 |
+
"name": "email-triage-dataset",
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"description": "Curated email dataset for the Email Triage & Response Environment. Contains real emails from the Enron corpus (SetFit/enron_spam on HuggingFace) with manually assigned priority labels and triage metadata.",
|
| 6 |
+
"source_dataset": "SetFit/enron_spam",
|
| 7 |
+
"source_url": "https://huggingface.co/datasets/SetFit/enron_spam",
|
| 8 |
+
"license": "Public domain (Enron corpus)",
|
| 9 |
+
"total_emails": 16,
|
| 10 |
+
"curation_seed": 42
|
| 11 |
+
},
|
| 12 |
+
"task1": {
|
| 13 |
+
"description": "Label 5 emails as urgent/normal/low priority",
|
| 14 |
+
"difficulty": "easy",
|
| 15 |
+
"emails": [
|
| 16 |
+
{
|
| 17 |
+
"id": "t1_001",
|
| 18 |
+
"from": "sally.beck@enron.com",
|
| 19 |
+
"subject": "year end 2000 performance feedback note : you will receive t...",
|
| 20 |
+
"body": "year end 2000 performance feedback note : you will receive this message each time you are selected as a reviewer . you have been selected to participate in the year end 2000 performance management process by providing meaningful feedback on specific employee ( s ) . your feedback plays an important role in the process , and your participation is critical to the success of enron ' s performance management goals . to complete requests for feedback , access pep at http : / / pep . corp . enron . com and select perform review under performance review services . you may begin providing feedback immediately and are requested to have all feedback forms completed by friday , november 17 , 2000 . if you have any questions regarding pep or your responsibility in the process , please contact the pep ",
|
| 21 |
+
"ground_truth_priority": "urgent",
|
| 22 |
+
"source": "SetFit/enron_spam",
|
| 23 |
+
"source_index": 78
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"id": "t1_002",
|
| 27 |
+
"from": "vince.kaminski@enron.com",
|
| 28 |
+
"subject": "perspective on ferc regulatory action client conf call today...",
|
| 29 |
+
"body": "perspective on ferc regulatory action client conf call today , jun e 19 th , 2 : 00 pm edt perspective on ferc regulatory action client conference call today , tuesday , june 19 th 2 : 00 pm edt host : ray niles , power / natural gas analyst speaker : steve bergstrom , president & coo of dynegy steve bergstrom , president and chief operating officer of dynegy , will join us at 2 : 00 p . m . today for a conference call discussion of the recent ferc action imposing price controls in the west . the discussion will be followed by q & a . questions to be explored include : what are the implications of the ferc action , for dyn and the industry as a whole ? what is the earnings impact ? what are the risks of further re - regulation ? and whatever else is on your minds we attach two recent notes",
|
| 30 |
+
"ground_truth_priority": "urgent",
|
| 31 |
+
"source": "SetFit/enron_spam",
|
| 32 |
+
"source_index": 1
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"id": "t1_003",
|
| 36 |
+
"from": "donna.fulton@regulatory.com",
|
| 37 |
+
"subject": "nominations for eastrans reciept for 3 / 15 and following . ...",
|
| 38 |
+
"body": "nominations for eastrans reciept for 3 / 15 and following . correction : 3 , 000 into the carrtwheel agreement . - - - - - - - - - - - - - - - - - - - - - - forwarded by bruce mcmills / ftworth / pefs / pec on 03 / 15 / 2000 03 : 50 pm - - - - - - - - - - - - - - - - - - - - - - - - - - - bruce mcmills 03 / 15 / 2000 03 : 49 pm to : dfarmer @ enron . com , stacey . neuweiler @ enron . com , briley @ enron . com cc : jim i . fields / gcs / cec / pec @ pec , chad w . cass / gcs / cec / pec @ pec , william e . speckels / gcs / cec / pec @ pec , michael r . cherry / easttexas / pefs / pec @ pec , darrel f . bane / easttexas / pefs / pec @ pec subject : nominations for eastrans reciept for 3 / 15 and following . also , 23 , 000 mmbtu into enron ' s cartwheel agreementat the hub . - - - - - - - ",
|
| 39 |
+
"ground_truth_priority": "normal",
|
| 40 |
+
"source": "SetFit/enron_spam",
|
| 41 |
+
"source_index": 28
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"id": "t1_004",
|
| 45 |
+
"from": "john.arnold@trading-desk.com",
|
| 46 |
+
"subject": "congratulations ! congratulations on your latest achievement...",
|
| 47 |
+
"body": "congratulations ! congratulations on your latest achievement ! it ' s great that you are recognized for all your hard work and dedication . sincerely rl",
|
| 48 |
+
"ground_truth_priority": "low",
|
| 49 |
+
"source": "SetFit/enron_spam",
|
| 50 |
+
"source_index": 56
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": "t1_005",
|
| 54 |
+
"from": "kate.symes@trading.com",
|
| 55 |
+
"subject": "lindy ' s b - day hi guys ! lindy ' s b - day lunch came to ...",
|
| 56 |
+
"body": "lindy ' s b - day hi guys ! lindy ' s b - day lunch came to $ 40 . 30 each . thanks , kim .",
|
| 57 |
+
"ground_truth_priority": "low",
|
| 58 |
+
"source": "SetFit/enron_spam",
|
| 59 |
+
"source_index": 98
|
| 60 |
+
}
|
| 61 |
+
],
|
| 62 |
+
"ground_truth": {
|
| 63 |
+
"t1_001": "urgent",
|
| 64 |
+
"t1_002": "urgent",
|
| 65 |
+
"t1_003": "normal",
|
| 66 |
+
"t1_004": "low",
|
| 67 |
+
"t1_005": "low"
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
"task2": {
|
| 71 |
+
"description": "Draft a professional reply to a complaint email",
|
| 72 |
+
"difficulty": "medium",
|
| 73 |
+
"emails": [
|
| 74 |
+
{
|
| 75 |
+
"id": "t2_001",
|
| 76 |
+
"from": "frustrated.trader@westcoast-power.com",
|
| 77 |
+
"subject": "UNACCEPTABLE: Trade confirmation errors - 3rd time this month",
|
| 78 |
+
"body": "To whom it may concern,\n\nI am writing to formally complain about the persistent errors in trade confirmations coming from your desk. This is the THIRD time this month that we have received confirmations with incorrect volumes and pricing. Our last trade (ref: WCP-2024-8847) showed 500 MW at $42.50 when the agreed terms were 750 MW at $38.75.\n\nWhen we called to rectify, your operations team said they would 'look into it' -- that was five business days ago with no follow-up.\n\nWe need:\n1. Immediate correction of trade ref WCP-2024-8847\n2. A reconciliation of ALL trades executed between our desks this quarter\n3. A written explanation of what process failure is causing these errors\n4. Assurance that this will not happen again\n\nIf this is not resolved by end of week, we will be escalating to our legal team and reconsidering our trading relationship.\n\nRegards,\nMichael Torres\nHead of Trading Operations\nWestCoast Power LLC",
|
| 79 |
+
"ground_truth_priority": "urgent",
|
| 80 |
+
"source": "manually_crafted_enron_context"
|
| 81 |
+
}
|
| 82 |
+
]
|
| 83 |
+
},
|
| 84 |
+
"task3": {
|
| 85 |
+
"description": "Full triage pipeline: label, reply, archive, flag",
|
| 86 |
+
"difficulty": "hard",
|
| 87 |
+
"emails": [
|
| 88 |
+
{
|
| 89 |
+
"id": "t3_001",
|
| 90 |
+
"from": "kate.symes@trading.com",
|
| 91 |
+
"subject": "year end 2000 performance feedback note : you will receive t...",
|
| 92 |
+
"body": "year end 2000 performance feedback note : you will receive this message each time you are selected as a reviewer . you have been selected to participate in the year end 2000 performance management process by providing meaningful feedback on specific employee ( s ) . your feedback plays an important role in the process , and your participation is critical to the success of enron ' s performance management goals . to complete requests for feedback , access pep at http : / / pep . corp . enron . com and select perform review under performance review services . you may begin providing feedback immediately and are requested to have all feedback forms completed by friday , november 17 , 2000 . if you have any questions regarding pep or your responsibility in the process , please contact the pep ",
|
| 93 |
+
"ground_truth_priority": "urgent",
|
| 94 |
+
"source": "SetFit/enron_spam",
|
| 95 |
+
"source_index": 1055
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"id": "t3_002",
|
| 99 |
+
"from": "richard.shapiro@enron.com",
|
| 100 |
+
"subject": "bcp seat assignments all : attached you will find a list tha...",
|
| 101 |
+
"body": "bcp seat assignments all : attached you will find a list that reflects your seat assignments for business continuity planning ( bcp ) . these seats are located on the 30 th and 31 st floors of enron center north ( ecn ) . as previously communicated , you will report to these designated seats in the event of an outage in ecs . the exception to this is as follows : if your seat assignment is located on the 31 st floor , you will report to your original location that you occupied prior to your move into ecs . this will hold true until the monday after thanksgiving , as we will have the 31 st floor seats set up at that time . testing : once you have moved to ecs , if you would like to test your bcp location , you will be able to test your seat for functionality every thursday from 3 - 6 pm . t",
|
| 102 |
+
"ground_truth_priority": "urgent",
|
| 103 |
+
"source": "SetFit/enron_spam",
|
| 104 |
+
"source_index": 1074
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"id": "t3_003",
|
| 108 |
+
"from": "digest@energy-news.io",
|
| 109 |
+
"subject": "Weekly Energy Market Report - Natural Gas Futures Update",
|
| 110 |
+
"body": "This week's energy market highlights:\n- Natural gas futures rose 3.2% on cold weather forecasts\n- FERC announced new transmission capacity rules\n- California ISO reported record renewable generation\n\nFull analysis at energy-news.io/weekly\nUnsubscribe: reply STOP",
|
| 111 |
+
"ground_truth_priority": "low",
|
| 112 |
+
"source": "manually_crafted",
|
| 113 |
+
"source_index": -2
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"id": "t3_004",
|
| 117 |
+
"from": "winner@prize-center.info",
|
| 118 |
+
"subject": "aggressive stock traders aiert the stock watch alert newslet...",
|
| 119 |
+
"body": "aggressive stock traders aiert the stock watch alert newsletter attn : subscribers , analysts , stockbrokers quest oil ' s mission is to deliver a competitive and sustainable rate of return for our shareholders by acquiring , exploring and developing oil and gas properties around the world . now that oil and gas has entered a | ong - term bul | market , our speciaity in pinpointing the hottest companies of the few remaining undervalued energy piays has produced soaring returns . quest oil corporation ( qoil ) hastargeted oi | and gas exploration in both north america and internationaliy . the company is focused on acquiring quality oi | and gas properties in regions that provide economicaliy and poiitica | | y stable environments . quest is currently involved in projects both on the intern",
|
| 120 |
+
"ground_truth_priority": "low",
|
| 121 |
+
"source": "SetFit/enron_spam",
|
| 122 |
+
"source_index": 450
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"id": "t3_005",
|
| 126 |
+
"from": "greg.whalley@enron.com",
|
| 127 |
+
"subject": "re : invitation to speak at infocast ' s upcoming \" market p...",
|
| 128 |
+
"body": "re : invitation to speak at infocast ' s upcoming \" market price volatility \" program ron , we are really swamped and i would like to keep our involvement in conferences to a reasonable minimum . i can promise that we shall help you with a future conference if it happens to be in houston . vince \" ron henderson \" on 01 / 11 / 2000 03 : 13 : 56 pm please respond to ronh @ . com to : vince j kaminski / hou / ect @ ect cc : subject : re : invitation to speak at infocast ' s upcoming \" market price volatility \" program vince , i am sorry you can ' t join us . is there someone on your staff who might be able to do the presentation \" a real options approach to asset valuation , \" scheduled for thursday , may 11 th , from 10 : 30 am to 12 : 00 pm . ? ron - - - - - original message - - - - - from ",
|
| 129 |
+
"ground_truth_priority": "normal",
|
| 130 |
+
"source": "SetFit/enron_spam",
|
| 131 |
+
"source_index": 1036
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"id": "t3_006",
|
| 135 |
+
"from": "noreply@free-offers.biz",
|
| 136 |
+
"subject": "penelope cruz pamela andersons ' s most widely recognized ca...",
|
| 137 |
+
"body": "penelope cruz pamela andersons ' s most widely recognized camera appearance in sexual acts don ' t want anymore kevin , although somewhat soothed by out and day ? . most laptops . believe that grenade take a peek at white .",
|
| 138 |
+
"ground_truth_priority": "low",
|
| 139 |
+
"source": "SetFit/enron_spam",
|
| 140 |
+
"source_index": 444
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"id": "t3_007",
|
| 144 |
+
"from": "richard.shapiro@enron.com",
|
| 145 |
+
"subject": "natural gas origination our natural gas business continues t...",
|
| 146 |
+
"body": "natural gas origination our natural gas business continues to benefit from effective account management and resource allocation focused on identifying and responding to the needs of our varied customers . in order to keep our organization optimally structured and to facilitate additional growth , we are making the following changes : producer / wellhead group the current mid - market , origination and wellhead pricing activity currently within the central and eastern gas regions will be consolidated with the derivatives group under fred lagrasta . this will create a single business unit focused upon the needs of the producing industry within the eastern u . s . the producer focus in the western u . s . and texas will remain unchanged reporting to mark whitt and brian redmond respectively .",
|
| 147 |
+
"ground_truth_priority": "normal",
|
| 148 |
+
"source": "SetFit/enron_spam",
|
| 149 |
+
"source_index": 1038
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"id": "t3_008",
|
| 153 |
+
"from": "sally.beck@enron.com",
|
| 154 |
+
"subject": "out of the office i will be out of the office beginning thur...",
|
| 155 |
+
"body": "out of the office i will be out of the office beginning thursday , 12 / 24 , returning on tuesday , 1 / 4 . in my absence , i have asked steve venturatos to be the point person for texas operations . i realize that many of you will be working over the new year ' s week - end to ensure a smooth transaction into the new year . in advance , i truly appreciate all of the efforts . additionally , i would like to be kept informed on any critical issues , mainly so that i have no surprises when i return . therefore , i have provided numbers below where i can be reached . i will leave it to your discretion as to whether you call me or leave me a voice mail in the office . as you all know , i would rather be informed than surprised ! pager 877 - 497 - 3757 cellular 713 - 417 - 2995 home 970 - 920 -",
|
| 156 |
+
"ground_truth_priority": "urgent",
|
| 157 |
+
"source": "SetFit/enron_spam",
|
| 158 |
+
"source_index": 1090
|
| 159 |
+
},
|
| 160 |
+
{
|
| 161 |
+
"id": "t3_009",
|
| 162 |
+
"from": "unknown.sender@company.com",
|
| 163 |
+
"subject": "Re: that discussion last week",
|
| 164 |
+
"body": "Following up on our conversation. I think we should move forward but wanted to get your read on the situation first. There are some concerns internally that I'd rather discuss offline. Can you call me when you get a chance?",
|
| 165 |
+
"ground_truth_priority": "normal",
|
| 166 |
+
"source": "manually_crafted",
|
| 167 |
+
"source_index": -1
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"id": "t3_010",
|
| 171 |
+
"from": "jeff.dasovich@regulatoryaffairs.com",
|
| 172 |
+
"subject": "re : mariner doorstep report dear kate , we are now ranking ...",
|
| 173 |
+
"body": "re : mariner doorstep report dear kate , we are now ranking the findings - red or yellow . since these need to be done immediately , i suggest that they are red findings . regards kate agnew 08 / 15 / 2000 01 : 59 pm to : shona wilson / na / enron @ enron , donna lowry / hou / ect @ ect , richard lauer / hou / ect @ ect , sally beck / hou / ect @ ect , wes colwell / hou / ect @ ect , ted murphy / hou / ect @ ect cc : john sorrells / aa / corp / enron @ enron subject : mariner doorstep report attached is the mariner doorstep report . jim brown indicated that the action dates for the three findings should be immediately , we decided that october 1 was a realistic deadline for implementation . please let us know any comments or questions . thank you kate",
|
| 174 |
+
"ground_truth_priority": "urgent",
|
| 175 |
+
"source": "SetFit/enron_spam",
|
| 176 |
+
"source_index": 1308
|
| 177 |
+
}
|
| 178 |
+
],
|
| 179 |
+
"ground_truth": {
|
| 180 |
+
"t3_001": "urgent",
|
| 181 |
+
"t3_002": "urgent",
|
| 182 |
+
"t3_003": "low",
|
| 183 |
+
"t3_004": "low",
|
| 184 |
+
"t3_005": "normal",
|
| 185 |
+
"t3_006": "low",
|
| 186 |
+
"t3_007": "normal",
|
| 187 |
+
"t3_008": "urgent",
|
| 188 |
+
"t3_009": "normal",
|
| 189 |
+
"t3_010": "urgent"
|
| 190 |
+
},
|
| 191 |
+
"urgent_ids": [
|
| 192 |
+
"t3_001",
|
| 193 |
+
"t3_002",
|
| 194 |
+
"t3_008",
|
| 195 |
+
"t3_010"
|
| 196 |
+
],
|
| 197 |
+
"archive_ids": [
|
| 198 |
+
"t3_004",
|
| 199 |
+
"t3_006"
|
| 200 |
+
],
|
| 201 |
+
"flag_ids": [
|
| 202 |
+
"t3_009"
|
| 203 |
+
]
|
| 204 |
+
}
|
| 205 |
+
}
|
environment.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Email Triage & Response Environment
|
| 3 |
+
OpenEnv-compatible environment for agent evaluation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
from typing import Optional, Literal
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
|
| 10 |
+
class Email(BaseModel):
|
| 11 |
+
id: str
|
| 12 |
+
from_: str = Field(..., alias="from")
|
| 13 |
+
subject: str
|
| 14 |
+
body: str
|
| 15 |
+
labels: list[str] = []
|
| 16 |
+
replied: bool = False
|
| 17 |
+
archived: bool = False
|
| 18 |
+
flagged: bool = False
|
| 19 |
+
flag_reason: Optional[str] = None
|
| 20 |
+
reply_body: Optional[str] = None
|
| 21 |
+
|
| 22 |
+
class Config:
|
| 23 |
+
populate_by_name = True
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class InboxState(BaseModel):
|
| 27 |
+
inbox: list[Email]
|
| 28 |
+
sent: list[dict] = []
|
| 29 |
+
step_count: int = 0
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Observation(BaseModel):
|
| 33 |
+
status: str
|
| 34 |
+
message: str
|
| 35 |
+
data: Optional[dict] = None
|
| 36 |
+
step_count: int = 0
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class Action(BaseModel):
|
| 40 |
+
action: Literal["label", "draft_reply", "archive", "flag", "read", "list_inbox"]
|
| 41 |
+
email_id: Optional[str] = None
|
| 42 |
+
priority: Optional[Literal["urgent", "normal", "low"]] = None
|
| 43 |
+
body: Optional[str] = None
|
| 44 |
+
reason: Optional[str] = None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class StepResult(BaseModel):
|
| 48 |
+
observation: Observation
|
| 49 |
+
reward: float
|
| 50 |
+
done: bool
|
| 51 |
+
info: dict = {}
|
| 52 |
+
|
| 53 |
+
import os
|
| 54 |
+
|
| 55 |
+
# Load dataset (generated by curate_dataset.py)
|
| 56 |
+
DATASET_PATH = os.path.join(os.path.dirname(__file__), "data", "emails.json")
|
| 57 |
+
try:
|
| 58 |
+
with open(DATASET_PATH, "r", encoding="utf-8") as f:
|
| 59 |
+
_dataset = json.load(f)
|
| 60 |
+
except FileNotFoundError:
|
| 61 |
+
# Fallback to empty if not curated yet, though curate_dataset.py should be run first
|
| 62 |
+
_dataset = {"task1": {"emails": [], "ground_truth": {}}, "task2": {"emails": []}, "task3": {"emails": [], "ground_truth": {}, "urgent_ids": [], "archive_ids": [], "flag_ids": []}}
|
| 63 |
+
|
| 64 |
+
TASK1_EMAILS = _dataset["task1"]["emails"]
|
| 65 |
+
TASK1_GROUND_TRUTH = _dataset["task1"].get("ground_truth", {})
|
| 66 |
+
|
| 67 |
+
TASK2_EMAIL = _dataset["task2"]["emails"][0] if _dataset["task2"]["emails"] else {}
|
| 68 |
+
|
| 69 |
+
TASK3_EMAILS = _dataset["task3"]["emails"]
|
| 70 |
+
TASK3_GROUND_TRUTH = _dataset["task3"].get("ground_truth", {})
|
| 71 |
+
|
| 72 |
+
TASK3_URGENT_IDS = set(_dataset["task3"].get("urgent_ids", []))
|
| 73 |
+
TASK3_ARCHIVE_IDS = set(_dataset["task3"].get("archive_ids", []))
|
| 74 |
+
TASK3_FLAG_IDS = set(_dataset["task3"].get("flag_ids", []))
|
| 75 |
+
|
| 76 |
+
def grade_task1(state: InboxState) -> float:
|
| 77 |
+
score = 0.0
|
| 78 |
+
for email in state.inbox:
|
| 79 |
+
gt = TASK1_GROUND_TRUTH.get(email.id)
|
| 80 |
+
if gt and "urgent" in email.labels and gt == "urgent":
|
| 81 |
+
score += 0.2
|
| 82 |
+
elif gt and "normal" in email.labels and gt == "normal":
|
| 83 |
+
score += 0.2
|
| 84 |
+
elif gt and "low" in email.labels and gt == "low":
|
| 85 |
+
score += 0.2
|
| 86 |
+
return round(min(score, 1.0), 2)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def grade_task2(state: InboxState) -> float:
|
| 90 |
+
score = 0.0
|
| 91 |
+
email = next((e for e in state.inbox if e.id == "t2_001"), None)
|
| 92 |
+
if email is None or not email.replied or not email.reply_body:
|
| 93 |
+
return 0.0
|
| 94 |
+
|
| 95 |
+
reply = email.reply_body.lower()
|
| 96 |
+
|
| 97 |
+
issues_covered = 0
|
| 98 |
+
if "order" in reply and ("48291" in reply or "order" in reply):
|
| 99 |
+
issues_covered += 1
|
| 100 |
+
if any(w in reply for w in ["refund", "deliver", "shipment", "track"]):
|
| 101 |
+
issues_covered += 1
|
| 102 |
+
if any(w in reply for w in ["compensat", "apologi", "sorry", "inconvenien"]):
|
| 103 |
+
issues_covered += 1
|
| 104 |
+
score += 0.1 * issues_covered # up to 0.3
|
| 105 |
+
|
| 106 |
+
# +0.3 professional tone
|
| 107 |
+
professional_signals = ["dear", "sincerely", "regards", "thank you", "we apologize",
|
| 108 |
+
"we understand", "please", "we will"]
|
| 109 |
+
rude_signals = ["whatever", "not our fault", "calm down"]
|
| 110 |
+
tone_score = sum(1 for w in professional_signals if w in reply)
|
| 111 |
+
rude_penalty = sum(1 for w in rude_signals if w in reply)
|
| 112 |
+
score += min(0.3, tone_score * 0.05) - (rude_penalty * 0.1)
|
| 113 |
+
|
| 114 |
+
# +0.2 correct recipient / subject handling
|
| 115 |
+
if email.reply_body and len(email.reply_body) > 50:
|
| 116 |
+
score += 0.2
|
| 117 |
+
|
| 118 |
+
# +0.2 no fabricated facts (heuristic: no invented order dates / amounts)
|
| 119 |
+
fabrication_signals = ["$", "€", "refund amount", "exact date", "tracking number is"]
|
| 120 |
+
fab_hits = sum(1 for w in fabrication_signals if w in reply)
|
| 121 |
+
if fab_hits == 0:
|
| 122 |
+
score += 0.2
|
| 123 |
+
|
| 124 |
+
return round(max(0.0, min(score, 1.0)), 2)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def grade_task3(state: InboxState, penalties: dict) -> float:
|
| 128 |
+
score = 0.0
|
| 129 |
+
email_map = {e.id: e for e in state.inbox}
|
| 130 |
+
|
| 131 |
+
# Priority labels (0.2 per correct, 10 emails = max 2.0 → normalise to 0.5 weight)
|
| 132 |
+
label_score = 0.0
|
| 133 |
+
for eid, gt in TASK3_GROUND_TRUTH.items():
|
| 134 |
+
email = email_map.get(eid)
|
| 135 |
+
if email and gt in email.labels:
|
| 136 |
+
label_score += 0.2
|
| 137 |
+
score += min(label_score, 2.0) * 0.25 # normalise to 0.5
|
| 138 |
+
|
| 139 |
+
# Replies for urgent emails (max 0.4)
|
| 140 |
+
reply_scores = []
|
| 141 |
+
for eid in TASK3_URGENT_IDS:
|
| 142 |
+
email = email_map.get(eid)
|
| 143 |
+
if email and email.replied and email.reply_body:
|
| 144 |
+
reply_scores.append(min(len(email.reply_body) / 200, 1.0) * 0.1)
|
| 145 |
+
score += sum(reply_scores)
|
| 146 |
+
|
| 147 |
+
# Archive spam (0.05 each, max 0.1)
|
| 148 |
+
for eid in TASK3_ARCHIVE_IDS:
|
| 149 |
+
email = email_map.get(eid)
|
| 150 |
+
if email and email.archived:
|
| 151 |
+
score += 0.05
|
| 152 |
+
|
| 153 |
+
# Flag ambiguous (0.05 each)
|
| 154 |
+
for eid in TASK3_FLAG_IDS:
|
| 155 |
+
email = email_map.get(eid)
|
| 156 |
+
if email and email.flagged:
|
| 157 |
+
score += 0.05
|
| 158 |
+
|
| 159 |
+
# Penalties
|
| 160 |
+
score -= penalties.get("destructive_actions", 0) * 0.1
|
| 161 |
+
score -= penalties.get("loop_actions", 0) * 0.05
|
| 162 |
+
|
| 163 |
+
return round(max(0.0, min(score, 1.0)), 2)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ---------------------------------------------------------------------------
|
| 167 |
+
# Environment Class
|
| 168 |
+
# ---------------------------------------------------------------------------
|
| 169 |
+
|
| 170 |
+
class EmailTriageEnv:
|
| 171 |
+
"""OpenEnv-compatible Email Triage environment."""
|
| 172 |
+
|
| 173 |
+
TASKS = {1, 2, 3}
|
| 174 |
+
|
| 175 |
+
def __init__(self, task: int = 1):
|
| 176 |
+
assert task in self.TASKS, f"task must be one of {self.TASKS}"
|
| 177 |
+
self.task = task
|
| 178 |
+
self._state: Optional[InboxState] = None
|
| 179 |
+
self._penalties = {"destructive_actions": 0, "loop_actions": 0}
|
| 180 |
+
self._action_history: list[str] = []
|
| 181 |
+
self._done = False
|
| 182 |
+
|
| 183 |
+
# ------------------------------------------------------------------
|
| 184 |
+
# OpenEnv interface
|
| 185 |
+
# ------------------------------------------------------------------
|
| 186 |
+
|
| 187 |
+
def reset(self) -> Observation:
|
| 188 |
+
self._penalties = {"destructive_actions": 0, "loop_actions": 0}
|
| 189 |
+
self._action_history = []
|
| 190 |
+
self._done = False
|
| 191 |
+
|
| 192 |
+
if self.task == 1:
|
| 193 |
+
emails = [Email(**{**e}) for e in TASK1_EMAILS]
|
| 194 |
+
elif self.task == 2:
|
| 195 |
+
emails = [Email(**{**TASK2_EMAIL})]
|
| 196 |
+
else:
|
| 197 |
+
emails = [Email(**{**e}) for e in TASK3_EMAILS]
|
| 198 |
+
|
| 199 |
+
self._state = InboxState(inbox=emails)
|
| 200 |
+
|
| 201 |
+
return Observation(
|
| 202 |
+
status="ok",
|
| 203 |
+
message=f"Task {self.task} environment reset. Inbox contains {len(emails)} email(s).",
|
| 204 |
+
data={"task": self.task, "inbox_size": len(emails)},
|
| 205 |
+
step_count=0,
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
def state(self) -> dict:
|
| 209 |
+
assert self._state is not None, "Call reset() first."
|
| 210 |
+
return json.loads(self._state.model_dump_json(by_alias=True))
|
| 211 |
+
|
| 212 |
+
def step(self, action: Action) -> StepResult:
|
| 213 |
+
assert self._state is not None, "Call reset() first."
|
| 214 |
+
|
| 215 |
+
if self._done:
|
| 216 |
+
return StepResult(
|
| 217 |
+
observation=Observation(status="done", message="Episode already finished.", step_count=self._state.step_count),
|
| 218 |
+
reward=0.0,
|
| 219 |
+
done=True,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
self._state.step_count += 1
|
| 223 |
+
action_key = f"{action.action}:{action.email_id}"
|
| 224 |
+
|
| 225 |
+
# Loop detection
|
| 226 |
+
if self._action_history.count(action_key) >= 2:
|
| 227 |
+
self._penalties["loop_actions"] += 1
|
| 228 |
+
|
| 229 |
+
self._action_history.append(action_key)
|
| 230 |
+
obs, reward = self._dispatch(action)
|
| 231 |
+
obs.step_count = self._state.step_count
|
| 232 |
+
return StepResult(observation=obs, reward=reward, done=self._done)
|
| 233 |
+
|
| 234 |
+
def score(self) -> float:
|
| 235 |
+
"""Return current cumulative score (0-1)."""
|
| 236 |
+
assert self._state is not None, "Call reset() first."
|
| 237 |
+
if self.task == 1:
|
| 238 |
+
return grade_task1(self._state)
|
| 239 |
+
elif self.task == 2:
|
| 240 |
+
return grade_task2(self._state)
|
| 241 |
+
else:
|
| 242 |
+
return grade_task3(self._state, self._penalties)
|
| 243 |
+
|
| 244 |
+
# ------------------------------------------------------------------
|
| 245 |
+
# Action dispatch
|
| 246 |
+
# ------------------------------------------------------------------
|
| 247 |
+
|
| 248 |
+
def _dispatch(self, action: Action):
|
| 249 |
+
handlers = {
|
| 250 |
+
"list_inbox": self._act_list_inbox,
|
| 251 |
+
"read": self._act_read,
|
| 252 |
+
"label": self._act_label,
|
| 253 |
+
"draft_reply":self._act_draft_reply,
|
| 254 |
+
"archive": self._act_archive,
|
| 255 |
+
"flag": self._act_flag,
|
| 256 |
+
}
|
| 257 |
+
handler = handlers.get(action.action)
|
| 258 |
+
if handler is None:
|
| 259 |
+
return Observation(status="error", message=f"Unknown action: {action.action}"), 0.0
|
| 260 |
+
return handler(action)
|
| 261 |
+
|
| 262 |
+
def _act_list_inbox(self, action: Action):
|
| 263 |
+
summaries = [
|
| 264 |
+
{"id": e.id, "from": e.from_, "subject": e.subject,
|
| 265 |
+
"labels": e.labels, "replied": e.replied, "archived": e.archived, "flagged": e.flagged}
|
| 266 |
+
for e in self._state.inbox
|
| 267 |
+
]
|
| 268 |
+
return Observation(status="ok", message="Inbox listed.", data={"emails": summaries}), 0.0
|
| 269 |
+
|
| 270 |
+
def _act_read(self, action: Action):
|
| 271 |
+
email = self._find(action.email_id)
|
| 272 |
+
if email is None:
|
| 273 |
+
return Observation(status="error", message=f"Email {action.email_id} not found."), 0.0
|
| 274 |
+
return Observation(
|
| 275 |
+
status="ok",
|
| 276 |
+
message=f"Read email {action.email_id}.",
|
| 277 |
+
data=json.loads(email.model_dump_json(by_alias=True)),
|
| 278 |
+
), 0.0
|
| 279 |
+
|
| 280 |
+
def _act_label(self, action: Action):
|
| 281 |
+
email = self._find(action.email_id)
|
| 282 |
+
if email is None:
|
| 283 |
+
return Observation(status="error", message=f"Email {action.email_id} not found."), 0.0
|
| 284 |
+
if action.priority not in ("urgent", "normal", "low"):
|
| 285 |
+
return Observation(status="error", message="priority must be urgent | normal | low"), 0.0
|
| 286 |
+
|
| 287 |
+
# Remove existing priority labels then add new
|
| 288 |
+
email.labels = [l for l in email.labels if l not in ("urgent", "normal", "low")]
|
| 289 |
+
email.labels.append(action.priority)
|
| 290 |
+
|
| 291 |
+
incremental = self._incremental_label_reward(email.id, action.priority)
|
| 292 |
+
return Observation(
|
| 293 |
+
status="ok",
|
| 294 |
+
message=f"Labelled {action.email_id} as {action.priority}.",
|
| 295 |
+
data={"email_id": action.email_id, "priority": action.priority},
|
| 296 |
+
), incremental
|
| 297 |
+
|
| 298 |
+
def _act_draft_reply(self, action: Action):
|
| 299 |
+
email = self._find(action.email_id)
|
| 300 |
+
if email is None:
|
| 301 |
+
return Observation(status="error", message=f"Email {action.email_id} not found."), 0.0
|
| 302 |
+
if not action.body or len(action.body.strip()) < 10:
|
| 303 |
+
return Observation(status="error", message="Reply body too short."), 0.0
|
| 304 |
+
|
| 305 |
+
email.replied = True
|
| 306 |
+
email.reply_body = action.body
|
| 307 |
+
self._state.sent.append({"to": email.from_, "subject": f"Re: {email.subject}", "body": action.body})
|
| 308 |
+
return Observation(status="ok", message=f"Reply drafted for {action.email_id}."), 0.0
|
| 309 |
+
|
| 310 |
+
def _act_archive(self, action: Action):
|
| 311 |
+
email = self._find(action.email_id)
|
| 312 |
+
if email is None:
|
| 313 |
+
return Observation(status="error", message=f"Email {action.email_id} not found."), 0.0
|
| 314 |
+
|
| 315 |
+
# Penalty if archiving urgent email
|
| 316 |
+
if "urgent" in email.labels:
|
| 317 |
+
self._penalties["destructive_actions"] += 1
|
| 318 |
+
return Observation(
|
| 319 |
+
status="warning",
|
| 320 |
+
message=f"Archived urgent email {action.email_id} — penalty applied.",
|
| 321 |
+
), -0.1
|
| 322 |
+
|
| 323 |
+
email.archived = True
|
| 324 |
+
return Observation(status="ok", message=f"Email {action.email_id} archived."), 0.0
|
| 325 |
+
|
| 326 |
+
def _act_flag(self, action: Action):
|
| 327 |
+
email = self._find(action.email_id)
|
| 328 |
+
if email is None:
|
| 329 |
+
return Observation(status="error", message=f"Email {action.email_id} not found."), 0.0
|
| 330 |
+
email.flagged = True
|
| 331 |
+
email.flag_reason = action.reason or "unspecified"
|
| 332 |
+
return Observation(status="ok", message=f"Email {action.email_id} flagged for human review."), 0.0
|
| 333 |
+
|
| 334 |
+
# ------------------------------------------------------------------
|
| 335 |
+
# Helpers
|
| 336 |
+
# ------------------------------------------------------------------
|
| 337 |
+
|
| 338 |
+
def _find(self, email_id: Optional[str]) -> Optional[Email]:
|
| 339 |
+
if email_id is None:
|
| 340 |
+
return None
|
| 341 |
+
return next((e for e in self._state.inbox if e.id == email_id), None)
|
| 342 |
+
|
| 343 |
+
def _incremental_label_reward(self, email_id: str, priority: str) -> float:
|
| 344 |
+
"""Return +0.2 if label matches ground truth for task 1."""
|
| 345 |
+
if self.task == 1:
|
| 346 |
+
gt = TASK1_GROUND_TRUTH.get(email_id)
|
| 347 |
+
return 0.2 if gt == priority else 0.0
|
| 348 |
+
return 0.0
|
inference.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
inference.py -- Agent that solves all three Email Triage tasks.
|
| 3 |
+
|
| 4 |
+
Uses the OpenAI Python client pointed at a Groq-compatible endpoint.
|
| 5 |
+
All LLM config is controlled via environment variables:
|
| 6 |
+
- API_BASE_URL : base URL for the OpenAI-compatible API (has default)
|
| 7 |
+
- MODEL_NAME : model to use for inference (has default)
|
| 8 |
+
- HF_TOKEN : API key (mandatory, injected by runner)
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python inference.py --task 1 # run task 1
|
| 12 |
+
python inference.py --task 2 # run task 2
|
| 13 |
+
python inference.py --task 3 # run task 3 (full pipeline)
|
| 14 |
+
python inference.py --all # run all tasks and report scores
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import time
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
from openai import OpenAI
|
| 24 |
+
|
| 25 |
+
from environment import EmailTriageEnv, Action
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
# Configuration via environment variables (hackathon-compliant)
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
# API_BASE_URL: Groq's OpenAI-compatible endpoint (default provided)
|
| 32 |
+
API_BASE_URL = os.environ.get("API_BASE_URL", "https://api.groq.com/openai/v1")
|
| 33 |
+
|
| 34 |
+
# MODEL_NAME: which model to use on the endpoint (default provided)
|
| 35 |
+
MODEL_NAME = os.environ.get("MODEL_NAME", "llama-3.3-70b-versatile")
|
| 36 |
+
|
| 37 |
+
# HF_TOKEN: the API key -- mandatory, injected by hackathon runner
|
| 38 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 39 |
+
|
| 40 |
+
# Initialize the OpenAI client pointing at Groq (or whatever API_BASE_URL is)
|
| 41 |
+
client = OpenAI(
|
| 42 |
+
base_url=API_BASE_URL,
|
| 43 |
+
api_key=HF_TOKEN,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# System prompt
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
SYSTEM_PROMPT = """You are an expert email triage agent. You manage an inbox
|
| 51 |
+
efficiently by reading emails, assigning priority labels, drafting professional
|
| 52 |
+
replies, archiving junk, and flagging ambiguous items for human review.
|
| 53 |
+
|
| 54 |
+
You interact with an email environment through a strict JSON action interface.
|
| 55 |
+
Each response you produce MUST be a single valid JSON object -- no markdown,
|
| 56 |
+
no extra text -- in exactly this format:
|
| 57 |
+
|
| 58 |
+
{
|
| 59 |
+
"action": "<action_name>",
|
| 60 |
+
"email_id": "<id or null>",
|
| 61 |
+
"priority": "<urgent|normal|low or null>",
|
| 62 |
+
"body": "<reply text or null>",
|
| 63 |
+
"reason": "<flag reason or null>"
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
Available actions:
|
| 67 |
+
- list_inbox -- see all emails (no email_id needed)
|
| 68 |
+
- read -- read full body of an email (requires email_id)
|
| 69 |
+
- label -- assign a priority label (requires email_id + priority)
|
| 70 |
+
- draft_reply -- write a reply (requires email_id + body)
|
| 71 |
+
- archive -- move to archive (requires email_id)
|
| 72 |
+
- flag -- escalate for human review (requires email_id + reason)
|
| 73 |
+
|
| 74 |
+
Rules:
|
| 75 |
+
- NEVER archive an urgent email.
|
| 76 |
+
- ALWAYS read an email before labelling or replying.
|
| 77 |
+
- Draft replies ONLY for urgent emails (unless instructed otherwise).
|
| 78 |
+
- Archive obvious spam/junk.
|
| 79 |
+
- Flag emails that are ambiguous or need human judgment.
|
| 80 |
+
- When drafting replies: be professional, address all issues raised, do NOT
|
| 81 |
+
invent facts (no fake tracking numbers, refund amounts, dates).
|
| 82 |
+
- Signal completion by returning: {"action": "done", "email_id": null, "priority": null, "body": null, "reason": null}
|
| 83 |
+
"""
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Agent helpers
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
def parse_action(text: str) -> dict[str, Any]:
|
| 90 |
+
"""Extract JSON from model output (handles minor formatting noise)."""
|
| 91 |
+
text = text.strip()
|
| 92 |
+
# Strip markdown fences if present (some models wrap JSON in ```)
|
| 93 |
+
if text.startswith("```"):
|
| 94 |
+
lines = text.split("\n")
|
| 95 |
+
text = "\n".join(l for l in lines if not l.startswith("```"))
|
| 96 |
+
return json.loads(text)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def run_task(task: int, max_steps: int = 40, verbose: bool = True) -> float:
|
| 100 |
+
"""Run a single task with the LLM agent. Returns final score."""
|
| 101 |
+
|
| 102 |
+
env = EmailTriageEnv(task=task)
|
| 103 |
+
obs = env.reset()
|
| 104 |
+
|
| 105 |
+
# --- Hackathon output marker ---
|
| 106 |
+
print("[START]")
|
| 107 |
+
|
| 108 |
+
if verbose:
|
| 109 |
+
print(f"Task {task} | Model: {MODEL_NAME} | Endpoint: {API_BASE_URL}")
|
| 110 |
+
|
| 111 |
+
task_instruction = {
|
| 112 |
+
1: (
|
| 113 |
+
"Task: Read all 5 emails and label each as urgent, normal, or low priority. "
|
| 114 |
+
"Start by listing the inbox, then read each email before labelling it. "
|
| 115 |
+
"When done, output the done action."
|
| 116 |
+
),
|
| 117 |
+
2: (
|
| 118 |
+
"Task: Read the customer complaint email and draft a professional reply "
|
| 119 |
+
"that addresses ALL issues the customer raised. Be empathetic, professional, "
|
| 120 |
+
"and do not invent any facts. When done, output the done action."
|
| 121 |
+
),
|
| 122 |
+
3: (
|
| 123 |
+
"Task: Full triage pipeline on 10 emails.\n"
|
| 124 |
+
"1. List the inbox.\n"
|
| 125 |
+
"2. Read each email.\n"
|
| 126 |
+
"3. Label all emails (urgent / normal / low).\n"
|
| 127 |
+
"4. Draft replies for urgent emails.\n"
|
| 128 |
+
"5. Archive obvious spam / junk.\n"
|
| 129 |
+
"6. Flag ambiguous emails for human review.\n"
|
| 130 |
+
"When everything is done, output the done action."
|
| 131 |
+
),
|
| 132 |
+
}[task]
|
| 133 |
+
|
| 134 |
+
# Build the message history (OpenAI format: system + user/assistant turns)
|
| 135 |
+
messages = [
|
| 136 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 137 |
+
{"role": "user", "content": task_instruction},
|
| 138 |
+
]
|
| 139 |
+
step = 0
|
| 140 |
+
|
| 141 |
+
while step < max_steps:
|
| 142 |
+
# Call the LLM via the OpenAI client (works with Groq, vLLM, etc.)
|
| 143 |
+
# Retry with backoff on rate-limit errors (Groq free tier: 30 RPM)
|
| 144 |
+
raw = None
|
| 145 |
+
for attempt in range(3):
|
| 146 |
+
try:
|
| 147 |
+
response = client.chat.completions.create(
|
| 148 |
+
model=MODEL_NAME,
|
| 149 |
+
messages=messages,
|
| 150 |
+
max_tokens=1000,
|
| 151 |
+
temperature=0.2,
|
| 152 |
+
)
|
| 153 |
+
raw = response.choices[0].message.content
|
| 154 |
+
break
|
| 155 |
+
except Exception as e:
|
| 156 |
+
wait = 2 ** attempt * 5 # 5s, 10s, 20s
|
| 157 |
+
if verbose:
|
| 158 |
+
print(f" [RETRY] {type(e).__name__} -- waiting {wait}s (attempt {attempt+1}/3)")
|
| 159 |
+
time.sleep(wait)
|
| 160 |
+
|
| 161 |
+
if raw is None:
|
| 162 |
+
if verbose:
|
| 163 |
+
print(" [ERROR] LLM call failed after 3 retries. Ending task.")
|
| 164 |
+
break
|
| 165 |
+
|
| 166 |
+
messages.append({"role": "assistant", "content": raw})
|
| 167 |
+
|
| 168 |
+
if verbose:
|
| 169 |
+
print(f"[Step {step+1}] Agent: {raw[:120]}{'...' if len(raw) > 120 else ''}")
|
| 170 |
+
|
| 171 |
+
# Parse action from model output
|
| 172 |
+
try:
|
| 173 |
+
action_dict = parse_action(raw)
|
| 174 |
+
except json.JSONDecodeError as e:
|
| 175 |
+
if verbose:
|
| 176 |
+
print(f" [WARN] JSON parse error: {e} -- asking agent to retry")
|
| 177 |
+
messages.append({
|
| 178 |
+
"role": "user",
|
| 179 |
+
"content": f"Your last response was not valid JSON. Error: {e}. Please try again with a valid JSON action."
|
| 180 |
+
})
|
| 181 |
+
continue
|
| 182 |
+
|
| 183 |
+
# Done?
|
| 184 |
+
if action_dict.get("action") == "done":
|
| 185 |
+
if verbose:
|
| 186 |
+
print(" Agent signalled completion.")
|
| 187 |
+
break
|
| 188 |
+
|
| 189 |
+
# Execute action in the environment
|
| 190 |
+
try:
|
| 191 |
+
action = Action(**action_dict)
|
| 192 |
+
except Exception as e:
|
| 193 |
+
messages.append({"role": "user", "content": f"Invalid action format: {e}. Try again."})
|
| 194 |
+
continue
|
| 195 |
+
|
| 196 |
+
result = env.step(action)
|
| 197 |
+
|
| 198 |
+
# --- Hackathon output marker ---
|
| 199 |
+
print("[STEP]")
|
| 200 |
+
|
| 201 |
+
if verbose:
|
| 202 |
+
print(f" Env: [{result.observation.status}] {result.observation.message} reward={result.reward:+.2f}")
|
| 203 |
+
|
| 204 |
+
# Feed observation back to the agent
|
| 205 |
+
obs_summary = {
|
| 206 |
+
"status": result.observation.status,
|
| 207 |
+
"message": result.observation.message,
|
| 208 |
+
"data": result.observation.data,
|
| 209 |
+
"step": result.observation.step_count,
|
| 210 |
+
"running_score": env.score(),
|
| 211 |
+
}
|
| 212 |
+
messages.append({"role": "user", "content": json.dumps(obs_summary)})
|
| 213 |
+
step += 1
|
| 214 |
+
|
| 215 |
+
final_score = env.score()
|
| 216 |
+
|
| 217 |
+
# --- Hackathon output marker ---
|
| 218 |
+
print("[END]")
|
| 219 |
+
|
| 220 |
+
if verbose:
|
| 221 |
+
print(f"Final score: {final_score:.2f} / 1.00 (steps used: {step})")
|
| 222 |
+
|
| 223 |
+
return final_score
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
# Entry point
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
|
| 230 |
+
def main():
|
| 231 |
+
parser = argparse.ArgumentParser(description="Email Triage Agent")
|
| 232 |
+
parser.add_argument("--task", type=int, choices=[1, 2, 3],
|
| 233 |
+
help="Run a specific task (1, 2, or 3)")
|
| 234 |
+
parser.add_argument("--all", action="store_true",
|
| 235 |
+
help="Run all three tasks")
|
| 236 |
+
parser.add_argument("--quiet", action="store_true",
|
| 237 |
+
help="Suppress verbose output")
|
| 238 |
+
args = parser.parse_args()
|
| 239 |
+
|
| 240 |
+
verbose = not args.quiet
|
| 241 |
+
|
| 242 |
+
if args.all:
|
| 243 |
+
scores = {}
|
| 244 |
+
for t in [1, 2, 3]:
|
| 245 |
+
scores[t] = run_task(t, verbose=verbose)
|
| 246 |
+
print("=" * 40)
|
| 247 |
+
print(" FINAL SCORES")
|
| 248 |
+
print("=" * 40)
|
| 249 |
+
for t, s in scores.items():
|
| 250 |
+
print(f" Task {t}: {s:.2f}")
|
| 251 |
+
print(f" Average: {sum(scores.values()) / 3:.2f}")
|
| 252 |
+
elif args.task:
|
| 253 |
+
run_task(args.task, verbose=verbose)
|
| 254 |
+
else:
|
| 255 |
+
parser.print_help()
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
if __name__ == "__main__":
|
| 259 |
+
main()
|
openenv.yaml
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: email-triage-env
|
| 2 |
+
version: "1.0.0"
|
| 3 |
+
description: >
|
| 4 |
+
An email triage and response environment where an agent reads inbox emails,
|
| 5 |
+
assigns priority labels (urgent/normal/low), drafts professional replies,
|
| 6 |
+
archives junk, and flags ambiguous messages for human review.
|
| 7 |
+
|
| 8 |
+
tasks:
|
| 9 |
+
- id: task1
|
| 10 |
+
name: Inbox Prioritisation
|
| 11 |
+
difficulty: easy
|
| 12 |
+
description: >
|
| 13 |
+
Read 5 emails and label each as urgent, normal, or low priority.
|
| 14 |
+
max_steps: 20
|
| 15 |
+
reward:
|
| 16 |
+
type: incremental
|
| 17 |
+
max: 1.0
|
| 18 |
+
per_correct_label: 0.2
|
| 19 |
+
|
| 20 |
+
- id: task2
|
| 21 |
+
name: Draft a Reply
|
| 22 |
+
difficulty: medium
|
| 23 |
+
description: >
|
| 24 |
+
Given a customer complaint email, draft a professional reply that
|
| 25 |
+
addresses all stated issues without fabricating facts.
|
| 26 |
+
max_steps: 10
|
| 27 |
+
reward:
|
| 28 |
+
type: checklist
|
| 29 |
+
max: 1.0
|
| 30 |
+
criteria:
|
| 31 |
+
- addresses_all_issues: 0.3
|
| 32 |
+
- professional_tone: 0.3
|
| 33 |
+
- correct_recipient_subject: 0.2
|
| 34 |
+
- no_fabricated_facts: 0.2
|
| 35 |
+
|
| 36 |
+
- id: task3
|
| 37 |
+
name: Full Triage Pipeline
|
| 38 |
+
difficulty: hard
|
| 39 |
+
description: >
|
| 40 |
+
10-email inbox: prioritise all, draft replies for urgent emails,
|
| 41 |
+
archive junk, flag ambiguous emails for human review.
|
| 42 |
+
max_steps: 60
|
| 43 |
+
reward:
|
| 44 |
+
type: holistic
|
| 45 |
+
max: 1.0
|
| 46 |
+
penalties:
|
| 47 |
+
destructive_action: -0.1
|
| 48 |
+
loop_action: -0.05
|
| 49 |
+
|
| 50 |
+
environment:
|
| 51 |
+
language: python
|
| 52 |
+
entry_point: server.py
|
| 53 |
+
port: 8000
|
| 54 |
+
health_check: /health
|
| 55 |
+
state_endpoint: /state
|
| 56 |
+
reset_endpoint: /reset
|
| 57 |
+
step_endpoint: /step
|
| 58 |
+
|
| 59 |
+
inference:
|
| 60 |
+
entry_point: inference.py
|
| 61 |
+
model: llama-3.3-70b-versatile
|
| 62 |
+
|
| 63 |
+
resources:
|
| 64 |
+
cpu: 2
|
| 65 |
+
memory_gb: 4
|
| 66 |
+
gpu: false
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openai>=1.0.0
|
| 2 |
+
fastapi>=0.110.0
|
| 3 |
+
uvicorn>=0.29.0
|
| 4 |
+
pydantic>=2.6.0
|
server.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI server exposing the Email Triage environment via HTTP.
|
| 3 |
+
Endpoints mirror the OpenEnv spec.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI, HTTPException
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
from typing import Optional
|
| 10 |
+
import uvicorn
|
| 11 |
+
|
| 12 |
+
from environment import EmailTriageEnv, Action
|
| 13 |
+
|
| 14 |
+
app = FastAPI(title="Email Triage Environment", version="1.0.0")
|
| 15 |
+
|
| 16 |
+
app.add_middleware(
|
| 17 |
+
CORSMiddleware,
|
| 18 |
+
allow_origins=["*"],
|
| 19 |
+
allow_methods=["*"],
|
| 20 |
+
allow_headers=["*"],
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# One env per task (task is set at reset time)
|
| 24 |
+
_envs: dict[int, EmailTriageEnv] = {}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ResetRequest(BaseModel):
|
| 28 |
+
task: int = 1
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class StepRequest(BaseModel):
|
| 32 |
+
task: int = 1
|
| 33 |
+
action: Action
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _get_env(task: int) -> EmailTriageEnv:
|
| 37 |
+
if task not in _envs:
|
| 38 |
+
raise HTTPException(status_code=400, detail=f"Task {task} not initialised. Call /reset first.")
|
| 39 |
+
return _envs[task]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@app.get("/health")
|
| 43 |
+
def health():
|
| 44 |
+
return {"status": "ok"}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@app.post("/reset")
|
| 48 |
+
def reset(req: ResetRequest):
|
| 49 |
+
env = EmailTriageEnv(task=req.task)
|
| 50 |
+
obs = env.reset()
|
| 51 |
+
_envs[req.task] = env
|
| 52 |
+
return {"observation": obs.model_dump(), "state": env.state()}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.post("/step")
|
| 56 |
+
def step(req: StepRequest):
|
| 57 |
+
env = _get_env(req.task)
|
| 58 |
+
result = env.step(req.action)
|
| 59 |
+
return {
|
| 60 |
+
"observation": result.observation.model_dump(),
|
| 61 |
+
"reward": result.reward,
|
| 62 |
+
"done": result.done,
|
| 63 |
+
"info": result.info,
|
| 64 |
+
"score": env.score(),
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@app.get("/state")
|
| 69 |
+
def state(task: int = 1):
|
| 70 |
+
env = _get_env(task)
|
| 71 |
+
return {"state": env.state(), "score": env.score()}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@app.get("/score")
|
| 75 |
+
def score(task: int = 1):
|
| 76 |
+
env = _get_env(task)
|
| 77 |
+
return {"score": env.score(), "task": task}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
test_out.txt
ADDED
|
Binary file (1.28 kB). View file
|
|
|
tests.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tests.py — Unit tests for the Email Triage environment.
|
| 3 |
+
Run with: python tests.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
from environment import (
|
| 9 |
+
EmailTriageEnv,
|
| 10 |
+
Action,
|
| 11 |
+
grade_task1,
|
| 12 |
+
grade_task2,
|
| 13 |
+
InboxState,
|
| 14 |
+
Email,
|
| 15 |
+
TASK1_GROUND_TRUTH,
|
| 16 |
+
TASK1_EMAILS
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
def run_test(name: str, fn):
|
| 20 |
+
try:
|
| 21 |
+
fn()
|
| 22 |
+
print(f" ✅ {name}")
|
| 23 |
+
return True
|
| 24 |
+
except AssertionError as e:
|
| 25 |
+
print(f" ❌ {name}: {e}")
|
| 26 |
+
return False
|
| 27 |
+
except Exception as e:
|
| 28 |
+
print(f" 💥 {name}: {type(e).__name__}: {e}")
|
| 29 |
+
return False
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Task 1 tests
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
def test_task1_reset():
|
| 37 |
+
env = EmailTriageEnv(task=1)
|
| 38 |
+
obs = env.reset()
|
| 39 |
+
assert obs.status == "ok"
|
| 40 |
+
assert obs.data["inbox_size"] == 5
|
| 41 |
+
|
| 42 |
+
def test_task1_list():
|
| 43 |
+
env = EmailTriageEnv(task=1)
|
| 44 |
+
env.reset()
|
| 45 |
+
result = env.step(Action(action="list_inbox"))
|
| 46 |
+
assert result.observation.status == "ok"
|
| 47 |
+
assert len(result.observation.data["emails"]) == 5
|
| 48 |
+
|
| 49 |
+
def test_task1_read():
|
| 50 |
+
env = EmailTriageEnv(task=1)
|
| 51 |
+
env.reset()
|
| 52 |
+
result = env.step(Action(action="read", email_id="t1_001"))
|
| 53 |
+
assert result.observation.status == "ok"
|
| 54 |
+
assert len(result.observation.data["subject"]) > 0
|
| 55 |
+
|
| 56 |
+
def test_task1_label_correct():
|
| 57 |
+
env = EmailTriageEnv(task=1)
|
| 58 |
+
env.reset()
|
| 59 |
+
gt = TASK1_GROUND_TRUTH["t1_001"]
|
| 60 |
+
result = env.step(Action(action="label", email_id="t1_001", priority=gt))
|
| 61 |
+
assert result.reward == 0.2, f"Expected 0.2, got {result.reward}"
|
| 62 |
+
|
| 63 |
+
def test_task1_label_wrong():
|
| 64 |
+
env = EmailTriageEnv(task=1)
|
| 65 |
+
env.reset()
|
| 66 |
+
gt = TASK1_GROUND_TRUTH["t1_001"]
|
| 67 |
+
wrong = "low" if gt in ("urgent", "normal") else "urgent"
|
| 68 |
+
result = env.step(Action(action="label", email_id="t1_001", priority=wrong))
|
| 69 |
+
assert result.reward == 0.0
|
| 70 |
+
|
| 71 |
+
def test_task1_full_score():
|
| 72 |
+
env = EmailTriageEnv(task=1)
|
| 73 |
+
env.reset()
|
| 74 |
+
for eid, priority in TASK1_GROUND_TRUTH.items():
|
| 75 |
+
env.step(Action(action="label", email_id=eid, priority=priority))
|
| 76 |
+
assert env.score() == 1.0, f"Expected 1.0, got {env.score()}"
|
| 77 |
+
|
| 78 |
+
def test_task1_partial_score():
|
| 79 |
+
env = EmailTriageEnv(task=1)
|
| 80 |
+
env.reset()
|
| 81 |
+
eids = list(TASK1_GROUND_TRUTH.keys())
|
| 82 |
+
env.step(Action(action="label", email_id=eids[0], priority=TASK1_GROUND_TRUTH[eids[0]]))
|
| 83 |
+
env.step(Action(action="label", email_id=eids[1], priority=TASK1_GROUND_TRUTH[eids[1]]))
|
| 84 |
+
score = env.score()
|
| 85 |
+
assert score == 0.4, f"Expected 0.4, got {score}"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
# Task 2 tests
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
|
| 92 |
+
def test_task2_reset():
|
| 93 |
+
env = EmailTriageEnv(task=2)
|
| 94 |
+
obs = env.reset()
|
| 95 |
+
assert obs.data["inbox_size"] == 1
|
| 96 |
+
|
| 97 |
+
def test_task2_no_reply_zero():
|
| 98 |
+
env = EmailTriageEnv(task=2)
|
| 99 |
+
env.reset()
|
| 100 |
+
assert env.score() == 0.0
|
| 101 |
+
|
| 102 |
+
def test_task2_good_reply():
|
| 103 |
+
env = EmailTriageEnv(task=2)
|
| 104 |
+
env.reset()
|
| 105 |
+
env.step(Action(
|
| 106 |
+
action="draft_reply",
|
| 107 |
+
email_id="t2_001",
|
| 108 |
+
body=(
|
| 109 |
+
"Dear Jamie,\n\nThank you for reaching out. We sincerely apologize for the "
|
| 110 |
+
"experience you have had with order #48291. We understand how frustrating "
|
| 111 |
+
"this must be.\n\nWe are urgently investigating the status of your delivery "
|
| 112 |
+
"and will provide an update within 2 hours. If we cannot confirm delivery "
|
| 113 |
+
"within 48 hours we will process a full refund immediately. We will also "
|
| 114 |
+
"review the service failures you experienced and follow up regarding "
|
| 115 |
+
"compensation.\n\nWe truly value your business and are committed to "
|
| 116 |
+
"making this right.\n\nSincerely,\nCustomer Support Team"
|
| 117 |
+
),
|
| 118 |
+
))
|
| 119 |
+
score = env.score()
|
| 120 |
+
assert score > 0.5, f"Expected score > 0.5, got {score}"
|
| 121 |
+
|
| 122 |
+
def test_task2_short_reply_penalised():
|
| 123 |
+
env = EmailTriageEnv(task=2)
|
| 124 |
+
env.reset()
|
| 125 |
+
result = env.step(Action(action="draft_reply", email_id="t2_001", body="ok"))
|
| 126 |
+
assert result.observation.status == "error"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ---------------------------------------------------------------------------
|
| 130 |
+
# Task 3 tests
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
|
| 133 |
+
def test_task3_reset():
|
| 134 |
+
env = EmailTriageEnv(task=3)
|
| 135 |
+
obs = env.reset()
|
| 136 |
+
assert obs.data["inbox_size"] == 10
|
| 137 |
+
|
| 138 |
+
def test_task3_archive_spam_no_penalty():
|
| 139 |
+
env = EmailTriageEnv(task=3)
|
| 140 |
+
env.reset()
|
| 141 |
+
# Label spam as low first (so archiving doesn't trigger urgent penalty)
|
| 142 |
+
env.step(Action(action="label", email_id="t3_002", priority="low"))
|
| 143 |
+
result = env.step(Action(action="archive", email_id="t3_002"))
|
| 144 |
+
assert result.observation.status == "ok"
|
| 145 |
+
|
| 146 |
+
def test_task3_archive_urgent_penalty():
|
| 147 |
+
env = EmailTriageEnv(task=3)
|
| 148 |
+
env.reset()
|
| 149 |
+
env.step(Action(action="label", email_id="t3_001", priority="urgent"))
|
| 150 |
+
result = env.step(Action(action="archive", email_id="t3_001"))
|
| 151 |
+
assert result.reward == -0.1
|
| 152 |
+
assert result.observation.status == "warning"
|
| 153 |
+
|
| 154 |
+
def test_task3_flag():
|
| 155 |
+
env = EmailTriageEnv(task=3)
|
| 156 |
+
env.reset()
|
| 157 |
+
result = env.step(Action(action="flag", email_id="t3_009", reason="Missing context — need sender identity"))
|
| 158 |
+
assert result.observation.status == "ok"
|
| 159 |
+
|
| 160 |
+
def test_task3_loop_detection():
|
| 161 |
+
env = EmailTriageEnv(task=3)
|
| 162 |
+
env.reset()
|
| 163 |
+
for _ in range(3):
|
| 164 |
+
env.step(Action(action="label", email_id="t3_006", priority="normal"))
|
| 165 |
+
assert env._penalties["loop_actions"] >= 1
|
| 166 |
+
|
| 167 |
+
def test_task3_not_found():
|
| 168 |
+
env = EmailTriageEnv(task=3)
|
| 169 |
+
env.reset()
|
| 170 |
+
result = env.step(Action(action="read", email_id="nonexistent"))
|
| 171 |
+
assert result.observation.status == "error"
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ---------------------------------------------------------------------------
|
| 175 |
+
# Runner
|
| 176 |
+
# ---------------------------------------------------------------------------
|
| 177 |
+
|
| 178 |
+
if __name__ == "__main__":
|
| 179 |
+
tests = [
|
| 180 |
+
# Task 1
|
| 181 |
+
("Task1 reset", test_task1_reset),
|
| 182 |
+
("Task1 list inbox", test_task1_list),
|
| 183 |
+
("Task1 read email", test_task1_read),
|
| 184 |
+
("Task1 correct label reward", test_task1_label_correct),
|
| 185 |
+
("Task1 wrong label no reward", test_task1_label_wrong),
|
| 186 |
+
("Task1 full score 1.0", test_task1_full_score),
|
| 187 |
+
("Task1 partial score 0.4", test_task1_partial_score),
|
| 188 |
+
# Task 2
|
| 189 |
+
("Task2 reset", test_task2_reset),
|
| 190 |
+
("Task2 no reply = 0.0", test_task2_no_reply_zero),
|
| 191 |
+
("Task2 good reply > 0.5", test_task2_good_reply),
|
| 192 |
+
("Task2 short reply error", test_task2_short_reply_penalised),
|
| 193 |
+
# Task 3
|
| 194 |
+
("Task3 reset", test_task3_reset),
|
| 195 |
+
("Task3 archive spam no penalty", test_task3_archive_spam_no_penalty),
|
| 196 |
+
("Task3 archive urgent = penalty", test_task3_archive_urgent_penalty),
|
| 197 |
+
("Task3 flag ambiguous", test_task3_flag),
|
| 198 |
+
("Task3 loop detection", test_task3_loop_detection),
|
| 199 |
+
("Task3 not found error", test_task3_not_found),
|
| 200 |
+
]
|
| 201 |
+
|
| 202 |
+
print("\nRunning Email Triage Environment Tests")
|
| 203 |
+
print("=" * 45)
|
| 204 |
+
passed = sum(run_test(name, fn) for name, fn in tests)
|
| 205 |
+
total = len(tests)
|
| 206 |
+
print(f"\n{passed}/{total} tests passed")
|
| 207 |
+
sys.exit(0 if passed == total else 1)
|