Gaurav711 commited on
Commit
fa06dbc
·
1 Parent(s): 82bc2af

Initial - Commit

Browse files
Files changed (11) hide show
  1. Dockerfile +45 -0
  2. README.md +277 -0
  3. data.py +216 -0
  4. environment.py +358 -0
  5. graders.py +261 -0
  6. inference.py +279 -0
  7. models.py +144 -0
  8. openenv.yaml +115 -0
  9. requirements.txt +7 -0
  10. server.py +189 -0
  11. 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}