zephO-O commited on
Commit
312907b
·
verified ·
1 Parent(s): 20cd78e

Update models.py

Browse files
Files changed (1) hide show
  1. models.py +77 -432
models.py CHANGED
@@ -1,465 +1,110 @@
1
  """
2
- inference.py – PhishGuard-Env Baseline Inference Script
3
- ========================================================
4
 
5
- Structured stdout logs (required by OpenEnv validator):
6
- [START] task=<level>
7
- [STEP] task=<level> step=N reward=R is_correct=true|false
8
- [END] task=<level> score=S steps=N
9
 
10
- The episode score in [END] comes directly from info["score"] returned by
11
- /step when done=True which is GRADERS[level](metrics) from grader.py.
12
- This guarantees the validator sees the same grader-based score that env.py
13
- computes internally.
 
 
 
 
 
 
14
  """
15
 
16
  from __future__ import annotations
17
 
18
- import sys
19
- import io
20
- # Force UTF-8 output so emoji in env.py feedback strings don't crash on Windows cp1252
21
- if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
22
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
23
- sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
24
-
25
- import argparse
26
- import json
27
- import os
28
-
29
- import textwrap
30
- import time
31
- from datetime import datetime, timezone
32
  from typing import Any, Dict, List, Optional
33
 
34
- import requests
35
- from dotenv import load_dotenv
36
- from openai import OpenAI
37
-
38
- # Load .env file first so HF_TOKEN / OPENAI_API_KEY are available via os.getenv
39
- load_dotenv()
40
-
41
- from grader import PASS_THRESHOLD, GRADERS, grade_performance
42
-
43
- # ─────────────────────────────────────────────────────────────────────────────
44
- # Configuration
45
- # ─────────────────────────────────────────────────────────────────────────────
46
-
47
- API_KEY = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY")
48
- API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
49
- MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
50
- ENV_BASE_URL = os.getenv("ENV_BASE_URL", "http://localhost:7860").rstrip("/")
51
 
52
- MAX_STEPS_PER_LEVEL = 15
53
- HTTP_MAX_RETRIES = 3
54
- HTTP_BACKOFF_BASE = 1.5
55
 
56
  # ─────────────────────────────────────────────────────────────────────────────
57
- # LLM client
58
  # ─────────────────────────────────────────────────────────────────────────────
59
 
