nik-55/medchain-openenv-hackathon
0
1"""2MedChain Env — Inference Script3================================4Runs all tasks sequentially and reports scores.5 6MANDATORY environment variables:7 API_BASE_URL The API endpoint for the LLM8 MODEL_NAME / MODEL The model identifier for inference9 HF_TOKEN / API_KEY Your Hugging Face / API key10 11OPTIONAL environment variables:12 LOCAL_IMAGE_NAME Docker image tag; if set, Docker is used (highest priority).13 BASE_URL URL of a running MedChain server (e.g. an HF Space).14 TASK_NAMES Comma-separated list of tasks to run.15 Default: orientation_ward,single_ward_stable,multi_ward_seasonal16 LOG_LEVEL INFO (default) or DEBUG (writes a timestamped log to logs/)17 18Environment connection priority (per task):19 1. LOCAL_IMAGE_NAME → spin up a Docker container20 2. BASE_URL → connect directly to that server URL21 3. Default HF Space → https://nik-55-medchain-openenv-hackathon.hf.space22 4. Default image → nik-55_medchain-openenv (last-resort Docker fallback)23 24STDOUT FORMAT25- The script emits exactly three line types to stdout, in this order:26 27 [START] task=<task_name> env=medchain model=<model_name>28 [STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>29 [END] success=<true|false> steps=<n> score=<0.000> rewards=<r1,r2,...,rn>30 31 Rules:32 - One [START] line at episode begin.33 - One [STEP] line per step, immediately after env.step() returns.34 - One [END] line after env.close(), always emitted (even on exception).35 - reward and rewards are formatted to 2 decimal places; score to 3.36 - done and success are lowercase booleans: true or false.37 - error is the raw error string, or null if none.38 - All fields on a single line with no newlines within a line.39"""40 41import asyncio42import json43import logging44import os45import sys46import time47import urllib.request48from datetime import datetime49from pathlib import Path50from typing import Any, Dict, List, Optional51 52from openai import BadRequestError, OpenAI, RateLimitError53 54sys.path.insert(0, str(Path(__file__).parent.parent))55 56from medchain_env import CallToolAction, MedchainEnv57 58LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()59 60_log_fmt = logging.Formatter(61 "[%(levelname)s] %(asctime)s %(message)s", datefmt="%H:%M:%S"62)63_stream_handler = logging.StreamHandler(sys.stdout)64_stream_handler.setFormatter(_log_fmt)65_handlers: list = [_stream_handler]66 67if LOG_LEVEL == "DEBUG":68 os.makedirs("logs", exist_ok=True)69 _log_filename = datetime.now().strftime("logs/inference_%Y%m%d_%H%M%S.log")70 _file_handler = logging.FileHandler(_log_filename)71 _file_handler.setFormatter(_log_fmt)72 _handlers.append(_file_handler)73 print(f"[DEBUG] Logging to file: {_log_filename}", flush=True)74 75logging.basicConfig(level=logging.WARNING, handlers=_handlers)76log = logging.getLogger(__name__)77log.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))78 79API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")80API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")81MODEL_NAME = os.getenv("MODEL_NAME") or os.getenv("MODEL", "openai/gpt-oss-120b:groq")82SMALL_MODEL = "openai/gpt-oss-20b:groq"83 84LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")85BASE_URL = os.getenv("BASE_URL")86DEFAULT_BASE_URL = "https://nik-55-medchain-openenv-hackathon.hf.space"87DEFAULT_IMAGE_NAME = "nik-55_medchain-openenv"88 89# All available tasks:90# "orientation_ward", "single_ward_stable", "multi_ward_seasonal", "hospital_network_crisis"91_task_names_env = os.getenv(92 "TASK_NAMES",93 "orientation_ward,single_ward_stable,multi_ward_seasonal",94)95TASKS = [t.strip() for t in _task_names_env.split(",") if t.strip()]96 97# Per-task step limits (actions_per_shift × max_days + generous error headroom)98MAX_STEPS_PER_TASK = {99 "orientation_ward": 30, # 8 actions × 2 days + headroom100 "single_ward_stable": 45, # 10 actions × 3 days + headroom101 "multi_ward_seasonal": 75, # 14 actions × 6 days + headroom102 "hospital_network_crisis": 180, # 18 actions × 12 days + headroom103}104MAX_TOKENS = 6000105TEMPERATURE = 0.1106MAX_CONSECUTIVE_ERRORS = 5107SLEEP_BETWEEN_STEPS = 2108SHIFT_HISTORY_KEEP = 6109 110# 429 rate-limit handling111_429_WINDOW = 60 # seconds to track 429 count112_429_DOWNGRADE_THRESHOLD = 3 # downgrade model after this many 429s in window113_429_BASE_BACKOFF = 5 # initial backoff seconds114_429_MAX_BACKOFF = 30 # cap backoff to stay within 20-min budget115 116BENCHMARK = "medchain"117 118SYSTEM_PROMPT = """You are an experienced hospital supply chain manager operating a legacy ERP system.119Your goal is to maintain adequate medical supplies across all locations while controlling costs.120 121CRITICAL — ACTION BUDGET: You have a strictly limited number of actions per shift.122Budget does NOT roll over. Unspent actions are lost at end_shift().123 124Recommended budget allocation (highest priority first):125 1. read_inbox() — ALWAYS do this first to catch urgent alerts126 2. query_erp(table='inventory') — check current stock levels across all locations127 3. submit_po(...) — place orders for items below safety stock (PRIORITY)128 4. end_shift() — call this when budget is exhausted OR tasks are done129 130Query tools (query_erp expiry/pipeline, query_forecast, query_supplier) are LOW PRIORITY.131Only use them if you have budget remaining AFTER placing critical orders.132 133MANDATORY RULES:134- If you receive "Action budget exhausted" → call end_shift() as your VERY NEXT action.135 Do NOT call any other tool. The budget cannot be restored until end_shift() is called.136- Order early: factor in lead times. If lead time is 2 days, order today to avoid stockout in 2 days.137- Expedited orders require file_justification(ticket_id=...) with a real clinical reason.138- FEFO: oldest stock consumed first — check expiry and rotate perishables proactively.139- Recalls: quarantine the recalled lot immediately, then order a replacement.140- MCI events: pre-emptive ordering beats reactive ordering. Order extra blood/critical supplies NOW.141 142Safety stock target: aim for at least (lead_time + 1) × daily_demand units on hand.143 144When calling tools, use the EXACT parameter names shown in the tool descriptions.145"""146 147 148def log_start(task: str, model: str) -> None:149 print(f"[START] task={task} env={BENCHMARK} model={model}", flush=True)150 151 152def log_step(153 step: int, action: str, reward: float, done: bool, error: Optional[str]154) -> None:155 error_val = error if error else "null"156 print(157 f"[STEP] step={step} action={action} reward={reward:.2f} done={str(done).lower()} error={error_val}",158 flush=True,159 )160 161 162def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:163 rewards_str = ",".join(f"{r:.2f}" for r in rewards)164 print(165 f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}",166 flush=True,167 )168 169 170def _tools_to_openai_format(tools) -> List[dict]:171 """Convert MCP tools to OpenAI function-calling format."""172 openai_tools = []173 for tool in tools:174 properties = {}175 required = []176 if tool.input_schema and "properties" in tool.input_schema:177 for name, schema in tool.input_schema["properties"].items():178 properties[name] = {179 "type": schema.get("type", "string"),180 "description": schema.get("description", ""),181 }182 required = tool.input_schema.get("required", [])183 openai_tools.append(184 {185 "type": "function",186 "function": {187 "name": tool.name,188 "description": tool.description or "",189 "parameters": {190 "type": "object",191 "properties": properties,192 "required": required,193 },194 },195 }196 )197 log.debug("Tool registered: %s (required=%s)", tool.name, required)198 return openai_tools199 200 201def _make_shift_summary(shift_day: int, end_shift_result: str) -> str:202 """Build a compact summary of a completed shift for the context window."""203 lines = []204 for line in (end_shift_result or "").splitlines():205 stripped = line.strip()206 if stripped and any(207 kw in stripped208 for kw in [209 "DEMAND:",210 "FULFILLED:",211 "DELIVERIES:",212 "EXPIRED:",213 "Spend:",214 "Waste",215 "Service Level",216 "END OF SHIFT",217 "Day ",218 "Score:",219 ]220 ):221 lines.append(stripped)222 if len(lines) >= 35:223 break224 summary_body = "\n".join(lines) if lines else (end_shift_result or "")[:900]225 return f"[SHIFT DAY {shift_day} SUMMARY]\n{summary_body}"226 227 228async def _is_url_reachable(url: str, timeout: float = 30.0) -> bool:229 loop = asyncio.get_event_loop()230 231 def _check() -> bool:232 try:233 data = b"{}"234 req = urllib.request.Request(235 url.rstrip("/") + "/reset",236 data=data,237 headers={"Content-Type": "application/json"},238 method="POST",239 )240 with urllib.request.urlopen(req, timeout=timeout) as resp:241 return resp.status == 200242 except Exception:243 return False244 245 try:246 return await loop.run_in_executor(None, _check)247 except Exception:248 return False249 250 251async def create_env(task_name: str) -> MedchainEnv:252 if LOCAL_IMAGE_NAME:253 log.info("Using Docker image '%s' for task '%s'", LOCAL_IMAGE_NAME, task_name)254 return await MedchainEnv.from_docker_image(LOCAL_IMAGE_NAME)255 256 if BASE_URL:257 log.info("Using BASE_URL '%s' for task '%s'", BASE_URL, task_name)258 env = MedchainEnv(base_url=BASE_URL)259 await env.connect()260 return env261 262 log.info("Probing default URL: %s", DEFAULT_BASE_URL)263 if await _is_url_reachable(DEFAULT_BASE_URL):264 log.info("Default URL reachable; connecting: %s", DEFAULT_BASE_URL)265 env = MedchainEnv(base_url=DEFAULT_BASE_URL)266 await env.connect()267 return env268 269 log.warning(270 "Default URL '%s' not reachable. Falling back to Docker image: %s",271 DEFAULT_BASE_URL,272 DEFAULT_IMAGE_NAME,273 )274 try:275 return await MedchainEnv.from_docker_image(DEFAULT_IMAGE_NAME)276 except Exception as docker_err:277 raise RuntimeError(278 f"All environment connection methods failed for task '{task_name}'.\n"279 f" 1. LOCAL_IMAGE_NAME: not set\n"280 f" 2. BASE_URL: not set\n"281 f" 3. Default URL ({DEFAULT_BASE_URL}): not reachable\n"282 f" 4. Default Docker image ({DEFAULT_IMAGE_NAME}): {docker_err}\n"283 "\nFix: set LOCAL_IMAGE_NAME (Docker image name) or BASE_URL (running server URL), "284 "or ensure Docker is running with the image available."285 ) from docker_err286 287 288async def run_task_episode(289 env: MedchainEnv,290 client: OpenAI,291 tools: List[dict],292 task_name: str,293) -> Dict[str, Any]:294 """Run one episode of a task and return the result."""295 tool_names = [t["function"]["name"] for t in tools]296 max_steps = MAX_STEPS_PER_TASK.get(task_name, 160)297 298 obs = await env.reset(task=task_name)299 obs = obs.observation300 dashboard = obs.metadata.get("dashboard", "")301 302 log_start(task=task_name, model=MODEL_NAME)303 log.debug(304 "[%s] Episode started. Tools: %s max_steps=%d", task_name, tool_names, max_steps305 )306 307 chat_history: List[dict] = [308 {"role": "system", "content": SYSTEM_PROMPT},309 {310 "role": "user",311 "content": f"Your shift has started. Current dashboard:\n\n{dashboard}",312 },313 ]314 315 step_count = 0316 final_reward = 0.0317 done = obs.done318 consecutive_errors = 0319 rewards: List[float] = []320 past_shift_summaries: List[str] = []321 current_shift_messages: List[dict] = []322 323 # 429 rate-limit tracking324 active_model = MODEL_NAME325 rate_limit_times: List[float] = []326 backoff_count = 0327 328 episode_start = time.monotonic()329 330 while not done and step_count < max_steps:331 step_count += 1332 log.debug(333 "[%s] Step %d/%d — %d messages in context",334 task_name,335 step_count,336 MAX_STEPS_PER_TASK[task_name],337 len(chat_history),338 )339 340 try:341 response = client.chat.completions.create(342 model=active_model,343 messages=chat_history,344 tools=tools,345 tool_choice="required",346 max_completion_tokens=MAX_TOKENS,347 temperature=TEMPERATURE,348 )349 consecutive_errors = 0350 backoff_count = 0351 except RateLimitError as e:352 now = time.monotonic()353 rate_limit_times.append(now)354 # Purge timestamps outside the tracking window355 rate_limit_times[:] = [356 t for t in rate_limit_times if now - t <= _429_WINDOW357 ]358 359 backoff_count += 1360 backoff_secs = min(361 _429_BASE_BACKOFF * (2 ** (backoff_count - 1)), _429_MAX_BACKOFF362 )363 log.warning(364 "[%s] Step %d — 429 RateLimitError (count=%d in last %ds, backoff=%.1fs): %s",365 task_name,366 step_count,367 len(rate_limit_times),368 _429_WINDOW,369 backoff_secs,370 e,371 )372 373 if (374 len(rate_limit_times) >= _429_DOWNGRADE_THRESHOLD375 and active_model != SMALL_MODEL376 ):377 active_model = SMALL_MODEL378 log.warning(379 "[%s] Step %d — Downgrading model to %s due to repeated 429 errors",380 task_name,381 step_count,382 SMALL_MODEL,383 )384 385 await asyncio.sleep(backoff_secs)386 continue387 except BadRequestError as e:388 consecutive_errors += 1389 log.warning(390 "[%s] Step %d — BadRequestError (%d/%d): %s",391 task_name,392 step_count,393 consecutive_errors,394 MAX_CONSECUTIVE_ERRORS,395 e,396 )397 if consecutive_errors >= MAX_CONSECUTIVE_ERRORS:398 log.error(399 "[%s] Aborting after %d consecutive errors",400 task_name,401 MAX_CONSECUTIVE_ERRORS,402 )403 break404 405 err_msg = (406 f"Your previous tool call was rejected with an error:\n{e}\n\n"407 "Please retry with a valid tool call. If your budget is exhausted, call end_shift()."408 )409 chat_history.append({"role": "user", "content": err_msg})410 current_shift_messages.append({"role": "user", "content": err_msg})411 continue412 413 message = response.choices[0].message414 log.debug(415 "[%s] Step %d — finish_reason=%s tool_calls=%d",416 task_name,417 step_count,418 response.choices[0].finish_reason,419 len(message.tool_calls) if message.tool_calls else 0,420 )421 422 if not message.tool_calls:423 log.warning(424 "[%s] Step %d — no tool_calls in response; falling back to end_shift",425 task_name,426 step_count,427 )428 tool_name = "end_shift"429 tool_args = {}430 tool_call_id = "fallback"431 else:432 tc = message.tool_calls[0]433 tool_name = tc.function.name434 tool_call_id = tc.id435 try:436 tool_args = json.loads(tc.function.arguments)437 except (json.JSONDecodeError, AttributeError):438 log.warning(439 "[%s] Step %d — failed to parse tool arguments: %r",440 task_name,441 step_count,442 tc.function.arguments,443 )444 tool_args = {}445 446 if tool_name not in tool_names:447 log.warning(448 "[%s] Step %d — unknown tool %r; falling back to end_shift",449 task_name,450 step_count,451 tool_name,452 )453 tool_name = "end_shift"454 tool_args = {}455 456 log.debug(457 "[%s] Step %d — calling %s(%s)", task_name, step_count, tool_name, tool_args458 )459 460 assistant_msg = {461 "role": "assistant",462 "content": None,463 "tool_calls": [464 {465 "id": tool_call_id,466 "type": "function",467 "function": {468 "name": tool_name,469 "arguments": json.dumps(tool_args),470 },471 }472 ],473 }474 chat_history.append(assistant_msg)475 current_shift_messages.append(assistant_msg)476 477 action = CallToolAction(tool_name=tool_name, arguments=tool_args)478 step_result = await env.step(action)479 obs = step_result.observation480 done = obs.done481 482 result_text = obs.metadata.get("tool_result", str(obs.metadata))483 step_reward = obs.reward or 0.0484 step_error: Optional[str] = None485 486 if "EPISODE COMPLETE" in (result_text or ""):487 log.info("[%s] Step %d — episode complete detected", task_name, step_count)488 done = True489 490 if obs.reward is not None and obs.reward > 0:491 final_reward = obs.reward492 493 rewards.append(step_reward)494 action_str = f"{tool_name}({json.dumps(tool_args)})"495 log_step(496 step=step_count,497 action=action_str,498 reward=step_reward,499 done=done,500 error=step_error,501 )502 503 tool_result_msg = {504 "role": "tool",505 "tool_call_id": tool_call_id,506 "content": result_text[:2700] if result_text else "OK",507 }508 chat_history.append(tool_result_msg)509 current_shift_messages.append(tool_result_msg)510 511 # Budget exhausted — inject directive and skip sleep512 if "Action budget exhausted" in (result_text or ""):513 log.info(514 "[%s] Step %d — budget exhausted; injecting end_shift directive",515 task_name,516 step_count,517 )518 directive = (519 "SYSTEM ALERT: Your action budget for this shift is fully exhausted. "520 "You MUST call end_shift() as your very next action. "521 "Every other tool call will fail until you do."522 )523 chat_history.append({"role": "user", "content": directive})524 current_shift_messages.append({"role": "user", "content": directive})525 continue526 527 await asyncio.sleep(SLEEP_BETWEEN_STEPS)528 529 # Shift ended — summarise and prune context, then set up next shift530 if (531 tool_name == "end_shift"532 and "END OF SHIFT" in (result_text or "")533 and not done534 ):535 shift_day = "?"536 for part in (result_text or "").split():537 if part.isdigit():538 shift_day = part539 break540 541 shift_summary = _make_shift_summary(shift_day, result_text or "")542 log.debug("[%s] Shift %s summary:\n%s", task_name, shift_day, shift_summary)543 past_shift_summaries.append(shift_summary)544 log.info(545 "[%s] Step %d — shift %s ended; pruning context (%d summaries)",546 task_name,547 step_count,548 shift_day,549 len(past_shift_summaries),550 )551 552 summaries_msg = {553 "role": "user",554 "content": "COMPLETED SHIFT SUMMARIES:\n\n"555 + "\n\n".join(past_shift_summaries),556 }557 trimmed = (558 current_shift_messages[-SHIFT_HISTORY_KEEP:]559 if len(current_shift_messages) > SHIFT_HISTORY_KEEP560 else list(current_shift_messages)561 )562 # Remove budget-exhausted directives so they don't bleed into the next shift563 trimmed = [564 m565 for m in trimmed566 if "Action budget exhausted" not in (m.get("content") or "")567 ]568 # Strip orphaned leading tool-response messages to avoid API errors569 while trimmed and trimmed[0].get("role") == "tool":570 log.debug(571 "[%s] Dropping orphaned leading tool msg (tool_call_id=%s)",572 task_name,573 trimmed[0].get("tool_call_id"),574 )575 trimmed = trimmed[1:]576 577 chat_history = (578 [579 {"role": "system", "content": SYSTEM_PROMPT},580 summaries_msg,581 ]582 + trimmed583 + [584 {585 "role": "user",586 "content": "Your next shift has begun. The dashboard is shown above in the last tool result. "587 "Continue managing the supply chain.",588 },589 ]590 )591 current_shift_messages = []592 593 episode_duration = time.monotonic() - episode_start594 log.info(595 "[%s] Episode finished. steps=%d done=%s final_reward=%.4f",596 task_name,597 step_count,598 done,599 final_reward,600 )601 log.debug("[%s] Episode duration: %.1fs", task_name, episode_duration)602 return {603 "task": task_name,604 "reward": final_reward,605 "steps": step_count,606 "done": done,607 "rewards": rewards,608 "duration": episode_duration,609 }610 611 612async def async_main() -> None:613 if not API_KEY:614 raise SystemExit("HF_TOKEN or API_KEY must be set.")615 if not MODEL_NAME:616 raise SystemExit("MODEL_NAME or MODEL must be set.")617 618 log.info("Starting. API_BASE_URL=%s MODEL_NAME=%s", API_BASE_URL, MODEL_NAME)619 log.info("Tasks: %s", TASKS)620 621 client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)622 results = []623 script_start = time.monotonic()624 625 for task_name in TASKS:626 log.info("Launching task: %s", task_name)627 task_start = time.monotonic()628 env = await create_env(task_name)629 final_reward = 0.0630 success = False631 steps = 0632 step_rewards: List[float] = []633 try:634 mcp_tools = await env.list_tools()635 tools = _tools_to_openai_format(mcp_tools)636 log.info("[%s] %d tools discovered", task_name, len(tools))637 638 result = await run_task_episode(env, client, tools, task_name)639 results.append(result)640 final_reward = result["reward"]641 steps = result["steps"]642 success = result["done"]643 step_rewards = result["rewards"]644 log.info(645 "[%s] Task complete: reward=%.4f steps=%d",646 task_name,647 final_reward,648 steps,649 )650 except Exception as e:651 log.error("[%s] Task failed with exception: %s", task_name, e)652 finally:653 try:654 await env.close()655 except Exception as e:656 log.error("[%s] env.close() failed: %s", task_name, e)657 log_end(658 success=success, steps=steps, score=final_reward, rewards=step_rewards659 )660 log.debug(661 "[%s] Total task wall time: %.1fs",662 task_name,663 time.monotonic() - task_start,664 )665 666 total_duration = time.monotonic() - script_start667 if results:668 avg_reward = sum(r["reward"] for r in results) / len(results)669 log.info("All tasks complete. avg_reward=%.4f", avg_reward)670 log.debug("Overall script duration: %.1fs", total_duration)671 672 673def main() -> None:674 asyncio.run(async_main())675 676 677if __name__ == "__main__":678 main()679 