Omkar1806 commited on
Commit
272ae1e
·
verified ·
1 Parent(s): 137d37b

Update env.py

Browse files
Files changed (1) hide show
  1. env.py +80 -375
env.py CHANGED
@@ -1,405 +1,110 @@
1
- """
2
- env.py — Email Gatekeeper RL Environment (OpenEnv Specification)
3
- ================================================================
4
- Gymnasium environment for intelligent email triage.
5
- Wraps the core EmailTriageEnv logic with:
6
- - Pydantic typed Action and Observation models
7
- - state() method returning current environment state
8
- - Three task splits: easy / medium / hard
9
- - Full OpenEnv-compatible interface
10
- """
11
-
12
- from __future__ import annotations
13
-
14
  import numpy as np
15
  import gymnasium as gym
16
  from gymnasium import spaces
17
- from pydantic import BaseModel, Field
18
- from typing import Optional
19
-
20
- # ── Vocabulary & encoding (canonical — must not change between versions) ──────
21
 
 
 
 
22
  KEYWORD_VOCAB = [
23
  "invoice", "payment", "overdue", "refund",
24
- "hacked", "breach", "unauthorized", "password",
25
- "crash", "error", "bug", "slow",
26
- "lawsuit", "legal", "attorney", "sue",
27
- "spam", "offer", "win", "free",
28
- "urgent", "critical","angry", "threat",
29
  ]
30
 
31
  SENTIMENT_MAP = {"positive": 0, "neutral": 1, "negative": 2}
32
- CONTEXT_MAP = {"spam": 0, "billing": 1, "tech": 2, "security": 3, "legal": 4}
33
- OBS_DIM = len(KEYWORD_VOCAB) + len(SENTIMENT_MAP) + len(CONTEXT_MAP) # 32
34
-
35
- # ── Label maps ────────────────────────────────────────────────────────────────
36
- URGENCY_LABELS = {0: "General", 1: "Billing", 2: "Security Breach"}
37
- ROUTING_LABELS = {0: "AI Auto-Reply", 1: "Tech Support", 2: "Legal"}
38
- RESOLUTION_LABELS = {0: "Archive", 1: "Draft Reply", 2: "Escalate"}
39
-
40
- # ── Reward weights ────────────────────────────────────────────────────────────
41
- REWARD_EXACT = 1.0
42
- REWARD_PARTIAL_1_WRONG = 0.2
43
- REWARD_PARTIAL_2_WRONG = 0.1
44
- PENALTY_SECURITY_MISS = -2.0
45
-
46
-
47
- # ─────────────────────────────────────────────────────────────────────────────
48
- # Pydantic Typed Models
49
- # ─────────────────────────────────────────────────────────────────────────────
50
-
51
- class EmailAction(BaseModel):
52
- """
53
- The agent's triage decision for one email.
54
- All three dimensions must be predicted simultaneously.
55
- """
56
- urgency: int = Field(
57
- ..., ge=0, le=2,
58
- description="0=General | 1=Billing | 2=Security Breach"
59
- )
60
- routing: int = Field(
61
- ..., ge=0, le=2,
62
- description="0=AI Auto-Reply | 1=Tech Support | 2=Legal"
63
- )
64
- resolution: int = Field(
65
- ..., ge=0, le=2,
66
- description="0=Archive | 1=Draft Reply | 2=Escalate"
67
- )
68
-
69
- def to_array(self) -> np.ndarray:
70
- return np.array([self.urgency, self.routing, self.resolution],
71
- dtype=np.int64)
72
-
73
- @classmethod
74
- def from_array(cls, arr: np.ndarray) -> "EmailAction":
75
- return cls(urgency=int(arr[0]), routing=int(arr[1]),
76
- resolution=int(arr[2]))
77
-
78
-
79
- class EmailObservation(BaseModel):
80
- """
81
- The agent's view of the current email.
82
- Encoded as a flat float32 vector of length 32.
83
- """
84
- keyword_flags: list[float] = Field(
85
- ..., description=f"Binary flags for {len(KEYWORD_VOCAB)} vocab keywords"
86
- )
87
- sentiment_onehot: list[float] = Field(
88
- ..., description="One-hot: [positive, neutral, negative]"
89
- )
90
- context_onehot: list[float] = Field(
91
- ..., description="One-hot: [spam, billing, tech, security, legal]"
92
- )
93
- # Human-readable metadata (not used by the agent, useful for logging)
94
- description: str = ""
95
- difficulty: str = ""
96
- context_str: str = ""
97
- sentiment_str: str = ""
98
- keywords: list[str] = Field(default_factory=list)
99
 
