pharma-agent / server /environment.py
Arshdeep2k5
Fresh start - no db files
8a9e2f2
Raw
History Blame Contribute Delete
23.7 kB
"""
PharmaAgent β€” environment.py
Clinical Decision RL Environment
Design principles:
1. Every reward signal is derived exclusively from DrugBank data.
No hardcoded drug lists, no opinion-based scoring.
2. Patient cases are generated dynamically from the live database.
The agent cannot memorise them β€” it must reason.
3. The LLM (Groq) is called once per episode, at the very end,
only to format a human-readable clinical summary of the final
regimen. It has zero influence on any reward value.
4. If DrugBank has no data on a drug, the reward is 0.0 β€” not a
guess. Unknown = unverified = no credit.
"""
import random
import re
import sqlite3
import os
from typing import Tuple, Optional
from .models import Action, Observation, State
DB_PATH = os.environ.get("DB_PATH", "drugbank.db")
# ── Reward ceiling (used by inference.py for normalisation) ───────────────
# Per episode maximum achievable:
# 0.30 diagnosis (DrugBank indication match, full score)
# 0.60 drugs (up to 3 drugs x 0.20 each from DrugBank)
# 0.30 DDI safety (catching a real contraindicated/major pair)
# 0.30 finalize (clean regimen, safety checks done, no banned drugs)
MAX_EPISODE_REWARD = 1.5
# Cap DDI checks that earn reward β€” prevents reward farming
MAX_REWARDED_DDI_CHECKS = 3
# Severity keywords parsed from DrugBank interaction description text.
# Ordered most-to-least severe so the first match wins.
_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)),
]
# ── DB helpers ────────────────────────────────────────────────────────────
def _get_db() -> Optional[sqlite3.Connection]:
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(drug_name: str) -> Optional[dict]:
"""Fetch a drug record from DrugBank by exact name (case-insensitive)."""
conn = _get_db()
if not conn:
return None
try:
row = conn.execute(
"""SELECT name, indication, mechanism_of_action,
pharmacodynamics, metabolism, status, type
FROM drugs WHERE LOWER(name) = LOWER(?)""",
(drug_name,)
).fetchone()
return dict(row) if row else None
finally:
conn.close()
def db_check_interaction(drug1: str, drug2: str) -> Optional[dict]:
"""
Look up a DDI pair in DrugBank.
Returns dict with keys: description, severity_label.
severity_label is derived by parsing DrugBank's own description text.
"""
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""",
(drug1, drug2, drug2, drug1)
).fetchone()
if not row:
return None
desc = row["description"] or ""
severity = "unknown"
for label, pattern in _SEVERITY_PATTERNS:
if pattern.search(desc):
severity = label
break
return {"description": desc, "severity_label": severity}
finally:
conn.close()
def db_get_interactions_for_drug(drug_name: str, limit: int = 10) -> list:
"""Return known interaction partners for a drug (used in case generation)."""
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 ?""",
(drug_name, limit)
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def db_drugs_for_indication(keywords: list, limit: int = 40) -> list:
"""
Return approved small-molecule drugs whose indication text contains
at least one of the given keywords.
"""
conn = _get_db()
if not conn:
return []
try:
results = []
seen = set()
for kw in keywords:
rows = conn.execute(
"""SELECT name, indication, mechanism_of_action, 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()
# ── Condition seeds ───────────────────────────────────────────────────────
# Plain-English conditions + keywords used to query DrugBank.
# These are established clinical facts, not scoring opinions.
_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"],
},
]
# ── Dynamic case generation ───────────────────────────────────────────────
def generate_case_from_db() -> dict:
"""
Build a patient case dynamically from DrugBank.
Steps:
1. Pick a random condition seed.
2. Query DrugBank for all approved drugs indicated for that condition.
3. Pick one of those drugs as the patient's existing medication.
4. Fetch that drug's known interaction partners from DrugBank.
5. Mark partners with contraindicated/major severity as avoid_drugs.
The result is a case where:
- indicated_drug_names comes from DrugBank (19,842 drugs)
- avoid_drugs comes from DrugBank (2.9M interaction pairs)
- No hardcoded drug names anywhere
Falls back to a minimal case if the DB is unavailable.
"""
seed = random.choice(_CONDITION_SEEDS)
indicated_drugs = db_drugs_for_indication(seed["indication_keywords"], limit=40)
if not indicated_drugs:
# DB unavailable β€” minimal fallback
return {
"id": f"fallback_{seed['condition'].replace(' ', '_')}",
"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": {},
}
existing_med = random.choice(indicated_drugs)
existing_med_name = existing_med["name"]
raw_interactions = db_get_interactions_for_drug(existing_med_name, limit=10)
avoid_drugs = []
existing_med_interactions = {}
for ix in raw_interactions:
partner = ix["partner"]
desc = ix["description"] or ""
severity = "unknown"
for label, pattern in _SEVERITY_PATTERNS:
if pattern.search(desc):
severity = label
break
existing_med_interactions[partner] = {"description": desc, "severity": severity}
if severity in ("contraindicated", "major"):
avoid_drugs.append(partner)
return {
"id": f"dynamic_{seed['condition'].replace(' ', '_')}_{random.randint(1000, 9999)}",
"condition": seed["condition"],
"symptoms": seed["symptoms"],
"existing_medications": [existing_med_name],
"indication_keywords": seed["indication_keywords"],
"diagnosis_keywords": seed["diagnosis_keywords"],
"indicated_drug_names": [d["name"] for d in indicated_drugs],
"avoid_drugs": avoid_drugs,
"existing_med_interactions": existing_med_interactions,
}
# ── Scoring ───────────────────────────────────────────────────────────────
def score_diagnosis(proposed: str, case: dict) -> Tuple[float, str]:
"""
Score against DrugBank-derived clinical keywords.
Full 0.30 for >= 2 keyword matches, 0.15 for 1, 0.0 for none.
"""
proposed_lower = proposed.lower()
keywords = case.get("diagnosis_keywords", [])
matches = [kw for kw in keywords if kw.lower() in proposed_lower]
if len(matches) >= 2:
return 0.30, (
f"Diagnosis supported β€” matched: {', '.join(matches[:3])}."
)
elif len(matches) == 1:
return 0.15, (
f"Partial diagnosis β€” matched '{matches[0]}'. Consider the full clinical picture."
)
else:
return 0.00, (
"Diagnosis does not align with the presenting symptoms."
)
def score_drug_selection(drug: str, case: dict) -> Tuple[float, str]:
"""
Score using DrugBank as sole arbiter.
-0.25 Drug is in avoid_drugs (derived from real DrugBank DDI severity)
0.20 DrugBank confirms indication for this condition
0.05 Drug exists in DrugBank but indication doesn't match
0.00 Drug not found in DrugBank (hallucinated names earn nothing)
"""
drug_lower = drug.lower()
avoid = [d.lower() for d in case.get("avoid_drugs", [])]
if drug_lower in avoid:
existing = case.get("existing_medications", ["existing medication"])
return -0.25, (
f"SAFETY: {drug} has a contraindicated or major interaction "
f"with {', '.join(existing)} per DrugBank. Not added to regimen."
)
db_record = db_get_drug(drug)
if db_record is None:
return 0.00, (
f"'{drug}' not found in DrugBank. "
f"Only verified drugs earn reward. Check spelling or use the full approved name."
)
indication_text = (db_record.get("indication") or "").lower()
keywords = case.get("indication_keywords", [])
matched_kw = [kw for kw in keywords if kw.lower() in indication_text]
in_indicated_list = drug in case.get("indicated_drug_names", [])
if matched_kw or in_indicated_list:
return 0.20, (
f"{drug} is indicated for this condition per DrugBank "
f"({db_record.get('status', 'approved')})."
)
else:
return 0.05, (
f"{drug} is a known approved drug in DrugBank, but its primary "
f"indication does not clearly match this condition."
)
def score_ddi_check(drug1: str, drug2: str, case: dict,
checked_interactions: list) -> Tuple[float, str]:
"""
Score using DrugBank's 2.9M interaction table exclusively.
Severity is parsed from DrugBank description text, never hardcoded.
"""
if drug1.lower() == drug2.lower():
return 0.0, f"Both drugs are the same ('{drug1}'). Provide two different drugs."
already_checked = {
frozenset([i["drug1"].lower(), i["drug2"].lower()])
for i in checked_interactions
}
this_pair = frozenset([drug1.lower(), drug2.lower()])
if this_pair in already_checked:
return 0.0, f"{drug1} x {drug2} already checked. No additional reward."
if len(already_checked) >= MAX_REWARDED_DDI_CHECKS:
return 0.0, f"Reward cap ({MAX_REWARDED_DDI_CHECKS} checks) reached."
avoid = [d.lower() for d in case.get("avoid_drugs", [])]
is_flagged = drug1.lower() in avoid or drug2.lower() in avoid
db_result = db_check_interaction(drug1, drug2)
if is_flagged and db_result:
sev = db_result["severity_label"].upper()
return 0.30, (
f"CRITICAL DDI [{sev}]: {drug1} x {drug2}\n"
f"DrugBank: {db_result['description'][:250]}\n"
f"This combination must be avoided for this patient."
)
elif is_flagged:
return 0.15, (
f"{drug1} or {drug2} is flagged as potentially dangerous "
f"with this patient's existing medications. Caution warranted."
)
elif db_result:
sev = db_result["severity_label"].upper()
return 0.15, (
f"Interaction found [{sev}]: {drug1} x {drug2}\n"
f"DrugBank: {db_result['description'][:200]}"
)
else:
return 0.05, (
f"No interaction found in DrugBank between {drug1} and {drug2}."
)
def score_finalize(state: State) -> Tuple[float, str]:
"""
Score the completed regimen. All checks against DrugBank-derived case data.
"""
case = state.patient_case
selected_lower = [d.lower() for d in state.selected_drugs]
indicated = [d.lower() for d in case.get("indicated_drug_names", [])]
avoid = [d.lower() for d in case.get("avoid_drugs", [])]
existing_meds = case.get("existing_medications", [])
indicated_hits = [d for d in selected_lower if d in indicated]
bad_drugs = [d for d in selected_lower if d in avoid]
has_diagnosis = state.proposed_diagnosis is not None
checked_safety = len(state.checked_interactions) > 0
skipped_safety = (
len(existing_meds) > 0
and len(state.selected_drugs) > 0
and not checked_safety
)
reward = 0.0
parts = []
if indicated_hits:
reward += 0.10
parts.append(f"Regimen contains DrugBank-indicated drug(s): {', '.join(d.title() for d in indicated_hits)}")
else:
parts.append("No DrugBank-indicated drugs found in the final regimen.")
if not bad_drugs:
reward += 0.10
parts.append("No contraindicated drugs in regimen.")
else:
penalty = 0.20 * len(bad_drugs)
reward -= penalty
parts.append(f"Contraindicated drug(s) in regimen: {', '.join(d.title() for d in bad_drugs)} (penalty -{penalty:.2f})")
if has_diagnosis:
reward += 0.05
parts.append(f"Diagnosis established: '{state.proposed_diagnosis}'.")
if checked_safety:
reward += 0.05
parts.append(f"{len(state.checked_interactions)} DDI check(s) performed.")
elif skipped_safety:
reward -= 0.10
parts.append(f"Safety penalty: patient on {', '.join(existing_meds)} but no DDI checks performed.")
feedback = "Final Regimen Evaluation\n" + "\n".join(f" {p}" for p in parts)
return round(reward, 3), feedback
# ── Environment ───────────────────────────────────────────────────────────
class PharmaAgentEnvironment:
def reset(self) -> Tuple[State, Observation]:
case = generate_case_from_db()
state = State(patient_case=case)
obs = Observation(
step=0,
phase="triage",
patient_case={
"condition": case["condition"],
"symptoms": case["symptoms"],
"existing_medications": case["existing_medications"],
"condition_hint": "Unknown β€” diagnose from symptoms only.",
},
feedback=(
f"New patient case.\n"
f"Symptoms: {', '.join(case['symptoms'])}\n"
f"Current medications: {', '.join(case['existing_medications']) or 'None'}\n\n"
f"Begin with action_type='diagnose'."
),
valid_options=["diagnose"],
reward_so_far=0.0,
done=False,
)
return state, obs
def step(self, state: State, action: Action) -> Tuple[State, Observation, float, bool]:
state.step += 1
reward = 0.0
feedback = ""
done = False
next_phase = state.current_phase
valid_options = ["diagnose", "select_drug", "check_ddi", "finalize"]
if action.action_type == "diagnose":
reward, feedback = score_diagnosis(action.value, state.patient_case)
state.proposed_diagnosis = action.value
state.cumulative_reward += reward
state.phase_rewards["triage"] = reward
next_phase = "selection"
valid_options = ["select_drug", "finalize"]
feedback += (
"\n\nNext: action_type='select_drug'. "
"Only drugs found in DrugBank earn reward. "
"Add 2-3 drugs, then use 'check_ddi' before finalising."
)
elif action.action_type == "select_drug":
reward, feedback = score_drug_selection(action.value, state.patient_case)
avoid = [d.lower() for d in state.patient_case.get("avoid_drugs", [])]
if action.value.lower() not in avoid and action.value not in state.selected_drugs:
state.selected_drugs.append(action.value)
state.cumulative_reward += reward
next_phase = "safety"
valid_options = ["select_drug", "check_ddi", "finalize"]
feedback += (
f"\n\nCurrent regimen: {', '.join(state.selected_drugs) or 'None'}"
)
elif action.action_type == "check_ddi":
parts = action.value.replace(" vs ", ",").replace(" and ", ",").split(",")
if len(parts) >= 2:
d1, d2 = parts[0].strip(), parts[1].strip()
reward, feedback = score_ddi_check(
d1, d2, state.patient_case, state.checked_interactions
)
state.checked_interactions.append({"drug1": d1, "drug2": d2, "reward": reward})
state.cumulative_reward += reward
next_phase = "safety"
valid_options = ["select_drug", "check_ddi", "finalize"]
feedback += f"\n\nCurrent regimen: {', '.join(state.selected_drugs) or 'None'}"
else:
feedback = "Provide two drug names separated by comma, 'vs', or 'and'."
reward = 0.0
valid_options = ["check_ddi"]
elif action.action_type == "finalize":
reward, feedback = score_finalize(state)
state.cumulative_reward += reward
state.phase_rewards["finalize"] = reward
done = True
next_phase = "done"
valid_options = []
feedback += (
f"\n\nEpisode complete.\n"
f"Final regimen: {', '.join(state.selected_drugs) or 'None'}\n"
f"Total reward: {round(state.cumulative_reward, 3)} / {MAX_EPISODE_REWARD}"
)
else:
feedback = (
f"Unknown action_type '{action.action_type}'. "
f"Valid: diagnose, select_drug, check_ddi, finalize."
)
if state.step >= state.max_steps and not done:
done = True
next_phase = "done"
valid_options = []
feedback += f"\n\nStep limit ({state.max_steps}) reached."
state.current_phase = next_phase
state.done = done
obs = Observation(
step=state.step,
phase=next_phase,
patient_case={
"condition": state.patient_case.get("condition"),
"symptoms": state.patient_case["symptoms"],
"existing_medications": state.patient_case["existing_medications"],
"proposed_diagnosis": state.proposed_diagnosis,
"current_regimen": state.selected_drugs,
},
feedback=feedback,
valid_options=valid_options,
reward_so_far=round(state.cumulative_reward, 3),
done=done,
)
return state, obs, round(reward, 3), done