zephO-O commited on
Commit
e7042a3
·
verified ·
1 Parent(s): eb11380

Update env.py

Browse files
Files changed (1) hide show
  1. env.py +592 -739
env.py CHANGED
@@ -1,739 +1,592 @@
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
- POST /reset reset for a chosen difficulty level; returns first email observation
15
- POST /step submit one triage action; returns (obs, reward, done, info)
16
- GET /state read-only snapshot of health, score, task index
17
- GET /health liveness probe for HF Spaces / load-balancers
18
-
19
- LEVEL DESIGN
20
- ------------
21
- easy → lv1, lv2, lv3 (3 tasks)
22
- medium → lv4, lv5, lv6, lv7 (4 tasks)
23
- hard → lv8, lv9, lv10 (3 tasks)
24
-
25
- State variables
26
- ---------------
27
- current_task_idx : int – index into the active scenario list
28
- health : int – lives remaining (starts at 3)
29
- score : float – cumulative reward for this episode
30
- task_scores : list – per-step reward history
31
-
32
- Reward contract
33
- ---------------
34
- All rewards are sourced from grader.py and strictly in the open interval
35
- (0.0, 1.0). No endpoint ever returns 0 or 1.
36
-
37
- Health drain
38
- ------------
39
- HEALTH_DRAIN_THRESHOLD = 0.15 (imported from grader).
40
- Any step reward below this threshold costs one life.
41
- Security Breach (0.02) → –1 life
42
- Business Disruption (0.05) → –1 life
43
- Wrong Procedure (0.10) → –1 life
44
- Cautious / partial (≥ 0.35) → no life loss
45
-
46
- Thread safety
47
- -------------
48
- _env is a singleton shared across all FastAPI requests. The asyncio.Lock
49
- `_env_lock` serialises access to _env so concurrent /step or /reset calls
50
- cannot race on current_task_idx, health, score, or task_scores.
51
-
52
- OpenEnv validator compliance
53
- -----------------------------
54
- Every normal /step response includes:
55
- task_id : str – the scenario id (e.g. "lv3") so the validator can
56
- correlate decisions to tasks.
57
- is_correct : bool – True when reward >= R_PERFECT, so the validator
58
- can count "tasks with graders" (≥ 3 required).
59
-
60
- BUG FIXES (v1.0.2 → v1.0.3)
61
- -----------------------------
62
- • Thread-safety: asyncio.Lock added around all _env mutations.
63
- • total_tasks in /reset now correctly reports len(LEVEL_MAP[level])
64
- (the count for the chosen level only) instead of len(_env.scenarios)
65
- which could include stale data from a previous reset.
66
- """
67
-
68
- from __future__ import annotations
69
-
70
- import asyncio
71
- import logging
72
- import random
73
- from contextlib import asynccontextmanager
74
- from typing import List, Optional
75
-
76
- from fastapi import FastAPI, HTTPException
77
- from fastapi.middleware.cors import CORSMiddleware
78
-
79
- from models import PhishAction, ResetRequest, StepResponse, ResetResponse
80
-
81
- # ── Logging ───────────────────────────────────────────────────────────────────
82
- logging.basicConfig(
83
- level=logging.INFO,
84
- format="%(asctime)s | %(levelname)s | %(message)s",
85
- datefmt="%H:%M:%S",
86
- )
87
- log = logging.getLogger("phishguard.env")
88
-
89
- # ── OpenEnv base-class (graceful degradation) ─────────────────────────────────
90
- try:
91
- from openenv import OpenEnv
92
- except ImportError:
93
- try:
94
- from openenv.core import OpenEnv # type: ignore
95
- except ImportError:
96
- OpenEnv = object # Runs as a plain Python class if openenv is absent
97
-
98
- # ── Grader imports ────────────────────────────────────────────────────────────
99
- from grader import (
100
- R_BREACH,
101
- R_PERFECT,
102
- HEALTH_DRAIN_THRESHOLD,
103
- calculate_overall_score,
104
- grade_action,
105
- grade_performance,
106
- GRADERS,
107
- SCENARIO_LOADERS,
108
- )
109
-
110
- VERSION = "1.0.3"
111
-
112
- # ═════════════════════════════════════════════════════════════════════════════
113
- # SCENARIO DEFINITIONS (lv1 → lv10)
114
- # ═════════════════════════════════════════════════════════════════════════════
115
-
116
- SCENARIOS: List[dict] = [
117
- # ── EASY (lv1lv3) ───────────────────────────────────────────────────────
118
- {
119
- "id": "lv1",
120
- "level": "easy",
121
- "type": "SPAM",
122
- "data": {
123
- "sender": "win@lotto.net",
124
- "subject": "Claim $1M Prize Congratulations!",
125
- "body": (
126
- "You have been selected. "
127
- "Click now to claim your prize before it expires."
128
- ),
129
- "links": ["http://bit.ly/123abc"],
130
- "has_attachments": False,
131
- "spf_record": "fail",
132
- "dmarc_record": "none",
133
- "urgency_level": "high",
134
- "confidence_hint": "SIEM: no prior communication history with this domain",
135
- },
136
- "correct": "MOVE_TO_SPAM",
137
- },
138
- {
139
- "id": "lv2",
140
- "level": "easy",
141
- "type": "PHISH",
142
- "data": {
143
- "sender": "support@googIe.com",
144
- "subject": "Urgent Security Alert Verify Your Account",
145
- "body": (
146
- "We detected suspicious activity on your account. "
147
- "Verify your identity immediately."
148
- ),
149
- "links": ["http://googIe-verify.com/login"],
150
- "has_attachments": False,
151
- "spf_record": "softfail",
152
- "dmarc_record": "fail",
153
- "urgency_level": "critical",
154
- "confidence_hint": "Gateway: domain registered 3 days ago, 0 prior sends",
155
- },
156
- "correct": "BLOCK_DOMAIN",
157
- },
158
- {
159
- "id": "lv3",
160
- "level": "easy",
161
- "type": "SAFE",
162
- "data": {
163
- "sender": "boss@company.com",
164
- "subject": "Team Meeting Tomorrow at 9 AM",
165
- "body": (
166
- "Hi team, please be ready for our weekly sync at 9 AM "
167
- "in the main boardroom."
168
- ),
169
- "links": [],
170
- "has_attachments": False,
171
- "spf_record": "pass",
172
- "dmarc_record": "pass",
173
- "urgency_level": "low",
174
- "confidence_hint": "SIEM: sender in address book for 2+ years, 200+ prior emails",
175
- },
176
- "correct": "MARK_SAFE",
177
- },
178
-
179
- # ── MEDIUM (lv4lv7) ─────────────────────────────────────────────────────
180
- {
181
- "id": "lv4",
182
- "level": "medium",
183
- "type": "MALWARE",
184
- "data": {
185
- "sender": "it@internal-help.com",
186
- "subject": "Critical Security Patch Action Required Immediately",
187
- "body": (
188
- "A critical vulnerability has been discovered. "
189
- "Run fix.exe to patch your system now."
190
- ),
191
- "links": ["http://internal-help.com/fix.exe"],
192
- "has_attachments": True,
193
- "spf_record": "fail",
194
- "dmarc_record": "none",
195
- "urgency_level": "critical",
196
- "confidence_hint": "AV: attachment flagged as suspicious PE binary (confidence 87%)",
197
- },
198
- "correct": "QUARANTINE",
199
- },
200
- {
201
- "id": "lv5",
202
- "level": "medium",
203
- "type": "SAFE",
204
- "data": {
205
- "sender": "hr@yourcompany.com",
206
- "subject": "Updated Holiday Schedule Please Review",
207
- "body": (
208
- "Hi all, the updated holiday list is now live on the HR portal. "
209
- "No action needed."
210
- ),
211
- "links": ["https://portal.yourcompany.com/holidays"],
212
- "has_attachments": False,
213
- "spf_record": "pass",
214
- "dmarc_record": "pass",
215
- "urgency_level": "low",
216
- "confidence_hint": (
217
- "SIEM: bulk-sent to all-staff distribution list, "
218
- "normal recurring pattern"
219
- ),
220
- },
221
- "correct": "MARK_SAFE",
222
- },
223
- {
224
- "id": "lv6",
225
- "level": "medium",
226
- "type": "BEC",
227
- "data": {
228
- "sender": "ceo@executive-mail.com",
229
- "subject": "URGENT – Wire Transfer Required Today",
230
- "body": (
231
- "I'm in a meeting and cannot be reached by phone. "
232
- "Wire $5,000 to the account below immediately."
233
- ),
234
- "links": [],
235
- "has_attachments": False,
236
- "spf_record": "pass",
237
- "dmarc_record": "none",
238
- "urgency_level": "critical",
239
- "confidence_hint": "SIEM: first-ever email from this domain to any company inbox",
240
- },
241
- "correct": "QUARANTINE",
242
- },
243
- {
244
- "id": "lv7",
245
- "level": "medium",
246
- "type": "PHISH",
247
- "data": {
248
- "sender": "notify@docs-share.net",
249
- "subject": "You Have a New Shared Document",
250
- "body": (
251
- "Your colleague shared a file with you. "
252
- "Click the link below to view it in your browser."
253
- ),
254
- "links": ["https://docs-verify.net/s/1x9az"],
255
- "has_attachments": False,
256
- "spf_record": "softfail",
257
- "dmarc_record": "fail",
258
- "urgency_level": "medium",
259
- "confidence_hint": (
260
- "Gateway: destination link redirects through 2 URL shorteners "
261
- "before landing"
262
- ),
263
- },
264
- "correct": "BLOCK_DOMAIN",
265
- },
266
-
267
- # ── HARD (lv8–lv10) ──────────────────────────────────────────────────────
268
- {
269
- "id": "lv8",
270
- "level": "hard",
271
- "type": "MALWARE",
272
- "data": {
273
- "sender": "payroll@hr-dept.net",
274
- "subject": "Your Bank Details Form – Action Required",
275
- "body": (
276
- "Please review and complete the attached payroll update form "
277
- "before Friday."
278
- ),
279
- "links": [],
280
- "has_attachments": True,
281
- "spf_record": "fail",
282
- "dmarc_record": "fail",
283
- "urgency_level": "high",
284
- "confidence_hint": (
285
- "AV: attachment is a macro-enabled Excel file (.xlsm), "
286
- "macros unsigned"
287
- ),
288
- },
289
- "correct": "QUARANTINE",
290
- },
291
- {
292
- "id": "lv9",
293
- "level": "hard",
294
- "type": "PHISH",
295
- "data": {
296
- "sender": "mfa@office365-auth.com",
297
- "subject": "Action Required: Update Your MFA Settings Now",
298
- "body": (
299
- "Your multi-factor authentication settings must be updated. "
300
- "Scan the QR code in the attached image to stay compliant."
301
- ),
302
- "links": [],
303
- "has_attachments": True,
304
- "spf_record": "softfail",
305
- "dmarc_record": "none",
306
- "urgency_level": "critical",
307
- "confidence_hint": "Gateway: QR code URL resolves to known credential-harvesting page",
308
- },
309
- "correct": "QUARANTINE",
310
- },
311
- {
312
- "id": "lv10",
313
- "level": "hard",
314
- "type": "BEC",
315
- "data": {
316
- "sender": "partner@trusted-firm.com",
317
- "subject": "Updated Project Specifications – Download Required by EOD",
318
- "body": (
319
- "Please find the revised project specs at the link below. "
320
- "Deadline is tomorrow morning."
321
- ),
322
- "links": ["https://trusted-partner.com/files/project_specs_final.zip"],
323
- "has_attachments": False,
324
- "spf_record": "pass",
325
- "dmarc_record": "pass",
326
- "urgency_level": "high",
327
- "confidence_hint": (
328
- "Threat Intel: trusted-firm.com added to IOC feed 6 hours ago "
329
- "— possible domain compromise"
330
- ),
331
- },
332
- "correct": "BLOCK_DOMAIN",
333
- },
334
- ]
335
-
336
- # ── Level → scenario IDs mapping ─────────────────────────────────────────────
337
- LEVEL_MAP: dict[str, list[str]] = {
338
- "easy": ["lv1", "lv2", "lv3"],
339
- "medium": ["lv4", "lv5", "lv6", "lv7"],
340
- "hard": ["lv8", "lv9", "lv10"],
341
- }
342
-
343
- _SCENARIO_BY_ID: dict[str, dict] = {s["id"]: s for s in SCENARIOS}
344
-
345
-
346
- # ═════════════════════════════════════════════════════════════════════════════
347
- # ENVIRONMENT CLASS
348
- # ═════════════════════════════════════════════════════════════════════════════
349
-
350
- class PhishGuardEnv(OpenEnv):
351
- """
352
- OpenEnv-compliant simulation environment for SOC analyst LLM benchmarking.
353
-
354
- State
355
- -----
356
- current_task_idx : int – pointer into the active (shuffled) scenario list
357
- health : int – lives remaining (3 → 0)
358
- score : float – cumulative reward for this episode
359
- task_scores : list – per-step reward history
360
- active_level : str – current difficulty level
361
- scenarios : list – scenarios loaded for the current level
362
- """
363
-
364
- MAX_HEALTH: int = 3
365
-
366
- def __init__(self) -> None:
367
- self.scenarios: List[dict] = []
368
- self.current_task_idx: int = 0
369
- self.health: int = self.MAX_HEALTH
370
- self.score: float = 0.0
371
- self.task_scores: List[float] = []
372
- self.active_level: str = "easy"
373
- # Initialise with easy level so the env is never empty on startup.
374
- self._load_level("easy")
375
-
376
- # ── Internal helpers ───────────────────────────────────────────���──────────
377
-
378
- def _load_level(self, level: str) -> None:
379
- """
380
- Filter and shuffle scenarios for the given difficulty level.
381
- Resets all state counters.
382
-
383
- Uses SCENARIO_LOADERS from grader.py (mirrors Focus-AI's
384
- TASK_LOADERS pattern) so scenario-to-level mapping is
385
- centralised in the grader module.
386
- """
387
- level = level.lower()
388
- if level not in LEVEL_MAP:
389
- raise ValueError(
390
- f"Unknown level '{level}'. Valid choices: easy | medium | hard"
391
- )
392
- # Use SCENARIO_LOADERS if available, fall back to LEVEL_MAP
393
- if level in SCENARIO_LOADERS:
394
- ids = SCENARIO_LOADERS[level]()
395
- else:
396
- ids = LEVEL_MAP[level]
397
- subset = [dict(_SCENARIO_BY_ID[sid]) for sid in ids]
398
- random.shuffle(subset)
399
-
400
- self.active_level = level
401
- self.scenarios = subset
402
- self.current_task_idx = 0
403
- self.health = self.MAX_HEALTH
404
- self.score = 0.0
405
- self.task_scores = []
406
-
407
- def _is_over(self) -> bool:
408
- return self.health <= 0 or self.current_task_idx >= len(self.scenarios)
409
-
410
- # ── Public API ────────────────────────────────────────────────────────────
411
-
412
- def reset(self, level: str = "easy") -> dict:
413
- """
414
- Reset the environment for a new episode at the given difficulty level.
415
-
416
- Returns the first email observation dict.
417
-
418
- Raises
419
- ------
420
- ValueError
421
- If the level is unknown or maps to zero scenarios.
422
- """
423
- self._load_level(level)
424
- if not self.scenarios:
425
- raise ValueError(f"No scenarios found for level '{level}'")
426
- first_task = self.scenarios[self.current_task_idx]
427
- log.info(
428
- "Episode reset | level=%s | first_scenario=%s | total=%d",
429
- self.active_level,
430
- first_task["id"],
431
- len(self.scenarios),
432
- )
433
- return first_task["data"]
434
-
435
- def step(self, action_str: str) -> tuple:
436
- """
437
- Advance the simulation by one triage decision.
438
-
439
- Returns
440
- -------
441
- (obs, reward, done, info)
442
- """
443
- # ── Guard: episode already over ───────────────────────────────────────
444
- if self._is_over():
445
- return None, R_BREACH, True, {
446
- "task_id": None,
447
- "task_group": None,
448
- "is_correct": False,
449
- "health": self.health,
450
- "feedback": "Episode already ended. Call /reset to start a new one.",
451
- "score": round(self.score, 4),
452
- "task_scores": list(self.task_scores),
453
- }
454
-
455
- # ── Resolve current scenario ──────────────────────────────────────────
456
- current_task = self.scenarios[self.current_task_idx]
457
- task_id = current_task["id"]
458
-
459
- # ── Grade the action ──────────────────────────────────────────────────
460
- reward, verdict_msg = grade_action(
461
- action_str,
462
- current_task["correct"],
463
- current_task["type"],
464
- )
465
-
466
- self.score += reward
467
- self.task_scores.append(reward)
468
-
469
- log.info(
470
- "Step | level=%s | task=%s | action=%s | reward=%.4f | verdict=%s",
471
- self.active_level,
472
- task_id,
473
- action_str.strip().upper(),
474
- reward,
475
- verdict_msg,
476
- )
477
-
478
- # ── Health drain ──────────────────────────────────────────────────────
479
- if reward < HEALTH_DRAIN_THRESHOLD:
480
- self.health -= 1
481
- feedback = (
482
- f"⚠️ CRITICAL ERROR: {verdict_msg} "
483
- f"| Health remaining: {self.health}/{self.MAX_HEALTH}"
484
- )
485
- else:
486
- feedback = f"✅ Analysis accepted: {verdict_msg}"
487
-
488
- # ── Advance task pointer ──────────────────────────────────────────────
489
- done = False
490
- self.current_task_idx += 1
491
-
492
- if self.health <= 0:
493
- done = True
494
- feedback = "❌ TERMINATED: Too many critical failures — health depleted."
495
-
496
- if self.current_task_idx >= len(self.scenarios):
497
- done = True
498
- if self.health > 0:
499
- feedback = (
500
- f"🏆 SUCCESS: All {len(self.scenarios)} "
501
- f"{self.active_level.upper()} scenarios completed."
502
- )
503
-
504
- # ── Next observation ──────────────────────────────────────────────────
505
- obs = (
506
- self.scenarios[self.current_task_idx]["data"]
507
- if not self._is_over()
508
- else None
509
- )
510
-
511
- return obs, reward, done, {
512
- "task_id": task_id,
513
- "task_group": current_task["level"],
514
- "is_correct": reward >= R_PERFECT,
515
- "health": self.health,
516
- "feedback": feedback,
517
- "score": round(self.score, 4),
518
- "task_scores": list(self.task_scores),
519
- }
520
-
521
-
522
- # ═════════════════════════════════════════════════════════════════════════════
523
- # FASTAPI APPLICATION
524
- # ═════════════════════════════════════════════════════════════════════════════
525
-
526
- # Singleton environment instance — shared across all requests.
527
- _env = PhishGuardEnv()
528
- # BUG FIX: asyncio.Lock serialises /reset and /step so concurrent requests
529
- # cannot race on _env's mutable state (current_task_idx, health, score, etc.).
530
- _env_lock = asyncio.Lock()
531
-
532
-
533
- @asynccontextmanager
534
- async def lifespan(app: FastAPI):
535
- log.info("PhishGuard-Env %s starting on port 7860.", VERSION)
536
- yield
537
- log.info("PhishGuard-Env shutting down.")
538
-
539
-
540
- app = FastAPI(
541
- title="PhishGuard-Env",
542
- description=(
543
- "OpenEnv-compliant SOC analyst simulation environment. "
544
- "Exposes /reset, /step, /state, and /health for LLM agent benchmarking."
545
- ),
546
- version=VERSION,
547
- lifespan=lifespan,
548
- )
549
-
550
- app.add_middleware(
551
- CORSMiddleware,
552
- allow_origins=["*"],
553
- allow_methods=["GET", "POST"],
554
- allow_headers=["*"],
555
- )
556
-
557
-
558
- # ── Liveness probe ────────────────────────────────────────────────────────────
559
- @app.get("/health", tags=["Meta"])
560
- async def health_probe() -> dict:
561
- """Liveness probe — HF Spaces and load-balancers call this endpoint."""
562
- return {"status": "ok", "env": "PhishGuard-Env", "version": VERSION}
563
-
564
-
565
- # ── Reset ─────────────────────────────────────────────────────────────────────
566
- @app.post("/reset", tags=["Environment"])
567
- async def reset(request: Optional[ResetRequest] = None) -> ResetResponse:
568
- """
569
- Reset the environment for a new episode at the chosen difficulty level.
570
-
571
- Body (optional): { "level": "easy" | "medium" | "hard" }
572
- If no body is provided, defaults to "easy".
573
- """
574
- level = (request.level if request else "easy").lower()
575
- if level not in LEVEL_MAP:
576
- raise HTTPException(
577
- status_code=422,
578
- detail=f"Invalid level '{level}'. Must be one of: easy | medium | hard",
579
- )
580
-
581
- async with _env_lock:
582
- obs = _env.reset(level=level)
583
- first_scenario = _env.scenarios[_env.current_task_idx]
584
- active_level = _env.active_level
585
-
586
- return ResetResponse(
587
- observation=obs,
588
- task_id=first_scenario["id"],
589
- task_group=first_scenario["level"],
590
- level=active_level,
591
- # BUG FIX: was len(_env.scenarios) which could be stale;
592
- # now reads directly from LEVEL_MAP for the requested level.
593
- total_tasks=len(LEVEL_MAP[level]),
594
- )
595
-
596
-
597
- # ── Step ──────────────────────────────────────────────────────────────────────
598
- @app.post("/step", tags=["Environment"])
599
- async def step(action: PhishAction) -> StepResponse:
600
- """
601
- Submit one triage action and receive the next observation + reward.
602
-
603
- Body: { "action": "MARK_SAFE | MOVE_TO_SPAM | QUARANTINE | BLOCK_DOMAIN",
604
- "reasoning": "optional" }
605
- """
606
- action_str = action.action.strip().upper()[:64]
607
-
608
- async with _env_lock:
609
- obs, reward, done, info = _env.step(action_str)
610
-
611
- return StepResponse(
612
- observation=obs,
613
- reward=reward,
614
- done=done,
615
- task_id=info["task_id"],
616
- is_correct=info["is_correct"],
617
- info=info,
618
- )
619
-
620
-
621
- # ── State ───────────────────────────��─────────────────────────────────────────
622
- @app.get("/state", tags=["Environment"])
623
- async def state() -> dict:
624
- """Read-only snapshot of the current environment state."""
625
- async with _env_lock:
626
- overall = calculate_overall_score(_env.task_scores)
627
- return {
628
- "level": _env.active_level,
629
- "health": _env.health,
630
- "score": round(_env.score, 4),
631
- "overall_score": overall,
632
- "task_index": _env.current_task_idx,
633
- "total_tasks": len(_env.scenarios),
634
- "task_scores": list(_env.task_scores),
635
- }
636
-
637
-
638
- # ── Tasks ─────────────────────────────────────────────────────────────────────
639
- @app.get("/tasks", tags=["Environment"])
640
- async def tasks() -> dict:
641
- """List all tasks with their IDs, difficulty, and correct actions."""
642
- return {
643
- "tasks": [
644
- {
645
- "task_id": s["id"],
646
- "difficulty": s["level"],
647
- "type": s["type"],
648
- "correct": s["correct"],
649
- }
650
- for s in SCENARIOS
651
- ]
652
- }
653
-
654
-
655
- # ── Grader ────────────────────────────────────────────────────────────────────
656
- @app.post("/grader", tags=["Environment"])
657
- async def grader(request: dict) -> dict:
658
- """
659
- Grade a triage action for a specific task without running a full episode.
660
- The OpenEnv validator calls this endpoint to verify graders are working.
661
-
662
- Body: { "task_id": "lv1", "action": "MOVE_TO_SPAM" }
663
- """
664
- task_id = request.get("task_id", "lv1")
665
- action = request.get("action", "QUARANTINE")
666
-
667
- scenario = next((s for s in SCENARIOS if s["id"] == task_id), None)
668
- if scenario is None:
669
- raise HTTPException(
670
- status_code=404,
671
- detail=f"Task '{task_id}' not found. Valid IDs: {[s['id'] for s in SCENARIOS]}",
672
- )
673
-
674
- reward, message = grade_action(action, scenario["correct"], scenario["type"])
675
-
676
- return {
677
- "task_id": task_id,
678
- "action": action,
679
- "reward": reward,
680
- "is_correct": reward >= R_PERFECT,
681
- "message": message,
682
- }
683
-
684
-
685
- # ── Grade by Difficulty ───────────────────────────────────────────────────────
686
- # Mirrors Focus-AI's GRADERS dict pattern — allows grading an entire
687
- # difficulty level by passing metrics, just like Focus-AI's env.py uses
688
- # GRADERS[difficulty](metrics) at episode end.
689
- @app.post("/grade/{difficulty}", tags=["Grading"])
690
- async def grade_difficulty(difficulty: str, metrics: dict) -> dict:
691
- """
692
- Grade a full episode for a specific difficulty level using the
693
- deterministic grader function.
694
-
695
- This mirrors Focus-AI's GRADERS[difficulty](metrics) pattern.
696
-
697
- Path param: difficulty = easy | medium | hard
698
- Body: metrics dict (e.g. {"total_tasks": 3, "correct_actions": 2, ...})
699
- """
700
- difficulty = difficulty.lower()
701
- if difficulty not in GRADERS:
702
- raise HTTPException(
703
- status_code=422,
704
- detail=f"Invalid difficulty '{difficulty}'. Must be one of: {list(GRADERS.keys())}",
705
- )
706
-
707
- score = GRADERS[difficulty](metrics)
708
- return {
709
- "difficulty": difficulty,
710
- "score": score,
711
- "metrics": metrics,
712
- }
713
-
714
-
715
- # ── Aggregate Performance Grade ──────────────────────────────────────────────
716
- @app.post("/grade/performance", tags=["Grading"])
717
- async def grade_perf(metrics: dict) -> dict:
718
- """
719
- Cross-difficulty aggregate grader for leaderboard ranking.
720
- Mirrors Focus-AI's grade_performance() function.
721
- """
722
- score = grade_performance(metrics)
723
- return {
724
- "difficulty": "aggregate",
725
- "score": score,
726
- "metrics": metrics,
727
- }
728
-
729
-
730
- # ── Entry point ───────────────────────────────────────────────────────────────
731
- if __name__ == "__main__":
732
- import uvicorn
733
- uvicorn.run(
734
- "server.app:app",
735
- host="0.0.0.0",
736
- port=7860,
737
- reload=False,
738
- log_level="info",
739
- )
 
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.
10
+
11
+ Endpoints
12
+ ---------
13
+ POST /reset → reset for a chosen difficulty level
14
+ POST /step submit one triage action
15
+ GET /state read-only snapshot of health, score, metrics
16
+ GET /health liveness probe
17
+ GET /tasks list all task IDs and correct actions
18
+ POST /grader → grade a single action without a full episode
19
+ POST /grade/{difficulty} → grade a full metrics dict for a difficulty
20
+ POST /grade/performance → aggregate cross-level grader
21
+
22
+ LEVEL DESIGN
23
+ ------------
24
+ easy → lv1, lv2, lv3 (3 tasks)
25
+ medium → lv4, lv5, lv6, lv7 (4 tasks)
26
+ hard → lv8, lv9, lv10 (3 tasks)
27
+
28
+ Metrics tracked per episode (keys required by GRADERS)
29
+ -------------------------------------------------------
30
+ total_tasks : number of scenarios in this level
31
+ completed_tasks : steps where a graded action was taken
32
+ perfect_tasks : steps where reward >= R_PERFECT
33
+ on_time : steps where reward >= HEALTH_DRAIN_THRESHOLD
34
+ breach_count : steps where reward == R_BREACH
35
+ disruption_count : steps where reward == R_DISRUPTION
36
+ total_steps : total /step calls
37
+
38
+ Episode score
39
+ -------------
40
+ When done=True, info["score"] = GRADERS[active_level](metrics).
41
+ This is the same function the OpenEnv validator checks — guaranteeing
42
+ consistency between what the validator sees and what we report.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import asyncio
48
+ import logging
49
+ import random
50
+ from contextlib import asynccontextmanager
51
+ from typing import List, Optional
52
+
53
+ from fastapi import FastAPI, HTTPException
54
+ from fastapi.middleware.cors import CORSMiddleware
55
+
56
+ from models import PhishAction, ResetRequest, StepResponse, ResetResponse
57
+
58
+ # ── Logging ───────────────────────────────────────────────────────────────────
59
+ logging.basicConfig(
60
+ level=logging.INFO,
61
+ format="%(asctime)s | %(levelname)s | %(message)s",
62
+ datefmt="%H:%M:%S",
63
+ )
64
+ log = logging.getLogger("phishguard.env")
65
+
66
+ # ── OpenEnv base-class (graceful degradation) ─────────────────────────────────
67
+ try:
68
+ from openenv import OpenEnv
69
+ except ImportError:
70
+ try:
71
+ from openenv.core import OpenEnv # type: ignore
72
+ except ImportError:
73
+ OpenEnv = object
74
+
75
+ # ── Grader imports ────────────────────────────────────────────────────────────
76
+ # NOTE: SCENARIO_LOADERS has been removed — it never existed in grader.py.
77
+ # Level-to-scenario mapping is owned entirely by LEVEL_MAP in this file.
78
+ from grader import (
79
+ R_BREACH,
80
+ R_DISRUPTION,
81
+ R_PERFECT,
82
+ HEALTH_DRAIN_THRESHOLD,
83
+ GRADERS,
84
+ TASK_GRADERS,
85
+ calculate_overall_score,
86
+ grade_action,
87
+ grade_performance,
88
+ )
89
+
90
+ VERSION = "1.0.3"
91
+
92
+ # ═════════════════════════════════════════════════════════════════════════════
93
+ # SCENARIO DEFINITIONS (lv1 → lv10)
94
+ # ═════════════════════════════════════════════════════════════════════════════
95
+
96
+ SCENARIOS: List[dict] = [
97
+ # ── EASY (lv1–lv3) ───────────────────────────────────────────────────────
98
+ {
99
+ "id": "lv1", "level": "easy", "type": "SPAM",
100
+ "data": {
101
+ "sender": "win@lotto.net",
102
+ "subject": "Claim $1M Prize – Congratulations!",
103
+ "body": "You have been selected. Click now to claim your prize before it expires.",
104
+ "links": ["http://bit.ly/123abc"],
105
+ "has_attachments": False,
106
+ "spf_record": "fail",
107
+ "dmarc_record": "none",
108
+ "urgency_level": "high",
109
+ "confidence_hint": "SIEM: no prior communication history with this domain",
110
+ },
111
+ "correct": "MOVE_TO_SPAM",
112
+ },
113
+ {
114
+ "id": "lv2", "level": "easy", "type": "PHISH",
115
+ "data": {
116
+ "sender": "support@googIe.com",
117
+ "subject": "Urgent Security Alert Verify Your Account",
118
+ "body": "We detected suspicious activity on your account. Verify your identity immediately.",
119
+ "links": ["http://googIe-verify.com/login"],
120
+ "has_attachments": False,
121
+ "spf_record": "softfail",
122
+ "dmarc_record": "fail",
123
+ "urgency_level": "critical",
124
+ "confidence_hint": "Gateway: domain registered 3 days ago, 0 prior sends",
125
+ },
126
+ "correct": "BLOCK_DOMAIN",
127
+ },
128
+ {
129
+ "id": "lv3", "level": "easy", "type": "SAFE",
130
+ "data": {
131
+ "sender": "boss@company.com",
132
+ "subject": "Team Meeting Tomorrow at 9 AM",
133
+ "body": "Hi team, please be ready for our weekly sync at 9 AM in the main boardroom.",
134
+ "links": [],
135
+ "has_attachments": False,
136
+ "spf_record": "pass",
137
+ "dmarc_record": "pass",
138
+ "urgency_level": "low",
139
+ "confidence_hint": "SIEM: sender in address book for 2+ years, 200+ prior emails",
140
+ },
141
+ "correct": "MARK_SAFE",
142
+ },
143
+
144
+ # ── MEDIUM (lv4lv7) ─────────────────────────────────────────────────────
145
+ {
146
+ "id": "lv4", "level": "medium", "type": "MALWARE",
147
+ "data": {
148
+ "sender": "it@internal-help.com",
149
+ "subject": "Critical Security Patch – Action Required Immediately",
150
+ "body": "A critical vulnerability has been discovered. Run fix.exe to patch your system now.",
151
+ "links": ["http://internal-help.com/fix.exe"],
152
+ "has_attachments": True,
153
+ "spf_record": "fail",
154
+ "dmarc_record": "none",
155
+ "urgency_level": "critical",
156
+ "confidence_hint": "AV: attachment flagged as suspicious PE binary (confidence 87%)",
157
+ },
158
+ "correct": "QUARANTINE",
159
+ },
160
+ {
161
+ "id": "lv5", "level": "medium", "type": "SAFE",
162
+ "data": {
163
+ "sender": "hr@yourcompany.com",
164
+ "subject": "Updated Holiday Schedule Please Review",
165
+ "body": "Hi all, the updated holiday list is now live on the HR portal. No action needed.",
166
+ "links": ["https://portal.yourcompany.com/holidays"],
167
+ "has_attachments": False,
168
+ "spf_record": "pass",
169
+ "dmarc_record": "pass",
170
+ "urgency_level": "low",
171
+ "confidence_hint": "SIEM: bulk-sent to all-staff distribution list, normal recurring pattern",
172
+ },
173
+ "correct": "MARK_SAFE",
174
+ },
175
+ {
176
+ "id": "lv6", "level": "medium", "type": "BEC",
177
+ "data": {
178
+ "sender": "ceo@executive-mail.com",
179
+ "subject": "URGENTWire Transfer Required Today",
180
+ "body": "I'm in a meeting and cannot be reached by phone. Wire $5,000 to the account below immediately.",
181
+ "links": [],
182
+ "has_attachments": False,
183
+ "spf_record": "pass",
184
+ "dmarc_record": "none",
185
+ "urgency_level": "critical",
186
+ "confidence_hint": "SIEM: first-ever email from this domain to any company inbox",
187
+ },
188
+ "correct": "QUARANTINE",
189
+ },
190
+ {
191
+ "id": "lv7", "level": "medium", "type": "PHISH",
192
+ "data": {
193
+ "sender": "notify@docs-share.net",
194
+ "subject": "You Have a New Shared Document",
195
+ "body": "Your colleague shared a file with you. Click the link below to view it in your browser.",
196
+ "links": ["https://docs-verify.net/s/1x9az"],
197
+ "has_attachments": False,
198
+ "spf_record": "softfail",
199
+ "dmarc_record": "fail",
200
+ "urgency_level": "medium",
201
+ "confidence_hint": "Gateway: destination link redirects through 2 URL shorteners before landing",
202
+ },
203
+ "correct": "BLOCK_DOMAIN",
204
+ },
205
+
206
+ # ── HARD (lv8lv10) ──────────────────────────────────────────────────────
207
+ {
208
+ "id": "lv8", "level": "hard", "type": "MALWARE",
209
+ "data": {
210
+ "sender": "payroll@hr-dept.net",
211
+ "subject": "Your Bank Details Form – Action Required",
212
+ "body": "Please review and complete the attached payroll update form before Friday.",
213
+ "links": [],
214
+ "has_attachments": True,
215
+ "spf_record": "fail",
216
+ "dmarc_record": "fail",
217
+ "urgency_level": "high",
218
+ "confidence_hint": "AV: attachment is a macro-enabled Excel file (.xlsm), macros unsigned",
219
+ },
220
+ "correct": "QUARANTINE",
221
+ },
222
+ {
223
+ "id": "lv9", "level": "hard", "type": "PHISH",
224
+ "data": {
225
+ "sender": "mfa@office365-auth.com",
226
+ "subject": "Action Required: Update Your MFA Settings Now",
227
+ "body": "Your multi-factor authentication settings must be updated. Scan the QR code in the attached image to stay compliant.",
228
+ "links": [],
229
+ "has_attachments": True,
230
+ "spf_record": "softfail",
231
+ "dmarc_record": "none",
232
+ "urgency_level": "critical",
233
+ "confidence_hint": "Gateway: QR code URL resolves to known credential-harvesting page",
234
+ },
235
+ "correct": "QUARANTINE",
236
+ },
237
+ {
238
+ "id": "lv10", "level": "hard", "type": "BEC",
239
+ "data": {
240
+ "sender": "partner@trusted-firm.com",
241
+ "subject": "Updated Project Specifications – Download Required by EOD",
242
+ "body": "Please find the revised project specs at the link below. Deadline is tomorrow morning.",
243
+ "links": ["https://trusted-partner.com/files/project_specs_final.zip"],
244
+ "has_attachments": False,
245
+ "spf_record": "pass",
246
+ "dmarc_record": "pass",
247
+ "urgency_level": "high",
248
+ "confidence_hint": "Threat Intel: trusted-firm.com added to IOC feed 6 hours ago — possible domain compromise",
249
+ },
250
+ "correct": "BLOCK_DOMAIN",
251
+ },
252
+ ]
253
+
254
+ LEVEL_MAP: dict[str, list[str]] = {
255
+ "easy": ["lv1", "lv2", "lv3"],
256
+ "medium": ["lv4", "lv5", "lv6", "lv7"],
257
+ "hard": ["lv8", "lv9", "lv10"],
258
+ }
259
+
260
+ _SCENARIO_BY_ID: dict[str, dict] = {s["id"]: s for s in SCENARIOS}
261
+
262
+
263
+ # ═════════════════════════════════════════════════════════════════════════════
264
+ # HELPERS
265
+ # ═════════════════════════════════════════════════════════════════════════════
266
+
267
+ def _empty_metrics(total_tasks: int = 0) -> dict:
268
+ """Zeroed metrics dict with all keys expected by GRADERS."""
269
+ return {
270
+ "total_tasks": total_tasks,
271
+ "completed_tasks": 0,
272
+ "perfect_tasks": 0,
273
+ "on_time": 0,
274
+ "breach_count": 0,
275
+ "disruption_count": 0,
276
+ "total_steps": 0,
277
+ }
278
+
279
+
280
+ # ═════════════════════════════════════════════════════════════════════════════
281
+ # ENVIRONMENT CLASS
282
+ # ═════════════════════════════════════════════════════════════════════════════
283
+
284
+ class PhishGuardEnv(OpenEnv):
285
+ MAX_HEALTH: int = 3
286
+
287
+ def __init__(self) -> None:
288
+ self.scenarios: List[dict] = []
289
+ self.current_task_idx: int = 0
290
+ self.health: int = self.MAX_HEALTH
291
+ self.score: float = 0.0
292
+ self.task_scores: List[float] = []
293
+ self.metrics: dict = _empty_metrics()
294
+ self.active_level: str = "easy"
295
+ self._load_level("easy")
296
+
297
+ def _load_level(self, level: str) -> None:
298
+ level = level.lower()
299
+ if level not in LEVEL_MAP:
300
+ raise ValueError(f"Unknown level '{level}'. Valid choices: easy | medium | hard")
301
+ ids = LEVEL_MAP[level]
302
+ subset = [dict(_SCENARIO_BY_ID[sid]) for sid in ids]
303
+ random.shuffle(subset)
304
+
305
+ self.active_level = level
306
+ self.scenarios = subset
307
+ self.current_task_idx = 0
308
+ self.health = self.MAX_HEALTH
309
+ self.score = 0.0
310
+ self.task_scores = []
311
+ self.metrics = _empty_metrics(total_tasks=len(subset))
312
+
313
+ def _is_over(self) -> bool:
314
+ return self.health <= 0 or self.current_task_idx >= len(self.scenarios)
315
+
316
+ def reset(self, level: str = "easy") -> dict:
317
+ self._load_level(level)
318
+ if not self.scenarios:
319
+ raise ValueError(f"No scenarios found for level '{level}'")
320
+ first_task = self.scenarios[self.current_task_idx]
321
+ log.info(
322
+ "Episode reset | level=%s | first=%s | total=%d",
323
+ self.active_level, first_task["id"], len(self.scenarios),
324
+ )
325
+ return first_task["data"]
326
+
327
+ def step(self, action_str: str) -> tuple:
328
+ """
329
+ Advance by one triage decision.
330
+
331
+ Returns (obs, reward, done, info).
332
+ info["score"] is set (non-None) only when done=True, using
333
+ GRADERS[active_level](metrics) — the grader the validator checks.
334
+ """
335
+ # ── Guard ─────────────────────────────────────────────────────────────
336
+ if self._is_over():
337
+ self.metrics["total_steps"] += 1
338
+ return None, R_BREACH, True, {
339
+ "task_id": None,
340
+ "task_group": None,
341
+ "is_correct": False,
342
+ "health": self.health,
343
+ "feedback": "Episode already ended. Call /reset to start a new one.",
344
+ "score": None,
345
+ "metrics": dict(self.metrics),
346
+ "task_scores": list(self.task_scores),
347
+ }
348
+
349
+ current_task = self.scenarios[self.current_task_idx]
350
+ task_id = current_task["id"]
351
+
352
+ # ── Grade ─────────────────────────────────────────────────────────────
353
+ reward, verdict_msg = grade_action(
354
+ action_str,
355
+ current_task["correct"],
356
+ current_task["type"],
357
+ )
358
+
359
+ self.score += reward
360
+ self.task_scores.append(reward)
361
+
362
+ # ── Update metrics ────────────────────────────────────────────────────
363
+ self.metrics["total_steps"] += 1
364
+ self.metrics["completed_tasks"] += 1
365
+
366
+ if reward >= R_PERFECT:
367
+ self.metrics["perfect_tasks"] += 1
368
+
369
+ if reward >= HEALTH_DRAIN_THRESHOLD:
370
+ self.metrics["on_time"] += 1
371
+
372
+ if reward == R_BREACH:
373
+ self.metrics["breach_count"] += 1
374
+
375
+ if reward == R_DISRUPTION:
376
+ self.metrics["disruption_count"] += 1
377
+
378
+ log.info(
379
+ "Step | level=%s | task=%s | action=%s | reward=%.4f | %s",
380
+ self.active_level, task_id,
381
+ action_str.strip().upper(), reward, verdict_msg,
382
+ )
383
+
384
+ # ── Health drain ──────────────────────────────────────────────────────
385
+ if reward < HEALTH_DRAIN_THRESHOLD:
386
+ self.health -= 1
387
+ feedback = (
388
+ f"⚠️ CRITICAL ERROR: {verdict_msg} "
389
+ f"| Health remaining: {self.health}/{self.MAX_HEALTH}"
390
+ )
391
+ else:
392
+ feedback = f"✅ Analysis accepted: {verdict_msg}"
393
+
394
+ # ── Advance pointer ───────────────────────────────────────────────────
395
+ done = False
396
+ self.current_task_idx += 1
397
+
398
+ if self.health <= 0:
399
+ done = True
400
+ feedback = "❌ TERMINATED: Too many critical failures — health depleted."
401
+
402
+ if self.current_task_idx >= len(self.scenarios):
403
+ done = True
404
+ if self.health > 0:
405
+ feedback = (
406
+ f"🏆 SUCCESS: All {len(self.scenarios)} "
407
+ f"{self.active_level.upper()} scenarios completed."
408
+ )
409
+
410
+ obs = (
411
+ self.scenarios[self.current_task_idx]["data"]
412
+ if not self._is_over()
413
+ else None
414
+ )
415
+
416
+ # ── Episode score via GRADERS ─────────────────────────────────────────
417
+ episode_score: Optional[float] = None
418
+ if done:
419
+ episode_score = GRADERS[self.active_level](self.metrics)
420
+ log.info(
421
+ "Episode done | level=%s | score=%.6f | metrics=%s",
422
+ self.active_level, episode_score, self.metrics,
423
+ )
424
+
425
+ return obs, reward, done, {
426
+ "task_id": task_id,
427
+ "task_group": current_task["level"],
428
+ "is_correct": reward >= R_PERFECT,
429
+ "health": self.health,
430
+ "feedback": feedback,
431
+ "score": episode_score,
432
+ "metrics": dict(self.metrics),
433
+ "task_scores": list(self.task_scores),
434
+ }
435
+
436
+
437
+ # ═════════════════════════════════════════════════════════════════════════════
438
+ # FASTAPI APPLICATION
439
+ # ═════════════════════════════════════════════════════════════════════════════
440
+
441
+ _env = PhishGuardEnv()
442
+ _env_lock = asyncio.Lock()
443
+
444
+
445
+ @asynccontextmanager
446
+ async def lifespan(app: FastAPI):
447
+ log.info("PhishGuard-Env %s starting on port 7860.", VERSION)
448
+ yield
449
+ log.info("PhishGuard-Env shutting down.")
450
+
451
+
452
+ app = FastAPI(
453
+ title="PhishGuard-Env",
454
+ description=(
455
+ "OpenEnv-compliant SOC analyst simulation environment. "
456
+ "Exposes /reset, /step, /state, and /health for LLM agent benchmarking."
457
+ ),
458
+ version=VERSION,
459
+ lifespan=lifespan,
460
+ )
461
+
462
+ app.add_middleware(
463
+ CORSMiddleware,
464
+ allow_origins=["*"],
465
+ allow_methods=["GET", "POST"],
466
+ allow_headers=["*"],
467
+ )
468
+
469
+
470
+ @app.get("/health", tags=["Meta"])
471
+ async def health_probe() -> dict:
472
+ return {"status": "ok", "env": "PhishGuard-Env", "version": VERSION}
473
+
474
+
475
+ @app.post("/reset", tags=["Environment"])
476
+ async def reset(request: Optional[ResetRequest] = None) -> ResetResponse:
477
+ """Reset for a new episode. Body (optional): { "level": "easy"|"medium"|"hard" }"""
478
+ level = (request.level if request else "easy").lower()
479
+ if level not in LEVEL_MAP:
480
+ raise HTTPException(
481
+ status_code=422,
482
+ detail=f"Invalid level '{level}'. Must be one of: easy | medium | hard",
483
+ )
484
+ async with _env_lock:
485
+ obs = _env.reset(level=level)
486
+ first_scenario = _env.scenarios[_env.current_task_idx]
487
+ active_level = _env.active_level
488
+
489
+ return ResetResponse(
490
+ observation=obs,
491
+ task_id=first_scenario["id"],
492
+ task_group=first_scenario["level"],
493
+ level=active_level,
494
+ total_tasks=len(LEVEL_MAP[level]),
495
+ )
496
+
497
+
498
+ @app.post("/step", tags=["Environment"])
499
+ async def step(action: PhishAction) -> StepResponse:
500
+ """Submit one triage action."""
501
+ action_str = action.action.strip().upper()[:64]
502
+ async with _env_lock:
503
+ obs, reward, done, info = _env.step(action_str)
504
+
505
+ return StepResponse(
506
+ observation=obs,
507
+ reward=reward,
508
+ done=done,
509
+ task_id=info["task_id"],
510
+ is_correct=info["is_correct"],
511
+ info=info,
512
+ )
513
+
514
+
515
+ @app.get("/state", tags=["Environment"])
516
+ async def state() -> dict:
517
+ """Read-only snapshot. Does not advance the simulation."""
518
+ async with _env_lock:
519
+ episode_score = GRADERS[_env.active_level](_env.metrics)
520
+ rolling_score = calculate_overall_score(_env.task_scores)
521
+ return {
522
+ "level": _env.active_level,
523
+ "health": _env.health,
524
+ "score": round(_env.score, 4),
525
+ "overall_score": episode_score, # GRADERS-based — what validator checks
526
+ "rolling_score": rolling_score, # calculate_overall_score per-step avg
527
+ "task_index": _env.current_task_idx,
528
+ "total_tasks": len(_env.scenarios),
529
+ "task_scores": list(_env.task_scores),
530
+ "metrics": dict(_env.metrics),
531
+ }
532
+
533
+
534
+ @app.get("/tasks", tags=["Environment"])
535
+ async def tasks() -> dict:
536
+ return {
537
+ "tasks": [
538
+ {"task_id": s["id"], "difficulty": s["level"],
539
+ "type": s["type"], "correct": s["correct"]}
540
+ for s in SCENARIOS
541
+ ]
542
+ }
543
+
544
+
545
+ @app.post("/grader", tags=["Grading"])
546
+ async def grader_endpoint(request: dict) -> dict:
547
+ """
548
+ Grade a single action for a task without a full episode.
549
+ Body: { "task_id": "lv1", "action": "MOVE_TO_SPAM" }
550
+ """
551
+ task_id = request.get("task_id", "lv1")
552
+ action = request.get("action", "QUARANTINE")
553
+ scenario = next((s for s in SCENARIOS if s["id"] == task_id), None)
554
+ if scenario is None:
555
+ raise HTTPException(
556
+ status_code=404,
557
+ detail=f"Task '{task_id}' not found. Valid IDs: {[s['id'] for s in SCENARIOS]}",
558
+ )
559
+ reward, message = grade_action(action, scenario["correct"], scenario["type"])
560
+ return {
561
+ "task_id": task_id,
562
+ "action": action,
563
+ "reward": reward,
564
+ "is_correct": reward >= R_PERFECT,
565
+ "message": message,
566
+ }
567
+
568
+
569
+ @app.post("/grade/{difficulty}", tags=["Grading"])
570
+ async def grade_difficulty(difficulty: str, metrics: dict) -> dict:
571
+ """
572
+ Grade a full episode metrics dict for a difficulty level.
573
+ Mirrors FocusAI's GRADERS[difficulty](metrics) pattern.
574
+ """
575
+ difficulty = difficulty.lower()
576
+ if difficulty not in GRADERS:
577
+ raise HTTPException(
578
+ status_code=422,
579
+ detail=f"Invalid difficulty '{difficulty}'. Must be one of: {list(GRADERS.keys())}",
580
+ )
581
+ return {"difficulty": difficulty, "score": GRADERS[difficulty](metrics), "metrics": metrics}
582
+
583
+
584
+ @app.post("/grade/performance", tags=["Grading"])
585
+ async def grade_perf(metrics: dict) -> dict:
586
+ """Cross-difficulty aggregate grader. Mirrors FocusAI's grade_performance()."""
587
+ return {"difficulty": "aggregate", "score": grade_performance(metrics), "metrics": metrics}
588
+
589
+
590
+ if __name__ == "__main__":
591
+ import uvicorn
592
+ uvicorn.run("server.app:app", host="0.0.0.0", port=7860, reload=False, log_level="info")