60
- if not API_KEY:
61
- print("[ERROR] No API key found. Set HF_TOKEN or OPENAI_API_KEY.", flush=True)
62
- sys.exit(1)
63
-
64
- client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
65
-
66
- SYSTEM_PROMPT = textwrap.dedent("""
67
- You are a SOC (Security Operations Centre) Analyst triaging incoming emails.
68
-
69
- Analyse the email data provided and respond ONLY with valid JSON in this exact format:
70
- {"action": "<ACTION>", "reasoning": "<one sentence technical justification>"}
71
-
72
- Valid actions:
73
- - MARK_SAFE : Deliver to inbox (confirmed legitimate email)
74
- - MOVE_TO_SPAM : Bulk/unsolicited mail with no active threat
75
- - QUARANTINE : Hold for analyst review (suspicious but unconfirmed)
76
- - BLOCK_DOMAIN : Block sender domain at perimeter (confirmed phishing/malware source)
77
-
78
- Signal interpretation:
79
- - SPF fail + DMARC fail + urgency + links → likely PHISH or MALWARE → BLOCK_DOMAIN or QUARANTINE
80
- - Known sender, SPF pass, DMARC pass, no suspicious links → likely SAFE → MARK_SAFE
81
- - Bulk unsolicited with no malicious payload → SPAM → MOVE_TO_SPAM
82
- - Wire transfer / CEO fraud / financial urgency from unknown domain → BEC → QUARANTINE
83
- - Malware attachment confirmed by AV → QUARANTINE (isolate, do not deliver)
84
- - Confirmed phishing domain → BLOCK_DOMAIN (sever attack vector)
85
-
86
- confidence_hint field:
87
- - This is a contextual signal from your SIEM, mail gateway, or threat-intel feed.
88
- - It is intentionally noisy — treat it as one data-point, not ground truth.
89
- - If it directly contradicts other signals (SPF, DMARC, links), weigh all evidence.
90
- """).strip()
91
-
92
-
93
- # ─────────────────────────────────────────────────────────────────────────────
94
- # HTTP helpers
95
- # ─────────────────────────────────────────────────────────────────────────────
96
-
97
- _session = requests.Session()
98
-
99
-
100
- def _post(endpoint: str, payload: dict) -> dict:
101
- url = f"{ENV_BASE_URL}{endpoint}"
102
- last_exc: Optional[Exception] = None
103
- for attempt in range(HTTP_MAX_RETRIES):
104
- try:
105
- resp = _session.post(url, json=payload, timeout=30)
106
- resp.raise_for_status()
107
- return resp.json()
108
- except (requests.ConnectionError, requests.Timeout) as exc:
109
- last_exc = exc
110
- wait = HTTP_BACKOFF_BASE ** attempt
111
- print(f" [WARN] POST {endpoint} failed (attempt {attempt+1}): {exc} — retrying in {wait:.1f}s", flush=True)
112
- time.sleep(wait)
113
- except requests.HTTPError as exc:
114
- if exc.response is not None and exc.response.status_code < 500:
115
- raise
116
- last_exc = exc
117
- wait = HTTP_BACKOFF_BASE ** attempt
118
- print(f" [WARN] POST {endpoint} server error (attempt {attempt+1}): {exc} — retrying in {wait:.1f}s", flush=True)
119
- time.sleep(wait)
120
- raise RuntimeError(f"POST {endpoint} failed after {HTTP_MAX_RETRIES} attempts: {last_exc}")
121
-
122
-
123
- def _get(endpoint: str) -> dict:
124
- url = f"{ENV_BASE_URL}{endpoint}"
125
- last_exc: Optional[Exception] = None
126
- for attempt in range(HTTP_MAX_RETRIES):
127
- try:
128
- resp = _session.get(url, timeout=10)
129
- resp.raise_for_status()
130
- return resp.json()
131
- except (requests.ConnectionError, requests.Timeout, requests.HTTPError) as exc:
132
- last_exc = exc
133
- wait = HTTP_BACKOFF_BASE ** attempt
134
- print(f" [WARN] GET {endpoint} failed (attempt {attempt+1}): {exc} — retrying in {wait:.1f}s", flush=True)
135
- time.sleep(wait)
136
- raise RuntimeError(f"GET {endpoint} failed after {HTTP_MAX_RETRIES} attempts: {last_exc}")
137
-
138
-
139
- # ─────────────────────────────────────────────────────────────────────────────
140
- # Rule-based fallback triage (used when LLM is unavailable / errors out)
141
- # Covers all 10 PhishGuard scenarios deterministically.
142
- # ─────────────────────────────────────────────────────────────────────────────
143
-
144
- def _rule_based_triage(obs: Dict[str, Any]) -> tuple[str, str]:
145
  """
146
- Deterministic SOC triage using email header signals and confidence hints.
147
- Returns (action, reasoning) — same signature as the LLM path.
148
 
149
- Decision priority
150
- -----------------
151
- 1. Threat-intel IOC hit on domain/link → BLOCK_DOMAIN
152
- 2. QR-code / credential-harvesting hint → QUARANTINE
153
- 3. AV-flagged or macro attachment → QUARANTINE
154
- 4. Suspicious attachment + auth failure → QUARANTINE
155
- 5. Spam content keywords → MOVE_TO_SPAM
156
- 6. BEC / financial urgency keywords → QUARANTINE
157
- 7. URL redirect chain + auth failure → BLOCK_DOMAIN
158
- 8. Auth failure + links → BLOCK_DOMAIN
159
- 9. Auth-OK, no threats → MARK_SAFE
160
- 10. Default (uncertain) → QUARANTINE
161
  """