100
- def to_array(self) -> np.ndarray:
101
- return np.array(
102
- self.keyword_flags + self.sentiment_onehot + self.context_onehot,
103
- dtype=np.float32,
104
- )
105
 
106
-
107
- class EnvironmentState(BaseModel):
108
- """Current snapshot of the environment — returned by state()."""
109
- step_index: int
110
- total_emails: int
111
- emails_remaining: int
112
- current_email: dict
113
- cumulative_reward: float
114
- task: str # "easy" | "medium" | "hard" | "all"
115
- terminated: bool
116
-
117
-
118
- class StepResult(BaseModel):
119
- """Typed return value from step()."""
120
- observation: EmailObservation
121
- reward: float
122
- normalised_reward: float
123
- terminated: bool
124
- truncated: bool
125
- info: dict
126
-
127
-
128
- # ─────────────────────────────────────────────────────────────────────────────
129
  # Dataset
130
- # ─────────────────────────────────────────────────────────────────────────────
131
-
132
- EMAIL_DATASET: list[dict] = [
133
- # ── Easy: Spam detection ─────────────────────────────────────────────────
134
- {"description": "Spam promo", "keywords": ["spam","offer","win","free"],
135
- "sentiment": "positive", "context": "spam", "difficulty": "easy",
136
- "correct_actions": (0, 0, 0)},
137
- {"description": "Spam lottery", "keywords": ["free","win","offer"],
138
- "sentiment": "positive", "context": "spam", "difficulty": "easy",
139
- "correct_actions": (0, 0, 0)},
140
- {"description": "Routine support", "keywords": ["slow","error"],
141
- "sentiment": "neutral", "context": "tech", "difficulty": "easy",
142
- "correct_actions": (0, 1, 1)},
143
- {"description": "General billing", "keywords": ["invoice","payment"],
144
- "sentiment": "neutral", "context": "billing", "difficulty": "easy",
145
- "correct_actions": (1, 0, 1)},
146
- # ── Medium: Support routing ───────────────────────────────────────────────
147
- {"description": "Overdue invoice", "keywords": ["invoice","overdue","payment","angry"],
148
- "sentiment": "negative", "context": "billing", "difficulty": "medium",
149
- "correct_actions": (1, 0, 1)},
150
- {"description": "Refund dispute", "keywords": ["refund","payment","angry"],
151
- "sentiment": "negative", "context": "billing", "difficulty": "medium",
152
- "correct_actions": (1, 2, 2)},
153
- {"description": "App crash report", "keywords": ["crash","bug","error"],
154
- "sentiment": "negative", "context": "tech", "difficulty": "medium",
155
- "correct_actions": (0, 1, 1)},
156
- {"description": "Persistent login bug","keywords": ["bug","password","error"],
157
- "sentiment": "negative", "context": "tech", "difficulty": "medium",
158
- "correct_actions": (0, 1, 1)},
159
- {"description": "Polite legal ultimatum","keywords": ["refund","legal","angry","threat"],
160
- "sentiment": "negative", "context": "legal", "difficulty": "medium",
161
- "correct_actions": (2, 2, 2)},
162
- {"description": "Attorney CC warning", "keywords": ["invoice","overdue","attorney","legal","payment","threat"],
163
- "sentiment": "negative", "context": "legal", "difficulty": "medium",
164
- "correct_actions": (2, 2, 2)},
165
- {"description": "Regulatory complaint","keywords": ["angry","threat","legal"],
166
- "sentiment": "negative", "context": "legal", "difficulty": "medium",
167
- "correct_actions": (2, 2, 2)},
168
- {"description": "SLA breach legal", "keywords": ["breach","legal","threat","angry"],
169
- "sentiment": "negative", "context": "legal", "difficulty": "medium",
170
- "correct_actions": (2, 2, 2)},
171
- # ── Hard: Phishing & security threats ────────────────────────────────────
172
- {"description": "IT audit phish", "keywords": ["password","unauthorized","critical","urgent","threat"],
173
- "sentiment": "negative", "context": "security","difficulty": "hard",
174
- "correct_actions": (2, 1, 2)},
175
- {"description": "Fake invoice portal", "keywords": ["invoice","payment","password","unauthorized","urgent"],
176
- "sentiment": "neutral", "context": "security","difficulty": "hard",
177
- "correct_actions": (2, 1, 2)},
178
- {"description": "HR credential phish", "keywords": ["password","urgent","critical"],
179
- "sentiment": "neutral", "context": "security","difficulty": "hard",
180
- "correct_actions": (2, 1, 2)},
181
- {"description": "Fake suspension", "keywords": ["unauthorized","password","breach","urgent","threat"],
182
- "sentiment": "negative", "context": "security","difficulty": "hard",
183
- "correct_actions": (2, 1, 2)},
184
- {"description": "BEC vendor reply", "keywords": ["password","unauthorized","urgent"],
185
- "sentiment": "neutral", "context": "security","difficulty": "hard",
186
- "correct_actions": (2, 1, 2)},
187
- {"description": "Sign-in alert phish", "keywords": ["unauthorized","password","hacked","breach","urgent"],
188
- "sentiment": "negative", "context": "security","difficulty": "hard",
189
- "correct_actions": (2, 1, 2)},
190
- {"description": "Payroll phish", "keywords": ["payment","password","urgent","threat"],
191
- "sentiment": "negative", "context": "security","difficulty": "hard",
192
- "correct_actions": (2, 1, 2)},
193
- {"description": "License renewal BEC", "keywords": ["password","critical","urgent","error"],
194
- "sentiment": "neutral", "context": "security","difficulty": "hard",
195
- "correct_actions": (2, 1, 2)},
196
- {"description": "GDPR phish", "keywords": ["breach","hacked","password","legal","threat","urgent","unauthorized"],
197
- "sentiment": "negative", "context": "security","difficulty": "hard",
198
- "correct_actions": (2, 1, 2)},
199
- {"description": "Ransomware audit", "keywords": ["hacked","breach","unauthorized","lawsuit","legal","threat","critical","urgent"],
200
- "sentiment": "negative", "context": "security","difficulty": "hard",
201
- "correct_actions": (2, 2, 2)},
202
- {"description": "Data extortion", "keywords": ["hacked","breach","unauthorized","attorney","threat","critical","urgent"],
203
- "sentiment": "negative", "context": "security","difficulty": "hard",
204
- "correct_actions": (2, 2, 2)},
205
- {"description": "Fake law firm", "keywords": ["unauthorized","breach","attorney","lawsuit","legal","threat"],
206
- "sentiment": "negative", "context": "legal", "difficulty": "hard",
207
- "correct_actions": (2, 2, 2)},
208
- {"description": "Account hacked", "keywords": ["hacked","unauthorized","password","urgent","angry"],
209
- "sentiment": "negative", "context": "security","difficulty": "hard",
210
- "correct_actions": (2, 1, 2)},
211
- {"description": "Data breach notice", "keywords": ["breach","unauthorized","critical","threat"],
212
- "sentiment": "negative", "context": "security","difficulty": "hard",
213
- "correct_actions": (2, 1, 2)},
214
- {"description": "Legal lawsuit threat","keywords": ["lawsuit","legal","attorney","threat","angry"],
215
- "sentiment": "negative", "context": "legal", "difficulty": "hard",
216
- "correct_actions": (2, 2, 2)},
217
- {"description": "Ransomware threat", "keywords": ["hacked","threat","critical","urgent","breach"],
218
- "sentiment": "negative", "context": "security","difficulty": "hard",
219
- "correct_actions": (2, 2, 2)},
220
  ]
