og-arin commited on
Commit
a75f2d5
Β·
verified Β·
1 Parent(s): 0ab616d

Update env.py

Browse files
Files changed (1) hide show
  1. env.py +60 -116
env.py CHANGED
@@ -1,46 +1,6 @@
1
  """
2
  env.py – PhishGuard-Env | FastAPI Environment Server
3
  =======================================================
4
-
5
- ARCHITECTURE ROLE
6
- -----------------
7
- This file IS the environment. It runs as a persistent FastAPI server on
8
- Hugging Face Spaces (port 7860). The inference agent (inference.py) is a
9
- separate process that interacts with it exclusively through HTTP β€” it NEVER
10
- imports this module directly.
11
-
12
- Endpoints
13
- ---------
14
- GET / β†’ root metadata: lists task types, version, and task registry
15
- POST /reset β†’ reset for a chosen difficulty level; returns first email observation
16
- POST /step β†’ submit one triage action; returns (obs, reward, done, info)
17
- GET /state β†’ read-only snapshot of health, score, task index
18
- GET /health β†’ liveness probe for HF Spaces / load-balancers
19
-
20
- LEVEL DESIGN
21
- ------------
22
- easy β†’ lv1, lv2, lv3 (SPAM, PHISH, SAFE)
23
- medium β†’ lv4, lv5, lv6, lv7 (MALWARE, SAFE, BEC, PHISH)
24
- hard β†’ lv8, lv9, lv10 (MALWARE, PHISH, BEC)
25
-
26
- TASK REGISTRY (3 graded tasks β€” satisfies OpenEnv validator)
27
- -------------------------------------------------------------
28
- task_phish β†’ PHISH scenarios (credential harvesting, typosquat)
29
- task_bec β†’ BEC scenarios (wire fraud, supply-chain compromise)
30
- task_internal β†’ SAFE / MALWARE scenarios (internal mail & malware triage)
31
-
32
- FIX APPLIED
33
- -----------
34
- The OpenEnv validator calls POST /reset with an EMPTY body (no JSON at all).
35
- The previous endpoint signature was:
36
- async def reset(request: ResetRequest)
37
- FastAPI requires a body when the parameter is not Optional, so it returned:
38
- HTTP 422 Unprocessable Entity
39
- {"detail":[{"type":"missing","loc":["body"],"msg":"Field required"}]}
40
-
41
- Fix: make the request body Optional. When None, default to level="easy".
42
- async def reset(request: Optional[ResetRequest] = None)
43
- This is the ONLY change from the live repo.
44
  """
45
 
46
  from __future__ import annotations
@@ -80,8 +40,18 @@ from grader import (
80
  HEALTH_DRAIN_THRESHOLD,
81
  calculate_overall_score,
82
  grade_action,
 
83
  )
84
 
 
 
 
 
 
 
 
 
 
85
  # ═════════════════════════════════════════════════════════════════════════════
86
  # SCENARIO DEFINITIONS (lv1 β†’ lv10)
87
  # ═════════════════════════════════════════════════════════════════════════════