162
- spf = obs.get("spf_record", "").lower()
163
- dmarc = obs.get("dmarc_record", "").lower()
164
- urgency = obs.get("urgency_level", "").lower()
165
- links = obs.get("links", [])
166
- has_attach = obs.get("has_attachments", False)
167
- subject = obs.get("subject", "").lower()
168
- body = obs.get("body", "").lower()
169
- hint = obs.get("confidence_hint", "").lower()
170
-
171
- auth_ok = (spf == "pass" and dmarc == "pass")
172
- auth_fail = spf in ("fail", "softfail") or dmarc in ("fail", "none")
173
-
174
- # 1. Threat-intel IOC hit → block the domain
175
- if "ioc feed" in hint or "ioc" in hint:
176
- if links:
177
- return "BLOCK_DOMAIN", "Domain appears on threat-intel IOC feed — block at perimeter"
178
- return "QUARANTINE", "IOC hit with no links — quarantine for analyst review"
179
-
180
- # 2. QR-code / credential harvesting phishing
181
- if "credential-harvest" in hint or "credential harvest" in hint:
182
- return "QUARANTINE", "QR-code credential-harvesting page detected — quarantine attachment"
183
-
184
- # 3. AV-flagged attachment (PE binary, macros, unsigned)
185
- if has_attach and any(kw in hint for kw in ("av:", "macro", "pe binary", "unsigned")):
186
- return "QUARANTINE", "AV/macro-flagged attachment — isolate from delivery"
187
-
188
- # 4. Attachment with authentication failure
189
- if has_attach and auth_fail:
190
- return "QUARANTINE", "Suspicious attachment combined with SPF/DMARC failure"
191
-
192
- # 5. Spam: prize / lottery / mass-marketing content
193
- spam_kw = ("prize", "congratulations", "claim", "won", "lottery", "$1m", "million")
194
- if any(kw in subject + " " + body for kw in spam_kw) and urgency != "critical":
195
- return "MOVE_TO_SPAM", "Bulk prize/lottery spam — no active threat payload"
196
-
197
- # 6. BEC: financial urgency keywords in body
198
- bec_kw = ("wire", "transfer", "account below", "fund", "bank details")
199
- if any(kw in body for kw in bec_kw) and urgency in ("critical", "high"):
200
- return "QUARANTINE", "BEC wire-transfer / financial-fraud pattern detected"
201
-
202
- # 7. URL redirect chain with auth failure → confirmed phishing source
203
- if ("redirect" in hint or "url shortener" in hint) and auth_fail:
204
- return "BLOCK_DOMAIN", "Multi-hop URL redirect chain with auth failure — block domain"
205
-
206
- # 8. Auth failure + suspicious links → block
207
- if auth_fail and links:
208
- return "BLOCK_DOMAIN", "Domain authentication failure with outbound links — block"
209
-
210
- # 9. Clean authentication, no threat signals → safe
211
- if auth_ok and not has_attach:
212
- safe_negative = ("ioc" not in hint and "malware" not in hint
213
- and "phish" not in hint and "credential" not in hint)
214
- if safe_negative:
215
- return "MARK_SAFE", "SPF/DMARC pass, no threat indicators — deliver to inbox"
216
-
217
- # 10. Default: hold for analyst review
218
- return "QUARANTINE", "Uncertain signals — quarantine as precaution"
219
-
220
 
221
- # ─────────────────────────────────────────────────────────────────────────────
222
- # LLM action selection (rule-based fallback when LLM errors)
223
- # ─────────────────────────────────────────────────────────────────────────────
224
 
225
- def _choose_action(observation: Dict[str, Any]) -> tuple[str, str]:
226
- try:
227
- completion = client.chat.completions.create(
228
- model=MODEL_NAME,
229
- messages=[
230
- {"role": "system", "content": SYSTEM_PROMPT},
231
- {"role": "user", "content": json.dumps(observation, indent=2)},
232
- ],
233
- response_format={"type": "json_object"},
234
- temperature=0,
235
- max_tokens=256,
236
- )
237
- parsed = json.loads(completion.choices[0].message.content)
238
- action = parsed.get("action", "QUARANTINE").strip().upper()
239
- reasoning = parsed.get("reasoning", "")
240
- return action, reasoning
241
- except Exception as exc:
242
- print(f" [WARN] LLM unavailable ({type(exc).__name__}) — using rule-based fallback", flush=True)
243
- return _rule_based_triage(observation)
244
 
245
 
246
  # ─────────────────────────────────────────────────────────────────────────────
247
- # Run one level
248
  # ─────────────────────────────────────────────────────────────────────────────
249
 
250
- def run_level(level: str) -> Dict[str, Any]:
251
  """
252
- Run a complete episode for the given difficulty level.
253
 
254
- The episode score is taken from info["score"] on the terminal step
255
- (done=True) this is GRADERS[level](metrics) computed by env.py,
256
- the same value the OpenEnv validator uses.
257
 
258
- Falls back to /state's overall_score only if no terminal step score
259
- was captured (e.g. MAX_STEPS_PER_LEVEL reached without done=True).
 
260
  """