221
 
222
- # Task splits — used by inference.py for per-task scoring
223
- TASK_SPLITS: dict[str, list[dict]] = {
224
- "easy": [e for e in EMAIL_DATASET if e["difficulty"] == "easy"],
225
- "medium": [e for e in EMAIL_DATASET if e["difficulty"] == "medium"],
226
- "hard": [e for e in EMAIL_DATASET if e["difficulty"] == "hard"],
227
- "all": EMAIL_DATASET,
228
- }
229
-
230
-
231
- # ─────────────────────────────────────────────────────────────────────────────
232
- # Core Environment
233
- # ─────────────────────────────────────────────────────────────────────────────
234
-
235
  class EmailTriageEnv(gym.Env):
236
- """
237
- OpenEnv-compliant Gymnasium environment for email triage.
238
-
239
- The agent receives one email per step as a 32-dim observation vector
240
- and must output three simultaneous discrete decisions.
241
-
242
- Parameters
243
- ----------
244
- task : str
245
- "easy" | "medium" | "hard" | "all" — which email subset to use.
246
- shuffle : bool
247
- Shuffle emails on each reset (default True).
248
- """
249
-
250
- metadata = {"render_modes": ["human"]}
251
-
252
- def __init__(self, task: str = "all", shuffle: bool = True):
253
  super().__init__()
 
 
 
 
 
 
 
 