@@ -313,12 +283,13 @@ LEVEL_MAP: dict[str, list[str]] = {
313
 
314
  _SCENARIO_BY_ID: dict[str, dict] = {s["id"]: s for s in SCENARIOS}
315
 
 
316
  # ═════════════════════════════════════════════════════════════════════════════
317
  # REQUEST SCHEMA
318
  # ═════════════════════════════════════════════════════════════════════════════
319
 
320
  class ResetRequest(BaseModel):
321
- level: str = "easy" # "easy" | "medium" | "hard"
322
 
323
 
324
  # ═════════════════════════════════════════════════════════════════════════════
@@ -344,8 +315,8 @@ class PhishGuardEnv(OpenEnv):
344
  raise ValueError(
345
  f"Unknown level '{level}'. Valid choices: easy | medium | hard"
346
  )
347
- ids = LEVEL_MAP[level]
348
- subset = [dict(_SCENARIO_BY_ID[sid]) for sid in ids]
349
  remaining = [s for s in SCENARIOS if s["id"] not in ids]
350
  subset.extend(remaining)
351
  random.shuffle(subset)
@@ -370,9 +341,14 @@ class PhishGuardEnv(OpenEnv):
370
  return first_task["data"]
371
 
372
  def step(self, action_str: str) -> tuple:
 
 
 
 
373
  if self._is_over():
374
  return None, R_BREACH, True, {
375
  "task_id": None,
 
376
  "is_correct": False,
377
  "health": self.health,
378
  "feedback": "Episode already ended. Call /reset to start a new one.",
@@ -380,18 +356,12 @@ class PhishGuardEnv(OpenEnv):
380
  "task_scores": list(self.task_scores),
381
  }
382
 
383
- current_task = self.scenarios[self.current_task_idx]
384
- _TYPE_TO_TASK_ID = {
385
- "SPAM": "task_spam", "PHISH": "task_phishing", "SAFE":"task_safe", "MALWARE": "task_malware", "BEC":"task_bec",
386
- }
387
- task_id = _TYPE_TO_TASK_ID.get(current_task["type"].upper(),current_task["id"])
388
- semantic_task_id = {
389
- "SPAM": "task_spam",
390
- "PHISH": "task_phishing",
391
- "SAFE": "task_safe",
392
- "MALWARE": "task_malware",
393
- "BEC": "task_bec"
394
- }.get(current_task["type"].upper(), current_task["id"])
395
 
396
  reward, verdict_msg = grade_action(
397
  action_str,
@@ -404,7 +374,11 @@ class PhishGuardEnv(OpenEnv):
404
 
405
  log.info(
406
  "Step | level=%s | task=%s | action=%s | reward=%.4f | verdict=%s",
407
- self.active_level, task_id, action_str.strip().upper(), reward, verdict_msg,
 
 
 
 
408
  )
409
 
410
  if reward < HEALTH_DRAIN_THRESHOLD:
@@ -438,8 +412,8 @@ class PhishGuardEnv(OpenEnv):
438
  )
439
 
440
  return obs, reward, done, {
441
- "task_id": semantic_task_id,
442
- "task_group": current_task["level"],
443
  "is_correct": reward >= R_PERFECT,
444
  "health": self.health,
445
  "feedback": feedback,
@@ -470,7 +444,7 @@ app = FastAPI(
470
  ),
471
  version="3.1.0",
472
  lifespan=lifespan,
473
- docs_url="/docs", # Swagger UI β€” required for OpenEnv validator inspection
474
  redoc_url="/redoc",
475
  )
476
 
@@ -483,15 +457,13 @@ app.add_middleware(
483
  )
484
 
485
 
 
486
  @app.get("/", tags=["Meta"])
487
  async def root_metadata() -> dict:
488
  """
489
- Root metadata endpoint β€” required by the OpenEnv Phase 2 validator.
490
-
491
- Returns a JSON document listing all task types registered in this
492
- environment so the validator can confirm there are β‰₯3 graded tasks.
493
  """
494
- from grader import TASK_REGISTRY
495
  return {
496
  "name": "PhishGuard-Env",
497
  "version": "3.1.0",
@@ -500,60 +472,39 @@ async def root_metadata() -> dict:
500
  "port": 7860,
501
  "tasks": [
502
  {
503
- "id": "task_phish",
504
- "description": "Identify phishing emails (credential harvesting, typosquat domains) and block at perimeter.",
505
- "grader": "grader.grade_easy",
506
- },
507
- {
508
- "id": "task_bec",
509
- "description": "Detect Business Email Compromise (wire fraud, CEO impersonation, supply-chain attacks).",
510
- "grader": "grader.grade_medium",
511
- },
512
- {
513
- "id": "task_internal",
514
- "description": "Triage internal / malware emails: pass safe mail, quarantine malware payloads.",
515
- "grader": "grader.grade_hard",
516
- },
517
- ],
518
- "task_registry": {
519
- task_id: {
520
  "description": meta["description"],
521
- "threat": meta["threat"],
522
  }
523
  for task_id, meta in TASK_REGISTRY.items()
524
- },
525
- "levels": list(LEVEL_MAP.keys()),
526
  "endpoints": {
527
- "reset": "POST /reset",
528
- "step": "POST /step",
529
- "state": "GET /state",
530
- "health":"GET /health",
531
- "docs": "GET /docs",
532
  },
533
  }
534
 
535
 
 
536
  @app.get("/health", tags=["Meta"])
537
  async def health_probe() -> dict:
538
  return {"status": "ok", "env": "PhishGuard-Env", "version": "3.1.0"}
539
 
540
 
541
- # ── THE FIX IS HERE ───────────────────────────────────────────────────────────
542
- # The validator calls POST /reset with an empty body.
543
- # Making `request` Optional with a default of None means FastAPI no longer
544
- # requires a body β€” when nothing is sent, we just use level="easy".
545
  @app.post("/reset", tags=["Environment"])
546
  async def reset(request: Optional[ResetRequest] = None) -> dict:
547
  """
548
  Reset the environment for a new episode.
549
-
550
- Body (JSON) β€” fully optional, send {} or nothing at all:
551
- { "level": "easy" | "medium" | "hard" }
552
-
553
- Defaults to level="easy" when no body is provided.
554
  """
555
  if request is None:
556
- request = ResetRequest() # defaults to level="easy"
557
 
558
  level = request.level.lower()
559
  if level not in LEVEL_MAP:
@@ -562,28 +513,19 @@ async def reset(request: Optional[ResetRequest] = None) -> dict:
562
  detail=f"Invalid level '{level}'. Must be one of: easy | medium | hard",
563
  )