261
- print(f"\n{'='*60}", flush=True)
262
- print(f" LEVEL: {level.upper()}", flush=True)
263
- print(f"{'='*60}", flush=True)
264
-
265
- # ── Emit [START] ─────────────────────────────────────────────────────────
266
- print(f"[START] task={level}", flush=True)
267
-
268
- reset_resp = _post("/reset", {"level": level})
269
- obs = reset_resp["observation"]
270
- total_tasks = reset_resp["total_tasks"]
271
- print(f" Tasks in this level: {total_tasks}", flush=True)
272
-
273
- steps: List[dict] = []
274
- step_num = 0
275
- done = False
276
- step_resp: Dict[str, Any] = {}
277
- # episode_score is populated from info["score"] when done=True.
278
- # It comes from GRADERS[level](metrics) inside env.py.
279
- episode_score: Optional[float] = None
280
- # episode_metrics is populated from info["metrics"] when done=True.
281
- episode_metrics: Optional[dict] = None
282
-
283
- # Track current scenario ID: starts from reset, then updated after each step.
284
- current_scenario_id = reset_resp.get("task_id", "?")
285
-
286
- while not done and step_num < MAX_STEPS_PER_LEVEL:
287
- step_num += 1
288
- print(f"\n Step {step_num} | scenario={current_scenario_id}", flush=True)
289
-
290
- action, reasoning = _choose_action(obs)
291
- print(f" -> Action : {action}", flush=True)
292
- print(f" -> Reasoning: {reasoning[:80]}", flush=True)
293
-
294
- step_resp = _post("/step", {"action": action, "reasoning": reasoning})
295
- reward = step_resp["reward"]
296
- done = step_resp["done"]
297
- is_correct = step_resp["is_correct"]
298
- info = step_resp.get("info", {})
299
-
300
- # task_id in response is the scenario JUST processed — update for display
301
- graded_scenario_id = step_resp.get("task_id") or current_scenario_id
302
-
303
- print(f" <- Graded : scenario={graded_scenario_id} correct={is_correct} reward={reward:.4f} done={done}", flush=True)
304
- feedback_raw = info.get('feedback', '')
305
- feedback_safe = feedback_raw.encode('ascii', errors='replace').decode('ascii')[:120]
306
- print(f" <- Feedback : {feedback_safe}", flush=True)
307
-
308
- # ── Emit [STEP] ───────────────────────────────────────────────────────
309
- print(f"[STEP] task={level} step={step_num} reward={reward:.4f} is_correct={is_correct}", flush=True)
310
-
311
- steps.append({
312
- "step": step_num,
313
- "task_id": graded_scenario_id,
314
- "action": action,
315
- "reward": reward,
316
- "is_correct": is_correct,
317
- "reasoning": reasoning,
318
- })
319
-
320
- # Advance scenario ID tracker: next obs comes from the following scenario
321
- # (we don't know its ID until after the next step, so use graded+1 label)
322
- current_scenario_id = step_resp.get("task_id", "?") # refreshed next iteration
323
-
324
- # Capture episode score and metrics from the terminal step.
325
- # info["score"] is non-None only when done=True (set by env.py via GRADERS).
326
- if done:
327
- episode_score = info.get("score")
328
- episode_metrics = info.get("metrics")
329
-
330
- obs = step_resp.get("observation")
331
- if obs is None and not done:
332
- print(" [WARN] obs is None but done=False — breaking.", flush=True)
333
- break
334
-
335
- # ── Fallback: fetch from /state if episode ended without done=True ────────
336
- # (happens when MAX_STEPS_PER_LEVEL is reached before all tasks complete)
337
- if episode_score is None:
338
- state_resp = _get("/state")
339
- episode_score = state_resp.get("overall_score", 0.01)
340
- episode_metrics = state_resp.get("metrics")
341
-
342
- print(f"\n {'-'*50}", flush=True)
343
- print(f" Level {level.upper()} complete | steps={step_num} | score={episode_score:.4f}", flush=True)
344
-
345
- # ── Emit [END] ────────────────────────────────────────────────────────────
346
- print(f"[END] task={level} score={episode_score:.4f} steps={step_num}", flush=True)
347
-
348
- return {
349
- "level": level,
350
- "total_tasks": total_tasks,
351
- "steps": steps,
352
- "overall_score": episode_score,
353
- "episode_metrics": episode_metrics or {},
354
- }
355
-
356
-
357
- # ──────────────────────────────��──────────────────────────────────────────────
358
- # Main
359
- # ─────────────────────────────────────────────────────────────────────────────
360
-
361
- def main() -> None:
362
- parser = argparse.ArgumentParser(description="PhishGuard-Env Baseline Inference")
363
- parser.add_argument(
364
- "--level",
365
- choices=["easy", "medium", "hard"],
366
- default=None,
367
- help="Run a single difficulty level instead of all three.",
368
  )
