Spaces:
Sleeping
Sleeping
Initial - Commit
Browse files- Dockerfile +45 -0
- README.md +277 -0
- data.py +216 -0
- environment.py +358 -0
- graders.py +261 -0
- inference.py +279 -0
- models.py +144 -0
- openenv.yaml +115 -0
- requirements.txt +7 -0
- server.py +189 -0
- tasks.py +99 -0
Dockerfile
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Support Ticket Triage — OpenEnv Dockerfile ─────────────────────────────
|
| 2 |
+
# Targets HuggingFace Spaces (Docker SDK).
|
| 3 |
+
# Build: docker build -t ticket-triage-env .
|
| 4 |
+
# Run: docker run -p 7860:7860 ticket-triage-env
|
| 5 |
+
# ---------------------------------------------------------------------------
|
| 6 |
+
|
| 7 |
+
FROM python:3.11-slim
|
| 8 |
+
|
| 9 |
+
LABEL org.opencontainers.image.title="Support Ticket Triage — OpenEnv"
|
| 10 |
+
LABEL org.opencontainers.image.description="OpenEnv environment for AI support ticket triage"
|
| 11 |
+
LABEL space_sdk="docker"
|
| 12 |
+
|
| 13 |
+
# --- system deps ---
|
| 14 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 15 |
+
curl \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# --- app user (HF Spaces runs as non-root) ---
|
| 19 |
+
RUN useradd -m -u 1000 appuser
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# --- Python dependencies ---
|
| 23 |
+
COPY requirements.txt .
|
| 24 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 25 |
+
|
| 26 |
+
# --- Application code ---
|
| 27 |
+
COPY env/ ./env/
|
| 28 |
+
COPY server.py .
|
| 29 |
+
COPY openenv.yaml .
|
| 30 |
+
COPY inference.py .
|
| 31 |
+
COPY README.md .
|
| 32 |
+
|
| 33 |
+
# Transfer ownership
|
| 34 |
+
RUN chown -R appuser:appuser /app
|
| 35 |
+
USER appuser
|
| 36 |
+
|
| 37 |
+
# --- HuggingFace Spaces expects port 7860 ---
|
| 38 |
+
EXPOSE 7860
|
| 39 |
+
|
| 40 |
+
# Healthcheck so HF knows when the server is ready
|
| 41 |
+
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
| 42 |
+
CMD curl -f http://localhost:7860/ || exit 1
|
| 43 |
+
|
| 44 |
+
# --- Launch the FastAPI server ---
|
| 45 |
+
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "2"]
|
README.md
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🎫 Support Ticket Triage — OpenEnv
|
| 2 |
+
|
| 3 |
+
A real-world [OpenEnv](https://huggingface.co/openenv) environment where AI agents must
|
| 4 |
+
**triage, route, and resolve customer support tickets** across three difficulty levels.
|
| 5 |
+
|
| 6 |
+
This environment models a task that real support organisations handle thousands of times
|
| 7 |
+
per day: reading an incoming ticket, routing it to the right team, judging its urgency,
|
| 8 |
+
crafting a helpful reply, and — for hard tasks — managing a multi-turn conversation
|
| 9 |
+
through to resolution.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 🌍 Motivation
|
| 14 |
+
|
| 15 |
+
Support ticket triage is:
|
| 16 |
+
|
| 17 |
+
- **High-volume** — enterprise companies route millions of tickets per year
|
| 18 |
+
- **High-stakes** — wrong routing costs money; slow responses lose customers
|
| 19 |
+
- **Multi-step** — requires reading comprehension, classification, and generation
|
| 20 |
+
- **Under-explored** in RL/agent benchmarks (most focus on code or web tasks)
|
| 21 |
+
|
| 22 |
+
This environment fills a genuine gap: an OpenEnv where agents can be trained and
|
| 23 |
+
evaluated on a real knowledge-worker workflow.
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## 🏗️ Environment Overview
|
| 28 |
+
|
| 29 |
+
| Field | Value |
|
| 30 |
+
|-------|-------|
|
| 31 |
+
| Action space | Discrete (7 action types) + optional text fields |
|
| 32 |
+
| Observation space | Structured ticket + conversation history |
|
| 33 |
+
| Reward | Shaped per-step + terminal grader score [0.0, 1.0] |
|
| 34 |
+
| Episodes | Stateful, multi-step (up to 12 steps for Hard) |
|
| 35 |
+
| Tasks | 3 (Easy / Medium / Hard) |
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## 📋 Tasks
|
| 40 |
+
|
| 41 |
+
### Task 1 — Ticket Routing *(Easy)*
|
| 42 |
+
|
| 43 |
+
> Route the incoming ticket to the correct department.
|
| 44 |
+
|
| 45 |
+
- **Actions required**: `ROUTE`
|
| 46 |
+
- **Score**: 1.0 for correct department, 0.1 for wrong (partial), 0.0 for no attempt
|
| 47 |
+
- **Departments**: `billing`, `technical_support`, `sales`, `customer_success`, `legal`
|
| 48 |
+
- **Max steps**: 3
|
| 49 |
+
- **Baseline score**: ~0.80
|
| 50 |
+
|
| 51 |
+
### Task 2 — Full Triage *(Medium)*
|
| 52 |
+
|
| 53 |
+
> Fully triage the ticket: route, set urgency, tag, and respond.
|
| 54 |
+
|
| 55 |
+
| Sub-task | Weight |
|
| 56 |
+
|----------|--------|
|
| 57 |
+
| Correct routing | 30% |
|
| 58 |
+
| Correct urgency level | 25% |
|
| 59 |
+
| Relevant tags applied | 20% |
|
| 60 |
+
| Informative customer response | 25% |
|
| 61 |
+
|
| 62 |
+
- **Max steps**: 8
|
| 63 |
+
- **Baseline score**: ~0.55
|
| 64 |
+
|
| 65 |
+
### Task 3 — Full Resolution *(Hard)*
|
| 66 |
+
|
| 67 |
+
> Manage a multi-turn support conversation to full resolution.
|
| 68 |
+
|
| 69 |
+
| Sub-task | Weight |
|
| 70 |
+
|----------|--------|
|
| 71 |
+
| Correct routing | 15% |
|
| 72 |
+
| Correct urgency | 10% |
|
| 73 |
+
| Quality initial response | 20% |
|
| 74 |
+
| Escalation decision | 20% |
|
| 75 |
+
| Handle customer follow-up | 20% |
|
| 76 |
+
| Close with resolution note | 15% |
|
| 77 |
+
|
| 78 |
+
- **Max steps**: 12
|
| 79 |
+
- **Baseline score**: ~0.40
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## 🔌 API Reference
|
| 84 |
+
|
| 85 |
+
The environment is served as a REST API (FastAPI).
|
| 86 |
+
|
| 87 |
+
### `GET /`
|
| 88 |
+
Health check. Returns environment metadata and task list.
|
| 89 |
+
|
| 90 |
+
### `POST /reset`
|
| 91 |
+
Start a new episode.
|
| 92 |
+
|
| 93 |
+
```json
|
| 94 |
+
{
|
| 95 |
+
"task_name": "route", // "route" | "triage" | "resolve"
|
| 96 |
+
"ticket_id": "TKT-001", // optional — omit for random
|
| 97 |
+
"seed": 42, // optional RNG seed
|
| 98 |
+
"session_id": "abc123" // optional — generated if omitted
|
| 99 |
+
}
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Returns: `{ "observation": {...}, "session_id": "..." }`
|
| 103 |
+
|
| 104 |
+
### `POST /step`
|
| 105 |
+
Apply an action.
|
| 106 |
+
|
| 107 |
+
```json
|
| 108 |
+
{
|
| 109 |
+
"session_id": "abc123",
|
| 110 |
+
"action_type": "route", // required
|
| 111 |
+
"department": "billing", // for ROUTE
|
| 112 |
+
"response_text": "Hello...", // for RESPOND
|
| 113 |
+
"urgency": "high", // for SET_URGENCY
|
| 114 |
+
"tags": ["billing", "refund"], // for TAG
|
| 115 |
+
"escalation_reason": "...", // for ESCALATE
|
| 116 |
+
"resolution_note": "..." // for CLOSE
|
| 117 |
+
}
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
Returns: `{ "observation": {...}, "reward": {...}, "done": bool, "info": {...}, "session_id": "..." }`
|
| 121 |
+
|
| 122 |
+
### `GET /state?session_id=abc123`
|
| 123 |
+
Full internal state including ground truth labels (for debugging/evaluation).
|
| 124 |
+
|
| 125 |
+
### `GET /tasks`
|
| 126 |
+
List all tasks with metadata.
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
## 🎬 Action Space
|
| 131 |
+
|
| 132 |
+
| action_type | Required fields | Description |
|
| 133 |
+
|-------------|----------------|-------------|
|
| 134 |
+
| `route` | `department` | Route ticket to a department |
|
| 135 |
+
| `set_urgency` | `urgency` | Set priority level |
|
| 136 |
+
| `respond` | `response_text` | Send a message to the customer |
|
| 137 |
+
| `tag` | `tags` | Apply classification labels |
|
| 138 |
+
| `escalate` | `escalation_reason` | Escalate with explanation |
|
| 139 |
+
| `close` | `resolution_note` | Resolve and close the ticket |
|
| 140 |
+
| `noop` | — | Take no action (wastes a step) |
|
| 141 |
+
|
| 142 |
+
**Departments**: `billing` · `technical_support` · `sales` · `customer_success` · `legal`
|
| 143 |
+
|
| 144 |
+
**Urgency levels**: `low` · `medium` · `high` · `critical`
|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
|
| 148 |
+
## 👁️ Observation Space
|
| 149 |
+
|
| 150 |
+
```json
|
| 151 |
+
{
|
| 152 |
+
"ticket_id": "TKT-001",
|
| 153 |
+
"subject": "Double charged on my invoice",
|
| 154 |
+
"body": "Full ticket text...",
|
| 155 |
+
"sender_email": "user@example.com",
|
| 156 |
+
"sender_name": "Jane Smith",
|
| 157 |
+
"conversation_history": [
|
| 158 |
+
{"sender": "Jane Smith", "content": "...", "timestamp": "2024-01-01T12:00:00Z"}
|
| 159 |
+
],
|
| 160 |
+
"current_department": null,
|
| 161 |
+
"current_urgency": null,
|
| 162 |
+
"tags": [],
|
| 163 |
+
"is_escalated": false,
|
| 164 |
+
"is_closed": false,
|
| 165 |
+
"step_number": 0,
|
| 166 |
+
"task_name": "route",
|
| 167 |
+
"task_description": "Route the ticket to the correct department...",
|
| 168 |
+
"available_actions": ["route", "respond", "set_urgency", "tag", "escalate", "close", "noop"]
|
| 169 |
+
}
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
## 🏆 Reward Function
|
| 175 |
+
|
| 176 |
+
**Step rewards** (shaped, provide dense signal):
|
| 177 |
+
- +0.30 — correct ROUTE
|
| 178 |
+
- +0.20 — correct SET_URGENCY
|
| 179 |
+
- +0.10×overlap — TAG matching required tags
|
| 180 |
+
- +0.15×quality — RESPOND addressing key topics
|
| 181 |
+
- +0.20 — justified ESCALATE
|
| 182 |
+
- −0.10 — unjustified ESCALATE
|
| 183 |
+
- +0.10 — CLOSE with substantive resolution note
|
| 184 |
+
|
| 185 |
+
**Terminal reward** (authoritative, [0.0, 1.0]):
|
| 186 |
+
Each task has a dedicated deterministic grader that computes a weighted aggregate
|
| 187 |
+
of all sub-task scores. The terminal reward is returned in `info["final_grader_reward"]`.
|
| 188 |
+
|
| 189 |
+
---
|
| 190 |
+
|
| 191 |
+
## 🚀 Setup & Usage
|
| 192 |
+
|
| 193 |
+
### Local development
|
| 194 |
+
|
| 195 |
+
```bash
|
| 196 |
+
# Install dependencies
|
| 197 |
+
pip install -r requirements.txt
|
| 198 |
+
|
| 199 |
+
# Start the environment server
|
| 200 |
+
python server.py
|
| 201 |
+
# → Server running at http://localhost:7860
|
| 202 |
+
|
| 203 |
+
# In another terminal, run the baseline inference
|
| 204 |
+
export API_BASE_URL="https://router.huggingface.co/v1"
|
| 205 |
+
export MODEL_NAME="meta-llama/Llama-3.3-70B-Instruct"
|
| 206 |
+
export HF_TOKEN="your_token_here"
|
| 207 |
+
export ENV_BASE_URL="http://localhost:7860"
|
| 208 |
+
|
| 209 |
+
python inference.py
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
### Docker
|
| 213 |
+
|
| 214 |
+
```bash
|
| 215 |
+
docker build -t ticket-triage-env .
|
| 216 |
+
docker run -p 7860:7860 ticket-triage-env
|
| 217 |
+
|
| 218 |
+
# Environment is now available at http://localhost:7860
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
### Quick API test
|
| 222 |
+
|
| 223 |
+
```bash
|
| 224 |
+
# Health check
|
| 225 |
+
curl http://localhost:7860/
|
| 226 |
+
|
| 227 |
+
# Start an episode
|
| 228 |
+
curl -X POST http://localhost:7860/reset \
|
| 229 |
+
-H "Content-Type: application/json" \
|
| 230 |
+
-d '{"task_name": "route", "ticket_id": "TKT-001", "seed": 42}'
|
| 231 |
+
|
| 232 |
+
# Take an action (use session_id from reset response)
|
| 233 |
+
curl -X POST http://localhost:7860/step \
|
| 234 |
+
-H "Content-Type: application/json" \
|
| 235 |
+
-d '{"session_id": "<ID>", "action_type": "route", "department": "billing"}'
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
---
|
| 239 |
+
|
| 240 |
+
## 📊 Baseline Scores
|
| 241 |
+
|
| 242 |
+
Measured with `meta-llama/Llama-3.3-70B-Instruct` via HuggingFace Inference API,
|
| 243 |
+
temperature=0.0 (greedy), seed=42:
|
| 244 |
+
|
| 245 |
+
| Task | Score | Notes |
|
| 246 |
+
|------|-------|-------|
|
| 247 |
+
| Route (Easy) | ~0.80 | Model occasionally confuses billing ↔ customer_success |
|
| 248 |
+
| Triage (Medium) | ~0.55 | Tags and urgency are hardest sub-tasks |
|
| 249 |
+
| Resolve (Hard) | ~0.40 | Follow-up handling and escalation decisions are challenging |
|
| 250 |
+
| **Overall** | **~0.58** | |
|
| 251 |
+
|
| 252 |
+
---
|
| 253 |
+
|
| 254 |
+
## 📁 Project Structure
|
| 255 |
+
|
| 256 |
+
```
|
| 257 |
+
ticket-triage-env/
|
| 258 |
+
├── openenv.yaml # OpenEnv metadata
|
| 259 |
+
├── Dockerfile # Container definition
|
| 260 |
+
├── requirements.txt # Python dependencies
|
| 261 |
+
├── inference.py # Baseline inference script (hackathon-required)
|
| 262 |
+
├── server.py # FastAPI HTTP server
|
| 263 |
+
├── README.md # This file
|
| 264 |
+
└── env/
|
| 265 |
+
├── __init__.py
|
| 266 |
+
├── environment.py # Core TicketTriageEnv class
|
| 267 |
+
├── models.py # Pydantic Observation/Action/Reward models
|
| 268 |
+
├── tasks.py # Task specifications
|
| 269 |
+
├── graders.py # Deterministic grader functions
|
| 270 |
+
└── data.py # Synthetic ticket dataset with ground truth
|
| 271 |
+
```
|
| 272 |
+
|
| 273 |
+
---
|
| 274 |
+
|
| 275 |
+
## 📜 License
|
| 276 |
+
|
| 277 |
+
MIT — free to use for research and commercial applications.
|
data.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Curated synthetic support tickets with ground-truth labels.
|
| 3 |
+
|
| 4 |
+
Each ticket is a dict with:
|
| 5 |
+
- ticket fields (subject, body, sender_*)
|
| 6 |
+
- ground_truth:
|
| 7 |
+
correct_department : Department enum value
|
| 8 |
+
correct_urgency : UrgencyLevel enum value
|
| 9 |
+
required_tags : set of tags graders accept
|
| 10 |
+
key_response_topics : keywords a good response must address
|
| 11 |
+
needs_escalation : bool
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from env.models import Department, UrgencyLevel
|
| 15 |
+
|
| 16 |
+
TICKETS = [
|
| 17 |
+
# -----------------------------------------------------------------------
|
| 18 |
+
# EASY routing tickets — one clear signal per email
|
| 19 |
+
# -----------------------------------------------------------------------
|
| 20 |
+
{
|
| 21 |
+
"ticket_id": "TKT-001",
|
| 22 |
+
"subject": "Double charged on my invoice #4821",
|
| 23 |
+
"body": (
|
| 24 |
+
"Hi, I was charged twice for my subscription this month. "
|
| 25 |
+
"My invoice number is #4821. The duplicate charge appeared on "
|
| 26 |
+
"March 15th. Please refund the extra amount ASAP. "
|
| 27 |
+
"My account email is jane.smith@example.com."
|
| 28 |
+
),
|
| 29 |
+
"sender_email": "jane.smith@example.com",
|
| 30 |
+
"sender_name": "Jane Smith",
|
| 31 |
+
"ground_truth": {
|
| 32 |
+
"correct_department": Department.BILLING,
|
| 33 |
+
"correct_urgency": UrgencyLevel.HIGH,
|
| 34 |
+
"required_tags": {"billing", "duplicate-charge", "refund"},
|
| 35 |
+
"key_response_topics": {"refund", "invoice", "charge", "apologize"},
|
| 36 |
+
"needs_escalation": False,
|
| 37 |
+
"good_resolution_keywords": {"refund processed", "resolved", "credited"},
|
| 38 |
+
},
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"ticket_id": "TKT-002",
|
| 42 |
+
"subject": "API returning 500 errors since yesterday",
|
| 43 |
+
"body": (
|
| 44 |
+
"Our production environment is broken. Your API has been returning "
|
| 45 |
+
"HTTP 500 errors on the /v2/users endpoint since yesterday 6pm UTC. "
|
| 46 |
+
"This is blocking our entire checkout flow. Error code: INTERNAL_500_USR. "
|
| 47 |
+
"We need this fixed urgently."
|
| 48 |
+
),
|
| 49 |
+
"sender_email": "ops@startup.io",
|
| 50 |
+
"sender_name": "DevOps Team",
|
| 51 |
+
"ground_truth": {
|
| 52 |
+
"correct_department": Department.TECHNICAL_SUPPORT,
|
| 53 |
+
"correct_urgency": UrgencyLevel.CRITICAL,
|
| 54 |
+
"required_tags": {"api", "500-error", "production-outage"},
|
| 55 |
+
"key_response_topics": {"error", "investigating", "update", "escalate"},
|
| 56 |
+
"needs_escalation": True,
|
| 57 |
+
"good_resolution_keywords": {"resolved", "fix deployed", "restored"},
|
| 58 |
+
},
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"ticket_id": "TKT-003",
|
| 62 |
+
"subject": "Interested in enterprise pricing for 500 seats",
|
| 63 |
+
"body": (
|
| 64 |
+
"Hello, we are evaluating your platform for our company of ~500 people. "
|
| 65 |
+
"Could you share enterprise pricing, volume discounts, and whether you "
|
| 66 |
+
"offer annual contracts? We'd also love a demo call with your team."
|
| 67 |
+
),
|
| 68 |
+
"sender_email": "procurement@bigcorp.com",
|
| 69 |
+
"sender_name": "Sarah Johnson",
|
| 70 |
+
"ground_truth": {
|
| 71 |
+
"correct_department": Department.SALES,
|
| 72 |
+
"correct_urgency": UrgencyLevel.MEDIUM,
|
| 73 |
+
"required_tags": {"enterprise", "pricing", "demo-request"},
|
| 74 |
+
"key_response_topics": {"pricing", "demo", "enterprise", "contact"},
|
| 75 |
+
"needs_escalation": False,
|
| 76 |
+
"good_resolution_keywords": {"demo scheduled", "pricing sent", "follow-up"},
|
| 77 |
+
},
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"ticket_id": "TKT-004",
|
| 81 |
+
"subject": "Need help setting up SSO with Okta",
|
| 82 |
+
"body": (
|
| 83 |
+
"We are trying to configure SAML SSO with Okta but keep getting "
|
| 84 |
+
"'Assertion validation failed' errors. We have followed the docs "
|
| 85 |
+
"but step 4 on the SAML config page seems outdated. Can you help?"
|
| 86 |
+
),
|
| 87 |
+
"sender_email": "it-admin@mediumco.org",
|
| 88 |
+
"sender_name": "IT Admin",
|
| 89 |
+
"ground_truth": {
|
| 90 |
+
"correct_department": Department.TECHNICAL_SUPPORT,
|
| 91 |
+
"correct_urgency": UrgencyLevel.MEDIUM,
|
| 92 |
+
"required_tags": {"sso", "saml", "okta", "configuration"},
|
| 93 |
+
"key_response_topics": {"saml", "configuration", "steps", "guide"},
|
| 94 |
+
"needs_escalation": False,
|
| 95 |
+
"good_resolution_keywords": {"configured", "working", "resolved"},
|
| 96 |
+
},
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"ticket_id": "TKT-005",
|
| 100 |
+
"subject": "Data retention policy — GDPR request",
|
| 101 |
+
"body": (
|
| 102 |
+
"We are undergoing a GDPR audit and need your written data retention "
|
| 103 |
+
"and deletion policy. Specifically: how long do you retain user logs, "
|
| 104 |
+
"do you have a DPA we can sign, and what is your sub-processor list?"
|
| 105 |
+
),
|
| 106 |
+
"sender_email": "dpo@eucompany.de",
|
| 107 |
+
"sender_name": "Klaus Weber",
|
| 108 |
+
"ground_truth": {
|
| 109 |
+
"correct_department": Department.LEGAL,
|
| 110 |
+
"correct_urgency": UrgencyLevel.HIGH,
|
| 111 |
+
"required_tags": {"gdpr", "legal", "data-retention", "compliance"},
|
| 112 |
+
"key_response_topics": {"gdpr", "dpa", "data retention", "legal team"},
|
| 113 |
+
"needs_escalation": False,
|
| 114 |
+
"good_resolution_keywords": {"dpa signed", "policy sent", "compliant"},
|
| 115 |
+
},
|
| 116 |
+
},
|
| 117 |
+
# -----------------------------------------------------------------------
|
| 118 |
+
# MEDIUM tickets — ambiguous signal, multi-action required
|
| 119 |
+
# -----------------------------------------------------------------------
|
| 120 |
+
{
|
| 121 |
+
"ticket_id": "TKT-006",
|
| 122 |
+
"subject": "My account is locked and I have a board demo in 2 hours",
|
| 123 |
+
"body": (
|
| 124 |
+
"I can't log in to my account — it says 'account suspended'. "
|
| 125 |
+
"I have a critical board presentation in 2 hours where I need to "
|
| 126 |
+
"show your platform live. I'm a paying Pro subscriber (since 2021). "
|
| 127 |
+
"Please unlock immediately. This is extremely time-sensitive."
|
| 128 |
+
),
|
| 129 |
+
"sender_email": "ceo@fastgrowth.com",
|
| 130 |
+
"sender_name": "Marcus Rivera",
|
| 131 |
+
"ground_truth": {
|
| 132 |
+
"correct_department": Department.CUSTOMER_SUCCESS,
|
| 133 |
+
"correct_urgency": UrgencyLevel.CRITICAL,
|
| 134 |
+
"required_tags": {"account-locked", "urgent", "enterprise-customer"},
|
| 135 |
+
"key_response_topics": {"unlock", "immediate", "apologize", "escalate"},
|
| 136 |
+
"needs_escalation": True,
|
| 137 |
+
"good_resolution_keywords": {"unlocked", "restored access", "resolved"},
|
| 138 |
+
},
|
| 139 |
+
},
|
| 140 |
+
{
|
| 141 |
+
"ticket_id": "TKT-007",
|
| 142 |
+
"subject": "Cancellation request + refund for annual plan",
|
| 143 |
+
"body": (
|
| 144 |
+
"I'd like to cancel my annual subscription and get a pro-rated refund "
|
| 145 |
+
"for the remaining 8 months. I'm leaving because the reporting features "
|
| 146 |
+
"don't meet our needs. Invoice #9034, paid $1,200. "
|
| 147 |
+
"Please confirm the cancellation and refund timeline."
|
| 148 |
+
),
|
| 149 |
+
"sender_email": "finance@retailer.biz",
|
| 150 |
+
"sender_name": "Patricia Lee",
|
| 151 |
+
"ground_truth": {
|
| 152 |
+
"correct_department": Department.BILLING,
|
| 153 |
+
"correct_urgency": UrgencyLevel.MEDIUM,
|
| 154 |
+
"required_tags": {"cancellation", "refund", "annual-plan", "churn-risk"},
|
| 155 |
+
"key_response_topics": {"cancellation", "refund", "timeline", "confirm"},
|
| 156 |
+
"needs_escalation": False,
|
| 157 |
+
"good_resolution_keywords": {"cancelled", "refund issued", "confirmed"},
|
| 158 |
+
},
|
| 159 |
+
},
|
| 160 |
+
# -----------------------------------------------------------------------
|
| 161 |
+
# HARD tickets — multi-turn, escalation, full resolution required
|
| 162 |
+
# -----------------------------------------------------------------------
|
| 163 |
+
{
|
| 164 |
+
"ticket_id": "TKT-008",
|
| 165 |
+
"subject": "Data loss — all our project files are gone",
|
| 166 |
+
"body": (
|
| 167 |
+
"URGENT: All project files in workspace 'Acme-Q1' have disappeared. "
|
| 168 |
+
"Last backup shown was 3 days ago but we've been working daily. "
|
| 169 |
+
"We have a client deadline TOMORROW morning. This is catastrophic. "
|
| 170 |
+
"Account: acme@enterprise.com, workspace ID: ws-39182."
|
| 171 |
+
),
|
| 172 |
+
"sender_email": "acme@enterprise.com",
|
| 173 |
+
"sender_name": "Acme Corp",
|
| 174 |
+
"ground_truth": {
|
| 175 |
+
"correct_department": Department.TECHNICAL_SUPPORT,
|
| 176 |
+
"correct_urgency": UrgencyLevel.CRITICAL,
|
| 177 |
+
"required_tags": {"data-loss", "critical", "enterprise", "backup"},
|
| 178 |
+
"key_response_topics": {"data", "recovery", "escalate", "urgent", "backup"},
|
| 179 |
+
"needs_escalation": True,
|
| 180 |
+
"good_resolution_keywords": {"data restored", "files recovered", "resolved"},
|
| 181 |
+
"follow_up_message": (
|
| 182 |
+
"Thank you for the quick response. We found some files are back "
|
| 183 |
+
"but project 'Acme-Q1-Sprint3' is still missing. Can you check again?"
|
| 184 |
+
),
|
| 185 |
+
"follow_up_response_topics": {"specific project", "sprint3", "checking"},
|
| 186 |
+
},
|
| 187 |
+
},
|
| 188 |
+
{
|
| 189 |
+
"ticket_id": "TKT-009",
|
| 190 |
+
"subject": "Billing discrepancy + threat of chargeback",
|
| 191 |
+
"body": (
|
| 192 |
+
"I've been charged $299/month for the past 6 months but my contract "
|
| 193 |
+
"clearly states $199/month. Total overcharge: $600. I have the signed "
|
| 194 |
+
"contract. If this isn't resolved with a full refund within 48 hours "
|
| 195 |
+
"I will dispute all 6 charges with my bank. Account: robert@agency.co"
|
| 196 |
+
),
|
| 197 |
+
"sender_email": "robert@agency.co",
|
| 198 |
+
"sender_name": "Robert Chen",
|
| 199 |
+
"ground_truth": {
|
| 200 |
+
"correct_department": Department.BILLING,
|
| 201 |
+
"correct_urgency": UrgencyLevel.CRITICAL,
|
| 202 |
+
"required_tags": {"billing-dispute", "chargeback-risk", "contract", "refund"},
|
| 203 |
+
"key_response_topics": {"contract", "overcharge", "refund", "investigate", "apologize"},
|
| 204 |
+
"needs_escalation": True,
|
| 205 |
+
"good_resolution_keywords": {"$600 refunded", "corrected", "resolved", "apologize"},
|
| 206 |
+
"follow_up_message": (
|
| 207 |
+
"I've been waiting 24 hours with no update on my refund. "
|
| 208 |
+
"I'm filing the chargeback now if I don't hear back."
|
| 209 |
+
),
|
| 210 |
+
"follow_up_response_topics": {"refund status", "timeline", "processing", "urgent"},
|
| 211 |
+
},
|
| 212 |
+
},
|
| 213 |
+
]
|
| 214 |
+
|
| 215 |
+
# Build a lookup dict for easy access
|
| 216 |
+
TICKET_LOOKUP = {t["ticket_id"]: t for t in TICKETS}
|
environment.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Support Ticket Triage — OpenEnv Environment
|
| 3 |
+
============================================
|
| 4 |
+
|
| 5 |
+
Implements the standard OpenEnv interface:
|
| 6 |
+
reset() -> TicketObservation
|
| 7 |
+
step(action: TicketAction) -> (TicketObservation, TicketReward, bool, dict)
|
| 8 |
+
state() -> EnvironmentState
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import copy
|
| 14 |
+
import random
|
| 15 |
+
from datetime import datetime, timezone
|
| 16 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 17 |
+
|
| 18 |
+
from env.data import TICKET_LOOKUP, TICKETS
|
| 19 |
+
from env.graders import GRADERS
|
| 20 |
+
from env.models import (
|
| 21 |
+
ActionType,
|
| 22 |
+
Department,
|
| 23 |
+
EnvironmentState,
|
| 24 |
+
TicketAction,
|
| 25 |
+
TicketMessage,
|
| 26 |
+
TicketObservation,
|
| 27 |
+
TicketReward,
|
| 28 |
+
UrgencyLevel,
|
| 29 |
+
)
|
| 30 |
+
from env.tasks import ALL_TASKS, TASK_LOOKUP, TaskSpec
|
| 31 |
+
|
| 32 |
+
AVAILABLE_ACTIONS = [at.value for at in ActionType]
|
| 33 |
+
AVAILABLE_DEPARTMENTS = [d.value for d in Department]
|
| 34 |
+
AVAILABLE_URGENCIES = [u.value for u in UrgencyLevel]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class TicketTriageEnv:
|
| 38 |
+
"""
|
| 39 |
+
OpenEnv-compliant environment for Support Ticket Triage.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
task_name: One of "route", "triage", "resolve". Default: "route".
|
| 43 |
+
ticket_id: Pin to a specific ticket (for reproducibility).
|
| 44 |
+
If None, picks randomly from the task's pool.
|
| 45 |
+
seed: Optional RNG seed.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
# ------------------------------------------------------------------
|
| 49 |
+
# Construction
|
| 50 |
+
# ------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
def __init__(
|
| 53 |
+
self,
|
| 54 |
+
task_name: str = "route",
|
| 55 |
+
ticket_id: Optional[str] = None,
|
| 56 |
+
seed: Optional[int] = None,
|
| 57 |
+
) -> None:
|
| 58 |
+
if task_name not in TASK_LOOKUP:
|
| 59 |
+
raise ValueError(
|
| 60 |
+
f"Unknown task '{task_name}'. "
|
| 61 |
+
f"Choose from: {list(TASK_LOOKUP.keys())}"
|
| 62 |
+
)
|
| 63 |
+
self._task_spec: TaskSpec = TASK_LOOKUP[task_name]
|
| 64 |
+
self._pinned_ticket_id: Optional[str] = ticket_id
|
| 65 |
+
self._rng = random.Random(seed)
|
| 66 |
+
|
| 67 |
+
# Episode state (initialised on reset)
|
| 68 |
+
self._ticket_data: Dict[str, Any] = {}
|
| 69 |
+
self._observation: Optional[TicketObservation] = None
|
| 70 |
+
self._actions_taken: List[Dict[str, Any]] = []
|
| 71 |
+
self._step_number: int = 0
|
| 72 |
+
self._done: bool = False
|
| 73 |
+
self._cumulative_reward: float = 0.0
|
| 74 |
+
self._follow_up_injected: bool = False
|
| 75 |
+
|
| 76 |
+
# ------------------------------------------------------------------
|
| 77 |
+
# OpenEnv API
|
| 78 |
+
# ------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
def reset(self) -> TicketObservation:
|
| 81 |
+
"""Reset the environment and return the initial observation."""
|
| 82 |
+
# Pick ticket
|
| 83 |
+
if self._pinned_ticket_id:
|
| 84 |
+
ticket_id = self._pinned_ticket_id
|
| 85 |
+
else:
|
| 86 |
+
ticket_id = self._rng.choice(self._task_spec.ticket_ids)
|
| 87 |
+
|
| 88 |
+
self._ticket_data = copy.deepcopy(TICKET_LOOKUP[ticket_id])
|
| 89 |
+
self._actions_taken = []
|
| 90 |
+
self._step_number = 0
|
| 91 |
+
self._done = False
|
| 92 |
+
self._cumulative_reward = 0.0
|
| 93 |
+
self._follow_up_injected = False
|
| 94 |
+
|
| 95 |
+
self._observation = TicketObservation(
|
| 96 |
+
ticket_id=ticket_id,
|
| 97 |
+
subject=self._ticket_data["subject"],
|
| 98 |
+
body=self._ticket_data["body"],
|
| 99 |
+
sender_email=self._ticket_data["sender_email"],
|
| 100 |
+
sender_name=self._ticket_data["sender_name"],
|
| 101 |
+
conversation_history=[
|
| 102 |
+
TicketMessage(
|
| 103 |
+
sender=self._ticket_data["sender_name"],
|
| 104 |
+
content=self._ticket_data["body"],
|
| 105 |
+
timestamp=self._now(),
|
| 106 |
+
)
|
| 107 |
+
],
|
| 108 |
+
current_department=None,
|
| 109 |
+
current_urgency=None,
|
| 110 |
+
tags=[],
|
| 111 |
+
is_escalated=False,
|
| 112 |
+
is_closed=False,
|
| 113 |
+
step_number=0,
|
| 114 |
+
task_name=self._task_spec.name,
|
| 115 |
+
task_description=self._task_spec.description,
|
| 116 |
+
available_actions=AVAILABLE_ACTIONS,
|
| 117 |
+
)
|
| 118 |
+
return copy.deepcopy(self._observation)
|
| 119 |
+
|
| 120 |
+
def step(
|
| 121 |
+
self, action: TicketAction
|
| 122 |
+
) -> Tuple[TicketObservation, TicketReward, bool, Dict[str, Any]]:
|
| 123 |
+
"""
|
| 124 |
+
Apply an action and return (observation, reward, done, info).
|
| 125 |
+
|
| 126 |
+
Reward is shaped per-step; the terminal reward summarises the episode.
|
| 127 |
+
"""
|
| 128 |
+
if self._done:
|
| 129 |
+
raise RuntimeError("Episode is done. Call reset() to start a new one.")
|
| 130 |
+
if self._observation is None:
|
| 131 |
+
raise RuntimeError("Call reset() before step().")
|
| 132 |
+
|
| 133 |
+
self._step_number += 1
|
| 134 |
+
self._observation.step_number = self._step_number
|
| 135 |
+
|
| 136 |
+
step_reward, info = self._apply_action(action)
|
| 137 |
+
self._actions_taken.append(action.model_dump())
|
| 138 |
+
|
| 139 |
+
# Check terminal conditions
|
| 140 |
+
done = self._check_done(action)
|
| 141 |
+
self._done = done
|
| 142 |
+
|
| 143 |
+
# On terminal step: compute final episode reward from grader
|
| 144 |
+
if done:
|
| 145 |
+
final_reward = self._run_grader()
|
| 146 |
+
self._cumulative_reward += final_reward.value
|
| 147 |
+
info["final_grader_reward"] = final_reward.model_dump()
|
| 148 |
+
terminal_reward = final_reward
|
| 149 |
+
else:
|
| 150 |
+
# Mid-episode shaped reward
|
| 151 |
+
self._cumulative_reward += step_reward.value
|
| 152 |
+
terminal_reward = step_reward
|
| 153 |
+
|
| 154 |
+
# Possibly inject a follow-up message from the customer (hard task)
|
| 155 |
+
self._maybe_inject_follow_up(action)
|
| 156 |
+
|
| 157 |
+
return copy.deepcopy(self._observation), terminal_reward, done, info
|
| 158 |
+
|
| 159 |
+
def state(self) -> EnvironmentState:
|
| 160 |
+
"""Return full internal state (includes hidden ground truth for graders)."""
|
| 161 |
+
if self._observation is None:
|
| 162 |
+
raise RuntimeError("Call reset() before state().")
|
| 163 |
+
return EnvironmentState(
|
| 164 |
+
observation=copy.deepcopy(self._observation),
|
| 165 |
+
ground_truth=self._ticket_data.get("ground_truth", {}),
|
| 166 |
+
cumulative_reward=self._cumulative_reward,
|
| 167 |
+
step_number=self._step_number,
|
| 168 |
+
done=self._done,
|
| 169 |
+
task_name=self._task_spec.name,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
# ------------------------------------------------------------------
|
| 173 |
+
# Internal helpers
|
| 174 |
+
# ------------------------------------------------------------------
|
| 175 |
+
|
| 176 |
+
def _apply_action(
|
| 177 |
+
self, action: TicketAction
|
| 178 |
+
) -> Tuple[TicketReward, Dict[str, Any]]:
|
| 179 |
+
"""Mutate observation state and return a shaped step reward."""
|
| 180 |
+
info: Dict[str, Any] = {}
|
| 181 |
+
|
| 182 |
+
if action.action_type == ActionType.ROUTE:
|
| 183 |
+
if action.department is None:
|
| 184 |
+
return TicketReward(
|
| 185 |
+
value=0.0,
|
| 186 |
+
reason="ROUTE requires 'department' field.",
|
| 187 |
+
partial_scores={},
|
| 188 |
+
), {"error": "missing department"}
|
| 189 |
+
self._observation.current_department = action.department
|
| 190 |
+
gt_dept = self._ticket_data["ground_truth"]["correct_department"]
|
| 191 |
+
score = 0.3 if action.department == gt_dept else 0.0
|
| 192 |
+
return TicketReward(
|
| 193 |
+
value=score,
|
| 194 |
+
reason=f"Routed to {action.department.value}.",
|
| 195 |
+
partial_scores={"routing": score},
|
| 196 |
+
), info
|
| 197 |
+
|
| 198 |
+
elif action.action_type == ActionType.SET_URGENCY:
|
| 199 |
+
if action.urgency is None:
|
| 200 |
+
return TicketReward(
|
| 201 |
+
value=0.0,
|
| 202 |
+
reason="SET_URGENCY requires 'urgency' field.",
|
| 203 |
+
partial_scores={},
|
| 204 |
+
), {"error": "missing urgency"}
|
| 205 |
+
self._observation.current_urgency = action.urgency
|
| 206 |
+
gt_urgency = self._ticket_data["ground_truth"]["correct_urgency"]
|
| 207 |
+
score = 0.2 if action.urgency == gt_urgency else 0.05
|
| 208 |
+
return TicketReward(
|
| 209 |
+
value=score,
|
| 210 |
+
reason=f"Set urgency to {action.urgency.value}.",
|
| 211 |
+
partial_scores={"urgency": score},
|
| 212 |
+
), info
|
| 213 |
+
|
| 214 |
+
elif action.action_type == ActionType.TAG:
|
| 215 |
+
if not action.tags:
|
| 216 |
+
return TicketReward(
|
| 217 |
+
value=0.0,
|
| 218 |
+
reason="TAG requires non-empty 'tags' list.",
|
| 219 |
+
partial_scores={},
|
| 220 |
+
), {"error": "missing tags"}
|
| 221 |
+
for tag in action.tags:
|
| 222 |
+
if tag not in self._observation.tags:
|
| 223 |
+
self._observation.tags.append(tag)
|
| 224 |
+
required = self._ticket_data["ground_truth"].get("required_tags", set())
|
| 225 |
+
overlap = len(set(self._observation.tags) & required) / max(len(required), 1)
|
| 226 |
+
return TicketReward(
|
| 227 |
+
value=round(0.1 * overlap, 4),
|
| 228 |
+
reason=f"Added tags: {action.tags}. Overlap with required: {overlap:.0%}",
|
| 229 |
+
partial_scores={"tagging": overlap},
|
| 230 |
+
), info
|
| 231 |
+
|
| 232 |
+
elif action.action_type == ActionType.RESPOND:
|
| 233 |
+
if not action.response_text:
|
| 234 |
+
return TicketReward(
|
| 235 |
+
value=0.0,
|
| 236 |
+
reason="RESPOND requires 'response_text' field.",
|
| 237 |
+
partial_scores={},
|
| 238 |
+
), {"error": "missing response_text"}
|
| 239 |
+
self._observation.conversation_history.append(
|
| 240 |
+
TicketMessage(
|
| 241 |
+
sender="Support Agent",
|
| 242 |
+
content=action.response_text,
|
| 243 |
+
timestamp=self._now(),
|
| 244 |
+
)
|
| 245 |
+
)
|
| 246 |
+
key_topics = self._ticket_data["ground_truth"].get("key_response_topics", set())
|
| 247 |
+
text_lower = action.response_text.lower()
|
| 248 |
+
found = sum(1 for kw in key_topics if kw.lower() in text_lower)
|
| 249 |
+
quality = found / max(len(key_topics), 1)
|
| 250 |
+
return TicketReward(
|
| 251 |
+
value=round(0.15 * quality, 4),
|
| 252 |
+
reason=f"Response addresses {found}/{len(key_topics)} key topics.",
|
| 253 |
+
partial_scores={"response_quality": quality},
|
| 254 |
+
), info
|
| 255 |
+
|
| 256 |
+
elif action.action_type == ActionType.ESCALATE:
|
| 257 |
+
if not action.escalation_reason:
|
| 258 |
+
return TicketReward(
|
| 259 |
+
value=0.0,
|
| 260 |
+
reason="ESCALATE requires 'escalation_reason' field.",
|
| 261 |
+
partial_scores={},
|
| 262 |
+
), {"error": "missing escalation_reason"}
|
| 263 |
+
self._observation.is_escalated = True
|
| 264 |
+
needs = self._ticket_data["ground_truth"].get("needs_escalation", False)
|
| 265 |
+
score = 0.2 if needs else -0.1 # penalise unnecessary escalation
|
| 266 |
+
return TicketReward(
|
| 267 |
+
value=max(0.0, score),
|
| 268 |
+
reason=(
|
| 269 |
+
"Escalated correctly." if needs
|
| 270 |
+
else "Unnecessary escalation (penalised)."
|
| 271 |
+
),
|
| 272 |
+
partial_scores={"escalation": score},
|
| 273 |
+
), info
|
| 274 |
+
|
| 275 |
+
elif action.action_type == ActionType.CLOSE:
|
| 276 |
+
self._observation.is_closed = True
|
| 277 |
+
note = action.resolution_note or ""
|
| 278 |
+
score = 0.1 if len(note.split()) >= 5 else 0.0
|
| 279 |
+
return TicketReward(
|
| 280 |
+
value=score,
|
| 281 |
+
reason=f"Ticket closed. Resolution note length: {len(note.split())} words.",
|
| 282 |
+
partial_scores={"closure": score},
|
| 283 |
+
), info
|
| 284 |
+
|
| 285 |
+
else: # NOOP
|
| 286 |
+
return TicketReward(
|
| 287 |
+
value=0.0,
|
| 288 |
+
reason="NOOP action — no state change.",
|
| 289 |
+
partial_scores={},
|
| 290 |
+
), info
|
| 291 |
+
|
| 292 |
+
def _check_done(self, action: TicketAction) -> bool:
|
| 293 |
+
"""Episode ends on CLOSE, task-specific completion, or max_steps reached."""
|
| 294 |
+
if action.action_type == ActionType.CLOSE:
|
| 295 |
+
return True
|
| 296 |
+
# Easy task: routing is the only required action — auto-complete after ROUTE
|
| 297 |
+
if self._task_spec.name == "route" and action.action_type == ActionType.ROUTE:
|
| 298 |
+
return True
|
| 299 |
+
if self._step_number >= self._task_spec.max_steps:
|
| 300 |
+
return True
|
| 301 |
+
return False
|
| 302 |
+
|
| 303 |
+
def _maybe_inject_follow_up(self, action: TicketAction) -> None:
|
| 304 |
+
"""
|
| 305 |
+
For the hard task, inject a customer follow-up after the first RESPOND.
|
| 306 |
+
Simulates a realistic multi-turn support conversation.
|
| 307 |
+
"""
|
| 308 |
+
if self._task_spec.name != "resolve":
|
| 309 |
+
return
|
| 310 |
+
if self._follow_up_injected:
|
| 311 |
+
return
|
| 312 |
+
gt = self._ticket_data["ground_truth"]
|
| 313 |
+
follow_up = gt.get("follow_up_message")
|
| 314 |
+
if not follow_up:
|
| 315 |
+
return
|
| 316 |
+
respond_count = sum(
|
| 317 |
+
1 for a in self._actions_taken if a.get("action_type") == ActionType.RESPOND
|
| 318 |
+
)
|
| 319 |
+
if respond_count >= 1:
|
| 320 |
+
self._observation.conversation_history.append(
|
| 321 |
+
TicketMessage(
|
| 322 |
+
sender=self._observation.sender_name,
|
| 323 |
+
content=follow_up,
|
| 324 |
+
timestamp=self._now(),
|
| 325 |
+
)
|
| 326 |
+
)
|
| 327 |
+
self._follow_up_injected = True
|
| 328 |
+
|
| 329 |
+
def _run_grader(self) -> TicketReward:
|
| 330 |
+
"""Run the task's grader on the completed episode."""
|
| 331 |
+
grader_fn = GRADERS[self._task_spec.grader_name]
|
| 332 |
+
episode = {
|
| 333 |
+
"observation": self._observation,
|
| 334 |
+
"ground_truth": self._ticket_data.get("ground_truth", {}),
|
| 335 |
+
"actions_taken": self._actions_taken,
|
| 336 |
+
"step_number": self._step_number,
|
| 337 |
+
}
|
| 338 |
+
return grader_fn(episode)
|
| 339 |
+
|
| 340 |
+
@staticmethod
|
| 341 |
+
def _now() -> str:
|
| 342 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 343 |
+
|
| 344 |
+
# ------------------------------------------------------------------
|
| 345 |
+
# Class-level metadata
|
| 346 |
+
# ------------------------------------------------------------------
|
| 347 |
+
|
| 348 |
+
@classmethod
|
| 349 |
+
def list_tasks(cls) -> List[Dict[str, str]]:
|
| 350 |
+
return [
|
| 351 |
+
{
|
| 352 |
+
"name": t.name,
|
| 353 |
+
"display_name": t.display_name,
|
| 354 |
+
"difficulty": t.difficulty,
|
| 355 |
+
"max_steps": str(t.max_steps),
|
| 356 |
+
}
|
| 357 |
+
for t in ALL_TASKS
|
| 358 |
+
]
|
graders.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Graders for the Support Ticket Triage OpenEnv environment.
|
| 3 |
+
|
| 4 |
+
All graders are deterministic and produce scores in [0.0, 1.0].
|
| 5 |
+
Each grader receives the full episode state and returns a TicketReward.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any, Dict, List
|
| 11 |
+
|
| 12 |
+
from env.models import ActionType, Department, TicketReward, UrgencyLevel
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# Helper utilities
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
def _keyword_overlap(text: str, keywords: set, threshold: int = 1) -> float:
|
| 20 |
+
"""
|
| 21 |
+
Returns fraction of keywords found in text (case-insensitive).
|
| 22 |
+
If text contains >= threshold keywords → full credit per keyword found.
|
| 23 |
+
"""
|
| 24 |
+
if not text or not keywords:
|
| 25 |
+
return 0.0
|
| 26 |
+
text_lower = text.lower()
|
| 27 |
+
found = sum(1 for kw in keywords if kw.lower() in text_lower)
|
| 28 |
+
return found / len(keywords)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _tag_overlap(actual_tags: List[str], required_tags: set) -> float:
|
| 32 |
+
"""Fraction of required tags that appear in actual_tags."""
|
| 33 |
+
if not required_tags:
|
| 34 |
+
return 1.0
|
| 35 |
+
actual_set = {t.lower() for t in actual_tags}
|
| 36 |
+
required_lower = {t.lower() for t in required_tags}
|
| 37 |
+
found = actual_set & required_lower
|
| 38 |
+
return len(found) / len(required_lower)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
# Route Grader (Easy task)
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
|
| 45 |
+
def route_grader(episode: Dict[str, Any]) -> TicketReward:
|
| 46 |
+
"""
|
| 47 |
+
Score: 1.0 if routed to the correct department, else 0.0.
|
| 48 |
+
Small partial credit (0.1) for attempting a route at all vs. noop-ing.
|
| 49 |
+
"""
|
| 50 |
+
ground_truth: Dict = episode["ground_truth"]
|
| 51 |
+
actions_taken: List[Dict] = episode.get("actions_taken", [])
|
| 52 |
+
observation = episode["observation"]
|
| 53 |
+
|
| 54 |
+
correct_dept: Department = ground_truth["correct_department"]
|
| 55 |
+
current_dept = observation.current_department
|
| 56 |
+
|
| 57 |
+
# Did the agent ever perform a ROUTE action?
|
| 58 |
+
route_actions = [a for a in actions_taken if a.get("action_type") == ActionType.ROUTE]
|
| 59 |
+
|
| 60 |
+
if not route_actions:
|
| 61 |
+
return TicketReward(
|
| 62 |
+
value=0.0,
|
| 63 |
+
reason="No ROUTE action taken.",
|
| 64 |
+
partial_scores={"routing": 0.0},
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
if current_dept == correct_dept:
|
| 68 |
+
return TicketReward(
|
| 69 |
+
value=1.0,
|
| 70 |
+
reason=f"Correctly routed to {correct_dept.value}.",
|
| 71 |
+
partial_scores={"routing": 1.0},
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Routed but to the wrong department
|
| 75 |
+
# Give 0.1 partial credit for at least routing (vs. doing nothing)
|
| 76 |
+
return TicketReward(
|
| 77 |
+
value=0.1,
|
| 78 |
+
reason=(
|
| 79 |
+
f"Routed to {current_dept.value if current_dept else 'unknown'} "
|
| 80 |
+
f"but correct answer is {correct_dept.value}."
|
| 81 |
+
),
|
| 82 |
+
partial_scores={"routing": 0.1},
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
# Triage Grader (Medium task)
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
def triage_grader(episode: Dict[str, Any]) -> TicketReward:
|
| 91 |
+
"""
|
| 92 |
+
Weighted score across four sub-tasks:
|
| 93 |
+
- Routing: 0.30
|
| 94 |
+
- Urgency: 0.25
|
| 95 |
+
- Tagging: 0.20
|
| 96 |
+
- Response: 0.25
|
| 97 |
+
"""
|
| 98 |
+
ground_truth: Dict = episode["ground_truth"]
|
| 99 |
+
observation = episode["observation"]
|
| 100 |
+
actions_taken: List[Dict] = episode.get("actions_taken", [])
|
| 101 |
+
|
| 102 |
+
# --- 1. Routing (0.30) ---
|
| 103 |
+
correct_dept: Department = ground_truth["correct_department"]
|
| 104 |
+
routing_score = 1.0 if observation.current_department == correct_dept else 0.0
|
| 105 |
+
|
| 106 |
+
# --- 2. Urgency (0.25) ---
|
| 107 |
+
correct_urgency: UrgencyLevel = ground_truth["correct_urgency"]
|
| 108 |
+
urgency_score = 0.0
|
| 109 |
+
if observation.current_urgency == correct_urgency:
|
| 110 |
+
urgency_score = 1.0
|
| 111 |
+
elif observation.current_urgency is not None:
|
| 112 |
+
# Partial credit for adjacent urgency levels
|
| 113 |
+
levels = [UrgencyLevel.LOW, UrgencyLevel.MEDIUM, UrgencyLevel.HIGH, UrgencyLevel.CRITICAL]
|
| 114 |
+
correct_idx = levels.index(correct_urgency)
|
| 115 |
+
actual_idx = levels.index(observation.current_urgency)
|
| 116 |
+
diff = abs(correct_idx - actual_idx)
|
| 117 |
+
urgency_score = max(0.0, 1.0 - diff * 0.4)
|
| 118 |
+
|
| 119 |
+
# --- 3. Tagging (0.20) ---
|
| 120 |
+
required_tags: set = ground_truth.get("required_tags", set())
|
| 121 |
+
tagging_score = _tag_overlap(observation.tags, required_tags)
|
| 122 |
+
|
| 123 |
+
# --- 4. Response quality (0.25) ---
|
| 124 |
+
key_topics: set = ground_truth.get("key_response_topics", set())
|
| 125 |
+
response_texts = [
|
| 126 |
+
a.get("response_text", "") or ""
|
| 127 |
+
for a in actions_taken
|
| 128 |
+
if a.get("action_type") == ActionType.RESPOND
|
| 129 |
+
]
|
| 130 |
+
combined_response = " ".join(response_texts)
|
| 131 |
+
response_score = min(1.0, _keyword_overlap(combined_response, key_topics) * 1.5)
|
| 132 |
+
|
| 133 |
+
# Aggregate
|
| 134 |
+
weights = {"routing": 0.30, "urgency": 0.25, "tagging": 0.20, "response": 0.25}
|
| 135 |
+
partial = {
|
| 136 |
+
"routing": routing_score,
|
| 137 |
+
"urgency": urgency_score,
|
| 138 |
+
"tagging": tagging_score,
|
| 139 |
+
"response": response_score,
|
| 140 |
+
}
|
| 141 |
+
total = sum(weights[k] * v for k, v in partial.items())
|
| 142 |
+
total = round(min(1.0, max(0.0, total)), 4)
|
| 143 |
+
|
| 144 |
+
return TicketReward(
|
| 145 |
+
value=total,
|
| 146 |
+
reason=(
|
| 147 |
+
f"Routing={'✓' if routing_score == 1 else '✗'} "
|
| 148 |
+
f"Urgency={'✓' if urgency_score == 1 else f'{urgency_score:.2f}'} "
|
| 149 |
+
f"Tags={tagging_score:.2f} Response={response_score:.2f}"
|
| 150 |
+
),
|
| 151 |
+
partial_scores=partial,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ---------------------------------------------------------------------------
|
| 156 |
+
# Resolve Grader (Hard task)
|
| 157 |
+
# ---------------------------------------------------------------------------
|
| 158 |
+
|
| 159 |
+
def resolve_grader(episode: Dict[str, Any]) -> TicketReward:
|
| 160 |
+
"""
|
| 161 |
+
Weighted score across six sub-tasks:
|
| 162 |
+
- Routing: 0.15
|
| 163 |
+
- Urgency: 0.10
|
| 164 |
+
- Initial resp: 0.20
|
| 165 |
+
- Escalation: 0.20
|
| 166 |
+
- Follow-up: 0.20
|
| 167 |
+
- Closure: 0.15
|
| 168 |
+
"""
|
| 169 |
+
ground_truth: Dict = episode["ground_truth"]
|
| 170 |
+
observation = episode["observation"]
|
| 171 |
+
actions_taken: List[Dict] = episode.get("actions_taken", [])
|
| 172 |
+
|
| 173 |
+
# --- 1. Routing (0.15) ---
|
| 174 |
+
correct_dept: Department = ground_truth["correct_department"]
|
| 175 |
+
routing_score = 1.0 if observation.current_department == correct_dept else 0.0
|
| 176 |
+
|
| 177 |
+
# --- 2. Urgency (0.10) ---
|
| 178 |
+
correct_urgency: UrgencyLevel = ground_truth["correct_urgency"]
|
| 179 |
+
levels = [UrgencyLevel.LOW, UrgencyLevel.MEDIUM, UrgencyLevel.HIGH, UrgencyLevel.CRITICAL]
|
| 180 |
+
if observation.current_urgency == correct_urgency:
|
| 181 |
+
urgency_score = 1.0
|
| 182 |
+
elif observation.current_urgency is not None:
|
| 183 |
+
diff = abs(levels.index(correct_urgency) - levels.index(observation.current_urgency))
|
| 184 |
+
urgency_score = max(0.0, 1.0 - diff * 0.4)
|
| 185 |
+
else:
|
| 186 |
+
urgency_score = 0.0
|
| 187 |
+
|
| 188 |
+
# --- 3. Initial response quality (0.20) ---
|
| 189 |
+
key_topics: set = ground_truth.get("key_response_topics", set())
|
| 190 |
+
respond_actions = [a for a in actions_taken if a.get("action_type") == ActionType.RESPOND]
|
| 191 |
+
initial_response = respond_actions[0].get("response_text", "") if respond_actions else ""
|
| 192 |
+
initial_resp_score = min(1.0, _keyword_overlap(initial_response, key_topics) * 1.5)
|
| 193 |
+
|
| 194 |
+
# --- 4. Escalation (0.20) ---
|
| 195 |
+
needs_escalation: bool = ground_truth.get("needs_escalation", False)
|
| 196 |
+
escalate_actions = [a for a in actions_taken if a.get("action_type") == ActionType.ESCALATE]
|
| 197 |
+
if needs_escalation:
|
| 198 |
+
if escalate_actions:
|
| 199 |
+
reason = escalate_actions[0].get("escalation_reason", "") or ""
|
| 200 |
+
escalation_score = 0.5 + 0.5 * min(1.0, len(reason.split()) / 10.0)
|
| 201 |
+
else:
|
| 202 |
+
escalation_score = 0.0
|
| 203 |
+
else:
|
| 204 |
+
# Should NOT have escalated
|
| 205 |
+
escalation_score = 0.0 if escalate_actions else 1.0
|
| 206 |
+
|
| 207 |
+
# --- 5. Follow-up response (0.20) ---
|
| 208 |
+
follow_up_topics: set = ground_truth.get("follow_up_response_topics", set())
|
| 209 |
+
follow_up_response = respond_actions[1].get("response_text", "") if len(respond_actions) > 1 else ""
|
| 210 |
+
if follow_up_topics:
|
| 211 |
+
follow_up_score = min(1.0, _keyword_overlap(follow_up_response, follow_up_topics) * 1.5)
|
| 212 |
+
else:
|
| 213 |
+
follow_up_score = 1.0 # No follow-up expected → full credit
|
| 214 |
+
|
| 215 |
+
# --- 6. Closure quality (0.15) ---
|
| 216 |
+
close_actions = [a for a in actions_taken if a.get("action_type") == ActionType.CLOSE]
|
| 217 |
+
if not close_actions:
|
| 218 |
+
closure_score = 0.0
|
| 219 |
+
else:
|
| 220 |
+
resolution_note = close_actions[0].get("resolution_note", "") or ""
|
| 221 |
+
good_keywords: set = ground_truth.get("good_resolution_keywords", set())
|
| 222 |
+
closure_score = (
|
| 223 |
+
min(1.0, _keyword_overlap(resolution_note, good_keywords) * 1.5)
|
| 224 |
+
if good_keywords else (1.0 if resolution_note else 0.3)
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
weights = {
|
| 228 |
+
"routing": 0.15,
|
| 229 |
+
"urgency": 0.10,
|
| 230 |
+
"initial_response": 0.20,
|
| 231 |
+
"escalation": 0.20,
|
| 232 |
+
"follow_up": 0.20,
|
| 233 |
+
"closure": 0.15,
|
| 234 |
+
}
|
| 235 |
+
partial = {
|
| 236 |
+
"routing": routing_score,
|
| 237 |
+
"urgency": urgency_score,
|
| 238 |
+
"initial_response": initial_resp_score,
|
| 239 |
+
"escalation": escalation_score,
|
| 240 |
+
"follow_up": follow_up_score,
|
| 241 |
+
"closure": closure_score,
|
| 242 |
+
}
|
| 243 |
+
total = sum(weights[k] * v for k, v in partial.items())
|
| 244 |
+
total = round(min(1.0, max(0.0, total)), 4)
|
| 245 |
+
|
| 246 |
+
return TicketReward(
|
| 247 |
+
value=total,
|
| 248 |
+
reason=" | ".join(f"{k}={v:.2f}" for k, v in partial.items()),
|
| 249 |
+
partial_scores=partial,
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# ---------------------------------------------------------------------------
|
| 254 |
+
# Registry
|
| 255 |
+
# ---------------------------------------------------------------------------
|
| 256 |
+
|
| 257 |
+
GRADERS = {
|
| 258 |
+
"route_grader": route_grader,
|
| 259 |
+
"triage_grader": triage_grader,
|
| 260 |
+
"resolve_grader": resolve_grader,
|
| 261 |
+
}
|
inference.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Inference Script — Support Ticket Triage OpenEnv
|
| 3 |
+
=================================================
|
| 4 |
+
MANDATORY environment variables:
|
| 5 |
+
API_BASE_URL The API endpoint for the LLM.
|
| 6 |
+
MODEL_NAME The model identifier to use for inference.
|
| 7 |
+
HF_TOKEN Your Hugging Face / API key.
|
| 8 |
+
|
| 9 |
+
This script runs the baseline agent against all 3 tasks and prints
|
| 10 |
+
reproducible scores for each task and per-ticket.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import textwrap
|
| 18 |
+
from typing import Any, Dict, List, Optional
|
| 19 |
+
|
| 20 |
+
import requests
|
| 21 |
+
from openai import OpenAI
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# Configuration
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
API_BASE_URL: str = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 28 |
+
API_KEY: str = os.getenv("HF_TOKEN") or os.getenv("API_KEY") or ""
|
| 29 |
+
MODEL_NAME: str = os.getenv("MODEL_NAME", "meta-llama/Llama-3.3-70B-Instruct")
|
| 30 |
+
|
| 31 |
+
# Where the environment server is running
|
| 32 |
+
ENV_BASE_URL: str = os.getenv("ENV_BASE_URL", "http://localhost:7860")
|
| 33 |
+
|
| 34 |
+
TEMPERATURE: float = 0.0 # Greedy for reproducibility
|
| 35 |
+
MAX_TOKENS: int = 512
|
| 36 |
+
MAX_STEPS: int = 10
|
| 37 |
+
|
| 38 |
+
# Tickets to evaluate per task (pinned for reproducibility)
|
| 39 |
+
TASK_CONFIGS = [
|
| 40 |
+
{
|
| 41 |
+
"task_name": "route",
|
| 42 |
+
"ticket_ids": ["TKT-001", "TKT-002", "TKT-003", "TKT-004", "TKT-005"],
|
| 43 |
+
"seed": 42,
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"task_name": "triage",
|
| 47 |
+
"ticket_ids": ["TKT-006", "TKT-007"],
|
| 48 |
+
"seed": 42,
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"task_name": "resolve",
|
| 52 |
+
"ticket_ids": ["TKT-008", "TKT-009"],
|
| 53 |
+
"seed": 42,
|
| 54 |
+
},
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# OpenAI client
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# System prompt
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
SYSTEM_PROMPT = textwrap.dedent("""
|
| 70 |
+
You are an expert customer support agent. You receive support tickets and must take
|
| 71 |
+
the most appropriate action.
|
| 72 |
+
|
| 73 |
+
Reply with EXACTLY a JSON object (no markdown, no explanation):
|
| 74 |
+
{
|
| 75 |
+
"action_type": "<one of: route, respond, set_urgency, tag, escalate, close, noop>",
|
| 76 |
+
"department": "<billing|technical_support|sales|customer_success|legal or null>",
|
| 77 |
+
"response_text": "<your message to the customer or null>",
|
| 78 |
+
"urgency": "<low|medium|high|critical or null>",
|
| 79 |
+
"tags": ["<tag1>", "<tag2>"] or null,
|
| 80 |
+
"escalation_reason": "<reason or null>",
|
| 81 |
+
"resolution_note": "<summary or null>"
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
Rules:
|
| 85 |
+
- For ROUTE: set department, leave rest null
|
| 86 |
+
- For SET_URGENCY: set urgency, leave rest null
|
| 87 |
+
- For RESPOND: set response_text (empathetic, clear, actionable)
|
| 88 |
+
- For TAG: set tags (relevant labels like 'billing', 'urgent', 'refund')
|
| 89 |
+
- For ESCALATE: set escalation_reason (explain why escalation is needed)
|
| 90 |
+
- For CLOSE: set resolution_note (what was done to resolve the ticket)
|
| 91 |
+
- Think about the task description shown to you and complete all required steps
|
| 92 |
+
""").strip()
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
# Environment HTTP helpers
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
|
| 99 |
+
def env_reset(task_name: str, ticket_id: str, seed: int = 42) -> Dict[str, Any]:
|
| 100 |
+
r = requests.post(f"{ENV_BASE_URL}/reset", json={
|
| 101 |
+
"task_name": task_name,
|
| 102 |
+
"ticket_id": ticket_id,
|
| 103 |
+
"seed": seed,
|
| 104 |
+
}, timeout=30)
|
| 105 |
+
r.raise_for_status()
|
| 106 |
+
return r.json()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def env_step(session_id: str, action: Dict[str, Any]) -> Dict[str, Any]:
|
| 110 |
+
payload = {"session_id": session_id, **action}
|
| 111 |
+
r = requests.post(f"{ENV_BASE_URL}/step", json=payload, timeout=30)
|
| 112 |
+
r.raise_for_status()
|
| 113 |
+
return r.json()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
# Agent decision logic
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
def observation_to_prompt(obs: Dict[str, Any]) -> str:
|
| 121 |
+
"""Convert observation dict to a text prompt for the model."""
|
| 122 |
+
hist_lines = []
|
| 123 |
+
for msg in obs.get("conversation_history", []):
|
| 124 |
+
hist_lines.append(f"[{msg['sender']}]: {msg['content']}")
|
| 125 |
+
|
| 126 |
+
return textwrap.dedent(f"""
|
| 127 |
+
TASK: {obs.get('task_description', '')}
|
| 128 |
+
|
| 129 |
+
--- TICKET ---
|
| 130 |
+
Ticket ID: {obs['ticket_id']}
|
| 131 |
+
Subject: {obs['subject']}
|
| 132 |
+
From: {obs['sender_name']} <{obs['sender_email']}>
|
| 133 |
+
|
| 134 |
+
Conversation:
|
| 135 |
+
{chr(10).join(hist_lines)}
|
| 136 |
+
-------------
|
| 137 |
+
|
| 138 |
+
Current state:
|
| 139 |
+
- Department: {obs.get('current_department') or 'not set'}
|
| 140 |
+
- Urgency: {obs.get('current_urgency') or 'not set'}
|
| 141 |
+
- Tags: {obs.get('tags') or 'none'}
|
| 142 |
+
- Escalated: {obs.get('is_escalated', False)}
|
| 143 |
+
- Closed: {obs.get('is_closed', False)}
|
| 144 |
+
- Step: {obs.get('step_number', 0)}
|
| 145 |
+
|
| 146 |
+
What is your next action? Reply with the JSON object.
|
| 147 |
+
""").strip()
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def call_model(prompt: str) -> Dict[str, Any]:
|
| 151 |
+
"""Call the LLM and parse its JSON action."""
|
| 152 |
+
try:
|
| 153 |
+
completion = client.chat.completions.create(
|
| 154 |
+
model=MODEL_NAME,
|
| 155 |
+
messages=[
|
| 156 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 157 |
+
{"role": "user", "content": prompt},
|
| 158 |
+
],
|
| 159 |
+
temperature=TEMPERATURE,
|
| 160 |
+
max_tokens=MAX_TOKENS,
|
| 161 |
+
)
|
| 162 |
+
text = completion.choices[0].message.content or "{}"
|
| 163 |
+
# Strip markdown fences if present
|
| 164 |
+
text = text.strip()
|
| 165 |
+
if text.startswith("```"):
|
| 166 |
+
lines = text.splitlines()
|
| 167 |
+
text = "\n".join(lines[1:-1]) if len(lines) > 2 else text
|
| 168 |
+
return json.loads(text)
|
| 169 |
+
except Exception as exc:
|
| 170 |
+
print(f" [warn] Model call failed: {exc}. Using noop.")
|
| 171 |
+
return {"action_type": "noop"}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def clean_action(raw: Dict[str, Any]) -> Dict[str, Any]:
|
| 175 |
+
"""Ensure action dict has valid fields only."""
|
| 176 |
+
valid_keys = {
|
| 177 |
+
"action_type", "department", "response_text",
|
| 178 |
+
"urgency", "tags", "escalation_reason", "resolution_note",
|
| 179 |
+
}
|
| 180 |
+
return {k: v for k, v in raw.items() if k in valid_keys and v is not None}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ---------------------------------------------------------------------------
|
| 184 |
+
# Episode runner
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
|
| 187 |
+
def run_episode(task_name: str, ticket_id: str, seed: int = 42) -> float:
|
| 188 |
+
"""Run one full episode. Returns the final reward score [0, 1]."""
|
| 189 |
+
print(f"\n → Episode: task={task_name}, ticket={ticket_id}")
|
| 190 |
+
|
| 191 |
+
reset_resp = env_reset(task_name, ticket_id, seed)
|
| 192 |
+
session_id: str = reset_resp["session_id"]
|
| 193 |
+
obs: Dict[str, Any] = reset_resp["observation"]
|
| 194 |
+
|
| 195 |
+
final_score = 0.0
|
| 196 |
+
|
| 197 |
+
for step in range(1, MAX_STEPS + 1):
|
| 198 |
+
prompt = observation_to_prompt(obs)
|
| 199 |
+
raw_action = call_model(prompt)
|
| 200 |
+
action = clean_action(raw_action)
|
| 201 |
+
|
| 202 |
+
print(f" Step {step}: action_type={action.get('action_type', 'noop')}", end="")
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
result = env_step(session_id, action)
|
| 206 |
+
except Exception as exc:
|
| 207 |
+
print(f" [ERROR: {exc}]")
|
| 208 |
+
break
|
| 209 |
+
|
| 210 |
+
reward_val = result["reward"]["value"]
|
| 211 |
+
done = result["done"]
|
| 212 |
+
obs = result["observation"]
|
| 213 |
+
|
| 214 |
+
print(f" reward={reward_val:.3f} done={done}")
|
| 215 |
+
|
| 216 |
+
if done:
|
| 217 |
+
# Terminal reward from grader is the authoritative score
|
| 218 |
+
final_score = result["reward"]["value"]
|
| 219 |
+
grader_info = result["info"].get("final_grader_reward", {})
|
| 220 |
+
if grader_info:
|
| 221 |
+
print(f" [grader] {grader_info.get('reason', '')}")
|
| 222 |
+
print(f" [partial] {grader_info.get('partial_scores', {})}")
|
| 223 |
+
break
|
| 224 |
+
else:
|
| 225 |
+
print(f" Max steps ({MAX_STEPS}) reached.")
|
| 226 |
+
final_score = result["reward"]["value"] if "result" in dir() else 0.0 # type: ignore[name-defined]
|
| 227 |
+
|
| 228 |
+
print(f" ✓ Final score: {final_score:.4f}")
|
| 229 |
+
return final_score
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# ---------------------------------------------------------------------------
|
| 233 |
+
# Main: run all tasks and aggregate
|
| 234 |
+
# ---------------------------------------------------------------------------
|
| 235 |
+
|
| 236 |
+
def main() -> None:
|
| 237 |
+
print("=" * 60)
|
| 238 |
+
print("Support Ticket Triage — Baseline Inference")
|
| 239 |
+
print(f"Model: {MODEL_NAME}")
|
| 240 |
+
print(f"Environment: {ENV_BASE_URL}")
|
| 241 |
+
print("=" * 60)
|
| 242 |
+
|
| 243 |
+
all_scores: Dict[str, List[float]] = {}
|
| 244 |
+
|
| 245 |
+
for task_cfg in TASK_CONFIGS:
|
| 246 |
+
task_name = task_cfg["task_name"]
|
| 247 |
+
ticket_ids = task_cfg["ticket_ids"]
|
| 248 |
+
seed = task_cfg["seed"]
|
| 249 |
+
|
| 250 |
+
print(f"\n{'─'*50}")
|
| 251 |
+
print(f"TASK: {task_name.upper()}")
|
| 252 |
+
print(f"{'─'*50}")
|
| 253 |
+
|
| 254 |
+
task_scores: List[float] = []
|
| 255 |
+
for tid in ticket_ids:
|
| 256 |
+
score = run_episode(task_name, tid, seed)
|
| 257 |
+
task_scores.append(score)
|
| 258 |
+
|
| 259 |
+
avg = sum(task_scores) / len(task_scores) if task_scores else 0.0
|
| 260 |
+
all_scores[task_name] = task_scores
|
| 261 |
+
print(f"\n Task '{task_name}' average: {avg:.4f}")
|
| 262 |
+
|
| 263 |
+
# Summary
|
| 264 |
+
print(f"\n{'='*60}")
|
| 265 |
+
print("FINAL SCORES")
|
| 266 |
+
print(f"{'='*60}")
|
| 267 |
+
overall_scores = []
|
| 268 |
+
for task_name, scores in all_scores.items():
|
| 269 |
+
avg = sum(scores) / len(scores) if scores else 0.0
|
| 270 |
+
overall_scores.append(avg)
|
| 271 |
+
print(f" {task_name:12s}: {avg:.4f} (tickets: {[f'{s:.3f}' for s in scores]})")
|
| 272 |
+
|
| 273 |
+
grand_avg = sum(overall_scores) / len(overall_scores) if overall_scores else 0.0
|
| 274 |
+
print(f" {'OVERALL':12s}: {grand_avg:.4f}")
|
| 275 |
+
print("=" * 60)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
if __name__ == "__main__":
|
| 279 |
+
main()
|
models.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Typed Pydantic models for the Support Ticket Triage OpenEnv environment.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from enum import Enum
|
| 8 |
+
from typing import Any, Dict, List, Optional
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ---------------------------------------------------------------------------
|
| 14 |
+
# Domain enumerations
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
|
| 17 |
+
class Department(str, Enum):
|
| 18 |
+
BILLING = "billing"
|
| 19 |
+
TECHNICAL_SUPPORT = "technical_support"
|
| 20 |
+
SALES = "sales"
|
| 21 |
+
CUSTOMER_SUCCESS = "customer_success"
|
| 22 |
+
LEGAL = "legal"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class UrgencyLevel(str, Enum):
|
| 26 |
+
LOW = "low"
|
| 27 |
+
MEDIUM = "medium"
|
| 28 |
+
HIGH = "high"
|
| 29 |
+
CRITICAL = "critical"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ActionType(str, Enum):
|
| 33 |
+
ROUTE = "route" # Assign to a department
|
| 34 |
+
RESPOND = "respond" # Send a reply to the customer
|
| 35 |
+
SET_URGENCY = "set_urgency" # Set urgency level
|
| 36 |
+
TAG = "tag" # Add classification tags
|
| 37 |
+
ESCALATE = "escalate" # Escalate with a reason
|
| 38 |
+
CLOSE = "close" # Resolve and close the ticket
|
| 39 |
+
NOOP = "noop" # Do nothing (wastes a step)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# Action model
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
class TicketAction(BaseModel):
|
| 47 |
+
"""One action the agent can take in the environment."""
|
| 48 |
+
|
| 49 |
+
action_type: ActionType = Field(
|
| 50 |
+
description="The type of action to perform."
|
| 51 |
+
)
|
| 52 |
+
department: Optional[Department] = Field(
|
| 53 |
+
default=None,
|
| 54 |
+
description="Target department (required for ROUTE action).",
|
| 55 |
+
)
|
| 56 |
+
response_text: Optional[str] = Field(
|
| 57 |
+
default=None,
|
| 58 |
+
description="Message body sent to the customer (required for RESPOND).",
|
| 59 |
+
)
|
| 60 |
+
urgency: Optional[UrgencyLevel] = Field(
|
| 61 |
+
default=None,
|
| 62 |
+
description="Urgency level (required for SET_URGENCY).",
|
| 63 |
+
)
|
| 64 |
+
tags: Optional[List[str]] = Field(
|
| 65 |
+
default=None,
|
| 66 |
+
description="Classification tags to apply (required for TAG).",
|
| 67 |
+
)
|
| 68 |
+
escalation_reason: Optional[str] = Field(
|
| 69 |
+
default=None,
|
| 70 |
+
description="Plain-text reason for escalation (required for ESCALATE).",
|
| 71 |
+
)
|
| 72 |
+
resolution_note: Optional[str] = Field(
|
| 73 |
+
default=None,
|
| 74 |
+
description="Summary of how the issue was resolved (required for CLOSE).",
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
# Observation model
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
|
| 82 |
+
class TicketMessage(BaseModel):
|
| 83 |
+
"""One message in the ticket conversation thread."""
|
| 84 |
+
|
| 85 |
+
sender: str
|
| 86 |
+
content: str
|
| 87 |
+
timestamp: str
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class TicketObservation(BaseModel):
|
| 91 |
+
"""Everything the agent can observe at a given step."""
|
| 92 |
+
|
| 93 |
+
# Ticket content
|
| 94 |
+
ticket_id: str
|
| 95 |
+
subject: str
|
| 96 |
+
body: str
|
| 97 |
+
sender_email: str
|
| 98 |
+
sender_name: str
|
| 99 |
+
|
| 100 |
+
# Evolving state
|
| 101 |
+
conversation_history: List[TicketMessage] = Field(default_factory=list)
|
| 102 |
+
current_department: Optional[Department] = None
|
| 103 |
+
current_urgency: Optional[UrgencyLevel] = None
|
| 104 |
+
tags: List[str] = Field(default_factory=list)
|
| 105 |
+
is_escalated: bool = False
|
| 106 |
+
is_closed: bool = False
|
| 107 |
+
|
| 108 |
+
# Episode metadata
|
| 109 |
+
step_number: int = 0
|
| 110 |
+
task_name: str = ""
|
| 111 |
+
task_description: str = ""
|
| 112 |
+
available_actions: List[str] = Field(default_factory=list)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
# Reward model
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
|
| 119 |
+
class TicketReward(BaseModel):
|
| 120 |
+
"""Structured reward with partial-credit breakdown."""
|
| 121 |
+
|
| 122 |
+
value: float = Field(ge=0.0, le=1.0, description="Aggregate reward [0, 1].")
|
| 123 |
+
reason: str = Field(description="Human-readable explanation.")
|
| 124 |
+
partial_scores: Dict[str, float] = Field(
|
| 125 |
+
default_factory=dict,
|
| 126 |
+
description="Per-criterion scores contributing to value.",
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
# State model (returned by state())
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
class EnvironmentState(BaseModel):
|
| 135 |
+
"""Full internal state snapshot (superset of observation)."""
|
| 136 |
+
|
| 137 |
+
observation: TicketObservation
|
| 138 |
+
ground_truth: Dict[str, Any] = Field(
|
| 139 |
+
description="Hidden ground-truth labels used by graders.",
|
| 140 |
+
)
|
| 141 |
+
cumulative_reward: float = 0.0
|
| 142 |
+
step_number: int = 0
|
| 143 |
+
done: bool = False
|
| 144 |
+
task_name: str = ""
|
openenv.yaml
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: support-ticket-triage
|
| 2 |
+
version: "1.0.0"
|
| 3 |
+
description: >
|
| 4 |
+
A real-world OpenEnv environment where AI agents must triage, route, and resolve
|
| 5 |
+
customer support tickets. Agents observe ticket content and conversation history,
|
| 6 |
+
then take actions such as routing to departments, setting urgency, responding
|
| 7 |
+
to customers, escalating issues, and closing tickets.
|
| 8 |
+
|
| 9 |
+
author: "OpenEnv Hackathon Submission"
|
| 10 |
+
tags:
|
| 11 |
+
- openenv
|
| 12 |
+
- customer-support
|
| 13 |
+
- triage
|
| 14 |
+
- nlp
|
| 15 |
+
- real-world
|
| 16 |
+
|
| 17 |
+
# ── Task definitions ────────────────────────────────────────────────────────
|
| 18 |
+
tasks:
|
| 19 |
+
- name: route
|
| 20 |
+
display_name: "Ticket Routing (Easy)"
|
| 21 |
+
difficulty: easy
|
| 22 |
+
max_steps: 3
|
| 23 |
+
success_threshold: 1.0
|
| 24 |
+
description: >
|
| 25 |
+
Route the incoming support ticket to the correct department.
|
| 26 |
+
Score 1.0 for correct routing, 0.0 for wrong department.
|
| 27 |
+
ticket_pool: [TKT-001, TKT-002, TKT-003, TKT-004, TKT-005]
|
| 28 |
+
|
| 29 |
+
- name: triage
|
| 30 |
+
display_name: "Full Triage (Medium)"
|
| 31 |
+
difficulty: medium
|
| 32 |
+
max_steps: 8
|
| 33 |
+
success_threshold: 0.7
|
| 34 |
+
description: >
|
| 35 |
+
Fully triage the ticket: route correctly (0.30), set urgency (0.25),
|
| 36 |
+
add relevant tags (0.20), and send an informative initial response (0.25).
|
| 37 |
+
ticket_pool: [TKT-006, TKT-007, TKT-001, TKT-003]
|
| 38 |
+
|
| 39 |
+
- name: resolve
|
| 40 |
+
display_name: "Full Resolution (Hard)"
|
| 41 |
+
difficulty: hard
|
| 42 |
+
max_steps: 12
|
| 43 |
+
success_threshold: 0.6
|
| 44 |
+
description: >
|
| 45 |
+
Fully resolve a complex ticket over multiple turns: route (0.15),
|
| 46 |
+
set urgency (0.10), respond initially (0.20), escalate if needed (0.20),
|
| 47 |
+
handle customer follow-up (0.20), and close with resolution note (0.15).
|
| 48 |
+
ticket_pool: [TKT-008, TKT-009]
|
| 49 |
+
|
| 50 |
+
# ── Observation space ────────────────────────────────────────────────────────
|
| 51 |
+
observation_space:
|
| 52 |
+
type: object
|
| 53 |
+
fields:
|
| 54 |
+
ticket_id: {type: string}
|
| 55 |
+
subject: {type: string}
|
| 56 |
+
body: {type: string}
|
| 57 |
+
sender_email: {type: string}
|
| 58 |
+
sender_name: {type: string}
|
| 59 |
+
conversation_history:
|
| 60 |
+
type: array
|
| 61 |
+
items:
|
| 62 |
+
sender: string
|
| 63 |
+
content: string
|
| 64 |
+
timestamp: string
|
| 65 |
+
current_department: {type: string, nullable: true, enum: [billing, technical_support, sales, customer_success, legal]}
|
| 66 |
+
current_urgency: {type: string, nullable: true, enum: [low, medium, high, critical]}
|
| 67 |
+
tags: {type: array, items: string}
|
| 68 |
+
is_escalated: {type: boolean}
|
| 69 |
+
is_closed: {type: boolean}
|
| 70 |
+
step_number: {type: integer}
|
| 71 |
+
task_name: {type: string}
|
| 72 |
+
task_description: {type: string}
|
| 73 |
+
available_actions: {type: array, items: string}
|
| 74 |
+
|
| 75 |
+
# ── Action space ─────────────────────────────────────────────────────────────
|
| 76 |
+
action_space:
|
| 77 |
+
type: object
|
| 78 |
+
fields:
|
| 79 |
+
action_type:
|
| 80 |
+
type: string
|
| 81 |
+
enum: [route, respond, set_urgency, tag, escalate, close, noop]
|
| 82 |
+
department:
|
| 83 |
+
type: string
|
| 84 |
+
nullable: true
|
| 85 |
+
enum: [billing, technical_support, sales, customer_success, legal]
|
| 86 |
+
response_text: {type: string, nullable: true}
|
| 87 |
+
urgency: {type: string, nullable: true, enum: [low, medium, high, critical]}
|
| 88 |
+
tags: {type: array, items: string, nullable: true}
|
| 89 |
+
escalation_reason: {type: string, nullable: true}
|
| 90 |
+
resolution_note: {type: string, nullable: true}
|
| 91 |
+
|
| 92 |
+
# ── Reward function ──────────────────────────────────────────────────────────
|
| 93 |
+
reward:
|
| 94 |
+
range: [0.0, 1.0]
|
| 95 |
+
type: shaped
|
| 96 |
+
description: >
|
| 97 |
+
Step-level shaped rewards guide the agent during the episode.
|
| 98 |
+
Terminal reward from the grader provides the authoritative episode score.
|
| 99 |
+
mid_episode: >
|
| 100 |
+
Partial credit per action: +0.3 correct routing, +0.2 correct urgency,
|
| 101 |
+
+0.1×overlap tag matching, +0.15×quality response, +0.2 justified escalation,
|
| 102 |
+
-0.1 unjustified escalation, +0.1 closure with note.
|
| 103 |
+
terminal: >
|
| 104 |
+
Task-specific weighted grader aggregates all sub-task scores into a
|
| 105 |
+
final [0.0, 1.0] score.
|
| 106 |
+
|
| 107 |
+
# ── API endpoints ─────────────────────────────────────────────────────────────
|
| 108 |
+
api:
|
| 109 |
+
base_url: "http://localhost:7860"
|
| 110 |
+
endpoints:
|
| 111 |
+
reset: {method: POST, path: /reset}
|
| 112 |
+
step: {method: POST, path: /step}
|
| 113 |
+
state: {method: GET, path: /state}
|
| 114 |
+
tasks: {method: GET, path: /tasks}
|
| 115 |
+
health: {method: GET, path: /}
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.32.0
|
| 3 |
+
pydantic>=2.0,<3.0
|
| 4 |
+
openai>=1.0,<2.0
|
| 5 |
+
requests>=2.31,<3.0
|
| 6 |
+
python-multipart==0.0.12
|
| 7 |
+
httpx>=0.27,<1.0
|
server.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI server — Support Ticket Triage OpenEnv
|
| 3 |
+
================================================
|
| 4 |
+
|
| 5 |
+
Exposes the standard OpenEnv HTTP API:
|
| 6 |
+
GET / → healthcheck + metadata
|
| 7 |
+
POST /reset → start episode, returns observation
|
| 8 |
+
POST /step → apply action, returns (obs, reward, done, info)
|
| 9 |
+
GET /state → full internal state (with ground truth)
|
| 10 |
+
GET /tasks → list all tasks
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import uuid
|
| 16 |
+
from typing import Any, Dict, Optional
|
| 17 |
+
|
| 18 |
+
from fastapi import FastAPI, HTTPException
|
| 19 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 20 |
+
from pydantic import BaseModel
|
| 21 |
+
|
| 22 |
+
from env.environment import TicketTriageEnv
|
| 23 |
+
from env.models import ActionType, Department, TicketAction, UrgencyLevel
|
| 24 |
+
|
| 25 |
+
app = FastAPI(
|
| 26 |
+
title="Support Ticket Triage — OpenEnv",
|
| 27 |
+
description=(
|
| 28 |
+
"An OpenEnv environment where AI agents must triage, route, and resolve "
|
| 29 |
+
"customer support tickets. Real-world task with 3 difficulty levels."
|
| 30 |
+
),
|
| 31 |
+
version="1.0.0",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
app.add_middleware(
|
| 35 |
+
CORSMiddleware,
|
| 36 |
+
allow_origins=["*"],
|
| 37 |
+
allow_methods=["*"],
|
| 38 |
+
allow_headers=["*"],
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
# In-memory session store (keyed by session_id)
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
|
| 45 |
+
_sessions: Dict[str, TicketTriageEnv] = {}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _get_env(session_id: str) -> TicketTriageEnv:
|
| 49 |
+
env = _sessions.get(session_id)
|
| 50 |
+
if env is None:
|
| 51 |
+
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found.")
|
| 52 |
+
return env
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# Request / response schemas
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
|
| 59 |
+
class ResetRequest(BaseModel):
|
| 60 |
+
task_name: str = "route"
|
| 61 |
+
ticket_id: Optional[str] = None
|
| 62 |
+
seed: Optional[int] = 42
|
| 63 |
+
session_id: Optional[str] = None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class StepRequest(BaseModel):
|
| 67 |
+
session_id: str
|
| 68 |
+
action_type: str
|
| 69 |
+
department: Optional[str] = None
|
| 70 |
+
response_text: Optional[str] = None
|
| 71 |
+
urgency: Optional[str] = None
|
| 72 |
+
tags: Optional[list[str]] = None
|
| 73 |
+
escalation_reason: Optional[str] = None
|
| 74 |
+
resolution_note: Optional[str] = None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class StepResponse(BaseModel):
|
| 78 |
+
observation: Dict[str, Any]
|
| 79 |
+
reward: Dict[str, Any]
|
| 80 |
+
done: bool
|
| 81 |
+
info: Dict[str, Any]
|
| 82 |
+
session_id: str
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Routes
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
@app.get("/")
|
| 90 |
+
def healthcheck():
|
| 91 |
+
return {
|
| 92 |
+
"status": "ok",
|
| 93 |
+
"environment": "Support Ticket Triage",
|
| 94 |
+
"version": "1.0.0",
|
| 95 |
+
"tasks": TicketTriageEnv.list_tasks(),
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@app.get("/tasks")
|
| 100 |
+
def list_tasks():
|
| 101 |
+
return {"tasks": TicketTriageEnv.list_tasks()}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@app.post("/reset")
|
| 105 |
+
def reset(req: ResetRequest):
|
| 106 |
+
session_id = req.session_id or str(uuid.uuid4())
|
| 107 |
+
env = TicketTriageEnv(
|
| 108 |
+
task_name=req.task_name,
|
| 109 |
+
ticket_id=req.ticket_id,
|
| 110 |
+
seed=req.seed,
|
| 111 |
+
)
|
| 112 |
+
_sessions[session_id] = env
|
| 113 |
+
obs = env.reset()
|
| 114 |
+
return {"observation": obs.model_dump(), "session_id": session_id}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.post("/step", response_model=StepResponse)
|
| 118 |
+
def step(req: StepRequest):
|
| 119 |
+
env = _get_env(req.session_id)
|
| 120 |
+
|
| 121 |
+
# Parse action
|
| 122 |
+
try:
|
| 123 |
+
action_type = ActionType(req.action_type)
|
| 124 |
+
except ValueError:
|
| 125 |
+
raise HTTPException(
|
| 126 |
+
status_code=422,
|
| 127 |
+
detail=f"Invalid action_type '{req.action_type}'. "
|
| 128 |
+
f"Valid: {[a.value for a in ActionType]}",
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
dept = None
|
| 132 |
+
if req.department:
|
| 133 |
+
try:
|
| 134 |
+
dept = Department(req.department)
|
| 135 |
+
except ValueError:
|
| 136 |
+
raise HTTPException(
|
| 137 |
+
status_code=422,
|
| 138 |
+
detail=f"Invalid department '{req.department}'.",
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
urg = None
|
| 142 |
+
if req.urgency:
|
| 143 |
+
try:
|
| 144 |
+
urg = UrgencyLevel(req.urgency)
|
| 145 |
+
except ValueError:
|
| 146 |
+
raise HTTPException(
|
| 147 |
+
status_code=422,
|
| 148 |
+
detail=f"Invalid urgency '{req.urgency}'.",
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
action = TicketAction(
|
| 152 |
+
action_type=action_type,
|
| 153 |
+
department=dept,
|
| 154 |
+
response_text=req.response_text,
|
| 155 |
+
urgency=urg,
|
| 156 |
+
tags=req.tags,
|
| 157 |
+
escalation_reason=req.escalation_reason,
|
| 158 |
+
resolution_note=req.resolution_note,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
obs, reward, done, info = env.step(action)
|
| 162 |
+
|
| 163 |
+
if done:
|
| 164 |
+
# Clean up session after episode ends
|
| 165 |
+
_sessions.pop(req.session_id, None)
|
| 166 |
+
|
| 167 |
+
return StepResponse(
|
| 168 |
+
observation=obs.model_dump(),
|
| 169 |
+
reward=reward.model_dump(),
|
| 170 |
+
done=done,
|
| 171 |
+
info=info,
|
| 172 |
+
session_id=req.session_id,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@app.get("/state")
|
| 177 |
+
def state(session_id: str):
|
| 178 |
+
env = _get_env(session_id)
|
| 179 |
+
s = env.state()
|
| 180 |
+
return s.model_dump()
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ---------------------------------------------------------------------------
|
| 184 |
+
# Entry point (for local dev)
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
|
| 187 |
+
if __name__ == "__main__":
|
| 188 |
+
import uvicorn
|
| 189 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
tasks.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task definitions for the Support Ticket Triage OpenEnv environment.
|
| 3 |
+
|
| 4 |
+
Each task specifies:
|
| 5 |
+
- name & description shown to the agent
|
| 6 |
+
- which tickets belong to this task
|
| 7 |
+
- max_steps allowed
|
| 8 |
+
- grader function reference
|
| 9 |
+
- expected difficulty
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from typing import List
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class TaskSpec:
|
| 20 |
+
name: str
|
| 21 |
+
display_name: str
|
| 22 |
+
description: str
|
| 23 |
+
ticket_ids: List[str]
|
| 24 |
+
max_steps: int
|
| 25 |
+
difficulty: str # "easy" | "medium" | "hard"
|
| 26 |
+
grader_name: str # matches key in graders.py GRADERS dict
|
| 27 |
+
success_threshold: float # minimum score considered "solved"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# Task 1 — EASY: Route to the correct department
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
TASK_ROUTE = TaskSpec(
|
| 34 |
+
name="route",
|
| 35 |
+
display_name="Ticket Routing (Easy)",
|
| 36 |
+
description=(
|
| 37 |
+
"Read the support ticket and route it to the correct department by "
|
| 38 |
+
"using the ROUTE action. You have one chance to route correctly. "
|
| 39 |
+
"Available departments: billing, technical_support, sales, "
|
| 40 |
+
"customer_success, legal. "
|
| 41 |
+
"Score 1.0 for the correct department, 0.0 for wrong."
|
| 42 |
+
),
|
| 43 |
+
ticket_ids=["TKT-001", "TKT-002", "TKT-003", "TKT-004", "TKT-005"],
|
| 44 |
+
max_steps=3,
|
| 45 |
+
difficulty="easy",
|
| 46 |
+
grader_name="route_grader",
|
| 47 |
+
success_threshold=1.0,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
# Task 2 — MEDIUM: Route + Set Urgency + Tag + Respond
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
TASK_TRIAGE = TaskSpec(
|
| 55 |
+
name="triage",
|
| 56 |
+
display_name="Full Triage (Medium)",
|
| 57 |
+
description=(
|
| 58 |
+
"Fully triage the support ticket by completing ALL of the following: "
|
| 59 |
+
"(1) ROUTE to the correct department, "
|
| 60 |
+
"(2) SET_URGENCY to the appropriate level (low/medium/high/critical), "
|
| 61 |
+
"(3) TAG with relevant classification labels, "
|
| 62 |
+
"(4) RESPOND with a helpful initial reply to the customer. "
|
| 63 |
+
"You must close the ticket with CLOSE after completing all steps. "
|
| 64 |
+
"Partial credit is awarded for each sub-task completed correctly."
|
| 65 |
+
),
|
| 66 |
+
ticket_ids=["TKT-006", "TKT-007", "TKT-001", "TKT-003"],
|
| 67 |
+
max_steps=8,
|
| 68 |
+
difficulty="medium",
|
| 69 |
+
grader_name="triage_grader",
|
| 70 |
+
success_threshold=0.7,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
# Task 3 — HARD: Full resolution including follow-up and escalation
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
TASK_RESOLVE = TaskSpec(
|
| 78 |
+
name="resolve",
|
| 79 |
+
display_name="Full Resolution (Hard)",
|
| 80 |
+
description=(
|
| 81 |
+
"Fully resolve a complex support ticket over multiple steps. "
|
| 82 |
+
"You must: (1) ROUTE correctly, (2) SET_URGENCY appropriately, "
|
| 83 |
+
"(3) RESPOND with an empathetic and actionable initial reply, "
|
| 84 |
+
"(4) ESCALATE with a clear reason if the ticket warrants it, "
|
| 85 |
+
"(5) Handle a follow-up message from the customer with another RESPOND, "
|
| 86 |
+
"(6) CLOSE the ticket with a resolution summary. "
|
| 87 |
+
"All steps are required for full score. Graders check response quality, "
|
| 88 |
+
"escalation decisions, and resolution completeness."
|
| 89 |
+
),
|
| 90 |
+
ticket_ids=["TKT-008", "TKT-009"],
|
| 91 |
+
max_steps=12,
|
| 92 |
+
difficulty="hard",
|
| 93 |
+
grader_name="resolve_grader",
|
| 94 |
+
success_threshold=0.6,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
ALL_TASKS: List[TaskSpec] = [TASK_ROUTE, TASK_TRIAGE, TASK_RESOLVE]
|
| 99 |
+
TASK_LOOKUP = {t.name: t for t in ALL_TASKS}
|