pharma-agent / inference.py
Arshdeep2k5
Fresh start - no db files
8a9e2f2
Raw
History Blame Contribute Delete
13.9 kB
"""
PharmaAgent β€” inference.py
Hackathon evaluation script (OpenMV-compatible).
Runs an LLM agent through clinical decision episodes and reports
a grader score from 0.0 to 1.0.
Design:
- The agent (Qwen 2.5 72B via HuggingFace router) makes clinical decisions.
- All scoring is performed by environment.py using DrugBank data only.
- Groq (Llama 3.3 70B) is called ONCE at the end of each episode,
purely to format a human-readable clinical summary of the final regimen.
It has zero influence on any reward value.
Usage:
uv run inference.py
Environment variables (.env or export):
HF_TOKEN β€” HuggingFace token (required)
GROQ_API_KEY β€” Groq API key (required for end-of-episode summary)
DOCKER_IMAGE_NAME β€” Docker image name (default: pharma-agent)
MODEL_NAME β€” HF model (default: Qwen/Qwen2.5-72B-Instruct)
"""
import os
import time
import json
import subprocess
import requests
from openai import OpenAI
from dotenv import load_dotenv
# Reward ceiling from environment β€” keeps normalisation in sync
from server.environment import MAX_EPISODE_REWARD
load_dotenv()
# ── Config ────────────────────────────────────────────────────────────────
HF_TOKEN = os.environ.get("HF_TOKEN", "")
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
DOCKER_IMAGE = os.environ.get("DOCKER_IMAGE_NAME", "pharma-agent")
MODEL_NAME = os.environ.get("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
ENV_PORT = 7860
ENV_BASE_URL = f"http://localhost:{ENV_PORT}"
HF_ROUTER_URL = "https://router.huggingface.co/v1"
NUM_EPISODES = 5
# ── Clients ───────────────────────────────────────────────────────────────
# Agent: Qwen via HuggingFace router (makes clinical decisions)
agent_client = OpenAI(base_url=HF_ROUTER_URL, api_key=HF_TOKEN)
# Formatter: Groq (formats the final regimen summary only β€” no reward influence)
try:
from groq import Groq
groq_client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None
except ImportError:
groq_client = None
# ── Docker helpers ────────────────────────────────────────────────────────
def start_docker() -> bool:
"""Start the environment container. Returns True when server is ready."""
print(f"Starting Docker container: {DOCKER_IMAGE}")
proc = subprocess.Popen(
["docker", "run", "--rm", "-p", f"{ENV_PORT}:{ENV_PORT}",
"-e", "ENABLE_WEB_INTERFACE=true", DOCKER_IMAGE],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
for _ in range(30):
try:
if requests.get(f"{ENV_BASE_URL}/health", timeout=2).status_code == 200:
print("Environment server is ready.")
return True
except Exception:
pass
ret = proc.poll()
if ret is not None:
out = proc.stdout.read().decode(errors="replace")
err = proc.stderr.read().decode(errors="replace")
print(f"Docker container exited with code {ret}.")
if out: print(f" stdout: {out[:400]}")
if err: print(f" stderr: {err[:400]}")
return False
time.sleep(2)
proc.terminate()
print("Timed out waiting for environment server.")
return False
def env_reset() -> dict:
r = requests.post(f"{ENV_BASE_URL}/reset")
r.raise_for_status()
return r.json()
def env_step(session_id: str, action_type: str, value: str) -> dict:
r = requests.post(
f"{ENV_BASE_URL}/step?session_id={session_id}",
json={"action_type": action_type, "value": value},
)
r.raise_for_status()
return r.json()
# ── Groq formatter (runs after episode, no reward influence) ──────────────
def format_regimen_summary(episode_log: dict) -> str:
"""
Ask Groq to write a plain-English clinical handover note for the
final regimen. This is purely for human readability β€” the score
has already been computed from DrugBank data before this is called.
"""
if not groq_client:
return ""
try:
prompt = (
f"A clinical decision agent managed a patient case.\n\n"
f"Condition: {episode_log.get('condition', 'unknown')}\n"
f"Symptoms: {', '.join(episode_log.get('symptoms', []))}\n"
f"Existing medications: {', '.join(episode_log.get('existing_meds', [])) or 'None'}\n"
f"Agent diagnosis: {episode_log.get('diagnosis', 'not established')}\n"
f"Final regimen: {', '.join(episode_log.get('final_drugs', [])) or 'None'}\n"
f"DDI checks performed: {episode_log.get('ddi_checks', [])}\n"
f"Score: {episode_log.get('score', 0)} / 1.0\n\n"
f"Write a concise clinical handover note (3-5 sentences) summarising "
f"this regimen for a human pharmacist to review. "
f"Flag any concerns. Do not add any new clinical recommendations β€” "
f"only summarise what the agent did."
)
response = groq_client.chat.completions.create(
model="llama-3.3-70b-versatile",
messages=[{"role": "user", "content": prompt}],
max_tokens=300,
temperature=0.1,
)
return response.choices[0].message.content.strip()
except Exception as e:
return f"(Summary unavailable: {e})"
# ── Agent system prompt ───────────────────────────────────────────────────
SYSTEM_PROMPT = """You are PharmaAgent, a clinical pharmacist AI operating in a drug safety evaluation environment.
IMPORTANT: This environment scores your actions using a real pharmacological database (DrugBank, 19,842 drugs, 2.9M interaction pairs). Your decisions are evaluated against real drug data β€” not opinions.
Your task per episode:
1. DIAGNOSE β€” identify the condition from symptoms. Use standard clinical terminology.
2. SELECT_DRUG β€” add drugs to the regimen one at a time. Use exact approved drug names as they appear in drug databases (e.g. "Metformin", "Atorvastatin", "Amlodipine"). Misspelled or hallucinated names earn zero reward.
3. CHECK_DDI β€” check for interactions between the drugs you've selected and the patient's existing medications. Format: "Drug1,Drug2".
4. FINALIZE β€” submit the final regimen.
Scoring rules you must know:
- A drug earns full reward only if DrugBank confirms it is indicated for this condition.
- A drug that has a major/contraindicated interaction with the patient's existing medication earns a safety penalty.
- Performing DDI checks earns reward β€” skipping them when the patient has existing meds earns a penalty.
- Drug names not found in DrugBank earn zero, regardless of clinical reasoning.
Respond ONLY in this JSON format:
{
"action_type": "diagnose|select_drug|check_ddi|finalize",
"value": "your response here",
"reasoning": "brief clinical reasoning (1-2 sentences)"
}"""
# ── Episode runner ────────────────────────────────────────────────────────
def run_agent_episode() -> float:
"""Run one full episode. Returns normalised reward (0.0–1.0)."""
reset_data = env_reset()
session_id = reset_data["session_id"]
obs = reset_data["observation"]
case_condition = obs["patient_case"].get("condition", "Unknown")
symptoms = obs["patient_case"].get("symptoms", [])
existing_meds = obs["patient_case"].get("existing_medications", [])
print(f"\n{'─' * 60}")
print(f"Condition: {case_condition}")
print(f"Symptoms: {', '.join(symptoms)}")
print(f"Existing meds: {', '.join(existing_meds) or 'None'}")
conversation = [{"role": "system", "content": SYSTEM_PROMPT}]
total_reward = 0.0
episode_log = {
"condition": case_condition,
"symptoms": symptoms,
"existing_meds": existing_meds,
"diagnosis": None,
"final_drugs": [],
"ddi_checks": [],
"score": 0.0,
}
for step_num in range(8):
user_msg = (
f"PATIENT CASE:\n"
f"Condition (hidden β€” diagnose from symptoms): {case_condition}\n"
f"Symptoms: {', '.join(obs['patient_case'].get('symptoms', []))}\n"
f"Existing medications: {', '.join(obs['patient_case'].get('existing_medications', [])) or 'None'}\n"
f"Current regimen: {', '.join(obs['patient_case'].get('current_regimen', [])) or 'None'}\n"
f"Diagnosis so far: {obs['patient_case'].get('proposed_diagnosis') or 'Not yet established'}\n\n"
f"Environment feedback:\n{obs['feedback']}\n\n"
f"Valid actions: {obs['valid_options']}\n"
f"Cumulative reward: {obs['reward_so_far']}\n\n"
f"What is your next action? Respond in JSON."
)
conversation.append({"role": "user", "content": user_msg})
try:
response = agent_client.chat.completions.create(
model=MODEL_NAME,
messages=conversation,
max_tokens=300,
temperature=0.2,
)
agent_reply = response.choices[0].message.content.strip()
except Exception as e:
print(f"LLM error: {e}")
break
conversation.append({"role": "assistant", "content": agent_reply})
try:
clean = agent_reply.replace("```json", "").replace("```", "").strip()
action_data = json.loads(clean)
action_type = action_data.get("action_type", "finalize")
value = action_data.get("value", "")
reasoning = action_data.get("reasoning", "")
except json.JSONDecodeError:
print("Could not parse agent JSON β€” finalising.")
action_type, value, reasoning = "finalize", "finalize", ""
print(f"\nStep {step_num + 1} | {action_type.upper()} | {value}")
if reasoning:
print(f" Reasoning: {reasoning[:100]}")
try:
step_result = env_step(session_id, action_type, value)
except Exception as e:
print(f"Environment step error: {e}")
break
obs = step_result["observation"]
step_reward = step_result["reward"]
total_reward = obs["reward_so_far"]
done = step_result["done"]
print(f" Reward: +{step_reward:.3f} | Cumulative: {total_reward:.3f}")
# Track episode log for Groq summary
if action_type == "diagnose":
episode_log["diagnosis"] = value
elif action_type == "check_ddi":
episode_log["ddi_checks"].append(value)
if done:
episode_log["final_drugs"] = obs["patient_case"].get("current_regimen", [])
normalized = round(min(max(total_reward / MAX_EPISODE_REWARD, 0.0), 1.0), 4)
episode_log["score"] = normalized
# Groq formats a human-readable summary β€” does NOT affect the score
summary = format_regimen_summary(episode_log)
if summary:
print(f"\n Clinical Summary (Groq β€” informational only):\n {summary}")
print(f"\nEpisode complete. Reward: {total_reward:.3f} | Normalised: {normalized:.4f}")
return normalized
normalized = round(min(max(total_reward / MAX_EPISODE_REWARD, 0.0), 1.0), 4)
return normalized
# ── Grader ────────────────────────────────────────────────────────────────
def grader(episode_rewards: list) -> float:
"""Average normalised reward across episodes, with consistency bonus."""
if not episode_rewards:
return 0.0
avg = sum(episode_rewards) / len(episode_rewards)
if len(episode_rewards) > 1:
variance = sum((r - avg) ** 2 for r in episode_rewards) / len(episode_rewards)
consistency_bonus = max(0.0, 0.1 - variance)
avg = min(1.0, avg + consistency_bonus)
return round(avg, 4)
# ── Main ──────────────────────────────────────────────────────────────────
def main():
print("=" * 60)
print(" PharmaAgent β€” Clinical Decision RL Environment")
print(" Scoring: DrugBank data only | LLM: formatting only")
print("=" * 60)
if not HF_TOKEN:
print("HF_TOKEN not set. Add it to your .env file.")
return
try:
r = requests.get(f"{ENV_BASE_URL}/health", timeout=3)
if r.status_code == 200:
print("Environment already running.")
except Exception:
if not start_docker():
print("Could not start environment. Ensure Docker is running.")
return
episode_rewards = []
for ep in range(1, NUM_EPISODES + 1):
print(f"\n{'=' * 60}")
print(f" Episode {ep}/{NUM_EPISODES}")
reward = run_agent_episode()
episode_rewards.append(reward)
print(f" Episode normalised reward: {reward:.4f}")
final_score = grader(episode_rewards)
print(f"\n{'=' * 60}")
print(f" FINAL GRADER SCORE: {final_score:.4f} / 1.0000")
print(f" Episode rewards: {episode_rewards}")
print("=" * 60)
return final_score
if __name__ == "__main__":
main()