369
- parser.add_argument(
370
- "--output",
 
 
 
 
 
371
  default=None,
372
- help="Path to write JSON results.",
 
 
 
 
 
 
373
  )
374
- args = parser.parse_args()
375
-
376
- levels_to_run = [args.level] if args.level else ["easy", "medium", "hard"]
377
- output_path = args.output or f"results_{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}.json"
378
-
379
- try:
380
- health = _get("/health")
381
- print(f" server status: {health.get('status', 'unknown')}", flush=True)
382
- except Exception as exc:
383
- print(f"[ERROR] Cannot reach environment server at {ENV_BASE_URL}: {exc}", flush=True)
384
- print(" Make sure `python env.py` is running in another terminal.", flush=True)
385
- sys.exit(1)
386
-
387
- results: List[Dict[str, Any]] = []
388
- for level in levels_to_run:
389
- result = run_level(level)
390
- results.append(result)
391
- time.sleep(1)
392
-
393
- # ── Aggregate scoring ─────────────────────────────────────────────────────
394
- # Weighted by task count so all 10 tasks contribute equally
395
- # (easy=3, medium=4, hard=3).
396
- total_steps = sum(len(r["steps"]) for r in results)
397
- total_correct = sum(s["is_correct"] for r in results for s in r["steps"])
398
- weighted_sum = sum(r["overall_score"] * r["total_tasks"] for r in results)
399
- total_tasks = sum(r["total_tasks"] for r in results)
400
- avg_score = weighted_sum / total_tasks if total_tasks else 0.0
401
-
402
- # Cross-level grade_performance over combined metrics (mirrors FocusAI)
403
- if len(results) > 1:
404
- combined_metrics: Dict[str, Any] = {
405
- "total_tasks": sum(r["episode_metrics"].get("total_tasks", 0) for r in results),
406
- "completed_tasks": sum(r["episode_metrics"].get("completed_tasks", 0) for r in results),
407
- "perfect_tasks": sum(r["episode_metrics"].get("perfect_tasks", 0) for r in results),
408
- "on_time": sum(r["episode_metrics"].get("on_time", 0) for r in results),
409
- "breach_count": sum(r["episode_metrics"].get("breach_count", 0) for r in results),
410
- "disruption_count": sum(r["episode_metrics"].get("disruption_count", 0) for r in results),
411
- "total_steps": sum(r["episode_metrics"].get("total_steps", 0) for r in results),
412
- }
413
- performance_score = float(grade_performance(combined_metrics))
414
- else:
415
- performance_score = avg_score
416
-
417
- print(f"\n{'='*60}", flush=True)
418
- print(f" BASELINE SUMMARY", flush=True)
419
- print(f"{'='*60}", flush=True)
420
- print(f" Total steps : {total_steps}", flush=True)
421
- print(f" Correct steps : {total_correct}", flush=True)
422
- print(f" Weighted score : {avg_score:.4f} (pass threshold: {PASS_THRESHOLD})", flush=True)
423
- print(f" Performance score: {performance_score:.4f} (grade_performance)", flush=True)
424
- for r in results:
425
- print(f" {r['level']:8s} score: {r['overall_score']:.4f} ({r['total_tasks']} tasks)", flush=True)
426
-
427
- success = avg_score >= PASS_THRESHOLD
428
-
429
- # Build per-task summary for the results file
430
- all_tasks: List[Dict[str, Any]] = []
431
- for r in results:
432
- level_correct = sum(1 for s in r["steps"] if s["is_correct"])
433
- all_tasks.append({
434
- "task_id": r["level"],
435
- "is_correct": level_correct > 0,
436
- "reward": r["overall_score"],
437
- "level": r["level"],
438
- "steps": r["steps"],
439
- })
440
-
441
- run_summary = {
442
- "timestamp": datetime.now(timezone.utc).isoformat(),
443
- "model": MODEL_NAME,
444
- "env": ENV_BASE_URL,
445
- "levels": levels_to_run,
446
- "total_steps": total_steps,
447
- "total_correct": total_correct,
448
- "avg_score": round(avg_score, 4),
449
- "performance_score": round(performance_score, 4),
450
- "pass_threshold": PASS_THRESHOLD,
451
- "success": success,
452
- "tasks": all_tasks,
453
- "level_results": results,
454
- }
455
-
456
- try:
457
- with open(output_path, "w", encoding="utf-8") as fh:
458
- json.dump(run_summary, fh, indent=2)
459
- print(f"\n Results saved -> {output_path}", flush=True)
460
- except OSError as exc:
461
- print(f"\n [WARN] Could not save results: {exc}", flush=True)
462
 