564
 
565
- obs = _env.reset(level=level)
566
- first_task = _env.scenarios[_env.current_task_idx]
567
- _TYPE_TO_SEMANTIC = {
568
- "SPAM": "task_spam",
569
- "PHISH": "task_phishing",
570
- "SAFE": "task_safe",
571
- "MALWARE": "task_malware",
572
- "BEC": "task_bec",
573
- }
574
- _TYPE_TO_TASK_ID = {
575
- "SPAM": "task_spam", "PHISH": "task_phishing",
576
- "SAFE": "task_safe", "MALWARE": "task_malware", "BEC": "task_bec",
577
- }
578
  return {
579
  "observation": obs,
580
- "task_id": _TYPE_TO_TASK_ID.get(first_task["type"].upper(), first_task["id"]),
581
- "task_group": first_task["level"],
582
- "level": _env.active_level,
583
  "total_tasks": len(_env.scenarios),
584
  }
585
 
586
 
 
587
  @app.post("/step", tags=["Environment"])
588
  async def step(action: PhishAction) -> dict:
589
  obs, reward, done, info = _env.step(action.action)
@@ -598,6 +540,7 @@ async def step(action: PhishAction) -> dict:
598
  }
599
 
600
 
 
601
  @app.get("/state", tags=["Environment"])
602
  async def state() -> dict:
603
  return {
@@ -611,6 +554,7 @@ async def state() -> dict:
611
  }
612
 
613
 
 
614
  if __name__ == "__main__":
615
  import uvicorn
616
- uvicorn.run("env:app", host="0.0.0.0", port=7860, reload=False, log_level="info")
 
1
  """
2
  env.py – PhishGuard-Env | FastAPI Environment Server
3
  =======================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  """
5
 
6
  from __future__ import annotations
 
40
  HEALTH_DRAIN_THRESHOLD,
41
  calculate_overall_score,
42
  grade_action,
43
+ TASK_REGISTRY,
44
  )
45
 
46
+ # ── Single source of truth: email type β†’ semantic task ID ────────────────────
47
+ _TYPE_TO_TASK_ID: dict[str, str] = {
48
+ "SPAM": "task_spam",
49
+ "PHISH": "task_phishing",
50
+ "SAFE": "task_safe",
51
+ "MALWARE": "task_malware",
52
+ "BEC": "task_bec",
53
+ }
54
+
55
  # ═════════════════════════════════════════════════════════════════════════════
56
  # SCENARIO DEFINITIONS (lv1 β†’ lv10)
57
  # ═════════════════════════════════════════════════════════════════════════════
 
283
 
284
  _SCENARIO_BY_ID: dict[str, dict] = {s["id"]: s for s in SCENARIOS}
285
 
286
+
287
  # ═════════════════════════════════════════════════════════════════════════════
288
  # REQUEST SCHEMA
289
  # ═════════════════════════════════════════════════════════════════════════════
290
 
291
  class ResetRequest(BaseModel):
292
+ level: str = "easy"
293
 
294
 
295
  # ═════════════════════════════════════════════════════════════════════════════
 
315
  raise ValueError(
316
  f"Unknown level '{level}'. Valid choices: easy | medium | hard"
317
  )
318
+ ids = LEVEL_MAP[level]
319
+ subset = [dict(_SCENARIO_BY_ID[sid]) for sid in ids]
320
  remaining = [s for s in SCENARIOS if s["id"] not in ids]
321
  subset.extend(remaining)
322
  random.shuffle(subset)
 
341
  return first_task["data"]
342
 
343
  def step(self, action_str: str) -> tuple:
344
+
345
+ # ── Guard: episode already over ───────────────────────────────────────
346
+ # task_group MUST be present here β€” the /step FastAPI endpoint reads
347
+ # info["task_group"] and crashes with KeyError if it is missing.
348
  if self._is_over():
349
  return None, R_BREACH, True, {
350
  "task_id": None,
351
+ "task_group": None, # ← CRITICAL: was missing, caused KeyError
352
  "is_correct": False,
353
  "health": self.health,
354
  "feedback": "Episode already ended. Call /reset to start a new one.",
 
356
  "task_scores": list(self.task_scores),
357
  }
