Spaces:
Sleeping
Sleeping
| """ | |
| 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": "<code> - <Label>: <evidence citing specific numbers>", | |
| "Academic Performance": "<code>: raw=X/Y β normalized=Z/4.0" | |
| }, | |
| "executive_summary": [ | |
| "Fact 1 about candidate with specific data", | |
| "Fact 2 about candidate with specific data" | |
| ] | |
| } | |
| IMPORTANT: Use only numeric codes in feedback_responses β never text labels.\ | |
| """ | |
| TOOLS = [ | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "get_task", | |
| "description": "Retrieve the current candidate screening task (resume, application, JD, template).", | |
| "parameters": {"type": "object", "properties": {}, "required": []}, | |
| }, | |
| }, | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "get_evaluation_criteria", | |
| "description": "Retrieve the evaluation rubric with GPA normalization, thresholds, and rules.", | |
| "parameters": {"type": "object", "properties": {}, "required": []}, | |
| }, | |
| }, | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "submit_decision", | |
| "description": "Submit your hiring decision JSON string. Ends the episode and returns reward.", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "decision_json": { | |
| "type": "string", | |
| "description": ( | |
| "JSON string with feedback_responses, justifications, " | |
| "and executive_summary." | |
| ), | |
| } | |
| }, | |
| "required": ["decision_json"], | |
| }, | |
| }, | |
| }, | |
| ] | |
| # ββ Episode runner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_episode(env, llm: OpenAI, task_name: str, difficulty: str, seed: int) -> float: | |
| """Run one full screening episode. Returns final reward (0.0β1.0).""" | |
| log_start(task_name=task_name, env="recruitment_screening_env", model=MODEL_NAME) | |
| # Reset environment for this task | |
| env.reset(difficulty=difficulty, seed=seed) | |
| messages: list[dict] = [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| { | |
| "role": "user", | |
| "content": ( | |
| f"New screening task β difficulty: {difficulty}, seed: {seed}. " | |
| "Start by calling get_task() to read the candidate profile." | |
| ), | |
| }, | |
| ] | |
| final_reward = 0.0 | |
| step = 0 | |
| done = False | |
| step_rewards: list[float] = [] | |
| last_error: str | None = None | |
| while step < MAX_STEPS and not done: | |
| step += 1 | |
| last_error = None | |
| response = llm.chat.completions.create( | |
| model=MODEL_NAME, | |
| messages=messages, | |
| tools=TOOLS, | |
| tool_choice="auto", | |
| temperature=0.0, | |
| max_tokens=2048, | |
| ) | |
| choice = response.choices[0] | |
| assistant_msg: dict = { | |
| "role": "assistant", | |
| "content": choice.message.content or "", | |
| } | |
| if choice.message.tool_calls: | |
| assistant_msg["tool_calls"] = [ | |
| { | |
| "id": tc.id, | |
| "type": "function", | |
| "function": { | |
| "name": tc.function.name, | |
| "arguments": tc.function.arguments, | |
| }, | |
| } | |
| for tc in choice.message.tool_calls | |
| ] | |
| messages.append(assistant_msg) | |
| if not choice.message.tool_calls: | |
| # Agent stopped calling tools β episode ends | |
| step_rewards.append(0.0) | |
| log_step(step=step, action="(no tool call)", reward=0.0, done=True, error=None) | |
| done = True | |
| break | |
| for tc in choice.message.tool_calls: | |
| tool_name = tc.function.name | |
| try: | |
| tool_args = json.loads(tc.function.arguments or "{}") | |
| except json.JSONDecodeError: | |
| tool_args = {} | |
| # Build a compact action string for the log | |
| args_repr = ", ".join( | |
| f"{k}={repr(v)[:80]}" for k, v in tool_args.items() | |
| ) | |
| action_str = f"{tool_name}({args_repr})" | |
| # Execute tool on the environment | |
| try: | |
| result_str = env.call_tool(tool_name, **tool_args) | |
| except Exception as exc: | |
| last_error = str(exc) | |
| result_str = json.dumps({"error": last_error}) | |
| # Track reward if this was a decision submission | |
| step_reward = 0.0 | |
| if tool_name == "submit_decision": | |
| try: | |
| result_data = json.loads(result_str) | |
| step_reward = float(result_data.get("reward", 0.0)) | |
| final_reward = step_reward | |
| done = bool(result_data.get("done", True)) | |
| if "error" in result_data: | |
| last_error = result_data["error"] | |
| except Exception as exc: | |
| last_error = str(exc) | |
| done = True | |
| step_rewards.append(step_reward) | |
| log_step( | |
| step=step, | |
| action=action_str, | |
| reward=step_reward, | |
| done=done, | |
| error=last_error, | |
| ) | |
| # Feed result back to LLM | |
| messages.append({ | |
| "role": "tool", | |
| "tool_call_id": tc.id, | |
| "content": result_str, | |
| }) | |
| log_end( | |
| success=(final_reward > 0.0), | |
| steps=step, | |
| score=final_reward, | |
| rewards=step_rewards, | |
| ) | |
| return final_reward | |
| # ββ Entry point βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main() -> None: | |
| if not HF_TOKEN: | |
| # print("[ERROR] HF_TOKEN is required. Set it via: export HF_TOKEN=hf_...", file=sys.stderr) | |
| sys.exit(1) | |
| # ββ Start environment server βββββββββββββββββββββββββββββββββββββββββββββ | |
| container_id = None | |
| base_url = ENV_BASE_URL | |
| if not base_url: | |
| container_id = start_container(DOCKER_IMAGE) | |
| base_url = "http://localhost:8000" | |
| if not wait_for_health(base_url, timeout=90): | |
| # print( | |
| # f"[ERROR] Server at {base_url} did not become healthy within 90 seconds.", | |
| # file=sys.stderr, | |
| # ) | |
| if container_id: | |
| stop_container(container_id) | |
| sys.exit(1) | |
| # print(f"[INFO] Server healthy at {base_url}", file=sys.stderr) | |
| # ββ Connect LLM client βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| llm = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN) | |
| # ββ Import environment client ββββββββββββββββββββββββββββββββββββββββββββ | |
| # Import here so PYTHONPATH is resolved after Docker is up | |
| from client import RecruitmentEnv | |
| # ββ Run all episodes βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| total_reward = 0.0 | |
| results: list[dict] = [] | |
| with RecruitmentEnv(base_url=base_url).sync() as env: | |
| for i, cfg in enumerate(TASKS): | |
| task_id = f"task_{i + 1}_{cfg['difficulty']}" | |
| try: | |
| reward = run_episode( | |
| env, llm, | |
| task_name=task_id, | |
| difficulty=cfg["difficulty"], | |
| seed=cfg["seed"], | |
| ) | |
| except Exception as exc: | |
| log_end( | |
| success=False, | |
| steps=0, | |
| score=0.0, | |
| rewards=[], | |
| ) | |
| reward = 0.0 | |
| total_reward += reward | |
| results.append({"task_id": task_id, "difficulty": cfg["difficulty"], "reward": reward}) | |
| # ββ Cleanup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if container_id: | |
| stop_container(container_id) | |
| # print(f"[INFO] Container {container_id[:12]} stopped.", file=sys.stderr) | |
| if __name__ == "__main__": | |
| main() | |