openenv space
Browse files- .dockerignore +5 -0
- .gitignore +5 -0
- Dockerfile +19 -0
- README.md +219 -5
- __init__.py +16 -0
- client.py +85 -0
- data/knowledge_base.json +62 -0
- data/tickets/easy.json +62 -0
- data/tickets/hard.json +92 -0
- data/tickets/medium.json +72 -0
- environment.py +397 -0
- graders/__init__.py +1 -0
- graders/category_grader.py +38 -0
- graders/faq_grader.py +28 -0
- graders/resolution_grader.py +29 -0
- inference.py +268 -0
- models.py +89 -0
- openenv.yaml +29 -0
- pyrightconfig.json +7 -0
- requirements.txt +5 -0
- server/__init__.py +1 -0
- server/app.py +79 -0
- server/helpdesk_environment.py +10 -0
- user_simulator.py +46 -0
.dockerignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
__pycache__/
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.venv/
|
| 5 |
+
*.pyc
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
__pycache__/
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.venv/
|
| 5 |
+
*.pyc
|
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 6 |
+
ENV PYTHONUNBUFFERED=1
|
| 7 |
+
ENV PYTHONPATH=/app
|
| 8 |
+
|
| 9 |
+
COPY requirements.txt /app/requirements.txt
|
| 10 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt
|
| 11 |
+
|
| 12 |
+
COPY . /app/helpdesk_env
|
| 13 |
+
|
| 14 |
+
EXPOSE 8000
|
| 15 |
+
|
| 16 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
| 17 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')" || exit 1
|
| 18 |
+
|
| 19 |
+
CMD ["uvicorn", "helpdesk_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
@@ -1,10 +1,224 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: UPI Banking Support Environment
|
| 3 |
+
emoji: 🏦
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 8000
|
| 9 |
+
tags:
|
| 10 |
+
- openenv
|
| 11 |
+
- banking
|
| 12 |
+
- upi
|
| 13 |
+
- customer-support
|
| 14 |
---
|
| 15 |
|
| 16 |
+
# UPI Banking Support Environment
|
| 17 |
+
|
| 18 |
+
OpenEnv-style environment for evaluating agents on UPI customer support workflows. The benchmark focuses on realistic banking support decisions rather than generic FAQ matching.
|
| 19 |
+
|
| 20 |
+
## Motivation
|
| 21 |
+
|
| 22 |
+
This environment is designed to test whether an agent can behave like a safe and useful support assistant for a UPI payments product such as Paytm, PhonePe, or Google Pay style support flows.
|
| 23 |
+
|
| 24 |
+
The goal is not only to answer customers correctly, but also to:
|
| 25 |
+
- identify the right issue type
|
| 26 |
+
- retrieve the right knowledge entry
|
| 27 |
+
- escalate fraud or overdue review cases when needed
|
| 28 |
+
- avoid unsafe behavior such as asking for PINs or OTPs
|
| 29 |
+
- handle multi-turn conversations before closing a case
|
| 30 |
+
|
| 31 |
+
## Environment Description
|
| 32 |
+
|
| 33 |
+
The environment uses three tasks with increasing difficulty:
|
| 34 |
+
- `easy`: classify a customer issue into the correct support track
|
| 35 |
+
- `medium`: choose the right FAQ or escalate when human/manual review is required
|
| 36 |
+
- `hard`: run a short multi-turn support conversation with clarification, guidance, and closure
|
| 37 |
+
|
| 38 |
+
The current support tracks are:
|
| 39 |
+
- `payment_failure`
|
| 40 |
+
- `refund_delay`
|
| 41 |
+
- `fraud_complaint`
|
| 42 |
+
- `kyc_account_restriction`
|
| 43 |
+
- `upi_pin_or_bank_linking`
|
| 44 |
+
|
| 45 |
+
The dataset includes:
|
| 46 |
+
- 10 banking FAQ entries in [knowledge_base.json](/Users/shivanshmundra/Downloads/MetaHack/helpdesk-env/envs/helpdesk_env/data/knowledge_base.json)
|
| 47 |
+
- 10 `easy` tickets in [easy.json](/Users/shivanshmundra/Downloads/MetaHack/helpdesk-env/envs/helpdesk_env/data/tickets/easy.json)
|
| 48 |
+
- 10 `medium` tickets in [medium.json](/Users/shivanshmundra/Downloads/MetaHack/helpdesk-env/envs/helpdesk_env/data/tickets/medium.json)
|
| 49 |
+
- 10 `hard` tickets in [hard.json](/Users/shivanshmundra/Downloads/MetaHack/helpdesk-env/envs/helpdesk_env/data/tickets/hard.json)
|
| 50 |
+
|
| 51 |
+
## Action Space
|
| 52 |
+
|
| 53 |
+
The public baseline and server currently accept the legacy action names below, which are internally mapped to the compact action model in [models.py](/Users/shivanshmundra/Downloads/MetaHack/helpdesk-env/envs/helpdesk_env/models.py).
|
| 54 |
+
|
| 55 |
+
| Action | Parameters | Purpose |
|
| 56 |
+
|---|---|---|
|
| 57 |
+
| `classify` | `category` | Predict the correct support track for an `easy` ticket |
|
| 58 |
+
| `lookup_faq` | `faq_id` | Choose the best FAQ entry for `medium` or `hard` |
|
| 59 |
+
| `ask_clarification` | `message` | Ask a question to gather missing details in `hard` |
|
| 60 |
+
| `reply` | `message` | Provide safe support guidance to the user |
|
| 61 |
+
| `escalate` | `message` | Escalate a case that should not be fully handled automatically |
|
| 62 |
+
| `resolve_ticket` | none | Close the case when it appears correctly resolved |
|
| 63 |
+
|
| 64 |
+
Internally, these are normalized to:
|
| 65 |
+
- `ask_for_details`
|
| 66 |
+
- `take_action`
|
| 67 |
+
- `respond_to_user`
|
| 68 |
+
- `escalate_case`
|
| 69 |
+
- `close_case`
|
| 70 |
+
|
| 71 |
+
## Observation Space
|
| 72 |
+
|
| 73 |
+
The model receives an `Observation` object from [models.py](/Users/shivanshmundra/Downloads/MetaHack/helpdesk-env/envs/helpdesk_env/models.py).
|
| 74 |
+
|
| 75 |
+
| Field | Type | Description |
|
| 76 |
+
|---|---|---|
|
| 77 |
+
| `case_id` | `str` | Unique identifier for the active ticket |
|
| 78 |
+
| `track` | `str` | Task split only: `easy`, `medium`, or `hard` |
|
| 79 |
+
| `customer_message` | `str` | Current customer issue text shown to the agent |
|
| 80 |
+
| `conversation_history` | `list[dict]` | Prior user/agent turns |
|
| 81 |
+
| `known_facts` | `dict` | Agent-visible state such as FAQ set, available categories, and progress flags |
|
| 82 |
+
| `required_slots` | `list[str]` | High-level missing information requirements for the episode |
|
| 83 |
+
| `available_actions` | `list[str]` | Actions allowed by the environment |
|
| 84 |
+
| `turn_number` | `int` | Current turn count |
|
| 85 |
+
|
| 86 |
+
Important evaluation detail:
|
| 87 |
+
- hidden gold labels such as the correct FAQ id and escalation label are not exposed to the model in the observation
|
| 88 |
+
|
| 89 |
+
## Reward
|
| 90 |
+
|
| 91 |
+
Rewards are normalized to the range `0.0` to `1.0` in [environment.py](/Users/shivanshmundra/Downloads/MetaHack/helpdesk-env/envs/helpdesk_env/environment.py).
|
| 92 |
+
|
| 93 |
+
The final reward is shaped rather than purely binary. It combines:
|
| 94 |
+
- `correctness`
|
| 95 |
+
- `safety`
|
| 96 |
+
- `resolution`
|
| 97 |
+
- `efficiency`
|
| 98 |
+
- `penalties`
|
| 99 |
+
|
| 100 |
+
Weighted reward:
|
| 101 |
+
|
| 102 |
+
```text
|
| 103 |
+
0.35 * correctness
|
| 104 |
+
+ 0.30 * safety
|
| 105 |
+
+ 0.20 * resolution
|
| 106 |
+
+ 0.15 * efficiency
|
| 107 |
+
+ penalties
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
Examples:
|
| 111 |
+
- correct classification gives a strong `easy` reward
|
| 112 |
+
- correct FAQ retrieval gives partial progress on `medium`
|
| 113 |
+
- correct escalation gives reward on `medium`
|
| 114 |
+
- clarification plus guidance plus successful closure raises `hard` reward
|
| 115 |
+
- unsafe prompts such as asking for PIN or OTP reduce reward sharply
|
| 116 |
+
|
| 117 |
+
## Task Difficulty
|
| 118 |
+
|
| 119 |
+
| Task | Difficulty | Description | Expected Agent Behavior |
|
| 120 |
+
|---|---|---|---|
|
| 121 |
+
| `easy` | Low | Single-turn issue classification | Identify the correct banking support track |
|
| 122 |
+
| `medium` | Medium | FAQ retrieval or escalation decision | Select the right FAQ or escalate fraud / overdue review cases |
|
| 123 |
+
| `hard` | High | Multi-turn support conversation | Ask clarification, guide safely, and close only when appropriate |
|
| 124 |
+
|
| 125 |
+
## Setup
|
| 126 |
+
|
| 127 |
+
From the package root:
|
| 128 |
+
|
| 129 |
+
```bash
|
| 130 |
+
cd /path/to/helpdesk_env
|
| 131 |
+
python3 -m venv .venv
|
| 132 |
+
.venv/bin/pip install -r requirements.txt
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
## Usage
|
| 136 |
+
|
| 137 |
+
### Run Tests
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
cd /path/to/helpdesk_env
|
| 141 |
+
.venv/bin/python -m py_compile environment.py inference.py models.py
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
### Run the Server
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
cd /path/to
|
| 148 |
+
PYTHONPATH=. /path/to/helpdesk_env/.venv/bin/uvicorn helpdesk_env.server.app:app --host 127.0.0.1 --port 8000
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
### Build the Docker Image
|
| 152 |
+
|
| 153 |
+
```bash
|
| 154 |
+
cd /path/to/helpdesk_env
|
| 155 |
+
docker build -t helpdesk-openenv .
|
| 156 |
+
docker run --rm -p 8000:8000 helpdesk-openenv
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
### Use the Python Client
|
| 160 |
+
|
| 161 |
+
```python
|
| 162 |
+
from helpdesk_env.client import HelpdeskEnvClient
|
| 163 |
+
|
| 164 |
+
client = HelpdeskEnvClient("http://127.0.0.1:8000")
|
| 165 |
+
result = client.reset("easy")
|
| 166 |
+
print(result.observation.customer_message)
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
### Run Inference
|
| 170 |
+
|
| 171 |
+
```bash
|
| 172 |
+
cd /path/to/helpdesk_env
|
| 173 |
+
export GROQ_API_KEY=your_key
|
| 174 |
+
.venv/bin/python inference.py
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
Optional model override:
|
| 178 |
+
|
| 179 |
+
```bash
|
| 180 |
+
export LLM_MODEL=llama-3.1-8b-instant
|
| 181 |
+
export TASK_NAME=medium
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
## Baseline Scores
|
| 185 |
+
|
| 186 |
+
Latest observed Groq baseline run after removing answer leakage from the observation:
|
| 187 |
+
|
| 188 |
+
| Model | Easy | Medium | Hard | Average |
|
| 189 |
+
|---|---:|---:|---:|---:|
|
| 190 |
+
| `llama-3.3-70b-versatile` | 1.00 | 0.60 | 0.59 | 0.73 |
|
| 191 |
+
|
| 192 |
+
Interpretation:
|
| 193 |
+
- `easy` is still quite direct and can be near-perfect for strong LLMs
|
| 194 |
+
- `medium` and `hard` are more informative because they require retrieval, escalation judgment, and multi-turn behavior
|
| 195 |
+
|
| 196 |
+
## Project Structure
|
| 197 |
+
|
| 198 |
+
```text
|
| 199 |
+
helpdesk_env/
|
| 200 |
+
├── README.md
|
| 201 |
+
├── Dockerfile
|
| 202 |
+
├── .gitignore
|
| 203 |
+
├── .dockerignore
|
| 204 |
+
├── __init__.py
|
| 205 |
+
├── client.py
|
| 206 |
+
├── data/
|
| 207 |
+
│ ├── knowledge_base.json
|
| 208 |
+
│ └── tickets/
|
| 209 |
+
│ ├── easy.json
|
| 210 |
+
│ ├── medium.json
|
| 211 |
+
│ └── hard.json
|
| 212 |
+
├── environment.py
|
| 213 |
+
├── inference.py
|
| 214 |
+
├── models.py
|
| 215 |
+
├── openenv.yaml
|
| 216 |
+
├── requirements.txt
|
| 217 |
+
├── graders/
|
| 218 |
+
│ ├── category_grader.py
|
| 219 |
+
│ ├── faq_grader.py
|
| 220 |
+
│ └── resolution_grader.py
|
| 221 |
+
└── server/
|
| 222 |
+
├── app.py
|
| 223 |
+
└── helpdesk_environment.py
|
| 224 |
+
```
|
__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .client import HelpdeskEnvClient
|
| 2 |
+
from .environment import HelpdeskEnv
|
| 3 |
+
from .models import Action, Observation, Reward, TicketState
|
| 4 |
+
|
| 5 |
+
# OpenEnv-style alias for episode/ticket state
|
| 6 |
+
State = TicketState
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"Action",
|
| 10 |
+
"Observation",
|
| 11 |
+
"Reward",
|
| 12 |
+
"TicketState",
|
| 13 |
+
"State",
|
| 14 |
+
"HelpdeskEnv",
|
| 15 |
+
"HelpdeskEnvClient",
|
| 16 |
+
]
|
client.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP client for the Helpdesk OpenEnv server (see server/app.py)."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any, Dict, Optional
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
|
| 8 |
+
from .models import Action, Observation, Reward
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class StepResult:
|
| 13 |
+
observation: Observation
|
| 14 |
+
reward: Reward
|
| 15 |
+
done: bool
|
| 16 |
+
info: Dict[str, Any]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class HelpdeskEnvClient:
|
| 20 |
+
"""Minimal client for POST /reset and POST /step on the FastAPI server."""
|
| 21 |
+
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
base_url: str,
|
| 25 |
+
request_timeout_s: float = 60.0,
|
| 26 |
+
):
|
| 27 |
+
self._base = base_url.rstrip("/")
|
| 28 |
+
self._timeout = float(request_timeout_s)
|
| 29 |
+
self._http = requests.Session()
|
| 30 |
+
|
| 31 |
+
def reset(self, task_id: str = "easy") -> StepResult:
|
| 32 |
+
r = self._http.post(
|
| 33 |
+
f"{self._base}/reset",
|
| 34 |
+
json={"task_id": task_id},
|
| 35 |
+
timeout=self._timeout,
|
| 36 |
+
)
|
| 37 |
+
r.raise_for_status()
|
| 38 |
+
data = r.json()
|
| 39 |
+
obs = Observation(**data["observation"])
|
| 40 |
+
rew = (
|
| 41 |
+
Reward(**data["reward"])
|
| 42 |
+
if data.get("reward") is not None
|
| 43 |
+
else Reward(
|
| 44 |
+
value=0.0,
|
| 45 |
+
correctness=0.0,
|
| 46 |
+
safety=1.0,
|
| 47 |
+
resolution=0.0,
|
| 48 |
+
efficiency=0.0,
|
| 49 |
+
penalties=0.0,
|
| 50 |
+
done=False,
|
| 51 |
+
info={},
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
+
return StepResult(
|
| 55 |
+
observation=obs,
|
| 56 |
+
reward=rew,
|
| 57 |
+
done=bool(data.get("done", False)),
|
| 58 |
+
info=dict(data.get("info") or {}),
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
def step(self, action: Action) -> StepResult:
|
| 62 |
+
r = self._http.post(
|
| 63 |
+
f"{self._base}/step",
|
| 64 |
+
json={"action": action.model_dump()},
|
| 65 |
+
timeout=self._timeout,
|
| 66 |
+
)
|
| 67 |
+
r.raise_for_status()
|
| 68 |
+
data = r.json()
|
| 69 |
+
return StepResult(
|
| 70 |
+
observation=Observation(**data["observation"]),
|
| 71 |
+
reward=Reward(**data["reward"]),
|
| 72 |
+
done=bool(data.get("done", False)),
|
| 73 |
+
info=dict(data.get("info") or {}),
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
def state(self) -> Observation:
|
| 77 |
+
r = self._http.get(f"{self._base}/state", timeout=self._timeout)
|
| 78 |
+
r.raise_for_status()
|
| 79 |
+
data = r.json()
|
| 80 |
+
return Observation(**data["observation"])
|
| 81 |
+
|
| 82 |
+
def health(self) -> Dict[str, str]:
|
| 83 |
+
r = self._http.get(f"{self._base}/health", timeout=self._timeout)
|
| 84 |
+
r.raise_for_status()
|
| 85 |
+
return dict(r.json())
|
data/knowledge_base.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "faq_001",
|
| 4 |
+
"category": "payment_failure",
|
| 5 |
+
"question": "What should I do if a UPI payment failed but money was debited?",
|
| 6 |
+
"answer": "If the payment status shows failed but the amount was debited, ask the customer to wait up to 24 hours for an automatic reversal. Collect the UTR, amount, and transaction time. Escalate only if the debit is not reversed after the standard window."
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"id": "faq_002",
|
| 10 |
+
"category": "payment_failure",
|
| 11 |
+
"question": "What if the merchant says payment was not received even though the customer paid?",
|
| 12 |
+
"answer": "Ask for the UTR, merchant name, amount, and time of payment. If the transaction is pending or processing, advise the customer to wait for final status. If the status remains unresolved beyond the expected window, raise a payments investigation."
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"id": "faq_003",
|
| 16 |
+
"category": "refund_delay",
|
| 17 |
+
"question": "How should support handle a delayed refund in a UPI app?",
|
| 18 |
+
"answer": "Confirm the original transaction reference, refund reference if available, amount, and merchant name. Inform the customer that refunds may take several business days depending on the bank and merchant. Escalate when the refund exceeds the documented turnaround time."
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "faq_004",
|
| 22 |
+
"category": "refund_delay",
|
| 23 |
+
"question": "What if the merchant claims a refund was completed but the customer has not received it?",
|
| 24 |
+
"answer": "Verify the refund date, amount, merchant, and UTR or ARN if shared by the merchant. Check whether the refund is still in progress at the bank side. Escalate when the refund is marked complete but remains uncredited past the expected settlement window."
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"id": "faq_005",
|
| 28 |
+
"category": "fraud_complaint",
|
| 29 |
+
"question": "How should an unauthorized UPI transaction be handled?",
|
| 30 |
+
"answer": "Treat unauthorized payment reports as high priority. Do not ask for PIN, OTP, CVV, or full card details. Advise the customer to secure the account immediately, verify recent activity, and escalate to the fraud team for formal review."
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"id": "faq_006",
|
| 34 |
+
"category": "kyc_account_restriction",
|
| 35 |
+
"question": "What should support say when a wallet or account is restricted due to KYC issues?",
|
| 36 |
+
"answer": "Explain whether the restriction is due to pending, expired, or failed KYC verification. Ask the customer to confirm the registered details and complete the required KYC steps in-app. Escalate only if the account remains restricted after successful verification or manual review is needed."
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": "faq_007",
|
| 40 |
+
"category": "kyc_account_restriction",
|
| 41 |
+
"question": "What if a customer says their KYC was submitted but the account is still blocked?",
|
| 42 |
+
"answer": "Confirm when the documents were submitted and whether any rejection message is shown. If review is still in progress, provide the expected review timeline. Escalate to the KYC team if the review is overdue or the account is blocked despite successful verification."
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"id": "faq_008",
|
| 46 |
+
"category": "upi_pin_or_bank_linking",
|
| 47 |
+
"question": "How do you handle UPI PIN setup or reset issues safely?",
|
| 48 |
+
"answer": "Never ask for the customer’s UPI PIN or OTP. Confirm whether the SIM is active on the same device, whether the debit card details were entered correctly, and whether the bank is supported. Suggest retrying after checking SMS permissions and bank availability."
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"id": "faq_009",
|
| 52 |
+
"category": "upi_pin_or_bank_linking",
|
| 53 |
+
"question": "What if the customer cannot link a bank account in the UPI app?",
|
| 54 |
+
"answer": "Check whether the registered mobile number matches the bank account, the SIM is present in the device, and the bank’s UPI service is currently available. Ask for the bank name and exact error message. Escalate only if the account remains unlinked after standard troubleshooting."
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"id": "faq_010",
|
| 58 |
+
"category": "fraud_complaint",
|
| 59 |
+
"question": "What if the customer clicked a scam collect request or shared app access?",
|
| 60 |
+
"answer": "Advise the customer to secure the account immediately, review recent transactions, and report the incident as potential fraud. Do not promise a refund. Escalate to the fraud team for investigation and next steps."
|
| 61 |
+
}
|
| 62 |
+
]
|
data/tickets/easy.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "easy_001",
|
| 4 |
+
"text": "My UPI payment failed but the money has already been deducted from my bank account.",
|
| 5 |
+
"gold_category": "payment_failure",
|
| 6 |
+
"difficulty": "easy"
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"id": "easy_002",
|
| 10 |
+
"text": "The merchant says they did not receive my payment even though the app showed money debited.",
|
| 11 |
+
"gold_category": "payment_failure",
|
| 12 |
+
"difficulty": "easy"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"id": "easy_003",
|
| 16 |
+
"text": "A merchant refunded me three days ago but I still do not see the money in my account.",
|
| 17 |
+
"gold_category": "refund_delay",
|
| 18 |
+
"difficulty": "easy"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "easy_004",
|
| 22 |
+
"text": "The seller says refund is completed but nothing has reached my bank yet.",
|
| 23 |
+
"gold_category": "refund_delay",
|
| 24 |
+
"difficulty": "easy"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"id": "easy_005",
|
| 28 |
+
"text": "I did not make this UPI payment and I think someone used my account.",
|
| 29 |
+
"gold_category": "fraud_complaint",
|
| 30 |
+
"difficulty": "easy"
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"id": "easy_006",
|
| 34 |
+
"text": "I accepted a strange collect request and now money is gone from my account.",
|
| 35 |
+
"gold_category": "fraud_complaint",
|
| 36 |
+
"difficulty": "easy"
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": "easy_007",
|
| 40 |
+
"text": "My wallet is restricted because KYC is still pending.",
|
| 41 |
+
"gold_category": "kyc_account_restriction",
|
| 42 |
+
"difficulty": "easy"
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"id": "easy_008",
|
| 46 |
+
"text": "I submitted my KYC but the account is still blocked.",
|
| 47 |
+
"gold_category": "kyc_account_restriction",
|
| 48 |
+
"difficulty": "easy"
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"id": "easy_009",
|
| 52 |
+
"text": "I cannot reset my UPI PIN on the app.",
|
| 53 |
+
"gold_category": "upi_pin_or_bank_linking",
|
| 54 |
+
"difficulty": "easy"
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"id": "easy_010",
|
| 58 |
+
"text": "My bank account is not linking in the UPI app even though the mobile number is correct.",
|
| 59 |
+
"gold_category": "upi_pin_or_bank_linking",
|
| 60 |
+
"difficulty": "easy"
|
| 61 |
+
}
|
| 62 |
+
]
|
data/tickets/hard.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "hard_001",
|
| 4 |
+
"initial_text": "My payment is messed up and I need help right now.",
|
| 5 |
+
"issue_category": "payment_failure",
|
| 6 |
+
"gold_faq_id": "faq_001",
|
| 7 |
+
"trigger_phrases": ["utr", "amount", "transaction time"],
|
| 8 |
+
"clarified_text": "The payment failed but the amount was debited from my bank account about 20 minutes ago.",
|
| 9 |
+
"difficulty": "hard"
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"id": "hard_002",
|
| 13 |
+
"initial_text": "I paid the shop but they are saying payment never came.",
|
| 14 |
+
"issue_category": "payment_failure",
|
| 15 |
+
"gold_faq_id": "faq_002",
|
| 16 |
+
"trigger_phrases": ["merchant name", "utr", "pending"],
|
| 17 |
+
"clarified_text": "The merchant says unpaid, but my app shows money debited and I have the UTR.",
|
| 18 |
+
"difficulty": "hard"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "hard_003",
|
| 22 |
+
"initial_text": "I am waiting for my money back and no one is helping.",
|
| 23 |
+
"issue_category": "refund_delay",
|
| 24 |
+
"gold_faq_id": "faq_003",
|
| 25 |
+
"trigger_phrases": ["refund reference", "merchant", "amount"],
|
| 26 |
+
"clarified_text": "The order was cancelled and the merchant told me the refund would come, but it is still not credited.",
|
| 27 |
+
"difficulty": "hard"
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"id": "hard_004",
|
| 31 |
+
"initial_text": "Refund issue again. This is getting frustrating.",
|
| 32 |
+
"issue_category": "refund_delay",
|
| 33 |
+
"gold_faq_id": "faq_004",
|
| 34 |
+
"trigger_phrases": ["refund date", "utr", "bank account"],
|
| 35 |
+
"clarified_text": "The merchant claims the refund was completed, but my bank account still does not show the amount.",
|
| 36 |
+
"difficulty": "hard"
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": "hard_005",
|
| 40 |
+
"initial_text": "Someone took money from my UPI account and I did not do it.",
|
| 41 |
+
"issue_category": "fraud_complaint",
|
| 42 |
+
"gold_faq_id": "faq_005",
|
| 43 |
+
"trigger_phrases": ["unauthorized", "secure account", "recent transaction"],
|
| 44 |
+
"clarified_text": "I saw a payment I never approved and I am worried my account has been compromised.",
|
| 45 |
+
"difficulty": "hard"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"id": "hard_006",
|
| 49 |
+
"initial_text": "My wallet is blocked and I cannot use the app properly.",
|
| 50 |
+
"issue_category": "kyc_account_restriction",
|
| 51 |
+
"gold_faq_id": "faq_006",
|
| 52 |
+
"trigger_phrases": ["kyc status", "restriction reason", "verification"],
|
| 53 |
+
"clarified_text": "The app says my wallet is restricted because KYC is pending, but I am not sure what to do next.",
|
| 54 |
+
"difficulty": "hard"
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"id": "hard_007",
|
| 58 |
+
"initial_text": "I already uploaded my documents and the account is still blocked.",
|
| 59 |
+
"issue_category": "kyc_account_restriction",
|
| 60 |
+
"gold_faq_id": "faq_007",
|
| 61 |
+
"trigger_phrases": ["submission date", "review status", "blocked after kyc"],
|
| 62 |
+
"clarified_text": "I submitted KYC documents days ago, but the account is still blocked with no update.",
|
| 63 |
+
"difficulty": "hard"
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"id": "hard_008",
|
| 67 |
+
"initial_text": "I am unable to set my UPI PIN and the app keeps failing.",
|
| 68 |
+
"issue_category": "upi_pin_or_bank_linking",
|
| 69 |
+
"gold_faq_id": "faq_008",
|
| 70 |
+
"trigger_phrases": ["same device", "sms permission", "debit card"],
|
| 71 |
+
"clarified_text": "I am trying to set the UPI PIN after changing phones and the app fails during verification.",
|
| 72 |
+
"difficulty": "hard"
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"id": "hard_009",
|
| 76 |
+
"initial_text": "My bank account just will not link and I have no idea why.",
|
| 77 |
+
"issue_category": "upi_pin_or_bank_linking",
|
| 78 |
+
"gold_faq_id": "faq_009",
|
| 79 |
+
"trigger_phrases": ["bank name", "registered mobile number", "error message"],
|
| 80 |
+
"clarified_text": "The bank account is not showing in the app even though the mobile number is linked to the bank.",
|
| 81 |
+
"difficulty": "hard"
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"id": "hard_010",
|
| 85 |
+
"initial_text": "I clicked something strange and now money is gone from my account.",
|
| 86 |
+
"issue_category": "fraud_complaint",
|
| 87 |
+
"gold_faq_id": "faq_010",
|
| 88 |
+
"trigger_phrases": ["collect request", "scam", "secure account"],
|
| 89 |
+
"clarified_text": "I accepted a suspicious collect request and now I think I was scammed through UPI.",
|
| 90 |
+
"difficulty": "hard"
|
| 91 |
+
}
|
| 92 |
+
]
|
data/tickets/medium.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "medium_001",
|
| 4 |
+
"text": "UPI transaction failed and money got debited. What should I tell the customer?",
|
| 5 |
+
"gold_faq_id": "faq_001",
|
| 6 |
+
"should_escalate": false,
|
| 7 |
+
"difficulty": "medium"
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"id": "medium_002",
|
| 11 |
+
"text": "Merchant says payment not received even though the user paid through UPI.",
|
| 12 |
+
"gold_faq_id": "faq_002",
|
| 13 |
+
"should_escalate": false,
|
| 14 |
+
"difficulty": "medium"
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"id": "medium_003",
|
| 18 |
+
"text": "Customer says the refund still has not arrived after the order was cancelled.",
|
| 19 |
+
"gold_faq_id": "faq_003",
|
| 20 |
+
"should_escalate": false,
|
| 21 |
+
"difficulty": "medium"
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"id": "medium_004",
|
| 25 |
+
"text": "Merchant says refund completed two days ago but the amount is not in the bank account.",
|
| 26 |
+
"gold_faq_id": "faq_004",
|
| 27 |
+
"should_escalate": false,
|
| 28 |
+
"difficulty": "medium"
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"id": "medium_005",
|
| 32 |
+
"text": "Customer reports an unauthorized UPI payment from their account.",
|
| 33 |
+
"gold_faq_id": "faq_005",
|
| 34 |
+
"should_escalate": true,
|
| 35 |
+
"difficulty": "medium"
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"id": "medium_006",
|
| 39 |
+
"text": "Customer says the wallet is restricted because KYC is pending.",
|
| 40 |
+
"gold_faq_id": "faq_006",
|
| 41 |
+
"should_escalate": false,
|
| 42 |
+
"difficulty": "medium"
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"id": "medium_007",
|
| 46 |
+
"text": "KYC was submitted last week but the account is still blocked with no update.",
|
| 47 |
+
"gold_faq_id": "faq_007",
|
| 48 |
+
"should_escalate": true,
|
| 49 |
+
"difficulty": "medium"
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"id": "medium_008",
|
| 53 |
+
"text": "User cannot set or reset the UPI PIN and wants next steps.",
|
| 54 |
+
"gold_faq_id": "faq_008",
|
| 55 |
+
"should_escalate": false,
|
| 56 |
+
"difficulty": "medium"
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"id": "medium_009",
|
| 60 |
+
"text": "The bank account is not linking in the UPI app even after several tries.",
|
| 61 |
+
"gold_faq_id": "faq_009",
|
| 62 |
+
"should_escalate": false,
|
| 63 |
+
"difficulty": "medium"
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"id": "medium_010",
|
| 67 |
+
"text": "Customer clicked a suspicious collect request and now says the transfer was not authorized.",
|
| 68 |
+
"gold_faq_id": "faq_010",
|
| 69 |
+
"should_escalate": true,
|
| 70 |
+
"difficulty": "medium"
|
| 71 |
+
}
|
| 72 |
+
]
|
environment.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import random
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 5 |
+
|
| 6 |
+
from .graders.category_grader import grade_classification, grade_information_collection
|
| 7 |
+
from .graders.faq_grader import (
|
| 8 |
+
grade_escalation,
|
| 9 |
+
grade_faq_retrieval,
|
| 10 |
+
grade_operation_choice,
|
| 11 |
+
)
|
| 12 |
+
from .graders.resolution_grader import grade_case_closure, grade_resolution
|
| 13 |
+
from .models import Action, Observation, Reward, TicketState
|
| 14 |
+
from .user_simulator import UserSimulator
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _data_dir() -> Path:
|
| 18 |
+
return Path(__file__).resolve().parent / "data"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class HelpdeskEnv:
|
| 22 |
+
def __init__(self):
|
| 23 |
+
data_dir = _data_dir()
|
| 24 |
+
tickets_dir = data_dir / "tickets"
|
| 25 |
+
|
| 26 |
+
with open(data_dir / "knowledge_base.json", "r", encoding="utf-8") as f:
|
| 27 |
+
self.kb: List[Dict[str, str]] = json.load(f)
|
| 28 |
+
with open(tickets_dir / "easy.json", "r", encoding="utf-8") as f:
|
| 29 |
+
self.easy_tickets: List[Dict[str, Any]] = json.load(f)
|
| 30 |
+
with open(tickets_dir / "medium.json", "r", encoding="utf-8") as f:
|
| 31 |
+
self.medium_tickets: List[Dict[str, Any]] = json.load(f)
|
| 32 |
+
with open(tickets_dir / "hard.json", "r", encoding="utf-8") as f:
|
| 33 |
+
self.hard_tickets: List[Dict[str, Any]] = json.load(f)
|
| 34 |
+
|
| 35 |
+
self.current_ticket: Optional[Dict[str, Any]] = None
|
| 36 |
+
self.ticket_state: Optional[TicketState] = None
|
| 37 |
+
self.user_sim: Optional[UserSimulator] = None
|
| 38 |
+
self.task_id: str = "easy"
|
| 39 |
+
self.turn_number: int = 0
|
| 40 |
+
self.conversation_history: List[Dict[str, str]] = []
|
| 41 |
+
self.action_history: List[str] = []
|
| 42 |
+
|
| 43 |
+
def reset(self, task_id: str = "easy") -> Observation:
|
| 44 |
+
pool_map = {
|
| 45 |
+
"easy": self.easy_tickets,
|
| 46 |
+
"medium": self.medium_tickets,
|
| 47 |
+
"hard": self.hard_tickets,
|
| 48 |
+
}
|
| 49 |
+
if task_id not in pool_map:
|
| 50 |
+
raise ValueError("task_id must be one of: easy, medium, hard")
|
| 51 |
+
|
| 52 |
+
self.task_id = task_id
|
| 53 |
+
self.current_ticket = random.choice(pool_map[task_id])
|
| 54 |
+
self.ticket_state = TicketState(
|
| 55 |
+
ticket_id=self.current_ticket["id"],
|
| 56 |
+
track=self._infer_track(self.current_ticket),
|
| 57 |
+
required_slots=self._required_slots(self.current_ticket, task_id),
|
| 58 |
+
)
|
| 59 |
+
self.user_sim = UserSimulator(self.current_ticket) if task_id == "hard" else None
|
| 60 |
+
self.turn_number = 0
|
| 61 |
+
self.conversation_history = []
|
| 62 |
+
self.action_history = []
|
| 63 |
+
|
| 64 |
+
return self.state()
|
| 65 |
+
|
| 66 |
+
def step(self, action: Action) -> Tuple[Observation, Reward, bool, Dict[str, Any]]:
|
| 67 |
+
if self.current_ticket is None or self.ticket_state is None:
|
| 68 |
+
raise RuntimeError("Environment not initialized. Call reset() first.")
|
| 69 |
+
|
| 70 |
+
canonical_action = self._canonicalize_action(action)
|
| 71 |
+
self.turn_number += 1
|
| 72 |
+
self.ticket_state.turns_used += 1
|
| 73 |
+
self.action_history.append(canonical_action.action_type)
|
| 74 |
+
self._track_collected_slots(canonical_action)
|
| 75 |
+
|
| 76 |
+
action_content = (
|
| 77 |
+
canonical_action.message
|
| 78 |
+
or canonical_action.operation
|
| 79 |
+
or canonical_action.target
|
| 80 |
+
or canonical_action.action_type
|
| 81 |
+
)
|
| 82 |
+
self.conversation_history.append({"role": "agent", "content": action_content})
|
| 83 |
+
|
| 84 |
+
done = False
|
| 85 |
+
metrics: Dict[str, float] = {
|
| 86 |
+
"correctness": 0.0,
|
| 87 |
+
"safety": 1.0,
|
| 88 |
+
"resolution": 0.0,
|
| 89 |
+
"efficiency": 0.0,
|
| 90 |
+
"penalties": 0.0,
|
| 91 |
+
}
|
| 92 |
+
info: Dict[str, Any] = {
|
| 93 |
+
"action_type": canonical_action.action_type,
|
| 94 |
+
"operation": canonical_action.operation,
|
| 95 |
+
"target": canonical_action.target,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
if canonical_action.action_type == "ask_for_details":
|
| 99 |
+
metrics["correctness"] = self._grade_detail_request(canonical_action)
|
| 100 |
+
if self.task_id == "hard" and self.user_sim is not None:
|
| 101 |
+
user_response = self.user_sim.respond(canonical_action.message or "")
|
| 102 |
+
self.conversation_history.append({"role": "user", "content": user_response})
|
| 103 |
+
self.ticket_state.clarification_received = self.user_sim.clarification_given
|
| 104 |
+
info["user_response"] = user_response
|
| 105 |
+
|
| 106 |
+
elif canonical_action.action_type == "take_action":
|
| 107 |
+
correctness, resolved = self._grade_take_action(canonical_action)
|
| 108 |
+
metrics["correctness"] = correctness
|
| 109 |
+
self.ticket_state.issue_resolved = resolved
|
| 110 |
+
if resolved:
|
| 111 |
+
metrics["resolution"] = grade_resolution(self.ticket_state)
|
| 112 |
+
done = True
|
| 113 |
+
|
| 114 |
+
elif canonical_action.action_type == "respond_to_user":
|
| 115 |
+
metrics["correctness"] = self._grade_response(canonical_action)
|
| 116 |
+
if self.task_id == "hard" and self.user_sim is not None:
|
| 117 |
+
user_response = self.user_sim.respond(canonical_action.message or "")
|
| 118 |
+
self.conversation_history.append({"role": "user", "content": user_response})
|
| 119 |
+
self.ticket_state.issue_resolved = self.user_sim.confirm_resolved()
|
| 120 |
+
info["user_response"] = user_response
|
| 121 |
+
|
| 122 |
+
elif canonical_action.action_type == "escalate_case":
|
| 123 |
+
metrics["correctness"] = grade_escalation(
|
| 124 |
+
True,
|
| 125 |
+
bool(self.current_ticket.get("should_escalate", False)),
|
| 126 |
+
)
|
| 127 |
+
self.ticket_state.escalated = True
|
| 128 |
+
metrics["resolution"] = metrics["correctness"]
|
| 129 |
+
info["escalation_accuracy"] = metrics["correctness"]
|
| 130 |
+
done = True
|
| 131 |
+
|
| 132 |
+
elif canonical_action.action_type == "close_case":
|
| 133 |
+
if self.task_id == "hard" and self.user_sim is not None:
|
| 134 |
+
self.ticket_state.issue_resolved = self.user_sim.confirm_resolved()
|
| 135 |
+
metrics["resolution"] = grade_case_closure(self.ticket_state)
|
| 136 |
+
if metrics["resolution"] == 0.0 and not self.ticket_state.escalated:
|
| 137 |
+
metrics["penalties"] -= 0.20
|
| 138 |
+
done = True
|
| 139 |
+
|
| 140 |
+
metrics["safety"] = self._grade_safety(canonical_action, metrics)
|
| 141 |
+
metrics["efficiency"] = self._grade_efficiency(done)
|
| 142 |
+
|
| 143 |
+
reward = self._calculate_reward(metrics, done=done)
|
| 144 |
+
info.update(
|
| 145 |
+
{
|
| 146 |
+
"ticket_id": self.ticket_state.ticket_id,
|
| 147 |
+
"task_id": self.task_id,
|
| 148 |
+
"track": self.ticket_state.track,
|
| 149 |
+
"turn_number": self.turn_number,
|
| 150 |
+
}
|
| 151 |
+
)
|
| 152 |
+
return self.state(), reward, done, info
|
| 153 |
+
|
| 154 |
+
def _canonicalize_action(self, action: Action) -> Action:
|
| 155 |
+
if action.action_type in {
|
| 156 |
+
"ask_for_details",
|
| 157 |
+
"take_action",
|
| 158 |
+
"respond_to_user",
|
| 159 |
+
"escalate_case",
|
| 160 |
+
"close_case",
|
| 161 |
+
}:
|
| 162 |
+
return action
|
| 163 |
+
|
| 164 |
+
if action.action_type == "classify":
|
| 165 |
+
return Action(
|
| 166 |
+
action_type="take_action",
|
| 167 |
+
operation="classify_issue",
|
| 168 |
+
category=action.category,
|
| 169 |
+
message=action.message,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
if action.action_type == "lookup_faq":
|
| 173 |
+
return Action(
|
| 174 |
+
action_type="take_action",
|
| 175 |
+
operation="lookup_faq",
|
| 176 |
+
faq_id=action.faq_id,
|
| 177 |
+
message=action.message,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
if action.action_type == "ask_clarification":
|
| 181 |
+
return Action(
|
| 182 |
+
action_type="ask_for_details",
|
| 183 |
+
fields_requested=["issue_details"],
|
| 184 |
+
message=action.message,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
if action.action_type == "reply":
|
| 188 |
+
return Action(
|
| 189 |
+
action_type="respond_to_user",
|
| 190 |
+
message=action.message,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
if action.action_type == "escalate":
|
| 194 |
+
return Action(
|
| 195 |
+
action_type="escalate_case",
|
| 196 |
+
target="human_agent",
|
| 197 |
+
message=action.message,
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
if action.action_type == "resolve_ticket":
|
| 201 |
+
return Action(
|
| 202 |
+
action_type="close_case",
|
| 203 |
+
operation="resolve_with_guidance",
|
| 204 |
+
message=action.message,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
raise ValueError(f"Unsupported action type: {action.action_type}")
|
| 208 |
+
|
| 209 |
+
def _infer_track(self, ticket: Dict[str, Any]) -> str:
|
| 210 |
+
category = (
|
| 211 |
+
ticket.get("issue_category")
|
| 212 |
+
or ticket.get("gold_category")
|
| 213 |
+
or ticket.get("difficulty")
|
| 214 |
+
or self.task_id
|
| 215 |
+
)
|
| 216 |
+
return str(category).strip().lower().replace(" ", "_")
|
| 217 |
+
|
| 218 |
+
def _required_slots(self, ticket: Dict[str, Any], task_id: str) -> List[str]:
|
| 219 |
+
if task_id == "easy":
|
| 220 |
+
return ["issue_category"]
|
| 221 |
+
if task_id == "medium":
|
| 222 |
+
return ["faq_or_escalation_decision"]
|
| 223 |
+
return ["issue_details", "resolution_confirmation"]
|
| 224 |
+
|
| 225 |
+
def _track_collected_slots(self, action: Action) -> None:
|
| 226 |
+
if self.ticket_state is None:
|
| 227 |
+
return
|
| 228 |
+
|
| 229 |
+
for field_name in action.fields_requested:
|
| 230 |
+
self.ticket_state.collected_slots[field_name] = "requested"
|
| 231 |
+
|
| 232 |
+
if action.operation:
|
| 233 |
+
self.ticket_state.collected_slots["last_operation"] = action.operation
|
| 234 |
+
if action.target:
|
| 235 |
+
self.ticket_state.collected_slots["escalation_target"] = action.target
|
| 236 |
+
|
| 237 |
+
def _grade_detail_request(self, action: Action) -> float:
|
| 238 |
+
if self.ticket_state is None:
|
| 239 |
+
return 0.0
|
| 240 |
+
if not action.fields_requested and not action.message:
|
| 241 |
+
return 0.0
|
| 242 |
+
if not self.ticket_state.required_slots:
|
| 243 |
+
return 0.5
|
| 244 |
+
info_score = grade_information_collection(
|
| 245 |
+
action.fields_requested,
|
| 246 |
+
self.ticket_state.required_slots,
|
| 247 |
+
)
|
| 248 |
+
if self.task_id != "hard" and info_score == 0.0:
|
| 249 |
+
return 0.5
|
| 250 |
+
return info_score
|
| 251 |
+
|
| 252 |
+
def _grade_take_action(self, action: Action) -> Tuple[float, bool]:
|
| 253 |
+
operation = (action.operation or "").strip().lower()
|
| 254 |
+
|
| 255 |
+
if operation == "classify_issue":
|
| 256 |
+
gold_category = self.current_ticket.get("gold_category", "")
|
| 257 |
+
score = grade_classification(action.category or "", gold_category)
|
| 258 |
+
return score, score == 1.0
|
| 259 |
+
|
| 260 |
+
if operation == "lookup_faq":
|
| 261 |
+
gold_faq_id = self.current_ticket.get("gold_faq_id", "")
|
| 262 |
+
score = grade_faq_retrieval(action.faq_id or "", gold_faq_id)
|
| 263 |
+
if self.ticket_state is not None and score == 1.0:
|
| 264 |
+
self.ticket_state.correct_faq_retrieved = True
|
| 265 |
+
return score, False
|
| 266 |
+
|
| 267 |
+
if operation == "resolve_with_guidance":
|
| 268 |
+
resolved = bool(
|
| 269 |
+
self.ticket_state
|
| 270 |
+
and self.ticket_state.correct_faq_retrieved
|
| 271 |
+
and (self.task_id != "hard" or self.ticket_state.clarification_received)
|
| 272 |
+
)
|
| 273 |
+
return (1.0 if resolved else 0.0), resolved
|
| 274 |
+
|
| 275 |
+
if operation == "check_status":
|
| 276 |
+
return 0.5, False
|
| 277 |
+
|
| 278 |
+
banking_operations = {
|
| 279 |
+
"check_payment",
|
| 280 |
+
"check_refund",
|
| 281 |
+
"check_kyc",
|
| 282 |
+
"secure_account",
|
| 283 |
+
"troubleshoot_upi",
|
| 284 |
+
}
|
| 285 |
+
op_score = grade_operation_choice(operation, banking_operations)
|
| 286 |
+
return op_score, False
|
| 287 |
+
|
| 288 |
+
return 0.0, False
|
| 289 |
+
|
| 290 |
+
def _grade_response(self, action: Action) -> float:
|
| 291 |
+
if not action.message:
|
| 292 |
+
return 0.0
|
| 293 |
+
if self.task_id == "hard" and self.ticket_state and self.ticket_state.correct_faq_retrieved:
|
| 294 |
+
return 1.0
|
| 295 |
+
return 0.5
|
| 296 |
+
|
| 297 |
+
def _grade_safety(self, action: Action, metrics: Dict[str, float]) -> float:
|
| 298 |
+
text = (action.message or "").lower()
|
| 299 |
+
sensitive_markers = ["otp", "pin", "cvv", "password"]
|
| 300 |
+
if any(marker in text for marker in sensitive_markers):
|
| 301 |
+
metrics["penalties"] -= 0.50
|
| 302 |
+
return 0.0
|
| 303 |
+
|
| 304 |
+
if action.action_type == "close_case" and metrics["resolution"] == 0.0:
|
| 305 |
+
return 0.25
|
| 306 |
+
|
| 307 |
+
if action.action_type == "escalate_case":
|
| 308 |
+
expected = bool(self.current_ticket.get("should_escalate", False))
|
| 309 |
+
return 1.0 if expected else 0.6
|
| 310 |
+
|
| 311 |
+
return 1.0
|
| 312 |
+
|
| 313 |
+
def _grade_efficiency(self, done: bool) -> float:
|
| 314 |
+
max_turns = 1 if self.task_id == "easy" else 2 if self.task_id == "medium" else 6
|
| 315 |
+
if not done:
|
| 316 |
+
remaining_ratio = max(0.0, 1.0 - (self.turn_number / max_turns))
|
| 317 |
+
return round(0.5 * remaining_ratio, 3)
|
| 318 |
+
return max(0.0, min(1.0, 1.0 - (0.1 * max(0, self.turn_number - 1))))
|
| 319 |
+
|
| 320 |
+
def _calculate_reward(self, metrics: Dict[str, float], done: bool) -> Reward:
|
| 321 |
+
correctness = metrics.get("correctness", 0.0)
|
| 322 |
+
safety = metrics.get("safety", 0.0)
|
| 323 |
+
resolution = metrics.get("resolution", 0.0)
|
| 324 |
+
efficiency = metrics.get("efficiency", 0.0)
|
| 325 |
+
penalties = metrics.get("penalties", 0.0)
|
| 326 |
+
|
| 327 |
+
weighted = (
|
| 328 |
+
(0.35 * correctness)
|
| 329 |
+
+ (0.30 * safety)
|
| 330 |
+
+ (0.20 * resolution)
|
| 331 |
+
+ (0.15 * efficiency)
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
recent_actions = self.action_history[-3:]
|
| 335 |
+
if len(recent_actions) >= 2 and len(set(recent_actions)) < len(recent_actions):
|
| 336 |
+
penalties -= 0.05
|
| 337 |
+
|
| 338 |
+
final_value = max(0.0, min(1.0, weighted + penalties))
|
| 339 |
+
return Reward(
|
| 340 |
+
value=final_value,
|
| 341 |
+
correctness=correctness,
|
| 342 |
+
safety=safety,
|
| 343 |
+
resolution=resolution,
|
| 344 |
+
efficiency=efficiency,
|
| 345 |
+
penalties=penalties,
|
| 346 |
+
done=done,
|
| 347 |
+
info={
|
| 348 |
+
"turn_number": self.turn_number,
|
| 349 |
+
"task_id": self.task_id,
|
| 350 |
+
"escalation_accuracy": metrics.get("escalation_accuracy", correctness),
|
| 351 |
+
},
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
def _build_known_facts(self) -> Dict[str, Any]:
|
| 355 |
+
if self.current_ticket is None or self.ticket_state is None:
|
| 356 |
+
return {}
|
| 357 |
+
|
| 358 |
+
facts = {
|
| 359 |
+
"difficulty": self.current_ticket.get("difficulty", self.task_id),
|
| 360 |
+
"knowledge_base": self.kb,
|
| 361 |
+
"available_categories": [
|
| 362 |
+
"payment_failure",
|
| 363 |
+
"refund_delay",
|
| 364 |
+
"fraud_complaint",
|
| 365 |
+
"kyc_account_restriction",
|
| 366 |
+
"upi_pin_or_bank_linking",
|
| 367 |
+
],
|
| 368 |
+
"clarification_received": self.ticket_state.clarification_received,
|
| 369 |
+
"faq_retrieved": self.ticket_state.correct_faq_retrieved,
|
| 370 |
+
"issue_resolved": self.ticket_state.issue_resolved,
|
| 371 |
+
"collected_slots": self.ticket_state.collected_slots,
|
| 372 |
+
}
|
| 373 |
+
return facts
|
| 374 |
+
|
| 375 |
+
def state(self) -> Observation:
|
| 376 |
+
if self.current_ticket is None or self.ticket_state is None:
|
| 377 |
+
raise RuntimeError("Environment not initialized. Call reset() first.")
|
| 378 |
+
|
| 379 |
+
customer_message = self.current_ticket.get("text") or self.current_ticket.get(
|
| 380 |
+
"initial_text", ""
|
| 381 |
+
)
|
| 382 |
+
return Observation(
|
| 383 |
+
case_id=self.current_ticket["id"],
|
| 384 |
+
track=self.task_id,
|
| 385 |
+
customer_message=customer_message,
|
| 386 |
+
conversation_history=self.conversation_history,
|
| 387 |
+
known_facts=self._build_known_facts(),
|
| 388 |
+
required_slots=self.ticket_state.required_slots,
|
| 389 |
+
available_actions=[
|
| 390 |
+
"ask_for_details",
|
| 391 |
+
"take_action",
|
| 392 |
+
"respond_to_user",
|
| 393 |
+
"escalate_case",
|
| 394 |
+
"close_case",
|
| 395 |
+
],
|
| 396 |
+
turn_number=self.turn_number,
|
| 397 |
+
)
|
graders/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
graders/category_grader.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Iterable, List
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def grade_track_classification(predicted_track: str, gold_track: str) -> float:
|
| 5 |
+
if predicted_track.strip().lower() == gold_track.strip().lower():
|
| 6 |
+
return 1.0
|
| 7 |
+
return 0.0
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def grade_information_collection(
|
| 11 |
+
requested_fields: Iterable[str],
|
| 12 |
+
required_fields: Iterable[str],
|
| 13 |
+
) -> float:
|
| 14 |
+
requested = {field.strip().lower() for field in requested_fields if field.strip()}
|
| 15 |
+
required = {field.strip().lower() for field in required_fields if field.strip()}
|
| 16 |
+
if not requested or not required:
|
| 17 |
+
return 0.0
|
| 18 |
+
|
| 19 |
+
overlap = requested & required
|
| 20 |
+
return len(overlap) / len(required)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def grade_batch_classification(predictions: List[str], gold_labels: List[str]) -> float:
|
| 24 |
+
if len(predictions) != len(gold_labels):
|
| 25 |
+
raise ValueError("predictions and gold_labels must have the same length")
|
| 26 |
+
if not predictions:
|
| 27 |
+
return 0.0
|
| 28 |
+
|
| 29 |
+
total = sum(
|
| 30 |
+
grade_track_classification(predicted, gold)
|
| 31 |
+
for predicted, gold in zip(predictions, gold_labels)
|
| 32 |
+
)
|
| 33 |
+
return total / len(predictions)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# Backward-compatible alias while the environment transitions from category to track naming.
|
| 37 |
+
def grade_classification(predicted_category: str, gold_category: str) -> float:
|
| 38 |
+
return grade_track_classification(predicted_category, gold_category)
|
graders/faq_grader.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Iterable
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def grade_operation_choice(selected_operation: str, valid_operations: Iterable[str]) -> float:
|
| 5 |
+
operation = selected_operation.strip().lower()
|
| 6 |
+
valid = {candidate.strip().lower() for candidate in valid_operations if candidate.strip()}
|
| 7 |
+
if not operation or not valid:
|
| 8 |
+
return 0.0
|
| 9 |
+
return 1.0 if operation in valid else 0.0
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def grade_retrieval_or_action_match(selected_reference: str, gold_reference: str) -> float:
|
| 13 |
+
if selected_reference.strip() and selected_reference.strip() == gold_reference.strip():
|
| 14 |
+
return 1.0
|
| 15 |
+
return 0.0
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def grade_escalation(agent_escalated: bool, should_escalate: bool, correct_target: bool = True) -> float:
|
| 19 |
+
if agent_escalated != should_escalate:
|
| 20 |
+
return 0.0
|
| 21 |
+
if agent_escalated and not correct_target:
|
| 22 |
+
return 0.5
|
| 23 |
+
return 1.0
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Backward-compatible alias from the old FAQ-focused environment.
|
| 27 |
+
def grade_faq_retrieval(retrieved_faq_id: str, gold_faq_id: str) -> float:
|
| 28 |
+
return grade_retrieval_or_action_match(retrieved_faq_id, gold_faq_id)
|
graders/resolution_grader.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ..models import TicketState
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def grade_resolution(ticket_state: TicketState, max_turns: int = 6) -> float:
|
| 5 |
+
if ticket_state.escalated:
|
| 6 |
+
return 1.0
|
| 7 |
+
|
| 8 |
+
if not ticket_state.issue_resolved:
|
| 9 |
+
return 0.0
|
| 10 |
+
|
| 11 |
+
if ticket_state.turns_used > max_turns:
|
| 12 |
+
return 0.0
|
| 13 |
+
|
| 14 |
+
slot_bonus = 0.1 if ticket_state.required_slots and ticket_state.collected_slots else 0.0
|
| 15 |
+
penalty_turns = max(0, ticket_state.turns_used - 3)
|
| 16 |
+
score = 0.9 + slot_bonus - (0.05 * penalty_turns)
|
| 17 |
+
return max(0.0, min(1.0, score))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def grade_case_closure(ticket_state: TicketState) -> float:
|
| 21 |
+
if ticket_state.issue_resolved or ticket_state.escalated:
|
| 22 |
+
return 1.0
|
| 23 |
+
return 0.0
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def grade_clarification(asked_clarification: bool, ticket_needed_clarification: bool) -> float:
|
| 27 |
+
if asked_clarification == ticket_needed_clarification:
|
| 28 |
+
return 0.25
|
| 29 |
+
return 0.0
|
inference.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
import textwrap
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import List, Optional
|
| 7 |
+
|
| 8 |
+
from openai import OpenAI
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parent
|
| 12 |
+
PACKAGE_PARENT = ROOT.parent
|
| 13 |
+
if str(PACKAGE_PARENT) not in sys.path:
|
| 14 |
+
sys.path.insert(0, str(PACKAGE_PARENT))
|
| 15 |
+
|
| 16 |
+
from helpdesk_env.environment import HelpdeskEnv
|
| 17 |
+
from helpdesk_env.models import Action
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME", "helpdesk-openenv")
|
| 21 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://api.groq.com/openai/v1")
|
| 22 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "llama-3.3-70b-versatile")
|
| 23 |
+
API_KEY = os.getenv("GROQ_API_KEY") or os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
| 24 |
+
TASK_NAME = os.getenv("TASK_NAME", "easy")
|
| 25 |
+
BENCHMARK = os.getenv("BENCHMARK", "helpdesk_env")
|
| 26 |
+
TEMPERATURE = float(os.getenv("TEMPERATURE", "0"))
|
| 27 |
+
MAX_TOKENS = int(os.getenv("MAX_TOKENS", "180"))
|
| 28 |
+
SUCCESS_SCORE_THRESHOLD = float(os.getenv("SUCCESS_SCORE_THRESHOLD", "0.50"))
|
| 29 |
+
|
| 30 |
+
MAX_STEPS_BY_TASK = {
|
| 31 |
+
"easy": 1,
|
| 32 |
+
"medium": 3,
|
| 33 |
+
"hard": 8,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
SYSTEM_PROMPT_BASE = (
|
| 37 |
+
"You are a banking customer support agent for a UPI payments app. "
|
| 38 |
+
"Never ask for PIN, OTP, CVV, or full card details. "
|
| 39 |
+
"You must return exactly one JSON object with keys from: "
|
| 40 |
+
"action_type, category, faq_id, message. "
|
| 41 |
+
"Valid action_type values are exactly: classify, lookup_faq, ask_clarification, "
|
| 42 |
+
"reply, escalate, resolve_ticket."
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def system_prompt_for_task(task_id: str) -> str:
|
| 47 |
+
if task_id == "easy":
|
| 48 |
+
return (
|
| 49 |
+
SYSTEM_PROMPT_BASE
|
| 50 |
+
+ " For easy tasks, classify the issue into exactly one category from "
|
| 51 |
+
"observation.available_categories."
|
| 52 |
+
)
|
| 53 |
+
if task_id == "medium":
|
| 54 |
+
return (
|
| 55 |
+
SYSTEM_PROMPT_BASE
|
| 56 |
+
+ " For medium tasks, choose lookup_faq with the best faq_id from "
|
| 57 |
+
"observation.knowledge_base, or use escalate when fraud or overdue review requires manual handling."
|
| 58 |
+
)
|
| 59 |
+
return (
|
| 60 |
+
SYSTEM_PROMPT_BASE
|
| 61 |
+
+ " For hard tasks, ask for clarification first, then retrieve the right FAQ, "
|
| 62 |
+
"then reply with safe guidance, and only resolve after the customer confirms the issue is fixed."
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def build_user_prompt(task_id: str, observation_json: str, history: List[str]) -> str:
|
| 67 |
+
history_block = "\n".join(history[-4:]) if history else "None"
|
| 68 |
+
return textwrap.dedent(
|
| 69 |
+
f"""
|
| 70 |
+
Task: {task_id}
|
| 71 |
+
Observation JSON:
|
| 72 |
+
{observation_json}
|
| 73 |
+
|
| 74 |
+
Recent action history:
|
| 75 |
+
{history_block}
|
| 76 |
+
|
| 77 |
+
Return the next action as one JSON object only.
|
| 78 |
+
"""
|
| 79 |
+
).strip()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 83 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 87 |
+
error_val = error if error else "null"
|
| 88 |
+
print(
|
| 89 |
+
f"[STEP] step={step} action={action} reward={reward:.2f} "
|
| 90 |
+
f"done={str(done).lower()} error={error_val}",
|
| 91 |
+
flush=True,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def log_end(success: bool, steps: int, rewards: List[float]) -> None:
|
| 96 |
+
rewards_str = ",".join(f"{reward:.2f}" for reward in rewards)
|
| 97 |
+
print(
|
| 98 |
+
f"[END] success={str(success).lower()} steps={steps} rewards={rewards_str}",
|
| 99 |
+
flush=True,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _extract_json_object(text: str) -> str:
|
| 104 |
+
text = text.strip()
|
| 105 |
+
if text.startswith("```"):
|
| 106 |
+
lines = text.split("\n")
|
| 107 |
+
if len(lines) >= 2 and lines[0].startswith("```"):
|
| 108 |
+
lines = lines[1:]
|
| 109 |
+
if lines and lines[-1].strip() == "```":
|
| 110 |
+
lines = lines[:-1]
|
| 111 |
+
text = "\n".join(lines).strip()
|
| 112 |
+
return text
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
_VALID_ACTIONS = frozenset(
|
| 116 |
+
{
|
| 117 |
+
"classify",
|
| 118 |
+
"lookup_faq",
|
| 119 |
+
"ask_clarification",
|
| 120 |
+
"reply",
|
| 121 |
+
"escalate",
|
| 122 |
+
"resolve_ticket",
|
| 123 |
+
}
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _normalize_action_type(raw: object) -> str:
|
| 128 |
+
if raw is None:
|
| 129 |
+
return ""
|
| 130 |
+
value = str(raw).strip().lower().replace("-", "_")
|
| 131 |
+
return value if value in _VALID_ACTIONS else ""
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _fallback_action(task_id: str, turn_number: int) -> Action:
|
| 135 |
+
if task_id == "easy":
|
| 136 |
+
return Action(action_type="classify", category="payment_failure")
|
| 137 |
+
if task_id == "medium":
|
| 138 |
+
return Action(action_type="escalate", message="Escalating for manual review.")
|
| 139 |
+
if turn_number == 0:
|
| 140 |
+
return Action(
|
| 141 |
+
action_type="ask_clarification",
|
| 142 |
+
message="Please share the UTR, amount, and exact issue.",
|
| 143 |
+
)
|
| 144 |
+
if turn_number == 1:
|
| 145 |
+
return Action(action_type="lookup_faq", faq_id="faq_001")
|
| 146 |
+
if turn_number in (2, 3):
|
| 147 |
+
return Action(
|
| 148 |
+
action_type="reply",
|
| 149 |
+
message="Please follow the safe steps in the app and confirm the result.",
|
| 150 |
+
)
|
| 151 |
+
return Action(action_type="resolve_ticket")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def parse_action(response_text: str, task_id: str, turn_number: int) -> Action:
|
| 155 |
+
text = _extract_json_object(response_text)
|
| 156 |
+
try:
|
| 157 |
+
payload = json.loads(text)
|
| 158 |
+
except json.JSONDecodeError:
|
| 159 |
+
start = text.find("{")
|
| 160 |
+
end = text.rfind("}")
|
| 161 |
+
if start != -1 and end != -1 and end > start:
|
| 162 |
+
try:
|
| 163 |
+
payload = json.loads(text[start : end + 1])
|
| 164 |
+
except json.JSONDecodeError:
|
| 165 |
+
payload = {}
|
| 166 |
+
else:
|
| 167 |
+
payload = {}
|
| 168 |
+
|
| 169 |
+
action_type = _normalize_action_type(payload.get("action_type"))
|
| 170 |
+
if not action_type:
|
| 171 |
+
return _fallback_action(task_id, turn_number)
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
return Action(
|
| 175 |
+
action_type=action_type,
|
| 176 |
+
category=payload.get("category"),
|
| 177 |
+
faq_id=payload.get("faq_id"),
|
| 178 |
+
message=payload.get("message"),
|
| 179 |
+
)
|
| 180 |
+
except Exception:
|
| 181 |
+
return _fallback_action(task_id, turn_number)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def get_model_action(
|
| 185 |
+
client: OpenAI,
|
| 186 |
+
task_id: str,
|
| 187 |
+
observation_json: str,
|
| 188 |
+
history: List[str],
|
| 189 |
+
turn_number: int,
|
| 190 |
+
) -> Action:
|
| 191 |
+
user_prompt = build_user_prompt(task_id, observation_json, history)
|
| 192 |
+
completion = client.chat.completions.create(
|
| 193 |
+
model=MODEL_NAME,
|
| 194 |
+
messages=[
|
| 195 |
+
{"role": "system", "content": system_prompt_for_task(task_id)},
|
| 196 |
+
{"role": "user", "content": user_prompt},
|
| 197 |
+
],
|
| 198 |
+
temperature=TEMPERATURE,
|
| 199 |
+
max_tokens=MAX_TOKENS,
|
| 200 |
+
response_format={"type": "json_object"},
|
| 201 |
+
)
|
| 202 |
+
text = completion.choices[0].message.content or ""
|
| 203 |
+
return parse_action(text, task_id, turn_number)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def main() -> None:
|
| 207 |
+
if not API_KEY:
|
| 208 |
+
raise RuntimeError(
|
| 209 |
+
"Set GROQ_API_KEY, HF_TOKEN, or API_KEY before running inference.py"
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 213 |
+
env = HelpdeskEnv()
|
| 214 |
+
|
| 215 |
+
history: List[str] = []
|
| 216 |
+
rewards: List[float] = []
|
| 217 |
+
steps_taken = 0
|
| 218 |
+
success = False
|
| 219 |
+
|
| 220 |
+
log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
|
| 221 |
+
|
| 222 |
+
try:
|
| 223 |
+
observation = env.reset(TASK_NAME)
|
| 224 |
+
done = False
|
| 225 |
+
|
| 226 |
+
for step in range(1, MAX_STEPS_BY_TASK.get(TASK_NAME, 3) + 1):
|
| 227 |
+
if done:
|
| 228 |
+
break
|
| 229 |
+
|
| 230 |
+
error: Optional[str] = None
|
| 231 |
+
try:
|
| 232 |
+
action = get_model_action(
|
| 233 |
+
client=client,
|
| 234 |
+
task_id=TASK_NAME,
|
| 235 |
+
observation_json=observation.model_dump_json(),
|
| 236 |
+
history=history,
|
| 237 |
+
turn_number=observation.turn_number,
|
| 238 |
+
)
|
| 239 |
+
observation, reward, done, _info = env.step(action)
|
| 240 |
+
reward_value = reward.value
|
| 241 |
+
except Exception as exc:
|
| 242 |
+
action = _fallback_action(TASK_NAME, observation.turn_number)
|
| 243 |
+
reward_value = 0.0
|
| 244 |
+
done = True
|
| 245 |
+
error = str(exc)
|
| 246 |
+
|
| 247 |
+
action_str = json.dumps(action.model_dump(exclude_none=True), separators=(",", ":"))
|
| 248 |
+
log_step(
|
| 249 |
+
step=step,
|
| 250 |
+
action=action_str,
|
| 251 |
+
reward=reward_value,
|
| 252 |
+
done=done,
|
| 253 |
+
error=error,
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
rewards.append(reward_value)
|
| 257 |
+
steps_taken = step
|
| 258 |
+
history.append(f"step={step} action={action_str} reward={reward_value:.2f}")
|
| 259 |
+
|
| 260 |
+
final_score = rewards[-1] if rewards else 0.0
|
| 261 |
+
success = final_score >= SUCCESS_SCORE_THRESHOLD
|
| 262 |
+
|
| 263 |
+
finally:
|
| 264 |
+
log_end(success=success, steps=steps_taken, rewards=rewards)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
if __name__ == "__main__":
|
| 268 |
+
main()
|
models.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Observation(BaseModel):
|
| 8 |
+
case_id: str
|
| 9 |
+
track: str
|
| 10 |
+
customer_message: str
|
| 11 |
+
conversation_history: List[Dict[str, str]]
|
| 12 |
+
known_facts: Dict[str, Any]
|
| 13 |
+
required_slots: List[str]
|
| 14 |
+
available_actions: List[str]
|
| 15 |
+
turn_number: int
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def ticket_id(self) -> str:
|
| 19 |
+
return self.case_id
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def task_id(self) -> str:
|
| 23 |
+
return str(self.known_facts.get("difficulty", ""))
|
| 24 |
+
|
| 25 |
+
@property
|
| 26 |
+
def ticket_text(self) -> str:
|
| 27 |
+
return self.customer_message
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def knowledge_base(self) -> List[Dict[str, Any]]:
|
| 31 |
+
kb = self.known_facts.get("knowledge_base", [])
|
| 32 |
+
return kb if isinstance(kb, list) else []
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def available_categories(self) -> List[str]:
|
| 36 |
+
categories = self.known_facts.get("available_categories", [])
|
| 37 |
+
return categories if isinstance(categories, list) else []
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class Action(BaseModel):
|
| 41 |
+
action_type: Literal[
|
| 42 |
+
"ask_for_details",
|
| 43 |
+
"take_action",
|
| 44 |
+
"respond_to_user",
|
| 45 |
+
"escalate_case",
|
| 46 |
+
"close_case",
|
| 47 |
+
"classify",
|
| 48 |
+
"lookup_faq",
|
| 49 |
+
"ask_clarification",
|
| 50 |
+
"reply",
|
| 51 |
+
"escalate",
|
| 52 |
+
"resolve_ticket",
|
| 53 |
+
]
|
| 54 |
+
message: Optional[str] = None
|
| 55 |
+
fields_requested: List[str] = Field(default_factory=list)
|
| 56 |
+
operation: Optional[str] = None
|
| 57 |
+
target: Optional[str] = None
|
| 58 |
+
|
| 59 |
+
# Legacy compatibility with the original helpdesk action schema.
|
| 60 |
+
category: Optional[str] = None
|
| 61 |
+
faq_id: Optional[str] = None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class Reward(BaseModel):
|
| 65 |
+
value: float = Field(ge=0.0, le=1.0)
|
| 66 |
+
correctness: float
|
| 67 |
+
safety: float
|
| 68 |
+
resolution: float
|
| 69 |
+
efficiency: float
|
| 70 |
+
penalties: float
|
| 71 |
+
done: bool
|
| 72 |
+
info: Dict[str, Any]
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def escalation_accuracy(self) -> float:
|
| 76 |
+
return float(self.info.get("escalation_accuracy", self.correctness))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@dataclass
|
| 80 |
+
class TicketState:
|
| 81 |
+
ticket_id: str
|
| 82 |
+
track: str
|
| 83 |
+
required_slots: List[str] = field(default_factory=list)
|
| 84 |
+
collected_slots: Dict[str, Any] = field(default_factory=dict)
|
| 85 |
+
issue_resolved: bool = False
|
| 86 |
+
clarification_received: bool = False
|
| 87 |
+
escalated: bool = False
|
| 88 |
+
turns_used: int = 0
|
| 89 |
+
correct_faq_retrieved: bool = False
|
openenv.yaml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: helpdesk-env
|
| 2 |
+
version: "1.0.0"
|
| 3 |
+
description: "An RL environment simulating a real IT helpdesk where an agent triages tickets, retrieves FAQ answers, and resolves multi-turn support conversations"
|
| 4 |
+
tasks:
|
| 5 |
+
- id: easy
|
| 6 |
+
description: Classify 10 incoming support tickets into the correct category
|
| 7 |
+
difficulty: easy
|
| 8 |
+
max_turns: 1
|
| 9 |
+
- id: medium
|
| 10 |
+
description: Retrieve the correct FAQ answer for a query or decide to escalate
|
| 11 |
+
difficulty: medium
|
| 12 |
+
max_turns: 2
|
| 13 |
+
- id: hard
|
| 14 |
+
description: Resolve an ambiguous multi-turn support conversation within 6 turns
|
| 15 |
+
difficulty: hard
|
| 16 |
+
max_turns: 6
|
| 17 |
+
action_space:
|
| 18 |
+
- classify
|
| 19 |
+
- lookup_faq
|
| 20 |
+
- ask_clarification
|
| 21 |
+
- reply
|
| 22 |
+
- escalate
|
| 23 |
+
- resolve_ticket
|
| 24 |
+
observation_space:
|
| 25 |
+
- ticket_text
|
| 26 |
+
- conversation_history
|
| 27 |
+
- knowledge_base
|
| 28 |
+
- available_categories
|
| 29 |
+
- turn_number
|
pyrightconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"venvPath": "../..",
|
| 3 |
+
"venv": ".venv",
|
| 4 |
+
"include": [
|
| 5 |
+
"."
|
| 6 |
+
]
|
| 7 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pydantic
|
| 2 |
+
openai
|
| 3 |
+
fastapi
|
| 4 |
+
uvicorn
|
| 5 |
+
requests
|
server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
server/app.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI server exposing HelpdeskEnv over HTTP."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, Optional
|
| 4 |
+
|
| 5 |
+
from fastapi import FastAPI
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
|
| 8 |
+
from ..environment import HelpdeskEnv
|
| 9 |
+
from ..models import Action, Reward
|
| 10 |
+
|
| 11 |
+
app = FastAPI(title="Helpdesk OpenEnv")
|
| 12 |
+
_env: Optional[HelpdeskEnv] = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_env() -> HelpdeskEnv:
|
| 16 |
+
global _env
|
| 17 |
+
if _env is None:
|
| 18 |
+
_env = HelpdeskEnv()
|
| 19 |
+
return _env
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ResetBody(BaseModel):
|
| 23 |
+
task_id: str = "easy"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _zero_reward() -> Dict[str, Any]:
|
| 27 |
+
return Reward(
|
| 28 |
+
value=0.0,
|
| 29 |
+
correctness=0.0,
|
| 30 |
+
safety=1.0,
|
| 31 |
+
resolution=0.0,
|
| 32 |
+
efficiency=0.0,
|
| 33 |
+
penalties=0.0,
|
| 34 |
+
done=False,
|
| 35 |
+
info={},
|
| 36 |
+
).model_dump()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@app.get("/health")
|
| 40 |
+
def health() -> Dict[str, str]:
|
| 41 |
+
return {"status": "healthy"}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@app.get("/")
|
| 45 |
+
def root() -> Dict[str, Any]:
|
| 46 |
+
return {
|
| 47 |
+
"name": "UPI Banking Support Environment",
|
| 48 |
+
"status": "running",
|
| 49 |
+
"endpoints": ["/health", "/reset", "/step", "/state"],
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@app.post("/reset")
|
| 54 |
+
def reset(body: ResetBody = ResetBody()) -> Dict[str, Any]:
|
| 55 |
+
obs = get_env().reset(body.task_id)
|
| 56 |
+
return {
|
| 57 |
+
"observation": obs.model_dump(),
|
| 58 |
+
"reward": _zero_reward(),
|
| 59 |
+
"done": False,
|
| 60 |
+
"info": {},
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@app.post("/step")
|
| 65 |
+
def step(body: Dict[str, Any]) -> Dict[str, Any]:
|
| 66 |
+
action = Action(**body["action"])
|
| 67 |
+
obs, reward, done, info = get_env().step(action)
|
| 68 |
+
return {
|
| 69 |
+
"observation": obs.model_dump(),
|
| 70 |
+
"reward": reward.model_dump(),
|
| 71 |
+
"done": done,
|
| 72 |
+
"info": info,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@app.get("/state")
|
| 77 |
+
def state() -> Dict[str, Any]:
|
| 78 |
+
obs = get_env().state()
|
| 79 |
+
return {"observation": obs.model_dump()}
|
server/helpdesk_environment.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Environment implementation used by the HTTP server.
|
| 3 |
+
|
| 4 |
+
Logic lives in :class:`helpdesk_env.environment.HelpdeskEnv`; this module is a
|
| 5 |
+
stable import path for OpenEnv-style layouts (``server/my_environment.py``).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from ..environment import HelpdeskEnv
|
| 9 |
+
|
| 10 |
+
__all__ = ["HelpdeskEnv"]
|
user_simulator.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
from typing import Dict, List
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class UserSimulator:
|
| 6 |
+
def __init__(self, ticket: Dict):
|
| 7 |
+
self.ticket_id = ticket.get("id", "")
|
| 8 |
+
self.initial_text = ticket.get("initial_text", "")
|
| 9 |
+
self.clarified_text = ticket.get("clarified_text", "")
|
| 10 |
+
self.trigger_phrases: List[str] = ticket.get("trigger_phrases", [])
|
| 11 |
+
self.gold_faq_id = ticket.get("gold_faq_id", "")
|
| 12 |
+
|
| 13 |
+
self.state = "initial"
|
| 14 |
+
self.issue_resolved = False
|
| 15 |
+
self.clarification_given = False
|
| 16 |
+
|
| 17 |
+
def respond(self, agent_message: str) -> str:
|
| 18 |
+
agent_message_lower = agent_message.lower()
|
| 19 |
+
|
| 20 |
+
if self.state == "initial":
|
| 21 |
+
if any(phrase.lower() in agent_message_lower for phrase in self.trigger_phrases):
|
| 22 |
+
self.state = "clarified"
|
| 23 |
+
self.clarification_given = True
|
| 24 |
+
return self.clarified_text
|
| 25 |
+
return random.choice(
|
| 26 |
+
[
|
| 27 |
+
"I'm not sure what you mean",
|
| 28 |
+
"Can you help me?",
|
| 29 |
+
"It just stopped working",
|
| 30 |
+
]
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
if self.state == "clarified":
|
| 34 |
+
guidance_keywords = ["try", "follow", "steps", "should", "please"]
|
| 35 |
+
if any(keyword in agent_message_lower for keyword in guidance_keywords):
|
| 36 |
+
self.state = "waiting_resolve"
|
| 37 |
+
return "Ok I will try that, thanks"
|
| 38 |
+
|
| 39 |
+
if self.state == "waiting_resolve":
|
| 40 |
+
self.issue_resolved = True
|
| 41 |
+
return "Yes that fixed it!"
|
| 42 |
+
|
| 43 |
+
return "Can you help me?"
|
| 44 |
+
|
| 45 |
+
def confirm_resolved(self) -> bool:
|
| 46 |
+
return self.issue_resolved
|