358
 
359
+ current_task = self.scenarios[self.current_task_idx]
360
+ task_group = current_task["level"]
361
+ # Use the module-level _TYPE_TO_TASK_ID β€” defined once, used everywhere
362
+ semantic_task_id = _TYPE_TO_TASK_ID.get(
363
+ current_task["type"].upper(), current_task["id"]
364
+ )
 
 
 
 
 
 
365
 
366
  reward, verdict_msg = grade_action(
367
  action_str,
 
374
 
375
  log.info(
376
  "Step | level=%s | task=%s | action=%s | reward=%.4f | verdict=%s",
377
+ self.active_level,
378
+ semantic_task_id,
379
+ action_str.strip().upper(),
380
+ reward,
381
+ verdict_msg,
382
  )
383
 
384
  if reward < HEALTH_DRAIN_THRESHOLD:
 
412
  )
413
 
414
  return obs, reward, done, {
415
+ "task_id": semantic_task_id, # "task_spam" not "lv1"
416
+ "task_group": task_group,
417
  "is_correct": reward >= R_PERFECT,
418
  "health": self.health,
419
  "feedback": feedback,
 
444
  ),
445
  version="3.1.0",
446
  lifespan=lifespan,
447
+ docs_url="/docs",
448
  redoc_url="/redoc",
449
  )
450
 
 
457
  )
458
 
459
 
460
+ # ── Root metadata ─────────────────────────────────────────────────────────────
461
  @app.get("/", tags=["Meta"])
462
  async def root_metadata() -> dict:
463
  """
464
+ Root metadata β€” lists all registered task types so the validator
465
+ can confirm at least 3 graded tasks exist before running the episode.
 
 
466
  """
 
467
  return {
468
  "name": "PhishGuard-Env",
469
  "version": "3.1.0",
 
472
  "port": 7860,
473
  "tasks": [
474
  {
475
+ "id": task_id,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  "description": meta["description"],
477
+ "grader": "grader.grade_action",
478
  }
479
  for task_id, meta in TASK_REGISTRY.items()
480
+ ],
481
+ "levels": list(LEVEL_MAP.keys()),
482
  "endpoints": {
483
+ "reset": "POST /reset",
484
+ "step": "POST /step",
485
+ "state": "GET /state",
486
+ "health": "GET /health",
487
+ "docs": "GET /docs",
488
  },
489
  }
490
 
491
 
492
+ # ── Liveness probe ─────────────────────────────────────────────────��──────────
493
  @app.get("/health", tags=["Meta"])
494
  async def health_probe() -> dict:
495
  return {"status": "ok", "env": "PhishGuard-Env", "version": "3.1.0"}
496
 
497
 
498
+ # ── Reset ─────────────────────────────────────────────────────────────────────
 
 
 
499
  @app.post("/reset", tags=["Environment"])
500
  async def reset(request: Optional[ResetRequest] = None) -> dict:
501
  """
502
  Reset the environment for a new episode.
503
+ Body is fully optional β€” validator calls this with no body at all.
504
+ Defaults to level='easy' when nothing is sent.
 
 
 
505
  """
506
  if request is None:
507
+ request = ResetRequest()
508
 
509
  level = request.level.lower()
510
  if level not in LEVEL_MAP:
 
513
  detail=f"Invalid level '{level}'. Must be one of: easy | medium | hard",
514
  )
515
 
516
+ obs = _env.reset(level=level)
517
+ first_task = _env.scenarios[_env.current_task_idx]
518
+
 
 
 
 
 
 
 
 
 
 
519
  return {
520
  "observation": obs,
521
+ "task_id": _TYPE_TO_TASK_ID.get(first_task["type"].upper(), first_task["id"]),
522
+ "task_group": first_task["level"],
523
+ "level": _env.active_level,
524
  "total_tasks": len(_env.scenarios),
525
  }
526
 
527
 
528
+ # ── Step ──────────────────────────────────────────────────────────────────────
529
  @app.post("/step", tags=["Environment"])
530
  async def step(action: PhishAction) -> dict:
531
  obs, reward, done, info = _env.step(action.action)
 
540
  }
541
 
542
 
543
+ # ── State ─────────────────────────────────────────────────────────────────────
544
  @app.get("/state", tags=["Environment"])
545
  async def state() -> dict:
546
  return {
 
554
  }
555
 
556
 
557
+ # ── Entry point ───────────────────────────────────────────────────────────────
558
  if __name__ == "__main__":
559
  import uvicorn
560
+ uvicorn.run("env:app", host="0.0.0.0", port=7860, reload=False, log_level="info")