Spaces:
Sleeping
Sleeping
Upload 17 files
#1
by aryanpatel - opened
- .env +3 -0
- Dockerfile +15 -0
- README.md +158 -10
- client.py +50 -0
- daigram.md +48 -0
- data_loader.py +64 -0
- dataset.csv +0 -0
- inference.py +200 -0
- kb.py +28 -0
- models.py +33 -0
- openenv.yaml +6 -0
- pre_submission_validate.py +130 -0
- pyproject.toml +23 -0
- requirements.txt +6 -0
- tasks.py +117 -0
- test_api_smoke.py +52 -0
- uv.lock +0 -0
.env
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
API_BASE_URL=https://api.openai.com/v1
|
| 2 |
+
MODEL_NAME=gpt-4o-mini
|
| 3 |
+
HF_TOKEN=replace_with_your_openai_api_key
|
Dockerfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 4 |
+
ENV PYTHONUNBUFFERED=1
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
COPY requirements.txt /app/requirements.txt
|
| 9 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt
|
| 10 |
+
|
| 11 |
+
COPY . /app
|
| 12 |
+
|
| 13 |
+
EXPOSE 8000
|
| 14 |
+
|
| 15 |
+
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
@@ -1,10 +1,158 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Customer Support OpenEnv Benchmark
|
| 3 |
+
emoji: 🧠
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 8000
|
| 9 |
+
tags:
|
| 10 |
+
- openenv
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Customer Support OpenEnv Benchmark
|
| 14 |
+
|
| 15 |
+
This project is a real-world customer support simulation environment for evaluating agent behavior in ticket triage, policy grounding, and escalation handling.
|
| 16 |
+
|
| 17 |
+
## Motivation
|
| 18 |
+
|
| 19 |
+
Support teams handle large volumes of incoming tickets where bad automation creates operational and safety risk. This environment evaluates whether an agent can:
|
| 20 |
+
|
| 21 |
+
- classify issue type correctly,
|
| 22 |
+
- ground responses in the right knowledge base entry,
|
| 23 |
+
- make safe escalation decisions,
|
| 24 |
+
- avoid low-value loops while being polite and policy-consistent.
|
| 25 |
+
|
| 26 |
+
## OpenEnv compliance
|
| 27 |
+
|
| 28 |
+
- Full OpenEnv manifest in openenv.yaml
|
| 29 |
+
- Typed models with Pydantic: action, observation, and state
|
| 30 |
+
- Environment API exposed through OpenEnv FastAPI server
|
| 31 |
+
- Standard endpoints available: reset, step, state, schema, health
|
| 32 |
+
- OpenEnv validator passes with openenv validate
|
| 33 |
+
|
| 34 |
+
## Action and observation spaces
|
| 35 |
+
|
| 36 |
+
### Action
|
| 37 |
+
|
| 38 |
+
SupportAction fields:
|
| 39 |
+
|
| 40 |
+
- action_type: classify | search_kb | respond | escalate
|
| 41 |
+
- content: text payload for that action
|
| 42 |
+
|
| 43 |
+
### Observation
|
| 44 |
+
|
| 45 |
+
SupportObservation fields:
|
| 46 |
+
|
| 47 |
+
- ticket_id, task_id, difficulty
|
| 48 |
+
- query, kb_id, requires_escalation
|
| 49 |
+
- history (trajectory events)
|
| 50 |
+
- reward, done, feedback, metadata
|
| 51 |
+
|
| 52 |
+
## Tasks and graders
|
| 53 |
+
|
| 54 |
+
Three deterministic task families are implemented, each with a programmatic grader returning a score in [0.0, 1.0].
|
| 55 |
+
|
| 56 |
+
1. easy_classify_respond
|
| 57 |
+
Objective: correct category classification and high-fidelity response.
|
| 58 |
+
|
| 59 |
+
2. medium_kb_grounded_response
|
| 60 |
+
Objective: correct KB retrieval followed by grounded response.
|
| 61 |
+
|
| 62 |
+
3. hard_escalation_safety
|
| 63 |
+
Objective: correct escalation decision plus safe/polite response behavior.
|
| 64 |
+
|
| 65 |
+
## Reward design
|
| 66 |
+
|
| 67 |
+
Reward shaping provides dense trajectory signal:
|
| 68 |
+
|
| 69 |
+
- positive for correct classify/search_kb decisions
|
| 70 |
+
- response similarity rewards for grounded answers
|
| 71 |
+
- politeness bonus
|
| 72 |
+
- penalties for hallucination risk and unnecessary escalation
|
| 73 |
+
- per-step loop penalty
|
| 74 |
+
|
| 75 |
+
## Quick start
|
| 76 |
+
|
| 77 |
+
Install dependencies:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
pip install -r requirements.txt
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
Run server locally:
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
Run baseline inference:
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
set API_BASE_URL=https://api.openai.com/v1
|
| 93 |
+
set MODEL_NAME=gpt-4o-mini
|
| 94 |
+
set HF_TOKEN=your_api_token
|
| 95 |
+
python inference.py --csv dataset.csv --limit-per-task 5
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
Offline smoke run (no API calls):
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
python inference.py --csv dataset.csv --limit-per-task 3 --offline
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
## Strict inference log format
|
| 105 |
+
|
| 106 |
+
Inference emits only structured markers:
|
| 107 |
+
|
| 108 |
+
- [START]
|
| 109 |
+
- [STEP]
|
| 110 |
+
- [END]
|
| 111 |
+
|
| 112 |
+
## Baseline score reproducibility
|
| 113 |
+
|
| 114 |
+
- Deterministic decoding (temperature=0)
|
| 115 |
+
- fixed task ordering (easy, medium, hard)
|
| 116 |
+
- deterministic grader criteria
|
| 117 |
+
- one model planning call per episode to reduce runtime variance and cost
|
| 118 |
+
|
| 119 |
+
## Pre-submission validation
|
| 120 |
+
|
| 121 |
+
Run all checks in one command:
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
python pre_submission_validate.py --skip-docker
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
Checks included:
|
| 128 |
+
|
| 129 |
+
- required env vars: API_BASE_URL, MODEL_NAME, HF_TOKEN
|
| 130 |
+
- openenv validate
|
| 131 |
+
- POST /reset returns HTTP 200
|
| 132 |
+
- optional docker build
|
| 133 |
+
|
| 134 |
+
For full parity with judge checks, run without skip:
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
python pre_submission_validate.py
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
## Docker
|
| 141 |
+
|
| 142 |
+
```bash
|
| 143 |
+
docker build -t customer-support-openenv .
|
| 144 |
+
docker run -p 8000:8000 customer-support-openenv
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
## Hugging Face Spaces
|
| 148 |
+
|
| 149 |
+
- Space SDK: Docker
|
| 150 |
+
- Required tag: openenv
|
| 151 |
+
- Health endpoint: /health
|
| 152 |
+
- Reset endpoint: POST /reset
|
| 153 |
+
|
| 154 |
+
Deploy command (after huggingface login):
|
| 155 |
+
|
| 156 |
+
```bash
|
| 157 |
+
openenv push
|
| 158 |
+
```
|
client.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Dict
|
| 4 |
+
|
| 5 |
+
from openenv.core import EnvClient
|
| 6 |
+
from openenv.core.client_types import StepResult
|
| 7 |
+
|
| 8 |
+
from models import SupportAction, SupportObservation, SupportState
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class CustomerSupportEnvClient(EnvClient[SupportAction, SupportObservation, SupportState]):
|
| 12 |
+
def _step_payload(self, action: SupportAction) -> Dict:
|
| 13 |
+
return {
|
| 14 |
+
"action_type": action.action_type,
|
| 15 |
+
"content": action.content,
|
| 16 |
+
"metadata": action.metadata,
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
def _parse_result(self, payload: Dict) -> StepResult[SupportObservation]:
|
| 20 |
+
obs_data = payload.get("observation", {})
|
| 21 |
+
observation = SupportObservation(
|
| 22 |
+
ticket_id=obs_data.get("ticket_id", 0),
|
| 23 |
+
task_id=obs_data.get("task_id", ""),
|
| 24 |
+
difficulty=obs_data.get("difficulty", "easy"),
|
| 25 |
+
query=obs_data.get("query", ""),
|
| 26 |
+
kb_id=obs_data.get("kb_id", ""),
|
| 27 |
+
requires_escalation=obs_data.get("requires_escalation", False),
|
| 28 |
+
history=obs_data.get("history", []),
|
| 29 |
+
feedback=obs_data.get("feedback", ""),
|
| 30 |
+
done=payload.get("done", False),
|
| 31 |
+
reward=payload.get("reward"),
|
| 32 |
+
metadata=obs_data.get("metadata", {}),
|
| 33 |
+
)
|
| 34 |
+
return StepResult(
|
| 35 |
+
observation=observation,
|
| 36 |
+
reward=payload.get("reward"),
|
| 37 |
+
done=payload.get("done", False),
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def _parse_state(self, payload: Dict) -> SupportState:
|
| 41 |
+
return SupportState(
|
| 42 |
+
episode_id=payload.get("episode_id"),
|
| 43 |
+
step_count=payload.get("step_count", 0),
|
| 44 |
+
difficulty_filter=payload.get("difficulty_filter"),
|
| 45 |
+
current_index=payload.get("current_index", 0),
|
| 46 |
+
task_id=payload.get("task_id", ""),
|
| 47 |
+
score_so_far=payload.get("score_so_far", 0.0),
|
| 48 |
+
final_score=payload.get("final_score", 0.0),
|
| 49 |
+
done=payload.get("done", False),
|
| 50 |
+
)
|
daigram.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Customer Support OpenEnv Benchmark Diagram
|
| 2 |
+
|
| 3 |
+
```mermaid
|
| 4 |
+
flowchart TD
|
| 5 |
+
A[dataset.csv] --> B[data_loader.py\nload + normalize + split]
|
| 6 |
+
B --> C[kb.py\nbuild knowledge base]
|
| 7 |
+
B --> D[server/customer_support_environment.py\nOpenEnv Environment]
|
| 8 |
+
C --> D
|
| 9 |
+
E[tasks.py\n3 tasks + deterministic graders] --> D
|
| 10 |
+
F[models.py\nTyped Action / Observation / State] --> D
|
| 11 |
+
|
| 12 |
+
D --> G[OpenEnv FastAPI App\nserver/app.py]
|
| 13 |
+
G --> H[POST /reset]
|
| 14 |
+
G --> I[POST /step]
|
| 15 |
+
G --> J[GET /state]
|
| 16 |
+
G --> K[GET /schema]
|
| 17 |
+
|
| 18 |
+
L[inference.py\nbaseline agent run] --> G
|
| 19 |
+
L --> M[[Structured Logs\n[START] [STEP] [END]]]
|
| 20 |
+
D --> N[[Reward Shaping\npartial progress + penalties]]
|
| 21 |
+
D --> O[[Episode Final Score\n0.0 to 1.0]]
|
| 22 |
+
|
| 23 |
+
P[openenv.yaml] --> G
|
| 24 |
+
Q[Dockerfile] --> R[HF Space Deployment]
|
| 25 |
+
G --> R
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## Episode Logic (high-level)
|
| 29 |
+
|
| 30 |
+
```mermaid
|
| 31 |
+
sequenceDiagram
|
| 32 |
+
participant Agent
|
| 33 |
+
participant Env as CustomerSupportEnvironment
|
| 34 |
+
participant Grader as Task Grader
|
| 35 |
+
|
| 36 |
+
Agent->>Env: reset(difficulty, index)
|
| 37 |
+
Env-->>Agent: Observation(ticket, query, kb_id, task_id)
|
| 38 |
+
|
| 39 |
+
loop up to max steps
|
| 40 |
+
Agent->>Env: step(Action)
|
| 41 |
+
Env->>Env: reward shaping (progress + penalties)
|
| 42 |
+
Env-->>Agent: Observation + reward + done
|
| 43 |
+
end
|
| 44 |
+
|
| 45 |
+
Env->>Grader: grade_task(task_id, expected, history)
|
| 46 |
+
Grader-->>Env: final score in [0.0, 1.0]
|
| 47 |
+
Env-->>Agent: final metadata with score
|
| 48 |
+
```
|
data_loader.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Dict
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
DIFFICULTIES = ("easy", "medium", "hard")
|
| 9 |
+
DESCRIPTION_COLUMNS = ("Description", "Ticket Description")
|
| 10 |
+
|
| 11 |
+
REQUIRED_COLUMNS = {
|
| 12 |
+
"TicketID",
|
| 13 |
+
"expected_category",
|
| 14 |
+
"expected_action",
|
| 15 |
+
"expected_response",
|
| 16 |
+
"requires_escalation",
|
| 17 |
+
"knowledge_base_id",
|
| 18 |
+
"difficulty",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def load_dataset(csv_path: str | Path = "dataset.csv") -> pd.DataFrame:
|
| 23 |
+
path = Path(csv_path)
|
| 24 |
+
if not path.exists() and not path.is_absolute():
|
| 25 |
+
candidate = Path(__file__).resolve().parent / path
|
| 26 |
+
if candidate.exists():
|
| 27 |
+
path = candidate
|
| 28 |
+
if not path.exists():
|
| 29 |
+
raise FileNotFoundError(f"Dataset not found at {path}")
|
| 30 |
+
|
| 31 |
+
df = pd.read_csv(path)
|
| 32 |
+
desc_col = next((c for c in DESCRIPTION_COLUMNS if c in df.columns), None)
|
| 33 |
+
missing = sorted(REQUIRED_COLUMNS - set(df.columns))
|
| 34 |
+
if desc_col is None:
|
| 35 |
+
missing.append("Description or Ticket Description")
|
| 36 |
+
if missing:
|
| 37 |
+
raise ValueError(f"Missing required dataset columns: {', '.join(missing)}")
|
| 38 |
+
|
| 39 |
+
df = df.copy()
|
| 40 |
+
if desc_col != "Description":
|
| 41 |
+
df["Description"] = df[desc_col]
|
| 42 |
+
df["difficulty"] = df["difficulty"].astype(str).str.lower().str.strip()
|
| 43 |
+
df["requires_escalation"] = (
|
| 44 |
+
df["requires_escalation"].astype(str).str.lower().map({"true": True, "false": False}).fillna(False)
|
| 45 |
+
)
|
| 46 |
+
df["TicketID"] = df["TicketID"].astype(int)
|
| 47 |
+
return df
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def split_difficulty(df: pd.DataFrame) -> Dict[str, pd.DataFrame]:
|
| 51 |
+
return {d: df[df["difficulty"] == d].reset_index(drop=True).copy() for d in DIFFICULTIES}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def build_expected(row: pd.Series) -> dict:
|
| 55 |
+
return {
|
| 56 |
+
"ticket_id": int(row["TicketID"]),
|
| 57 |
+
"query": str(row["Description"]),
|
| 58 |
+
"expected_category": str(row["expected_category"]),
|
| 59 |
+
"expected_action": str(row["expected_action"]),
|
| 60 |
+
"expected_response": str(row["expected_response"]),
|
| 61 |
+
"requires_escalation": bool(row["requires_escalation"]),
|
| 62 |
+
"kb_id": str(row["knowledge_base_id"]),
|
| 63 |
+
"difficulty": str(row["difficulty"]),
|
| 64 |
+
}
|
dataset.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
inference.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
from typing import Dict, List
|
| 7 |
+
|
| 8 |
+
from openai import OpenAI
|
| 9 |
+
|
| 10 |
+
from models import SupportAction
|
| 11 |
+
from server.customer_support_environment import CustomerSupportEnvironment
|
| 12 |
+
from tasks import grade_task
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 16 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: str | None = None) -> None:
|
| 20 |
+
error_value = "null" if error is None else error.replace("\n", " ")
|
| 21 |
+
print(
|
| 22 |
+
f"[STEP] step={step} action={action!r} reward={reward:.4f} done={done} error={error_value}",
|
| 23 |
+
flush=True,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 28 |
+
reward_text = ",".join(f"{r:.4f}" for r in rewards)
|
| 29 |
+
print(
|
| 30 |
+
f"[END] success={str(success).lower()} steps={steps} score={score:.4f} rewards=[{reward_text}]",
|
| 31 |
+
flush=True,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_model_plan(client: OpenAI, model_name: str, task_prompt: str) -> Dict[str, str | bool]:
|
| 36 |
+
completion = client.chat.completions.create(
|
| 37 |
+
model=model_name,
|
| 38 |
+
temperature=0,
|
| 39 |
+
messages=[
|
| 40 |
+
{
|
| 41 |
+
"role": "system",
|
| 42 |
+
"content": (
|
| 43 |
+
"You are a customer support policy agent. Reply with strict JSON only and no markdown. "
|
| 44 |
+
"Schema: {\"category\": string, \"search_kb\": bool, \"escalate\": bool, \"response\": string}."
|
| 45 |
+
),
|
| 46 |
+
},
|
| 47 |
+
{"role": "user", "content": task_prompt},
|
| 48 |
+
],
|
| 49 |
+
response_format={"type": "json_object"},
|
| 50 |
+
)
|
| 51 |
+
raw = completion.choices[0].message.content or "{}"
|
| 52 |
+
data = json.loads(raw)
|
| 53 |
+
return {
|
| 54 |
+
"category": str(data.get("category", "technical_issue")),
|
| 55 |
+
"search_kb": bool(data.get("search_kb", True)),
|
| 56 |
+
"escalate": bool(data.get("escalate", False)),
|
| 57 |
+
"response": str(data.get("response", "Thank you for contacting support. We will assist shortly.")),
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def heuristic_plan(observation: object) -> Dict[str, str | bool]:
|
| 62 |
+
query = str(getattr(observation, "query")).lower()
|
| 63 |
+
requires_escalation = bool(getattr(observation, "requires_escalation"))
|
| 64 |
+
|
| 65 |
+
category = "technical_issue"
|
| 66 |
+
if any(t in query for t in ("refund", "charge", "invoice", "billing")):
|
| 67 |
+
category = "billing"
|
| 68 |
+
elif any(t in query for t in ("cancel", "termination", "close account", "cancellation")):
|
| 69 |
+
category = "cancellation"
|
| 70 |
+
elif any(t in query for t in ("warranty", "buy", "purchase", "availability", "product")):
|
| 71 |
+
category = "product_inquiry"
|
| 72 |
+
elif any(t in query for t in ("refund not received", "return", "reimburse")):
|
| 73 |
+
category = "refund"
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"category": category,
|
| 77 |
+
"search_kb": True,
|
| 78 |
+
"escalate": requires_escalation,
|
| 79 |
+
"response": "Thank you for contacting us. We will review your case and assist you shortly.",
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def build_actions(observation: object, plan: Dict[str, str | bool]) -> List[SupportAction]:
|
| 84 |
+
actions = [
|
| 85 |
+
SupportAction(action_type="classify", content=str(plan["category"])),
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
if bool(plan.get("search_kb", True)):
|
| 89 |
+
actions.append(SupportAction(action_type="search_kb", content=str(getattr(observation, "kb_id"))))
|
| 90 |
+
|
| 91 |
+
if bool(plan.get("escalate", False)):
|
| 92 |
+
actions.append(SupportAction(action_type="escalate", content="Escalating to specialist support for manual review."))
|
| 93 |
+
else:
|
| 94 |
+
actions.append(SupportAction(action_type="respond", content=str(plan.get("response", "Thank you for contacting support."))))
|
| 95 |
+
return actions
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def main() -> int:
|
| 99 |
+
parser = argparse.ArgumentParser(description="OpenEnv baseline inference for customer support benchmark")
|
| 100 |
+
parser.add_argument("--csv", default="dataset.csv")
|
| 101 |
+
parser.add_argument("--limit-per-task", type=int, default=5)
|
| 102 |
+
parser.add_argument("--max-steps", type=int, default=4)
|
| 103 |
+
parser.add_argument("--offline", action="store_true", help="Use heuristic policy without API calls")
|
| 104 |
+
args = parser.parse_args()
|
| 105 |
+
|
| 106 |
+
api_base_url = os.getenv("API_BASE_URL")
|
| 107 |
+
model_name = os.getenv("MODEL_NAME")
|
| 108 |
+
hf_token = os.getenv("HF_TOKEN")
|
| 109 |
+
|
| 110 |
+
if not args.offline:
|
| 111 |
+
missing = [name for name, value in (("API_BASE_URL", api_base_url), ("MODEL_NAME", model_name), ("HF_TOKEN", hf_token)) if not value]
|
| 112 |
+
if missing:
|
| 113 |
+
raise RuntimeError(f"Missing required env vars: {', '.join(missing)}")
|
| 114 |
+
|
| 115 |
+
client = OpenAI(base_url=api_base_url, api_key=hf_token) if not args.offline else None
|
| 116 |
+
env = CustomerSupportEnvironment(csv_path=args.csv)
|
| 117 |
+
|
| 118 |
+
all_rewards: List[float] = []
|
| 119 |
+
task_scores: List[float] = []
|
| 120 |
+
task_labels: List[str] = []
|
| 121 |
+
total_steps = 0
|
| 122 |
+
|
| 123 |
+
log_start(task="all", env="customer_support_benchmark", model=model_name or "offline-heuristic")
|
| 124 |
+
difficulties = ["easy", "medium", "hard"]
|
| 125 |
+
for difficulty in difficulties:
|
| 126 |
+
for idx in range(args.limit_per_task):
|
| 127 |
+
obs = env.reset(difficulty=difficulty, index=idx)
|
| 128 |
+
rewards: List[float] = []
|
| 129 |
+
done = False
|
| 130 |
+
|
| 131 |
+
prompt = (
|
| 132 |
+
"Return the best support plan for this ticket. "
|
| 133 |
+
"Prefer policy-safe responses and escalate only when needed.\n"
|
| 134 |
+
f"TicketID={obs.ticket_id}\n"
|
| 135 |
+
f"Difficulty={obs.difficulty}\n"
|
| 136 |
+
f"Task={obs.task_id}\n"
|
| 137 |
+
f"Query={obs.query}\n"
|
| 138 |
+
f"KB={obs.kb_id}\n"
|
| 139 |
+
f"RequiresEscalation={obs.requires_escalation}\n"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
error = None
|
| 143 |
+
try:
|
| 144 |
+
plan = heuristic_plan(obs)
|
| 145 |
+
if client is not None and model_name is not None:
|
| 146 |
+
plan = get_model_plan(client, model_name, prompt)
|
| 147 |
+
except Exception as exc:
|
| 148 |
+
error = str(exc)
|
| 149 |
+
plan = heuristic_plan(obs)
|
| 150 |
+
|
| 151 |
+
planned_actions = build_actions(obs, plan)
|
| 152 |
+
|
| 153 |
+
for step, action in enumerate(planned_actions[: args.max_steps], start=1):
|
| 154 |
+
if done:
|
| 155 |
+
break
|
| 156 |
+
obs = env.step(action)
|
| 157 |
+
|
| 158 |
+
reward = float(obs.reward or 0.0)
|
| 159 |
+
rewards.append(reward)
|
| 160 |
+
all_rewards.append(reward)
|
| 161 |
+
total_steps += 1
|
| 162 |
+
done = bool(obs.done)
|
| 163 |
+
step_error = error if step == 1 else None
|
| 164 |
+
log_step(step=step, action=f"{action.action_type}|{action.content}", reward=reward, done=done, error=step_error)
|
| 165 |
+
|
| 166 |
+
final_score = grade_task(obs.task_id, obs.metadata, obs.history)
|
| 167 |
+
task_scores.append(final_score)
|
| 168 |
+
task_labels.append(difficulty)
|
| 169 |
+
log_step(
|
| 170 |
+
step=total_steps,
|
| 171 |
+
action=f"episode_score|difficulty={difficulty}|index={idx}",
|
| 172 |
+
reward=final_score,
|
| 173 |
+
done=True,
|
| 174 |
+
error=None,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
benchmark_score = sum(task_scores) / len(task_scores) if task_scores else 0.0
|
| 178 |
+
by_task: dict[str, list[float]] = {"easy": [], "medium": [], "hard": []}
|
| 179 |
+
for label, score in zip(task_labels, task_scores):
|
| 180 |
+
by_task[label].append(score)
|
| 181 |
+
for label in ("easy", "medium", "hard"):
|
| 182 |
+
values = by_task[label]
|
| 183 |
+
avg = sum(values) / len(values) if values else 0.0
|
| 184 |
+
log_step(
|
| 185 |
+
step=total_steps,
|
| 186 |
+
action=f"task_average|difficulty={label}",
|
| 187 |
+
reward=avg,
|
| 188 |
+
done=False,
|
| 189 |
+
error=None,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
print(
|
| 193 |
+
f"[END] success={str(benchmark_score >= 0.7).lower()} steps={total_steps} score={benchmark_score:.4f} rewards_count={len(all_rewards)}",
|
| 194 |
+
flush=True,
|
| 195 |
+
)
|
| 196 |
+
return 0
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
if __name__ == "__main__":
|
| 200 |
+
raise SystemExit(main())
|
kb.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import Counter, defaultdict
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def build_knowledge_base(df: pd.DataFrame) -> dict[str, dict]:
|
| 9 |
+
grouped: dict[str, list] = defaultdict(list)
|
| 10 |
+
for _, row in df.iterrows():
|
| 11 |
+
grouped[str(row["knowledge_base_id"])].append(row)
|
| 12 |
+
|
| 13 |
+
kb: dict[str, dict] = {}
|
| 14 |
+
for kb_id, rows in grouped.items():
|
| 15 |
+
kb[kb_id] = {
|
| 16 |
+
"title": _most_common([str(row.get("Subject", kb_id)) for row in rows]),
|
| 17 |
+
"category": _most_common([str(row["expected_category"]) for row in rows]),
|
| 18 |
+
"canonical_action": _most_common([str(row["expected_action"]) for row in rows]),
|
| 19 |
+
"canonical_response": _most_common([str(row["expected_response"]) for row in rows]),
|
| 20 |
+
"summary": "Ground responses in policy and avoid unsupported claims.",
|
| 21 |
+
}
|
| 22 |
+
return kb
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _most_common(items: list[str]) -> str:
|
| 26 |
+
if not items:
|
| 27 |
+
return ""
|
| 28 |
+
return Counter(items).most_common(1)[0][0]
|
models.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 4 |
+
|
| 5 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 6 |
+
from pydantic import Field
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class SupportAction(Action):
|
| 10 |
+
action_type: Literal["classify", "search_kb", "respond", "escalate"] = Field(
|
| 11 |
+
..., description="Action category taken by the agent"
|
| 12 |
+
)
|
| 13 |
+
content: str = Field(default="", description="Action payload text")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class SupportObservation(Observation):
|
| 17 |
+
ticket_id: int = Field(..., description="Current ticket identifier")
|
| 18 |
+
task_id: str = Field(..., description="Current task id")
|
| 19 |
+
difficulty: Literal["easy", "medium", "hard"] = Field(...)
|
| 20 |
+
query: str = Field(..., description="Customer support query")
|
| 21 |
+
kb_id: str = Field(..., description="Expected knowledge-base identifier")
|
| 22 |
+
requires_escalation: bool = Field(..., description="Whether escalation is required")
|
| 23 |
+
history: List[Dict[str, Any]] = Field(default_factory=list)
|
| 24 |
+
feedback: str = Field(default="", description="Reward-shaping feedback")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class SupportState(State):
|
| 28 |
+
difficulty_filter: Optional[str] = Field(default=None)
|
| 29 |
+
current_index: int = Field(default=0)
|
| 30 |
+
task_id: str = Field(default="")
|
| 31 |
+
score_so_far: float = Field(default=0.0)
|
| 32 |
+
final_score: float = Field(default=0.0)
|
| 33 |
+
done: bool = Field(default=False)
|
openenv.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: customer_support_benchmark
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 8000
|
pre_submission_validate.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
import time
|
| 10 |
+
import urllib.error
|
| 11 |
+
import urllib.request
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parent
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _run(cmd: list[str], timeout: int = 600, cwd: Path | None = None) -> tuple[int, str]:
|
| 19 |
+
process = subprocess.run(
|
| 20 |
+
cmd,
|
| 21 |
+
cwd=str(cwd or ROOT),
|
| 22 |
+
stdout=subprocess.PIPE,
|
| 23 |
+
stderr=subprocess.STDOUT,
|
| 24 |
+
timeout=timeout,
|
| 25 |
+
text=True,
|
| 26 |
+
check=False,
|
| 27 |
+
)
|
| 28 |
+
return process.returncode, process.stdout
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _check_env_vars() -> bool:
|
| 32 |
+
required = ["API_BASE_URL", "MODEL_NAME", "HF_TOKEN"]
|
| 33 |
+
missing = [name for name in required if not os.getenv(name)]
|
| 34 |
+
if missing:
|
| 35 |
+
print(f"[FAIL] missing env vars: {', '.join(missing)}")
|
| 36 |
+
return False
|
| 37 |
+
print("[PASS] required env vars are set")
|
| 38 |
+
return True
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _check_openenv_validate() -> bool:
|
| 42 |
+
openenv_path = shutil.which("openenv")
|
| 43 |
+
if openenv_path is None:
|
| 44 |
+
scripts_dir = Path(sys.executable).resolve().parent
|
| 45 |
+
candidate = scripts_dir / ("openenv.exe" if os.name == "nt" else "openenv")
|
| 46 |
+
if candidate.exists():
|
| 47 |
+
openenv_path = str(candidate)
|
| 48 |
+
if openenv_path is None:
|
| 49 |
+
print("[FAIL] openenv CLI not found in PATH")
|
| 50 |
+
return False
|
| 51 |
+
code, output = _run([openenv_path, "validate"], timeout=300)
|
| 52 |
+
if code != 0:
|
| 53 |
+
print("[FAIL] openenv validate failed")
|
| 54 |
+
print(output)
|
| 55 |
+
return False
|
| 56 |
+
print("[PASS] openenv validate")
|
| 57 |
+
return True
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _check_reset_ping(port: int = 8000) -> bool:
|
| 61 |
+
python_bin = sys.executable
|
| 62 |
+
cmd = [python_bin, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1", "--port", str(port)]
|
| 63 |
+
proc = subprocess.Popen(
|
| 64 |
+
cmd,
|
| 65 |
+
cwd=str(ROOT),
|
| 66 |
+
stdout=subprocess.PIPE,
|
| 67 |
+
stderr=subprocess.STDOUT,
|
| 68 |
+
text=True,
|
| 69 |
+
)
|
| 70 |
+
try:
|
| 71 |
+
deadline = time.time() + 30
|
| 72 |
+
url = f"http://127.0.0.1:{port}/reset"
|
| 73 |
+
payload = json.dumps({}).encode("utf-8")
|
| 74 |
+
request = urllib.request.Request(url, data=payload, method="POST")
|
| 75 |
+
request.add_header("Content-Type", "application/json")
|
| 76 |
+
|
| 77 |
+
while time.time() < deadline:
|
| 78 |
+
try:
|
| 79 |
+
with urllib.request.urlopen(request, timeout=5) as response:
|
| 80 |
+
if response.status == 200:
|
| 81 |
+
print("[PASS] POST /reset returned 200")
|
| 82 |
+
return True
|
| 83 |
+
except urllib.error.URLError:
|
| 84 |
+
time.sleep(1)
|
| 85 |
+
continue
|
| 86 |
+
print("[FAIL] POST /reset did not return 200 in time")
|
| 87 |
+
return False
|
| 88 |
+
finally:
|
| 89 |
+
proc.terminate()
|
| 90 |
+
try:
|
| 91 |
+
proc.wait(timeout=5)
|
| 92 |
+
except subprocess.TimeoutExpired:
|
| 93 |
+
proc.kill()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _check_docker(skip: bool) -> bool:
|
| 97 |
+
if skip:
|
| 98 |
+
print("[SKIP] docker build check")
|
| 99 |
+
return True
|
| 100 |
+
docker_path = shutil.which("docker")
|
| 101 |
+
if docker_path is None:
|
| 102 |
+
print("[FAIL] docker not found")
|
| 103 |
+
return False
|
| 104 |
+
code, output = _run([docker_path, "build", "-t", "customer-support-openenv", "."], timeout=1200)
|
| 105 |
+
if code != 0:
|
| 106 |
+
print("[FAIL] docker build failed")
|
| 107 |
+
print(output)
|
| 108 |
+
return False
|
| 109 |
+
print("[PASS] docker build")
|
| 110 |
+
return True
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def main() -> int:
|
| 114 |
+
parser = argparse.ArgumentParser(description="Local pre-submission checks for OpenEnv benchmark")
|
| 115 |
+
parser.add_argument("--skip-docker", action="store_true", help="Skip docker build check")
|
| 116 |
+
args = parser.parse_args()
|
| 117 |
+
|
| 118 |
+
checks = [
|
| 119 |
+
_check_env_vars(),
|
| 120 |
+
_check_openenv_validate(),
|
| 121 |
+
_check_reset_ping(),
|
| 122 |
+
_check_docker(args.skip_docker),
|
| 123 |
+
]
|
| 124 |
+
passed = all(checks)
|
| 125 |
+
print("[DONE] pre-submission checks passed" if passed else "[DONE] pre-submission checks failed")
|
| 126 |
+
return 0 if passed else 1
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
if __name__ == "__main__":
|
| 130 |
+
raise SystemExit(main())
|
pyproject.toml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "customer-support-openenv-benchmark"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "OpenEnv customer support benchmark environment with graded tasks"
|
| 9 |
+
requires-python = ">=3.10"
|
| 10 |
+
dependencies = [
|
| 11 |
+
"pandas>=2.2.0",
|
| 12 |
+
"pydantic>=2.7.0",
|
| 13 |
+
"openenv-core[core]>=0.2.2",
|
| 14 |
+
"fastapi>=0.115.0",
|
| 15 |
+
"uvicorn>=0.34.0",
|
| 16 |
+
"openai>=1.76.0",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
[project.scripts]
|
| 20 |
+
server = "server.app:main"
|
| 21 |
+
|
| 22 |
+
[tool.setuptools]
|
| 23 |
+
py-modules = ["models", "tasks", "data_loader", "kb", "client", "inference"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pandas>=2.2.0
|
| 2 |
+
pydantic>=2.7.0
|
| 3 |
+
openenv-core[core]>=0.2.2
|
| 4 |
+
fastapi>=0.115.0
|
| 5 |
+
uvicorn>=0.34.0
|
| 6 |
+
openai>=1.76.0
|
tasks.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from difflib import SequenceMatcher
|
| 5 |
+
from typing import Callable, Dict, List
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class TaskSpec:
|
| 10 |
+
task_id: str
|
| 11 |
+
difficulty: str
|
| 12 |
+
objective: str
|
| 13 |
+
grader_name: str
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
TASKS: Dict[str, TaskSpec] = {
|
| 17 |
+
"easy_classify_respond": TaskSpec(
|
| 18 |
+
task_id="easy_classify_respond",
|
| 19 |
+
difficulty="easy",
|
| 20 |
+
objective="Classify ticket correctly and provide the expected response.",
|
| 21 |
+
grader_name="grade_easy",
|
| 22 |
+
),
|
| 23 |
+
"medium_kb_grounded_response": TaskSpec(
|
| 24 |
+
task_id="medium_kb_grounded_response",
|
| 25 |
+
difficulty="medium",
|
| 26 |
+
objective="Use the correct KB entry and provide a grounded response.",
|
| 27 |
+
grader_name="grade_medium",
|
| 28 |
+
),
|
| 29 |
+
"hard_escalation_safety": TaskSpec(
|
| 30 |
+
task_id="hard_escalation_safety",
|
| 31 |
+
difficulty="hard",
|
| 32 |
+
objective="Make the right escalation decision and keep the response safe/polite.",
|
| 33 |
+
grader_name="grade_hard",
|
| 34 |
+
),
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def task_for_difficulty(difficulty: str) -> TaskSpec:
|
| 39 |
+
normalized = difficulty.strip().lower()
|
| 40 |
+
for task in TASKS.values():
|
| 41 |
+
if task.difficulty == normalized:
|
| 42 |
+
return task
|
| 43 |
+
return TASKS["easy_classify_respond"]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def grade_easy(expected: dict, history: List[dict]) -> float:
|
| 47 |
+
classify_ok = any(
|
| 48 |
+
h["action_type"] == "classify" and _norm(h["content"]) == _norm(expected["expected_category"])
|
| 49 |
+
for h in history
|
| 50 |
+
)
|
| 51 |
+
response_score = _best_response_similarity(expected["expected_response"], history)
|
| 52 |
+
score = (0.5 if classify_ok else 0.0) + 0.5 * response_score
|
| 53 |
+
return _clamp01(score)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def grade_medium(expected: dict, history: List[dict]) -> float:
|
| 57 |
+
kb_ok = any(
|
| 58 |
+
h["action_type"] == "search_kb" and _norm(h["content"]) == _norm(expected["kb_id"])
|
| 59 |
+
for h in history
|
| 60 |
+
)
|
| 61 |
+
response_score = _best_response_similarity(expected["expected_response"], history)
|
| 62 |
+
score = (0.4 if kb_ok else 0.0) + 0.6 * response_score
|
| 63 |
+
return _clamp01(score)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def grade_hard(expected: dict, history: List[dict]) -> float:
|
| 67 |
+
escalated = any(h["action_type"] == "escalate" for h in history)
|
| 68 |
+
escalation_ok = escalated == bool(expected["requires_escalation"])
|
| 69 |
+
response_score = _best_response_similarity(expected["expected_response"], history)
|
| 70 |
+
safe = any(h["action_type"] == "respond" and _is_safe(h["content"]) for h in history)
|
| 71 |
+
score = (0.5 if escalation_ok else 0.0) + 0.25 * response_score + (0.25 if safe else 0.0)
|
| 72 |
+
return _clamp01(score)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
GRADERS: Dict[str, Callable[[dict, List[dict]], float]] = {
|
| 76 |
+
"grade_easy": grade_easy,
|
| 77 |
+
"grade_medium": grade_medium,
|
| 78 |
+
"grade_hard": grade_hard,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def grade_task(task_id: str, expected: dict, history: List[dict]) -> float:
|
| 83 |
+
task = TASKS[task_id]
|
| 84 |
+
grader = GRADERS[task.grader_name]
|
| 85 |
+
return grader(expected, history)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _best_response_similarity(expected_response: str, history: List[dict]) -> float:
|
| 89 |
+
expected = _norm(expected_response)
|
| 90 |
+
if not expected:
|
| 91 |
+
return 0.0
|
| 92 |
+
best = 0.0
|
| 93 |
+
for h in history:
|
| 94 |
+
if h["action_type"] != "respond":
|
| 95 |
+
continue
|
| 96 |
+
candidate = _norm(h["content"])
|
| 97 |
+
if not candidate:
|
| 98 |
+
continue
|
| 99 |
+
best = max(best, SequenceMatcher(None, expected, candidate).ratio())
|
| 100 |
+
return _clamp01(best)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _is_safe(content: str) -> bool:
|
| 104 |
+
lowered = _norm(content)
|
| 105 |
+
unsafe_markers = ("guaranteed fix", "ignore policy", "skip verification", "hack")
|
| 106 |
+
if any(marker in lowered for marker in unsafe_markers):
|
| 107 |
+
return False
|
| 108 |
+
safe_markers = ("thank", "please", "assist", "support", "review", "escalat")
|
| 109 |
+
return any(marker in lowered for marker in safe_markers)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _norm(value: str) -> str:
|
| 113 |
+
return " ".join(str(value).strip().lower().split())
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _clamp01(value: float) -> float:
|
| 117 |
+
return max(0.0, min(1.0, round(float(value), 4)))
|
test_api_smoke.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
|
| 3 |
+
from client import CustomerSupportEnvClient
|
| 4 |
+
from models import SupportAction
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
async def run_episode(difficulty: str, index: int) -> dict:
|
| 8 |
+
env = CustomerSupportEnvClient(base_url="http://127.0.0.1:8000")
|
| 9 |
+
await env.connect()
|
| 10 |
+
reset_result = await env.reset(difficulty=difficulty, index=index)
|
| 11 |
+
|
| 12 |
+
rewards: list[float] = []
|
| 13 |
+
done = bool(reset_result.done)
|
| 14 |
+
observation = reset_result.observation
|
| 15 |
+
|
| 16 |
+
actions = [
|
| 17 |
+
SupportAction(action_type="classify", content="technical_issue"),
|
| 18 |
+
SupportAction(action_type="search_kb", content=observation.kb_id),
|
| 19 |
+
SupportAction(
|
| 20 |
+
action_type="respond",
|
| 21 |
+
content="Thank you for contacting us. We will assist you shortly.",
|
| 22 |
+
),
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
for action in actions:
|
| 26 |
+
if done:
|
| 27 |
+
break
|
| 28 |
+
step_result = await env.step(action)
|
| 29 |
+
rewards.append(float(step_result.reward or 0.0))
|
| 30 |
+
done = bool(step_result.done)
|
| 31 |
+
observation = step_result.observation
|
| 32 |
+
|
| 33 |
+
await env.close()
|
| 34 |
+
return {
|
| 35 |
+
"difficulty": difficulty,
|
| 36 |
+
"index": index,
|
| 37 |
+
"ticket_id": observation.ticket_id,
|
| 38 |
+
"done": done,
|
| 39 |
+
"steps": len(rewards),
|
| 40 |
+
"reward_sum": round(sum(rewards), 4),
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def main() -> None:
|
| 45 |
+
for difficulty in ("easy", "medium", "hard"):
|
| 46 |
+
for index in (0, 1):
|
| 47 |
+
result = await run_episode(difficulty, index)
|
| 48 |
+
print(result)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
asyncio.run(main())
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|