254
 
255
- if task not in TASK_SPLITS:
256
- raise ValueError(f"task must be one of {list(TASK_SPLITS)}. Got '{task}'.")
257
-
258
- self.task = task
259
- self.shuffle = shuffle
260
- self.email_batch = TASK_SPLITS[task]
261
-
262
- # Gymnasium spaces
263
  self.action_space = spaces.MultiDiscrete([3, 3, 3])
264
- self.observation_space = spaces.Box(
265
- low=0.0, high=1.0, shape=(OBS_DIM,), dtype=np.float32
266
- )
267
-
268
- # Internal state
269
- self._queue: list[dict] = []
270
- self._current_email: dict = {}
271
- self._step_idx: int = 0
272
- self._cumulative_reward: float = 0.0
273
- self._max_episode_reward: float = len(self.email_batch) * REWARD_EXACT
274
-
275
- # ── Encoding helpers ──────────────────────────────────────────────────────
276
-
277
- def _encode_to_obs(self, email: dict) -> EmailObservation:
278
- """Convert an email dict into a typed EmailObservation."""
279
- kw_flags = [1.0 if kw in email["keywords"] else 0.0
280
- for kw in KEYWORD_VOCAB]
281
-
282
- sentiment_vec = [0.0] * len(SENTIMENT_MAP)
283
- sentiment_vec[SENTIMENT_MAP[email["sentiment"]]] = 1.0
284
-
285
- context_vec = [0.0] * len(CONTEXT_MAP)
286
- context_vec[CONTEXT_MAP[email["context"]]] = 1.0
287
-
288
- return EmailObservation(
289
- keyword_flags=kw_flags,
290
- sentiment_onehot=sentiment_vec,
291
- context_onehot=context_vec,
292
- description=email.get("description", ""),
293
- difficulty=email.get("difficulty", ""),
294
- context_str=email["context"],
295
- sentiment_str=email["sentiment"],
296
- keywords=email["keywords"],
297
- )
298
-
299
- def _compute_reward(self, action: np.ndarray, email: dict) -> float:
300
- """
301
- Reward function — same logic as environment.py, priority order:
302
- 1. Security miss → -2.0 (correct urgency=2, predicted otherwise)
303
- 2. Exact match → +1.0
304
- 3. Partial-1 → +0.2 (urgency correct, 1 other wrong)
305
- 4. Partial-2 → +0.1 (urgency correct, both others wrong)
306
- 5. Wrong → 0.0
307
- """
308
- u, r, res = int(action[0]), int(action[1]), int(action[2])
309
- c = email["correct_actions"]
310
-
311
- if c[0] == 2 and u != 2:
312
- return PENALTY_SECURITY_MISS
313
- if (u, r, res) == c:
314
- return REWARD_EXACT
315
- if u == c[0]:
316
- wrong = sum([r != c[1], res != c[2]])
317
- return REWARD_PARTIAL_1_WRONG if wrong == 1 else REWARD_PARTIAL_2_WRONG
318
- return 0.0
319
-
320
- # ── OpenEnv API ───────────────────────────────────────────────────────────
321
-
322
- def reset(
323
- self,
324
- *,
325
- seed: Optional[int] = None,
326
- options: Optional[dict] = None,
327
- ) -> tuple[np.ndarray, dict]:
328
  super().reset(seed=seed)
329
-
330
  self._queue = list(self.email_batch)
331
  if self.shuffle:
332
  self.np_random.shuffle(self._queue)
 
 
 
333
 
334
- self._step_idx = 0
335
- self._cumulative_reward = 0.0
336
- self._current_email = self._queue[0]
337
-
338
- obs = self._encode_to_obs(self._current_email)
339
- info = {
340
- "description": self._current_email["description"],
341
- "difficulty": self._current_email["difficulty"],
342
- "task": self.task,
343
- "total_steps": len(self._queue),
344
- }
345
- return obs.to_array(), info
346
-
347
- def step(
348
- self, action: np.ndarray
349
- ) -> tuple[np.ndarray, float, bool, bool, dict]:
350
- # Capture current email BEFORE advancing pointer
351
  scored_email = self._current_email
