Spaces:
Running
Running
Merge pull request #10 from suyashkumar102/final-submit-gap-fixes
Browse files- KNOWLEDGE.md +25 -10
- README.md +46 -7
- ROADMAP.md +72 -6
- analysis/deep_competitive_gap_report.md +1374 -0
- data/dataset.json +36 -0
- gaps.md +146 -0
- inference.py +142 -8
- models.py +29 -1
- openenv.yaml +4 -0
- server/app.py +20 -0
- server/environment.py +285 -33
- server/reward.py +12 -4
- server/tasks.py +7 -3
- tests/test_api_integration.py +1 -1
- tests/test_competitive_upgrade.py +746 -0
- tests/test_environment_smoke.py +7 -0
- tests/test_extra_fields_penalty.py +182 -0
- tests/test_inference_unit.py +16 -0
- tests/test_tasks_unit.py +1 -1
KNOWLEDGE.md
CHANGED
|
@@ -24,7 +24,7 @@ IT helpdesk routing is a strong hackathon fit because it is:
|
|
| 24 |
- deterministic to grade
|
| 25 |
- naturally multi-step
|
| 26 |
|
| 27 |
-
A helpdesk agent has to decide what the ticket is about, how urgent it is, who should own it, and what should happen next.
|
| 28 |
|
| 29 |
## The Repo In One Sentence
|
| 30 |
|
|
@@ -134,7 +134,7 @@ Important fields:
|
|
| 134 |
|
| 135 |
### `HelpdeskTicketAction`
|
| 136 |
|
| 137 |
-
Represents the agent
|
| 138 |
|
| 139 |
### `HelpdeskTicketObservation`
|
| 140 |
|
|
@@ -142,6 +142,7 @@ Represents what the agent sees for each step:
|
|
| 142 |
|
| 143 |
- task metadata
|
| 144 |
- visible ticket fields
|
|
|
|
| 145 |
- queue progress
|
| 146 |
- score history
|
| 147 |
|
|
@@ -179,10 +180,19 @@ The observation exposes:
|
|
| 179 |
|
| 180 |
- task metadata
|
| 181 |
- the current ticket
|
|
|
|
|
|
|
|
|
|
| 182 |
- queue progress counters
|
| 183 |
- history
|
| 184 |
- reward and done status
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
The state tracks:
|
| 187 |
|
| 188 |
- current task
|
|
@@ -191,12 +201,13 @@ The state tracks:
|
|
| 191 |
- current ticket index
|
| 192 |
- per-ticket scores
|
| 193 |
- total reward
|
|
|
|
| 194 |
|
| 195 |
## Task Design
|
| 196 |
|
| 197 |
### Task 1: Issue Type Classification
|
| 198 |
|
| 199 |
-
The agent predicts:
|
| 200 |
|
| 201 |
- `issue_type`
|
| 202 |
|
|
@@ -206,7 +217,7 @@ Purpose:
|
|
| 206 |
|
| 207 |
### Task 2: Issue Type And Priority
|
| 208 |
|
| 209 |
-
The agent predicts:
|
| 210 |
|
| 211 |
- `issue_type`
|
| 212 |
- `priority`
|
|
@@ -217,7 +228,7 @@ Purpose:
|
|
| 217 |
|
| 218 |
### Task 3: Full Ticket Routing
|
| 219 |
|
| 220 |
-
The agent predicts:
|
| 221 |
|
| 222 |
- `issue_type`
|
| 223 |
- `priority`
|
|
@@ -256,14 +267,14 @@ This is now proven in checked-in unit tests rather than left as a docs claim.
|
|
| 256 |
|
| 257 |
Step reward:
|
| 258 |
|
| 259 |
-
- current ticket score
|
| 260 |
|
| 261 |
Final reward:
|
| 262 |
|
| 263 |
- average of ticket scores
|
| 264 |
-
- minus a
|
| 265 |
|
| 266 |
-
This
|
| 267 |
|
| 268 |
## Dataset Mental Model
|
| 269 |
|
|
@@ -277,6 +288,8 @@ Current structure:
|
|
| 277 |
- harder ambiguous cases
|
| 278 |
- follow-up tickets connected through `related_ticket_id`
|
| 279 |
|
|
|
|
|
|
|
| 280 |
The dataset is meant to test routing judgment, not just keyword spotting.
|
| 281 |
|
| 282 |
## Grounding Note
|
|
@@ -299,16 +312,18 @@ It:
|
|
| 299 |
|
| 300 |
1. connects to the environment
|
| 301 |
2. loads the available tasks
|
| 302 |
-
3. runs one episode
|
| 303 |
4. picks an action for each ticket
|
| 304 |
5. sends the action back through the client
|
| 305 |
6. records rewards
|
| 306 |
-
7. prints
|
| 307 |
|
| 308 |
It supports:
|
| 309 |
|
| 310 |
- heuristic mode with no external model
|
| 311 |
- LLM mode through an OpenAI-compatible API
|
|
|
|
|
|
|
| 312 |
|
| 313 |
## Files That Matter Most
|
| 314 |
|
|
|
|
| 24 |
- deterministic to grade
|
| 25 |
- naturally multi-step
|
| 26 |
|
| 27 |
+
A helpdesk agent has to decide what the ticket is about, how urgent it is, who should own it, and what should happen next. The current runtime now supports a small two-mode action object: investigate first when needed, then submit the final routing answer.
|
| 28 |
|
| 29 |
## The Repo In One Sentence
|
| 30 |
|
|
|
|
| 134 |
|
| 135 |
### `HelpdeskTicketAction`
|
| 136 |
|
| 137 |
+
Represents the agent step. `action_type="submit"` carries routing fields, while `action_type="investigate"` uses a small built-in tool surface before the final submission.
|
| 138 |
|
| 139 |
### `HelpdeskTicketObservation`
|
| 140 |
|
|
|
|
| 142 |
|
| 143 |
- task metadata
|
| 144 |
- visible ticket fields
|
| 145 |
+
- optional ambiguity or follow-up context
|
| 146 |
- queue progress
|
| 147 |
- score history
|
| 148 |
|
|
|
|
| 180 |
|
| 181 |
- task metadata
|
| 182 |
- the current ticket
|
| 183 |
+
- available investigation tools
|
| 184 |
+
- remaining free investigation budget
|
| 185 |
+
- the latest tool result, when one was requested
|
| 186 |
- queue progress counters
|
| 187 |
- history
|
| 188 |
- reward and done status
|
| 189 |
|
| 190 |
+
Useful queue counters now include:
|
| 191 |
+
|
| 192 |
+
- `tickets_remaining`: not-yet-processed tickets, including the current ticket when one is active
|
| 193 |
+
- `tickets_after_current`: how many tickets remain after the current one
|
| 194 |
+
- `queue_position`: 1-based position of the current ticket in the queue
|
| 195 |
+
|
| 196 |
The state tracks:
|
| 197 |
|
| 198 |
- current task
|
|
|
|
| 201 |
- current ticket index
|
| 202 |
- per-ticket scores
|
| 203 |
- total reward
|
| 204 |
+
- investigation step count
|
| 205 |
|
| 206 |
## Task Design
|
| 207 |
|
| 208 |
### Task 1: Issue Type Classification
|
| 209 |
|
| 210 |
+
The agent ultimately predicts:
|
| 211 |
|
| 212 |
- `issue_type`
|
| 213 |
|
|
|
|
| 217 |
|
| 218 |
### Task 2: Issue Type And Priority
|
| 219 |
|
| 220 |
+
The agent ultimately predicts:
|
| 221 |
|
| 222 |
- `issue_type`
|
| 223 |
- `priority`
|
|
|
|
| 228 |
|
| 229 |
### Task 3: Full Ticket Routing
|
| 230 |
|
| 231 |
+
The agent ultimately predicts:
|
| 232 |
|
| 233 |
- `issue_type`
|
| 234 |
- `priority`
|
|
|
|
| 267 |
|
| 268 |
Step reward:
|
| 269 |
|
| 270 |
+
- current ticket score with a small milestone bonus for strong steps and a small penalty for very weak steps
|
| 271 |
|
| 272 |
Final reward:
|
| 273 |
|
| 274 |
- average of ticket scores
|
| 275 |
+
- minus a tiny penalty only if the agent exceeds the free investigation budget for the queue
|
| 276 |
|
| 277 |
+
This keeps the reward dense and deterministic, removes the dead overshoot logic, and adds a small queue-level economics signal without disturbing the no-tool baseline path.
|
| 278 |
|
| 279 |
## Dataset Mental Model
|
| 280 |
|
|
|
|
| 288 |
- harder ambiguous cases
|
| 289 |
- follow-up tickets connected through `related_ticket_id`
|
| 290 |
|
| 291 |
+
When a follow-up link exists, the observation can now surface a lightweight `related_ticket_preview`, and the tool layer can fetch richer related-ticket or requester-history context so the agent does not have to route every ticket from isolated text alone.
|
| 292 |
+
|
| 293 |
The dataset is meant to test routing judgment, not just keyword spotting.
|
| 294 |
|
| 295 |
## Grounding Note
|
|
|
|
| 312 |
|
| 313 |
1. connects to the environment
|
| 314 |
2. loads the available tasks
|
| 315 |
+
3. runs one episode for the requested task
|
| 316 |
4. picks an action for each ticket
|
| 317 |
5. sends the action back through the client
|
| 318 |
6. records rewards
|
| 319 |
+
7. prints structured logs for that run
|
| 320 |
|
| 321 |
It supports:
|
| 322 |
|
| 323 |
- heuristic mode with no external model
|
| 324 |
- LLM mode through an OpenAI-compatible API
|
| 325 |
+
- lightweight investigation-tool calls before the final submit action
|
| 326 |
+
- an explicit local `RUN_ALL_TASKS=1` override when you want the old multi-task sweep
|
| 327 |
|
| 328 |
## Files That Matter Most
|
| 329 |
|
README.md
CHANGED
|
@@ -34,7 +34,7 @@ The environment models a realistic helpdesk workflow:
|
|
| 34 |
|
| 35 |
1. a new ticket enters the queue
|
| 36 |
2. the agent reads the ticket title and description
|
| 37 |
-
3. the agent
|
| 38 |
4. the grader assigns deterministic credit
|
| 39 |
5. the environment advances to the next ticket until the queue is complete
|
| 40 |
|
|
@@ -43,7 +43,7 @@ This domain is useful for OpenEnv because it is operationally realistic, easy to
|
|
| 43 |
## Why This Is A Good Hackathon Domain
|
| 44 |
|
| 45 |
- it reflects real enterprise support operations
|
| 46 |
-
- the action space is structured and judge-friendly
|
| 47 |
- correctness can be scored deterministically
|
| 48 |
- the hard task is meaningfully harder than the easy and medium tasks
|
| 49 |
- the environment is small enough to rerun quickly
|
|
@@ -55,7 +55,7 @@ The project uses a queue-based episode model.
|
|
| 55 |
- `reset()` samples a task and a queue of 3 to 5 tickets
|
| 56 |
- `step()` grades one ticket submission at a time
|
| 57 |
- `state()` exposes the internal episode snapshot
|
| 58 |
-
- final reward is based on average ticket quality
|
| 59 |
|
| 60 |
The environment classes and vocabulary are intentionally frozen to keep collaboration and judging simple.
|
| 61 |
|
|
@@ -115,6 +115,9 @@ Visible ticket fields:
|
|
| 115 |
- `title`
|
| 116 |
- `requester`
|
| 117 |
- `description`
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
Each observation also includes:
|
| 120 |
|
|
@@ -122,9 +125,14 @@ Each observation also includes:
|
|
| 122 |
- `task_name`
|
| 123 |
- `instructions`
|
| 124 |
- `allowed_fields`
|
|
|
|
|
|
|
|
|
|
| 125 |
- `queue_size`
|
| 126 |
- `tickets_remaining`
|
|
|
|
| 127 |
- `tickets_processed`
|
|
|
|
| 128 |
- `history`
|
| 129 |
- standard OpenEnv fields such as `done` and `reward`
|
| 130 |
|
|
@@ -138,11 +146,23 @@ The internal `HelpdeskTicketState` tracks:
|
|
| 138 |
- `current_ticket_index`
|
| 139 |
- `per_ticket_scores`
|
| 140 |
- `total_reward`
|
|
|
|
|
|
|
| 141 |
|
| 142 |
## Grading And Reward
|
| 143 |
|
| 144 |
Scoring is deterministic and normalized to `[0.0, 1.0]`.
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
Per-field behavior:
|
| 147 |
|
| 148 |
- `issue_type`: exact match, with a few near-miss partial-credit pairs
|
|
@@ -161,11 +181,15 @@ Task weights:
|
|
| 161 |
Final episode reward:
|
| 162 |
|
| 163 |
```text
|
| 164 |
-
average(per_ticket_scores)
|
| 165 |
```
|
| 166 |
|
| 167 |
The result is clamped to `[0.0, 1.0]`.
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
## Grounded Scoring
|
| 170 |
|
| 171 |
The grader is intentionally not fuzzy by default.
|
|
@@ -285,7 +309,7 @@ curl http://localhost:7860/tasks
|
|
| 285 |
|
| 286 |
## Running The Baseline Inference Script
|
| 287 |
|
| 288 |
-
The baseline script supports
|
| 289 |
|
| 290 |
### Heuristic mode
|
| 291 |
|
|
@@ -295,6 +319,12 @@ If no LLM credentials are set, it uses a keyword-based ticket router:
|
|
| 295 |
python inference.py
|
| 296 |
```
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
### LLM mode
|
| 299 |
|
| 300 |
Set these environment variables first:
|
|
@@ -313,6 +343,14 @@ Optional target:
|
|
| 313 |
|
| 314 |
- `ENV_URL`
|
| 315 |
- default value: `http://localhost:7860`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
## Runtime Validation Snapshot
|
| 318 |
|
|
@@ -324,7 +362,7 @@ Validated locally:
|
|
| 324 |
- `/health`
|
| 325 |
- `/tasks`
|
| 326 |
- `/reset`
|
| 327 |
-
- heuristic `inference.py` run across all 3 tasks
|
| 328 |
|
| 329 |
Current local heuristic results:
|
| 330 |
|
|
@@ -358,7 +396,7 @@ docker run -p 7860:7860 helpdesk-ticket-routing
|
|
| 358 |
Then run inference against it (default `ENV_URL` points to `http://localhost:7860`):
|
| 359 |
|
| 360 |
```bash
|
| 361 |
-
python inference.py
|
| 362 |
```
|
| 363 |
|
| 364 |
If you publish the container on a different host port, set `ENV_URL` accordingly before running `inference.py`.
|
|
@@ -376,6 +414,7 @@ OpenEnv provides the core environment endpoints, and the repo adds a custom task
|
|
| 376 |
| POST | `/step` | submit an action |
|
| 377 |
| GET | `/state` | inspect internal state |
|
| 378 |
| GET | `/tasks` | list task metadata |
|
|
|
|
| 379 |
| GET | `/docs` | interactive API docs |
|
| 380 |
|
| 381 |
## Submission Readiness
|
|
|
|
| 34 |
|
| 35 |
1. a new ticket enters the queue
|
| 36 |
2. the agent reads the ticket title and description
|
| 37 |
+
3. the agent may investigate with lightweight tools, then submit structured routing fields
|
| 38 |
4. the grader assigns deterministic credit
|
| 39 |
5. the environment advances to the next ticket until the queue is complete
|
| 40 |
|
|
|
|
| 43 |
## Why This Is A Good Hackathon Domain
|
| 44 |
|
| 45 |
- it reflects real enterprise support operations
|
| 46 |
+
- the action space is structured and judge-friendly, with a small investigate-versus-submit split
|
| 47 |
- correctness can be scored deterministically
|
| 48 |
- the hard task is meaningfully harder than the easy and medium tasks
|
| 49 |
- the environment is small enough to rerun quickly
|
|
|
|
| 55 |
- `reset()` samples a task and a queue of 3 to 5 tickets
|
| 56 |
- `step()` grades one ticket submission at a time
|
| 57 |
- `state()` exposes the internal episode snapshot
|
| 58 |
+
- final reward is based on average ticket quality across the queue
|
| 59 |
|
| 60 |
The environment classes and vocabulary are intentionally frozen to keep collaboration and judging simple.
|
| 61 |
|
|
|
|
| 115 |
- `title`
|
| 116 |
- `requester`
|
| 117 |
- `description`
|
| 118 |
+
- optional `ambiguity_note`
|
| 119 |
+
- optional `related_ticket_id`
|
| 120 |
+
- optional `related_ticket_preview`
|
| 121 |
|
| 122 |
Each observation also includes:
|
| 123 |
|
|
|
|
| 125 |
- `task_name`
|
| 126 |
- `instructions`
|
| 127 |
- `allowed_fields`
|
| 128 |
+
- `available_tools`
|
| 129 |
+
- `investigation_budget_remaining`
|
| 130 |
+
- `last_tool_result`
|
| 131 |
- `queue_size`
|
| 132 |
- `tickets_remaining`
|
| 133 |
+
- `tickets_after_current`
|
| 134 |
- `tickets_processed`
|
| 135 |
+
- `queue_position`
|
| 136 |
- `history`
|
| 137 |
- standard OpenEnv fields such as `done` and `reward`
|
| 138 |
|
|
|
|
| 146 |
- `current_ticket_index`
|
| 147 |
- `per_ticket_scores`
|
| 148 |
- `total_reward`
|
| 149 |
+
- `reward`
|
| 150 |
+
- `done`
|
| 151 |
|
| 152 |
## Grading And Reward
|
| 153 |
|
| 154 |
Scoring is deterministic and normalized to `[0.0, 1.0]`.
|
| 155 |
|
| 156 |
+
The action model now supports two paths:
|
| 157 |
+
|
| 158 |
+
- `action_type="submit"` for the final routing answer
|
| 159 |
+
- `action_type="investigate"` with a small built-in tool surface before submission
|
| 160 |
+
|
| 161 |
+
Available tools:
|
| 162 |
+
|
| 163 |
+
- `lookup_related_ticket`
|
| 164 |
+
- `lookup_requester_history`
|
| 165 |
+
|
| 166 |
Per-field behavior:
|
| 167 |
|
| 168 |
- `issue_type`: exact match, with a few near-miss partial-credit pairs
|
|
|
|
| 181 |
Final episode reward:
|
| 182 |
|
| 183 |
```text
|
| 184 |
+
average(per_ticket_scores)
|
| 185 |
```
|
| 186 |
|
| 187 |
The result is clamped to `[0.0, 1.0]`.
|
| 188 |
|
| 189 |
+
Step reward is lightly milestone-shaped: high per-ticket scores get a small bonus and very low scores get a small penalty before the final clamp.
|
| 190 |
+
|
| 191 |
+
Final reward also includes a tiny queue-economics penalty only when the agent exceeds the free investigation budget. One investigation per queued ticket is free; extra investigation steps reduce the final reward slightly.
|
| 192 |
+
|
| 193 |
## Grounded Scoring
|
| 194 |
|
| 195 |
The grader is intentionally not fuzzy by default.
|
|
|
|
| 309 |
|
| 310 |
## Running The Baseline Inference Script
|
| 311 |
|
| 312 |
+
The baseline script supports single-task evaluator mode by default, plus an explicit local batch override.
|
| 313 |
|
| 314 |
### Heuristic mode
|
| 315 |
|
|
|
|
| 319 |
python inference.py
|
| 320 |
```
|
| 321 |
|
| 322 |
+
By default that runs exactly one task and emits exactly one `[START] ... [END]` block. To target a specific task:
|
| 323 |
+
|
| 324 |
+
```bash
|
| 325 |
+
TASK_ID=3 python inference.py
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
### LLM mode
|
| 329 |
|
| 330 |
Set these environment variables first:
|
|
|
|
| 343 |
|
| 344 |
- `ENV_URL`
|
| 345 |
- default value: `http://localhost:7860`
|
| 346 |
+
- `TASK_ID`
|
| 347 |
+
- `RUN_ALL_TASKS`
|
| 348 |
+
|
| 349 |
+
To reproduce the multi-task local benchmark sweep:
|
| 350 |
+
|
| 351 |
+
```bash
|
| 352 |
+
RUN_ALL_TASKS=1 python inference.py
|
| 353 |
+
```
|
| 354 |
|
| 355 |
## Runtime Validation Snapshot
|
| 356 |
|
|
|
|
| 362 |
- `/health`
|
| 363 |
- `/tasks`
|
| 364 |
- `/reset`
|
| 365 |
+
- heuristic `inference.py` run across all 3 tasks with `RUN_ALL_TASKS=1`
|
| 366 |
|
| 367 |
Current local heuristic results:
|
| 368 |
|
|
|
|
| 396 |
Then run inference against it (default `ENV_URL` points to `http://localhost:7860`):
|
| 397 |
|
| 398 |
```bash
|
| 399 |
+
RUN_ALL_TASKS=1 python inference.py
|
| 400 |
```
|
| 401 |
|
| 402 |
If you publish the container on a different host port, set `ENV_URL` accordingly before running `inference.py`.
|
|
|
|
| 414 |
| POST | `/step` | submit an action |
|
| 415 |
| GET | `/state` | inspect internal state |
|
| 416 |
| GET | `/tasks` | list task metadata |
|
| 417 |
+
| GET | `/web` | lightweight HF Space UI |
|
| 418 |
| GET | `/docs` | interactive API docs |
|
| 419 |
|
| 420 |
## Submission Readiness
|
ROADMAP.md
CHANGED
|
@@ -11,10 +11,39 @@
|
|
| 11 |
## How To Use This File
|
| 12 |
|
| 13 |
- `PROJECT_STATUS.md` is the canonical log of completed work.
|
| 14 |
-
- This roadmap is the
|
| 15 |
- `required.md` is now the combined official-requirements and project-compliance file.
|
| 16 |
- `KNOWLEDGE.md` defines the current repo truth and judge-facing explanation.
|
| 17 |
- `analysis/competition_notes.md` is the merged internal competitive note. Use it to prioritize work, but do not mention competitor repos in public-facing docs.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
## What We Are Optimizing For
|
| 20 |
|
|
@@ -47,14 +76,51 @@ The repo already has:
|
|
| 47 |
- deterministic grading with limited partial credit
|
| 48 |
- working heuristic baseline
|
| 49 |
- merged local validation on `/health`, `/tasks`, and `inference.py`
|
| 50 |
-
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
The remaining work should be treated as targeted strengthening, not broad feature invention.
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
## Submission Gates That Must Still Hold
|
| 59 |
|
| 60 |
These come directly from `required.md` and `KNOWLEDGE.md`:
|
|
|
|
| 11 |
## How To Use This File
|
| 12 |
|
| 13 |
- `PROJECT_STATUS.md` is the canonical log of completed work.
|
| 14 |
+
- This roadmap is the active plan from the verified April 6, 2026 repo state to final submission.
|
| 15 |
- `required.md` is now the combined official-requirements and project-compliance file.
|
| 16 |
- `KNOWLEDGE.md` defines the current repo truth and judge-facing explanation.
|
| 17 |
- `analysis/competition_notes.md` is the merged internal competitive note. Use it to prioritize work, but do not mention competitor repos in public-facing docs.
|
| 18 |
+
- The dated April 3 to April 5 sections below are now historical context; the active execution block is the final 24-hour plan for April 6 to April 7, 2026.
|
| 19 |
+
|
| 20 |
+
## Status As Of April 6, 2026
|
| 21 |
+
|
| 22 |
+
The repo is now in the expected "stabilize and merge" phase rather than the earlier "build core fixes" phase.
|
| 23 |
+
|
| 24 |
+
Completed and locally verified:
|
| 25 |
+
|
| 26 |
+
- all concrete items from `gaps.md`
|
| 27 |
+
- the viable low-risk improvements from `analysis/deep_competitive_gap_report.md`
|
| 28 |
+
- single-task `inference.py` execution with `TASK_ID` support and optional `RUN_ALL_TASKS=1`
|
| 29 |
+
- `state()` exposure of `reward` and `done`
|
| 30 |
+
- richer history with predicted actions and follow-up context
|
| 31 |
+
- lightweight investigate-versus-submit action support with tool-backed context lookup
|
| 32 |
+
- small queue-economics signal without major benchmark redesign
|
| 33 |
+
- `/web` UI route
|
| 34 |
+
- local full test pass:
|
| 35 |
+
- `126 passed, 137 subtests passed`
|
| 36 |
+
- local validator pass:
|
| 37 |
+
- `[OK] meta-AIHack: Ready for multi-mode deployment`
|
| 38 |
+
|
| 39 |
+
Merge recommendation:
|
| 40 |
+
|
| 41 |
+
- mergeable as an incremental submission-ready improvement branch
|
| 42 |
+
- do not block merge on major redesign items that were explicitly out of scope:
|
| 43 |
+
- scenario-family task redesign
|
| 44 |
+
- breaking the issue-type-to-assignment shortcut
|
| 45 |
+
- large dataset expansion
|
| 46 |
+
- full queue simulator / economics redesign
|
| 47 |
|
| 48 |
## What We Are Optimizing For
|
| 49 |
|
|
|
|
| 76 |
- deterministic grading with limited partial credit
|
| 77 |
- working heuristic baseline
|
| 78 |
- merged local validation on `/health`, `/tasks`, and `inference.py`
|
| 79 |
+
- single-task evaluator-safe inference behavior
|
| 80 |
+
- reward and done fields on `state()`
|
| 81 |
+
- richer observation history and linked-ticket context
|
| 82 |
+
- lightweight investigate / submit split with small built-in tool support
|
| 83 |
+
- local full-suite verification:
|
| 84 |
+
- `126 passed, 137 subtests passed`
|
| 85 |
+
- local validator verification:
|
| 86 |
+
- `[OK] meta-AIHack: Ready for multi-mode deployment`
|
| 87 |
|
| 88 |
The remaining work should be treated as targeted strengthening, not broad feature invention.
|
| 89 |
|
| 90 |
+
## Final 24-Hour Plan
|
| 91 |
+
|
| 92 |
+
**Active window:** April 6 to April 7, 2026
|
| 93 |
+
**Internal target:** open PR, merge to the common `main`, and complete the final smoke checks by April 7, 2026
|
| 94 |
+
**Official deadline:** April 8, 2026, 11:59 PM IST
|
| 95 |
+
|
| 96 |
+
### Must finish before merge
|
| 97 |
+
|
| 98 |
+
- review the final diff and stage only the intended submission files
|
| 99 |
+
- open the merge PR from a dedicated branch
|
| 100 |
+
- merge into the shared `main` after one last reviewer pass
|
| 101 |
+
- rerun the post-merge smoke checks:
|
| 102 |
+
- `pytest`
|
| 103 |
+
- `openenv validate`
|
| 104 |
+
- `/health`
|
| 105 |
+
- `/tasks`
|
| 106 |
+
- one `reset()` / `step()` sanity path
|
| 107 |
+
|
| 108 |
+
### Do not add before merge
|
| 109 |
+
|
| 110 |
+
- no new benchmark redesign work
|
| 111 |
+
- no new dataset expansion
|
| 112 |
+
- no schema churn
|
| 113 |
+
- no reward refactors beyond blocker-level fixes
|
| 114 |
+
- no last-minute inference prompt rewrites
|
| 115 |
+
|
| 116 |
+
### Success condition for April 7, 2026
|
| 117 |
+
|
| 118 |
+
- PR is up
|
| 119 |
+
- PR is reviewed against `gaps.md` and `analysis/deep_competitive_gap_report.md`
|
| 120 |
+
- shared `main` contains the tested gap-fix branch
|
| 121 |
+
- deployment sanity checks are green
|
| 122 |
+
- repo is frozen except for typo-level fixes
|
| 123 |
+
|
| 124 |
## Submission Gates That Must Still Hold
|
| 125 |
|
| 126 |
These come directly from `required.md` and `KNOWLEDGE.md`:
|
analysis/deep_competitive_gap_report.md
ADDED
|
@@ -0,0 +1,1374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deep Codebase Comparison: OpenEnv Reference Environments vs This Helpdesk Project
|
| 2 |
+
|
| 3 |
+
## Scope and Method
|
| 4 |
+
|
| 5 |
+
This report was written from a direct code read, not from README-driven interpretation. I treated the `OpenEnv/envs` directory as the reference baseline you pointed to, and I compared it against the implementation that lives in this repository root plus `server/`.
|
| 6 |
+
|
| 7 |
+
I focused on code that actually defines runtime behavior:
|
| 8 |
+
|
| 9 |
+
- `models.py`
|
| 10 |
+
- `inference.py`
|
| 11 |
+
- `client.py`
|
| 12 |
+
- `vocabulary.py`
|
| 13 |
+
- `server/environment.py`
|
| 14 |
+
- `server/tasks.py`
|
| 15 |
+
- `server/grader.py`
|
| 16 |
+
- `server/reward.py`
|
| 17 |
+
- `server/app.py`
|
| 18 |
+
- `tests/*.py`
|
| 19 |
+
- `data/dataset.json`
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
That reading set is enough to answer the question that matters: what design moves make the strongest reference environments hard to beat, where your project is currently thinner than it looks, and what concrete changes would make your environment competitive instead of merely correct.
|
| 23 |
+
|
| 24 |
+
## Executive Verdict
|
| 25 |
+
|
| 26 |
+
Your project is a clean, readable, deterministic mini-benchmark. It is not yet a high-ceiling agent benchmark.
|
| 27 |
+
|
| 28 |
+
That sounds harsh, but it is also the clearest way to unlock the right next move. Right now your environment behaves much more like a structured multi-label classification task wrapped in OpenEnv than like the richer reference environments that expose hidden state, tool use, long-horizon consequences, multi-step reasoning, or grounded interaction with external systems. The code is good enough as a starter environment. It is not yet strong enough to beat the best reference projects on depth, realism, or benchmark credibility.
|
| 29 |
+
|
| 30 |
+
The good news is that the codebase is small, coherent, and fixable. The bad news is that the gap is not a one-line polish gap. It is a benchmark design gap.
|
| 31 |
+
|
| 32 |
+
The strongest OpenEnv reference environments win for one or more of these reasons:
|
| 33 |
+
|
| 34 |
+
- they expose a real action surface, not just label prediction
|
| 35 |
+
- they make the agent inspect state rather than infer everything from one text blob
|
| 36 |
+
- they reward process, not only end labels
|
| 37 |
+
- they support long-horizon or multi-step behavior
|
| 38 |
+
- they are harder to brute-force with dataset-specific heuristics
|
| 39 |
+
- they are backed by real engines, shells, browsers, tools, or stateful simulators
|
| 40 |
+
- they treat evaluation as a first-class system, not as a tiny helper function
|
| 41 |
+
|
| 42 |
+
Your project currently loses on most of those axes.
|
| 43 |
+
|
| 44 |
+
At the same time, your project has an underrated advantage: the domain is practical, legible, and product-shaped. IT helpdesk routing is a great benchmark domain if you push it harder. It naturally supports ambiguity, policy lookup, account context, queue optimization, escalation rules, duplicates, follow-up chains, customer sentiment, service health, SLA clocks, and partial observability. In other words, the domain is better than the current implementation. The environment has room to grow into something much stronger without abandoning the idea.
|
| 45 |
+
|
| 46 |
+
So the answer is not “throw this away and copy BrowserGym.” The answer is “turn this from a label benchmark into a realistic triage operations environment.”
|
| 47 |
+
|
| 48 |
+
## What the Reference Environments Actually Do Better
|
| 49 |
+
|
| 50 |
+
### 1. They expose richer action spaces
|
| 51 |
+
|
| 52 |
+
The single biggest difference between your code and the strongest reference projects is that the agent in your environment does very little. In your environment, the step is basically “predict some labels for this ticket.” In the stronger reference environments, the agent interacts.
|
| 53 |
+
|
| 54 |
+
`BrowserGymEnvironment` accepts an `action_str` and pushes it into a live browser benchmark. That means the benchmark difficulty comes from action selection in stateful UI space, not just from text classification. `OpenAppEnvironment` similarly supports `click`, `fill`, `select_option`, `goto`, `scroll`, and `send_keys`, and even mixes BrowserGym-style element IDs with raw Playwright CSS selectors for pragmatic reliability. `GitTaskEnvironment` supports clone, list, and git command execution against a Gitea-backed workspace. `Tbench2Environment` supports `exec`, `write`, `view`, `wait`, `kill`, `write_file`, and `evaluate`, which is much closer to real agent work. `FinQAEnvironment` turns the task into tool use over tables, SQL, and answer submission. `REPLEnvironment` exposes code execution with optional recursive LLM calls. `TextArenaEnvironment` takes natural-language moves and advances a game engine.
|
| 55 |
+
|
| 56 |
+
Your environment exposes none of that. The agent does not gather missing evidence. It does not inspect a related ticket. It does not search a KB. It does not look up account tier. It does not check service health. It does not add an internal note. It does not choose between acknowledging first and escalating later. It does not defer. It does not ask for more information. It does not resolve duplicates. It does not manage a queue. It only emits one shot structured output.
|
| 57 |
+
|
| 58 |
+
That makes the benchmark much easier to game, much easier to overfit, and much less diagnostic of real agent competence.
|
| 59 |
+
|
| 60 |
+
### 2. They separate visible observation from hidden truth
|
| 61 |
+
|
| 62 |
+
The strongest reference environments keep some truth state behind the curtain. The agent sees an observation. The environment owns more. That separation is what makes an environment feel like an environment instead of a dataframe with reward labels.
|
| 63 |
+
|
| 64 |
+
In `ChessEnvironment`, the agent observes legal moves, FEN, checks, and result state, but the environment owns board progression, opponent strategy, and trajectory reward accumulation. In `MazeEnvironment`, the environment tracks maze status and legal movement dynamics. In `TextArenaEnvironment`, the wrapped engine owns turn state, raw logs, rewards, role mapping, and step info. In `FinQAEnvironment`, the agent sees the question and tools, but the hidden ground truth answer, question identity, and full structured table data live behind the environment. In `Tbench2Environment`, the hidden truth is in the task files and tests. In `BrowserGymEnvironment`, the browser session and benchmark internals are hidden behind the observation.
|
| 65 |
+
|
| 66 |
+
Your environment has much less hidden truth than it should. The ticket label is hidden, yes, but the benchmark structure is shallow. More importantly, the code already hints at richer hidden structure and then fails to expose or exploit it. `HelpdeskTicketRecord` includes `ambiguity_note` and `related_ticket_id`, but `_build_observation()` throws both away and only exposes `ticket_id`, `title`, `requester`, and `description`. So even though the dataset contains follow-up relationships and ambiguity annotations, the environment does not actually let the agent work with them as structured state. That is a missed opportunity and a design leak at the same time.
|
| 67 |
+
|
| 68 |
+
The dataset is telling you the domain wants threads, ambiguity, and context. The environment currently flattens it back into plain text.
|
| 69 |
+
|
| 70 |
+
### 3. They reward more than a final label match
|
| 71 |
+
|
| 72 |
+
The reference environments do not all have brilliant reward design, but the best ones take reward seriously.
|
| 73 |
+
|
| 74 |
+
`REPLEnvironment` combines an outcome rubric with optional process reward. It can reward successful execution, penalize failures, and separately judge the final answer. `ChessEnvironment` uses a trajectory rubric with exponential discounting to assign credit across a game. `FinQAEnvironment` does robust answer normalization, including boxed answers, percentages, fractions, and multi-value comparisons. `TextArenaEnvironment` overlays auxiliary reward signals such as Wordle greens, yellows, repetitions, and correctness. `Tbench2Environment` evaluates by actually running tests, which is a grounded form of outcome reward.
|
| 75 |
+
|
| 76 |
+
Your reward design is better than “exact match only,” but it is still thin. `grade_action()` uses one handcrafted issue similarity table, one handcrafted priority proximity table, and exact match for assignment group and resolution action. `compute_step_reward()` is just clamping. `compute_trajectory_reward()` averages scores and subtracts an overshoot penalty.
|
| 77 |
+
|
| 78 |
+
That sounds reasonable until you inspect the runtime path. In practice, the overshoot penalty is effectively dead logic. `step()` increments the ticket index once per ticket and sets done when the index reaches queue length. A later `step()` call raises an error. That means `steps_taken` cannot exceed `queue_size` during normal episode execution, so the overshoot branch in `compute_trajectory_reward()` has no meaningful role in the current environment. The code suggests the benchmark penalizes wasteful action loops, but the environment does not actually allow them.
|
| 79 |
+
|
| 80 |
+
The deeper issue is that the reward judges only final fields, not triage quality as a process. There is no penalty for unnecessary escalation unless the final field is wrong. There is no reward for correctly identifying a duplicate and linking it. There is no cost model for routing everything to security “just in case.” There is no SLA-aware penalty for under-prioritizing a time-sensitive issue that still happens to hit some partial-credit similarity. There is no queue-level reward. There is no explanation consistency. There is no tool efficiency score because there are no tools. There is no notion of customer harm, resolver cost, escalation burden, or backlog impact.
|
| 81 |
+
|
| 82 |
+
The strongest environments earn their credibility by making reward a modeling decision. Your reward is still a convenience function.
|
| 83 |
+
|
| 84 |
+
### 4. They support multi-step or long-horizon behavior
|
| 85 |
+
|
| 86 |
+
Even the simpler reference environments tend to have longer horizon than your three-task ladder suggests.
|
| 87 |
+
|
| 88 |
+
`ChessEnvironment` is naturally long horizon. `BrowserGymEnvironment` and `OpenAppEnvironment` are stepwise interactions. `TextArenaEnvironment` proceeds over turns. `Tbench2Environment` supports iterative shell work and explicit evaluation. `REPLEnvironment` supports repeated code execution over an evolving namespace. `FinQAEnvironment` allows repeated tool calls up to `max_steps` before submission. Even `ReasoningGymEnvironment`, which is single-step, supports parameterized dataset generation and configurable tasks.
|
| 89 |
+
|
| 90 |
+
Your environment has multiple steps inside an episode, but they are just a queue of independent tickets. Each step is still one-shot labeling. Tickets do not affect each other. The queue order does not matter. There is no resource constraint. There is no carry-over state except a score list and counters. No later ticket depends on an earlier action. No policy evolves over the episode. No investigation outcome from step one informs step two.
|
| 91 |
+
|
| 92 |
+
So while the environment is technically episodic, it is not operationally long horizon. It is batching.
|
| 93 |
+
|
| 94 |
+
That difference matters. The best agents and best benchmarks separate “can classify one item” from “can operate over a process.” Right now your environment mainly measures the first.
|
| 95 |
+
|
| 96 |
+
### 5. They parameterize tasks rather than freezing one tiny benchmark
|
| 97 |
+
|
| 98 |
+
`ReasoningGymEnvironment` rebuilds datasets from `dataset_name`, `dataset_config`, `dataset_specs`, `seed`, and `size`. `BrowserGymEnvironment` can choose a benchmark and task. `Tbench2Environment` can resolve tasks by task ID or path, even downloading a repo cache if needed. `GitTaskEnvironment` supports task-specific base repo states. `REPLEnvironment` can accept context, task prompt, expected answer, recursion depth, and model parameters at reset. `FinQAEnvironment` iterates over a question bank with real data-backed tools.
|
| 99 |
+
|
| 100 |
+
Your environment has three tasks, but they are not truly different environments. They are the same tickets with a different subset of fields exposed through `allowed_fields`. That is a very weak notion of task diversity. Task difficulty is not created by different data generating processes, different hidden state, different workflows, or different action surfaces. It is created by output dimensionality alone.
|
| 101 |
+
|
| 102 |
+
That means the easy, medium, and hard tasks are less like three tasks and more like one task with three scoring schemas.
|
| 103 |
+
|
| 104 |
+
### 6. They take concurrency and runtime isolation seriously
|
| 105 |
+
|
| 106 |
+
Several reference environments explicitly set `SUPPORTS_CONCURRENT_SESSIONS = True`, including `REPLEnvironment`, `Tbench2Environment`, `ReasoningGymEnvironment`, `MazeEnvironment`, and some others. The framework core in `http_server.py` is built around WebSocket sessions, session capacity, session info, session factories, and asynchronous handling. `MCPEnvironment` has explicit async and sync step paths because the framework authors ran into real event-loop and deadlock issues. `Tbench2DockerEnvironment` handles Docker-in-Docker by copying task directories into containers rather than assuming host bind mounts. `Calendar` builds database sessions per tenant. `GitTaskEnvironment` assumes isolated workspaces. `BrowserGymEnvironment` does cleanup of resources.
|
| 107 |
+
|
| 108 |
+
Your environment inherits some capability from OpenEnv, but your own code does not actually engage with that depth. The server is mostly a minimal `create_app()` call plus a `/tasks` endpoint. There is no custom metadata. No custom concurrency choices. No session isolation logic beyond what the base server gives you. No runtime cleanup concerns because the environment owns almost no external resources. That simplicity is pleasant, but it also means the project is not stress-tested as a real environment service.
|
| 109 |
+
|
| 110 |
+
### 7. They integrate grounded external systems or simulators
|
| 111 |
+
|
| 112 |
+
This is where the biggest credibility gap appears.
|
| 113 |
+
|
| 114 |
+
`FinQAEnvironment` grounds answers in company tables and SQL. `GitTaskEnvironment` grounds tasks in actual repositories. `Tbench2Environment` grounds them in actual shell execution and tests. `BrowserGymEnvironment` grounds tasks in web environments. `TextArenaEnvironment` grounds them in game engines. `ChessEnvironment` grounds them in a real board state. `Calendar` grounds them in a stateful API-backed application.
|
| 115 |
+
|
| 116 |
+
Your environment is grounded in a JSON dataset. That is fine for a prototype, but it is dramatically easier to shortcut. If the environment does not provide tools, latent objects, or stateful consequences, the fastest route to a good score is to learn the labeling policy over the text. That is exactly what your current `inference.py` is doing.
|
| 117 |
+
|
| 118 |
+
If you want to beat more ambitious projects, you need to force the agent to do more than map n-grams to labels.
|
| 119 |
+
|
| 120 |
+
## Deep Audit of Your Current Project
|
| 121 |
+
|
| 122 |
+
### Overall strengths before the critique
|
| 123 |
+
|
| 124 |
+
Before I get more surgical, it is worth naming what is already good:
|
| 125 |
+
|
| 126 |
+
- The codebase is small enough to understand quickly.
|
| 127 |
+
- The naming is clear and the domain is coherent.
|
| 128 |
+
- Pydantic validation is used correctly in the core models.
|
| 129 |
+
- The taxonomy in `vocabulary.py` is readable and operational.
|
| 130 |
+
- The environment is deterministic given a seed.
|
| 131 |
+
- The three-task ladder is a decent pedagogical introduction.
|
| 132 |
+
- The tests, while limited, are not absent.
|
| 133 |
+
- The dataset has at least some intentional ambiguity and follow-up cases.
|
| 134 |
+
|
| 135 |
+
So this is not a bad project. It is a project that has not yet converted a good domain into a hard benchmark.
|
| 136 |
+
|
| 137 |
+
### Domain model and task structure
|
| 138 |
+
|
| 139 |
+
`vocabulary.py` defines a clean label space:
|
| 140 |
+
|
| 141 |
+
- 9 issue types
|
| 142 |
+
- 4 priorities
|
| 143 |
+
- 6 assignment groups
|
| 144 |
+
- 5 resolution actions
|
| 145 |
+
- 3 task IDs
|
| 146 |
+
|
| 147 |
+
The mapping dictionaries immediately reveal one important structural weakness: assignment group is fully determined by issue type. Every issue type maps to exactly one assignment group. That means the “assignment_group” prediction in task 3 is not an independent reasoning problem. Once the model gets issue type right, assignment group is a lookup. That collapses the apparent complexity of the hardest task.
|
| 148 |
+
|
| 149 |
+
The same problem exists, though less absolutely, for resolution action. `ISSUE_TYPE_TO_RESOLUTION_ACTION` already maps every issue type to a default resolution action. The dataset confirms that several issue types only ever use one resolution action:
|
| 150 |
+
|
| 151 |
+
- `feature_request -> acknowledge`
|
| 152 |
+
- `general_inquiry -> acknowledge`
|
| 153 |
+
- `onboarding -> fulfill`
|
| 154 |
+
- `service_request -> assign`
|
| 155 |
+
- `spam_phishing -> ignore`
|
| 156 |
+
|
| 157 |
+
Only a subset of issue types vary their resolution action in practice. So task 3 looks like a four-field prediction problem, but much of it is structurally reducible to issue type plus a few keyword exceptions. That is not how hard triage environments should work if the goal is to test agentic reasoning.
|
| 158 |
+
|
| 159 |
+
`server/tasks.py` compounds this by defining difficulty purely as output field count:
|
| 160 |
+
|
| 161 |
+
- Task 1: issue type only
|
| 162 |
+
- Task 2: issue type plus priority
|
| 163 |
+
- Task 3: full routing
|
| 164 |
+
|
| 165 |
+
The ticket pool is the same across tasks. There is no task-specific curation, no task-family-specific observation, no different process constraints, and no different control surface. The only thing that changes is what the grader will read from the submitted action.
|
| 166 |
+
|
| 167 |
+
That means your easy-medium-hard ladder is mostly a scoring ladder, not an environment ladder.
|
| 168 |
+
|
| 169 |
+
### Observation and state design
|
| 170 |
+
|
| 171 |
+
`HelpdeskTicketObservation` contains:
|
| 172 |
+
|
| 173 |
+
- task metadata
|
| 174 |
+
- `allowed_fields`
|
| 175 |
+
- `current_ticket`
|
| 176 |
+
- queue counts
|
| 177 |
+
- history
|
| 178 |
+
|
| 179 |
+
`current_ticket` exposes only:
|
| 180 |
+
|
| 181 |
+
- `ticket_id`
|
| 182 |
+
- `title`
|
| 183 |
+
- `requester`
|
| 184 |
+
- `description`
|
| 185 |
+
|
| 186 |
+
This is too little for a benchmark that wants to simulate real helpdesk operations, and it is oddly little given what your data already stores. `HelpdeskTicketRecord` also includes:
|
| 187 |
+
|
| 188 |
+
- `ambiguity_note`
|
| 189 |
+
- `related_ticket_id`
|
| 190 |
+
|
| 191 |
+
Those two fields are exactly the sort of structured hints that could turn this from flat classification into contextual triage. Yet `_build_observation()` discards them. That means the dataset contains richer structure than the observation contract.
|
| 192 |
+
|
| 193 |
+
The state is also minimal:
|
| 194 |
+
|
| 195 |
+
- `current_task_id`
|
| 196 |
+
- `seed`
|
| 197 |
+
- `queue_ticket_ids`
|
| 198 |
+
- `current_ticket_index`
|
| 199 |
+
- `per_ticket_scores`
|
| 200 |
+
- `total_reward`
|
| 201 |
+
|
| 202 |
+
This is enough for bookkeeping, but not enough for operational simulation. There is no notion of:
|
| 203 |
+
|
| 204 |
+
- queue ordering rationale
|
| 205 |
+
- account status
|
| 206 |
+
- customer tier
|
| 207 |
+
- outage context
|
| 208 |
+
- prior communication attempts
|
| 209 |
+
- internal notes
|
| 210 |
+
- pending escalations
|
| 211 |
+
- workload or resolver capacity
|
| 212 |
+
- elapsed time or SLA timers
|
| 213 |
+
- deduplication chains
|
| 214 |
+
- partial investigation state
|
| 215 |
+
|
| 216 |
+
The result is that the environment never becomes more informative or more demanding as the episode progresses. The state is a score ledger, not a world model.
|
| 217 |
+
|
| 218 |
+
Compare that with the stronger references:
|
| 219 |
+
|
| 220 |
+
- `BrowserGymState` tracks benchmark, task, URL, goal, max steps, cumulative reward.
|
| 221 |
+
- `REPLState` tracks context, prompt, iteration, namespace keys, final answer, total execution time.
|
| 222 |
+
- `Tbench2State` tracks task, session, command history, terminal readiness, last output.
|
| 223 |
+
- `TextArenaState` tracks turn, raw state, last reward, last info, environment identity.
|
| 224 |
+
- `FinQAState` tracks current question, company, ground truth, question ID.
|
| 225 |
+
|
| 226 |
+
Those states are not just counters. They represent the environment’s evolving operational memory. Yours mostly does not.
|
| 227 |
+
|
| 228 |
+
### Environment lifecycle
|
| 229 |
+
|
| 230 |
+
`HelpdeskTicketRoutingEnvironment.reset()` is straightforward:
|
| 231 |
+
|
| 232 |
+
- coerce `seed`
|
| 233 |
+
- get task definition
|
| 234 |
+
- seed RNG
|
| 235 |
+
- sample a queue size from 3 to 5
|
| 236 |
+
- sample that many tickets from the fixed dataset
|
| 237 |
+
- initialize state
|
| 238 |
+
- return the first observation
|
| 239 |
+
|
| 240 |
+
`step()`:
|
| 241 |
+
|
| 242 |
+
- validates reset happened
|
| 243 |
+
- grades action against current ticket
|
| 244 |
+
- computes reward
|
| 245 |
+
- advances to next ticket
|
| 246 |
+
- if done, computes trajectory reward
|
| 247 |
+
- otherwise returns immediate step reward
|
| 248 |
+
|
| 249 |
+
This is tidy. It is also shallow.
|
| 250 |
+
|
| 251 |
+
There is no environment mutation other than index movement. No internal state changes based on the chosen action. No branching. No action-dependent future ticket behavior. No queue reprioritization. No retries. No note writing. No escalation backlog. No “wrong earlier action causes downstream penalty.” The only environment response is score feedback.
|
| 252 |
+
|
| 253 |
+
A benchmark like this can still be useful, but it sits much closer to supervised evaluation than to agentic interaction. That becomes a competitive problem when the reference set includes environments where actions actually transform the world.
|
| 254 |
+
|
| 255 |
+
One subtle but important weakness is that `step()` does not enforce the task contract tightly. `HelpdeskTicketAction` allows all four fields to be present on any task, and `grade_action()` simply reads the fields relevant to the chosen `task_id`. Extra fields are ignored. That means the environment tells the agent “allowed_fields are X,” but it does not enforce “only X may be submitted.” It is not catastrophic, but it reflects a looser benchmark contract than the environment surface suggests.
|
| 256 |
+
|
| 257 |
+
### Grader and reward design
|
| 258 |
+
|
| 259 |
+
`server/grader.py` is the most benchmark-defining file in the project, and it currently underdelivers relative to its importance.
|
| 260 |
+
|
| 261 |
+
What is good:
|
| 262 |
+
|
| 263 |
+
- it has partial credit for issue-type confusions
|
| 264 |
+
- it has proximity-based scoring for priority
|
| 265 |
+
- task weights sum to 1
|
| 266 |
+
- it is deterministic
|
| 267 |
+
- it is easy to reason about
|
| 268 |
+
|
| 269 |
+
What is weak:
|
| 270 |
+
|
| 271 |
+
- the similarity tables are static, narrow, and handcrafted
|
| 272 |
+
- assignment group and resolution action are exact-match only even though the environment does not expose enough context to make some distinctions fully grounded
|
| 273 |
+
- there is no calibration check on over-escalation
|
| 274 |
+
- there is no queue-level objective
|
| 275 |
+
- there is no policy compliance signal
|
| 276 |
+
- there is no explanation consistency
|
| 277 |
+
- there is no distinction between “reasonable but conservative” and “reckless but lucky”
|
| 278 |
+
|
| 279 |
+
The biggest conceptual weakness is that the reward is local and label-centric. A strong helpdesk environment should care about operational behavior, not just answer key overlap.
|
| 280 |
+
|
| 281 |
+
For example, suppose two actions both get the final resolution action wrong:
|
| 282 |
+
|
| 283 |
+
- one escalates a low-risk general inquiry to security
|
| 284 |
+
- one acknowledges a critical account lockout without escalation
|
| 285 |
+
|
| 286 |
+
Today those mistakes mostly show up as missed fields in a flat weighted sum. But in real operations they are qualitatively different failures. One wastes specialist capacity. The other is a dangerous underreaction. A competitive benchmark should encode that asymmetry.
|
| 287 |
+
|
| 288 |
+
There is also a concrete implementation weakness in `compute_trajectory_reward()`. It computes:
|
| 289 |
+
|
| 290 |
+
- average per-ticket score
|
| 291 |
+
- minus `0.03 * overshoot`
|
| 292 |
+
|
| 293 |
+
But `overshoot = max(0, steps_taken - queue_size)`, and the environment ends the episode when the current ticket index reaches queue length. After that point, further stepping raises an error. So in the normal execution path, overshoot is effectively always zero. The code suggests the environment cares about extra wasted steps, but the environment does not actually permit them. That means part of the trajectory logic is decorative rather than active.
|
| 294 |
+
|
| 295 |
+
In strong benchmarks, reward code usually reveals the benchmark’s philosophy. In your project, the reward code mostly reveals the current label schema.
|
| 296 |
+
|
| 297 |
+
### Dataset design
|
| 298 |
+
|
| 299 |
+
`data/dataset.json` currently holds 45 tickets. The class distribution is not terrible for a prototype, but it is still small:
|
| 300 |
+
|
| 301 |
+
- `application_support`: 9
|
| 302 |
+
- `billing_license`: 7
|
| 303 |
+
- `service_request`: 6
|
| 304 |
+
- `security_compliance`: 5
|
| 305 |
+
- `spam_phishing`: 5
|
| 306 |
+
- `identity_access`: 4
|
| 307 |
+
- `onboarding`: 4
|
| 308 |
+
- `general_inquiry`: 3
|
| 309 |
+
- `feature_request`: 2
|
| 310 |
+
|
| 311 |
+
That is a tiny dataset for any benchmark that hopes to resist memorization or heuristic overfitting. The especially small classes are a concern. A benchmark with 2 feature requests and 3 general inquiries is not meaningfully testing generalization in those categories.
|
| 312 |
+
|
| 313 |
+
The priority distribution is also limited:
|
| 314 |
+
|
| 315 |
+
- critical: 9
|
| 316 |
+
- high: 15
|
| 317 |
+
- medium: 12
|
| 318 |
+
- low: 9
|
| 319 |
+
|
| 320 |
+
That is balanced enough to be usable, but not rich enough to encode the true structure of priority assignment. There is no obvious representation of customer segment, contractual urgency, outage blast radius, legal exposure, dependency graphs, or business calendar sensitivity. Priority is largely being inferred from words in the title and description, which is exactly what a heuristic baseline will exploit.
|
| 321 |
+
|
| 322 |
+
The dataset does have four ambiguous records and three follow-up linked records. That is good. But because the environment does not structurally expose `ambiguity_note` or `related_ticket_id`, those richer cases do not actually become richer environment mechanics. They mostly remain hints for the benchmark designer, not tools for the agent.
|
| 323 |
+
|
| 324 |
+
The follow-up handling is especially underused. Tickets like `ticket-038` and `ticket-045` clearly encode longitudinal customer frustration and repeated failure, which should change triage behavior. But the environment treats them like standalone text blobs. There is no action to inspect previous tickets. No thread retrieval. No stateful consequence from unresolved history. The environment has the seed of longitudinal realism and then does not build on it.
|
| 325 |
+
|
| 326 |
+
There is also no train/eval split, no hidden split, no procedural generation, no adversarial generation, and no OOD slice. The same fixed dataset defines the universe. That is fine for unit tests. It is weak for a benchmark intended to compete.
|
| 327 |
+
|
| 328 |
+
### Inference baseline and benchmark leakage
|
| 329 |
+
|
| 330 |
+
`inference.py` is more important than it may look, because it tells you how easy the benchmark is to shortcut.
|
| 331 |
+
|
| 332 |
+
The heuristic path:
|
| 333 |
+
|
| 334 |
+
- scans ticket text for fixed issue-type keywords in fixed order
|
| 335 |
+
- assigns priority from small keyword buckets
|
| 336 |
+
- assigns resolution action from issue type plus a few escalation and fulfillment keywords
|
| 337 |
+
- assigns assignment group from issue type mapping
|
| 338 |
+
|
| 339 |
+
That baseline is not merely a harmless example. It is a diagnostic of benchmark leakage. The easier it is to hand-author a ruleset that tracks your label policy, the less benchmark headroom you have.
|
| 340 |
+
|
| 341 |
+
And in this codebase, the baseline is not just simple. It is tightly coupled to the environment’s ontology:
|
| 342 |
+
|
| 343 |
+
- it uses the exact taxonomy constants
|
| 344 |
+
- it exploits the one-to-one issue-to-assignment mapping
|
| 345 |
+
- it exploits mostly deterministic issue-to-resolution defaults
|
| 346 |
+
- it assumes priority is keyword-addressable from the visible text alone
|
| 347 |
+
|
| 348 |
+
That means the benchmark currently invites ontology-driven shortcutting.
|
| 349 |
+
|
| 350 |
+
There is an even more concerning signal. The tests describe a heuristic baseline around `0.9400`, but a local code-faithful replay of the rule ordering in PowerShell over the full `data/dataset.json` gives a much weaker picture:
|
| 351 |
+
|
| 352 |
+
- issue type exact accuracy: about `0.7333`
|
| 353 |
+
- priority exact accuracy: about `0.3778`
|
| 354 |
+
- assignment exact accuracy: about `0.7333`
|
| 355 |
+
- resolution exact accuracy: about `0.6889`
|
| 356 |
+
- full task-3 exact match: about `0.2444`
|
| 357 |
+
- approximate weighted average score across tasks 1, 2, and 3: about `0.7344`
|
| 358 |
+
|
| 359 |
+
The exact number is less important than what it implies: the benchmark narrative about heuristic strength and the actual rule behavior appear out of sync. That can happen for several reasons:
|
| 360 |
+
|
| 361 |
+
- the tests are stale relative to current data
|
| 362 |
+
- the claimed baseline was measured on sampled queues rather than the whole dataset
|
| 363 |
+
- the heuristic ordering now creates more collisions than expected
|
| 364 |
+
- the benchmark evolved without a full-baseline recomputation
|
| 365 |
+
|
| 366 |
+
Whatever the cause, it is a warning sign. When benchmark claims and benchmark code diverge, trust in the environment falls.
|
| 367 |
+
|
| 368 |
+
### Test strategy
|
| 369 |
+
|
| 370 |
+
Your project has six test files. That is good relative to many small hackathon projects. But the content of the tests matters more than the count.
|
| 371 |
+
|
| 372 |
+
The most important limitation is that multiple tests stub the OpenEnv types, interfaces, or `create_app()` implementation rather than exercising the real installed framework. `tests/openenv_test_stubs.py` injects fake `openenv.core.env_server.types`. `tests/test_environment_smoke.py` and `tests/test_api_integration.py` patch in a fake `Environment` base class. `tests/test_api_integration.py` also installs a stub `create_app` that returns a small FastAPI app with simplified routes.
|
| 373 |
+
|
| 374 |
+
That means much of the test suite verifies your code against a locally simulated OpenEnv contract, not against the actual `openenv-core` dependency declared in `pyproject.toml`.
|
| 375 |
+
|
| 376 |
+
This is a big competitive weakness because the reference repository’s core is full of behavior that your test harness never touches:
|
| 377 |
+
|
| 378 |
+
- WebSocket `/ws` interactions
|
| 379 |
+
- session handling
|
| 380 |
+
- concurrency settings
|
| 381 |
+
- serialization edge cases
|
| 382 |
+
- metadata and schema endpoints
|
| 383 |
+
- MCP endpoints
|
| 384 |
+
- async step paths
|
| 385 |
+
- actual `EnvClient` protocol semantics
|
| 386 |
+
|
| 387 |
+
Your tests mostly prove that the environment behaves under your own simplified assumptions. That is useful, but it is not the same as proving robust OpenEnv integration.
|
| 388 |
+
|
| 389 |
+
The other limitation is that the tests are mostly shallow-contract tests:
|
| 390 |
+
|
| 391 |
+
- reset returns something valid
|
| 392 |
+
- step increments counts
|
| 393 |
+
- reward is in `[0, 1]`
|
| 394 |
+
- task IDs are present
|
| 395 |
+
- heuristic episodes do not error
|
| 396 |
+
|
| 397 |
+
Those are necessary. They are not sufficient for a competitive benchmark.
|
| 398 |
+
|
| 399 |
+
What is missing includes:
|
| 400 |
+
|
| 401 |
+
- real WebSocket end-to-end tests
|
| 402 |
+
- invalid action contract tests with actual framework validation
|
| 403 |
+
- tests for extra fields on restricted tasks
|
| 404 |
+
- concurrency tests
|
| 405 |
+
- seed reproducibility tests across actual server sessions
|
| 406 |
+
- golden regression tests on full-dataset benchmark score
|
| 407 |
+
- hidden/eval split integrity tests
|
| 408 |
+
- tests for ambiguity and follow-up handling
|
| 409 |
+
- tests that verify the environment is hard in the intended way, not just runnable
|
| 410 |
+
|
| 411 |
+
In short, the current test suite validates operability, not benchmark integrity.
|
| 412 |
+
|
| 413 |
+
## Critical Gaps That Matter Most
|
| 414 |
+
|
| 415 |
+
This section is the most actionable part of the report. If the goal is to beat stronger reference projects, these are the gaps that matter.
|
| 416 |
+
|
| 417 |
+
### Gap 1: The project is benchmarked as an environment, but designed as a classifier
|
| 418 |
+
|
| 419 |
+
The core problem is conceptual. Your code uses the OpenEnv interface, but the actual task shape is still mostly multi-label classification over short ticket text.
|
| 420 |
+
|
| 421 |
+
The better reference environments are hard because the agent has to interact:
|
| 422 |
+
|
| 423 |
+
- `BrowserGymEnvironment` asks the agent to act in a browser.
|
| 424 |
+
- `FinQAEnvironment` asks the agent to inspect tools and query structured data.
|
| 425 |
+
- `REPLEnvironment` asks the agent to iteratively execute code and decide when to finalize.
|
| 426 |
+
- `Tbench2Environment` asks the agent to manipulate a terminal workspace and then survive evaluation.
|
| 427 |
+
- `TextArenaEnvironment` asks the agent to play through game turns.
|
| 428 |
+
|
| 429 |
+
Your environment asks the agent to emit labels. Even when multiple tickets appear in a queue, the agent is still doing the same one-shot operation repeatedly. It is not exploring, not investigating, not mutating meaningful state, not managing resources, and not making action-sequence tradeoffs.
|
| 430 |
+
|
| 431 |
+
That difference is bigger than it looks. Once the benchmark is classifier-shaped, the fastest route to good performance is classifier-shaped too. The environment does not force the agent to behave like an operator. It only asks it to sound like one.
|
| 432 |
+
|
| 433 |
+
That is why the next leap must be architectural, not cosmetic.
|
| 434 |
+
|
| 435 |
+
### Gap 2: The hardest task is structurally easier than it claims
|
| 436 |
+
|
| 437 |
+
Task 3 appears to be a four-field routing task, but the ontology collapses much of the difficulty.
|
| 438 |
+
|
| 439 |
+
`ISSUE_TYPE_TO_ASSIGNMENT_GROUP` is one-to-one. If the agent gets issue type right, assignment group is already implied. That means one quarter of the task-3 score is mostly a lookup rather than a separate judgment call.
|
| 440 |
+
|
| 441 |
+
Resolution action is not fully deterministic, but it is still heavily compressed by issue type defaults. Several issue types have only one action in practice across the dataset. Others vary under small numbers of recognizable phrases such as legal threat, follow-up pressure, or explicit request wording.
|
| 442 |
+
|
| 443 |
+
So the “hard” task is closer to:
|
| 444 |
+
|
| 445 |
+
- infer issue type
|
| 446 |
+
- infer urgency from a few cues
|
| 447 |
+
- apply one deterministic mapping
|
| 448 |
+
- apply one mostly deterministic mapping with a few exceptions
|
| 449 |
+
|
| 450 |
+
That is not trivial, but it is much less rich than real service-desk routing. Real hard cases exist when the same visible ticket text can map to different actions depending on hidden context such as account tier, live incident status, prior history, or internal policy. Your environment does not currently model those cases.
|
| 451 |
+
|
| 452 |
+
### Gap 3: The environment underuses the best parts of its own data
|
| 453 |
+
|
| 454 |
+
Your dataset is more interesting than your observation contract.
|
| 455 |
+
|
| 456 |
+
`HelpdeskTicketRecord` contains `ambiguity_note` and `related_ticket_id`. Those are exactly the kinds of fields that could turn this into a stronger environment:
|
| 457 |
+
|
| 458 |
+
- ambiguity makes decisions less keyword-deterministic
|
| 459 |
+
- related ticket IDs create thread continuity
|
| 460 |
+
- follow-ups create escalation pressure and temporal realism
|
| 461 |
+
|
| 462 |
+
But `_build_observation()` discards them and only exposes the basic ticket text fields.
|
| 463 |
+
|
| 464 |
+
That has two consequences:
|
| 465 |
+
|
| 466 |
+
First, the richer authored structure is lost to the agent. Second, the benchmark stops short of the very complexity the dataset author was already beginning to encode.
|
| 467 |
+
|
| 468 |
+
This is one of the clearest signs that the current project is a first version. The seeds of a deeper environment are already present in the data model. The runtime contract just does not use them.
|
| 469 |
+
|
| 470 |
+
### Gap 4: There is no investigation loop
|
| 471 |
+
|
| 472 |
+
In real helpdesk operations, the visible complaint is rarely the whole decision problem.
|
| 473 |
+
|
| 474 |
+
An operator often needs to know:
|
| 475 |
+
|
| 476 |
+
- whether the requester is on an enterprise contract
|
| 477 |
+
- whether the problem aligns with an active outage
|
| 478 |
+
- whether the user is an admin
|
| 479 |
+
- whether prior tickets already established a root cause
|
| 480 |
+
- whether a security signal exists on the account
|
| 481 |
+
- whether a compliance deadline is legally binding
|
| 482 |
+
- whether the request is actually a duplicate
|
| 483 |
+
|
| 484 |
+
Your environment has no tool loop for this. The agent sees a title, requester, and description, then is expected to decide everything directly.
|
| 485 |
+
|
| 486 |
+
That makes the environment much easier to brute-force and much less realistic than the domains represented by the best reference projects. `FinQAEnvironment` does not ask the model to guess answers from wording alone; it gives tools. `GitTaskEnvironment` gives a repo. `Tbench2Environment` gives a terminal. `BrowserGymEnvironment` gives a browser. Your helpdesk environment gives a paragraph.
|
| 487 |
+
|
| 488 |
+
The fastest path to a stronger benchmark is to add internal tools and make the hardest scenarios impossible to solve reliably without using them.
|
| 489 |
+
|
| 490 |
+
### Gap 5: There is almost no internal economics
|
| 491 |
+
|
| 492 |
+
A good environment usually has some notion of tradeoff or cost even if it is not expressed as money.
|
| 493 |
+
|
| 494 |
+
In your environment:
|
| 495 |
+
|
| 496 |
+
- there is no time budget
|
| 497 |
+
- there is no backlog pressure
|
| 498 |
+
- there is no penalty for over-escalating except field mismatch
|
| 499 |
+
- there is no cost for routing everything to the safest specialist
|
| 500 |
+
- there is no consequence for queue ordering
|
| 501 |
+
- there is no tension between fast response and careful investigation
|
| 502 |
+
|
| 503 |
+
The queue exists, but it is not an economy. It is just a list.
|
| 504 |
+
|
| 505 |
+
That means the environment cannot really test operational judgment. It can only test whether the final labels match the benchmark designer’s answer key. Stronger environments force decisions under constraints. Your current implementation mostly scores unconstrained annotation.
|
| 506 |
+
|
| 507 |
+
### Gap 6: The reward story is thinner than the benchmark story
|
| 508 |
+
|
| 509 |
+
`grade_action()` is neat and deterministic, but it still mainly scores label overlap. It does not score operator quality.
|
| 510 |
+
|
| 511 |
+
There is no difference between:
|
| 512 |
+
|
| 513 |
+
- a cautious but slightly conservative routing choice
|
| 514 |
+
- a reckless underreaction that happens to get some partial credit
|
| 515 |
+
- an unnecessary escalation that wastes the security team
|
| 516 |
+
- a smart intermediate step that gathers evidence before final routing
|
| 517 |
+
|
| 518 |
+
Those distinctions do not exist because the action surface does not allow them and the reward design does not look for them.
|
| 519 |
+
|
| 520 |
+
There is also a direct implementation issue: `compute_trajectory_reward()` includes an overshoot penalty, but because the environment ends when the queue is exhausted and refuses later steps, overshoot does not really happen in the normal path. So part of the trajectory logic looks more meaningful than it actually is.
|
| 521 |
+
|
| 522 |
+
When reward code contains dead or decorative logic, trust in the benchmark drops.
|
| 523 |
+
|
| 524 |
+
### Gap 7: The current benchmark is highly vulnerable to ontology memorization
|
| 525 |
+
|
| 526 |
+
The more the task can be solved by memorizing your ontology and keyword policy, the lower the ceiling of the benchmark.
|
| 527 |
+
|
| 528 |
+
Right now the environment is vulnerable because:
|
| 529 |
+
|
| 530 |
+
- the dataset is small
|
| 531 |
+
- the label space is public and fixed
|
| 532 |
+
- some output fields are deterministic functions of others
|
| 533 |
+
- the observation is a short text blob
|
| 534 |
+
- the heuristic baseline directly encodes the ontology
|
| 535 |
+
- there is no hidden split or generator-based variation
|
| 536 |
+
|
| 537 |
+
The current inference script is a warning sign here. It is not just a demo baseline. It is evidence that a carefully chosen keyword system can cover a large fraction of the problem structure because the problem structure is currently that compressible.
|
| 538 |
+
|
| 539 |
+
If you want to build something harder to game, the benchmark must stop being reducible to a keyword policy plus a few ontology tables.
|
| 540 |
+
|
| 541 |
+
### Gap 8: The tests are too synthetic for the actual risk profile
|
| 542 |
+
|
| 543 |
+
The test suite checks that the environment is runnable. It does not yet prove that the benchmark is trustworthy.
|
| 544 |
+
|
| 545 |
+
The biggest limitation is the heavy use of stubs around the OpenEnv dependency boundary. Several tests replace the real OpenEnv types, interfaces, or `create_app()` implementation. That helps local testability, but it means the suite is not validating actual WebSocket session behavior, actual framework serialization, actual schema generation, or actual concurrency handling.
|
| 546 |
+
|
| 547 |
+
That is a serious gap if the environment is meant to compete with stronger projects. Reference environments are embedded in a framework that supports:
|
| 548 |
+
|
| 549 |
+
- WebSocket sessions
|
| 550 |
+
- session capacity and session info
|
| 551 |
+
- schema endpoints
|
| 552 |
+
- metadata endpoints
|
| 553 |
+
- MCP endpoints
|
| 554 |
+
- sync and async execution paths
|
| 555 |
+
|
| 556 |
+
Your current tests mostly validate business logic under a simplified local harness. That is still useful. It is just not enough to prove benchmark robustness.
|
| 557 |
+
|
| 558 |
+
There is also no strong integrity suite around the benchmark itself. Missing pieces include:
|
| 559 |
+
|
| 560 |
+
- full-dataset regression scoring
|
| 561 |
+
- hidden split integrity
|
| 562 |
+
- adversarial edge-case suites
|
| 563 |
+
- benchmark versioning checks
|
| 564 |
+
- ambiguity and follow-up behavior tests
|
| 565 |
+
- contract tests that verify the hard task is genuinely hard in the intended way
|
| 566 |
+
|
| 567 |
+
If you want the project to be taken seriously, the environment and the benchmark need separate test surfaces.
|
| 568 |
+
|
| 569 |
+
### Gap 9: The benchmark narrative and executable reality are drifting apart
|
| 570 |
+
|
| 571 |
+
A benchmark becomes fragile when people cannot tell which number to trust.
|
| 572 |
+
|
| 573 |
+
Your tests imply a strong heuristic baseline. The environment code and local replay of the actual heuristic rules over the dataset suggest a weaker story. That discrepancy may be caused by stale thresholds, changed data, queue sampling effects, or unrefreshed benchmark assumptions. Whatever the reason, it is not a small issue.
|
| 574 |
+
|
| 575 |
+
Strong benchmarks need executable answers to simple questions:
|
| 576 |
+
|
| 577 |
+
- what is the official baseline?
|
| 578 |
+
- how is it measured?
|
| 579 |
+
- on which split?
|
| 580 |
+
- with what seeds?
|
| 581 |
+
- on which version of the data?
|
| 582 |
+
- under which scenario families?
|
| 583 |
+
|
| 584 |
+
Right now those answers are not fully stabilized in code. The result is that the benchmark is harder to trust than it should be.
|
| 585 |
+
|
| 586 |
+
That may sound administrative, but it is actually competitive. A benchmark that feels ad hoc will lose to a benchmark that feels governed, even if both are interesting.
|
| 587 |
+
|
| 588 |
+
### Gap 10: The project does not yet have a competitive moat
|
| 589 |
+
|
| 590 |
+
The strongest environments in the reference set each have a clear identity:
|
| 591 |
+
|
| 592 |
+
- BrowserGym: browser-native multimodal interaction
|
| 593 |
+
- FinQA: tool-mediated reasoning over structured finance data
|
| 594 |
+
- REPL: iterative code execution and rubric-based finalization
|
| 595 |
+
- TBench2: terminal tasks grounded by executable evaluation
|
| 596 |
+
- Calendar: stateful tool ecosystem over application APIs
|
| 597 |
+
- Chess: adversarial long-horizon board play
|
| 598 |
+
|
| 599 |
+
Your current identity is “helpdesk routing from short ticket text.” That is useful, but not yet distinctive enough to dominate.
|
| 600 |
+
|
| 601 |
+
The domain itself can support a much stronger identity:
|
| 602 |
+
|
| 603 |
+
- service desk triage under partial observability
|
| 604 |
+
- enterprise support operations with tool use and policy constraints
|
| 605 |
+
- multi-ticket queue management under SLA and escalation economics
|
| 606 |
+
|
| 607 |
+
That is the moat you should build. The domain is good enough. The current benchmark shape is not yet deep enough to own it.
|
| 608 |
+
|
| 609 |
+
## What Specific Reference Environments Teach You
|
| 610 |
+
|
| 611 |
+
### BrowserGym: rich observations create real decision space
|
| 612 |
+
|
| 613 |
+
`BrowserGymObservation` includes text, URL, optional screenshot, goal, accessibility tree text, pruned HTML, error strings, and action-error flags. `BrowserGymEnvironment` carefully converts raw benchmark objects into those modalities and preserves additional metadata while filtering large raw fields.
|
| 614 |
+
|
| 615 |
+
The lesson is not “copy browser features.” The lesson is that an observation should support several reasoning strategies at once. Strong environments do not force everything through one narrow channel if the domain can naturally expose more.
|
| 616 |
+
|
| 617 |
+
Your helpdesk environment should likely move from a plain ticket view to a mixed observation view that includes structured context, queue state, optional note previews, and pointers to retrievable evidence. A stronger observation contract makes the environment harder to solve with surface heuristics and easier to use for real agent development.
|
| 618 |
+
|
| 619 |
+
### FinQA: tool use transforms a QA task into an environment
|
| 620 |
+
|
| 621 |
+
`FinQAEnvironment` is one of the most relevant reference environments for your redesign. It takes a question-answering domain that could have been implemented as “read prompt, output answer” and instead builds a tool-mediated workflow:
|
| 622 |
+
|
| 623 |
+
- list tools
|
| 624 |
+
- inspect table descriptions
|
| 625 |
+
- inspect table metadata
|
| 626 |
+
- run SQL queries
|
| 627 |
+
- submit final answer
|
| 628 |
+
|
| 629 |
+
The ground truth is hidden. The agent has to do work. The reward system then normalizes answer formats so the benchmark is measuring reasoning rather than answer string quirks.
|
| 630 |
+
|
| 631 |
+
Your helpdesk project should follow that pattern. The hard task should not be “read ticket and guess routing.” It should be “use service desk tools to investigate and then submit routing.” That would immediately raise the benchmark ceiling.
|
| 632 |
+
|
| 633 |
+
### REPL: process reward and outcome reward should be separate
|
| 634 |
+
|
| 635 |
+
`REPLEnvironment` is instructive because it distinguishes execution quality from final answer quality. The environment tracks iterations, namespace state, execution results, and finalization patterns. The rubric layer then separates outcome reward from process reward.
|
| 636 |
+
|
| 637 |
+
That is directly applicable to helpdesk operations. A strong service desk environment should separately measure:
|
| 638 |
+
|
| 639 |
+
- whether the final routing/action was correct
|
| 640 |
+
- whether the agent investigated responsibly
|
| 641 |
+
- whether the agent made avoidable operational mistakes
|
| 642 |
+
- whether the agent wasted steps or overused escalation
|
| 643 |
+
|
| 644 |
+
Without that split, you cannot tell the difference between good operations and lucky guessing.
|
| 645 |
+
|
| 646 |
+
### TBench2: grounded evaluation is a moat
|
| 647 |
+
|
| 648 |
+
`Tbench2Environment` is powerful because success is not a declared label. It is an executable check. The agent can manipulate a workspace and then call `evaluate`, which runs tests. That style of evaluation is very hard to fake and very easy to defend.
|
| 649 |
+
|
| 650 |
+
Helpdesk will not use pytest in the same way, but the principle transfers cleanly. A stronger helpdesk benchmark should evaluate against hidden operational truth and downstream effects, not just a visible label table. If the environment can compute whether the chosen action violated SLA policy, ignored an active incident, or misrouted a duplicate chain, then benchmark credibility goes up immediately.
|
| 651 |
+
|
| 652 |
+
### Calendar MCP: tool ecosystems can scale if the boundary is clean
|
| 653 |
+
|
| 654 |
+
The Calendar stack shows how a domain can become more realistic without exploding the action schema. The environment exposes tools, request context, user context, and database-backed state. Tool handlers are generic where possible and dynamic routing does a lot of the heavy lifting.
|
| 655 |
+
|
| 656 |
+
For your domain, that is a strong hint that helpdesk should probably become tool-centric. Instead of stuffing everything into one giant action object, expose a small set of operational tools. This will scale better, feel more realistic, and let you design harder scenarios without turning the action model into a kitchen sink.
|
| 657 |
+
|
| 658 |
+
### GitTask: reproducible scenario resets matter
|
| 659 |
+
|
| 660 |
+
`GitTaskEnvironment` is not the most feature-rich environment in the set, but it gets one important thing right: reproducible task state. Reset means something concrete. The environment can put you back into a known repo state efficiently.
|
| 661 |
+
|
| 662 |
+
You need the same discipline in scenario design. Instead of sampling any 3 to 5 tickets from one public pool, define reproducible episode families:
|
| 663 |
+
|
| 664 |
+
- urgent outage follow-up
|
| 665 |
+
- mixed billing queue
|
| 666 |
+
- false-positive security scare
|
| 667 |
+
- onboarding plus access control bundle
|
| 668 |
+
- executive escalation chain
|
| 669 |
+
|
| 670 |
+
Once episodes become scenario-driven rather than ticket-sampled, the benchmark will feel much more intentional.
|
| 671 |
+
|
| 672 |
+
### Chess and TextArena: delayed reward and auxiliary signals are valuable
|
| 673 |
+
|
| 674 |
+
`ChessEnvironment` plus `ChessWinLossRubric` shows how delayed reward can be modeled cleanly across a trajectory. `TextArenaEnvironment` plus its reward providers shows how auxiliary signals can coexist with the main reward without replacing it. Those patterns matter because helpdesk operations are not fully one-shot even when the final routing choice is what gets judged.
|
| 675 |
+
|
| 676 |
+
In a stronger version of your environment, you could preserve a main final reward while also emitting auxiliary channels such as:
|
| 677 |
+
|
| 678 |
+
- evidence quality
|
| 679 |
+
- duplicate-handling quality
|
| 680 |
+
- escalation efficiency
|
| 681 |
+
- SLA awareness
|
| 682 |
+
- customer experience quality
|
| 683 |
+
- policy compliance
|
| 684 |
+
|
| 685 |
+
Even if you keep one main scalar reward for training or evaluation, those auxiliary signals would make the benchmark much more diagnosable.
|
| 686 |
+
|
| 687 |
+
### ReasoningGym and Maze: simplicity is fine if it is honest
|
| 688 |
+
|
| 689 |
+
`ReasoningGymEnvironment` is a simple parameterized single-step environment. `MazeEnvironment` is a simple gridworld. Neither one pretends to be deeper than it is. That honesty is useful as a design lesson.
|
| 690 |
+
|
| 691 |
+
If you want to keep a light version of your current project, that is perfectly reasonable. But then it should be presented as a starter triage benchmark, not as a fully realized agentic operations environment. If you want to claim higher competitive value, the environment itself needs to support that claim with deeper mechanics.
|
| 692 |
+
|
| 693 |
+
## A Concrete Design for Beating the Stronger Projects
|
| 694 |
+
|
| 695 |
+
The right goal is not to imitate the broadest reference project. The right goal is to go much deeper in one domain you already own.
|
| 696 |
+
|
| 697 |
+
You do not need to out-BrowserGym BrowserGym. You do not need to out-TBench2 TBench2. You need to become clearly better at service desk operations simulation than the reference set is today.
|
| 698 |
+
|
| 699 |
+
### North star: build a service operations simulator
|
| 700 |
+
|
| 701 |
+
The strongest future version of this project looks more like an IT service desk simulator than a label prediction benchmark.
|
| 702 |
+
|
| 703 |
+
Core properties of that simulator should be:
|
| 704 |
+
|
| 705 |
+
- partially observed ticket and account state
|
| 706 |
+
- internal tools for investigation
|
| 707 |
+
- scenario families rather than one static pool
|
| 708 |
+
- multi-step resolution workflows
|
| 709 |
+
- queue-level tradeoffs
|
| 710 |
+
- policy-aware reward
|
| 711 |
+
- hidden evaluation truth
|
| 712 |
+
|
| 713 |
+
If you hit those properties, you will not just be polishing the current environment. You will be changing the category of the benchmark.
|
| 714 |
+
|
| 715 |
+
### Proposed visible entities
|
| 716 |
+
|
| 717 |
+
The agent should see richer but still realistic objects, for example:
|
| 718 |
+
|
| 719 |
+
- ticket thread summary
|
| 720 |
+
- current requester details
|
| 721 |
+
- account/org summary
|
| 722 |
+
- queue overview
|
| 723 |
+
- recent internal note previews
|
| 724 |
+
- live incident banner or incident tool access
|
| 725 |
+
- available tools
|
| 726 |
+
- allowed actions
|
| 727 |
+
- task budget and SLA hints
|
| 728 |
+
|
| 729 |
+
That does not mean every observation must be huge. It means the visible world should make the agent reason like an operator instead of like a labeler.
|
| 730 |
+
|
| 731 |
+
### Proposed hidden entities
|
| 732 |
+
|
| 733 |
+
The environment should own hidden state that determines the correct policy:
|
| 734 |
+
|
| 735 |
+
- canonical root-cause category
|
| 736 |
+
- customer tier
|
| 737 |
+
- resolver ownership
|
| 738 |
+
- actual business impact
|
| 739 |
+
- active incident linkage
|
| 740 |
+
- prior unresolved duplicates
|
| 741 |
+
- whether manual escalation is necessary or wasteful
|
| 742 |
+
- whether policy requires a specific handling path
|
| 743 |
+
- whether the ticket is self-servable by documented guidance
|
| 744 |
+
|
| 745 |
+
These hidden variables are what create genuinely hard cases. Two tickets that look similar on the surface should sometimes route differently because the hidden state differs.
|
| 746 |
+
|
| 747 |
+
### Proposed action surface
|
| 748 |
+
|
| 749 |
+
I would split the action space into investigation actions and commitment actions.
|
| 750 |
+
|
| 751 |
+
Investigation actions:
|
| 752 |
+
|
| 753 |
+
- `lookup_requester`
|
| 754 |
+
- `get_account_plan`
|
| 755 |
+
- `get_related_tickets`
|
| 756 |
+
- `check_service_health`
|
| 757 |
+
- `search_kb`
|
| 758 |
+
- `inspect_internal_notes`
|
| 759 |
+
- `get_security_signals`
|
| 760 |
+
- `get_asset_or_license_state`
|
| 761 |
+
|
| 762 |
+
Operational actions:
|
| 763 |
+
|
| 764 |
+
- `add_internal_note`
|
| 765 |
+
- `request_more_info`
|
| 766 |
+
- `merge_duplicate`
|
| 767 |
+
- `set_priority`
|
| 768 |
+
- `assign_group`
|
| 769 |
+
- `escalate`
|
| 770 |
+
- `acknowledge`
|
| 771 |
+
- `submit_final_decision`
|
| 772 |
+
|
| 773 |
+
This preserves your current routing taxonomy while forcing the agent to earn the final answer through interaction.
|
| 774 |
+
|
| 775 |
+
### Proposed task families
|
| 776 |
+
|
| 777 |
+
Replace the current output-field ladder with scenario families.
|
| 778 |
+
|
| 779 |
+
1. **Baseline classification**
|
| 780 |
+
Keep a simple version of the current task for calibration.
|
| 781 |
+
|
| 782 |
+
2. **Priority under operational context**
|
| 783 |
+
Add visible account metadata and SLA hints.
|
| 784 |
+
|
| 785 |
+
3. **Tool-assisted routing**
|
| 786 |
+
Hard cases require evidence retrieval.
|
| 787 |
+
|
| 788 |
+
4. **Follow-up chain handling**
|
| 789 |
+
Correct routing depends on thread history and prior failures.
|
| 790 |
+
|
| 791 |
+
5. **Duplicate resolution**
|
| 792 |
+
The agent must detect and merge with existing tickets or note the linkage.
|
| 793 |
+
|
| 794 |
+
6. **Queue management**
|
| 795 |
+
Multiple tickets compete for limited steps or limited escalation budget.
|
| 796 |
+
|
| 797 |
+
7. **Incident-aware triage**
|
| 798 |
+
Correct behavior depends on checking active incident state.
|
| 799 |
+
|
| 800 |
+
8. **Policy-constrained operations**
|
| 801 |
+
Compliance, security, or executive-account policies change what the correct action is.
|
| 802 |
+
|
| 803 |
+
Now difficulty comes from task structure, not just output dimensionality.
|
| 804 |
+
|
| 805 |
+
### Proposed reward design
|
| 806 |
+
|
| 807 |
+
A strong reward design for this domain should likely have four layers.
|
| 808 |
+
|
| 809 |
+
Layer 1: **final outcome correctness**
|
| 810 |
+
|
| 811 |
+
- correct issue family
|
| 812 |
+
- correct priority
|
| 813 |
+
- correct resolver team
|
| 814 |
+
- correct action
|
| 815 |
+
|
| 816 |
+
Layer 2: **operational policy correctness**
|
| 817 |
+
|
| 818 |
+
- no violation of mandatory escalation rules
|
| 819 |
+
- no unjustified critical priority
|
| 820 |
+
- no missed compliance deadlines
|
| 821 |
+
- no unsupported closure
|
| 822 |
+
|
| 823 |
+
Layer 3: **process quality**
|
| 824 |
+
|
| 825 |
+
- useful tool use
|
| 826 |
+
- correct duplicate inspection
|
| 827 |
+
- efficient evidence gathering
|
| 828 |
+
- no unnecessary specialist escalation
|
| 829 |
+
|
| 830 |
+
Layer 4: **episode economics**
|
| 831 |
+
|
| 832 |
+
- queue-wide quality
|
| 833 |
+
- backlog harm
|
| 834 |
+
- escalation cost
|
| 835 |
+
- SLA miss cost
|
| 836 |
+
|
| 837 |
+
That may sound like a lot, but you do not need to expose all of it as one scalar at once. Some of it can be stored as metadata or auxiliary reward channels first.
|
| 838 |
+
|
| 839 |
+
### Proposed data strategy
|
| 840 |
+
|
| 841 |
+
Do not try to hand-author ten thousand fully custom tickets from scratch. Instead, build a layered data strategy.
|
| 842 |
+
|
| 843 |
+
Layer A: curated seed cases
|
| 844 |
+
|
| 845 |
+
- your best handcrafted exemplars
|
| 846 |
+
- ambiguous pairs
|
| 847 |
+
- follow-up chains
|
| 848 |
+
- adversarial near-neighbors
|
| 849 |
+
|
| 850 |
+
Layer B: templated scenario generation
|
| 851 |
+
|
| 852 |
+
- same underlying issue with different requester tiers
|
| 853 |
+
- same wording with different hidden incident context
|
| 854 |
+
- duplicate vs non-duplicate versions
|
| 855 |
+
- billing dispute with and without outage linkage
|
| 856 |
+
|
| 857 |
+
Layer C: hidden benchmark splits
|
| 858 |
+
|
| 859 |
+
- development split
|
| 860 |
+
- public validation split
|
| 861 |
+
- private evaluation split
|
| 862 |
+
|
| 863 |
+
Layer D: scenario tagging
|
| 864 |
+
|
| 865 |
+
- issue family
|
| 866 |
+
- ambiguity level
|
| 867 |
+
- investigation depth required
|
| 868 |
+
- tool requirement
|
| 869 |
+
- risk class
|
| 870 |
+
- queue pressure
|
| 871 |
+
|
| 872 |
+
This approach gives you scale without giving up control.
|
| 873 |
+
|
| 874 |
+
## File-by-File Improvement Plan for This Repository
|
| 875 |
+
|
| 876 |
+
This section ties the redesign back to the actual code you already have. The point is to show how the current repo can evolve into the stronger benchmark rather than be abandoned.
|
| 877 |
+
|
| 878 |
+
### `models.py`
|
| 879 |
+
|
| 880 |
+
Right now the models encode the benchmark as a label submission problem. That is fine for version one and too restrictive for version two.
|
| 881 |
+
|
| 882 |
+
I would keep the existing validation patterns, but I would expand the schema into typed action families and typed observation payloads.
|
| 883 |
+
|
| 884 |
+
Recommended direction:
|
| 885 |
+
|
| 886 |
+
- keep `HelpdeskTicketRecord`, but add typed visible vs hidden fields
|
| 887 |
+
- replace the loose `current_ticket: Optional[dict[str, str]]` with a ticket-view model
|
| 888 |
+
- split actions into investigation actions and final submission actions
|
| 889 |
+
- add typed structures for tool results, notes, queue items, and thread previews
|
| 890 |
+
- enrich state with scenario metadata, action audit trail, and resource counters
|
| 891 |
+
|
| 892 |
+
Why this matters:
|
| 893 |
+
|
| 894 |
+
As long as the schema itself says “the agent submits optional routing fields,” every other part of the environment will naturally stay classifier-shaped. Schema is architecture. If you want the environment to feel agentic, the models have to make agentic behavior first-class.
|
| 895 |
+
|
| 896 |
+
### `server/environment.py`
|
| 897 |
+
|
| 898 |
+
This file is currently the main reason the benchmark feels thin. It is clean, but it is clean because it has very little world logic.
|
| 899 |
+
|
| 900 |
+
I would evolve it in stages.
|
| 901 |
+
|
| 902 |
+
Stage 1:
|
| 903 |
+
|
| 904 |
+
- expose structured thread/follow-up information
|
| 905 |
+
- enforce task contracts more tightly
|
| 906 |
+
- store full action history, not just scores
|
| 907 |
+
- make scenario metadata visible
|
| 908 |
+
|
| 909 |
+
Stage 2:
|
| 910 |
+
|
| 911 |
+
- add tool dispatch for investigation actions
|
| 912 |
+
- maintain scenario-local hidden state
|
| 913 |
+
- let actions mutate environment state
|
| 914 |
+
- support final decision submission separately from intermediate investigation
|
| 915 |
+
|
| 916 |
+
Stage 3:
|
| 917 |
+
|
| 918 |
+
- add queue-level episodes with budget constraints
|
| 919 |
+
- let earlier choices affect later ticket handling
|
| 920 |
+
- introduce scenario-specific logic for duplicates, incidents, and policy constraints
|
| 921 |
+
|
| 922 |
+
Why this matters:
|
| 923 |
+
|
| 924 |
+
This file should become the simulator, not just the grader entrypoint.
|
| 925 |
+
|
| 926 |
+
### `server/tasks.py`
|
| 927 |
+
|
| 928 |
+
This file needs the most conceptual change after the environment itself.
|
| 929 |
+
|
| 930 |
+
The current task list is:
|
| 931 |
+
|
| 932 |
+
- task 1: issue type only
|
| 933 |
+
- task 2: issue type plus priority
|
| 934 |
+
- task 3: full routing
|
| 935 |
+
|
| 936 |
+
That is too narrow. I would turn `tasks.py` into a scenario-family registry instead.
|
| 937 |
+
|
| 938 |
+
For example:
|
| 939 |
+
|
| 940 |
+
- `single_ticket_classification`
|
| 941 |
+
- `priority_under_sla`
|
| 942 |
+
- `tool_assisted_routing`
|
| 943 |
+
- `duplicate_chain_resolution`
|
| 944 |
+
- `incident_aware_triage`
|
| 945 |
+
- `queue_optimization`
|
| 946 |
+
- `policy_constrained_security_case`
|
| 947 |
+
|
| 948 |
+
Each task family should define:
|
| 949 |
+
|
| 950 |
+
- visible observation contract
|
| 951 |
+
- allowed actions
|
| 952 |
+
- hidden truth generator
|
| 953 |
+
- episode budget
|
| 954 |
+
- reward composition
|
| 955 |
+
- benchmark split membership
|
| 956 |
+
|
| 957 |
+
Why this matters:
|
| 958 |
+
|
| 959 |
+
Right now tasks differ by scoring columns. A strong benchmark needs tasks that differ by problem structure.
|
| 960 |
+
|
| 961 |
+
### `server/grader.py`
|
| 962 |
+
|
| 963 |
+
This file should stop being only a lookup-based scorer and become the place where service-desk policy is encoded.
|
| 964 |
+
|
| 965 |
+
I would keep the basic idea of partial credit, but move from a pure field-overlap worldview to a policy-and-outcome worldview.
|
| 966 |
+
|
| 967 |
+
Examples of richer scoring logic:
|
| 968 |
+
|
| 969 |
+
- small penalty for unnecessary escalation
|
| 970 |
+
- strong penalty for under-prioritizing active access outages
|
| 971 |
+
- reward for correctly linking duplicates
|
| 972 |
+
- reward for choosing acknowledgment before final resolution when that is the right workflow
|
| 973 |
+
- penalty for routing compliance work to general support
|
| 974 |
+
- scenario-aware scoring where the same visible ticket can score differently depending on retrieved evidence
|
| 975 |
+
|
| 976 |
+
Why this matters:
|
| 977 |
+
|
| 978 |
+
The grader is the actual benchmark. It should reflect operational quality, not only taxonomy overlap.
|
| 979 |
+
|
| 980 |
+
### `server/reward.py`
|
| 981 |
+
|
| 982 |
+
This file is a good place to simplify and then rebuild.
|
| 983 |
+
|
| 984 |
+
First, remove or redesign logic that is not meaningfully active, such as the current overshoot penalty that normal episode flow does not really trigger.
|
| 985 |
+
|
| 986 |
+
Then add reward layers deliberately:
|
| 987 |
+
|
| 988 |
+
- final decision score
|
| 989 |
+
- process score
|
| 990 |
+
- economics score
|
| 991 |
+
- optional auxiliary diagnostics
|
| 992 |
+
|
| 993 |
+
Why this matters:
|
| 994 |
+
|
| 995 |
+
A benchmark becomes much easier to improve if the reward code honestly reflects what is being optimized.
|
| 996 |
+
|
| 997 |
+
### `server/app.py`
|
| 998 |
+
|
| 999 |
+
This file is currently fine for a minimal environment, but it should grow once the environment grows.
|
| 1000 |
+
|
| 1001 |
+
Recommended additions:
|
| 1002 |
+
|
| 1003 |
+
- environment metadata endpoint support if you want richer UI or benchmark introspection
|
| 1004 |
+
- possibly custom routes for benchmark info, scenario families, or baseline metadata
|
| 1005 |
+
- cleaner packaging around path setup once the project stabilizes
|
| 1006 |
+
|
| 1007 |
+
Why this matters:
|
| 1008 |
+
|
| 1009 |
+
This is not the highest-priority file, but stronger benchmark ergonomics do help credibility and usability.
|
| 1010 |
+
|
| 1011 |
+
### `data/dataset.json`
|
| 1012 |
+
|
| 1013 |
+
This file should evolve from “the benchmark” into “part of the benchmark.”
|
| 1014 |
+
|
| 1015 |
+
Keep a curated hand-authored slice, but do not let one public JSON file define the whole environment forever.
|
| 1016 |
+
|
| 1017 |
+
Recommended evolution:
|
| 1018 |
+
|
| 1019 |
+
- expand the dataset substantially
|
| 1020 |
+
- add many more feature request and general inquiry cases
|
| 1021 |
+
- add multiple duplicate chains
|
| 1022 |
+
- add hidden context fields
|
| 1023 |
+
- add templated variants of existing scenarios
|
| 1024 |
+
- create a private evaluation bank
|
| 1025 |
+
|
| 1026 |
+
Why this matters:
|
| 1027 |
+
|
| 1028 |
+
A tiny fixed public dataset makes memorization too easy and benchmark claims too brittle.
|
| 1029 |
+
|
| 1030 |
+
### `inference.py`
|
| 1031 |
+
|
| 1032 |
+
This file is useful, but it currently plays several roles at once:
|
| 1033 |
+
|
| 1034 |
+
- demo script
|
| 1035 |
+
- heuristic baseline
|
| 1036 |
+
- optional LLM runner
|
| 1037 |
+
- environment smoke path
|
| 1038 |
+
|
| 1039 |
+
I would separate those responsibilities.
|
| 1040 |
+
|
| 1041 |
+
Recommended structure:
|
| 1042 |
+
|
| 1043 |
+
- one official deterministic baseline runner
|
| 1044 |
+
- one optional tool-using baseline runner once tools exist
|
| 1045 |
+
- one separate example script for simple local usage
|
| 1046 |
+
- one benchmark harness that records split, seed, scenario family, and version
|
| 1047 |
+
|
| 1048 |
+
Why this matters:
|
| 1049 |
+
|
| 1050 |
+
Benchmarks need reproducible baselines more than they need convenient demos.
|
| 1051 |
+
|
| 1052 |
+
### `tests/`
|
| 1053 |
+
|
| 1054 |
+
The most important change after environment design is testing philosophy.
|
| 1055 |
+
|
| 1056 |
+
I would split tests into at least four groups:
|
| 1057 |
+
|
| 1058 |
+
1. **unit tests**
|
| 1059 |
+
Validation, scoring primitives, dataset loaders, tool helpers.
|
| 1060 |
+
|
| 1061 |
+
2. **real integration tests**
|
| 1062 |
+
Actual OpenEnv app, actual serialization, actual WebSocket interactions.
|
| 1063 |
+
|
| 1064 |
+
3. **benchmark regression tests**
|
| 1065 |
+
Fixed scenario suites, stable baseline scores, hidden split checks.
|
| 1066 |
+
|
| 1067 |
+
4. **integrity tests**
|
| 1068 |
+
No task leakage, no duplicate split contamination, no benchmark version drift.
|
| 1069 |
+
|
| 1070 |
+
Why this matters:
|
| 1071 |
+
|
| 1072 |
+
A serious benchmark is a data product, an environment product, and an evaluation product. The tests should reflect all three.
|
| 1073 |
+
|
| 1074 |
+
## Practical Roadmap
|
| 1075 |
+
|
| 1076 |
+
### Phase 1: Make the current environment honest and sturdier
|
| 1077 |
+
|
| 1078 |
+
This is the fastest and cheapest improvement phase. Do this even if you are not ready for a full redesign.
|
| 1079 |
+
|
| 1080 |
+
Goals:
|
| 1081 |
+
|
| 1082 |
+
- expose thread/follow-up structure
|
| 1083 |
+
- tighten task contracts
|
| 1084 |
+
- recompute and stabilize baseline measurements
|
| 1085 |
+
- add a hidden evaluation split
|
| 1086 |
+
- remove decorative reward logic
|
| 1087 |
+
- improve test realism
|
| 1088 |
+
|
| 1089 |
+
Deliverables:
|
| 1090 |
+
|
| 1091 |
+
- stronger observation model
|
| 1092 |
+
- benchmark regression script
|
| 1093 |
+
- real integration tests
|
| 1094 |
+
- scenario-family-aware tasks, even if still text-only
|
| 1095 |
+
|
| 1096 |
+
This phase will not yet make the environment winner-beating, but it will make it much more defensible.
|
| 1097 |
+
|
| 1098 |
+
### Phase 2: Add tool-assisted investigation
|
| 1099 |
+
|
| 1100 |
+
This is the highest-return phase because it changes the category of the benchmark.
|
| 1101 |
+
|
| 1102 |
+
Minimum viable tool set:
|
| 1103 |
+
|
| 1104 |
+
- requester/account lookup
|
| 1105 |
+
- related-ticket retrieval
|
| 1106 |
+
- service health lookup
|
| 1107 |
+
- KB search
|
| 1108 |
+
- final decision submission
|
| 1109 |
+
|
| 1110 |
+
Once those exist, create scenario families where the visible ticket text is insufficient without tool use. That immediately raises the benchmark ceiling and reduces shortcutability.
|
| 1111 |
+
|
| 1112 |
+
### Phase 3: Add operational economics and queue-level behavior
|
| 1113 |
+
|
| 1114 |
+
After tool use works, add:
|
| 1115 |
+
|
| 1116 |
+
- queue-wide episodes
|
| 1117 |
+
- time or action budgets
|
| 1118 |
+
- escalation cost
|
| 1119 |
+
- SLA miss cost
|
| 1120 |
+
- duplicate-handling benefit
|
| 1121 |
+
- specialist-capacity awareness
|
| 1122 |
+
|
| 1123 |
+
This turns the environment from a case-by-case annotation task into an operational management task.
|
| 1124 |
+
|
| 1125 |
+
### Phase 4: Add benchmark governance
|
| 1126 |
+
|
| 1127 |
+
At this point you should formalize:
|
| 1128 |
+
|
| 1129 |
+
- public vs private splits
|
| 1130 |
+
- scenario-family tags
|
| 1131 |
+
- official baselines
|
| 1132 |
+
- benchmark versioning
|
| 1133 |
+
- scorecards by scenario family
|
| 1134 |
+
- release notes for benchmark changes
|
| 1135 |
+
|
| 1136 |
+
This is what makes the project not just interesting, but trustworthy.
|
| 1137 |
+
|
| 1138 |
+
## Prioritized Recommendation List
|
| 1139 |
+
|
| 1140 |
+
If I had to choose only ten improvements, in order, I would choose these:
|
| 1141 |
+
|
| 1142 |
+
1. Stop defining difficulty only by `allowed_fields`.
|
| 1143 |
+
2. Add investigation tools and final submission as separate actions.
|
| 1144 |
+
3. Break the deterministic issue-type-to-assignment shortcut.
|
| 1145 |
+
4. Make resolution depend on hidden operational context more often.
|
| 1146 |
+
5. Surface follow-up and related-ticket structure.
|
| 1147 |
+
6. Expand data and add hidden eval splits.
|
| 1148 |
+
7. Add process-aware reward and remove dead trajectory logic.
|
| 1149 |
+
8. Add queue-level economics and limited budgets.
|
| 1150 |
+
9. Replace stub-heavy integration tests with real framework tests.
|
| 1151 |
+
10. Publish a stable benchmark harness and official baseline measurement.
|
| 1152 |
+
|
| 1153 |
+
## Final Assessment
|
| 1154 |
+
|
| 1155 |
+
After a deep code read, my conclusion is simple:
|
| 1156 |
+
|
| 1157 |
+
Your project is promising, readable, and based on a very strong domain. But in its current form it is still a compact routing benchmark, not yet a high-ceiling service-operations environment.
|
| 1158 |
+
|
| 1159 |
+
The better reference environments in `OpenEnv/envs` are better not because they are bigger for the sake of being bigger, but because they force the agent to operate inside state, tools, or consequences that cannot be collapsed into label mapping so easily.
|
| 1160 |
+
|
| 1161 |
+
The encouraging part is that your domain can support exactly that kind of benchmark. IT helpdesk operations naturally contain ambiguity, hidden context, tool use, policy constraints, long threads, queue pressure, and downstream costs. Very few toy domains offer that combination so cleanly.
|
| 1162 |
+
|
| 1163 |
+
So the right move is not to abandon the project. The right move is to evolve it.
|
| 1164 |
+
|
| 1165 |
+
If you keep the current shape and only add more tickets, you will get a better classifier benchmark. That may be useful, but it probably will not beat the strongest reference projects.
|
| 1166 |
+
|
| 1167 |
+
If you turn this into a tool-assisted, partially observed, multi-step service-operations simulator with stronger reward design and stronger benchmark governance, then you can absolutely build something more compelling than many of the reference environments, because your domain has the right raw material for a benchmark that is both realistic and highly evaluable.
|
| 1168 |
+
|
| 1169 |
+
The domain is already winner material.
|
| 1170 |
+
|
| 1171 |
+
The current implementation is starter material.
|
| 1172 |
+
|
| 1173 |
+
The opportunity is to close that gap deliberately.
|
| 1174 |
+
|
| 1175 |
+
## Appendix A: Comparative Scorecard
|
| 1176 |
+
|
| 1177 |
+
The table below is not a scientific benchmark. It is a code-read scorecard based on the implementations reviewed in this report. The goal is to make the gap tangible.
|
| 1178 |
+
|
| 1179 |
+
| Dimension | Your project now | Strong reference environments |
|
| 1180 |
+
| --- | --- | --- |
|
| 1181 |
+
| Action richness | Low | Medium to very high |
|
| 1182 |
+
| Hidden state depth | Low | Medium to high |
|
| 1183 |
+
| Tool use | None | Present in FinQA, Calendar, TBench2, Git, REPL |
|
| 1184 |
+
| Multistep interaction | Low-medium | Medium to high |
|
| 1185 |
+
| Queue/process economics | Very low | Medium in some envs, high in operational ones |
|
| 1186 |
+
| Reward sophistication | Low-medium | Medium to high |
|
| 1187 |
+
| Benchmark anti-overfitting | Low | Medium |
|
| 1188 |
+
| Runtime realism | Low | Medium to high |
|
| 1189 |
+
| Testing depth | Low-medium | Medium to high at repo scale |
|
| 1190 |
+
| Domain relevance | High | Varies by env |
|
| 1191 |
+
| Potential ceiling | High | Already demonstrated in several envs |
|
| 1192 |
+
|
| 1193 |
+
The most important row here is the last one. Your current implementation is not yet at the same level as the strongest references, but the domain ceiling is absolutely high enough to catch up and possibly surpass them if you execute the redesign well.
|
| 1194 |
+
|
| 1195 |
+
## Appendix B: What You Should Preserve
|
| 1196 |
+
|
| 1197 |
+
When teams hear “major redesign,” they often accidentally throw away the parts that were already working. I do not recommend that here.
|
| 1198 |
+
|
| 1199 |
+
The current project has several strengths that should be preserved as you expand it:
|
| 1200 |
+
|
| 1201 |
+
### 1. Preserve the compactness of the taxonomy
|
| 1202 |
+
|
| 1203 |
+
The label space in `vocabulary.py` is clear and product-shaped. It is not bloated. Even when the environment becomes tool-based and stateful, keep the routing ontology understandable. The problem with the current benchmark is not that the taxonomy is wrong. The problem is that the environment around the taxonomy is too thin.
|
| 1204 |
+
|
| 1205 |
+
### 2. Preserve deterministic core scoring where possible
|
| 1206 |
+
|
| 1207 |
+
Even after you add process reward and hidden context, keep as much deterministic scoring as possible. One reason your current project is easy to debug is that the grader is inspectable. Do not replace everything with opaque LLM judging if you can avoid it. Use explicit hidden truth and rule-based evaluation for most of the benchmark, and reserve softer judging only for areas that truly need it.
|
| 1208 |
+
|
| 1209 |
+
### 3. Preserve readability
|
| 1210 |
+
|
| 1211 |
+
The current codebase is easy to onboard into. That is an asset. Several bigger reference environments are strong, but also much harder to reason about quickly because they wrap external systems or broad framework machinery. As you deepen this project, keep modules well-separated:
|
| 1212 |
+
|
| 1213 |
+
- models
|
| 1214 |
+
- scenario generation
|
| 1215 |
+
- environment runtime
|
| 1216 |
+
- tools
|
| 1217 |
+
- scoring
|
| 1218 |
+
- reward composition
|
| 1219 |
+
- benchmark harness
|
| 1220 |
+
|
| 1221 |
+
That separation will make future iteration much faster.
|
| 1222 |
+
|
| 1223 |
+
### 4. Preserve seeded reproducibility
|
| 1224 |
+
|
| 1225 |
+
Your existing environment is deterministic under a seed, and that is worth keeping. Stronger benchmarks become much easier to trust when a given scenario family plus seed reproduces the same world state. As you add hidden context and generators, make seed behavior even more explicit instead of less.
|
| 1226 |
+
|
| 1227 |
+
### 5. Preserve explicit validation
|
| 1228 |
+
|
| 1229 |
+
The Pydantic validation in the current models is a quiet strength. Keep that discipline. As the action surface grows, validation becomes more important, not less. Tools and action types should reject malformed inputs cleanly so that environment failures are informative rather than muddy.
|
| 1230 |
+
|
| 1231 |
+
## Appendix C: Example Scenario Families for Version 2
|
| 1232 |
+
|
| 1233 |
+
To make the redesign more concrete, here are example scenario families that would feel much closer to a winner-level helpdesk benchmark.
|
| 1234 |
+
|
| 1235 |
+
### Scenario Family 1: Access outage with incident ambiguity
|
| 1236 |
+
|
| 1237 |
+
Visible state:
|
| 1238 |
+
|
| 1239 |
+
- multiple users report being locked out
|
| 1240 |
+
- one requester sounds urgent
|
| 1241 |
+
- another sounds like a normal password reset
|
| 1242 |
+
|
| 1243 |
+
Hidden state:
|
| 1244 |
+
|
| 1245 |
+
- there is an active identity provider outage
|
| 1246 |
+
- some tickets are duplicate symptoms of the same incident
|
| 1247 |
+
|
| 1248 |
+
Tools needed:
|
| 1249 |
+
|
| 1250 |
+
- `check_service_health`
|
| 1251 |
+
- `get_related_tickets`
|
| 1252 |
+
- `lookup_requester_role`
|
| 1253 |
+
|
| 1254 |
+
What this tests:
|
| 1255 |
+
|
| 1256 |
+
- whether the agent distinguishes isolated access issues from systemic incidents
|
| 1257 |
+
- whether it avoids handling every case as an independent ticket
|
| 1258 |
+
- whether it correctly prioritizes executive or admin users without overreacting on every case
|
| 1259 |
+
|
| 1260 |
+
### Scenario Family 2: Billing dispute tied to product defect
|
| 1261 |
+
|
| 1262 |
+
Visible state:
|
| 1263 |
+
|
| 1264 |
+
- customer says they were charged incorrectly
|
| 1265 |
+
- another case mentions checkout failures
|
| 1266 |
+
|
| 1267 |
+
Hidden state:
|
| 1268 |
+
|
| 1269 |
+
- the billing dispute is caused by a known application defect that duplicated transactions
|
| 1270 |
+
|
| 1271 |
+
Tools needed:
|
| 1272 |
+
|
| 1273 |
+
- `search_related_tickets`
|
| 1274 |
+
- `check_service_health`
|
| 1275 |
+
- `read_internal_incident_note`
|
| 1276 |
+
|
| 1277 |
+
What this tests:
|
| 1278 |
+
|
| 1279 |
+
- whether the agent routes based on real causal structure rather than superficial department ownership
|
| 1280 |
+
- whether it recognizes that pure billing handling is insufficient because engineering is involved
|
| 1281 |
+
|
| 1282 |
+
### Scenario Family 3: Compliance deadline with account-context twist
|
| 1283 |
+
|
| 1284 |
+
Visible state:
|
| 1285 |
+
|
| 1286 |
+
- requester references GDPR or legal obligation
|
| 1287 |
+
|
| 1288 |
+
Hidden state:
|
| 1289 |
+
|
| 1290 |
+
- some requests are legitimate deletion requests
|
| 1291 |
+
- some are actually admin-level data export requests misphrased as deletion
|
| 1292 |
+
- some belong to customers on contracts with defined response obligations
|
| 1293 |
+
|
| 1294 |
+
Tools needed:
|
| 1295 |
+
|
| 1296 |
+
- `lookup_contract_tier`
|
| 1297 |
+
- `retrieve_policy_snippet`
|
| 1298 |
+
- `get_account_data_scope`
|
| 1299 |
+
|
| 1300 |
+
What this tests:
|
| 1301 |
+
|
| 1302 |
+
- whether the agent can combine legal wording with account and policy context
|
| 1303 |
+
- whether it overroutes all legal-sounding tickets to the same team
|
| 1304 |
+
|
| 1305 |
+
### Scenario Family 4: Duplicate-heavy queue optimization
|
| 1306 |
+
|
| 1307 |
+
Visible state:
|
| 1308 |
+
|
| 1309 |
+
- ten tickets in a queue
|
| 1310 |
+
- several appear to be related
|
| 1311 |
+
|
| 1312 |
+
Hidden state:
|
| 1313 |
+
|
| 1314 |
+
- six are duplicates of two underlying issues
|
| 1315 |
+
- one low-volume ticket is actually the most SLA-critical
|
| 1316 |
+
|
| 1317 |
+
Tools needed:
|
| 1318 |
+
|
| 1319 |
+
- `search_related_tickets`
|
| 1320 |
+
- `merge_duplicate`
|
| 1321 |
+
- `set_priority`
|
| 1322 |
+
- `submit_queue_plan`
|
| 1323 |
+
|
| 1324 |
+
What this tests:
|
| 1325 |
+
|
| 1326 |
+
- whether the agent can manage a queue as a system
|
| 1327 |
+
- whether it reduces work through linkage
|
| 1328 |
+
- whether it balances urgency against volume
|
| 1329 |
+
|
| 1330 |
+
### Scenario Family 5: Feature request versus broken workflow
|
| 1331 |
+
|
| 1332 |
+
Visible state:
|
| 1333 |
+
|
| 1334 |
+
- customer asks for export filters or better reporting
|
| 1335 |
+
|
| 1336 |
+
Hidden state:
|
| 1337 |
+
|
| 1338 |
+
- in some scenarios the feature genuinely does not exist
|
| 1339 |
+
- in others the feature exists but the customer lacks permissions or is using the wrong path
|
| 1340 |
+
|
| 1341 |
+
Tools needed:
|
| 1342 |
+
|
| 1343 |
+
- `search_kb`
|
| 1344 |
+
- `lookup_plan_features`
|
| 1345 |
+
- `inspect_recent_product_change`
|
| 1346 |
+
|
| 1347 |
+
What this tests:
|
| 1348 |
+
|
| 1349 |
+
- whether the agent treats every request for missing functionality as a feature request
|
| 1350 |
+
- whether it can separate education/support from roadmap input
|
| 1351 |
+
|
| 1352 |
+
## Appendix D: Red Flags to Avoid During the Redesign
|
| 1353 |
+
|
| 1354 |
+
There are a few ways a redesign like this can go wrong. Avoid these.
|
| 1355 |
+
|
| 1356 |
+
### 1. Do not add tools that are merely decorative
|
| 1357 |
+
|
| 1358 |
+
If a hard task can still be solved reliably without using the tools, then the tool surface is just benchmark theater. The hard scenario families should be designed so that retrieved evidence actually changes the correct answer.
|
| 1359 |
+
|
| 1360 |
+
### 2. Do not make every scenario gigantic
|
| 1361 |
+
|
| 1362 |
+
Richer does not mean bloated. Some scenarios should stay compact. The goal is meaningful hidden context, not maximum token count.
|
| 1363 |
+
|
| 1364 |
+
### 3. Do not replace all scoring with LLM judging
|
| 1365 |
+
|
| 1366 |
+
Use explicit hidden truth and deterministic scoring wherever possible. Opaque judging should be a last resort, not a default.
|
| 1367 |
+
|
| 1368 |
+
### 4. Do not let the ontology become a maze
|
| 1369 |
+
|
| 1370 |
+
Your current taxonomy is pleasantly clean. Keep it that way. More realism should come from state and evidence, not from exploding the label space into dozens of nearly indistinguishable categories.
|
| 1371 |
+
|
| 1372 |
+
### 5. Do not forget benchmark governance
|
| 1373 |
+
|
| 1374 |
+
If you add scenario generation but do not formalize splits, baselines, and versioning, you will create a cooler environment without creating a more trustworthy benchmark.
|
data/dataset.json
CHANGED
|
@@ -538,6 +538,42 @@
|
|
| 538 |
"resolution_action": "escalate",
|
| 539 |
"ambiguity_note": null,
|
| 540 |
"related_ticket_id": "ticket-030"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
}
|
| 542 |
]
|
| 543 |
|
|
|
|
| 538 |
"resolution_action": "escalate",
|
| 539 |
"ambiguity_note": null,
|
| 540 |
"related_ticket_id": "ticket-030"
|
| 541 |
+
},
|
| 542 |
+
{
|
| 543 |
+
"ticket_id": "TKT-NONDEFAULT-001",
|
| 544 |
+
"title": "Billing question from free-tier account",
|
| 545 |
+
"requester": "user@freetier.io",
|
| 546 |
+
"description": "I have a question about my invoice but I am on the free plan and there is no charge. The billing team cannot action this; please route to service desk for general assistance.",
|
| 547 |
+
"issue_type": "billing_license",
|
| 548 |
+
"priority": "low",
|
| 549 |
+
"assignment_group": "service_desk",
|
| 550 |
+
"resolution_action": "fulfill",
|
| 551 |
+
"ambiguity_note": "Account tier is free; billing team cannot action, routed to service desk",
|
| 552 |
+
"related_ticket_id": null
|
| 553 |
+
},
|
| 554 |
+
{
|
| 555 |
+
"ticket_id": "TKT-NONDEFAULT-002",
|
| 556 |
+
"title": "App vulnerability flagged in compliance scan",
|
| 557 |
+
"requester": "security@clientcorp.com",
|
| 558 |
+
"description": "Our compliance scan flagged a product-specific vulnerability in the application layer. This is not a general security policy issue but an app bug requiring the application team to remediate.",
|
| 559 |
+
"issue_type": "security_compliance",
|
| 560 |
+
"priority": "high",
|
| 561 |
+
"assignment_group": "application_team",
|
| 562 |
+
"resolution_action": "escalate",
|
| 563 |
+
"ambiguity_note": "Compliance issue is product-specific (app vulnerability), routed to app team",
|
| 564 |
+
"related_ticket_id": null
|
| 565 |
+
},
|
| 566 |
+
{
|
| 567 |
+
"ticket_id": "TKT-NONDEFAULT-003",
|
| 568 |
+
"title": "Contractor onboarding blocked by access issue",
|
| 569 |
+
"requester": "pm@contractorco.com",
|
| 570 |
+
"description": "A new contractor cannot complete onboarding because their account access is blocked by a permissions error. The onboarding team cannot resolve access issues; routing to service desk.",
|
| 571 |
+
"issue_type": "onboarding",
|
| 572 |
+
"priority": "medium",
|
| 573 |
+
"assignment_group": "service_desk",
|
| 574 |
+
"resolution_action": "fulfill",
|
| 575 |
+
"ambiguity_note": "Contractor onboarding blocked by access issue, routed to service desk",
|
| 576 |
+
"related_ticket_id": null
|
| 577 |
}
|
| 578 |
]
|
| 579 |
|
gaps.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Gap Analysis — IT Helpdesk Ticket Routing OpenEnv
|
| 2 |
+
|
| 3 |
+
Deep cross-reference of the codebase against every concrete mentor statement from the bootcamp transcript and Discord Q&A.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## GAP 1 — CRITICAL: `inference.py` runs all 3 tasks in one invocation
|
| 8 |
+
|
| 9 |
+
**Mentor (4/1/26, 9:48 PM, confirmed twice):**
|
| 10 |
+
> "inference.py should execute a single task per run and emit exactly one [START] … [END] block. The evaluation system handles running across multiple tasks, so batching all tasks in one invocation is not expected."
|
| 11 |
+
|
| 12 |
+
**Your code in `inference.py`:**
|
| 13 |
+
```python
|
| 14 |
+
TASKS = list(TASK_IDS) # [1, 2, 3]
|
| 15 |
+
for task_id in TASKS: # loops all 3
|
| 16 |
+
emit_log("START", ...)
|
| 17 |
+
...
|
| 18 |
+
emit_log("END", ...)
|
| 19 |
+
emit_log("END", overall_avg=...) # second END
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
The evaluator calls `inference.py` once per task. Your script ignores that and runs all 3 itself, emitting 3 `[START]`/`[END]` pairs. The evaluator expects exactly one. There is no `TASK_ID` env var read anywhere.
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## GAP 2 — CRITICAL: `state()` response is missing `reward` and `done` fields
|
| 27 |
+
|
| 28 |
+
**Mentor (4/1/26, 9:33 PM):**
|
| 29 |
+
> "state() must return minimum: `{ 'observation': ..., 'reward': last_step_reward, 'done': True/False }`"
|
| 30 |
+
|
| 31 |
+
**Your `HelpdeskTicketState` model:**
|
| 32 |
+
```python
|
| 33 |
+
class HelpdeskTicketState(State):
|
| 34 |
+
current_task_id: Optional[int] = None
|
| 35 |
+
seed: Optional[int] = None
|
| 36 |
+
queue_ticket_ids: list[str]
|
| 37 |
+
current_ticket_index: int = 0
|
| 38 |
+
per_ticket_scores: list[float]
|
| 39 |
+
total_reward: float = 0.0
|
| 40 |
+
# NO reward field (last step reward)
|
| 41 |
+
# NO done field
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
`GET /state` returns this model directly. The evaluator checking `state()` for `reward` and `done` will find neither. `total_reward` is the accumulated reward, not the last step reward — which the mentor explicitly said NOT to return.
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## GAP 3 — MEDIUM: `history` in observation is too sparse for RL usefulness
|
| 49 |
+
|
| 50 |
+
**Ben (YouTube bootcamp, ~00:31:07):**
|
| 51 |
+
> "process supervision... give these more detailed rewards... enrich history with ticket title, predicted fields"
|
| 52 |
+
|
| 53 |
+
**Your `_build_observation` history:**
|
| 54 |
+
```python
|
| 55 |
+
history.append({"step": i + 1, "score": s})
|
| 56 |
+
# final entry gets: {"step": N, "ticket_id": ..., "score": ..., "breakdown": ...}
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
Non-final history entries only have `step` and `score`. No ticket title, no predicted action fields. The agent cannot learn from history because it cannot see what it predicted or what the ticket was. This directly weakens RL signal quality.
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## GAP 4 — MEDIUM: No milestone/delta reward shaping — flat score passthrough
|
| 64 |
+
|
| 65 |
+
**Mentor (4/1/26, 9:34 PM):**
|
| 66 |
+
> "A deterministic terminal grader with partial credit is valid, but it's better to include some intermediate (non-terminal) reward signals as well so the environment provides step-wise feedback. Milestone-based shaping is preferred over dense per-action rewards."
|
| 67 |
+
|
| 68 |
+
**Your `step()` in `environment.py`:**
|
| 69 |
+
```python
|
| 70 |
+
if is_done:
|
| 71 |
+
final_reward = traj_reward # trajectory reward only at end
|
| 72 |
+
else:
|
| 73 |
+
final_reward = step_reward # per-ticket score for non-final steps
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
You do return `step_reward` on non-final steps, which is correct. But `step_reward` is just `compute_step_reward(score)` which is `max(0.0, min(1.0, score))` — identical to the raw score. There is no shaping, no milestone signal, no delta-based signal. This is a quality gap, not a blocker.
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## GAP 5 — MEDIUM: `observation.history` doesn't include the predicted action
|
| 81 |
+
|
| 82 |
+
**Your `_build_observation`:**
|
| 83 |
+
```python
|
| 84 |
+
history_entry = {
|
| 85 |
+
"ticket_id": current_ticket.ticket_id,
|
| 86 |
+
"score": score,
|
| 87 |
+
"breakdown": breakdown,
|
| 88 |
+
}
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
The agent's own predicted action is never stored in history. When the agent looks at history to decide its next action, it cannot see what it previously predicted. This is a real RL signal gap — the agent has no memory of its own decisions.
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
## GAP 6 — LOW: `tickets_remaining` semantics slightly ambiguous
|
| 96 |
+
|
| 97 |
+
**Your `_build_observation`:**
|
| 98 |
+
```python
|
| 99 |
+
tickets_remaining=max(0, queue_size - idx),
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
`idx` is `current_ticket_index` which has already been incremented by `step()` before `_build_observation` is called. During the episode, `tickets_remaining` counts the current ticket as "remaining" even though it is being processed. Minor but could confuse an LLM agent reading the observation.
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## GAP 7 — LOW: `openenv.yaml` `entry_point` vs `pyproject.toml` `server` script mismatch
|
| 107 |
+
|
| 108 |
+
**Mentor (3/31/26, 11:27 PM):**
|
| 109 |
+
> "The validator is checking for a specific callable entrypoint. In some setups, it expects a main() function instead of an app object."
|
| 110 |
+
|
| 111 |
+
**Your `pyproject.toml`:**
|
| 112 |
+
```toml
|
| 113 |
+
[project.scripts]
|
| 114 |
+
server = "server.app:main"
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
**Your `openenv.yaml`:**
|
| 118 |
+
```yaml
|
| 119 |
+
entry_point: server.environment:HelpdeskTicketRoutingEnvironment
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
These point to different things. The validator may check `entry_point` in `openenv.yaml` and expect it to match `[project.scripts] server`. This inconsistency could cause validation confusion.
|
| 123 |
+
|
| 124 |
+
---
|
| 125 |
+
|
| 126 |
+
## GAP 8 — LOW: No `/web` UI endpoint — blank HF Space page
|
| 127 |
+
|
| 128 |
+
**Ben (YouTube, ~00:45:08):**
|
| 129 |
+
> "They're small apps and they're based as spaces. So they're deployed with a UI and an API."
|
| 130 |
+
|
| 131 |
+
The echo env example had `/web` for the UI. Your app has no `/web` route. The mentor said UI is optional and not scored, but the HF Space will show a blank page with no UI, which looks unpolished to judges doing Phase 3 human review.
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## Summary
|
| 136 |
+
|
| 137 |
+
| # | Gap | Severity | File(s) |
|
| 138 |
+
|---|-----|----------|---------|
|
| 139 |
+
| 1 | `inference.py` runs all 3 tasks, evaluator expects 1 per run | CRITICAL | `inference.py` |
|
| 140 |
+
| 2 | `GET /state` missing `reward` (last step) and `done` fields | CRITICAL | `models.py`, `environment.py` |
|
| 141 |
+
| 3 | `history` missing predicted action — agent has no memory of decisions | MEDIUM | `environment.py` |
|
| 142 |
+
| 4 | No milestone/delta reward shaping — flat score passthrough | MEDIUM | `reward.py` |
|
| 143 |
+
| 5 | `history` non-final entries missing ticket title | MEDIUM | `environment.py` |
|
| 144 |
+
| 6 | `tickets_remaining` semantics slightly ambiguous | LOW | `environment.py` |
|
| 145 |
+
| 7 | `openenv.yaml` `entry_point` vs `pyproject.toml` `server` script mismatch | LOW | `openenv.yaml`, `pyproject.toml` |
|
| 146 |
+
| 8 | No `/web` UI — blank HF Space page | LOW | `server/app.py` |
|
inference.py
CHANGED
|
@@ -20,6 +20,15 @@ HF_TOKEN
|
|
| 20 |
HuggingFace authentication token for the LLM provider.
|
| 21 |
No default is set.
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
LOCAL_IMAGE_NAME
|
| 24 |
Optional compatibility variable from the sample inference pattern.
|
| 25 |
This script does not use ``from_docker_image()``, so the value is unused here.
|
|
@@ -64,7 +73,12 @@ LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
|
|
| 64 |
ENV_URL = os.getenv("ENV_URL", "http://localhost:7860")
|
| 65 |
|
| 66 |
SEED = 42
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
# ---------------------------------------------------------------------------
|
| 70 |
# LLM helper
|
|
@@ -99,13 +113,36 @@ Return ONLY valid JSON with the requested fields. No markdown, no explanation.""
|
|
| 99 |
|
| 100 |
def call_llm(ticket: dict, allowed_fields: list[str], instructions: str) -> dict:
|
| 101 |
assert llm_client is not None, "LLM client not configured"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
user_msg = (
|
| 104 |
f"Instructions: {instructions}\n\n"
|
| 105 |
f"Allowed fields: {', '.join(allowed_fields)}\n\n"
|
| 106 |
f"Title: {ticket['title']}\n"
|
| 107 |
f"Requester: {ticket['requester']}\n"
|
| 108 |
-
f"Description: {ticket['description']}
|
|
|
|
| 109 |
f"Respond with JSON containing ONLY these fields: {', '.join(allowed_fields)}"
|
| 110 |
)
|
| 111 |
|
|
@@ -134,6 +171,29 @@ def emit_log(tag: str, **payload: Any) -> None:
|
|
| 134 |
print(f"[{tag}] {json.dumps(payload, sort_keys=True, ensure_ascii=True)}")
|
| 135 |
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
# ---------------------------------------------------------------------------
|
| 138 |
# Heuristic fallback (no LLM needed)
|
| 139 |
# ---------------------------------------------------------------------------
|
|
@@ -264,7 +324,18 @@ def heuristic_resolution_action(text: str, issue_type: str) -> str:
|
|
| 264 |
|
| 265 |
|
| 266 |
def heuristic_action(ticket: dict, allowed_fields: list[str]) -> dict:
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
issue_type = "general_inquiry"
|
| 270 |
for kw, mapped_issue_type in KEYWORD_ISSUE_TYPES.items():
|
|
@@ -315,6 +386,31 @@ def build_action(
|
|
| 315 |
)
|
| 316 |
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
# ---------------------------------------------------------------------------
|
| 319 |
# Main loop using the HTTP-based sync EnvClient for multi-step episodes
|
| 320 |
# ---------------------------------------------------------------------------
|
|
@@ -332,7 +428,12 @@ def run() -> None:
|
|
| 332 |
|
| 333 |
all_results: dict[int, dict[str, float | int]] = {}
|
| 334 |
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
if task_id not in available_tasks:
|
| 337 |
continue
|
| 338 |
|
|
@@ -360,8 +461,40 @@ def run() -> None:
|
|
| 360 |
if ticket is None:
|
| 361 |
break
|
| 362 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
action, action_source, fallback_reason = build_action(
|
| 364 |
-
|
| 365 |
obs.allowed_fields,
|
| 366 |
obs.instructions,
|
| 367 |
)
|
|
@@ -400,11 +533,12 @@ def run() -> None:
|
|
| 400 |
|
| 401 |
overall = [
|
| 402 |
float(all_results[task_id]["final_reward"])
|
| 403 |
-
for task_id in
|
| 404 |
if task_id in all_results
|
| 405 |
]
|
| 406 |
-
|
| 407 |
-
|
|
|
|
| 408 |
|
| 409 |
|
| 410 |
if __name__ == "__main__":
|
|
|
|
| 20 |
HuggingFace authentication token for the LLM provider.
|
| 21 |
No default is set.
|
| 22 |
|
| 23 |
+
TASK_ID
|
| 24 |
+
Optional OpenEnv task ID to run. When unset, the script defaults to the
|
| 25 |
+
first available task so it still emits exactly one ``[START]`` ... ``[END]``
|
| 26 |
+
block for evaluator-style runs.
|
| 27 |
+
|
| 28 |
+
RUN_ALL_TASKS
|
| 29 |
+
Optional local-development override. Set to ``1`` to run every available
|
| 30 |
+
task in sequence and print the aggregate closing ``[END]`` summary.
|
| 31 |
+
|
| 32 |
LOCAL_IMAGE_NAME
|
| 33 |
Optional compatibility variable from the sample inference pattern.
|
| 34 |
This script does not use ``from_docker_image()``, so the value is unused here.
|
|
|
|
| 73 |
ENV_URL = os.getenv("ENV_URL", "http://localhost:7860")
|
| 74 |
|
| 75 |
SEED = 42
|
| 76 |
+
TASK_ID_ENV = os.getenv("TASK_ID")
|
| 77 |
+
RUN_ALL_TASKS_ENV = os.getenv("RUN_ALL_TASKS", "").strip().lower() in {
|
| 78 |
+
"1",
|
| 79 |
+
"true",
|
| 80 |
+
"yes",
|
| 81 |
+
}
|
| 82 |
|
| 83 |
# ---------------------------------------------------------------------------
|
| 84 |
# LLM helper
|
|
|
|
| 113 |
|
| 114 |
def call_llm(ticket: dict, allowed_fields: list[str], instructions: str) -> dict:
|
| 115 |
assert llm_client is not None, "LLM client not configured"
|
| 116 |
+
ambiguity_note = ticket.get("ambiguity_note")
|
| 117 |
+
related_preview = ticket.get("related_ticket_preview") or {}
|
| 118 |
+
last_tool_result = ticket.get("last_tool_result")
|
| 119 |
+
extra_context_lines: list[str] = []
|
| 120 |
+
if ambiguity_note:
|
| 121 |
+
extra_context_lines.append(f"Ambiguity note: {ambiguity_note}")
|
| 122 |
+
if related_preview:
|
| 123 |
+
extra_context_lines.extend(
|
| 124 |
+
[
|
| 125 |
+
"Related ticket preview:",
|
| 126 |
+
f"- Title: {related_preview.get('title', '')}",
|
| 127 |
+
f"- Requester: {related_preview.get('requester', '')}",
|
| 128 |
+
f"- Description: {related_preview.get('description', '')}",
|
| 129 |
+
]
|
| 130 |
+
)
|
| 131 |
+
if last_tool_result is not None:
|
| 132 |
+
extra_context_lines.append(
|
| 133 |
+
"Investigation result: " + json.dumps(last_tool_result, sort_keys=True)
|
| 134 |
+
)
|
| 135 |
+
extra_context_block = ""
|
| 136 |
+
if extra_context_lines:
|
| 137 |
+
extra_context_block = "\n" + "\n".join(extra_context_lines)
|
| 138 |
|
| 139 |
user_msg = (
|
| 140 |
f"Instructions: {instructions}\n\n"
|
| 141 |
f"Allowed fields: {', '.join(allowed_fields)}\n\n"
|
| 142 |
f"Title: {ticket['title']}\n"
|
| 143 |
f"Requester: {ticket['requester']}\n"
|
| 144 |
+
f"Description: {ticket['description']}"
|
| 145 |
+
f"{extra_context_block}\n\n"
|
| 146 |
f"Respond with JSON containing ONLY these fields: {', '.join(allowed_fields)}"
|
| 147 |
)
|
| 148 |
|
|
|
|
| 171 |
print(f"[{tag}] {json.dumps(payload, sort_keys=True, ensure_ascii=True)}")
|
| 172 |
|
| 173 |
|
| 174 |
+
def get_tasks_to_run(available_tasks: dict) -> list[int]:
|
| 175 |
+
available_task_ids = sorted(int(task_id) for task_id in available_tasks)
|
| 176 |
+
if TASK_ID_ENV:
|
| 177 |
+
try:
|
| 178 |
+
task_id = int(TASK_ID_ENV)
|
| 179 |
+
except ValueError:
|
| 180 |
+
print(f"[ERROR] TASK_ID={TASK_ID_ENV!r} is not a valid integer", flush=True)
|
| 181 |
+
raise SystemExit(1)
|
| 182 |
+
if task_id not in available_task_ids:
|
| 183 |
+
print(
|
| 184 |
+
f"[ERROR] TASK_ID={task_id} not in available tasks {available_task_ids}",
|
| 185 |
+
flush=True,
|
| 186 |
+
)
|
| 187 |
+
raise SystemExit(1)
|
| 188 |
+
return [task_id]
|
| 189 |
+
if RUN_ALL_TASKS_ENV:
|
| 190 |
+
return available_task_ids
|
| 191 |
+
if not available_task_ids:
|
| 192 |
+
return []
|
| 193 |
+
# Default to a single task so evaluation emits exactly one START/END block.
|
| 194 |
+
return [available_task_ids[0]]
|
| 195 |
+
|
| 196 |
+
|
| 197 |
# ---------------------------------------------------------------------------
|
| 198 |
# Heuristic fallback (no LLM needed)
|
| 199 |
# ---------------------------------------------------------------------------
|
|
|
|
| 324 |
|
| 325 |
|
| 326 |
def heuristic_action(ticket: dict, allowed_fields: list[str]) -> dict:
|
| 327 |
+
related_preview = ticket.get("related_ticket_preview") or {}
|
| 328 |
+
last_tool_result = ticket.get("last_tool_result") or {}
|
| 329 |
+
text = " ".join(
|
| 330 |
+
[
|
| 331 |
+
ticket.get("title", ""),
|
| 332 |
+
ticket.get("description", ""),
|
| 333 |
+
ticket.get("ambiguity_note", ""),
|
| 334 |
+
related_preview.get("title", ""),
|
| 335 |
+
related_preview.get("description", ""),
|
| 336 |
+
json.dumps(last_tool_result, sort_keys=True),
|
| 337 |
+
]
|
| 338 |
+
).lower()
|
| 339 |
|
| 340 |
issue_type = "general_inquiry"
|
| 341 |
for kw, mapped_issue_type in KEYWORD_ISSUE_TYPES.items():
|
|
|
|
| 386 |
)
|
| 387 |
|
| 388 |
|
| 389 |
+
def should_investigate(ticket: dict, history: list[dict[str, Any]]) -> tuple[bool, str | None]:
|
| 390 |
+
if not ticket:
|
| 391 |
+
return False, None
|
| 392 |
+
current_ticket_id = ticket.get("ticket_id")
|
| 393 |
+
already_investigated = any(
|
| 394 |
+
entry.get("ticket_id") == current_ticket_id
|
| 395 |
+
and entry.get("predicted", {}).get("action_type") == "investigate"
|
| 396 |
+
for entry in history
|
| 397 |
+
)
|
| 398 |
+
if already_investigated:
|
| 399 |
+
return False, None
|
| 400 |
+
if ticket.get("related_ticket_id"):
|
| 401 |
+
return True, "lookup_related_ticket"
|
| 402 |
+
if ticket.get("ambiguity_note"):
|
| 403 |
+
return True, "lookup_requester_history"
|
| 404 |
+
return False, None
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def merge_ticket_context(ticket: dict, observation: Any) -> dict:
|
| 408 |
+
merged_ticket = dict(ticket)
|
| 409 |
+
if getattr(observation, "last_tool_result", None) is not None:
|
| 410 |
+
merged_ticket["last_tool_result"] = observation.last_tool_result
|
| 411 |
+
return merged_ticket
|
| 412 |
+
|
| 413 |
+
|
| 414 |
# ---------------------------------------------------------------------------
|
| 415 |
# Main loop using the HTTP-based sync EnvClient for multi-step episodes
|
| 416 |
# ---------------------------------------------------------------------------
|
|
|
|
| 428 |
|
| 429 |
all_results: dict[int, dict[str, float | int]] = {}
|
| 430 |
|
| 431 |
+
tasks_to_run = get_tasks_to_run(available_tasks)
|
| 432 |
+
if not tasks_to_run:
|
| 433 |
+
return
|
| 434 |
+
single_task_mode = len(tasks_to_run) == 1
|
| 435 |
+
|
| 436 |
+
for task_id in tasks_to_run:
|
| 437 |
if task_id not in available_tasks:
|
| 438 |
continue
|
| 439 |
|
|
|
|
| 461 |
if ticket is None:
|
| 462 |
break
|
| 463 |
|
| 464 |
+
investigate, tool_name = should_investigate(ticket, obs.history)
|
| 465 |
+
if (
|
| 466 |
+
investigate
|
| 467 |
+
and tool_name is not None
|
| 468 |
+
and getattr(obs, "investigation_budget_remaining", 0) > 0
|
| 469 |
+
):
|
| 470 |
+
tool_action = HelpdeskTicketAction(
|
| 471 |
+
action_type="investigate",
|
| 472 |
+
tool_name=tool_name,
|
| 473 |
+
tool_target_ticket_id=ticket.get("related_ticket_id"),
|
| 474 |
+
)
|
| 475 |
+
result = sync_client.step(tool_action)
|
| 476 |
+
obs = result.observation
|
| 477 |
+
step_num += 1
|
| 478 |
+
emit_log(
|
| 479 |
+
"STEP",
|
| 480 |
+
action=tool_action.model_dump(exclude_none=True),
|
| 481 |
+
action_source="investigation_tool",
|
| 482 |
+
done=bool(result.done),
|
| 483 |
+
fallback_reason=None,
|
| 484 |
+
reward=float(result.reward or 0.0),
|
| 485 |
+
step=step_num,
|
| 486 |
+
task_id=task_id,
|
| 487 |
+
ticket_id=ticket["ticket_id"],
|
| 488 |
+
)
|
| 489 |
+
if result.done:
|
| 490 |
+
break
|
| 491 |
+
ticket = obs.current_ticket
|
| 492 |
+
if ticket is None:
|
| 493 |
+
break
|
| 494 |
+
|
| 495 |
+
ticket_with_context = merge_ticket_context(ticket, obs)
|
| 496 |
action, action_source, fallback_reason = build_action(
|
| 497 |
+
ticket_with_context,
|
| 498 |
obs.allowed_fields,
|
| 499 |
obs.instructions,
|
| 500 |
)
|
|
|
|
| 533 |
|
| 534 |
overall = [
|
| 535 |
float(all_results[task_id]["final_reward"])
|
| 536 |
+
for task_id in tasks_to_run
|
| 537 |
if task_id in all_results
|
| 538 |
]
|
| 539 |
+
if not single_task_mode:
|
| 540 |
+
overall_avg = round(sum(overall) / len(overall), 4) if overall else 0.0
|
| 541 |
+
emit_log("END", overall_avg=overall_avg, tasks_completed=len(overall))
|
| 542 |
|
| 543 |
|
| 544 |
if __name__ == "__main__":
|
models.py
CHANGED
|
@@ -16,6 +16,8 @@ ISSUE_TYPE_SET = set(ISSUE_TYPES)
|
|
| 16 |
PRIORITY_SET = set(PRIORITIES)
|
| 17 |
ASSIGNMENT_GROUP_SET = set(ASSIGNMENT_GROUPS)
|
| 18 |
RESOLUTION_ACTION_SET = set(RESOLUTION_ACTIONS)
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def _validate_choice(value: str, allowed: set[str], field_name: str) -> str:
|
|
@@ -67,11 +69,24 @@ class HelpdeskTicketRecord(BaseModel):
|
|
| 67 |
|
| 68 |
|
| 69 |
class HelpdeskTicketAction(Action):
|
|
|
|
|
|
|
|
|
|
| 70 |
issue_type: Optional[str] = None
|
| 71 |
priority: Optional[str] = None
|
| 72 |
assignment_group: Optional[str] = None
|
| 73 |
resolution_action: Optional[str] = None
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
@field_validator("issue_type")
|
| 76 |
@classmethod
|
| 77 |
def validate_issue_type(cls, value: Optional[str]) -> Optional[str]:
|
|
@@ -98,10 +113,15 @@ class HelpdeskTicketObservation(Observation):
|
|
| 98 |
task_name: str = ""
|
| 99 |
instructions: str = ""
|
| 100 |
allowed_fields: list[str] = Field(default_factory=list)
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
| 102 |
queue_size: int = 0
|
| 103 |
tickets_remaining: int = 0
|
|
|
|
| 104 |
tickets_processed: int = 0
|
|
|
|
| 105 |
history: list[dict[str, Any]] = Field(default_factory=list)
|
| 106 |
|
| 107 |
|
|
@@ -112,3 +132,11 @@ class HelpdeskTicketState(State):
|
|
| 112 |
current_ticket_index: int = 0
|
| 113 |
per_ticket_scores: list[float] = Field(default_factory=list)
|
| 114 |
total_reward: float = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
PRIORITY_SET = set(PRIORITIES)
|
| 17 |
ASSIGNMENT_GROUP_SET = set(ASSIGNMENT_GROUPS)
|
| 18 |
RESOLUTION_ACTION_SET = set(RESOLUTION_ACTIONS)
|
| 19 |
+
ACTION_TYPE_SET = {"submit", "investigate"}
|
| 20 |
+
TOOL_NAME_SET = {"lookup_related_ticket", "lookup_requester_history"}
|
| 21 |
|
| 22 |
|
| 23 |
def _validate_choice(value: str, allowed: set[str], field_name: str) -> str:
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
class HelpdeskTicketAction(Action):
|
| 72 |
+
action_type: str = "submit"
|
| 73 |
+
tool_name: Optional[str] = None
|
| 74 |
+
tool_target_ticket_id: Optional[str] = None
|
| 75 |
issue_type: Optional[str] = None
|
| 76 |
priority: Optional[str] = None
|
| 77 |
assignment_group: Optional[str] = None
|
| 78 |
resolution_action: Optional[str] = None
|
| 79 |
|
| 80 |
+
@field_validator("action_type")
|
| 81 |
+
@classmethod
|
| 82 |
+
def validate_action_type(cls, value: str) -> str:
|
| 83 |
+
return _validate_choice(value, ACTION_TYPE_SET, "action_type")
|
| 84 |
+
|
| 85 |
+
@field_validator("tool_name")
|
| 86 |
+
@classmethod
|
| 87 |
+
def validate_tool_name(cls, value: Optional[str]) -> Optional[str]:
|
| 88 |
+
return _validate_optional_choice(value, TOOL_NAME_SET, "tool_name")
|
| 89 |
+
|
| 90 |
@field_validator("issue_type")
|
| 91 |
@classmethod
|
| 92 |
def validate_issue_type(cls, value: Optional[str]) -> Optional[str]:
|
|
|
|
| 113 |
task_name: str = ""
|
| 114 |
instructions: str = ""
|
| 115 |
allowed_fields: list[str] = Field(default_factory=list)
|
| 116 |
+
available_tools: list[str] = Field(default_factory=list)
|
| 117 |
+
investigation_budget_remaining: int = 0
|
| 118 |
+
last_tool_result: Optional[dict[str, Any]] = None
|
| 119 |
+
current_ticket: Optional[dict[str, Any]] = None
|
| 120 |
queue_size: int = 0
|
| 121 |
tickets_remaining: int = 0
|
| 122 |
+
tickets_after_current: int = 0
|
| 123 |
tickets_processed: int = 0
|
| 124 |
+
queue_position: int = 0
|
| 125 |
history: list[dict[str, Any]] = Field(default_factory=list)
|
| 126 |
|
| 127 |
|
|
|
|
| 132 |
current_ticket_index: int = 0
|
| 133 |
per_ticket_scores: list[float] = Field(default_factory=list)
|
| 134 |
total_reward: float = 0.0
|
| 135 |
+
last_step_reward: Optional[float] = None
|
| 136 |
+
# `reward` is the field the evaluator checks on GET /state (mentor spec)
|
| 137 |
+
reward: Optional[float] = None
|
| 138 |
+
done: bool = False
|
| 139 |
+
investigation_steps: int = 0
|
| 140 |
+
investigation_budget_remaining: int = 0
|
| 141 |
+
last_tool_result: Optional[dict[str, Any]] = None
|
| 142 |
+
history_entries: list[dict] = Field(default_factory=list)
|
openenv.yaml
CHANGED
|
@@ -7,6 +7,9 @@ author: Hackstreet Boys - Roopal Guha Neogi, Suyash Kumar
|
|
| 7 |
|
| 8 |
environment:
|
| 9 |
type: openenv
|
|
|
|
|
|
|
|
|
|
| 10 |
entry_point: server.environment:HelpdeskTicketRoutingEnvironment
|
| 11 |
action_model: models:HelpdeskTicketAction
|
| 12 |
observation_model: models:HelpdeskTicketObservation
|
|
@@ -50,6 +53,7 @@ inference:
|
|
| 50 |
- MODEL_NAME
|
| 51 |
- HF_TOKEN
|
| 52 |
- ENV_URL
|
|
|
|
| 53 |
|
| 54 |
requirements:
|
| 55 |
python: ">=3.11"
|
|
|
|
| 7 |
|
| 8 |
environment:
|
| 9 |
type: openenv
|
| 10 |
+
# entry_point identifies the Environment class for the OpenEnv validator.
|
| 11 |
+
# The HTTP server entrypoint for deployment is defined separately in
|
| 12 |
+
# pyproject.toml under [project.scripts] as: server = "server.app:main"
|
| 13 |
entry_point: server.environment:HelpdeskTicketRoutingEnvironment
|
| 14 |
action_model: models:HelpdeskTicketAction
|
| 15 |
observation_model: models:HelpdeskTicketObservation
|
|
|
|
| 53 |
- MODEL_NAME
|
| 54 |
- HF_TOKEN
|
| 55 |
- ENV_URL
|
| 56 |
+
- TASK_ID
|
| 57 |
|
| 58 |
requirements:
|
| 59 |
python: ">=3.11"
|
server/app.py
CHANGED
|
@@ -6,6 +6,7 @@ _repo_root = str(Path(__file__).resolve().parent.parent)
|
|
| 6 |
if _repo_root not in sys.path:
|
| 7 |
sys.path.insert(0, _repo_root)
|
| 8 |
|
|
|
|
| 9 |
from openenv.core.env_server import create_app
|
| 10 |
|
| 11 |
from models import HelpdeskTicketAction, HelpdeskTicketObservation
|
|
@@ -37,6 +38,25 @@ def list_tasks():
|
|
| 37 |
}
|
| 38 |
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
def main() -> None:
|
| 41 |
import uvicorn
|
| 42 |
|
|
|
|
| 6 |
if _repo_root not in sys.path:
|
| 7 |
sys.path.insert(0, _repo_root)
|
| 8 |
|
| 9 |
+
from fastapi.responses import HTMLResponse
|
| 10 |
from openenv.core.env_server import create_app
|
| 11 |
|
| 12 |
from models import HelpdeskTicketAction, HelpdeskTicketObservation
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
|
| 41 |
+
@app.get("/web", response_class=HTMLResponse)
|
| 42 |
+
def web_ui():
|
| 43 |
+
task_rows = "".join(
|
| 44 |
+
f"<tr><td>{t['id']}</td><td>{t['name']}</td><td>{t['difficulty']}</td></tr>"
|
| 45 |
+
for t in TASKS.values()
|
| 46 |
+
)
|
| 47 |
+
html = f"""<!DOCTYPE html>
|
| 48 |
+
<html><head><title>{APP_ENV_NAME}</title></head>
|
| 49 |
+
<body>
|
| 50 |
+
<h1>{APP_ENV_NAME}</h1>
|
| 51 |
+
<p>Version: 0.1.0 | <a href="/health">Health</a> | <a href="/docs">API Docs</a></p>
|
| 52 |
+
<h2>Tasks</h2>
|
| 53 |
+
<table border="1"><tr><th>ID</th><th>Name</th><th>Difficulty</th></tr>
|
| 54 |
+
{task_rows}
|
| 55 |
+
</table>
|
| 56 |
+
</body></html>"""
|
| 57 |
+
return HTMLResponse(content=html)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
def main() -> None:
|
| 61 |
import uvicorn
|
| 62 |
|
server/environment.py
CHANGED
|
@@ -18,6 +18,10 @@ from server.tasks import get_task_definition, load_dataset
|
|
| 18 |
|
| 19 |
|
| 20 |
QUEUE_SIZE_RANGE = (3, 5)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def _coerce_optional_int(value: Any, field_name: str) -> Optional[int]:
|
|
@@ -36,9 +40,12 @@ def _coerce_optional_int(value: Any, field_name: str) -> Optional[int]:
|
|
| 36 |
class HelpdeskTicketRoutingEnvironment(
|
| 37 |
Environment[HelpdeskTicketAction, HelpdeskTicketObservation, HelpdeskTicketState]
|
| 38 |
):
|
|
|
|
|
|
|
| 39 |
def __init__(self) -> None:
|
| 40 |
super().__init__()
|
| 41 |
self._dataset = load_dataset()
|
|
|
|
| 42 |
self._rng = random.Random()
|
| 43 |
self._queue: list[HelpdeskTicketRecord] = []
|
| 44 |
self._state = HelpdeskTicketState()
|
|
@@ -55,13 +62,19 @@ class HelpdeskTicketRoutingEnvironment(
|
|
| 55 |
) -> HelpdeskTicketObservation:
|
| 56 |
normalized_seed = _coerce_optional_int(seed, "seed")
|
| 57 |
task_id_value = _coerce_optional_int(kwargs.get("task_id", 1), "task_id")
|
|
|
|
| 58 |
task_id = 1 if task_id_value is None else task_id_value
|
| 59 |
task = get_task_definition(task_id)
|
|
|
|
|
|
|
| 60 |
|
| 61 |
if normalized_seed is not None:
|
| 62 |
self._rng.seed(normalized_seed)
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
| 65 |
self._queue = self._rng.sample(self._dataset, min(queue_size, len(self._dataset)))
|
| 66 |
|
| 67 |
self._state = HelpdeskTicketState(
|
|
@@ -73,6 +86,7 @@ class HelpdeskTicketRoutingEnvironment(
|
|
| 73 |
current_ticket_index=0,
|
| 74 |
per_ticket_scores=[],
|
| 75 |
total_reward=0.0,
|
|
|
|
| 76 |
)
|
| 77 |
|
| 78 |
return self._build_observation(task)
|
|
@@ -94,38 +108,84 @@ class HelpdeskTicketRoutingEnvironment(
|
|
| 94 |
task_id = self._state.current_task_id
|
| 95 |
task = get_task_definition(task_id)
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
score, breakdown = grade_action(action, current_ticket, task_id)
|
| 98 |
step_reward = compute_step_reward(score)
|
| 99 |
|
| 100 |
-
self._state.
|
| 101 |
-
self._state.step_count += 1
|
| 102 |
-
self._state.current_ticket_index += 1
|
| 103 |
-
|
| 104 |
-
is_done = self._state.current_ticket_index >= len(self._queue)
|
| 105 |
|
| 106 |
if is_done:
|
|
|
|
|
|
|
|
|
|
| 107 |
traj_reward = compute_trajectory_reward(
|
| 108 |
self._state.per_ticket_scores,
|
| 109 |
len(self._queue),
|
| 110 |
self._state.step_count,
|
| 111 |
)
|
| 112 |
-
|
| 113 |
-
|
| 114 |
else:
|
|
|
|
|
|
|
|
|
|
| 115 |
final_reward = step_reward
|
| 116 |
|
| 117 |
-
history_entry =
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
return self._build_observation(
|
| 124 |
-
task,
|
| 125 |
-
done=is_done,
|
| 126 |
-
reward=final_reward,
|
| 127 |
-
extra_history=history_entry,
|
| 128 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
@property
|
| 131 |
def state(self) -> HelpdeskTicketState:
|
|
@@ -135,44 +195,236 @@ class HelpdeskTicketRoutingEnvironment(
|
|
| 135 |
# Helpers
|
| 136 |
# ------------------------------------------------------------------
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
def _build_observation(
|
| 139 |
self,
|
| 140 |
task: dict,
|
| 141 |
done: bool = False,
|
| 142 |
reward: float | None = None,
|
| 143 |
-
extra_history: dict | None = None,
|
| 144 |
) -> HelpdeskTicketObservation:
|
| 145 |
idx = self._state.current_ticket_index
|
| 146 |
queue_size = len(self._queue)
|
| 147 |
|
| 148 |
if idx < queue_size:
|
| 149 |
ticket = self._queue[idx]
|
| 150 |
-
ticket_view =
|
| 151 |
-
|
| 152 |
-
"title": ticket.title,
|
| 153 |
-
"requester": ticket.requester,
|
| 154 |
-
"description": ticket.description,
|
| 155 |
-
}
|
| 156 |
else:
|
| 157 |
ticket_view = None
|
|
|
|
| 158 |
|
| 159 |
-
history
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
| 164 |
|
| 165 |
return HelpdeskTicketObservation(
|
| 166 |
done=done,
|
| 167 |
reward=reward,
|
| 168 |
-
metadata={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
task_id=task["id"],
|
| 170 |
task_name=task["name"],
|
| 171 |
instructions=task["instructions"],
|
| 172 |
allowed_fields=list(task["allowed_fields"]),
|
|
|
|
|
|
|
|
|
|
| 173 |
current_ticket=ticket_view,
|
| 174 |
queue_size=queue_size,
|
| 175 |
-
tickets_remaining=
|
|
|
|
| 176 |
tickets_processed=idx,
|
|
|
|
| 177 |
history=history,
|
| 178 |
)
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
QUEUE_SIZE_RANGE = (3, 5)
|
| 21 |
+
AVAILABLE_TOOLS = ("lookup_related_ticket", "lookup_requester_history")
|
| 22 |
+
FREE_INVESTIGATIONS_PER_TICKET = 1
|
| 23 |
+
EXTRA_INVESTIGATION_COST = 0.02
|
| 24 |
+
MAX_EXTRA_INVESTIGATION_PENALTY = 0.15
|
| 25 |
|
| 26 |
|
| 27 |
def _coerce_optional_int(value: Any, field_name: str) -> Optional[int]:
|
|
|
|
| 40 |
class HelpdeskTicketRoutingEnvironment(
|
| 41 |
Environment[HelpdeskTicketAction, HelpdeskTicketObservation, HelpdeskTicketState]
|
| 42 |
):
|
| 43 |
+
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 44 |
+
|
| 45 |
def __init__(self) -> None:
|
| 46 |
super().__init__()
|
| 47 |
self._dataset = load_dataset()
|
| 48 |
+
self._tickets_by_id = {ticket.ticket_id: ticket for ticket in self._dataset}
|
| 49 |
self._rng = random.Random()
|
| 50 |
self._queue: list[HelpdeskTicketRecord] = []
|
| 51 |
self._state = HelpdeskTicketState()
|
|
|
|
| 62 |
) -> HelpdeskTicketObservation:
|
| 63 |
normalized_seed = _coerce_optional_int(seed, "seed")
|
| 64 |
task_id_value = _coerce_optional_int(kwargs.get("task_id", 1), "task_id")
|
| 65 |
+
queue_size_value = _coerce_optional_int(kwargs.get("queue_size"), "queue_size")
|
| 66 |
task_id = 1 if task_id_value is None else task_id_value
|
| 67 |
task = get_task_definition(task_id)
|
| 68 |
+
if queue_size_value is not None and queue_size_value < 1:
|
| 69 |
+
raise ValueError("queue_size must be >= 1")
|
| 70 |
|
| 71 |
if normalized_seed is not None:
|
| 72 |
self._rng.seed(normalized_seed)
|
| 73 |
|
| 74 |
+
if queue_size_value is None:
|
| 75 |
+
queue_size = self._rng.randint(*QUEUE_SIZE_RANGE)
|
| 76 |
+
else:
|
| 77 |
+
queue_size = min(queue_size_value, len(self._dataset))
|
| 78 |
self._queue = self._rng.sample(self._dataset, min(queue_size, len(self._dataset)))
|
| 79 |
|
| 80 |
self._state = HelpdeskTicketState(
|
|
|
|
| 86 |
current_ticket_index=0,
|
| 87 |
per_ticket_scores=[],
|
| 88 |
total_reward=0.0,
|
| 89 |
+
investigation_budget_remaining=queue_size * FREE_INVESTIGATIONS_PER_TICKET,
|
| 90 |
)
|
| 91 |
|
| 92 |
return self._build_observation(task)
|
|
|
|
| 108 |
task_id = self._state.current_task_id
|
| 109 |
task = get_task_definition(task_id)
|
| 110 |
|
| 111 |
+
if action.action_type == "investigate":
|
| 112 |
+
return self._handle_investigation_action(task, current_ticket, action, idx)
|
| 113 |
+
|
| 114 |
+
submitted_fields = {
|
| 115 |
+
f
|
| 116 |
+
for f, v in action.model_dump(exclude_none=True).items()
|
| 117 |
+
if v is not None
|
| 118 |
+
and f not in {"action_type", "tool_name", "tool_target_ticket_id"}
|
| 119 |
+
}
|
| 120 |
+
allowed = set(task["allowed_fields"])
|
| 121 |
+
extra_fields = submitted_fields - allowed
|
| 122 |
+
if extra_fields:
|
| 123 |
+
# Penalty: record score 0.0, advance index, return penalty observation
|
| 124 |
+
self._state.per_ticket_scores.append(0.0)
|
| 125 |
+
self._state.history_entries.append(
|
| 126 |
+
self._build_history_entry(
|
| 127 |
+
current_ticket,
|
| 128 |
+
predicted=action.model_dump(exclude_none=True),
|
| 129 |
+
score=0.0,
|
| 130 |
+
breakdown={},
|
| 131 |
+
queue_position=idx + 1,
|
| 132 |
+
penalty_reason=f"extra_fields: {sorted(extra_fields)}",
|
| 133 |
+
)
|
| 134 |
+
)
|
| 135 |
+
self._state.step_count += 1
|
| 136 |
+
self._state.current_ticket_index += 1
|
| 137 |
+
is_done = self._state.current_ticket_index >= len(self._queue)
|
| 138 |
+
self._state.done = is_done
|
| 139 |
+
if is_done:
|
| 140 |
+
traj_reward = compute_trajectory_reward(
|
| 141 |
+
self._state.per_ticket_scores, len(self._queue), self._state.step_count
|
| 142 |
+
)
|
| 143 |
+
final_reward = self._apply_episode_economics(traj_reward)
|
| 144 |
+
self._state.total_reward = final_reward
|
| 145 |
+
else:
|
| 146 |
+
final_reward = 0.0
|
| 147 |
+
self._state.last_step_reward = final_reward
|
| 148 |
+
self._state.reward = final_reward
|
| 149 |
+
self._state.last_tool_result = None
|
| 150 |
+
return self._build_observation(task, done=is_done, reward=final_reward)
|
| 151 |
+
|
| 152 |
score, breakdown = grade_action(action, current_ticket, task_id)
|
| 153 |
step_reward = compute_step_reward(score)
|
| 154 |
|
| 155 |
+
is_done = (self._state.current_ticket_index + 1) >= len(self._queue)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
if is_done:
|
| 158 |
+
self._state.per_ticket_scores.append(score)
|
| 159 |
+
self._state.step_count += 1
|
| 160 |
+
self._state.current_ticket_index += 1
|
| 161 |
traj_reward = compute_trajectory_reward(
|
| 162 |
self._state.per_ticket_scores,
|
| 163 |
len(self._queue),
|
| 164 |
self._state.step_count,
|
| 165 |
)
|
| 166 |
+
final_reward = self._apply_episode_economics(traj_reward)
|
| 167 |
+
self._state.total_reward = final_reward
|
| 168 |
else:
|
| 169 |
+
self._state.per_ticket_scores.append(score)
|
| 170 |
+
self._state.step_count += 1
|
| 171 |
+
self._state.current_ticket_index += 1
|
| 172 |
final_reward = step_reward
|
| 173 |
|
| 174 |
+
history_entry = self._build_history_entry(
|
| 175 |
+
current_ticket,
|
| 176 |
+
predicted=action.model_dump(exclude_none=True),
|
| 177 |
+
score=score,
|
| 178 |
+
breakdown=breakdown,
|
| 179 |
+
queue_position=idx + 1,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
)
|
| 181 |
+
self._state.history_entries.append(history_entry)
|
| 182 |
+
|
| 183 |
+
self._state.last_step_reward = final_reward
|
| 184 |
+
self._state.reward = final_reward
|
| 185 |
+
self._state.done = is_done
|
| 186 |
+
self._state.last_tool_result = None
|
| 187 |
+
|
| 188 |
+
return self._build_observation(task, done=is_done, reward=final_reward)
|
| 189 |
|
| 190 |
@property
|
| 191 |
def state(self) -> HelpdeskTicketState:
|
|
|
|
| 195 |
# Helpers
|
| 196 |
# ------------------------------------------------------------------
|
| 197 |
|
| 198 |
+
def _apply_episode_economics(self, base_reward: float) -> float:
|
| 199 |
+
free_investigations = len(self._queue) * FREE_INVESTIGATIONS_PER_TICKET
|
| 200 |
+
extra_investigations = max(0, self._state.investigation_steps - free_investigations)
|
| 201 |
+
penalty = min(
|
| 202 |
+
MAX_EXTRA_INVESTIGATION_PENALTY,
|
| 203 |
+
extra_investigations * EXTRA_INVESTIGATION_COST,
|
| 204 |
+
)
|
| 205 |
+
return max(0.0, min(1.0, base_reward - penalty))
|
| 206 |
+
|
| 207 |
+
def _lookup_related_ticket(
|
| 208 |
+
self,
|
| 209 |
+
current_ticket: HelpdeskTicketRecord,
|
| 210 |
+
target_ticket_id: str | None,
|
| 211 |
+
) -> dict[str, Any]:
|
| 212 |
+
target_id = target_ticket_id or current_ticket.related_ticket_id
|
| 213 |
+
if target_id is None:
|
| 214 |
+
return {
|
| 215 |
+
"tool_name": "lookup_related_ticket",
|
| 216 |
+
"found": False,
|
| 217 |
+
"message": "Current ticket has no linked related_ticket_id.",
|
| 218 |
+
}
|
| 219 |
+
related_ticket = self._tickets_by_id.get(target_id)
|
| 220 |
+
if related_ticket is None:
|
| 221 |
+
return {
|
| 222 |
+
"tool_name": "lookup_related_ticket",
|
| 223 |
+
"found": False,
|
| 224 |
+
"message": f"Ticket {target_id!r} was not found in the dataset.",
|
| 225 |
+
}
|
| 226 |
+
return {
|
| 227 |
+
"tool_name": "lookup_related_ticket",
|
| 228 |
+
"found": True,
|
| 229 |
+
"ticket": {
|
| 230 |
+
"ticket_id": related_ticket.ticket_id,
|
| 231 |
+
"title": related_ticket.title,
|
| 232 |
+
"requester": related_ticket.requester,
|
| 233 |
+
"description": related_ticket.description,
|
| 234 |
+
"issue_type": related_ticket.issue_type,
|
| 235 |
+
"priority": related_ticket.priority,
|
| 236 |
+
"assignment_group": related_ticket.assignment_group,
|
| 237 |
+
"resolution_action": related_ticket.resolution_action,
|
| 238 |
+
},
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
def _lookup_requester_history(self, current_ticket: HelpdeskTicketRecord) -> dict[str, Any]:
|
| 242 |
+
matches = [
|
| 243 |
+
{
|
| 244 |
+
"ticket_id": ticket.ticket_id,
|
| 245 |
+
"title": ticket.title,
|
| 246 |
+
"issue_type": ticket.issue_type,
|
| 247 |
+
"priority": ticket.priority,
|
| 248 |
+
"assignment_group": ticket.assignment_group,
|
| 249 |
+
"resolution_action": ticket.resolution_action,
|
| 250 |
+
}
|
| 251 |
+
for ticket in self._dataset
|
| 252 |
+
if ticket.requester == current_ticket.requester
|
| 253 |
+
and ticket.ticket_id != current_ticket.ticket_id
|
| 254 |
+
]
|
| 255 |
+
return {
|
| 256 |
+
"tool_name": "lookup_requester_history",
|
| 257 |
+
"found": bool(matches),
|
| 258 |
+
"requester": current_ticket.requester,
|
| 259 |
+
"matches": matches,
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
def _run_investigation_tool(
|
| 263 |
+
self,
|
| 264 |
+
current_ticket: HelpdeskTicketRecord,
|
| 265 |
+
tool_name: str,
|
| 266 |
+
target_ticket_id: str | None,
|
| 267 |
+
) -> dict[str, Any]:
|
| 268 |
+
if tool_name == "lookup_related_ticket":
|
| 269 |
+
return self._lookup_related_ticket(current_ticket, target_ticket_id)
|
| 270 |
+
if tool_name == "lookup_requester_history":
|
| 271 |
+
return self._lookup_requester_history(current_ticket)
|
| 272 |
+
raise ValueError(f"Unsupported tool_name: {tool_name}")
|
| 273 |
+
|
| 274 |
+
def _handle_investigation_action(
|
| 275 |
+
self,
|
| 276 |
+
task: dict,
|
| 277 |
+
current_ticket: HelpdeskTicketRecord,
|
| 278 |
+
action: HelpdeskTicketAction,
|
| 279 |
+
idx: int,
|
| 280 |
+
) -> HelpdeskTicketObservation:
|
| 281 |
+
if action.tool_name is None:
|
| 282 |
+
raise ValueError("Investigate actions require tool_name")
|
| 283 |
+
submitted_fields = {
|
| 284 |
+
field
|
| 285 |
+
for field in ("issue_type", "priority", "assignment_group", "resolution_action")
|
| 286 |
+
if getattr(action, field) is not None
|
| 287 |
+
}
|
| 288 |
+
if submitted_fields:
|
| 289 |
+
raise ValueError(
|
| 290 |
+
"Investigate actions cannot include submit fields: "
|
| 291 |
+
f"{sorted(submitted_fields)}"
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
tool_result = self._run_investigation_tool(
|
| 295 |
+
current_ticket,
|
| 296 |
+
action.tool_name,
|
| 297 |
+
action.tool_target_ticket_id,
|
| 298 |
+
)
|
| 299 |
+
self._state.step_count += 1
|
| 300 |
+
self._state.investigation_steps += 1
|
| 301 |
+
self._state.investigation_budget_remaining = max(
|
| 302 |
+
0,
|
| 303 |
+
self._state.investigation_budget_remaining - 1,
|
| 304 |
+
)
|
| 305 |
+
self._state.last_tool_result = tool_result
|
| 306 |
+
self._state.last_step_reward = 0.0
|
| 307 |
+
self._state.reward = 0.0
|
| 308 |
+
self._state.done = False
|
| 309 |
+
self._state.history_entries.append(
|
| 310 |
+
self._build_history_entry(
|
| 311 |
+
current_ticket,
|
| 312 |
+
predicted=action.model_dump(exclude_none=True),
|
| 313 |
+
score=0.0,
|
| 314 |
+
breakdown={},
|
| 315 |
+
queue_position=idx + 1,
|
| 316 |
+
tool_result=tool_result,
|
| 317 |
+
)
|
| 318 |
+
)
|
| 319 |
+
return self._build_observation(task, done=False, reward=0.0)
|
| 320 |
+
|
| 321 |
+
def _build_ticket_view(self, ticket: HelpdeskTicketRecord) -> dict[str, Any]:
|
| 322 |
+
ticket_view: dict[str, Any] = {
|
| 323 |
+
"ticket_id": ticket.ticket_id,
|
| 324 |
+
"title": ticket.title,
|
| 325 |
+
"requester": ticket.requester,
|
| 326 |
+
"description": ticket.description,
|
| 327 |
+
}
|
| 328 |
+
if ticket.ambiguity_note is not None:
|
| 329 |
+
ticket_view["ambiguity_note"] = ticket.ambiguity_note
|
| 330 |
+
if ticket.related_ticket_id is not None:
|
| 331 |
+
ticket_view["related_ticket_id"] = ticket.related_ticket_id
|
| 332 |
+
related_ticket = self._tickets_by_id.get(ticket.related_ticket_id)
|
| 333 |
+
if related_ticket is not None:
|
| 334 |
+
ticket_view["related_ticket_preview"] = {
|
| 335 |
+
"ticket_id": related_ticket.ticket_id,
|
| 336 |
+
"title": related_ticket.title,
|
| 337 |
+
"requester": related_ticket.requester,
|
| 338 |
+
"description": related_ticket.description,
|
| 339 |
+
}
|
| 340 |
+
return ticket_view
|
| 341 |
+
|
| 342 |
+
def _build_history_entry(
|
| 343 |
+
self,
|
| 344 |
+
ticket: HelpdeskTicketRecord,
|
| 345 |
+
*,
|
| 346 |
+
predicted: dict[str, Any],
|
| 347 |
+
score: float,
|
| 348 |
+
breakdown: dict[str, float],
|
| 349 |
+
queue_position: int,
|
| 350 |
+
penalty_reason: str | None = None,
|
| 351 |
+
tool_result: dict[str, Any] | None = None,
|
| 352 |
+
) -> dict[str, Any]:
|
| 353 |
+
history_entry: dict[str, Any] = {
|
| 354 |
+
"ticket_id": ticket.ticket_id,
|
| 355 |
+
"title": ticket.title,
|
| 356 |
+
"requester": ticket.requester,
|
| 357 |
+
"predicted": predicted,
|
| 358 |
+
"score": score,
|
| 359 |
+
"breakdown": breakdown,
|
| 360 |
+
"queue_position": queue_position,
|
| 361 |
+
}
|
| 362 |
+
if ticket.ambiguity_note is not None:
|
| 363 |
+
history_entry["ambiguity_note"] = ticket.ambiguity_note
|
| 364 |
+
if ticket.related_ticket_id is not None:
|
| 365 |
+
history_entry["related_ticket_id"] = ticket.related_ticket_id
|
| 366 |
+
related_ticket = self._tickets_by_id.get(ticket.related_ticket_id)
|
| 367 |
+
if related_ticket is not None:
|
| 368 |
+
history_entry["related_ticket_preview"] = {
|
| 369 |
+
"ticket_id": related_ticket.ticket_id,
|
| 370 |
+
"title": related_ticket.title,
|
| 371 |
+
"requester": related_ticket.requester,
|
| 372 |
+
"description": related_ticket.description,
|
| 373 |
+
}
|
| 374 |
+
if penalty_reason is not None:
|
| 375 |
+
history_entry["penalty_reason"] = penalty_reason
|
| 376 |
+
if tool_result is not None:
|
| 377 |
+
history_entry["tool_result"] = tool_result
|
| 378 |
+
return history_entry
|
| 379 |
+
|
| 380 |
def _build_observation(
|
| 381 |
self,
|
| 382 |
task: dict,
|
| 383 |
done: bool = False,
|
| 384 |
reward: float | None = None,
|
|
|
|
| 385 |
) -> HelpdeskTicketObservation:
|
| 386 |
idx = self._state.current_ticket_index
|
| 387 |
queue_size = len(self._queue)
|
| 388 |
|
| 389 |
if idx < queue_size:
|
| 390 |
ticket = self._queue[idx]
|
| 391 |
+
ticket_view = self._build_ticket_view(ticket)
|
| 392 |
+
queue_position = idx + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
else:
|
| 394 |
ticket_view = None
|
| 395 |
+
queue_position = 0
|
| 396 |
|
| 397 |
+
history = list(self._state.history_entries)
|
| 398 |
+
tickets_remaining = max(0, queue_size - idx)
|
| 399 |
+
tickets_after_current = max(
|
| 400 |
+
0,
|
| 401 |
+
tickets_remaining - (1 if ticket_view is not None else 0),
|
| 402 |
+
)
|
| 403 |
|
| 404 |
return HelpdeskTicketObservation(
|
| 405 |
done=done,
|
| 406 |
reward=reward,
|
| 407 |
+
metadata={
|
| 408 |
+
"queue_position": queue_position,
|
| 409 |
+
"tickets_remaining_includes_current": ticket_view is not None,
|
| 410 |
+
"has_ambiguity_note": bool(ticket_view and ticket_view.get("ambiguity_note")),
|
| 411 |
+
"has_related_ticket_context": bool(
|
| 412 |
+
ticket_view and ticket_view.get("related_ticket_preview")
|
| 413 |
+
),
|
| 414 |
+
"action_mode": "investigate_or_submit",
|
| 415 |
+
},
|
| 416 |
task_id=task["id"],
|
| 417 |
task_name=task["name"],
|
| 418 |
instructions=task["instructions"],
|
| 419 |
allowed_fields=list(task["allowed_fields"]),
|
| 420 |
+
available_tools=list(AVAILABLE_TOOLS),
|
| 421 |
+
investigation_budget_remaining=self._state.investigation_budget_remaining,
|
| 422 |
+
last_tool_result=self._state.last_tool_result,
|
| 423 |
current_ticket=ticket_view,
|
| 424 |
queue_size=queue_size,
|
| 425 |
+
tickets_remaining=tickets_remaining,
|
| 426 |
+
tickets_after_current=tickets_after_current,
|
| 427 |
tickets_processed=idx,
|
| 428 |
+
queue_position=queue_position,
|
| 429 |
history=history,
|
| 430 |
)
|
server/reward.py
CHANGED
|
@@ -1,8 +1,18 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
def compute_step_reward(score: float) -> float:
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def compute_trajectory_reward(
|
|
@@ -11,6 +21,4 @@ def compute_trajectory_reward(
|
|
| 11 |
if not per_ticket_scores:
|
| 12 |
return 0.0
|
| 13 |
avg = sum(per_ticket_scores) / len(per_ticket_scores)
|
| 14 |
-
|
| 15 |
-
penalty = overshoot * 0.03
|
| 16 |
-
return max(0.0, min(1.0, avg - penalty))
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
MILESTONE_HIGH_THRESHOLD = 0.8
|
| 4 |
+
MILESTONE_LOW_THRESHOLD = 0.2
|
| 5 |
+
MILESTONE_BONUS = 0.05
|
| 6 |
+
MILESTONE_PENALTY = 0.05
|
| 7 |
+
|
| 8 |
|
| 9 |
def compute_step_reward(score: float) -> float:
|
| 10 |
+
base = max(0.0, min(1.0, score))
|
| 11 |
+
if score >= MILESTONE_HIGH_THRESHOLD:
|
| 12 |
+
return min(1.0, base + MILESTONE_BONUS)
|
| 13 |
+
if score < MILESTONE_LOW_THRESHOLD:
|
| 14 |
+
return max(0.0, base - MILESTONE_PENALTY)
|
| 15 |
+
return base
|
| 16 |
|
| 17 |
|
| 18 |
def compute_trajectory_reward(
|
|
|
|
| 21 |
if not per_ticket_scores:
|
| 22 |
return 0.0
|
| 23 |
avg = sum(per_ticket_scores) / len(per_ticket_scores)
|
| 24 |
+
return max(0.0, min(1.0, avg))
|
|
|
|
|
|
server/tasks.py
CHANGED
|
@@ -13,7 +13,8 @@ TASKS = {
|
|
| 13 |
"name": "Issue Type Classification",
|
| 14 |
"difficulty": "easy",
|
| 15 |
"instructions": (
|
| 16 |
-
"Read the ticket and select the single best IT issue type."
|
|
|
|
| 17 |
),
|
| 18 |
"allowed_fields": ["issue_type"],
|
| 19 |
},
|
|
@@ -23,7 +24,8 @@ TASKS = {
|
|
| 23 |
"difficulty": "medium",
|
| 24 |
"instructions": (
|
| 25 |
"Read the ticket, select the best IT issue type, and estimate the "
|
| 26 |
-
"correct operational priority."
|
|
|
|
| 27 |
),
|
| 28 |
"allowed_fields": ["issue_type", "priority"],
|
| 29 |
},
|
|
@@ -33,7 +35,9 @@ TASKS = {
|
|
| 33 |
"difficulty": "hard",
|
| 34 |
"instructions": (
|
| 35 |
"Perform full helpdesk routing by selecting the best issue type, "
|
| 36 |
-
"priority, assignment group, and resolution action for the ticket."
|
|
|
|
|
|
|
| 37 |
),
|
| 38 |
"allowed_fields": [
|
| 39 |
"issue_type",
|
|
|
|
| 13 |
"name": "Issue Type Classification",
|
| 14 |
"difficulty": "easy",
|
| 15 |
"instructions": (
|
| 16 |
+
"Read the ticket and select the single best IT issue type. "
|
| 17 |
+
"You may investigate first, then submit a final routing answer."
|
| 18 |
),
|
| 19 |
"allowed_fields": ["issue_type"],
|
| 20 |
},
|
|
|
|
| 24 |
"difficulty": "medium",
|
| 25 |
"instructions": (
|
| 26 |
"Read the ticket, select the best IT issue type, and estimate the "
|
| 27 |
+
"correct operational priority. If the observation includes ambiguity "
|
| 28 |
+
"or follow-up context, use it. You may investigate before you submit."
|
| 29 |
),
|
| 30 |
"allowed_fields": ["issue_type", "priority"],
|
| 31 |
},
|
|
|
|
| 35 |
"difficulty": "hard",
|
| 36 |
"instructions": (
|
| 37 |
"Perform full helpdesk routing by selecting the best issue type, "
|
| 38 |
+
"priority, assignment group, and resolution action for the ticket. "
|
| 39 |
+
"Use any ambiguity notes or related-ticket previews when present. "
|
| 40 |
+
"You may investigate with tools before you submit the final action."
|
| 41 |
),
|
| 42 |
"allowed_fields": [
|
| 43 |
"issue_type",
|
tests/test_api_integration.py
CHANGED
|
@@ -400,7 +400,7 @@ class TestHeuristicInferenceRegression(unittest.TestCase):
|
|
| 400 |
import inference as _inf
|
| 401 |
cls._heuristic_action = staticmethod(_inf.heuristic_action)
|
| 402 |
cls._SEED = _inf.SEED
|
| 403 |
-
cls._TASKS = list(_inf.
|
| 404 |
|
| 405 |
def _run_heuristic_episode(self, task_id: int) -> float:
|
| 406 |
"""Run one full heuristic episode for the given task_id via TestClient.
|
|
|
|
| 400 |
import inference as _inf
|
| 401 |
cls._heuristic_action = staticmethod(_inf.heuristic_action)
|
| 402 |
cls._SEED = _inf.SEED
|
| 403 |
+
cls._TASKS = list(_inf.TASK_IDS)
|
| 404 |
|
| 405 |
def _run_heuristic_episode(self, task_id: int) -> float:
|
| 406 |
"""Run one full heuristic episode for the given task_id via TestClient.
|
tests/test_competitive_upgrade.py
ADDED
|
@@ -0,0 +1,746 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the helpdesk-competitive-upgrade spec (Task 9).
|
| 3 |
+
|
| 4 |
+
Covers:
|
| 5 |
+
9.1 test_inference_single_task_mode
|
| 6 |
+
9.2 test_state_has_reward_and_done
|
| 7 |
+
9.3 test_history_has_title_and_predicted
|
| 8 |
+
9.4 test_milestone_reward_shaping
|
| 9 |
+
9.5 test_trajectory_reward_no_overshoot
|
| 10 |
+
9.6 test_ambiguity_note_in_observation
|
| 11 |
+
9.7 test_dataset_nondefault_routing
|
| 12 |
+
9.9 test_concurrent_sessions_flag
|
| 13 |
+
9.10 test_web_ui_endpoint
|
| 14 |
+
|
| 15 |
+
Run with:
|
| 16 |
+
pytest tests/test_competitive_upgrade.py
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
import types as _types
|
| 23 |
+
import unittest
|
| 24 |
+
|
| 25 |
+
# Ensure repo root is on sys.path
|
| 26 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 27 |
+
|
| 28 |
+
import openenv_test_stubs # noqa: F401 — must come before any openenv imports
|
| 29 |
+
|
| 30 |
+
# Patch in the interfaces module so environment.py can import Environment.
|
| 31 |
+
if "openenv.core.env_server.interfaces" not in sys.modules:
|
| 32 |
+
_interfaces_mod = _types.ModuleType("openenv.core.env_server.interfaces")
|
| 33 |
+
|
| 34 |
+
class _Environment:
|
| 35 |
+
"""Minimal stub matching the openenv-core Environment base class."""
|
| 36 |
+
|
| 37 |
+
def __init__(self) -> None:
|
| 38 |
+
pass
|
| 39 |
+
|
| 40 |
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
| 41 |
+
super().__init_subclass__(**kwargs)
|
| 42 |
+
|
| 43 |
+
@classmethod
|
| 44 |
+
def __class_getitem__(cls, item: object) -> type:
|
| 45 |
+
return cls
|
| 46 |
+
|
| 47 |
+
_interfaces_mod.Environment = _Environment # type: ignore[attr-defined]
|
| 48 |
+
sys.modules["openenv.core.env_server.interfaces"] = _interfaces_mod
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
from models import HelpdeskTicketAction, HelpdeskTicketObservation, HelpdeskTicketState
|
| 52 |
+
from server.environment import HelpdeskTicketRoutingEnvironment
|
| 53 |
+
from server.reward import compute_step_reward, compute_trajectory_reward
|
| 54 |
+
from server.tasks import load_dataset
|
| 55 |
+
from vocabulary import ISSUE_TYPES, PRIORITIES, ASSIGNMENT_GROUPS, RESOLUTION_ACTIONS, TASK_IDS
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# Helpers
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
def _make_env() -> HelpdeskTicketRoutingEnvironment:
|
| 63 |
+
return HelpdeskTicketRoutingEnvironment()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _heuristic_action(obs: HelpdeskTicketObservation) -> HelpdeskTicketAction:
|
| 67 |
+
allowed = obs.allowed_fields
|
| 68 |
+
kwargs: dict = {}
|
| 69 |
+
if "issue_type" in allowed:
|
| 70 |
+
kwargs["issue_type"] = ISSUE_TYPES[0]
|
| 71 |
+
if "priority" in allowed:
|
| 72 |
+
kwargs["priority"] = PRIORITIES[0]
|
| 73 |
+
if "assignment_group" in allowed:
|
| 74 |
+
kwargs["assignment_group"] = ASSIGNMENT_GROUPS[0]
|
| 75 |
+
if "resolution_action" in allowed:
|
| 76 |
+
kwargs["resolution_action"] = RESOLUTION_ACTIONS[0]
|
| 77 |
+
return HelpdeskTicketAction(**kwargs)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
# 9.1 — Inference single-task mode
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
|
| 84 |
+
def _get_tasks_to_run_impl(
|
| 85 |
+
task_id_env: str | None,
|
| 86 |
+
available_tasks: dict,
|
| 87 |
+
run_all_tasks: bool = False,
|
| 88 |
+
) -> list[int]:
|
| 89 |
+
"""
|
| 90 |
+
Standalone re-implementation of inference.get_tasks_to_run() logic for testing.
|
| 91 |
+
|
| 92 |
+
This mirrors the logic in inference.py without importing the full module
|
| 93 |
+
(which has heavy dependencies like openai, httpx, and client.py).
|
| 94 |
+
"""
|
| 95 |
+
if task_id_env:
|
| 96 |
+
try:
|
| 97 |
+
task_id = int(task_id_env)
|
| 98 |
+
except ValueError:
|
| 99 |
+
raise SystemExit(1)
|
| 100 |
+
if task_id not in available_tasks:
|
| 101 |
+
raise SystemExit(1)
|
| 102 |
+
return [task_id]
|
| 103 |
+
if run_all_tasks:
|
| 104 |
+
return sorted(available_tasks)
|
| 105 |
+
if not available_tasks:
|
| 106 |
+
return []
|
| 107 |
+
return [sorted(available_tasks)[0]]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class TestInferenceSingleTaskMode(unittest.TestCase):
|
| 111 |
+
"""9.1 — get_tasks_to_run() respects TASK_ID env var."""
|
| 112 |
+
|
| 113 |
+
def test_task_id_set_to_valid_id_returns_single_element_list(self) -> None:
|
| 114 |
+
available = {1: {}, 2: {}, 3: {}}
|
| 115 |
+
result = _get_tasks_to_run_impl("1", available)
|
| 116 |
+
self.assertEqual(result, [1])
|
| 117 |
+
|
| 118 |
+
def test_task_id_set_to_unavailable_id_exits(self) -> None:
|
| 119 |
+
available = {1: {}, 2: {}, 3: {}}
|
| 120 |
+
with self.assertRaises(SystemExit):
|
| 121 |
+
_get_tasks_to_run_impl("999", available)
|
| 122 |
+
|
| 123 |
+
def test_task_id_unset_defaults_to_first_available_task(self) -> None:
|
| 124 |
+
available = {1: {}, 2: {}, 3: {}}
|
| 125 |
+
result = _get_tasks_to_run_impl(None, available)
|
| 126 |
+
self.assertEqual(result, [1])
|
| 127 |
+
|
| 128 |
+
def test_run_all_tasks_override_returns_all_task_ids(self) -> None:
|
| 129 |
+
available = {1: {}, 2: {}, 3: {}}
|
| 130 |
+
result = _get_tasks_to_run_impl(None, available, run_all_tasks=True)
|
| 131 |
+
self.assertEqual(sorted(result), sorted(list(TASK_IDS)))
|
| 132 |
+
|
| 133 |
+
def test_task_id_set_to_2_returns_only_task_2(self) -> None:
|
| 134 |
+
available = {1: {}, 2: {}, 3: {}}
|
| 135 |
+
result = _get_tasks_to_run_impl("2", available)
|
| 136 |
+
self.assertEqual(result, [2])
|
| 137 |
+
|
| 138 |
+
def test_task_id_set_to_3_returns_only_task_3(self) -> None:
|
| 139 |
+
available = {1: {}, 2: {}, 3: {}}
|
| 140 |
+
result = _get_tasks_to_run_impl("3", available)
|
| 141 |
+
self.assertEqual(result, [3])
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ---------------------------------------------------------------------------
|
| 145 |
+
# 9.2 — State has last_step_reward and done after step()
|
| 146 |
+
# ---------------------------------------------------------------------------
|
| 147 |
+
|
| 148 |
+
class TestStateHasRewardAndDone(unittest.TestCase):
|
| 149 |
+
"""9.2 — state.last_step_reward and state.done are set after step()."""
|
| 150 |
+
|
| 151 |
+
def test_last_step_reward_is_none_after_reset(self) -> None:
|
| 152 |
+
env = _make_env()
|
| 153 |
+
env.reset(seed=42, task_id=1)
|
| 154 |
+
self.assertIsNone(env.state.last_step_reward)
|
| 155 |
+
|
| 156 |
+
def test_done_is_false_after_reset(self) -> None:
|
| 157 |
+
env = _make_env()
|
| 158 |
+
env.reset(seed=42, task_id=1)
|
| 159 |
+
self.assertFalse(env.state.done)
|
| 160 |
+
|
| 161 |
+
def test_last_step_reward_set_after_step(self) -> None:
|
| 162 |
+
env = _make_env()
|
| 163 |
+
obs = env.reset(seed=42, task_id=1)
|
| 164 |
+
action = _heuristic_action(obs)
|
| 165 |
+
env.step(action)
|
| 166 |
+
state = env.state
|
| 167 |
+
self.assertIsNotNone(state.last_step_reward)
|
| 168 |
+
self.assertGreaterEqual(state.last_step_reward, 0.0)
|
| 169 |
+
self.assertLessEqual(state.last_step_reward, 1.0)
|
| 170 |
+
|
| 171 |
+
def test_done_is_true_after_last_ticket(self) -> None:
|
| 172 |
+
env = _make_env()
|
| 173 |
+
obs = env.reset(seed=42, task_id=1)
|
| 174 |
+
while not obs.done:
|
| 175 |
+
obs = env.step(_heuristic_action(obs))
|
| 176 |
+
self.assertTrue(env.state.done)
|
| 177 |
+
|
| 178 |
+
def test_done_is_false_before_last_ticket(self) -> None:
|
| 179 |
+
env = _make_env()
|
| 180 |
+
obs = env.reset(seed=42, task_id=1)
|
| 181 |
+
if obs.queue_size > 1:
|
| 182 |
+
obs = env.step(_heuristic_action(obs))
|
| 183 |
+
self.assertFalse(env.state.done)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ---------------------------------------------------------------------------
|
| 187 |
+
# 9.3 — History entry contains title and predicted
|
| 188 |
+
# ---------------------------------------------------------------------------
|
| 189 |
+
|
| 190 |
+
class TestHistoryHasTitleAndPredicted(unittest.TestCase):
|
| 191 |
+
"""9.3 — observation.history[0] contains 'title' and 'predicted' keys."""
|
| 192 |
+
|
| 193 |
+
def test_history_entry_has_title(self) -> None:
|
| 194 |
+
env = _make_env()
|
| 195 |
+
obs = env.reset(seed=42, task_id=1)
|
| 196 |
+
action = _heuristic_action(obs)
|
| 197 |
+
obs2 = env.step(action)
|
| 198 |
+
self.assertEqual(len(obs2.history), 1)
|
| 199 |
+
self.assertIn("title", obs2.history[0])
|
| 200 |
+
self.assertIsInstance(obs2.history[0]["title"], str)
|
| 201 |
+
self.assertTrue(obs2.history[0]["title"]) # non-empty
|
| 202 |
+
|
| 203 |
+
def test_history_entry_has_predicted(self) -> None:
|
| 204 |
+
env = _make_env()
|
| 205 |
+
obs = env.reset(seed=42, task_id=1)
|
| 206 |
+
action = _heuristic_action(obs)
|
| 207 |
+
obs2 = env.step(action)
|
| 208 |
+
self.assertIn("predicted", obs2.history[0])
|
| 209 |
+
self.assertIsInstance(obs2.history[0]["predicted"], dict)
|
| 210 |
+
|
| 211 |
+
def test_history_predicted_matches_action(self) -> None:
|
| 212 |
+
env = _make_env()
|
| 213 |
+
obs = env.reset(seed=42, task_id=1)
|
| 214 |
+
action = _heuristic_action(obs)
|
| 215 |
+
obs2 = env.step(action)
|
| 216 |
+
predicted = obs2.history[0]["predicted"]
|
| 217 |
+
action_dict = action.model_dump(exclude_none=True)
|
| 218 |
+
self.assertEqual(predicted, action_dict)
|
| 219 |
+
|
| 220 |
+
def test_history_entry_has_ticket_id_and_score(self) -> None:
|
| 221 |
+
env = _make_env()
|
| 222 |
+
obs = env.reset(seed=42, task_id=1)
|
| 223 |
+
obs2 = env.step(_heuristic_action(obs))
|
| 224 |
+
entry = obs2.history[0]
|
| 225 |
+
self.assertIn("ticket_id", entry)
|
| 226 |
+
self.assertIn("score", entry)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# ---------------------------------------------------------------------------
|
| 230 |
+
# 9.4 — Milestone reward shaping
|
| 231 |
+
# ---------------------------------------------------------------------------
|
| 232 |
+
|
| 233 |
+
class TestMilestoneRewardShaping(unittest.TestCase):
|
| 234 |
+
"""9.4 — compute_step_reward applies bonus at high scores, penalty at low scores."""
|
| 235 |
+
|
| 236 |
+
def test_high_score_gets_bonus(self) -> None:
|
| 237 |
+
# score=0.9 >= 0.8 threshold → base=0.9, bonus=0.05 → 0.95
|
| 238 |
+
result = compute_step_reward(0.9)
|
| 239 |
+
self.assertAlmostEqual(result, 0.95, places=9)
|
| 240 |
+
|
| 241 |
+
def test_low_score_gets_penalty(self) -> None:
|
| 242 |
+
# score=0.1 < 0.2 threshold → base=0.1, penalty=0.05 → 0.05
|
| 243 |
+
result = compute_step_reward(0.1)
|
| 244 |
+
self.assertAlmostEqual(result, 0.05, places=9)
|
| 245 |
+
|
| 246 |
+
def test_mid_score_is_neutral(self) -> None:
|
| 247 |
+
# score=0.5 is in [0.2, 0.8) → no shaping → 0.5
|
| 248 |
+
result = compute_step_reward(0.5)
|
| 249 |
+
self.assertAlmostEqual(result, 0.5, places=9)
|
| 250 |
+
|
| 251 |
+
def test_boundary_high_threshold_gets_bonus(self) -> None:
|
| 252 |
+
# score=0.8 exactly → bonus applies → 0.85
|
| 253 |
+
result = compute_step_reward(0.8)
|
| 254 |
+
self.assertAlmostEqual(result, 0.85, places=9)
|
| 255 |
+
|
| 256 |
+
def test_boundary_low_threshold_is_neutral(self) -> None:
|
| 257 |
+
# score=0.2 exactly → not < 0.2, so neutral → 0.2
|
| 258 |
+
result = compute_step_reward(0.2)
|
| 259 |
+
self.assertAlmostEqual(result, 0.2, places=9)
|
| 260 |
+
|
| 261 |
+
def test_reward_clamped_to_unit_interval(self) -> None:
|
| 262 |
+
# score=1.0 → base=1.0, bonus would push to 1.05 → clamped to 1.0
|
| 263 |
+
result = compute_step_reward(1.0)
|
| 264 |
+
self.assertLessEqual(result, 1.0)
|
| 265 |
+
self.assertGreaterEqual(result, 0.0)
|
| 266 |
+
|
| 267 |
+
def test_zero_score_clamped_to_zero(self) -> None:
|
| 268 |
+
# score=0.0 < 0.2 → base=0.0, penalty → max(0.0, -0.05) = 0.0
|
| 269 |
+
result = compute_step_reward(0.0)
|
| 270 |
+
self.assertGreaterEqual(result, 0.0)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ---------------------------------------------------------------------------
|
| 274 |
+
# 9.5 — Trajectory reward has no overshoot penalty
|
| 275 |
+
# ---------------------------------------------------------------------------
|
| 276 |
+
|
| 277 |
+
class TestTrajectoryRewardNoOvershoot(unittest.TestCase):
|
| 278 |
+
"""9.5 — compute_trajectory_reward does not penalise when steps > queue_size."""
|
| 279 |
+
|
| 280 |
+
def test_no_penalty_when_steps_exceed_queue_size(self) -> None:
|
| 281 |
+
scores = [0.8, 0.9, 0.7]
|
| 282 |
+
queue_size = 3
|
| 283 |
+
steps_taken = 10 # more steps than queue_size
|
| 284 |
+
result = compute_trajectory_reward(scores, queue_size, steps_taken)
|
| 285 |
+
expected_avg = sum(scores) / len(scores)
|
| 286 |
+
self.assertAlmostEqual(result, expected_avg, places=9)
|
| 287 |
+
|
| 288 |
+
def test_result_equals_average_regardless_of_steps(self) -> None:
|
| 289 |
+
scores = [0.5, 0.6]
|
| 290 |
+
for steps in [1, 2, 5, 100]:
|
| 291 |
+
result = compute_trajectory_reward(scores, len(scores), steps)
|
| 292 |
+
self.assertAlmostEqual(result, 0.55, places=9,
|
| 293 |
+
msg=f"Failed for steps={steps}")
|
| 294 |
+
|
| 295 |
+
def test_empty_scores_returns_zero(self) -> None:
|
| 296 |
+
self.assertEqual(compute_trajectory_reward([], 3, 3), 0.0)
|
| 297 |
+
|
| 298 |
+
def test_result_in_unit_interval(self) -> None:
|
| 299 |
+
scores = [0.9, 1.0, 0.95]
|
| 300 |
+
result = compute_trajectory_reward(scores, 3, 3)
|
| 301 |
+
self.assertGreaterEqual(result, 0.0)
|
| 302 |
+
self.assertLessEqual(result, 1.0)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# ---------------------------------------------------------------------------
|
| 306 |
+
# 9.6 — ambiguity_note appears in current_ticket observation
|
| 307 |
+
# ---------------------------------------------------------------------------
|
| 308 |
+
|
| 309 |
+
class TestAmbiguityNoteInObservation(unittest.TestCase):
|
| 310 |
+
"""9.6 — current_ticket includes ambiguity_note when the ticket has one."""
|
| 311 |
+
|
| 312 |
+
def _find_seed_with_ambiguity_note(self, task_id: int = 3) -> int | None:
|
| 313 |
+
"""Try seeds 0..999 to find one where the first ticket has ambiguity_note."""
|
| 314 |
+
env = _make_env()
|
| 315 |
+
for seed in range(1000):
|
| 316 |
+
obs = env.reset(seed=seed, task_id=task_id)
|
| 317 |
+
if obs.current_ticket and obs.current_ticket.get("ambiguity_note"):
|
| 318 |
+
return seed
|
| 319 |
+
return None
|
| 320 |
+
|
| 321 |
+
def test_ambiguity_note_present_when_ticket_has_one(self) -> None:
|
| 322 |
+
"""Force a ticket with ambiguity_note by patching the dataset."""
|
| 323 |
+
from unittest.mock import patch
|
| 324 |
+
from server.tasks import load_dataset
|
| 325 |
+
|
| 326 |
+
dataset = load_dataset()
|
| 327 |
+
# Find a ticket with ambiguity_note
|
| 328 |
+
ambiguous_tickets = [t for t in dataset if t.ambiguity_note is not None]
|
| 329 |
+
self.assertGreater(len(ambiguous_tickets), 0, "No tickets with ambiguity_note in dataset")
|
| 330 |
+
|
| 331 |
+
target = ambiguous_tickets[0]
|
| 332 |
+
|
| 333 |
+
env = _make_env()
|
| 334 |
+
# Patch the dataset to only contain the ambiguous ticket
|
| 335 |
+
with patch.object(env, "_dataset", [target]):
|
| 336 |
+
obs = env.reset(seed=0, task_id=3)
|
| 337 |
+
|
| 338 |
+
self.assertIsNotNone(obs.current_ticket)
|
| 339 |
+
self.assertIn("ambiguity_note", obs.current_ticket)
|
| 340 |
+
self.assertEqual(obs.current_ticket["ambiguity_note"], target.ambiguity_note)
|
| 341 |
+
|
| 342 |
+
def test_ambiguity_note_absent_when_ticket_has_none(self) -> None:
|
| 343 |
+
"""Tickets without ambiguity_note should not expose the key."""
|
| 344 |
+
from unittest.mock import patch
|
| 345 |
+
from server.tasks import load_dataset
|
| 346 |
+
|
| 347 |
+
dataset = load_dataset()
|
| 348 |
+
non_ambiguous = [t for t in dataset if t.ambiguity_note is None]
|
| 349 |
+
self.assertGreater(len(non_ambiguous), 0)
|
| 350 |
+
|
| 351 |
+
target = non_ambiguous[0]
|
| 352 |
+
env = _make_env()
|
| 353 |
+
with patch.object(env, "_dataset", [target]):
|
| 354 |
+
obs = env.reset(seed=0, task_id=3)
|
| 355 |
+
|
| 356 |
+
self.assertIsNotNone(obs.current_ticket)
|
| 357 |
+
self.assertNotIn("ambiguity_note", obs.current_ticket)
|
| 358 |
+
|
| 359 |
+
def test_tkt_nondefault_001_has_ambiguity_note(self) -> None:
|
| 360 |
+
"""TKT-NONDEFAULT-001 specifically has ambiguity_note set."""
|
| 361 |
+
from unittest.mock import patch
|
| 362 |
+
from server.tasks import load_dataset
|
| 363 |
+
|
| 364 |
+
dataset = load_dataset()
|
| 365 |
+
ticket = next((t for t in dataset if t.ticket_id == "TKT-NONDEFAULT-001"), None)
|
| 366 |
+
self.assertIsNotNone(ticket, "TKT-NONDEFAULT-001 not found in dataset")
|
| 367 |
+
self.assertIsNotNone(ticket.ambiguity_note)
|
| 368 |
+
|
| 369 |
+
env = _make_env()
|
| 370 |
+
with patch.object(env, "_dataset", [ticket]):
|
| 371 |
+
obs = env.reset(seed=0, task_id=3)
|
| 372 |
+
|
| 373 |
+
self.assertIn("ambiguity_note", obs.current_ticket)
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
class TestRelatedTicketPreviewInObservation(unittest.TestCase):
|
| 377 |
+
"""Follow-up tickets expose a lightweight preview of the linked ticket."""
|
| 378 |
+
|
| 379 |
+
def _reset_linked_ticket_env(self):
|
| 380 |
+
from unittest.mock import patch
|
| 381 |
+
|
| 382 |
+
dataset = load_dataset()
|
| 383 |
+
ticket = next((t for t in dataset if t.related_ticket_id is not None), None)
|
| 384 |
+
self.assertIsNotNone(ticket, "No follow-up ticket found in dataset")
|
| 385 |
+
related = next(
|
| 386 |
+
(t for t in dataset if t.ticket_id == ticket.related_ticket_id),
|
| 387 |
+
None,
|
| 388 |
+
)
|
| 389 |
+
self.assertIsNotNone(related, "Linked ticket missing from dataset")
|
| 390 |
+
|
| 391 |
+
env = _make_env()
|
| 392 |
+
with patch.object(env, "_dataset", [ticket]):
|
| 393 |
+
with patch.object(
|
| 394 |
+
env,
|
| 395 |
+
"_tickets_by_id",
|
| 396 |
+
{ticket.ticket_id: ticket, related.ticket_id: related},
|
| 397 |
+
):
|
| 398 |
+
obs = env.reset(seed=0, task_id=3, queue_size=1)
|
| 399 |
+
|
| 400 |
+
return env, obs, related
|
| 401 |
+
|
| 402 |
+
def test_related_ticket_preview_present_when_ticket_has_link(self) -> None:
|
| 403 |
+
env, obs, related = self._reset_linked_ticket_env()
|
| 404 |
+
|
| 405 |
+
self.assertIsNotNone(obs.current_ticket)
|
| 406 |
+
self.assertIn("related_ticket_preview", obs.current_ticket)
|
| 407 |
+
self.assertEqual(
|
| 408 |
+
obs.current_ticket["related_ticket_preview"]["ticket_id"],
|
| 409 |
+
related.ticket_id,
|
| 410 |
+
)
|
| 411 |
+
self.assertEqual(
|
| 412 |
+
obs.current_ticket["related_ticket_preview"]["title"],
|
| 413 |
+
related.title,
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
def test_history_keeps_related_ticket_preview_after_step(self) -> None:
|
| 417 |
+
env, obs, related = self._reset_linked_ticket_env()
|
| 418 |
+
next_obs = env.step(_heuristic_action(obs))
|
| 419 |
+
|
| 420 |
+
self.assertGreaterEqual(len(next_obs.history), 1)
|
| 421 |
+
self.assertIn("related_ticket_preview", next_obs.history[0])
|
| 422 |
+
self.assertEqual(
|
| 423 |
+
next_obs.history[0]["related_ticket_preview"]["ticket_id"],
|
| 424 |
+
related.ticket_id,
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
class TestObservationQueueContext(unittest.TestCase):
|
| 429 |
+
"""Observation includes clearer queue-position counters."""
|
| 430 |
+
|
| 431 |
+
def test_reset_sets_queue_position_and_after_current_counts(self) -> None:
|
| 432 |
+
env = _make_env()
|
| 433 |
+
obs = env.reset(seed=0, task_id=1, queue_size=3)
|
| 434 |
+
|
| 435 |
+
self.assertEqual(obs.queue_position, 1)
|
| 436 |
+
self.assertEqual(obs.tickets_remaining, 3)
|
| 437 |
+
self.assertEqual(obs.tickets_after_current, 2)
|
| 438 |
+
|
| 439 |
+
def test_step_updates_queue_position_and_after_current_counts(self) -> None:
|
| 440 |
+
env = _make_env()
|
| 441 |
+
obs = env.reset(seed=0, task_id=1, queue_size=3)
|
| 442 |
+
obs = env.step(_heuristic_action(obs))
|
| 443 |
+
|
| 444 |
+
if obs.done:
|
| 445 |
+
self.assertEqual(obs.queue_position, 0)
|
| 446 |
+
self.assertEqual(obs.tickets_after_current, 0)
|
| 447 |
+
else:
|
| 448 |
+
self.assertEqual(obs.queue_position, 2)
|
| 449 |
+
self.assertEqual(obs.tickets_remaining, 2)
|
| 450 |
+
self.assertEqual(obs.tickets_after_current, 1)
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
# ---------------------------------------------------------------------------
|
| 454 |
+
# 9.6b — investigation actions and queue economics
|
| 455 |
+
# ---------------------------------------------------------------------------
|
| 456 |
+
|
| 457 |
+
class TestInvestigationActions(unittest.TestCase):
|
| 458 |
+
"""Minimal tool-assisted investigate/submit flow works and stays backwards compatible."""
|
| 459 |
+
|
| 460 |
+
def _make_linked_env(self):
|
| 461 |
+
from unittest.mock import patch
|
| 462 |
+
|
| 463 |
+
dataset = load_dataset()
|
| 464 |
+
ticket = next((t for t in dataset if t.related_ticket_id is not None), None)
|
| 465 |
+
self.assertIsNotNone(ticket, "No follow-up ticket found in dataset")
|
| 466 |
+
related = next(
|
| 467 |
+
(t for t in dataset if t.ticket_id == ticket.related_ticket_id),
|
| 468 |
+
None,
|
| 469 |
+
)
|
| 470 |
+
self.assertIsNotNone(related, "Linked ticket missing from dataset")
|
| 471 |
+
env = _make_env()
|
| 472 |
+
patch_dataset = patch.object(env, "_dataset", [ticket])
|
| 473 |
+
patch_lookup = patch.object(
|
| 474 |
+
env,
|
| 475 |
+
"_tickets_by_id",
|
| 476 |
+
{ticket.ticket_id: ticket, related.ticket_id: related},
|
| 477 |
+
)
|
| 478 |
+
patch_dataset.start()
|
| 479 |
+
patch_lookup.start()
|
| 480 |
+
self.addCleanup(patch_dataset.stop)
|
| 481 |
+
self.addCleanup(patch_lookup.stop)
|
| 482 |
+
obs = env.reset(seed=0, task_id=3, queue_size=1)
|
| 483 |
+
return env, obs, ticket, related
|
| 484 |
+
|
| 485 |
+
def test_investigation_action_does_not_advance_queue(self) -> None:
|
| 486 |
+
env, obs, ticket, related = self._make_linked_env()
|
| 487 |
+
|
| 488 |
+
investigate = HelpdeskTicketAction(
|
| 489 |
+
action_type="investigate",
|
| 490 |
+
tool_name="lookup_related_ticket",
|
| 491 |
+
tool_target_ticket_id=ticket.related_ticket_id,
|
| 492 |
+
)
|
| 493 |
+
obs2 = env.step(investigate)
|
| 494 |
+
|
| 495 |
+
self.assertFalse(obs2.done)
|
| 496 |
+
self.assertEqual(obs2.tickets_processed, 0)
|
| 497 |
+
self.assertEqual(obs2.queue_position, 1)
|
| 498 |
+
self.assertIsNotNone(obs2.last_tool_result)
|
| 499 |
+
self.assertTrue(obs2.last_tool_result["found"])
|
| 500 |
+
self.assertEqual(
|
| 501 |
+
obs2.last_tool_result["ticket"]["ticket_id"],
|
| 502 |
+
related.ticket_id,
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
def test_submit_after_investigation_completes_episode(self) -> None:
|
| 506 |
+
env, obs, ticket, related = self._make_linked_env()
|
| 507 |
+
env.step(
|
| 508 |
+
HelpdeskTicketAction(
|
| 509 |
+
action_type="investigate",
|
| 510 |
+
tool_name="lookup_related_ticket",
|
| 511 |
+
tool_target_ticket_id=ticket.related_ticket_id,
|
| 512 |
+
)
|
| 513 |
+
)
|
| 514 |
+
final_obs = env.step(
|
| 515 |
+
HelpdeskTicketAction(
|
| 516 |
+
issue_type=ticket.issue_type,
|
| 517 |
+
priority=ticket.priority,
|
| 518 |
+
assignment_group=ticket.assignment_group,
|
| 519 |
+
resolution_action=ticket.resolution_action,
|
| 520 |
+
)
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
self.assertTrue(final_obs.done)
|
| 524 |
+
self.assertEqual(final_obs.tickets_processed, 1)
|
| 525 |
+
self.assertGreaterEqual(final_obs.reward, 0.0)
|
| 526 |
+
self.assertLessEqual(final_obs.reward, 1.0)
|
| 527 |
+
|
| 528 |
+
def test_requester_history_tool_returns_matches_for_same_requester(self) -> None:
|
| 529 |
+
from unittest.mock import patch
|
| 530 |
+
|
| 531 |
+
dataset = load_dataset()
|
| 532 |
+
requester_counts: dict[str, int] = {}
|
| 533 |
+
for ticket in dataset:
|
| 534 |
+
requester_counts[ticket.requester] = requester_counts.get(ticket.requester, 0) + 1
|
| 535 |
+
target_requester = next(
|
| 536 |
+
(requester for requester, count in requester_counts.items() if count >= 2),
|
| 537 |
+
None,
|
| 538 |
+
)
|
| 539 |
+
self.assertIsNotNone(target_requester, "Dataset has no repeated requester")
|
| 540 |
+
duplicate_requester_group = [
|
| 541 |
+
ticket for ticket in dataset if ticket.requester == target_requester
|
| 542 |
+
]
|
| 543 |
+
self.assertGreaterEqual(len(duplicate_requester_group), 2)
|
| 544 |
+
|
| 545 |
+
env = _make_env()
|
| 546 |
+
with patch.object(env, "_dataset", duplicate_requester_group):
|
| 547 |
+
with patch.object(
|
| 548 |
+
env,
|
| 549 |
+
"_tickets_by_id",
|
| 550 |
+
{ticket.ticket_id: ticket for ticket in duplicate_requester_group},
|
| 551 |
+
):
|
| 552 |
+
obs = env.reset(seed=0, task_id=2, queue_size=1)
|
| 553 |
+
|
| 554 |
+
obs2 = env.step(
|
| 555 |
+
HelpdeskTicketAction(
|
| 556 |
+
action_type="investigate",
|
| 557 |
+
tool_name="lookup_requester_history",
|
| 558 |
+
)
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
self.assertIsNotNone(obs2.last_tool_result)
|
| 562 |
+
self.assertEqual(obs2.last_tool_result["tool_name"], "lookup_requester_history")
|
| 563 |
+
self.assertTrue(obs2.last_tool_result["found"])
|
| 564 |
+
self.assertGreaterEqual(len(obs2.last_tool_result["matches"]), 1)
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
class TestQueueEconomics(unittest.TestCase):
|
| 568 |
+
"""Free investigations are allowed, but excessive investigation gets a queue-level penalty."""
|
| 569 |
+
|
| 570 |
+
def test_extra_investigations_reduce_final_reward(self) -> None:
|
| 571 |
+
from unittest.mock import patch
|
| 572 |
+
|
| 573 |
+
dataset = load_dataset()
|
| 574 |
+
ticket = dataset[0]
|
| 575 |
+
env = _make_env()
|
| 576 |
+
with patch.object(env, "_dataset", [ticket]):
|
| 577 |
+
with patch.object(env, "_tickets_by_id", {ticket.ticket_id: ticket}):
|
| 578 |
+
obs = env.reset(seed=0, task_id=1, queue_size=1)
|
| 579 |
+
|
| 580 |
+
obs = env.step(
|
| 581 |
+
HelpdeskTicketAction(
|
| 582 |
+
action_type="investigate",
|
| 583 |
+
tool_name="lookup_requester_history",
|
| 584 |
+
)
|
| 585 |
+
)
|
| 586 |
+
self.assertEqual(env.state.investigation_steps, 1)
|
| 587 |
+
self.assertEqual(env.state.investigation_budget_remaining, 0)
|
| 588 |
+
|
| 589 |
+
obs = env.step(
|
| 590 |
+
HelpdeskTicketAction(
|
| 591 |
+
action_type="investigate",
|
| 592 |
+
tool_name="lookup_requester_history",
|
| 593 |
+
)
|
| 594 |
+
)
|
| 595 |
+
self.assertEqual(env.state.investigation_steps, 2)
|
| 596 |
+
|
| 597 |
+
final_obs = env.step(HelpdeskTicketAction(issue_type=ticket.issue_type))
|
| 598 |
+
|
| 599 |
+
self.assertTrue(final_obs.done)
|
| 600 |
+
self.assertAlmostEqual(final_obs.reward, 0.98, places=9)
|
| 601 |
+
|
| 602 |
+
|
| 603 |
+
class TestTerminalInvalidActionFinalReward(unittest.TestCase):
|
| 604 |
+
"""Terminal invalid submit actions should still return the queue-level final reward."""
|
| 605 |
+
|
| 606 |
+
def test_last_invalid_submit_returns_trajectory_reward_not_zero(self) -> None:
|
| 607 |
+
from unittest.mock import patch
|
| 608 |
+
|
| 609 |
+
dataset = load_dataset()
|
| 610 |
+
first = dataset[0]
|
| 611 |
+
second = dataset[1]
|
| 612 |
+
|
| 613 |
+
env = _make_env()
|
| 614 |
+
with patch.object(env, "_dataset", [first, second]):
|
| 615 |
+
with patch.object(
|
| 616 |
+
env,
|
| 617 |
+
"_tickets_by_id",
|
| 618 |
+
{first.ticket_id: first, second.ticket_id: second},
|
| 619 |
+
):
|
| 620 |
+
obs = env.reset(seed=0, task_id=1, queue_size=2)
|
| 621 |
+
|
| 622 |
+
tickets_by_id = {first.ticket_id: first, second.ticket_id: second}
|
| 623 |
+
current = tickets_by_id[obs.current_ticket["ticket_id"]]
|
| 624 |
+
obs = env.step(HelpdeskTicketAction(issue_type=current.issue_type))
|
| 625 |
+
self.assertFalse(obs.done)
|
| 626 |
+
|
| 627 |
+
current = tickets_by_id[obs.current_ticket["ticket_id"]]
|
| 628 |
+
final_obs = env.step(
|
| 629 |
+
HelpdeskTicketAction(
|
| 630 |
+
issue_type=current.issue_type,
|
| 631 |
+
priority="medium",
|
| 632 |
+
)
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
self.assertTrue(final_obs.done)
|
| 636 |
+
self.assertAlmostEqual(final_obs.reward, 0.5, places=9)
|
| 637 |
+
self.assertAlmostEqual(env.state.total_reward, 0.5, places=9)
|
| 638 |
+
self.assertAlmostEqual(env.state.reward or 0.0, 0.5, places=9)
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
# ---------------------------------------------------------------------------
|
| 642 |
+
# 9.7 — Dataset has >= 3 non-default routing tickets
|
| 643 |
+
# ---------------------------------------------------------------------------
|
| 644 |
+
|
| 645 |
+
class TestDatasetNonDefaultRouting(unittest.TestCase):
|
| 646 |
+
"""9.7 — Dataset contains at least 3 tickets with non-default assignment_group."""
|
| 647 |
+
|
| 648 |
+
def test_at_least_three_nondefault_routing_tickets(self) -> None:
|
| 649 |
+
from vocabulary import ISSUE_TYPE_TO_ASSIGNMENT_GROUP
|
| 650 |
+
|
| 651 |
+
dataset = load_dataset()
|
| 652 |
+
non_default = [
|
| 653 |
+
t for t in dataset
|
| 654 |
+
if t.assignment_group != ISSUE_TYPE_TO_ASSIGNMENT_GROUP.get(t.issue_type)
|
| 655 |
+
]
|
| 656 |
+
self.assertGreaterEqual(
|
| 657 |
+
len(non_default), 3,
|
| 658 |
+
f"Expected >= 3 non-default routing tickets, found {len(non_default)}: "
|
| 659 |
+
+ str([(t.ticket_id, t.issue_type, t.assignment_group) for t in non_default])
|
| 660 |
+
)
|
| 661 |
+
|
| 662 |
+
def test_tkt_nondefault_tickets_exist(self) -> None:
|
| 663 |
+
dataset = load_dataset()
|
| 664 |
+
ids = {t.ticket_id for t in dataset}
|
| 665 |
+
for expected_id in ("TKT-NONDEFAULT-001", "TKT-NONDEFAULT-002", "TKT-NONDEFAULT-003"):
|
| 666 |
+
self.assertIn(expected_id, ids, f"{expected_id} not found in dataset")
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
# ---------------------------------------------------------------------------
|
| 670 |
+
# 9.9 — SUPPORTS_CONCURRENT_SESSIONS is True
|
| 671 |
+
# ---------------------------------------------------------------------------
|
| 672 |
+
|
| 673 |
+
class TestConcurrentSessionsFlag(unittest.TestCase):
|
| 674 |
+
"""9.9 — HelpdeskTicketRoutingEnvironment.SUPPORTS_CONCURRENT_SESSIONS is True."""
|
| 675 |
+
|
| 676 |
+
def test_supports_concurrent_sessions_is_true(self) -> None:
|
| 677 |
+
self.assertTrue(HelpdeskTicketRoutingEnvironment.SUPPORTS_CONCURRENT_SESSIONS)
|
| 678 |
+
|
| 679 |
+
def test_flag_is_boolean_true(self) -> None:
|
| 680 |
+
flag = HelpdeskTicketRoutingEnvironment.SUPPORTS_CONCURRENT_SESSIONS
|
| 681 |
+
self.assertIs(flag, True)
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
# ---------------------------------------------------------------------------
|
| 685 |
+
# 9.10 — GET /web returns 200 with HTML content
|
| 686 |
+
# ---------------------------------------------------------------------------
|
| 687 |
+
|
| 688 |
+
def _build_web_test_app():
|
| 689 |
+
"""Build a minimal FastAPI app with only the /web route for testing."""
|
| 690 |
+
from fastapi import FastAPI
|
| 691 |
+
from fastapi.responses import HTMLResponse
|
| 692 |
+
from server.tasks import TASKS
|
| 693 |
+
from vocabulary import APP_ENV_NAME
|
| 694 |
+
|
| 695 |
+
_app = FastAPI()
|
| 696 |
+
|
| 697 |
+
@_app.get("/web", response_class=HTMLResponse)
|
| 698 |
+
def web_ui():
|
| 699 |
+
task_rows = "".join(
|
| 700 |
+
f"<tr><td>{t['id']}</td><td>{t['name']}</td><td>{t['difficulty']}</td></tr>"
|
| 701 |
+
for t in TASKS.values()
|
| 702 |
+
)
|
| 703 |
+
html = f"""<!DOCTYPE html>
|
| 704 |
+
<html><head><title>{APP_ENV_NAME}</title></head>
|
| 705 |
+
<body>
|
| 706 |
+
<h1>{APP_ENV_NAME}</h1>
|
| 707 |
+
<p>Version: 0.1.0 | <a href="/health">Health</a> | <a href="/docs">API Docs</a></p>
|
| 708 |
+
<h2>Tasks</h2>
|
| 709 |
+
<table border="1"><tr><th>ID</th><th>Name</th><th>Difficulty</th></tr>
|
| 710 |
+
{task_rows}
|
| 711 |
+
</table>
|
| 712 |
+
</body></html>"""
|
| 713 |
+
return HTMLResponse(content=html)
|
| 714 |
+
|
| 715 |
+
return _app
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
class TestWebUIEndpoint(unittest.TestCase):
|
| 719 |
+
"""9.10 — GET /web returns HTTP 200 with HTML content."""
|
| 720 |
+
|
| 721 |
+
@classmethod
|
| 722 |
+
def setUpClass(cls) -> None:
|
| 723 |
+
from starlette.testclient import TestClient
|
| 724 |
+
app = _build_web_test_app()
|
| 725 |
+
cls.client = TestClient(app)
|
| 726 |
+
|
| 727 |
+
def test_web_returns_200(self) -> None:
|
| 728 |
+
response = self.client.get("/web")
|
| 729 |
+
self.assertEqual(response.status_code, 200)
|
| 730 |
+
|
| 731 |
+
def test_web_returns_html_content_type(self) -> None:
|
| 732 |
+
response = self.client.get("/web")
|
| 733 |
+
self.assertIn("text/html", response.headers.get("content-type", ""))
|
| 734 |
+
|
| 735 |
+
def test_web_response_contains_html_tag(self) -> None:
|
| 736 |
+
response = self.client.get("/web")
|
| 737 |
+
self.assertIn("<!DOCTYPE html>", response.text)
|
| 738 |
+
|
| 739 |
+
def test_web_response_contains_env_name(self) -> None:
|
| 740 |
+
from vocabulary import APP_ENV_NAME
|
| 741 |
+
response = self.client.get("/web")
|
| 742 |
+
self.assertIn(APP_ENV_NAME, response.text)
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
if __name__ == "__main__":
|
| 746 |
+
unittest.main()
|
tests/test_environment_smoke.py
CHANGED
|
@@ -101,6 +101,8 @@ class TestResetReturnsValidObservation(unittest.TestCase):
|
|
| 101 |
self.assertIsNotNone(obs.current_ticket)
|
| 102 |
self.assertGreater(obs.queue_size, 0)
|
| 103 |
self.assertEqual(obs.tickets_processed, 0)
|
|
|
|
|
|
|
| 104 |
|
| 105 |
|
| 106 |
class TestResetAllTaskIds(unittest.TestCase):
|
|
@@ -116,6 +118,7 @@ class TestResetAllTaskIds(unittest.TestCase):
|
|
| 116 |
self.assertEqual(obs.tickets_processed, 0)
|
| 117 |
# allowed_fields must match the task definition
|
| 118 |
self.assertEqual(obs.allowed_fields, TASKS[task_id]["allowed_fields"])
|
|
|
|
| 119 |
|
| 120 |
def test_reset_task2(self) -> None:
|
| 121 |
env = _make_env()
|
|
@@ -142,6 +145,10 @@ class TestStepAdvancesTicketsProcessed(unittest.TestCase):
|
|
| 142 |
obs2 = env.step(action)
|
| 143 |
|
| 144 |
self.assertEqual(obs2.tickets_processed, 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
def test_step_reward_in_unit_interval(self) -> None:
|
| 147 |
from models import HelpdeskTicketAction
|
|
|
|
| 101 |
self.assertIsNotNone(obs.current_ticket)
|
| 102 |
self.assertGreater(obs.queue_size, 0)
|
| 103 |
self.assertEqual(obs.tickets_processed, 0)
|
| 104 |
+
self.assertEqual(obs.queue_position, 1)
|
| 105 |
+
self.assertEqual(obs.tickets_after_current, max(0, obs.queue_size - 1))
|
| 106 |
|
| 107 |
|
| 108 |
class TestResetAllTaskIds(unittest.TestCase):
|
|
|
|
| 118 |
self.assertEqual(obs.tickets_processed, 0)
|
| 119 |
# allowed_fields must match the task definition
|
| 120 |
self.assertEqual(obs.allowed_fields, TASKS[task_id]["allowed_fields"])
|
| 121 |
+
self.assertEqual(obs.queue_position, 1)
|
| 122 |
|
| 123 |
def test_reset_task2(self) -> None:
|
| 124 |
env = _make_env()
|
|
|
|
| 145 |
obs2 = env.step(action)
|
| 146 |
|
| 147 |
self.assertEqual(obs2.tickets_processed, 1)
|
| 148 |
+
if obs2.done:
|
| 149 |
+
self.assertEqual(obs2.queue_position, 0)
|
| 150 |
+
else:
|
| 151 |
+
self.assertEqual(obs2.queue_position, 2)
|
| 152 |
|
| 153 |
def test_step_reward_in_unit_interval(self) -> None:
|
| 154 |
from models import HelpdeskTicketAction
|
tests/test_extra_fields_penalty.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for action field validation (Task 4) in HelpdeskTicketRoutingEnvironment.step().
|
| 3 |
+
|
| 4 |
+
Validates Requirement 7: Step Validates Action Fields Against Task Contract.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
import unittest
|
| 11 |
+
import types as _types
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 14 |
+
|
| 15 |
+
import openenv_test_stubs # noqa: F401
|
| 16 |
+
|
| 17 |
+
if "openenv.core.env_server.interfaces" not in sys.modules:
|
| 18 |
+
_interfaces_mod = _types.ModuleType("openenv.core.env_server.interfaces")
|
| 19 |
+
|
| 20 |
+
class _Environment:
|
| 21 |
+
def __init__(self) -> None:
|
| 22 |
+
pass
|
| 23 |
+
|
| 24 |
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
| 25 |
+
super().__init_subclass__(**kwargs)
|
| 26 |
+
|
| 27 |
+
@classmethod
|
| 28 |
+
def __class_getitem__(cls, item: object) -> type:
|
| 29 |
+
return cls
|
| 30 |
+
|
| 31 |
+
_interfaces_mod.Environment = _Environment # type: ignore[attr-defined]
|
| 32 |
+
sys.modules["openenv.core.env_server.interfaces"] = _interfaces_mod
|
| 33 |
+
|
| 34 |
+
from models import HelpdeskTicketAction, HelpdeskTicketObservation
|
| 35 |
+
from server.environment import HelpdeskTicketRoutingEnvironment
|
| 36 |
+
from server.tasks import TASKS
|
| 37 |
+
from vocabulary import ISSUE_TYPES, PRIORITIES, ASSIGNMENT_GROUPS, RESOLUTION_ACTIONS
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _make_env() -> HelpdeskTicketRoutingEnvironment:
|
| 41 |
+
return HelpdeskTicketRoutingEnvironment()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class TestExtraFieldsPenalty(unittest.TestCase):
|
| 45 |
+
"""Requirement 7: step() rejects actions with fields outside the task's allowed_fields."""
|
| 46 |
+
|
| 47 |
+
def test_extra_fields_returns_reward_zero(self) -> None:
|
| 48 |
+
"""Task 1 only allows issue_type and priority; submitting assignment_group triggers penalty."""
|
| 49 |
+
env = _make_env()
|
| 50 |
+
obs = env.reset(seed=42, task_id=1)
|
| 51 |
+
|
| 52 |
+
# Task 1 allowed_fields should NOT include assignment_group
|
| 53 |
+
self.assertNotIn("assignment_group", obs.allowed_fields)
|
| 54 |
+
|
| 55 |
+
# Submit an action with an extra field (assignment_group) not in task 1's allowed_fields
|
| 56 |
+
action = HelpdeskTicketAction(
|
| 57 |
+
issue_type=ISSUE_TYPES[0],
|
| 58 |
+
priority=PRIORITIES[0],
|
| 59 |
+
assignment_group=ASSIGNMENT_GROUPS[0], # extra field
|
| 60 |
+
)
|
| 61 |
+
penalty_obs = env.step(action)
|
| 62 |
+
|
| 63 |
+
self.assertIsInstance(penalty_obs, HelpdeskTicketObservation)
|
| 64 |
+
self.assertEqual(penalty_obs.reward, 0.0)
|
| 65 |
+
|
| 66 |
+
def test_extra_fields_advances_ticket_index(self) -> None:
|
| 67 |
+
"""Penalty step must advance tickets_processed by 1."""
|
| 68 |
+
env = _make_env()
|
| 69 |
+
obs = env.reset(seed=42, task_id=1)
|
| 70 |
+
self.assertEqual(obs.tickets_processed, 0)
|
| 71 |
+
|
| 72 |
+
action = HelpdeskTicketAction(
|
| 73 |
+
issue_type=ISSUE_TYPES[0],
|
| 74 |
+
assignment_group=ASSIGNMENT_GROUPS[0], # extra field for task 1
|
| 75 |
+
)
|
| 76 |
+
penalty_obs = env.step(action)
|
| 77 |
+
|
| 78 |
+
self.assertEqual(penalty_obs.tickets_processed, 1)
|
| 79 |
+
|
| 80 |
+
def test_extra_fields_records_score_zero(self) -> None:
|
| 81 |
+
"""per_ticket_scores must contain 0.0 after a penalty step."""
|
| 82 |
+
env = _make_env()
|
| 83 |
+
env.reset(seed=42, task_id=1)
|
| 84 |
+
|
| 85 |
+
action = HelpdeskTicketAction(
|
| 86 |
+
issue_type=ISSUE_TYPES[0],
|
| 87 |
+
assignment_group=ASSIGNMENT_GROUPS[0], # extra field
|
| 88 |
+
)
|
| 89 |
+
env.step(action)
|
| 90 |
+
|
| 91 |
+
state = env.state
|
| 92 |
+
self.assertEqual(len(state.per_ticket_scores), 1)
|
| 93 |
+
self.assertEqual(state.per_ticket_scores[0], 0.0)
|
| 94 |
+
|
| 95 |
+
def test_extra_fields_history_entry_has_penalty_reason(self) -> None:
|
| 96 |
+
"""History entry for a penalty step must include penalty_reason."""
|
| 97 |
+
env = _make_env()
|
| 98 |
+
env.reset(seed=42, task_id=1)
|
| 99 |
+
|
| 100 |
+
action = HelpdeskTicketAction(
|
| 101 |
+
issue_type=ISSUE_TYPES[0],
|
| 102 |
+
assignment_group=ASSIGNMENT_GROUPS[0], # extra field
|
| 103 |
+
)
|
| 104 |
+
penalty_obs = env.step(action)
|
| 105 |
+
|
| 106 |
+
self.assertEqual(len(penalty_obs.history), 1)
|
| 107 |
+
entry = penalty_obs.history[0]
|
| 108 |
+
self.assertIn("penalty_reason", entry)
|
| 109 |
+
self.assertIn("assignment_group", entry["penalty_reason"])
|
| 110 |
+
self.assertEqual(entry["score"], 0.0)
|
| 111 |
+
|
| 112 |
+
def test_no_extra_fields_grades_normally(self) -> None:
|
| 113 |
+
"""When action fields are within allowed_fields, grading proceeds normally (reward != forced 0.0)."""
|
| 114 |
+
env = _make_env()
|
| 115 |
+
obs = env.reset(seed=42, task_id=1)
|
| 116 |
+
|
| 117 |
+
# Build action using only allowed fields
|
| 118 |
+
allowed = obs.allowed_fields
|
| 119 |
+
action_kwargs = {}
|
| 120 |
+
if "issue_type" in allowed:
|
| 121 |
+
action_kwargs["issue_type"] = ISSUE_TYPES[0]
|
| 122 |
+
if "priority" in allowed:
|
| 123 |
+
action_kwargs["priority"] = PRIORITIES[0]
|
| 124 |
+
|
| 125 |
+
action = HelpdeskTicketAction(**action_kwargs)
|
| 126 |
+
result_obs = env.step(action)
|
| 127 |
+
|
| 128 |
+
# Should be a valid observation; reward may be any value in [0.0, 1.0]
|
| 129 |
+
self.assertIsInstance(result_obs, HelpdeskTicketObservation)
|
| 130 |
+
self.assertIsNotNone(result_obs.reward)
|
| 131 |
+
# No penalty_reason in history
|
| 132 |
+
self.assertEqual(len(result_obs.history), 1)
|
| 133 |
+
self.assertNotIn("penalty_reason", result_obs.history[0])
|
| 134 |
+
|
| 135 |
+
def test_extra_fields_no_exception_raised(self) -> None:
|
| 136 |
+
"""Requirement 7.4: extra fields must not raise an unhandled exception."""
|
| 137 |
+
env = _make_env()
|
| 138 |
+
env.reset(seed=42, task_id=1)
|
| 139 |
+
|
| 140 |
+
action = HelpdeskTicketAction(
|
| 141 |
+
issue_type=ISSUE_TYPES[0],
|
| 142 |
+
priority=PRIORITIES[0],
|
| 143 |
+
assignment_group=ASSIGNMENT_GROUPS[0],
|
| 144 |
+
resolution_action=RESOLUTION_ACTIONS[0], # multiple extra fields
|
| 145 |
+
)
|
| 146 |
+
try:
|
| 147 |
+
obs = env.step(action)
|
| 148 |
+
except Exception as exc: # noqa: BLE001
|
| 149 |
+
self.fail(f"step() raised an unexpected exception: {exc}")
|
| 150 |
+
|
| 151 |
+
self.assertIsInstance(obs, HelpdeskTicketObservation)
|
| 152 |
+
|
| 153 |
+
def test_extra_fields_done_flag_set_correctly_on_last_ticket(self) -> None:
|
| 154 |
+
"""When the penalty step is on the last ticket, done stays True and reward stays episode-level."""
|
| 155 |
+
env = _make_env()
|
| 156 |
+
obs = env.reset(seed=42, task_id=1)
|
| 157 |
+
queue_size = obs.queue_size
|
| 158 |
+
tickets_by_id = env._tickets_by_id # noqa: SLF001 - test-only inspection
|
| 159 |
+
|
| 160 |
+
# Process all tickets except the last one normally
|
| 161 |
+
for _ in range(queue_size - 1):
|
| 162 |
+
current_ticket_id = obs.current_ticket["ticket_id"]
|
| 163 |
+
current_ticket = tickets_by_id[current_ticket_id]
|
| 164 |
+
obs = env.step(HelpdeskTicketAction(issue_type=current_ticket.issue_type))
|
| 165 |
+
|
| 166 |
+
# Now trigger penalty on the last ticket
|
| 167 |
+
current_ticket_id = obs.current_ticket["ticket_id"]
|
| 168 |
+
current_ticket = tickets_by_id[current_ticket_id]
|
| 169 |
+
action = HelpdeskTicketAction(
|
| 170 |
+
issue_type=current_ticket.issue_type,
|
| 171 |
+
assignment_group=ASSIGNMENT_GROUPS[0], # extra field
|
| 172 |
+
)
|
| 173 |
+
final_obs = env.step(action)
|
| 174 |
+
|
| 175 |
+
self.assertTrue(final_obs.done)
|
| 176 |
+
expected_reward = (queue_size - 1) / queue_size
|
| 177 |
+
self.assertAlmostEqual(final_obs.reward, expected_reward, places=9)
|
| 178 |
+
self.assertAlmostEqual(env.state.total_reward, expected_reward, places=9)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
if __name__ == "__main__":
|
| 182 |
+
unittest.main()
|
tests/test_inference_unit.py
CHANGED
|
@@ -163,6 +163,22 @@ class InferenceUnitTests(unittest.TestCase):
|
|
| 163 |
)
|
| 164 |
)
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
if __name__ == "__main__":
|
| 168 |
unittest.main()
|
|
|
|
| 163 |
)
|
| 164 |
)
|
| 165 |
|
| 166 |
+
def test_default_task_selection_runs_single_first_task(self) -> None:
|
| 167 |
+
inference = _load_inference_module()
|
| 168 |
+
|
| 169 |
+
self.assertEqual(
|
| 170 |
+
inference.get_tasks_to_run({1: {}, 2: {}, 3: {}}),
|
| 171 |
+
[1],
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
def test_run_all_tasks_override_keeps_local_batch_mode_available(self) -> None:
|
| 175 |
+
inference = _load_inference_module({"RUN_ALL_TASKS": "1"})
|
| 176 |
+
|
| 177 |
+
self.assertEqual(
|
| 178 |
+
inference.get_tasks_to_run({1: {}, 2: {}, 3: {}}),
|
| 179 |
+
[1, 2, 3],
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
|
| 183 |
if __name__ == "__main__":
|
| 184 |
unittest.main()
|
tests/test_tasks_unit.py
CHANGED
|
@@ -50,7 +50,7 @@ class TasksAndDatasetUnitTests(unittest.TestCase):
|
|
| 50 |
def test_load_dataset_returns_valid_records(self) -> None:
|
| 51 |
dataset = load_dataset()
|
| 52 |
|
| 53 |
-
self.
|
| 54 |
self.assertTrue(all(isinstance(record, HelpdeskTicketRecord) for record in dataset))
|
| 55 |
|
| 56 |
def test_dataset_ticket_ids_are_unique(self) -> None:
|
|
|
|
| 50 |
def test_load_dataset_returns_valid_records(self) -> None:
|
| 51 |
dataset = load_dataset()
|
| 52 |
|
| 53 |
+
self.assertGreaterEqual(len(dataset), 45)
|
| 54 |
self.assertTrue(all(isinstance(record, HelpdeskTicketRecord) for record in dataset))
|
| 55 |
|
| 56 |
def test_dataset_ticket_ids_are_unique(self) -> None:
|