Files changed (17) hide show
  1. .env +3 -0
  2. Dockerfile +15 -0
  3. README.md +158 -10
  4. client.py +50 -0
  5. daigram.md +48 -0
  6. data_loader.py +64 -0
  7. dataset.csv +0 -0
  8. inference.py +200 -0
  9. kb.py +28 -0
  10. models.py +33 -0
  11. openenv.yaml +6 -0
  12. pre_submission_validate.py +130 -0
  13. pyproject.toml +23 -0
  14. requirements.txt +6 -0
  15. tasks.py +117 -0
  16. test_api_smoke.py +52 -0
  17. 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: OpenenvScaler
3
- emoji: 🔥
4
- colorFrom: pink
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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