352
- raw_reward = self._compute_reward(action, scored_email)
353
- norm_reward = raw_reward / self._max_episode_reward
354
-
355
- self._cumulative_reward += norm_reward
356
- self._step_idx += 1
357
- terminated = self._step_idx >= len(self._queue)
358
-
 
 
 
 
 
 
 
 
 
359
  if not terminated:
360
  self._current_email = self._queue[self._step_idx]
361
- obs = self._encode_to_obs(self._current_email)
362
  else:
363
- obs = self._encode_to_obs(scored_email)
364
-
365
- # Decode action for info dict
366
- typed_action = EmailAction.from_array(action)
367
- correct = scored_email["correct_actions"]
368
 
 
 
 
369
  info = {
370
- "raw_reward": raw_reward,
371
- "correct_actions": correct,
372
- "predicted": (typed_action.urgency,
373
- typed_action.routing,
374
- typed_action.resolution),
375
- "difficulty": scored_email["difficulty"],
376
- "description": scored_email.get("description", ""),
377
- "urgency_label": URGENCY_LABELS[typed_action.urgency],
378
- "routing_label": ROUTING_LABELS[typed_action.routing],
379
- "resolution_label": RESOLUTION_LABELS[typed_action.resolution],
380
- "cumulative_score": self._cumulative_reward,
381
  }