463
 
464
- if __name__ == "__main__":
465
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ models.py – PhishGuard-Env Pydantic Models
3
+ ==========================================
4
 
5
+ Typed request / response schemas used by env.py (FastAPI).
 
 
 
6
 
7
+ PhishAction : Body schema for POST /step
8
+ StepResponse : Response schema for POST /step (OpenEnv grader compliance)
9
+ ResetResponse : Response schema for POST /reset
10
+
11
+ BUG FIX (v1.0.2 → v1.0.3)
12
+ ────────────────────────────────────────────────────────────────────────────
13
+ StepResponse.task_id was typed as `str` but the episode-already-over guard
14
+ branch in env.py returns task_id=None. Pydantic would raise a validation
15
+ error on every post-episode /step call.
16
+ Fix: task_id is now Optional[str] with a default of None.
17
  """
18
 
19
  from __future__ import annotations
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  from typing import Any, Dict, List, Optional
22
 
23
+ from pydantic import BaseModel, Field
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
 
 
 
25
 
26
  # ─────────────────────────────────────────────────────────────────────────────
27
+ # REQUEST MODELS
28
  # ─────────────────────────────────────────────────────────────────────────────
29
 
30
+ class PhishAction(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  """
32
+ Action submitted by the agent to POST /step.
 
33
 
34
+ Fields
35
+ ------
36
+ action : One of MARK_SAFE | MOVE_TO_SPAM | QUARANTINE | BLOCK_DOMAIN
37
+ reasoning : Optional one-sentence technical justification (for logging).
 
 
 
 
 
 
 
 
38
  """
39
+ action: str = Field(
40
+ max_length=64,
41
+ description="Triage decision. Must be exactly one of: "
42
+ "MARK_SAFE | MOVE_TO_SPAM | QUARANTINE | BLOCK_DOMAIN"
43
+ )
44
+ reasoning: Optional[str] = Field(
45
+ default=None,
46
+ description="One-sentence technical justification for the triage decision",
47
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
 
 
 
49
 
50
+ class ResetRequest(BaseModel):
51
+ """Body schema for POST /reset."""
52
+ level: str = Field(
53
+ default="easy",
54
+ description="Difficulty level: easy | medium | hard",
55
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
 
58
  # ─────────────────────────────────────────────────────────────────────────────
59
+ # RESPONSE MODELS (OpenEnv spec — all fields required by validator)
60
  # ─────────────────────────────────────────────────────────────────────────────
61
 
62
+ class StepResponse(BaseModel):
63
  """
64
+ Full response for POST /step.
65
 
66
+ The OpenEnv validator inspects `task_id` and `is_correct` on every step
67
+ to count how many distinct tasks have been graded.
 
68
 
69
+ task_id is Optional[str] (not str) because the episode-already-over guard
70
+ branch returns None a non-optional field would cause a Pydantic
71
+ ValidationError on every post-episode call.
72
  """
73
+ observation: Optional[Dict[str, Any]] = Field(
74
+ description="Next email dict, or null when the episode is done"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  )
76
+ reward: float = Field(
77
+ description="Step reward strictly in (0.0, 1.0)"
78
+ )
79
+ done: bool = Field(
80
+ description="True when all scenarios are complete or health reaches 0"
81
+ )
82
+ task_id: Optional[str] = Field( # BUG FIX: was `str`, must be Optional
83
  default=None,
84
+ description="Scenario ID e.g. 'lv3' — required by OpenEnv validator"
85
+ )
86
+ is_correct: bool = Field(
87
+ description="True when reward >= R_PERFECT (0.95)"
88
+ )
89
+ info: Dict[str, Any] = Field(
90
+ description="Full grader info payload"
91
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
 
94
+ class ResetResponse(BaseModel):
95
+ """Response for POST /reset."""
96
+ observation: Dict[str, Any] = Field(
97
+ description="First email observation for this episode"
98
+ )
99
+ task_id: str = Field(
100
+ description="ID of the first scenario in this episode"
101
+ )
102
+ task_group: str = Field(
103
+ description="Difficulty level of the first scenario: easy | medium | hard"
104
+ )
105
+ level: str = Field(
106
+ description="Active difficulty level for this episode"
107
+ )
108
+ total_tasks: int = Field(
109
+ description="Total number of scenarios in this level"
110
+ )