Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| Scaler Meta PyTorch / OpenEnv Round 1 — root inference.py. | |
| MANDATORY for --agent llm (OpenAI client + env vars from the judge / LiteLLM proxy): | |
| API_BASE_URL Injected proxy base URL (do not hardcode another provider in eval). | |
| API_KEY Injected key for the proxy (preferred). HF_TOKEN also accepted for local dev. | |
| MODEL_NAME Model id for chat completions. | |
| Stdout (per episode), field order must match organizer sample: | |
| [START] task=<task_name> env=<benchmark> model=<model_name> | |
| [STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null> | |
| [END] success=<true|false> steps=<n> score=<0.00> rewards=<r1,r2,...> | |
| Unless --all-tasks is set, the driver runs at least three full episode blocks (each block is | |
| [START] ... [STEP]* ... [END]) so logs always contain three scored runs for a single --task. | |
| Stay under the ~20 minute judge cap; optional SCAM_ENV_MAX_RUNTIME_SEC (default 1140s). | |
| Optional LLM tuning: SCAM_ENV_LLM_MAX_RETRIES (default 3), SCAM_ENV_LLM_JSON_MODE=1 | |
| (response_format json_object + {{"action":"..."}}), SCAM_ENV_LLM_CACHE=1 (dev-only cache). | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import json | |
| import math | |
| import os | |
| import re | |
| import sys | |
| import textwrap | |
| import time | |
| from pathlib import Path | |
| from typing import Any | |
| ROOT = Path(__file__).resolve().parent | |
| if str(ROOT) not in sys.path: | |
| sys.path.insert(0, str(ROOT)) | |
| from baseline.baseline_agent import BaselineAgent | |
| from env.models import Action | |
| from env.scam_env import ScamEnv | |
| from tasks.graders import finalize_episode_score, grade_episode | |
| from tasks.task_registry import CANONICAL_TASK_IDS, MAX_STEPS_BY_TASK, TASK_ALIASES | |
| # --- Config (env + argparse) --- | |
| API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1") | |
| MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct") | |
| # Judge injects API_KEY; HF_TOKEN for local Hugging Face router — API_KEY first for proxy billing. | |
| API_KEY = os.getenv("API_KEY") or os.getenv("HF_TOKEN") or "" | |
| def _default_agent_arg() -> str: | |
| """Use LLM when keys exist (Phase 2 proxy observes API calls); else baseline for local smoke.""" | |
| a = os.getenv("SCAM_ENV_AGENT") | |
| if a in ("llm", "baseline"): | |
| return a | |
| return "llm" if (os.getenv("API_KEY") or os.getenv("HF_TOKEN")) else "baseline" | |
| BENCHMARK = os.getenv("BENCHMARK", "scam-detection-env") | |
| LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME") or os.getenv("IMAGE_NAME") or "" | |
| SUCCESS_SCORE_THRESHOLD = float(os.getenv("SUCCESS_SCORE_THRESHOLD", "0.8")) | |
| TEMPERATURE = float(os.getenv("SCAM_ENV_TEMPERATURE", "0.2")) | |
| MAX_TOKENS = int(os.getenv("SCAM_ENV_MAX_TOKENS", "120")) | |
| LLM_MAX_RETRIES = max(1, int(os.getenv("SCAM_ENV_LLM_MAX_RETRIES", "3"))) | |
| LLM_JSON_MODE = os.getenv("SCAM_ENV_LLM_JSON_MODE", "").lower() in ("1", "true", "yes") | |
| LLM_CACHE_ENABLED = os.getenv("SCAM_ENV_LLM_CACHE", "").lower() in ("1", "true", "yes") | |
| _LLM_ACTION_CACHE: dict[str, str] = {} | |
| ALLOWED_ACTIONS: tuple[str, ...] = tuple(a.value for a in Action) | |
| def log_start(task: str, env_name: str, model: str) -> None: | |
| print(f"[START] task={task} env={env_name} model={model}", flush=True) | |
| def log_step(step: int, action: str, reward: float, done: bool, error: str | None) -> None: | |
| err = "null" if error is None else _one_line(error) | |
| act = _one_line(action) | |
| print( | |
| f"[STEP] step={step} action={act} reward={reward:.2f} done={str(done).lower()} error={err}", | |
| flush=True, | |
| ) | |
| def log_end(success: bool, steps: int, score: float, rewards: list[float]) -> None: | |
| rewards_str = ",".join(f"{r:.2f}" for r in rewards) | |
| # Score already clamped to (0,1) open via finalize_episode_score / grade_episode. | |
| sc = float(score) | |
| print( | |
| f"[END] success={str(success).lower()} steps={steps} score={sc:.2f} rewards={rewards_str}", | |
| flush=True, | |
| ) | |
| def _one_line(s: str) -> str: | |
| return re.sub(r"\s+", " ", (s or "").strip())[:500] | |
| def parse_action_from_model_text(text: str) -> str: | |
| """Return exactly one allowed action string or raise ValueError.""" | |
| raw = (text or "").strip() | |
| if not raw: | |
| raise ValueError("empty model output") | |
| low = raw.lower() | |
| if low in ALLOWED_ACTIONS: | |
| return low | |
| for name in sorted(ALLOWED_ACTIONS, key=len, reverse=True): | |
| if re.search(rf"\b{re.escape(name)}\b", low): | |
| return name | |
| raise ValueError(f"unparseable_action:{raw!r}") | |
| SYSTEM_PROMPT = textwrap.dedent( | |
| """ | |
| You are a bank fraud analyst agent in a simulation. You receive a JSON observation each turn. | |
| You must output exactly one action from this list (plain text, no JSON, no quotes): | |
| {actions} | |
| Rules of thumb: | |
| - Terminal actions end the episode: ignore, flag_scam, block_sender, escalate_to_bank. | |
| - verify_sender and warn_user are non-terminal and gather/confirm risk before closing the case. | |
| - Use observation fields: message_text, conversation_history, sender_type, sender_verified, | |
| link_present, urgency_score, risk_score, risk_factors, steps_taken, max_episode_steps. | |
| Reply with exactly one line: the action name only. | |
| """ | |
| ).strip().format(actions=", ".join(ALLOWED_ACTIONS)) | |
| SYSTEM_PROMPT_JSON = textwrap.dedent( | |
| """ | |
| You are a bank fraud analyst agent in a simulation. You receive a JSON observation each turn. | |
| Respond with a single JSON object only, no markdown, no extra keys: | |
| {{"action": "<one_of_allowed>"}} | |
| where <one_of_allowed> is exactly one of: {actions} | |
| Rules of thumb: | |
| - Terminal actions end the episode: ignore, flag_scam, block_sender, escalate_to_bank. | |
| - verify_sender and warn_user are non-terminal when you need more certainty. | |
| """ | |
| ).strip().format(actions=", ".join(ALLOWED_ACTIONS)) | |
| _JSON_USER_HINT = 'Return only JSON: {"action": "..."}' | |
| _PLAIN_USER_HINT = "Choose the next action (one token from the allowed list)." | |
| def _llm_cache_key(observation: dict[str, Any], trace: list[str]) -> str: | |
| blob = json.dumps(observation, sort_keys=True, ensure_ascii=False) + "\n" + json.dumps(trace) | |
| return hashlib.sha256(blob.encode("utf-8")).hexdigest() | |
| def _action_from_json_content(text: str) -> str: | |
| obj = json.loads(text) | |
| act = obj.get("action") if isinstance(obj, dict) else None | |
| if act is None and isinstance(obj, dict): | |
| act = obj.get("Action") | |
| if act is None: | |
| raise ValueError("json_missing_action_key") | |
| return parse_action_from_model_text(str(act)) | |
| def get_llm_action(client: Any, observation: dict[str, Any], trace: list[str]) -> str: | |
| from openai import OpenAI | |
| assert isinstance(client, OpenAI) | |
| json_mode = LLM_JSON_MODE | |
| if LLM_CACHE_ENABLED: | |
| ck = _llm_cache_key(observation, trace) | |
| hit = _LLM_ACTION_CACHE.get(ck) | |
| if hit is not None: | |
| return hit | |
| system = SYSTEM_PROMPT_JSON if json_mode else SYSTEM_PROMPT | |
| hint = _JSON_USER_HINT if json_mode else _PLAIN_USER_HINT | |
| obs_json = json.dumps(observation, ensure_ascii=False) | |
| trace_repr = repr(trace) | |
| user = textwrap.dedent( | |
| """ | |
| Current observation (JSON): | |
| {obs} | |
| Actions taken so far: {tr} | |
| {hint} | |
| """ | |
| ).format(obs=obs_json, tr=trace_repr, hint=hint).strip() | |
| kwargs: dict[str, Any] = dict( | |
| model=MODEL_NAME, | |
| messages=[ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": user}, | |
| ], | |
| temperature=TEMPERATURE, | |
| max_tokens=MAX_TOKENS, | |
| stream=False, | |
| ) | |
| if json_mode: | |
| kwargs["response_format"] = {"type": "json_object"} | |
| last_err: Exception | None = None | |
| for attempt in range(LLM_MAX_RETRIES): | |
| try: | |
| completion = client.chat.completions.create(**kwargs) | |
| text = (completion.choices[0].message.content or "").strip() | |
| if json_mode: | |
| out = _action_from_json_content(text) | |
| else: | |
| out = parse_action_from_model_text(text) | |
| if LLM_CACHE_ENABLED: | |
| _LLM_ACTION_CACHE[_llm_cache_key(observation, trace)] = out | |
| return out | |
| except Exception as e: | |
| last_err = e | |
| if attempt >= LLM_MAX_RETRIES - 1: | |
| raise | |
| assert last_err is not None | |
| raise last_err | |
| def run_episode_protocol( | |
| *, | |
| task: str, | |
| seed: int | None, | |
| scenario_id: str | None, | |
| agent_mode: str, | |
| client: Any | None, | |
| model_label: str, | |
| ) -> None: | |
| max_steps = MAX_STEPS_BY_TASK[task] | |
| env = ScamEnv(task_id=task, max_steps=max_steps) | |
| rewards: list[float] = [] | |
| steps_taken = 0 | |
| success = False | |
| sid: str | None = None | |
| episode_error: str | None = None | |
| grader_val: float | None = None | |
| log_start(task=task, env_name=BENCHMARK, model=model_label) | |
| try: | |
| obs, info = env.reset(seed=seed, scenario_id=scenario_id) | |
| sid = info["scenario_id"] | |
| baseline = BaselineAgent() | |
| done = False | |
| step_n = 0 | |
| while not done: | |
| step_n += 1 | |
| action_str = "" | |
| try: | |
| if agent_mode == "llm": | |
| if client is None: | |
| raise RuntimeError("LLM agent selected but OpenAI client is not configured") | |
| action_str = get_llm_action(client, obs, env.action_trace) | |
| else: | |
| action_str = baseline.act(obs, env.action_trace) | |
| obs, reward, step_done, _info = env.step(action_str) | |
| rewards.append(reward) | |
| steps_taken = step_n | |
| log_step(step_n, action_str, reward, step_done, None) | |
| done = step_done | |
| except ValueError as e: | |
| episode_error = str(e) | |
| rewards.append(0.0) | |
| steps_taken = step_n | |
| log_step(step_n, action_str or "invalid_action", 0.0, True, episode_error) | |
| done = True | |
| break | |
| except Exception as e: | |
| episode_error = str(e) | |
| rewards.append(0.0) | |
| steps_taken = step_n | |
| log_step(step_n, action_str or "error", 0.0, True, episode_error) | |
| print(f"[DEBUG] step exception: {e}", file=sys.stderr, flush=True) | |
| done = True | |
| break | |
| if sid is not None and episode_error is None: | |
| grader_val = grade_episode(task, env.action_trace, sid, env.data_path) | |
| success = grader_val >= SUCCESS_SCORE_THRESHOLD | |
| else: | |
| success = False | |
| except Exception as e: | |
| success = False | |
| episode_error = str(e) | |
| print(f"[DEBUG] episode exception: {e}", file=sys.stderr, flush=True) | |
| finally: | |
| try: | |
| env.close() | |
| except Exception as e: | |
| print(f"[DEBUG] env.close() error: {e}", file=sys.stderr, flush=True) | |
| end_score = finalize_episode_score(grader_val) | |
| log_end(success=success, steps=steps_taken, score=end_score, rewards=rewards) | |
| sys.stdout.flush() | |
| if os.getenv("SCAM_ENV_DEBUG") and grader_val is not None and sid is not None: | |
| print(f"[DEBUG] grader_score={grader_val:.2f} scenario={sid}", file=sys.stderr, flush=True) | |
| def main() -> None: | |
| t0 = time.monotonic() | |
| max_runtime_s = float(os.getenv("SCAM_ENV_MAX_RUNTIME_SEC", "1140")) | |
| parser = argparse.ArgumentParser(description="Scam env — hackathon STDOUT protocol") | |
| _task_choices = list(CANONICAL_TASK_IDS) + list(TASK_ALIASES.keys()) | |
| parser.add_argument( | |
| "--task", | |
| choices=sorted(set(_task_choices)), | |
| default=os.getenv("SCAM_ENV_TASK", "easy"), | |
| ) | |
| parser.add_argument("--seed", type=int, default=int(os.getenv("SCAM_ENV_SEED", "42"))) | |
| parser.add_argument("--scenario-id", default=os.getenv("SCAM_ENV_SCENARIO_ID") or None) | |
| parser.add_argument("--episodes", type=int, default=int(os.getenv("SCAM_ENV_EPISODES", "1"))) | |
| parser.add_argument( | |
| "--all-tasks", | |
| action="store_true", | |
| help="Run one episode per canonical task (six tasks; pre-submission smoke test)", | |
| ) | |
| parser.add_argument( | |
| "--agent", | |
| choices=["llm", "baseline"], | |
| default=_default_agent_arg(), | |
| help="llm uses OpenAI client + API_BASE_URL/API_KEY (default llm if API_KEY or HF_TOKEN set)", | |
| ) | |
| args = parser.parse_args() | |
| client = None | |
| model_label = MODEL_NAME if args.agent == "llm" else "baseline-rules" | |
| if args.agent == "llm": | |
| try: | |
| from openai import OpenAI | |
| except ImportError as e: | |
| print("Install openai: pip install openai", file=sys.stderr) | |
| raise SystemExit(1) from e | |
| api_key = os.getenv("API_KEY") or os.getenv("HF_TOKEN") or "" | |
| if not api_key: | |
| print("API_KEY or HF_TOKEN required for --agent llm", file=sys.stderr) | |
| raise SystemExit(1) | |
| base_url = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1") | |
| client = OpenAI(base_url=base_url, api_key=api_key) | |
| if LOCAL_IMAGE_NAME: | |
| print(f"[DEBUG] LOCAL_IMAGE_NAME={LOCAL_IMAGE_NAME} (not used; in-process ScamEnv)", file=sys.stderr) | |
| if args.all_tasks: | |
| task_list = list(CANONICAL_TASK_IDS) | |
| episodes_per = 1 | |
| else: | |
| task_list = [args.task] | |
| episodes_per = args.episodes | |
| min_protocol_cycles = 3 | |
| n_tasks = len(task_list) | |
| if n_tasks * episodes_per < min_protocol_cycles: | |
| episodes_per = max(episodes_per, math.ceil(min_protocol_cycles / n_tasks)) | |
| for task in task_list: | |
| if time.monotonic() - t0 > max_runtime_s: | |
| print("[DEBUG] Stopping: SCAM_ENV_MAX_RUNTIME_SEC exceeded", file=sys.stderr) | |
| raise SystemExit(2) | |
| for ep in range(episodes_per): | |
| if time.monotonic() - t0 > max_runtime_s: | |
| print("[DEBUG] Stopping: SCAM_ENV_MAX_RUNTIME_SEC exceeded", file=sys.stderr) | |
| raise SystemExit(2) | |
| seed = args.seed + ep if args.seed is not None else None | |
| run_episode_protocol( | |
| task=task, | |
| seed=seed, | |
| scenario_id=args.scenario_id, | |
| agent_mode=args.agent, | |
| client=client, | |
| model_label=model_label, | |
| ) | |
| if __name__ == "__main__": | |
| main() | |