zephO-O commited on
Commit
20cd78e
Β·
verified Β·
1 Parent(s): 6c3d83c

Update models.py

Browse files
Files changed (1) hide show
  1. models.py +465 -110
models.py CHANGED
@@ -1,110 +1,465 @@
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
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()