382
- return obs.to_array(), norm_reward, terminated, False, info
383
-
384
- def state(self) -> EnvironmentState:
385
- """
386
- Return a typed snapshot of the current environment state.
387
- Required by the OpenEnv specification.
388
- """
389
- return EnvironmentState(
390
- step_index=self._step_idx,
391
- total_emails=len(self._queue),
392
- emails_remaining=max(0, len(self._queue) - self._step_idx),
393
- current_email=self._current_email,
394
- cumulative_reward=self._cumulative_reward,
395
- task=self.task,
396
- terminated=self._step_idx >= len(self._queue),
397
- )
398
-
399
- def render(self, mode: str = "human") -> None:
400
- e = self._current_email
401
- print(
402
- f"[{self.task.upper()} | Step {self._step_idx}/{len(self._queue)}] "
403
- f"{e['description']} | {e['difficulty']} | "
404
- f"sentiment={e['sentiment']} context={e['context']}"
405
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import numpy as np
2
  import gymnasium as gym
3
  from gymnasium import spaces
 
 
 
 
4
 
5
+ # ---------------------------------------------------------------------------
6
+ # Vocabulary & Encoding
7
+ # ---------------------------------------------------------------------------
8
  KEYWORD_VOCAB = [
9
  "invoice", "payment", "overdue", "refund",
10
+ "hacked", "breach", "unauthorized", "password",
11
+ "crash", "error", "bug", "slow",
12
+ "lawsuit", "legal", "attorney", "sue",
13
+ "spam", "offer", "win", "free",
14
+ "urgent", "critical", "angry", "threat",
15
  ]
16
 
17
  SENTIMENT_MAP = {"positive": 0, "neutral": 1, "negative": 2}
18
+ CONTEXT_MAP = {"spam": 0, "billing": 1, "tech": 2, "security": 3, "legal": 4}
19
+ OBS_DIM = len(KEYWORD_VOCAB) + len(SENTIMENT_MAP) + len(CONTEXT_MAP)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
+ # Labels for UI
22
+ URGENCY_LABELS = ["General", "Billing", "Security Breach"]
23
+ ROUTING_LABELS = ["AI Auto-Reply", "Tech Support", "Legal"]
24
+ RESOLUTION_LABELS = ["Archive", "Draft Reply", "Escalate to Human"]
 
25
 
26
+ # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  # Dataset
28
+ # ---------------------------------------------------------------------------
29
+ EMAIL_DATASET = [
30
+ {"description": "Spam promo", "keywords": ["spam", "offer"], "sentiment": "positive", "context": "spam", "difficulty": "easy", "correct_actions": (0, 0, 0)},
31
+ {"description": "Routine support", "keywords": ["slow", "error"], "sentiment": "neutral", "context": "tech", "difficulty": "easy", "correct_actions": (0, 1, 1)},
32
+ {"description": "Billing inquiry", "keywords": ["invoice", "payment"], "sentiment": "neutral", "context": "billing", "difficulty": "easy", "correct_actions": (1, 0, 1)},
33
+ {"description": "Overdue invoice", "keywords": ["invoice", "overdue"], "sentiment": "negative", "context": "billing", "difficulty": "medium", "correct_actions": (1, 0, 1)},
34
+ {"description": "Refund dispute", "keywords": ["refund", "angry"], "sentiment": "negative", "context": "billing", "difficulty": "medium", "correct_actions": (1, 2, 2)},
35
+ {"description": "Legal threat", "keywords": ["lawsuit", "attorney"], "sentiment": "negative", "context": "legal", "difficulty": "hard", "correct_actions": (2, 2, 2)},
36
+ {"description": "Account hacked", "keywords": ["hacked", "password"], "sentiment": "negative", "context": "security", "difficulty": "hard", "correct_actions": (2, 1, 2)},
37
+ {"description": "Data breach", "keywords": ["breach", "unauthorized"], "sentiment": "negative", "context": "security", "difficulty": "hard", "correct_actions": (2, 1, 2)},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  ]
39
 
40
+ # ---------------------------------------------------------------------------
41
+ # Environment Class
42
+ # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
43
  class EmailTriageEnv(gym.Env):
44
+ def __init__(self, batch: list | None = None, shuffle: bool = True, task: str = "easy"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  super().__init__()
46
+
47
+ # Meta Grader logic: filter by task if no batch provided
48
+ if batch is None:
49
+ self.email_batch = [e for e in EMAIL_DATASET if e["difficulty"] == task]
50
+ if not self.email_batch: # Fallback agar task match na ho
51
+ self.email_batch = EMAIL_DATASET[:3]
52
+ else:
53
+ self.email_batch = batch
54
 
55
+ self.shuffle = shuffle
 
 
 
 
 
 
 
56
  self.action_space = spaces.MultiDiscrete([3, 3, 3])
57
+ self.observation_space = spaces.Box(low=0.0, high=1.0, shape=(OBS_DIM,), dtype=np.float32)
58
+
59
+ self._max_episode_reward = len(self.email_batch) * 1.0
60
+
61
+ def _encode(self, email):
62
+ kw_flags = np.array([1.0 if kw in email["keywords"] else 0.0 for kw in KEYWORD_VOCAB], dtype=np.float32)
63
+ sent_vec = np.zeros(len(SENTIMENT_MAP), dtype=np.float32)
64
+ sent_vec[SENTIMENT_MAP[email["sentiment"]]] = 1.0
65
+ ctx_vec = np.zeros(len(CONTEXT_MAP), dtype=np.float32)
66
+ ctx_vec[CONTEXT_MAP[email["context"]]] = 1.0
67
+ return np.concatenate([kw_flags, sent_vec, ctx_vec])
68
+
69
+ def reset(self, *, seed=None, options=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  super().reset(seed=seed)
 
71
  self._queue = list(self.email_batch)
72
  if self.shuffle:
73
  self.np_random.shuffle(self._queue)
74
+ self._step_idx = 0
75
+ self._current_email = self._queue[self._step_idx]
76
+ return self._encode(self._current_email), {"difficulty": self._current_email["difficulty"]}
77
 
78
+ def step(self, action):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  scored_email = self._current_email
80
+ correct = scored_email["correct_actions"]
81
+
82
+ # Reward Logic
83
+ raw_reward = 0.0
84
+ if tuple(action) == correct:
85
+ raw_reward = 1.0
86
+ elif action[0] == correct[0]:
87
+ raw_reward = 0.2 # Partial
88
+
89
+ # Security Penalty
90
+ if correct[0] == 2 and action[0] != 2:
91
+ raw_reward = -2.0
92
+
93
+ self._step_idx += 1
94
+ terminated = self._step_idx >= len(self._queue)
95
+
96
  if not terminated:
97
  self._current_email = self._queue[self._step_idx]
98
+ obs = self._encode(self._current_email)
99
  else:
100
+ obs = self._encode(scored_email)
 
 
 
 
101
 
102
+ # Normalize reward
103
+ norm_reward = raw_reward / self._max_episode_reward
104
+
105
  info = {
106
+ "raw_reward": raw_reward,
107
+ "correct_actions": correct,
108
+ "difficulty": scored_email["difficulty"]
 
 
 
 
 
 
 
 
109
  }
110
+ return obs, norm_reward, terminated, False, info