""" Recruitment Screening Environment — Baseline Inference Script. Runs three graded episodes (easy → medium → hard) using an LLM agent via the Hugging Face Router. Emits structured [START] / [STEP] / [END] logs to stdout in the exact format required by the hackathon evaluation harness. Prerequisites: 1. Build the Docker image: docker build -t recruitment-screening-env . 2. Set your HF token: export HF_TOKEN=hf_... 3. Run: uv run inference.py Or with overrides: DOCKER_IMAGE=recruitment-screening-env \\ MODEL_NAME=Qwen/Qwen2.5-72B-Instruct \\ HF_TOKEN=hf_... \\ uv run inference.py Environment variables: DOCKER_IMAGE Docker image name (default: recruitment-screening-env) API_BASE_URL LLM API endpoint (default: https://router.huggingface.co/v1) MODEL_NAME Model identifier (default: Qwen/Qwen2.5-72B-Instruct) HF_TOKEN Hugging Face token (required) ENV_BASE_URL Override env server URL (default: auto-start via Docker) MAX_STEPS Max tool calls per episode (default: 6) """ # /// script # requires-python = ">=3.10" # dependencies = [ # "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git@v0.2.3", # "openai>=1.0.0", # "requests>=2.31.0", # ] # /// from __future__ import annotations import json import os import subprocess import sys import time from datetime import datetime, timezone from openai import OpenAI # ── Configuration ──────────────────────────────────────────────────────────── DOCKER_IMAGE = os.environ.get("DOCKER_IMAGE", "recruitment-screening-env") API_BASE_URL = os.environ.get("API_BASE_URL", "https://router.huggingface.co/v1") MODEL_NAME = os.environ.get("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct") HF_TOKEN = os.environ.get("HF_TOKEN", "") ENV_BASE_URL = os.environ.get("ENV_BASE_URL", "") MAX_STEPS = int(os.environ.get("MAX_STEPS", "6")) # Three tasks — one per difficulty level TASKS = [ {"difficulty": "easy", "seed": 42}, {"difficulty": "medium", "seed": 17}, {"difficulty": "hard", "seed": 99}, ] # ── Structured log helpers ─────────────────────────────────────────────────── def _ts() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def log_start(task_name: str, env: str, model: str) -> None: print(f"[START] task={task_name} env={env} model={model}", flush=True) def log_step( step: int, action: str, reward: float, done: bool, error: str | None, ) -> None: done_str = "true" if done else "false" error_str = error if error else "null" print( f"[STEP] step={step} action={action} reward={reward:.2f} " f"done={done_str} error={error_str}", flush=True, ) def log_end( success: bool, steps: int, score: float, rewards: list[float], ) -> None: success_str = "true" if success else "false" rewards_str = ",".join(f"{r:.2f}" for r in rewards) print( f"[END] success={success_str} steps={steps} score={score:.2f} rewards={rewards_str}", flush=True, ) # ── Docker helpers ─────────────────────────────────────────────────────────── def start_container(image: str, port: int = 8000) -> str | None: try: r = subprocess.run( ["docker", "run", "-d", "-p", f"{port}:8000", image], capture_output=True, text=True, timeout=30, ) if r.returncode != 0: # print(f"[WARN] docker run failed: {r.stderr.strip()}", file=sys.stderr) return None cid = r.stdout.strip() # print(f"[INFO] Container started: {cid[:12]}", file=sys.stderr) return cid except Exception as exc: # print(f"[WARN] Could not start container: {exc}", file=sys.stderr) return None def wait_for_health(base_url: str, timeout: int = 90) -> bool: import urllib.request, urllib.error deadline = time.time() + timeout while time.time() < deadline: try: with urllib.request.urlopen(f"{base_url}/health", timeout=3) as r: if r.status == 200: return True except Exception: pass time.sleep(2) return False def stop_container(cid: str) -> None: for cmd in (["docker", "stop", cid], ["docker", "rm", cid]): try: subprocess.run(cmd, capture_output=True, timeout=15) except Exception: pass # ── LLM Agent ──────────────────────────────────────────────────────────────── SYSTEM_PROMPT = """\ You are an expert recruitment screening agent for a Financial Technology graduate programme. You have exactly THREE tools — use them in this order every episode: 1. get_task() → read the candidate package 2. get_evaluation_criteria() → read the rubric (optional but recommended) 3. submit_decision(json_str) → submit your decision (ends the episode) ═══ SCREENING RULES ═══ MUST-HAVE (automatic No Hire if any violated): • No visa sponsorship required • Degree completed January 2024 – September 2026 • Normalized GPA ≥ 3.30 / 4.0 [formula: normalized = (raw / max_scale) × 4.0] • Cover letter is specific and genuine (no generic buzzwords) MUST-NOT-HAVE (automatic No Hire): • Work experience > 24 months counted AFTER the latest degree start date → EXCLUDE any experience that happened BEFORE the latest degree began → If dual degree: use the MOST RECENT degree as the reference point • Significant conflict between application_data and resume_text (date diff > 3 months) OVERALL RATING CODES: "4" = Strong Hire (all must-haves + leadership + technical + initiatives ALL rated 4) "3" = Hire (all must-haves met, no disqualifiers) "2" = No Hire (any must-have violated OR disqualifier present) ACADEMIC CODES (apply normalization FIRST): "4" = normalized_gpa ≥ 3.60 "3" = 3.40 ≤ normalized_gpa < 3.60 "2" = 3.30 ≤ normalized_gpa < 3.40 "1" = normalized_gpa < 3.30 → also triggers automatic No Hire on Overall Rating WORK EXPERIENCE CODES: "4" = ≤ 12 months eligible, high-quality firms "3" = ≤ 12 months eligible, relevant "2" = no post-degree experience "1" = > 24 months eligible → automatic No Hire COVER LETTER: "1" = passes (specific, genuine, references this company/role) "0" = fails (buzzwords, generic praise, could apply to any firm) ═══ DECISION JSON FORMAT ═══ { "feedback_responses": { "Overall Rating": "<2|3|4>", "Academic Performance": "<1|2|3|4>", "Work Experience": "<1|2|3|4>", "Interest in Finance / Technology": "<1|2|3|4>", "CV Quality": "<1|2|3|4>", "Passes Cover Letter / Why Us Check": "<0|1>" }, "justifications": { "Overall Rating": " -