# server/pharma_agent_environment.py """ PharmaAgent Clinical Decision RL Environment. Three tasks of increasing difficulty: easy - No existing medications. Diagnose + select 1 indicated drug + finalize. medium - Patient has existing medications. Must also perform DDI check. hard - Patient has existing medications with a known contraindicated interaction. Agent must identify and avoid the dangerous drug AND check DDI. """ import os import sys import random import re import sqlite3 import threading from uuid import uuid4 # Ensure the project root is on sys.path so models.py can be found _HERE = os.path.dirname(os.path.abspath(__file__)) _ROOT = os.path.dirname(_HERE) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) if _HERE not in sys.path: sys.path.insert(0, _HERE) from openenv.core.env_server.interfaces import Environment from openenv.core.env_server.types import State from models import PharmaAgentAction, PharmaAgentObservation # ── Config ──────────────────────────────────────────────────────────────────── DB_PATH = os.environ.get("DB_PATH", os.path.join(_ROOT, "drugbank_lite.db")) MAX_EPISODE_REWARD = 1.5 MAX_REWARDED_DDI_CHECKS = 3 MAX_STEPS = 10 # ── Severity patterns ───────────────────────────────────────────────────────── _SEVERITY_PATTERNS = [ ("contraindicated", re.compile(r"\bcontraindicated\b", re.I)), ("major", re.compile(r"\b(major|serious|severe|life.?threatening|fatal)\b", re.I)), ("moderate", re.compile(r"\b(moderate|caution|monitor)\b", re.I)), ("minor", re.compile(r"\b(minor|mild|small)\b", re.I)), ] # ── Condition seeds ─────────────────────────────────────────────────────────── _CONDITION_SEEDS = [ { "condition": "Hypertension", "symptoms": ["persistent headache", "elevated blood pressure", "dizziness", "blurred vision"], "indication_keywords": ["hypertension", "high blood pressure", "antihypertensive"], "diagnosis_keywords": ["hypertension", "blood pressure", "antihypertensive", "vascular"], }, { "condition": "Type 2 Diabetes Mellitus", "symptoms": ["excessive thirst", "frequent urination", "fatigue", "blurred vision"], "indication_keywords": ["type 2 diabetes", "diabetes mellitus", "hyperglycemia", "glycemic"], "diagnosis_keywords": ["diabetes", "hyperglycemia", "insulin resistance", "glycemic", "glucose"], }, { "condition": "Chronic Heart Failure", "symptoms": ["shortness of breath on exertion", "ankle swelling", "fatigue", "orthopnoea"], "indication_keywords": ["heart failure", "cardiac failure", "congestive heart"], "diagnosis_keywords": ["heart failure", "cardiac", "ejection fraction", "congestive"], }, { "condition": "Rheumatoid Arthritis", "symptoms": ["symmetric joint pain", "morning stiffness", "joint swelling", "fatigue"], "indication_keywords": ["rheumatoid arthritis", "rheumatoid", "autoimmune arthritis"], "diagnosis_keywords": ["rheumatoid", "arthritis", "autoimmune", "synovitis", "joint inflammation"], }, { "condition": "Bronchial Asthma", "symptoms": ["recurrent wheeze", "chest tightness", "shortness of breath", "nocturnal cough"], "indication_keywords": ["asthma", "bronchial asthma", "bronchospasm", "airway obstruction"], "diagnosis_keywords": ["asthma", "bronchospasm", "airway", "bronchial", "respiratory"], }, { "condition": "Epilepsy", "symptoms": ["recurrent seizures", "transient loss of consciousness", "post-ictal confusion"], "indication_keywords": ["epilepsy", "seizure", "anticonvulsant", "antiepileptic"], "diagnosis_keywords": ["epilepsy", "seizure", "anticonvulsant", "antiepileptic", "ictal"], }, { "condition": "Hypothyroidism", "symptoms": ["fatigue", "weight gain", "cold intolerance", "constipation", "dry skin"], "indication_keywords": ["hypothyroidism", "thyroid deficiency", "levothyroxine"], "diagnosis_keywords": ["hypothyroidism", "thyroid", "TSH", "levothyroxine", "thyroid hormone"], }, { "condition": "Major Depressive Disorder", "symptoms": ["persistent low mood", "anhedonia", "insomnia", "fatigue", "poor concentration"], "indication_keywords": ["depression", "major depressive", "antidepressant"], "diagnosis_keywords": ["depression", "depressive", "antidepressant", "mood", "serotonin"], }, { "condition": "Peptic Ulcer Disease", "symptoms": ["epigastric pain", "nausea", "bloating", "pain relieved by food"], "indication_keywords": ["peptic ulcer", "gastric ulcer", "duodenal ulcer", "H. pylori"], "diagnosis_keywords": ["peptic ulcer", "gastric", "H. pylori", "proton pump", "acid"], }, { "condition": "Atrial Fibrillation", "symptoms": ["palpitations", "irregular heartbeat", "dyspnoea on exertion", "fatigue"], "indication_keywords": ["atrial fibrillation", "AF", "anticoagulation", "rate control"], "diagnosis_keywords": ["atrial fibrillation", "arrhythmia", "anticoagul", "rate control", "AF"], }, ] # ── Module-level session store ──────────────────────────────────────────────── # Persists state across HTTP requests since the framework creates a new # environment instance per request. _SESSION_LOCK = threading.Lock() _SESSIONS: dict = {} def _store_session(eid: str, ep: dict) -> None: with _SESSION_LOCK: _SESSIONS[eid] = ep def _load_session(eid: str) -> dict | None: with _SESSION_LOCK: return _SESSIONS.get(eid) def _clear_session(eid: str) -> None: with _SESSION_LOCK: _SESSIONS.pop(eid, None) # ── Database helpers ────────────────────────────────────────────────────────── def _get_db(): if not os.path.exists(DB_PATH) or os.path.getsize(DB_PATH) == 0: return None conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def db_get_drug(name: str) -> dict | None: conn = _get_db() if not conn: return None try: row = conn.execute( "SELECT name, indication, status, type FROM drugs WHERE LOWER(name)=LOWER(?)", (name,), ).fetchone() return dict(row) if row else None finally: conn.close() def db_check_interaction(d1: str, d2: str) -> dict | None: conn = _get_db() if not conn: return None try: row = conn.execute( "SELECT description FROM interactions " "WHERE (LOWER(drug1_name)=LOWER(?) AND LOWER(drug2_name)=LOWER(?)) " " OR (LOWER(drug1_name)=LOWER(?) AND LOWER(drug2_name)=LOWER(?)) " "LIMIT 1", (d1, d2, d2, d1), ).fetchone() if not row: return None desc = row["description"] or "" sev = "unknown" for label, pat in _SEVERITY_PATTERNS: if pat.search(desc): sev = label break return {"description": desc, "severity_label": sev} finally: conn.close() def db_get_interactions_for_drug(name: str, limit: int = 10) -> list: conn = _get_db() if not conn: return [] try: rows = conn.execute( "SELECT drug2_name AS partner, description FROM interactions " "WHERE LOWER(drug1_name)=LOWER(?) AND description!='' LIMIT ?", (name, limit), ).fetchall() return [dict(r) for r in rows] finally: conn.close() def db_drugs_for_indication(keywords: list, limit: int = 40) -> list: conn = _get_db() if not conn: return [] try: results, seen = [], set() for kw in keywords: rows = conn.execute( "SELECT name, indication, status FROM drugs " "WHERE LOWER(indication) LIKE LOWER(?) " " AND status LIKE '%approved%' " " AND type='small molecule' " " AND indication!='' LIMIT ?", (f"%{kw}%", limit), ).fetchall() for r in rows: if r["name"] not in seen: seen.add(r["name"]) results.append(dict(r)) return results finally: conn.close() def _parse_sev(desc: str) -> str: for label, pat in _SEVERITY_PATTERNS: if pat.search(desc): return label return "unknown" # ── Case generation ─────────────────────────────────────────────────────────── def generate_case(task: str) -> dict: seed = random.choice(_CONDITION_SEEDS) indicated = db_drugs_for_indication(seed["indication_keywords"], limit=40) if not indicated: return { "id": f"fallback_{task}", "task": task, "condition": seed["condition"], "symptoms": seed["symptoms"], "existing_medications": [], "indication_keywords": seed["indication_keywords"], "diagnosis_keywords": seed["diagnosis_keywords"], "indicated_drug_names": [], "avoid_drugs": [], "existing_med_interactions": {}, } ex_name = random.choice(indicated)["name"] raw_ix = db_get_interactions_for_drug(ex_name, limit=20) avoid, ex_med_ix = [], {} for ix in raw_ix: p, desc = ix["partner"], ix["description"] or "" sev = _parse_sev(desc) ex_med_ix[p] = {"description": desc, "severity": sev} if sev in ("contraindicated", "major"): avoid.append(p) if task == "easy": existing, avoid, ex_med_ix = [], [], {} elif task == "medium": existing = [ex_name] else: # hard existing = [ex_name] if not avoid: avoid = [ p for p, v in ex_med_ix.items() if v["severity"] in ("contraindicated", "major", "moderate") ][:3] return { "id": f"{task}_{seed['condition'].replace(' ', '_')}_{random.randint(1000, 9999)}", "task": task, "condition": seed["condition"], "symptoms": seed["symptoms"], "existing_medications": existing, "indication_keywords": seed["indication_keywords"], "diagnosis_keywords": seed["diagnosis_keywords"], "indicated_drug_names": [d["name"] for d in indicated], "avoid_drugs": avoid, "existing_med_interactions": ex_med_ix, } def _fresh_ep(task: str) -> dict: return { "case": generate_case(task), "task": task, "proposed_diagnosis": None, "selected_drugs": [], "checked_interactions": [], "cumulative_reward": 0.0, "phase": "triage", "done": False, "step_count": 0, } # ── Scoring functions ───────────────────────────────────────────────────────── def score_diagnosis(proposed: str, case: dict) -> tuple[float, str]: pl = proposed.lower() kws = case.get("diagnosis_keywords", []) m = [k for k in kws if k.lower() in pl] if len(m) >= 2: return 0.30, f"Diagnosis supported — matched: {', '.join(m[:3])}." if len(m) == 1: return 0.15, f"Partial diagnosis — matched '{m[0]}'." return 0.00, "Diagnosis does not align with the presenting symptoms." def score_drug(drug: str, case: dict) -> tuple[float, str]: dl = drug.lower() avoid = [d.lower() for d in case.get("avoid_drugs", [])] if dl in avoid: ex = case.get("existing_medications", ["existing medication"]) return -0.25, f"SAFETY: {drug} has a contraindicated/major interaction with {', '.join(ex)} per DrugBank." rec = db_get_drug(drug) if rec is None: return 0.00, f"'{drug}' not found in DrugBank." ind = (rec.get("indication") or "").lower() kws = case.get("indication_keywords", []) if any(k.lower() in ind for k in kws) or drug in case.get("indicated_drug_names", []): return 0.20, f"{drug} is indicated for this condition per DrugBank." return 0.05, f"{drug} exists in DrugBank but indication does not clearly match." def score_ddi(d1: str, d2: str, case: dict, checked: list) -> tuple[float, str]: if d1.lower() == d2.lower(): return 0.0, "Both drugs are the same." already = {frozenset([i["drug1"].lower(), i["drug2"].lower()]) for i in checked} pair = frozenset([d1.lower(), d2.lower()]) if pair in already: return 0.0, f"{d1} x {d2} already checked." if len(already) >= MAX_REWARDED_DDI_CHECKS: return 0.0, "DDI reward cap reached." avoid = [d.lower() for d in case.get("avoid_drugs", [])] flagged = d1.lower() in avoid or d2.lower() in avoid res = db_check_interaction(d1, d2) if flagged and res: sev = res["severity_label"].upper() return 0.30, f"CRITICAL DDI [{sev}]: {d1} x {d2}. {res['description'][:200]}" if flagged: return 0.15, f"{d1} or {d2} flagged as dangerous with existing medications." if res: sev = res["severity_label"].upper() return 0.15, f"Interaction [{sev}]: {d1} x {d2}. {res['description'][:150]}" return 0.05, f"No interaction found between {d1} and {d2}." def score_finalize(ep: dict) -> tuple[float, str]: sl = [d.lower() for d in ep["selected_drugs"]] ind = [d.lower() for d in ep["case"].get("indicated_drug_names", [])] avoid = [d.lower() for d in ep["case"].get("avoid_drugs", [])] ex = ep["case"].get("existing_medications", []) hits = [d for d in sl if d in ind] bad = [d for d in sl if d in avoid] r, parts = 0.0, [] if hits: r += 0.10 parts.append(f"Indicated: {', '.join(d.title() for d in hits)}") else: parts.append("No indicated drugs in regimen.") if not bad: r += 0.10 parts.append("No contraindicated drugs.") else: pen = 0.20 * len(bad) r -= pen parts.append(f"Contraindicated present: -{pen:.2f}") if ep["proposed_diagnosis"]: r += 0.05 parts.append("Diagnosis established.") if ep["checked_interactions"]: r += 0.05 parts.append(f"{len(ep['checked_interactions'])} DDI check(s).") elif ex and ep["selected_drugs"]: r -= 0.10 parts.append("Safety penalty: no DDI checks.") return round(r, 3), "Final Evaluation\n" + "\n".join(f" {p}" for p in parts) # ── Environment class ───────────────────────────────────────────────────────── class PharmaAgentEnvironment(Environment): """ PharmaAgent Clinical Decision RL Environment. The environment simulates clinical pharmacist decision-making: - Diagnose the patient's condition from symptoms - Select appropriate drugs (checked against DrugBank) - Check drug-drug interactions (DDI) when patient has existing meds - Finalize the treatment regimen Three tasks of increasing difficulty: easy - No existing medications. Common condition. medium - Patient has existing medications. DDI check required. hard - Existing meds with a contraindicated interaction to catch. """ SUPPORTS_CONCURRENT_SESSIONS: bool = True TASKS = ["easy", "medium", "hard"] def __init__(self, task: str = "easy"): self._task = task if task in self.TASKS else "easy" self._episode_id: str = str(uuid4()) self._state = State(episode_id=self._episode_id, step_count=0) def reset(self) -> PharmaAgentObservation: """Reset the environment and generate a new patient case.""" self._episode_id = str(uuid4()) self._state = State(episode_id=self._episode_id, step_count=0) ep = _fresh_ep(self._task) _store_session(self._episode_id, ep) case = ep["case"] existing_str = ", ".join(case["existing_medications"]) or "None" feedback = ( f"New patient [{self._task.upper()}].\n" f"Symptoms: {', '.join(case['symptoms'])}\n" f"Existing meds: {existing_str}\n\n" f"Start with action_type='diagnose'." ) return PharmaAgentObservation( task=self._task, phase="triage", symptoms=case["symptoms"], existing_medications=case["existing_medications"], current_regimen=[], proposed_diagnosis=None, feedback=feedback, valid_options=["diagnose"], reward_so_far=0.0, step_count=0, done=False, reward=0.0, metadata={"episode_id": self._episode_id, "task": self._task}, ) def step(self, action: PharmaAgentAction) -> PharmaAgentObservation: """Execute one action and return the next observation.""" eid = self._episode_id ep = _load_session(eid) # Guard: if no session exists (e.g. framework created fresh instance), # start a fresh episode so we never crash. if ep is None: ep = _fresh_ep(self._task) _store_session(eid, ep) ep["step_count"] = ep.get("step_count", 0) + 1 case = ep["case"] step_r = 0.0 feedback = "" done = False # Already done if ep["done"]: return PharmaAgentObservation( task=self._task, phase="done", symptoms=case["symptoms"], existing_medications=case["existing_medications"], current_regimen=ep["selected_drugs"], proposed_diagnosis=ep["proposed_diagnosis"], feedback="Episode complete. Call reset() to start a new episode.", valid_options=[], reward_so_far=round(ep["cumulative_reward"], 3), step_count=ep["step_count"], done=True, reward=0.0, metadata={"episode_id": eid}, ) atype = action.action_type.strip().lower() value = action.value.strip() if atype == "diagnose": step_r, feedback = score_diagnosis(value, case) ep["proposed_diagnosis"] = value ep["cumulative_reward"] += step_r ep["phase"] = "selection" feedback += "\n\nNext: select_drug." elif atype == "select_drug": step_r, feedback = score_drug(value, case) avoid = [d.lower() for d in case.get("avoid_drugs", [])] if value.lower() not in avoid and value not in ep["selected_drugs"]: ep["selected_drugs"].append(value) ep["cumulative_reward"] += step_r ep["phase"] = "safety" feedback += f"\n\nRegimen: {', '.join(ep['selected_drugs']) or 'None'}" elif atype == "check_ddi": pts = value.replace(" vs ", ",").replace(" and ", ",").split(",") if len(pts) >= 2: d1, d2 = pts[0].strip(), pts[1].strip() step_r, feedback = score_ddi(d1, d2, case, ep["checked_interactions"]) ep["checked_interactions"].append({"drug1": d1, "drug2": d2, "reward": step_r}) ep["cumulative_reward"] += step_r ep["phase"] = "safety" feedback += f"\n\nRegimen: {', '.join(ep['selected_drugs']) or 'None'}" else: feedback = "Provide two drugs separated by comma: Drug1,Drug2" elif atype == "finalize": step_r, feedback = score_finalize(ep) ep["cumulative_reward"] += step_r done = True ep["done"] = True ep["phase"] = "done" feedback += f"\n\nTotal reward: {round(ep['cumulative_reward'], 3)} / {MAX_EPISODE_REWARD}" _clear_session(eid) else: feedback = ( f"Unknown action_type '{atype}'. " "Valid: diagnose, select_drug, check_ddi, finalize." ) # Step limit if ep["step_count"] >= MAX_STEPS and not done: done = True ep["done"] = True ep["phase"] = "done" feedback += "\n\nStep limit reached. Episode ended." _clear_session(eid) if not done: _store_session(eid, ep) valid_opts: list[str] if done: valid_opts = [] elif ep["phase"] == "triage": valid_opts = ["diagnose"] else: valid_opts = ["select_drug", "check_ddi", "finalize"] return PharmaAgentObservation( task=self._task, phase=ep["phase"], symptoms=case["symptoms"], existing_medications=case["existing_medications"], current_regimen=ep["selected_drugs"], proposed_diagnosis=ep["proposed_diagnosis"], feedback=feedback, valid_options=valid_opts, reward_so_far=round(ep["cumulative_reward"], 3), step_count=ep["step_count"], done=done, reward=round(step_r, 3), metadata={"episode_id": eid, "task": self._task}, ) @property def state(self) -> State: return self._state