Spaces:
Paused
Paused
Upload folder using huggingface_hub
Browse files- __init__.py +4 -0
- notebooks/crisisinbox_grpo_connected.ipynb +121 -7
- rewards.py +107 -0
- server/crisis_inbox_environment.py +3 -61
__init__.py
CHANGED
|
@@ -8,9 +8,11 @@ from openenv.core.env_server.mcp_types import CallToolAction, ListToolsAction
|
|
| 8 |
try:
|
| 9 |
from .client import CrisisInboxEnv
|
| 10 |
from .models import Channel, Message, Urgency
|
|
|
|
| 11 |
except ImportError:
|
| 12 |
from client import CrisisInboxEnv
|
| 13 |
from models import Channel, Message, Urgency
|
|
|
|
| 14 |
|
| 15 |
__all__ = [
|
| 16 |
"CrisisInboxEnv",
|
|
@@ -19,4 +21,6 @@ __all__ = [
|
|
| 19 |
"Message",
|
| 20 |
"Channel",
|
| 21 |
"Urgency",
|
|
|
|
|
|
|
| 22 |
]
|
|
|
|
| 8 |
try:
|
| 9 |
from .client import CrisisInboxEnv
|
| 10 |
from .models import Channel, Message, Urgency
|
| 11 |
+
from .rewards import calculate_reward, tone_multiplier
|
| 12 |
except ImportError:
|
| 13 |
from client import CrisisInboxEnv
|
| 14 |
from models import Channel, Message, Urgency
|
| 15 |
+
from rewards import calculate_reward, tone_multiplier
|
| 16 |
|
| 17 |
__all__ = [
|
| 18 |
"CrisisInboxEnv",
|
|
|
|
| 21 |
"Message",
|
| 22 |
"Channel",
|
| 23 |
"Urgency",
|
| 24 |
+
"calculate_reward",
|
| 25 |
+
"tone_multiplier",
|
| 26 |
]
|
notebooks/crisisinbox_grpo_connected.ipynb
CHANGED
|
@@ -3,13 +3,21 @@
|
|
| 3 |
{
|
| 4 |
"cell_type": "markdown",
|
| 5 |
"id": "ym4tunggrm",
|
| 6 |
-
"source": "# CrisisInbox GRPO Training (Connected to HF Space)\n\nTrain a small LLM (Qwen2.5-0.5B) to triage crisis inbox messages using Group Relative Policy Optimization.\n\n**This notebook connects to the live CrisisInbox environment** deployed on HuggingFace Spaces at `https://eptan-crisis-inbox.hf.space` to collect training episodes in real-time, then trains the model using GRPO.\n\n**Stack:** HF TRL + PEFT (LoRA on full bf16 model
|
| 7 |
"metadata": {}
|
| 8 |
},
|
| 9 |
{
|
| 10 |
"cell_type": "code",
|
| 11 |
"id": "p0j1w7pr7ib",
|
| 12 |
-
"source":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"metadata": {},
|
| 14 |
"execution_count": null,
|
| 15 |
"outputs": []
|
|
@@ -39,7 +47,7 @@
|
|
| 39 |
{
|
| 40 |
"cell_type": "code",
|
| 41 |
"id": "pmbt9gcp9hb",
|
| 42 |
-
"source": "import json\nimport time as _time\nfrom openenv.core.mcp_client import MCPToolClient\n\nBASE_URL = \"https://eptan-crisis-inbox.hf.space\"\n\n# Wake up the HF Space (may be sleeping) and verify connectivity\nprint(\"Connecting to HF Space (may take a moment if cold-starting)...\")\nfor attempt in range(3):\n try:\n with MCPToolClient(base_url=BASE_URL, connect_timeout_s=60.0).sync() as env:\n env.reset(seed=0)\n tools = env.list_tools()\n print(f\"Connected! Available tools: {[t.name for t in tools]}\")\n for t in tools:\n print(f\" - {t.name}: {t.description[:80]}...\")\n\n status = json.loads(env.call_tool(\"get_status\"))\n print(f\"\\nEnvironment ready
|
| 43 |
"metadata": {},
|
| 44 |
"execution_count": null,
|
| 45 |
"outputs": []
|
|
@@ -69,7 +77,113 @@
|
|
| 69 |
{
|
| 70 |
"cell_type": "code",
|
| 71 |
"id": "2xd2afp4g99",
|
| 72 |
-
"source":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
"metadata": {},
|
| 74 |
"execution_count": null,
|
| 75 |
"outputs": []
|
|
@@ -83,7 +197,7 @@
|
|
| 83 |
{
|
| 84 |
"cell_type": "code",
|
| 85 |
"id": "zey499u5w1a",
|
| 86 |
-
"source": "from transformers import AutoModelForCausalLM, AutoTokenizer\nfrom peft import LoraConfig\nimport torch\n\n# Auto-detect precision\n_use_bf16 = torch.cuda.is_bf16_supported() if torch.cuda.is_available() else False\n_compute_dtype = torch.bfloat16 if _use_bf16 else torch.float16\n\n# Load in full bf16/fp16
|
| 87 |
"metadata": {},
|
| 88 |
"execution_count": null,
|
| 89 |
"outputs": []
|
|
@@ -97,7 +211,7 @@
|
|
| 97 |
{
|
| 98 |
"cell_type": "code",
|
| 99 |
"id": "6r2zhgg94fk",
|
| 100 |
-
"source": "# --- Pre-training baseline evaluation against live environment ---\nfrom openenv.core.env_server.mcp_types import CallToolAction\n\ndef generate_action_baseline(model, tokenizer, prompt_text):\n \"\"\"Generate an action from the model (used for both baseline and trained eval).\"\"\"\n msgs = [{\"role\": \"user\", \"content\": prompt_text}]\n input_ids = tokenizer.apply_chat_template(msgs, return_tensors=\"pt\", add_generation_prompt=True)\n if not isinstance(input_ids, torch.Tensor):\n input_ids = input_ids[\"input_ids\"]\n input_ids = input_ids.to(\"cuda\")\n prompt_len = input_ids.shape[1]\n with torch.no_grad():\n output = model.generate(input_ids=input_ids, max_new_tokens=200, temperature=0.7,\n pad_token_id=tokenizer.pad_token_id, do_sample=True)\n return tokenizer.decode(output[0][prompt_len:], skip_special_tokens=True)\n\n\ndef _extract_tool_result(obs):\n \"\"\"Extract the JSON result dict from a CallToolObservation.\n\n obs.result may be a plain string, a dict with 'data' key (FastMCP\n CallToolResult serialization), or an object with a .data attribute.\n Normalize to a dict.\n \"\"\"\n raw = getattr(obs, \"result\", None)\n\n # FastMCP CallToolResult object
|
| 101 |
"metadata": {},
|
| 102 |
"execution_count": null,
|
| 103 |
"outputs": []
|
|
@@ -127,7 +241,7 @@
|
|
| 127 |
{
|
| 128 |
"cell_type": "code",
|
| 129 |
"id": "arbp96a9wi",
|
| 130 |
-
"source": "from trl import GRPOConfig, GRPOTrainer\n\n# Build lookup from prompt text -> prompt metadata for reward scoring\n# Use first 200 chars as key (reliable
|
| 131 |
"metadata": {},
|
| 132 |
"execution_count": null,
|
| 133 |
"outputs": []
|
|
|
|
| 3 |
{
|
| 4 |
"cell_type": "markdown",
|
| 5 |
"id": "ym4tunggrm",
|
| 6 |
+
"source": "# CrisisInbox GRPO Training (Connected to HF Space)\n\nTrain a small LLM (Qwen2.5-0.5B) to triage crisis inbox messages using Group Relative Policy Optimization.\n\n**This notebook connects to the live CrisisInbox environment** deployed on HuggingFace Spaces at `https://eptan-crisis-inbox.hf.space` to collect training episodes in real-time, then trains the model using GRPO.\n\n**Stack:** HF TRL + PEFT (LoRA on full bf16 model \u2014 no quantization needed for 0.5B)\n\n**What this does:**\n1. Connects to the deployed CrisisInbox environment via WebSocket\n2. Collects episodes by interacting with the environment (reset, list tools, call tools)\n3. Builds training prompts from live environment observations\n4. Trains the model with GRPO using a reward function\n5. Evaluates the trained model against the live environment\n\nOpen in Google Colab or Northflank with a GPU runtime.",
|
| 7 |
"metadata": {}
|
| 8 |
},
|
| 9 |
{
|
| 10 |
"cell_type": "code",
|
| 11 |
"id": "p0j1w7pr7ib",
|
| 12 |
+
"source": [
|
| 13 |
+
"# Install dependencies (pure HF TRL + PEFT, no quantization needed for 0.5B model)\n",
|
| 14 |
+
"!pip install trl transformers datasets accelerate peft -q\n",
|
| 15 |
+
"!pip install \"openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git\" -q\n",
|
| 16 |
+
"!pip install huggingface_hub matplotlib -q\n",
|
| 17 |
+
"# Install crisis-inbox package so we can import the shared reward function\n",
|
| 18 |
+
"!pip install \"crisis-inbox @ git+https://github.com/eptan/crisis-inbox.git\" -q\n",
|
| 19 |
+
"print(\"Setup complete\")\n"
|
| 20 |
+
],
|
| 21 |
"metadata": {},
|
| 22 |
"execution_count": null,
|
| 23 |
"outputs": []
|
|
|
|
| 47 |
{
|
| 48 |
"cell_type": "code",
|
| 49 |
"id": "pmbt9gcp9hb",
|
| 50 |
+
"source": "import json\nimport time as _time\nfrom openenv.core.mcp_client import MCPToolClient\n\nBASE_URL = \"https://eptan-crisis-inbox.hf.space\"\n\n# Wake up the HF Space (may be sleeping) and verify connectivity\nprint(\"Connecting to HF Space (may take a moment if cold-starting)...\")\nfor attempt in range(3):\n try:\n with MCPToolClient(base_url=BASE_URL, connect_timeout_s=60.0).sync() as env:\n env.reset(seed=0)\n tools = env.list_tools()\n print(f\"Connected! Available tools: {[t.name for t in tools]}\")\n for t in tools:\n print(f\" - {t.name}: {t.description[:80]}...\")\n\n status = json.loads(env.call_tool(\"get_status\"))\n print(f\"\\nEnvironment ready \u2014 {status['messages_total_arrived']} messages at hour {status['current_hour']}\")\n break\n except Exception as e:\n if attempt < 2:\n print(f\" Attempt {attempt + 1} failed ({e}), retrying in 10s...\")\n _time.sleep(10)\n else:\n raise RuntimeError(f\"Could not connect to {BASE_URL} after 3 attempts: {e}\")",
|
| 51 |
"metadata": {},
|
| 52 |
"execution_count": null,
|
| 53 |
"outputs": []
|
|
|
|
| 77 |
{
|
| 78 |
"cell_type": "code",
|
| 79 |
"id": "2xd2afp4g99",
|
| 80 |
+
"source": [
|
| 81 |
+
"import re\n",
|
| 82 |
+
"from crisis_inbox.rewards import calculate_reward\n",
|
| 83 |
+
"from crisis_inbox.models import Message, Urgency, Channel\n",
|
| 84 |
+
"\n",
|
| 85 |
+
"\n",
|
| 86 |
+
"def score_action(completion: str, prompt_data: dict) -> float:\n",
|
| 87 |
+
" \"\"\"Score a model completion against the inbox state.\n",
|
| 88 |
+
"\n",
|
| 89 |
+
" Parses the model output for respond_to_message(msg_id, response),\n",
|
| 90 |
+
" constructs a Message object, and delegates to the shared\n",
|
| 91 |
+
" calculate_reward() from rewards.py (single source of truth).\n",
|
| 92 |
+
" \"\"\"\n",
|
| 93 |
+
" messages = prompt_data[\"messages\"]\n",
|
| 94 |
+
" hour = prompt_data[\"hour\"]\n",
|
| 95 |
+
" superseded = prompt_data.get(\"superseded\", {})\n",
|
| 96 |
+
"\n",
|
| 97 |
+
" # Parse the model output for message_id and response text\n",
|
| 98 |
+
" msg_id = None\n",
|
| 99 |
+
" response_text = \"\"\n",
|
| 100 |
+
"\n",
|
| 101 |
+
" match = re.search(r'respond_to_message\\s*\\(\\s*[\"']?(msg_\\d+)[\"']?\\s*,\\s*[\"'](.+?)[\"']', completion, re.DOTALL)\n",
|
| 102 |
+
" if match:\n",
|
| 103 |
+
" msg_id = match.group(1)\n",
|
| 104 |
+
" response_text = match.group(2)\n",
|
| 105 |
+
" else:\n",
|
| 106 |
+
" id_match = re.search(r'(msg_\\d+)', completion)\n",
|
| 107 |
+
" if id_match:\n",
|
| 108 |
+
" msg_id = id_match.group(1)\n",
|
| 109 |
+
" response_text = completion\n",
|
| 110 |
+
"\n",
|
| 111 |
+
" if not msg_id:\n",
|
| 112 |
+
" return -1.0\n",
|
| 113 |
+
"\n",
|
| 114 |
+
" # Find the message dict in the inbox\n",
|
| 115 |
+
" target_dict = None\n",
|
| 116 |
+
" for msg in messages:\n",
|
| 117 |
+
" if msg[\"id\"] == msg_id:\n",
|
| 118 |
+
" target_dict = msg\n",
|
| 119 |
+
" break\n",
|
| 120 |
+
"\n",
|
| 121 |
+
" if target_dict is None:\n",
|
| 122 |
+
" return -0.5\n",
|
| 123 |
+
"\n",
|
| 124 |
+
" # Construct a Message object for the shared reward function\n",
|
| 125 |
+
" target_msg = Message(\n",
|
| 126 |
+
" id=target_dict[\"id\"],\n",
|
| 127 |
+
" sender=target_dict[\"sender\"],\n",
|
| 128 |
+
" channel=target_dict.get(\"channel\", \"email\"),\n",
|
| 129 |
+
" subject=target_dict.get(\"subject\", \"\"),\n",
|
| 130 |
+
" content=target_dict.get(\"content\", \"\"),\n",
|
| 131 |
+
" urgency=target_dict[\"urgency\"],\n",
|
| 132 |
+
" timestamp_hours=target_dict.get(\"timestamp_hours\", 0.0),\n",
|
| 133 |
+
" deadline_hours=target_dict.get(\"deadline_hours\"),\n",
|
| 134 |
+
" dependencies=target_dict.get(\"dependencies\", []),\n",
|
| 135 |
+
" drift_flag=target_dict.get(\"drift_flag\", False),\n",
|
| 136 |
+
" supersedes=target_dict.get(\"supersedes\"),\n",
|
| 137 |
+
" )\n",
|
| 138 |
+
"\n",
|
| 139 |
+
" # Build visible_messages as Message objects for priority penalty\n",
|
| 140 |
+
" handled_ids = {m[\"id\"]: \"\" for m in messages if m.get(\"handled\", False)}\n",
|
| 141 |
+
" visible = []\n",
|
| 142 |
+
" for m in messages:\n",
|
| 143 |
+
" visible.append(Message(\n",
|
| 144 |
+
" id=m[\"id\"],\n",
|
| 145 |
+
" sender=m[\"sender\"],\n",
|
| 146 |
+
" channel=m.get(\"channel\", \"email\"),\n",
|
| 147 |
+
" subject=m.get(\"subject\", \"\"),\n",
|
| 148 |
+
" content=m.get(\"content\", \"\"),\n",
|
| 149 |
+
" urgency=m[\"urgency\"],\n",
|
| 150 |
+
" timestamp_hours=m.get(\"timestamp_hours\", 0.0),\n",
|
| 151 |
+
" deadline_hours=m.get(\"deadline_hours\"),\n",
|
| 152 |
+
" dependencies=m.get(\"dependencies\", []),\n",
|
| 153 |
+
" drift_flag=m.get(\"drift_flag\", False),\n",
|
| 154 |
+
" supersedes=m.get(\"supersedes\"),\n",
|
| 155 |
+
" ))\n",
|
| 156 |
+
"\n",
|
| 157 |
+
" return calculate_reward(\n",
|
| 158 |
+
" msg=target_msg,\n",
|
| 159 |
+
" current_hour=hour,\n",
|
| 160 |
+
" response=response_text,\n",
|
| 161 |
+
" superseded=superseded,\n",
|
| 162 |
+
" visible_messages=visible,\n",
|
| 163 |
+
" handled=handled_ids,\n",
|
| 164 |
+
" )\n",
|
| 165 |
+
"\n",
|
| 166 |
+
"\n",
|
| 167 |
+
"# Test the reward function\n",
|
| 168 |
+
"test_data = prompts[0]\n",
|
| 169 |
+
"print(\"Testing reward function on first decision point:\")\n",
|
| 170 |
+
"print(f\" Hour: {test_data[\"hour\"]}, Messages: {test_data[\"visible_count\"]}\")\n",
|
| 171 |
+
"\n",
|
| 172 |
+
"critical_msgs = [m for m in test_data[\"messages\"] if m[\"urgency\"] == \"critical\"]\n",
|
| 173 |
+
"if critical_msgs:\n",
|
| 174 |
+
" good_action = f'respond_to_message(\"{critical_msgs[0][\"id\"]}\", \"Acknowledged. Evacuating immediately with documents and medication.\")'\n",
|
| 175 |
+
" good_score = score_action(good_action, test_data)\n",
|
| 176 |
+
" print(f\" Good action (critical msg): {good_score:.2f} pts\")\n",
|
| 177 |
+
"\n",
|
| 178 |
+
"low_msgs = [m for m in test_data[\"messages\"] if m[\"urgency\"] == \"low\"]\n",
|
| 179 |
+
"if low_msgs:\n",
|
| 180 |
+
" bad_action = f'respond_to_message(\"{low_msgs[0][\"id\"]}\", \"ok\")'\n",
|
| 181 |
+
" bad_score = score_action(bad_action, test_data)\n",
|
| 182 |
+
" print(f\" Bad action (low msg, short response): {bad_score:.2f} pts\")\n",
|
| 183 |
+
"\n",
|
| 184 |
+
"junk_score = score_action(\"I think we should do something\", test_data)\n",
|
| 185 |
+
"print(f\" Unparseable action: {junk_score:.2f} pts\")\n"
|
| 186 |
+
],
|
| 187 |
"metadata": {},
|
| 188 |
"execution_count": null,
|
| 189 |
"outputs": []
|
|
|
|
| 197 |
{
|
| 198 |
"cell_type": "code",
|
| 199 |
"id": "zey499u5w1a",
|
| 200 |
+
"source": "from transformers import AutoModelForCausalLM, AutoTokenizer\nfrom peft import LoraConfig\nimport torch\n\n# Auto-detect precision\n_use_bf16 = torch.cuda.is_bf16_supported() if torch.cuda.is_available() else False\n_compute_dtype = torch.bfloat16 if _use_bf16 else torch.float16\n\n# Load in full bf16/fp16 \u2014 no 4-bit quantization.\n# Qwen2.5-0.5B is ~1GB in bf16, fits easily on any GPU.\n# This avoids all bitsandbytes dtype mismatch issues with lm_head.\nmodel = AutoModelForCausalLM.from_pretrained(\n \"Qwen/Qwen2.5-0.5B-Instruct\",\n device_map=\"auto\",\n torch_dtype=_compute_dtype,\n)\ntokenizer = AutoTokenizer.from_pretrained(\"Qwen/Qwen2.5-0.5B-Instruct\")\n\n# Fix: TRL GRPOTrainer expects warnings_issued but newer transformers removed it.\nif not hasattr(model, \"warnings_issued\"):\n model.warnings_issued = {}\n\n# GRPO requires left padding so completions align across the batch\ntokenizer.padding_side = \"left\"\nif tokenizer.pad_token_id is None:\n tokenizer.pad_token = tokenizer.eos_token\n tokenizer.pad_token_id = tokenizer.eos_token_id\n\n# LoRA config \u2014 passed to GRPOTrainer, not applied here\nlora_config = LoraConfig(\n r=16,\n lora_alpha=16,\n target_modules=[\"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\",\n \"gate_proj\", \"up_proj\", \"down_proj\"],\n lora_dropout=0.0,\n bias=\"none\",\n task_type=\"CAUSAL_LM\",\n)\n\nprint(f\"Model loaded in {_compute_dtype} (no quantization)\")\nprint(f\"Precision: {'bf16' if _use_bf16 else 'fp16'}\")\nprint(f\"Model size: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M params\")",
|
| 201 |
"metadata": {},
|
| 202 |
"execution_count": null,
|
| 203 |
"outputs": []
|
|
|
|
| 211 |
{
|
| 212 |
"cell_type": "code",
|
| 213 |
"id": "6r2zhgg94fk",
|
| 214 |
+
"source": "# --- Pre-training baseline evaluation against live environment ---\nfrom openenv.core.env_server.mcp_types import CallToolAction\n\ndef generate_action_baseline(model, tokenizer, prompt_text):\n \"\"\"Generate an action from the model (used for both baseline and trained eval).\"\"\"\n msgs = [{\"role\": \"user\", \"content\": prompt_text}]\n input_ids = tokenizer.apply_chat_template(msgs, return_tensors=\"pt\", add_generation_prompt=True)\n if not isinstance(input_ids, torch.Tensor):\n input_ids = input_ids[\"input_ids\"]\n input_ids = input_ids.to(\"cuda\")\n prompt_len = input_ids.shape[1]\n with torch.no_grad():\n output = model.generate(input_ids=input_ids, max_new_tokens=200, temperature=0.7,\n pad_token_id=tokenizer.pad_token_id, do_sample=True)\n return tokenizer.decode(output[0][prompt_len:], skip_special_tokens=True)\n\n\ndef _extract_tool_result(obs):\n \"\"\"Extract the JSON result dict from a CallToolObservation.\n\n obs.result may be a plain string, a dict with 'data' key (FastMCP\n CallToolResult serialization), or an object with a .data attribute.\n Normalize to a dict.\n \"\"\"\n raw = getattr(obs, \"result\", None)\n\n # FastMCP CallToolResult object \u2192 unwrap .data\n if hasattr(raw, \"data\"):\n raw = raw.data\n\n # Serialized as {\"data\": \"...\"} dict\n if isinstance(raw, dict) and \"data\" in raw:\n raw = raw[\"data\"]\n\n # Now raw should be a JSON string\n if isinstance(raw, str):\n try:\n return json.loads(raw)\n except (json.JSONDecodeError, TypeError):\n return {}\n\n # Already a dict\n if isinstance(raw, dict):\n return raw\n\n return {}\n\n\ndef evaluate_on_live_env(model, tokenizer, base_url, seed, max_steps=20):\n \"\"\"Run model against the live environment using OpenEnv's step() flow.\n\n Uses env.step(CallToolAction(...)) for respond_to_message so that\n rewards flow through Observation.reward \u2014 the standard OpenEnv contract.\n Read-only tools (get_inbox, get_status, advance_time) use call_tool().\n \"\"\"\n with MCPToolClient(base_url=base_url, connect_timeout_s=60.0, message_timeout_s=120.0).sync() as env:\n env.reset(seed=seed)\n total_reward = 0.0\n actions_taken = []\n\n for step in range(max_steps):\n inbox = json.loads(env.call_tool(\"get_inbox\"))\n status = json.loads(env.call_tool(\"get_status\"))\n current_hour = status[\"current_hour\"]\n\n if status.get(\"done\"):\n break\n\n unhandled = [m for m in inbox if not m.get(\"handled\", False)]\n if not unhandled:\n env.call_tool(\"advance_time\", hours=2.0)\n continue\n\n completion = generate_action_baseline(model, tokenizer, build_prompt(inbox, current_hour))\n\n match = re.search(r'respond_to_message\\s*\\(\\s*[\"\\']?(msg_\\d+)[\"\\']?\\s*,\\s*[\"\\'](.+?)[\"\\']', completion, re.DOTALL)\n if not match:\n id_match = re.search(r'(msg_\\d+)', completion)\n if id_match:\n msg_id, response_text = id_match.group(1), completion[:200]\n else:\n env.call_tool(\"advance_time\", hours=1.0)\n continue\n else:\n msg_id, response_text = match.group(1), match.group(2)\n\n # Use env.step() with CallToolAction so reward flows through\n # OpenEnv's Observation.reward \u2014 the proper RL loop contract.\n action = CallToolAction(\n tool_name=\"respond_to_message\",\n arguments={\"message_id\": msg_id, \"response\": response_text},\n )\n step_result = env.step(action)\n obs = step_result.observation\n\n # Read reward from observation (populated by server's step() override)\n reward = obs.reward if obs.reward is not None else 0.0\n done = obs.done\n\n # Parse tool result for error/status info\n result_data = _extract_tool_result(obs)\n\n if \"error\" in result_data:\n env.call_tool(\"advance_time\", hours=1.0)\n continue\n\n total_reward += reward\n target_msg = next((m for m in inbox if m[\"id\"] == msg_id), None)\n urgency = target_msg[\"urgency\"] if target_msg else \"?\"\n actions_taken.append({\"step\": step, \"hour\": current_hour, \"msg_id\": msg_id, \"urgency\": urgency, \"reward\": reward})\n print(f\" Step {step:2d} | Hour {current_hour:5.1f} | {msg_id} ({urgency:8s}) | Reward: {reward:+.1f} | Total: {total_reward:.1f}\")\n\n if done:\n break\n\n final_status = json.loads(env.call_tool(\"get_status\"))\n\n return {\"seed\": seed, \"total_reward\": total_reward, \"actions\": actions_taken, \"final_status\": final_status}\n\n\n# Run baseline on 3 seeds to get a stable estimate\nprint(\"=== PRE-TRAINING BASELINE (untrained model) ===\\n\")\nbaseline_results = []\nfor seed in [99, 42, 7]:\n print(f\"--- Seed {seed} ---\")\n res = evaluate_on_live_env(model, tokenizer, BASE_URL, seed=seed)\n baseline_results.append(res)\n print(f\" Total: {res['total_reward']:.1f} | Actions: {len(res['actions'])}\\n\")\n\nbaseline_avg = sum(r[\"total_reward\"] for r in baseline_results) / len(baseline_results)\nprint(f\"Baseline average reward: {baseline_avg:.1f}\")\nprint(\"(Saving for comparison after training)\")",
|
| 215 |
"metadata": {},
|
| 216 |
"execution_count": null,
|
| 217 |
"outputs": []
|
|
|
|
| 241 |
{
|
| 242 |
"cell_type": "code",
|
| 243 |
"id": "arbp96a9wi",
|
| 244 |
+
"source": "from trl import GRPOConfig, GRPOTrainer\n\n# Build lookup from prompt text -> prompt metadata for reward scoring\n# Use first 200 chars as key (reliable \u2014 TRL may not pass custom dataset columns)\nprompt_lookup = {}\nfor p in prompts:\n key = p[\"prompt\"][:200]\n prompt_lookup[key] = p\n\n\ndef reward_fn(prompts, completions, **kwargs):\n \"\"\"GRPO reward function. Scores each completion against its inbox state.\"\"\"\n rewards = []\n for prompt_msgs, completion in zip(prompts, completions):\n # Extract prompt text to look up metadata\n if isinstance(prompt_msgs, list):\n prompt_text = prompt_msgs[-1][\"content\"] if prompt_msgs else \"\"\n else:\n prompt_text = str(prompt_msgs)\n\n key = prompt_text[:200]\n prompt_data = prompt_lookup.get(key)\n\n if prompt_data is None:\n rewards.append(0.0)\n continue\n\n if isinstance(completion, list):\n if completion and isinstance(completion[0], (int, float)):\n comp_text = tokenizer.decode(completion, skip_special_tokens=True)\n else:\n comp_text = completion[-1].get(\"content\", \"\") if completion else \"\"\n else:\n comp_text = str(completion)\n\n score = score_action(comp_text, prompt_data)\n rewards.append(score)\n\n return rewards\n\n\ntraining_args = GRPOConfig(\n output_dir=\"crisisinbox-grpo-output\",\n num_train_epochs=3,\n per_device_train_batch_size=2,\n gradient_accumulation_steps=2,\n learning_rate=1e-5,\n max_completion_length=256,\n max_prompt_length=1024,\n num_generations=4,\n logging_steps=1,\n save_steps=100,\n bf16=_use_bf16,\n fp16=not _use_bf16,\n sync_ref_model=True,\n)\n\n# Let GRPOTrainer handle PEFT wrapping (avoids dtype mismatches from manual setup)\ntrainer = GRPOTrainer(\n model=model,\n processing_class=tokenizer,\n reward_funcs=reward_fn,\n args=training_args,\n train_dataset=dataset,\n peft_config=lora_config,\n)\n\n# After trainer init, update model ref to the PEFT-wrapped version\nmodel = trainer.model\n\nprint(f\"Trainer configured \u2014 {len(prompt_lookup)} unique prompt keys\")\nprint(f\"Precision: {'bf16' if _use_bf16 else 'fp16'}\")\nprint(f\"Training for {training_args.num_train_epochs} epochs\")\nprint(\"Ready to train\")",
|
| 245 |
"metadata": {},
|
| 246 |
"execution_count": null,
|
| 247 |
"outputs": []
|
rewards.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CrisisInbox Reward Function — Single Source of Truth.
|
| 3 |
+
|
| 4 |
+
Both the environment server and training notebooks import from here.
|
| 5 |
+
No OpenEnv dependencies — only requires models.py (pydantic).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
from .models import Message, Urgency
|
| 10 |
+
except ImportError:
|
| 11 |
+
from models import Message, Urgency
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
_FAMILY_SENDERS = {"mom", "sister", "neighbor dave", "emma"}
|
| 15 |
+
_FAMILY_TONE_WORDS = {"love", "safe", "worried", "sorry", "care", "okay", "miss", "hang in there"}
|
| 16 |
+
_FORMAL_TONE_WORDS = {"confirm", "attached", "documentation", "regarding", "request", "please", "submit"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def tone_multiplier(sender: str, response: str) -> float:
|
| 20 |
+
"""
|
| 21 |
+
Small reward multiplier for tone-appropriate responses.
|
| 22 |
+
|
| 23 |
+
Family/personal senders reward empathetic language.
|
| 24 |
+
Professional/institutional senders reward formal language.
|
| 25 |
+
Returns 1.0-1.15 (bonus) or 1.0 (neutral). Never penalizes.
|
| 26 |
+
"""
|
| 27 |
+
resp_lower = response.lower()
|
| 28 |
+
sender_lower = sender.lower()
|
| 29 |
+
|
| 30 |
+
if any(f in sender_lower for f in _FAMILY_SENDERS):
|
| 31 |
+
matches = sum(1 for w in _FAMILY_TONE_WORDS if w in resp_lower)
|
| 32 |
+
if matches >= 2:
|
| 33 |
+
return 1.15
|
| 34 |
+
elif matches == 1:
|
| 35 |
+
return 1.07
|
| 36 |
+
else:
|
| 37 |
+
matches = sum(1 for w in _FORMAL_TONE_WORDS if w in resp_lower)
|
| 38 |
+
if matches >= 2:
|
| 39 |
+
return 1.1
|
| 40 |
+
elif matches == 1:
|
| 41 |
+
return 1.05
|
| 42 |
+
|
| 43 |
+
return 1.0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def calculate_reward(
|
| 47 |
+
msg: Message,
|
| 48 |
+
current_hour: float,
|
| 49 |
+
response: str,
|
| 50 |
+
superseded: dict[str, str],
|
| 51 |
+
visible_messages: list[Message] | None = None,
|
| 52 |
+
handled: dict[str, str] | None = None,
|
| 53 |
+
) -> float:
|
| 54 |
+
"""
|
| 55 |
+
Calculate reward for handling a message.
|
| 56 |
+
|
| 57 |
+
Reward signals:
|
| 58 |
+
- Base reward by urgency (critical=10, high=5, medium=3, low=1)
|
| 59 |
+
- Deadline timing bonus (up to +50% for early, -75% for late)
|
| 60 |
+
- Response quality (penalty for very short responses)
|
| 61 |
+
- Tone awareness (up to +15% for matching tone to sender type)
|
| 62 |
+
- Schema drift adaptation bonus (+50% for handling drift messages)
|
| 63 |
+
- Penalty for acting on superseded/stale information (-50%)
|
| 64 |
+
- Priority penalty (-70% for choosing low/medium when critical is pending)
|
| 65 |
+
"""
|
| 66 |
+
base_rewards = {
|
| 67 |
+
Urgency.CRITICAL: 10.0,
|
| 68 |
+
Urgency.HIGH: 5.0,
|
| 69 |
+
Urgency.MEDIUM: 3.0,
|
| 70 |
+
Urgency.LOW: 1.0,
|
| 71 |
+
}
|
| 72 |
+
reward = base_rewards.get(msg.urgency, 1.0)
|
| 73 |
+
|
| 74 |
+
# Deadline timing
|
| 75 |
+
if msg.deadline_hours is not None:
|
| 76 |
+
if current_hour <= msg.deadline_hours:
|
| 77 |
+
time_remaining_frac = (msg.deadline_hours - current_hour) / max(msg.deadline_hours, 1.0)
|
| 78 |
+
reward *= 1.0 + 0.5 * time_remaining_frac
|
| 79 |
+
else:
|
| 80 |
+
reward *= 0.25
|
| 81 |
+
|
| 82 |
+
# Response quality - penalty for very short/empty responses
|
| 83 |
+
if len(response.strip()) < 10:
|
| 84 |
+
reward *= 0.5
|
| 85 |
+
|
| 86 |
+
# Tone awareness: small bonus for matching tone to sender type
|
| 87 |
+
reward *= tone_multiplier(msg.sender, response)
|
| 88 |
+
|
| 89 |
+
# Drift adaptation bonus
|
| 90 |
+
if msg.drift_flag:
|
| 91 |
+
reward *= 1.5
|
| 92 |
+
|
| 93 |
+
# Penalty for responding to a superseded message (stale info)
|
| 94 |
+
if msg.id in superseded:
|
| 95 |
+
reward *= 0.5
|
| 96 |
+
|
| 97 |
+
# Priority penalty: choosing low/medium when unhandled critical messages exist
|
| 98 |
+
if visible_messages and handled is not None:
|
| 99 |
+
has_unhandled_critical = any(
|
| 100 |
+
m.urgency == Urgency.CRITICAL and m.id not in handled
|
| 101 |
+
for m in visible_messages
|
| 102 |
+
if m.id != msg.id # exclude the message being handled now
|
| 103 |
+
)
|
| 104 |
+
if has_unhandled_critical and msg.urgency in (Urgency.LOW, Urgency.MEDIUM):
|
| 105 |
+
reward *= 0.3
|
| 106 |
+
|
| 107 |
+
return round(reward, 2)
|
server/crisis_inbox_environment.py
CHANGED
|
@@ -25,10 +25,12 @@ try:
|
|
| 25 |
from ..models import Channel, Message, Urgency
|
| 26 |
from ..messages import ALL_MESSAGES
|
| 27 |
from ..drift_events import ALL_DRIFT_EVENTS, DriftEvent, select_drift_events
|
|
|
|
| 28 |
except ImportError:
|
| 29 |
from models import Channel, Message, Urgency
|
| 30 |
from messages import ALL_MESSAGES
|
| 31 |
from drift_events import ALL_DRIFT_EVENTS, DriftEvent, select_drift_events
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
class CrisisInboxEnvironment(MCPEnvironment):
|
|
@@ -161,7 +163,7 @@ class CrisisInboxEnvironment(MCPEnvironment):
|
|
| 161 |
})
|
| 162 |
|
| 163 |
# Calculate reward
|
| 164 |
-
reward =
|
| 165 |
msg, self._current_hour, response, self._superseded,
|
| 166 |
self._visible_messages, self._handled,
|
| 167 |
)
|
|
@@ -397,63 +399,3 @@ class CrisisInboxEnvironment(MCPEnvironment):
|
|
| 397 |
@property
|
| 398 |
def state(self) -> State:
|
| 399 |
return self._state
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
def _calculate_reward(
|
| 403 |
-
msg: Message,
|
| 404 |
-
current_hour: float,
|
| 405 |
-
response: str,
|
| 406 |
-
superseded: dict[str, str],
|
| 407 |
-
visible_messages: list[Message] | None = None,
|
| 408 |
-
handled: dict[str, str] | None = None,
|
| 409 |
-
) -> float:
|
| 410 |
-
"""
|
| 411 |
-
Calculate reward for handling a message.
|
| 412 |
-
|
| 413 |
-
Reward signals:
|
| 414 |
-
- Base reward by urgency (critical=10, high=5, medium=3, low=1)
|
| 415 |
-
- Deadline timing bonus (up to +50% for early, -75% for late)
|
| 416 |
-
- Response quality (penalty for very short responses)
|
| 417 |
-
- Schema drift adaptation bonus (+50% for handling drift messages)
|
| 418 |
-
- Penalty for acting on superseded/stale information (-50%)
|
| 419 |
-
- Priority penalty (-70% for choosing low/medium when critical is pending)
|
| 420 |
-
"""
|
| 421 |
-
base_rewards = {
|
| 422 |
-
Urgency.CRITICAL: 10.0,
|
| 423 |
-
Urgency.HIGH: 5.0,
|
| 424 |
-
Urgency.MEDIUM: 3.0,
|
| 425 |
-
Urgency.LOW: 1.0,
|
| 426 |
-
}
|
| 427 |
-
reward = base_rewards.get(msg.urgency, 1.0)
|
| 428 |
-
|
| 429 |
-
# Deadline timing
|
| 430 |
-
if msg.deadline_hours is not None:
|
| 431 |
-
if current_hour <= msg.deadline_hours:
|
| 432 |
-
time_remaining_frac = (msg.deadline_hours - current_hour) / max(msg.deadline_hours, 1.0)
|
| 433 |
-
reward *= 1.0 + 0.5 * time_remaining_frac
|
| 434 |
-
else:
|
| 435 |
-
reward *= 0.25
|
| 436 |
-
|
| 437 |
-
# Response quality - penalty for very short/empty responses
|
| 438 |
-
if len(response.strip()) < 10:
|
| 439 |
-
reward *= 0.5
|
| 440 |
-
|
| 441 |
-
# Drift adaptation bonus
|
| 442 |
-
if msg.drift_flag:
|
| 443 |
-
reward *= 1.5
|
| 444 |
-
|
| 445 |
-
# Penalty for responding to a superseded message (stale info)
|
| 446 |
-
if msg.id in superseded:
|
| 447 |
-
reward *= 0.5
|
| 448 |
-
|
| 449 |
-
# Priority penalty: choosing low/medium when unhandled critical messages exist
|
| 450 |
-
if visible_messages and handled is not None:
|
| 451 |
-
has_unhandled_critical = any(
|
| 452 |
-
m.urgency == Urgency.CRITICAL and m.id not in handled
|
| 453 |
-
for m in visible_messages
|
| 454 |
-
if m.id != msg.id # exclude the message being handled now
|
| 455 |
-
)
|
| 456 |
-
if has_unhandled_critical and msg.urgency in (Urgency.LOW, Urgency.MEDIUM):
|
| 457 |
-
reward *= 0.3
|
| 458 |
-
|
| 459 |
-
return round(reward, 2)
|
|
|
|
| 25 |
from ..models import Channel, Message, Urgency
|
| 26 |
from ..messages import ALL_MESSAGES
|
| 27 |
from ..drift_events import ALL_DRIFT_EVENTS, DriftEvent, select_drift_events
|
| 28 |
+
from ..rewards import calculate_reward
|
| 29 |
except ImportError:
|
| 30 |
from models import Channel, Message, Urgency
|
| 31 |
from messages import ALL_MESSAGES
|
| 32 |
from drift_events import ALL_DRIFT_EVENTS, DriftEvent, select_drift_events
|
| 33 |
+
from rewards import calculate_reward
|
| 34 |
|
| 35 |
|
| 36 |
class CrisisInboxEnvironment(MCPEnvironment):
|
|
|
|
| 163 |
})
|
| 164 |
|
| 165 |
# Calculate reward
|
| 166 |
+
reward = calculate_reward(
|
| 167 |
msg, self._current_hour, response, self._superseded,
|
| 168 |
self._visible_messages, self._handled,
|
| 169 |
)
|
|
|
|
| 399 |
@property
|
| 400 |
def state(self) -> State:
|
| 401 |
return self._state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|