File size: 8,334 Bytes
f27fd6a 1c03487 ec747a2 1c03487 ec747a2 b43f9e6 ec747a2 1c03487 f27fd6a 1c03487 ec747a2 f27fd6a 1c03487 f27fd6a 1c03487 ec747a2 f27fd6a ec747a2 7f110fa 0092607 7f110fa 1c03487 ec747a2 f27fd6a ec747a2 f27fd6a ec747a2 fe22c22 f27fd6a ec747a2 f27fd6a c454091 ec747a2 1c03487 f27fd6a ec747a2 1c03487 f27fd6a 1c03487 f27fd6a 1c03487 f27fd6a 1c03487 0092607 fe22c22 1c03487 ec747a2 1c03487 ec747a2 1c03487 ec747a2 0092607 74f461a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | import os, sys, json, re
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from openai import OpenAI
from models import CityObservation, ContainmentAction
def get_client() -> OpenAI:
return OpenAI(
api_key = os.environ.get("API_KEY") or os.environ.get("HF_TOKEN", ""),
base_url = os.environ.get("API_BASE_URL", "https://router.huggingface.co/v1"),
)
def build_prompt(obs: CityObservation) -> str:
num_districts = len(obs.districts)
has_data_lag = obs.data_lag_days > 0
sorted_districts = sorted(
obs.districts,
key=lambda d: d.reported_infection_rate,
reverse=True
)
lines = [
"You are an epidemic response coordinator making life-or-death resource decisions.",
"Your goal: keep infection BELOW 0.40 in all districts and prevent hospital collapse.",
"",
f"Step {obs.current_step}/{obs.max_steps} | Resources remaining: {obs.available_resources}",
"ℹ️ Resources replenish by 1 each step, capped at starting pool. Spend carefully.",
"",
"Districts (sorted by CURRENT infection, highest threat first):",
]
for d in sorted_districts:
if d.reported_infection_rate > 0.4:
status = "🔴 CRITICAL"
elif d.reported_infection_rate > 0.2:
if d.growth_rate_hint > 0.06:
status = "🟡 WARNING→CRITICAL SOON"
else:
status = "🟡 WARNING"
else:
status = "🟢 SAFE"
if d.hospital_capacity_remaining < 0.3:
hosp_status = "⚠️ HOSPITAL DANGER"
elif d.hospital_capacity_remaining < 0.6:
hosp_status = "hospital LOW"
else:
hosp_status = "hospital OK"
if has_data_lag:
# pre-compute estimated current infection so the LLM doesn't have to do the math
estimated = round(min(1.0, d.reported_infection_rate + 3 * d.growth_rate_hint), 2)
if estimated > 0.4:
est_status = "🔴 EST.CRITICAL"
elif estimated > 0.2:
est_status = "🟡 EST.WARNING"
else:
est_status = "🟢 EST.SAFE"
lines.append(
f" D{d.district_id}: reported={d.reported_infection_rate:.2f} [3 days old] "
f"growth={d.growth_rate_hint:.2f} → ESTIMATED NOW={estimated:.2f} {est_status} "
f"{hosp_status}({d.hospital_capacity_remaining:.2f})"
)
else:
lines.append(
f" D{d.district_id}: {status} infection={d.reported_infection_rate:.2f} "
f"growth={d.growth_rate_hint:.2f} {hosp_status}({d.hospital_capacity_remaining:.2f})"
)
lines += [""]
if not has_data_lag:
lines += [
"HOW ACTIONS WORK:",
" - 'allocate': costs 1 resource. REDUCES existing infection by 5% AND slows spread.",
" Infections naturally recover 1%/day but spread (3-8%/day) dominates without action.",
" You need SUSTAINED allocation (multiple steps) to drive a district below safe level.",
" - 'restrict': FREE. Slows future spread only. Does NOT reduce existing infection.",
" Use only when you have no resources OR for districts already below 0.20.",
" - 'test': wastes 1 resource. Data is already real-time. NEVER use this.",
"",
"DECISION RULES — follow this priority order every step:",
"1. HOSPITAL EMERGENCY: If ANY hospital < 0.30 capacity → allocate on that district NOW.",
"2. TRIAGE: Look at ALL districts. Find the one with the HIGHEST infection rate right now.",
" That is your target this step. Do not stick to the same district if another is worse.",
"3. CRITICAL DISTRICT (above 0.40 and have resources): allocate on the highest.",
"4. WARNING DISTRICT (0.20-0.40) with growth > 0.06 AND resources available:",
" allocate on it NOW to prevent it from becoming CRITICAL next step.",
"5. If resources = 0: restrict on the highest infected district.",
"6. NEVER restrict a district below 0.20 — you will be penalised.",
"7. NEVER use 'test' — it wastes a resource you cannot afford.",
"",
"KEY INSIGHT: Infection spreads 3-8% per day. A WARNING district at 0.38 with",
"growth=0.07 will be CRITICAL next step. Act before it escalates, not after.",
]
else:
lines += [
"HOW ACTIONS WORK:",
" - 'allocate': costs 1 resource. Reduces infection AND slows spread.",
" - 'restrict': FREE. Slows future spread only.",
" - Data is 3 DAYS OLD — you cannot see current true infection rates.",
" Use growth_hint to estimate which districts are getting worse fastest.",
"",
"DECISION RULES for delayed-information scenario:",
"1. HOSPITAL EMERGENCY: If ANY hospital < 0.30 → allocate on that district NOW.",
"2. Look at ESTIMATED NOW column — this is your best estimate of current infection.",
" The estimation accounts for 3 days of growth automatically.",
"3. Allocate on the district with HIGHEST 'ESTIMATED NOW' value.",
"4. If resources = 0: restrict on the district with highest ESTIMATED NOW.",
"5. NEVER use 'test' — data lag is structural, testing does not help.",
]
lines += [
"",
"Think briefly (1 sentence): Which district is most dangerous RIGHT NOW and why?",
"Then give your JSON decision.",
"",
"Example response:",
'District 0 is critical at 0.65 and growing fastest.',
'{"action_type": "allocate", "district_id": 0}',
"",
"Your response:",
]
return "\n".join(lines)
def call_llm(prompt: str, client: OpenAI) -> str:
response = client.chat.completions.create(
model = os.environ.get("MODEL_NAME", "meta-llama/Llama-3.1-8B-Instruct"),
messages = [
{
"role": "system",
"content": (
"You are an epidemic response AI. "
"First write one sentence of reasoning, then a JSON action on the next line. "
"JSON must be valid and contain action_type and district_id."
)
},
{"role": "user", "content": prompt}
],
max_tokens = 60,
temperature = 0.1,
)
return (response.choices[0].message.content or "").strip()
def parse_action(response: str, num_districts: int) -> ContainmentAction:
valid_types = {"test", "restrict", "allocate"}
try:
cleaned = re.sub(r"```(?:json)?|```", "", response).strip()
match = re.search(r"\{.*?\}", cleaned, re.DOTALL)
if match:
cleaned = match.group()
data = json.loads(cleaned)
action_type = str(data.get("action_type", "allocate")).lower().strip()
district_id = int(data.get("district_id", 0))
if action_type not in valid_types:
action_type = "allocate"
district_id = max(0, min(district_id, num_districts - 1))
return ContainmentAction(action_type=action_type, district_id=district_id)
except Exception:
return ContainmentAction(action_type="allocate", district_id=0)
def get_action(obs: CityObservation, client: OpenAI) -> ContainmentAction:
prompt = build_prompt(obs)
response = call_llm(prompt, client)
return parse_action(response, len(obs.districts))
def build_prompt_with_memory(obs: CityObservation, memory) -> str:
"""
Builds the LLM prompt augmented with relevant past decisions from episodic memory.
Injects memory block just before the 'Your response:' line so the model sees
prior high-reward decisions as concrete examples before making its choice.
"""
base = build_prompt(obs)
memory_block = memory.retrieve(obs)
if not memory_block:
return base
injection = "\n" + memory_block + "\nApply these lessons to your current decision.\n"
return base.replace("Your response:", injection + "Your response:")
|