Files changed (3) hide show
  1. app.py +1 -26
  2. customer_support_environment.py +200 -0
  3. pyproject.toml +1 -1
app.py CHANGED
@@ -1,35 +1,10 @@
1
  from __future__ import annotations
2
 
3
- from importlib.util import module_from_spec, spec_from_file_location
4
- from pathlib import Path
5
-
6
  from openenv.core.env_server.http_server import create_app
7
 
 
8
  from models import SupportAction, SupportObservation
9
 
10
-
11
- def _load_environment_class():
12
- try:
13
- from server.customer_support_environment import CustomerSupportEnvironment
14
-
15
- return CustomerSupportEnvironment
16
- except ModuleNotFoundError:
17
- # Fallback for runtimes where package-style imports are not resolved.
18
- env_file = Path(__file__).resolve().parent / "server" / "customer_support_environment.py"
19
- if not env_file.exists():
20
- raise
21
-
22
- spec = spec_from_file_location("customer_support_environment", env_file)
23
- if spec is None or spec.loader is None:
24
- raise RuntimeError("Unable to load customer_support_environment module")
25
-
26
- module = module_from_spec(spec)
27
- spec.loader.exec_module(module)
28
- return module.CustomerSupportEnvironment
29
-
30
-
31
- CustomerSupportEnvironment = _load_environment_class()
32
-
33
  app = create_app(
34
  CustomerSupportEnvironment,
35
  SupportAction,
 
1
  from __future__ import annotations
2
 
 
 
 
3
  from openenv.core.env_server.http_server import create_app
4
 
5
+ from customer_support_environment import CustomerSupportEnvironment
6
  from models import SupportAction, SupportObservation
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  app = create_app(
9
  CustomerSupportEnvironment,
10
  SupportAction,
customer_support_environment.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from difflib import SequenceMatcher
4
+ from typing import Any, Optional
5
+ from uuid import uuid4
6
+
7
+ from openenv.core.env_server.interfaces import Environment
8
+
9
+ from data_loader import build_expected, load_dataset, split_difficulty
10
+ from kb import build_knowledge_base
11
+ from models import SupportAction, SupportObservation, SupportState
12
+ from tasks import grade_task, task_for_difficulty
13
+
14
+
15
+ class CustomerSupportEnvironment(Environment[SupportAction, SupportObservation, SupportState]):
16
+ SUPPORTS_CONCURRENT_SESSIONS = True
17
+
18
+ def __init__(self, csv_path: str = "dataset.csv", max_steps: int = 6):
19
+ super().__init__()
20
+ self.df = load_dataset(csv_path)
21
+ self.splits = split_difficulty(self.df)
22
+ self.kb = build_knowledge_base(self.df)
23
+ self.max_steps = max_steps
24
+
25
+ self._episodes = self.df.reset_index(drop=True)
26
+ self._cursor = 0
27
+ self._expected: dict[str, Any] | None = None
28
+ self._history: list[dict[str, Any]] = []
29
+ self._difficulty_filter: Optional[str] = None
30
+ self._final_score = 0.0
31
+ self._done = False
32
+ self._task_id = ""
33
+ self._state = SupportState(episode_id=str(uuid4()), step_count=0)
34
+
35
+ def reset(
36
+ self,
37
+ seed: Optional[int] = None,
38
+ episode_id: Optional[str] = None,
39
+ difficulty: Optional[str] = None,
40
+ index: Optional[int] = None,
41
+ **kwargs: Any,
42
+ ) -> SupportObservation:
43
+ del seed, kwargs
44
+ if difficulty is not None:
45
+ normalized = difficulty.strip().lower()
46
+ if normalized not in self.splits:
47
+ raise ValueError(f"Unknown difficulty: {difficulty}")
48
+ self._difficulty_filter = normalized
49
+ self._episodes = self.splits[normalized].reset_index(drop=True)
50
+ elif self._difficulty_filter is None:
51
+ self._episodes = self.df.reset_index(drop=True)
52
+
53
+ if len(self._episodes) == 0:
54
+ raise ValueError("No episodes found for the requested filter")
55
+
56
+ if index is None:
57
+ self._cursor = self._cursor % len(self._episodes)
58
+ else:
59
+ self._cursor = int(index) % len(self._episodes)
60
+
61
+ row = self._episodes.iloc[self._cursor]
62
+ self._cursor = (self._cursor + 1) % len(self._episodes)
63
+ self._expected = build_expected(row)
64
+ self._task_id = task_for_difficulty(self._expected["difficulty"]).task_id
65
+ self._history = []
66
+ self._done = False
67
+ self._final_score = 0.0
68
+
69
+ self._state = SupportState(
70
+ episode_id=episode_id or str(uuid4()),
71
+ step_count=0,
72
+ difficulty_filter=self._difficulty_filter,
73
+ current_index=self._cursor,
74
+ task_id=self._task_id,
75
+ score_so_far=0.0,
76
+ final_score=0.0,
77
+ done=False,
78
+ )
79
+
80
+ return self._make_observation(reward=0.0, done=False, feedback="Environment reset")
81
+
82
+ def step(
83
+ self,
84
+ action: SupportAction,
85
+ timeout_s: Optional[float] = None,
86
+ **kwargs: Any,
87
+ ) -> SupportObservation:
88
+ del timeout_s, kwargs
89
+ if self._expected is None:
90
+ raise RuntimeError("Call reset before step")
91
+ if self._done:
92
+ return self._make_observation(reward=0.0, done=True, feedback="Episode already done")
93
+
94
+ self._state.step_count += 1
95
+ reward = -0.01 * self._state.step_count
96
+ feedback_parts: list[str] = ["time_penalty"]
97
+
98
+ if action.action_type == "classify":
99
+ if _norm(action.content) == _norm(self._expected["expected_category"]):
100
+ reward += 0.3
101
+ feedback_parts.append("classification_correct")
102
+ else:
103
+ reward -= 0.1
104
+ feedback_parts.append("classification_incorrect")
105
+
106
+ elif action.action_type == "search_kb":
107
+ if _norm(action.content) == _norm(self._expected["kb_id"]):
108
+ reward += 0.2
109
+ feedback_parts.append("kb_match")
110
+ else:
111
+ reward -= 0.05
112
+ feedback_parts.append("kb_mismatch")
113
+
114
+ elif action.action_type == "respond":
115
+ similarity = SequenceMatcher(
116
+ None,
117
+ _norm(action.content),
118
+ _norm(self._expected["expected_response"]),
119
+ ).ratio()
120
+ reward += 0.45 * similarity
121
+ if _is_polite(action.content):
122
+ reward += 0.1
123
+ feedback_parts.append("polite")
124
+ if similarity < 0.25:
125
+ reward -= 0.2
126
+ feedback_parts.append("hallucination_risk")
127
+ self._done = True
128
+ feedback_parts.append("terminal_respond")
129
+
130
+ elif action.action_type == "escalate":
131
+ if bool(self._expected["requires_escalation"]):
132
+ reward += 0.3
133
+ feedback_parts.append("escalation_correct")
134
+ else:
135
+ reward -= 0.2
136
+ feedback_parts.append("unnecessary_escalation")
137
+ self._done = True
138
+ feedback_parts.append("terminal_escalate")
139
+
140
+ if self._state.step_count >= self.max_steps:
141
+ self._done = True
142
+ feedback_parts.append("max_steps")
143
+
144
+ event = {
145
+ "step": self._state.step_count,
146
+ "action_type": action.action_type,
147
+ "content": action.content,
148
+ "reward": round(reward, 4),
149
+ }
150
+ self._history.append(event)
151
+
152
+ if self._done:
153
+ self._final_score = grade_task(self._task_id, self._expected, self._history)
154
+ self._state.final_score = self._final_score
155
+
156
+ self._state.score_so_far = max(0.0, min(1.0, self._state.score_so_far + max(reward, 0.0) / 2.0))
157
+ self._state.done = self._done
158
+
159
+ return self._make_observation(
160
+ reward=round(reward, 4),
161
+ done=self._done,
162
+ feedback=",".join(feedback_parts),
163
+ )
164
+
165
+ @property
166
+ def state(self) -> SupportState:
167
+ return self._state
168
+
169
+ def _make_observation(self, reward: float, done: bool, feedback: str) -> SupportObservation:
170
+ if self._expected is None:
171
+ raise RuntimeError("Environment is not initialized")
172
+ return SupportObservation(
173
+ ticket_id=self._expected["ticket_id"],
174
+ task_id=self._task_id,
175
+ difficulty=self._expected["difficulty"],
176
+ query=self._expected["query"],
177
+ kb_id=self._expected["kb_id"],
178
+ requires_escalation=self._expected["requires_escalation"],
179
+ history=list(self._history),
180
+ done=done,
181
+ reward=reward,
182
+ feedback=feedback,
183
+ metadata={
184
+ "expected_category": self._expected["expected_category"],
185
+ "expected_action": self._expected["expected_action"],
186
+ "expected_response": self._expected["expected_response"],
187
+ "kb_id": self._expected["kb_id"],
188
+ "requires_escalation": self._expected["requires_escalation"],
189
+ "final_score": self._final_score,
190
+ },
191
+ )
192
+
193
+
194
+ def _norm(text: str) -> str:
195
+ return " ".join(str(text).strip().lower().split())
196
+
197
+
198
+ def _is_polite(text: str) -> bool:
199
+ lowered = _norm(text)
200
+ return any(token in lowered for token in ("thank", "please", "assist", "apolog"))
pyproject.toml CHANGED
@@ -20,7 +20,7 @@ dependencies = [
20
  server = "app:main"
21
 
22
  [tool.setuptools]
23
- py-modules = ["app", "models", "tasks", "data_loader", "kb", "client", "inference"]
24
 
25
  [tool.setuptools.packages.find]
26
  include = ["server", "server.*"]
 
20
  server = "app:main"
21
 
22
  [tool.setuptools]
23
+ py-modules = ["app", "customer_support_environment", "models", "tasks", "data_loader", "kb", "client", "inference"]
24
 
25
  [tool.setuptools.packages.find]
26
  include = ["server", "server.*"]