diff --git a/.gitignore b/.gitignore index c8f32108bd848ae948a1ea99189c62c952017198..9baeb38e3f93d0d7d831ff3f28373b5341acf696 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,34 @@ +# --- Python --- venv/ +.venv/ +env/ .env +.env.* +*.py[cod] __pycache__/ -*.pyc -frontend/node_modules/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml + +# --- Node / frontend --- +node_modules/ +**/node_modules/ frontend/dist/ +**/dist/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# --- Build / temp --- +*.log +*.tmp +*.swp +.DS_Store + +# --- Project-specific nested paths --- +openenv-polypharmacy/frontend/node_modules/ +openenv-polypharmacy/frontend/dist/ +openenv-polypharmacy/.pytest_cache/ diff --git a/openenv-polypharmacy/.dockerignore b/openenv-polypharmacy/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..5007867e3a3b2c5514c4ff5bb18588b36951901f --- /dev/null +++ b/openenv-polypharmacy/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +**/__pycache__/ +**/.pytest_cache/ +**/.DS_Store +.env +frontend/node_modules +frontend/dist diff --git a/openenv-polypharmacy/Dockerfile b/openenv-polypharmacy/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..68b69d986a780501f6c9461410b2add26413473e --- /dev/null +++ b/openenv-polypharmacy/Dockerfile @@ -0,0 +1,39 @@ +FROM node:20-alpine AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM python:3.11-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends build-essential curl && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY backend/requirements.txt /app/backend/requirements.txt +RUN pip install --no-cache-dir -r /app/backend/requirements.txt + +COPY backend /app/backend +COPY data /app/data +COPY scripts /app/scripts +COPY openenv.yaml /app/openenv.yaml +COPY .env.example /app/.env.example +COPY inference.py /app/inference.py + +COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist + +RUN python3 /app/scripts/preprocess_data.py + +ENV PORT=7860 +ENV PYTHONPATH="/app/backend/src:${PYTHONPATH}" +ENV PYTHONUNBUFFERED=1 + +EXPOSE 7860 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \ + CMD curl -f http://localhost:7860/health || exit 1 + +CMD ["sh", "-c", "uvicorn backend.main:app --host 0.0.0.0 --port ${PORT:-7860}"] diff --git a/openenv-polypharmacy/PROMPT.md b/openenv-polypharmacy/PROMPT.md new file mode 100644 index 0000000000000000000000000000000000000000..d7d5481b1ba6a83c8590ebcec024b911f5800982 --- /dev/null +++ b/openenv-polypharmacy/PROMPT.md @@ -0,0 +1,571 @@ +You are an expert Python backend, ML, and infrastructure engineer. +Your task is to implement a complete, production-ready OpenEnv environment called **PolypharmacyEnv** for training and evaluating agentic RL policies that act as an "elderly polypharmacy safety agent" (clinical pharmacist assistant). + +The deliverable MUST satisfy all of the following: +- Fully compliant with the OpenEnv spec (typed models, `step()` / `reset()` / `state()`, `openenv.yaml`, HTTP server, Dockerfile). +- Simulates a realistic healthcare workflow around elderly polypharmacy and dangerous drug combinations. +- Defines at least **3 tasks** (easy → medium → hard) with deterministic agent graders producing scores in (0.0, 1.0). +- Provides shaped rewards over the trajectory (not just sparse terminal rewards). +- Includes a baseline LLM-based inference script `inference.py` in the repo root, following the evaluation requirements: + - Uses the OpenAI Python client. + - Reads `OPENAI_API_KEY`, `API_BASE_URL`, `MODEL_NAME`, and `HF_TOKEN` from the environment. + - Emits structured stdout logs in the exact `[START]`, `[STEP]`, `[END]` format from the OpenEnv sample inference script. +- Is containerized and deployable as a **Hugging Face Space** tagged with `openenv` that responds to OpenEnv-style `reset` / `step` / `state` HTTP calls. + +Implement everything described below. + +================================================= +1. Repository and folder structure +================================================= + +Create a Python package repository with this structure (names are important unless clearly labeled as examples): + +- `openenv-polypharmacy/` + - `openenv.yaml` + - `README.md` + - `requirements.txt` + - `Dockerfile` + - `inference.py` # baseline LLM agent per spec + - `pyproject.toml` or `setup.cfg` (optional but recommended) + - `src/` + - `polypharmacy_env/` + - `__init__.py` + - `config.py` + - `models.py` # Action, Observation, State, helper models + - `env_core.py` # PolypharmacyEnv implementation + - `tasks.py` # task setup utilities + - `graders.py` # deterministic graders for each task + - `rewards.py` # reward shaping logic + - `data_loader.py` # load/preprocess patient and lookup data + - `ddi_simulator.py` # local DDI / guideline simulator + - `api/` + - `__init__.py` + - `schemas.py` # HTTP request/response schemas + - `server.py` # FastAPI app exposing OpenEnv endpoints + - `baselines/` + - `__init__.py` + - `heuristic_agent.py` # simple rule-based baseline agent + - `random_agent.py` # trivial random baseline (optional) + - `tests/` + - `__init__.py` + - `test_env_core.py` + - `test_api.py` + - `data/` + - `raw/` # placeholder for real/synthetic source data + - `processed/` + - `lookups/` + - `ddi_rules.csv` + - `beers_criteria.csv` + - `drug_metadata.csv` + - `scripts/` + - `preprocess_data.py` + - `run_validation.sh` # optional; runs OpenEnv validator, tests, etc. + +Use Python 3.10+ with full type hints, and keep the code black/isort-compatible. + +================================================= +2. Domain, data, and clinical abstraction +================================================= + +2.1. Core scenario + +Model an elderly patient (age ≥ 65) with: +- Demographics: age, sex. +- Comorbidities: e.g., hypertension, diabetes, heart failure, CKD, dementia. +- Basic labs: kidney function (eGFR category), liver function category. +- A current medication list (polypharmacy, e.g., 3–15 drugs depending on task). + +Each **episode** is one medication-review session where the agent: +- Observes patient info and current meds. +- Optionally **queries** a DDI/guideline tool for specific drug pairs. +- Proposes **interventions**: + - `stop`: discontinue a drug. + - `dose_reduce`: lower dose of a drug. + - `substitute`: swap to a safer alternative. + - `add_monitoring`: keep the drug but flag extra monitoring. +- Calls `finish_review` when it decides the regimen is acceptable or budgets are exhausted. + +No external PHI, EHRs, or online APIs: all data is **synthetic** or de-identified and local to the container (CSV files). + +2.2. Data files and CSV schemas + +Implement local CSVs under `data/lookups/`: + +**`drug_metadata.csv`** +- `drug_id` (string; unique key) +- `generic_name` (string) +- `atc_class` (string) +- `is_high_risk_elderly` (0/1) +- `default_dose_mg` (float) +- `min_dose_mg` (float) +- `max_dose_mg` (float) + +**`beers_criteria.csv`** +- `drug_id` (string) +- `criterion_type` (enum string: `avoid`, `caution`, `dose_adjust`, `avoid_in_condition`) +- `condition` (nullable string; e.g., `CKD`, `dementia`) +- `rationale` (brief text) + +**`ddi_rules.csv`** +- `drug_id_1` (string; normalized so `drug_id_1 < drug_id_2` lexicographically) +- `drug_id_2` (string) +- `severity` (enum string: `mild`, `moderate`, `severe`) +- `mechanism` (short text) +- `recommendation` (enum string: `avoid_combination`, `monitor_closely`, `dose_adjust`, `no_action`) +- `base_risk_score` (float in [0.0, 1.0]) + +Implement a synthetic patient-episode dataset under `data/processed/`: + +**`patients_polypharmacy.csv`** +- `episode_id` (string) +- `age` (int) +- `sex` (enum: `M`, `F`, `O`) +- `conditions` (semicolon-separated; e.g., `HTN;DM;CKD`) +- `eGFR_category` (enum: `normal`, `mild`, `moderate`, `severe`) +- `liver_function_category` (enum: `normal`, `impaired`) +- `medication_ids` (semicolon-separated list of `drug_id`) +- `baseline_risk_score` (float in [0.0, 1.0]) + +2.3. Preprocessing script + +In `scripts/preprocess_data.py`: +- If real data is not provided, procedurally generate synthetic but plausible data using: + - Random combinations of conditions and drugs constrained by simple rules (e.g., CKD + renally-cleared drugs). + - Controlled distribution of high-risk DDIs and Beers violations. +- Explicitly tag episodes as easy/medium/hard (e.g., via number of drugs, number/severity of DDIs, and number of Beers issues). +- Save `patients_polypharmacy.csv` ready for the environment to consume. + +================================================= +3. OpenEnv models and environment implementation +================================================= + +3.1. Models + +In `models.py`, define dataclasses or Pydantic models that extend the appropriate OpenEnv base types (`Action`, `Observation`, `State`) and are JSON-compatible. + +Auxiliary models: + +**`MedicationEntry`** +- `drug_id: str` +- `generic_name: str` +- `atc_class: str` +- `dose_mg: float` +- `frequency: str` # e.g., `qd`, `bid` +- `route: str` # e.g., `po` +- `is_high_risk_elderly: bool` +- `beers_flags: list[str]` # e.g., `["avoid", "dose_adjust_CKD"]` + +**`InteractionQueryRecord`** +- `drug_id_1: str` +- `drug_id_2: str` +- `severity: str | None` +- `recommendation: str | None` +- `risk_score: float | None` +- `step_index: int` + +**`InterventionRecord`** +- `target_drug_id: str` +- `action_type: Literal["stop", "dose_reduce", "substitute", "add_monitoring"]` +- `proposed_new_drug_id: str | None` +- `rationale: str` +- `step_index: int` + +Core wire models: + +**`PolypharmacyObservation`** (extends OpenEnv `Observation`) +- `episode_id: str` +- `task_id: Literal["easy_screening", "budgeted_screening", "complex_tradeoff"]` +- `age: int` +- `sex: str` +- `conditions: list[str]` +- `eGFR_category: str` +- `liver_function_category: str` +- `current_medications: list[MedicationEntry]` +- `interaction_queries: list[InteractionQueryRecord]` +- `interventions: list[InterventionRecord]` +- `step_index: int` +- `remaining_query_budget: int` +- `remaining_intervention_budget: int` +- `shaped_reward: float` # reward from last step +- `done: bool` + +**`PolypharmacyAction`** (extends OpenEnv `Action`) +- `action_type: Literal["query_ddi", "propose_intervention", "finish_review"]` +- `drug_id_1: str | None` # for DDI queries or some interventions +- `drug_id_2: str | None` # for DDI queries +- `target_drug_id: str | None` # for interventions +- `intervention_type: Literal["stop", "dose_reduce", "substitute", "add_monitoring", "none"] | None` +- `proposed_new_drug_id: str | None` +- `rationale: str | None` + +**`PolypharmacyState`** (extends OpenEnv `State`) +- `episode_id: str` +- `task_id: str` +- `step_count: int` +- `max_steps: int` +- `num_query_actions: int` +- `num_interventions: int` + +3.2. Environment core + +In `env_core.py`, implement `PolypharmacyEnv` extending the appropriate OpenEnv environment base class. It must implement: + +**`reset(task_id: str | None = None) -> PolypharmacyObservation`** +- If `task_id` is `None`, default to medium (`budgeted_screening`). +- Sample an episode from `patients_polypharmacy.csv` filtered by difficulty. +- Initialize: + - `episode_id` + - `step_count = 0` + - task-specific budgets (query, interventions, max_steps) + - baseline regime and risk + - empty `interaction_queries` and `interventions` +- Return the initial `PolypharmacyObservation` with: + - `step_index = 0` + - `shaped_reward = 0.0` + - `done = False` + +**`step(action: PolypharmacyAction) -> dict`** +- Validate the action; if invalid: + - Apply a negative reward. + - Do not modify regimen, but log error in `info`. +- If `action_type == "query_ddi"`: + - If query budget exhausted, apply penalty and do not query. + - Else: + - Use `ddi_simulator.lookup_ddi(drug_id_1, drug_id_2)` to get severity, recommendation, base_risk_score. + - Append an `InteractionQueryRecord`. + - Apply a small negative reward for query cost. +- If `action_type == "propose_intervention"`: + - If intervention budget exhausted, apply penalty and ignore change. + - Else: + - Update `current_medications` according to `intervention_type`: + - `stop`: remove medication. + - `dose_reduce`: adjust dose downward within [min_dose_mg, default_dose_mg]. + - `substitute`: replace with a safer alternative from same `atc_class`. + - `add_monitoring`: keep drug but tag in internal state. + - Append an `InterventionRecord`. + - Recompute current regimen risk using the risk model (see 3.3). + - Compute shaped reward = (previous_risk - new_risk) - small intervention cost. +- If `action_type == "finish_review"`: + - Mark `done = True`. + - Call the task’s grader to get episode-level score in [0.0, 1.0]. + - Add this as a terminal bonus to the current step reward. + +- In all cases: + - Increment `step_count`. + - Check `max_steps`; if exceeded, auto-terminate: + - `done = True` + - apply time-out penalty + - call grader with current trajectory for a final score if appropriate. + - Construct next `PolypharmacyObservation` with updated fields. + - Return a dict: + - `observation`: `PolypharmacyObservation` + - `reward`: float shaped reward for this step + - `done`: bool + - `info`: dict with fields like `current_risk`, `baseline_risk`, `grader_score_if_terminal`, and debug flags. + +**`state` property** +- Returns `PolypharmacyState` reflecting the current internal state. + +3.3. DDI simulator and risk model + +In `ddi_simulator.py`: +- Load `ddi_rules.csv` once via `data_loader`. +- Implement `lookup_ddi(drug_id_1, drug_id_2) -> tuple[severity, recommendation, base_risk_score]`: + - Normalize the pair ordering. + - Look up row; if missing, return: + - severity = `"none"` + - recommendation = `"no_action"` + - base_risk_score = 0.0 + +In `rewards.py` (or a dedicated module), implement: +- `compute_regimen_risk(current_drug_ids, patient_context, ddi_rules, beers_rules, drug_metadata) -> float` + - Aggregate contributions from: + - Beers violations (weighted by `criterion_type` and relevant conditions). + - DDI base risk scores for all present drug pairs. + - High-risk elderly drugs. + - Normalize and clip to [0.0, 1.0]. + +Use this function to compute: +- `baseline_risk` at episode start. +- Risk after each intervention step. + +Also implement: +- `compute_shaped_reward(previous_risk, new_risk, action, context, partial_metrics) -> float` + - Positive component: `previous_risk - new_risk`. + - Negative components: per-query cost, per-intervention cost, invalid-action penalty, time-out penalty. + +================================================= +4. Tasks and graders (3 difficulty levels) +================================================= + +Define three task IDs and semantics in `tasks.py` and `graders.py`: + +Task IDs: +- `easy_screening` +- `budgeted_screening` +- `complex_tradeoff` + +4.1. `easy_screening` (easy) + +- Small regimen: 3–5 drugs. +- Exactly one **severe** DDI pair and possibly one simple Beers violation. +- Budgets: + - query_budget ≈ 4 + - intervention_budget ≈ 2 + - max_steps ≈ 10 + +Grader: +- Input: full trajectory, baseline risk, final risk, list of interventions. +- Compute: + - `risk_reduction = max(0.0, baseline_risk - final_risk) / max(baseline_risk, ε)` (normalized). + - `targeted_intervention_flag = 1.0` if at least one intervention affects one of the drugs in the known severe DDI pair, else 0.0. +- Score: + - `score = 0.5 * risk_reduction + 0.5 * targeted_intervention_flag` + - Clip to [0.0, 1.0]. + +4.2. `budgeted_screening` (medium) + +- Medium regimen: 6–10 drugs. +- Multiple DDIs (mild/moderate/severe) and multiple Beers issues. +- Budgets: + - query_budget ≈ 8 + - intervention_budget ≈ 3 + - max_steps ≈ 20 + +Grader: +- Compute: + - `risk_reduction_score` as normalized risk drop. + - `intervention_precision_score` = fraction of interventions that actually reduce risk or fix guideline violations. + - `query_efficiency_score` = (number of severe/moderate DDIs discovered) / (number of queries used), normalized. +- Weighted score, for example: + - `score = 0.5 * risk_reduction_score + 0.3 * intervention_precision_score + 0.2 * query_efficiency_score` + - Clip to [0.0, 1.0]. + +4.3. `complex_tradeoff` (hard) + +- Larger regimen: 10–15 drugs. +- Some drugs are **clinically critical** (e.g., anticoagulants, insulin analogues) and encoded as such in `drug_metadata` or a small internal map. +- Episodes contain: + - multiple DDIs and Beers issues, including ones involving critical drugs. + - safer substitutes for some risky drugs. + +Budgets: +- query_budget ≈ 12 +- intervention_budget ≈ 5 +- max_steps ≈ 30 + +Grader adds a **regimen disruption penalty** component: +- Metrics: + - `risk_reduction_score` (as above). + - `critical_drug_penalty` = penalty if a critical drug is stopped without substitution to another suitable agent. + - `total_drug_changes` = number of drugs stopped or substituted. + - `regimen_disruption_penalty` derived from `total_drug_changes` and `critical_drug_penalty`. + +Example scoring: +- `base = risk_reduction_score` +- `penalty = α * regimen_disruption_penalty` +- `score = clamp(base - penalty, 0.0, 1.0)` + +4.4. Reward shaping + +In `rewards.py`, define a consistent shaping scheme: +- On each query: + - Small negative reward (e.g., −0.01) plus any small bonus if it discovers a severe DDI, if desired. +- On each intervention: + - Reward ≈ (previous_risk - new_risk) − small intervention cost. +- On invalid actions: + - Larger negative reward (e.g., −0.1) and no state change. +- On `finish_review`: + - Add the task-level `score` ∈ [0.0, 1.0] from the corresponding grader to that step’s shaped reward. + +Ensure the sum of step rewards per episode remains in a reasonable numeric range (e.g., roughly -5 to +5) while still allowing meaningful differentiation by graders. + +================================================= +5. HTTP API server and openenv.yaml +================================================= + +5.1. HTTP server (FastAPI) + +In `api/server.py`: +- Implement a FastAPI app that maintains a `PolypharmacyEnv` instance (or a multiplexing scheme if needed). +- Endpoints: + - `POST /reset`: + - Request body: may include `task_id` (string). + - Response: serialized `PolypharmacyObservation`. + - `POST /step`: + - Request body: serialized `PolypharmacyAction`. + - Response: dict with: + - `observation`: `PolypharmacyObservation` + - `reward`: float + - `done`: bool + - `info`: dict + - `GET /state`: + - Response: `PolypharmacyState`. + +Provide a module-level `app = FastAPI(...)` object for use with uvicorn and Hugging Face Spaces. Ensure the JSON schema is consistent with OpenEnv clients (simple, flat JSON for observation/action/state). + +5.2. `openenv.yaml` + +At repo root, define `openenv.yaml` consistent with the latest OpenEnv spec. At minimum, include: +- `name`: `polypharmacy_env` +- `version`: e.g., `0.1.0` +- `description`: human-readable description. +- `author`: your details. +- `tags`: e.g., `["healthcare", "polypharmacy", "openenv"]` +- `tasks`: + - One entry per task: + - `id`: `"easy_screening"` / `"budgeted_screening"` / `"complex_tradeoff"` + - `description`: one-line description + - `difficulty`: `"easy"`, `"medium"`, `"hard"` + +Ensure `openenv validate` (or equivalent validator) passes once implemented. + +================================================= +6. Baseline heuristic (non-LLM) agent +================================================= + +In `baselines/heuristic_agent.py`, implement a simple, deterministic baseline agent that: + +For each episode: +- Iterates through all unordered medication pairs within query budget: + - Calls `query_ddi` via the environment for each pair until the query budget is exhausted or all pairs are examined. + - Records severe and moderate interactions. +- After querying: + - For each severe DDI pair: + - Try `substitute` one of the drugs using `drug_metadata`: + - Prefer substitute within same `atc_class` that: + - is not marked high-risk elderly. + - does not participate in known severe DDIs with the rest of the regimen. + - If no substitute exists, propose `stop` for the higher-risk drug. + - Respect intervention budget limits. +- Finally, call `finish_review`. + +This baseline should be callable as a simple Python function that interacts with `PolypharmacyEnv` directly (without HTTP). + +================================================= +7. Baseline LLM inference script (inference.py) +================================================= + +At repo root, create `inference.py` that: + +7.1. Uses the OpenAI Python client + +- Import and configure the official OpenAI Python client. +- Read environment variables: + - `OPENAI_API_KEY` (required). + - `API_BASE_URL` (base URL for LLM; default to OpenAI standard if not set). + - `MODEL_NAME` (e.g., `gpt-4.1` or similar). + - `HF_TOKEN` (if needed for HF auth; do not hardcode). +- Read `POLYPHARMACY_ENV_URL` (or similar) for the environment’s HTTP base URL. + +7.2. Implements the required logging format + +- For each **run** across all tasks: + - Emit a `[START]` line with a JSON payload exactly matching the evaluation specification: + - Fields such as `run_id`, `task_id`, `model`, etc., in the same order and naming as the sample OpenEnv inference script. +- For each **step** in an episode: + - Emit a `[STEP]` line with JSON fields including: + - `run_id` + - `task_id` + - `episode_id` + - `step_index` + - `observation_summary` (brief, machine-readable summary) + - `action_payload` (the action sent to the env) + - `reward` + - `done` +- After finishing an episode for a task: + - Emit an `[END]` line summarizing: + - `run_id` + - `task_id` + - per-episode statistics (e.g., total reward, grader score from last step’s `info`). +- The stdout format MUST follow the sample exactly: + - Same tags: `[START]`, `[STEP]`, `[END]`. + - Same JSON field names and ordering as the provided reference. + - No extra prints except these structured logs (and necessary error messages to stderr). + +7.3. LLM agent loop + +- For each task (`easy_screening`, `budgeted_screening`, `complex_tradeoff`): + - Run a fixed small number of episodes (e.g., 5–10 per task) for baseline scoring. + - For each episode: + - Call `/reset` with the task id. + - At each step: + - Summarize the observation into a concise prompt for the LLM: + - Include age, sex, conditions, high-risk flags, budgets, and a compressed view of meds and previous actions. + - Ask the model to output a **strict JSON** representing `PolypharmacyAction` fields. + - Parse and validate the JSON; if invalid, fall back to a safe default (e.g., `finish_review` or a no-op) and penalize in evaluation. + - Send this action to `/step` and log `[STEP]`. + - End when `done=True` or max_steps is reached. +- At the end, print aggregate scores per task and overall. + +Make sure runtime < 20 minutes and that the script can run within 2 vCPUs and 8 GB RAM. + +================================================= +8. Dockerfile and Hugging Face Space +================================================= + +8.1. Dockerfile + +Create a `Dockerfile` that: +- Starts from a slim Python image (e.g., `python:3.11-slim`). +- Installs system dependencies as needed (e.g., `build-essential`, `curl`). +- Copies the project into the container. +- Installs Python dependencies from `requirements.txt`. +- Sets appropriate environment variables for the app (e.g., `PORT=7860`). +- Exposes port 7860. +- Uses a `CMD` or `ENTRYPOINT` that runs the FastAPI server, for example: + - `uvicorn polypharmacy_env.api.server:app --host 0.0.0.0 --port 7860` + +8.2. Hugging Face Space + +Ensure the repository is ready to be used as a Hugging Face Space: +- Space type: `docker`. +- Tag: `openenv`. +- On container start, the server must listen on the correct port and respond to: + - `POST /reset` + - `POST /step` + - `GET /state` +- The environment must start cleanly with `docker build` + `docker run` locally. + +================================================= +9. README and documentation +================================================= + +In `README.md`, include: + +- **Environment description & motivation**: + - What PolypharmacyEnv simulates. + - Why elderly polypharmacy safety matters. +- **Action and observation spaces**: + - Describe `PolypharmacyAction`, `PolypharmacyObservation`, and `PolypharmacyState` fields and semantics. +- **Task descriptions**: + - `easy_screening`, `budgeted_screening`, `complex_tradeoff`, their difficulty and goals. +- **Reward structure**: + - Summarize shaping and terminal rewards. +- **Setup & usage**: + - How to install dependencies. + - How to run the API server locally (uvicorn command). + - How to run the heuristic baseline. + - How to run `inference.py` with environment variables. +- **Baseline scores**: + - Document reproducible baseline scores for each task (heuristic agent, and LLM baseline if available). + +================================================= +10. Validation and quality gates +================================================= + +- Ensure: + - `openenv.yaml` and the HTTP server pass the OpenEnv validation script. + - `docker build` and `docker run` work without errors. + - `inference.py` completes under 20 minutes, within 2 vCPUs / 8 GB RAM. + - All graders: + - Are deterministic. + - Return scores strictly in [0.0, 1.0]. + - No grader returns a constant score irrespective of behavior. + +Aim for clean, well-structured, well-documented code with clear separation of concerns between: +- Data loading, +- Environment state & dynamics, +- Reward/grade logic, +- HTTP serving, +- Baseline agents and inference. \ No newline at end of file diff --git a/openenv-polypharmacy/README.md b/openenv-polypharmacy/README.md new file mode 100644 index 0000000000000000000000000000000000000000..dddbf4047eacd2e74adc50df3b8cf73f80f162f9 --- /dev/null +++ b/openenv-polypharmacy/README.md @@ -0,0 +1,245 @@ +# PolypharmacyEnv + +Monorepo for an OpenEnv-compatible medication safety environment with: + +- a FastAPI backend (`backend/`) +- a React frontend (`frontend/`) +- data assets (`data/`) +- utility scripts (`scripts/`) + +--- + +## Repository Structure + +```text +openenv-polypharmacy/ + backend/ + main.py # ASGI entrypoint (uvicorn target) + requirements.txt # Backend dependencies + Dockerfile # Backend container + src/polypharmacy_env/ # Python package source + api/ + app.py # FastAPI/OpenEnv app assembly + server.py # Compatibility import wrapper + routes/agent.py # /agent/suggest route + services/ + groq_agent.py # Groq-based action suggestion logic + env_core.py # OpenEnv environment core + models.py # Action/observation/state models + data_loader.py # CSV loading + ddi_simulator.py # DDI and Beers lookups + rewards.py # Reward shaping + graders.py # Task graders + tasks.py # Task/episode selection + tests/ # Backend tests + frontend/ + src/ # React UI code + package.json + Dockerfile # Frontend container + data/ + lookups/ # drug_metadata.csv, ddi_rules.csv, beers_criteria.csv + processed/ # patients_polypharmacy.csv + scripts/ + preprocess_data.py # Synthetic data generation + dev_backend.sh # Local backend run helper + dev_frontend.sh # Local frontend run helper + run_validation.sh # Tests + baseline validation + docker-compose.yml # Full stack orchestration + openenv.yaml # OpenEnv manifest + inference.py # Optional CLI inference baseline + .env.example # Environment template +``` + +--- + +## What It Does + +The environment simulates elderly polypharmacy review. Agent actions: + +- `query_ddi` +- `propose_intervention` +- `finish_review` + +Supported tasks: + +- `easy_screening` +- `budgeted_screening` +- `complex_tradeoff` + +--- + +## Prerequisites + +- Python 3.10+ +- Node.js 18+ (or 20+ recommended) +- npm +- Docker + Docker Compose (optional, for containerized run) + +--- + +## Environment Setup + +Create `.env`: + +```bash +cp .env.example .env +``` + +Set values: + +- `GROQ_API_KEY=...` (required) +- `GROQ_BASE_URL=https://api.groq.com/openai/v1` (recommended) +- `GROQ_MODEL_NAME=llama-3.3-70b-versatile` (recommended) + +--- + +## Local Run (Recommended During Development) + +### 1) Install dependencies + +Backend: + +```bash +pip install -r backend/requirements.txt +``` + +Frontend: + +```bash +cd frontend +npm install +cd .. +``` + +### 2) Generate/update synthetic data (if needed) + +```bash +python scripts/preprocess_data.py +``` + +### 3) Start services in two terminals + +Terminal A: + +```bash +./scripts/dev_backend.sh +``` + +Terminal B: + +```bash +./scripts/dev_frontend.sh +``` + +### 4) Open app + +- Frontend: [http://localhost:5173](http://localhost:5173) +- Backend health: [http://localhost:7860/health](http://localhost:7860/health) + +--- + +## Docker Run + +Run both services: + +```bash +docker compose up --build +``` + +Stop: + +```bash +docker compose down +``` + +Ports: + +- backend: `7860` +- frontend: `5173` + +--- + +## Hugging Face Spaces Deployment (Docker) + +This repo now includes a **root `Dockerfile`** that builds frontend + backend into one container, so Spaces can host both API and UI together. + +### 1) Create a new Space + +- Go to [Hugging Face Spaces](https://huggingface.co/new-space) +- Choose **Docker** SDK +- Create the Space + +### 2) Add Space secrets/variables + +In Space Settings -> Variables and Secrets: + +- Secret: `GROQ_API_KEY` +- Variable: `GROQ_BASE_URL=https://api.groq.com/openai/v1` +- Variable: `GROQ_MODEL_NAME=llama-3.3-70b-versatile` + +### 3) Push this repository to the Space + +Commit and push all files, including root `Dockerfile`. + +### 4) Verify after build + +- Space root URL loads the React UI +- `/health` returns healthy status +- OpenEnv endpoints are available (`/reset`, `/step`, `/state`, `/schema`) + +Notes: + +- Container reads `PORT` (defaults to `7860`) which is Space-friendly. +- Frontend static assets are served by FastAPI from `frontend/dist`. + +--- + +## API Endpoints + +OpenEnv/health: + +- `POST /reset` +- `POST /step` +- `GET /state` +- `GET /health` +- `GET /schema` +- `WS /ws` (stateful session) + +AI helper: + +- `POST /agent/suggest` + +--- + +## Testing + +Run backend tests: + +```bash +python -m pytest backend/src/polypharmacy_env/tests -v +``` + +Or run validation script: + +```bash +./scripts/run_validation.sh +``` + +--- + +## Notes + +- OpenEnv HTTP reset/step is stateless; multi-step episode continuity should use websocket (`/ws`). +- The frontend uses websocket for episode continuity and HTTP for AI suggestion. +- AI behavior includes rule-based guardrails to avoid repetitive low-value loops. + +--- + +## Troubleshooting + +- `ModuleNotFoundError: polypharmacy_env` + - Start backend using `./scripts/dev_backend.sh` from repo root. +- `/agent/suggest` fails + - Check `.env` keys and restart backend. +- UI state looks stale + - Hard refresh browser and click `Reset Episode`. diff --git a/openenv-polypharmacy/backend/Dockerfile b/openenv-polypharmacy/backend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..0cba5065ef8349abee5686485d0ada06fe57ce05 --- /dev/null +++ b/openenv-polypharmacy/backend/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends build-essential curl && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY backend/requirements.txt /app/backend/requirements.txt +RUN pip install --no-cache-dir -r /app/backend/requirements.txt + +COPY backend /app/backend +COPY data /app/data +COPY scripts /app/scripts +COPY .env.example /app/.env.example + +RUN python3 /app/scripts/preprocess_data.py + +ENV PORT=7860 +ENV PYTHONPATH="/app/backend/src:${PYTHONPATH}" +ENV PYTHONUNBUFFERED=1 + +EXPOSE 7860 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:7860/health || exit 1 + +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/openenv-polypharmacy/backend/__init__.py b/openenv-polypharmacy/backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2f40af2be5beb19dd1c96a0a63b74124b144b304 --- /dev/null +++ b/openenv-polypharmacy/backend/__init__.py @@ -0,0 +1 @@ +"""Backend entrypoint package for monorepo structure.""" diff --git a/openenv-polypharmacy/backend/main.py b/openenv-polypharmacy/backend/main.py new file mode 100644 index 0000000000000000000000000000000000000000..d7058d14ed4d68e0e2612e4168c9ec6d3ee0828c --- /dev/null +++ b/openenv-polypharmacy/backend/main.py @@ -0,0 +1,15 @@ +"""ASGI entrypoint for backend service in monorepo layout.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +BACKEND_DIR = Path(__file__).resolve().parent +SRC = BACKEND_DIR / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from polypharmacy_env.api.app import app # noqa: E402 + +__all__ = ["app"] diff --git a/openenv-polypharmacy/backend/requirements.txt b/openenv-polypharmacy/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..c975be48d5a69da77e34da8973f265732a115236 --- /dev/null +++ b/openenv-polypharmacy/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.104.0 +uvicorn>=0.24.0 +pydantic>=2.0.0 +requests>=2.31.0 +httpx>=0.25.0 +openenv-core>=0.2.0 +openai>=1.0.0 +python-dotenv>=1.0.0 +pytest>=7.0.0 diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6798eed03973a697da00247c5ac1aef68ee5e8d8 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/__init__.py @@ -0,0 +1,11 @@ +"""PolypharmacyEnv – an OpenEnv environment for elderly polypharmacy safety.""" + +from .client import PolypharmacyClient +from .models import PolypharmacyAction, PolypharmacyObservation, PolypharmacyState + +__all__ = [ + "PolypharmacyClient", + "PolypharmacyAction", + "PolypharmacyObservation", + "PolypharmacyState", +] diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/api/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..dff53e5af3f99340bcfd48c954f9ba7d6c2f12fa --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/api/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/api/app.py b/openenv-polypharmacy/backend/src/polypharmacy_env/api/app.py new file mode 100644 index 0000000000000000000000000000000000000000..a4377baaca9f6c0d8191c1a2e89d1e9c70b48605 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/api/app.py @@ -0,0 +1,63 @@ +"""FastAPI app factory for PolypharmacyEnv.""" + +from __future__ import annotations + +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from openenv.core.env_server.http_server import create_app +from starlette.responses import FileResponse + +from ..env_core import PolypharmacyEnv +from ..models import PolypharmacyAction, PolypharmacyObservation +from .routes.agent import router as agent_router + +load_dotenv() + + +class SPAStaticFiles(StaticFiles): + """Serve SPA index for unknown frontend routes.""" + + async def get_response(self, path: str, scope): + response = await super().get_response(path, scope) + if response.status_code != 404: + return response + index_path = Path(self.directory) / "index.html" + if index_path.exists(): + return FileResponse(index_path) + raise HTTPException(status_code=404, detail="Not Found") + + +def create_polypharmacy_app(): + app = create_app( + PolypharmacyEnv, + PolypharmacyAction, + PolypharmacyObservation, + env_name="polypharmacy_env", + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:5173", + "http://127.0.0.1:5173", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + app.include_router(agent_router) + + # In Docker Space deployment, serve built frontend from same container. + project_root = Path(__file__).resolve().parents[4] + frontend_dist = project_root / "frontend" / "dist" + if frontend_dist.exists(): + app.mount("/", SPAStaticFiles(directory=frontend_dist, html=True), name="frontend") + + return app + + +app = create_polypharmacy_app() diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fb0a2f808230d18b89b49b68b2bce37b7c869206 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/__init__.py @@ -0,0 +1 @@ +"""API route modules.""" diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/agent.py b/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..f74e627ff9f5fcea9954cc51f51991bdb6218fbf --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/api/routes/agent.py @@ -0,0 +1,35 @@ +"""Agent suggestion API routes.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from ...models import PolypharmacyAction, PolypharmacyObservation +from ...services.groq_agent import suggest_action_from_observation + +router = APIRouter(prefix="/agent", tags=["agent"]) + + +class AgentSuggestRequest(BaseModel): + observation: PolypharmacyObservation + model_name: str | None = None + + +class AgentSuggestResponse(BaseModel): + action: PolypharmacyAction + source: str = Field(default="groq") + + +@router.post("/suggest", response_model=AgentSuggestResponse) +def suggest_agent_action(payload: AgentSuggestRequest) -> AgentSuggestResponse: + """Return a model-suggested action for the current observation.""" + try: + action = suggest_action_from_observation( + payload.observation, model_name=payload.model_name + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Model call failed: {exc}") from exc + return AgentSuggestResponse(action=action) diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/api/server.py b/openenv-polypharmacy/backend/src/polypharmacy_env/api/server.py new file mode 100644 index 0000000000000000000000000000000000000000..63717ca71f81ef20f44c410d4dd7a59576942b2e --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/api/server.py @@ -0,0 +1,6 @@ +"""Backward-compatible app import path. + +Use `polypharmacy_env.api.app:app` for the main app module. +""" + +from .app import app diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0f2de5b4de65026f090ebceec783d333169cd6ac --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/__init__.py @@ -0,0 +1 @@ +"""Baseline agents.""" diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/heuristic_agent.py b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/heuristic_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..ec579d927aac4144b62f8b33f69a340cf5c327cb --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/heuristic_agent.py @@ -0,0 +1,197 @@ +"""Deterministic heuristic baseline agent for PolypharmacyEnv. + +Strategy: +1. Query all unordered medication pairs for DDIs (within budget), + prioritising high-risk elderly drugs first. +2. For each severe DDI found, attempt substitution or stop. +3. For each moderate DDI found, attempt substitution or stop. +4. For remaining budget, address Beers-flagged "avoid" drugs. +5. Call finish_review. +""" + +from __future__ import annotations + +from itertools import combinations +from typing import List, Tuple + +from ..env_core import PolypharmacyEnv +from ..models import PolypharmacyAction, PolypharmacyObservation + + +def run_heuristic_episode( + env: PolypharmacyEnv, + task_id: str = "budgeted_screening", + seed: int | None = None, +) -> Tuple[float, float, int]: + """Run one episode with the heuristic agent. + + Returns (total_reward, grader_score, steps). + """ + obs = env.reset(seed=seed, task_id=task_id) + total_reward = 0.0 + grader_score = 0.0 + steps = 0 + + # Phase 1: Query DDIs between medication pairs, prioritising high-risk drugs + meds = obs.current_medications + # Sort: high-risk elderly drugs first, then by Beers flag count + meds_sorted = sorted( + meds, + key=lambda m: (not m.is_high_risk_elderly, -len(m.beers_flags), m.drug_id), + ) + med_ids = [m.drug_id for m in meds_sorted] + pairs: List[Tuple[str, str]] = list(combinations(med_ids, 2)) + severe_pairs: List[Tuple[str, str]] = [] + moderate_pairs: List[Tuple[str, str]] = [] + + for a, b in pairs: + if obs.remaining_query_budget <= 0: + break + action = PolypharmacyAction( + action_type="query_ddi", + drug_id_1=a, + drug_id_2=b, + ) + obs = env.step(action) + reward = obs.reward or 0.0 + total_reward += reward + steps += 1 + + if obs.done: + grader_score = obs.metadata.get("grader_score", 0.0) + return total_reward, grader_score, steps + + # Track severity from metadata + ddi_info = obs.metadata.get("ddi_result", {}) + sev = ddi_info.get("severity", "none") + if sev == "severe": + severe_pairs.append((a, b)) + elif sev == "moderate": + moderate_pairs.append((a, b)) + + # Phase 2: Intervene on severe DDI drugs first + intervened: set[str] = set() + + def _try_intervene( + target: str, + rationale: str, + ) -> Tuple[bool, PolypharmacyObservation]: + """Try substitute then stop. Returns (done, obs).""" + nonlocal total_reward, steps + # Try substitute first + act = PolypharmacyAction( + action_type="propose_intervention", + target_drug_id=target, + intervention_type="substitute", + rationale=rationale, + ) + obs_new = env.step(act) + total_reward += obs_new.reward or 0.0 + steps += 1 + + if obs_new.done: + return True, obs_new + + # If substitute failed, try stop + if obs_new.metadata.get("warning"): + if obs_new.remaining_intervention_budget <= 0: + return False, obs_new + act2 = PolypharmacyAction( + action_type="propose_intervention", + target_drug_id=target, + intervention_type="stop", + rationale=f"No substitute; {rationale}", + ) + obs_new = env.step(act2) + total_reward += obs_new.reward or 0.0 + steps += 1 + if obs_new.done: + return True, obs_new + + return False, obs_new + + # Intervene on severe pairs + for a, b in severe_pairs: + if obs.remaining_intervention_budget <= 0: + break + target = b if a in intervened else a + if target in intervened: + target = b + if target in intervened: + continue + intervened.add(target) + + done, obs = _try_intervene(target, f"Severe DDI between {a} and {b}") + if done: + grader_score = obs.metadata.get("grader_score", 0.0) + return total_reward, grader_score, steps + + # Phase 2b: Intervene on moderate DDI drugs + for a, b in moderate_pairs: + if obs.remaining_intervention_budget <= 0: + break + target = b if a in intervened else a + if target in intervened: + target = b + if target in intervened: + continue + intervened.add(target) + + done, obs = _try_intervene(target, f"Moderate DDI between {a} and {b}") + if done: + grader_score = obs.metadata.get("grader_score", 0.0) + return total_reward, grader_score, steps + + # Phase 3: Address Beers-flagged "avoid" drugs + for med in meds_sorted: + if obs.remaining_intervention_budget <= 0: + break + if med.drug_id in intervened: + continue + if not med.beers_flags: + continue + if any("avoid" in f for f in med.beers_flags): + intervened.add(med.drug_id) + done, obs = _try_intervene( + med.drug_id, f"Beers criteria: {', '.join(med.beers_flags)}" + ) + if done: + grader_score = obs.metadata.get("grader_score", 0.0) + return total_reward, grader_score, steps + + # Phase 4: Finish + action = PolypharmacyAction(action_type="finish_review") + obs = env.step(action) + total_reward += obs.reward or 0.0 + steps += 1 + grader_score = obs.metadata.get("grader_score", 0.0) + + return total_reward, grader_score, steps + + +def run_heuristic_baseline( + n_episodes: int = 5, + task_ids: List[str] | None = None, +) -> None: + """Run the heuristic agent across tasks and print results.""" + if task_ids is None: + task_ids = ["easy_screening", "budgeted_screening", "complex_tradeoff"] + + env = PolypharmacyEnv() + + for tid in task_ids: + scores: list[float] = [] + rewards: list[float] = [] + for i in range(n_episodes): + total_r, score, steps = run_heuristic_episode(env, task_id=tid, seed=i) + scores.append(score) + rewards.append(total_r) + print(f" [{tid}] ep={i} steps={steps} reward={total_r:.4f} score={score:.4f}") + + avg_s = sum(scores) / len(scores) if scores else 0.0 + avg_r = sum(rewards) / len(rewards) if rewards else 0.0 + print(f" [{tid}] avg_score={avg_s:.4f} avg_reward={avg_r:.4f}\n") + + +if __name__ == "__main__": + run_heuristic_baseline() diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/random_agent.py b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/random_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..44b647b4d558cddef7706bc93ed837bdd11e8285 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/baselines/random_agent.py @@ -0,0 +1,53 @@ +"""Trivial random baseline agent for PolypharmacyEnv.""" + +from __future__ import annotations + +import random +from typing import List, Tuple + +from ..env_core import PolypharmacyEnv +from ..models import PolypharmacyAction, PolypharmacyObservation + + +def run_random_episode( + env: PolypharmacyEnv, + task_id: str = "budgeted_screening", + seed: int | None = None, +) -> Tuple[float, float, int]: + rng = random.Random(seed) + obs = env.reset(task_id=task_id, seed=seed) + total_reward = 0.0 + grader_score = 0.0 + steps = 0 + + while not obs.done: + med_ids = [m.drug_id for m in obs.current_medications] + choice = rng.choice(["query_ddi", "propose_intervention", "finish_review"]) + + if choice == "query_ddi" and len(med_ids) >= 2 and obs.remaining_query_budget > 0: + pair = rng.sample(med_ids, 2) + action = PolypharmacyAction( + action_type="query_ddi", + drug_id_1=pair[0], + drug_id_2=pair[1], + ) + elif choice == "propose_intervention" and med_ids and obs.remaining_intervention_budget > 0: + target = rng.choice(med_ids) + itype = rng.choice(["stop", "dose_reduce", "substitute", "add_monitoring"]) + action = PolypharmacyAction( + action_type="propose_intervention", + target_drug_id=target, + intervention_type=itype, + rationale="random", + ) + else: + action = PolypharmacyAction(action_type="finish_review") + + obs = env.step(action) + total_reward += obs.reward or 0.0 + steps += 1 + if obs.done: + grader_score = obs.metadata.get("grader_score", 0.0) + break + + return total_reward, grader_score, steps diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/client.py b/openenv-polypharmacy/backend/src/polypharmacy_env/client.py new file mode 100644 index 0000000000000000000000000000000000000000..9172acf8bbe3a436281124c59c8f89a759beb5d6 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/client.py @@ -0,0 +1,49 @@ +"""OpenEnv client for PolypharmacyEnv. + +Provides a typed async/sync client for interacting with a PolypharmacyEnv +server via WebSocket, following the OpenEnv EnvClient pattern. + +Example (async): + >>> async with PolypharmacyClient(base_url="ws://localhost:8000") as env: + ... result = await env.reset(task_id="easy_screening") + ... result = await env.step(PolypharmacyAction(action_type="finish_review")) + +Example (sync): + >>> with PolypharmacyClient(base_url="ws://localhost:8000").sync() as env: + ... result = env.reset(task_id="easy_screening") +""" + +from __future__ import annotations + +from typing import Any, Dict + +from openenv.core.client_types import StepResult +from openenv.core.env_client import EnvClient + +from .models import PolypharmacyAction, PolypharmacyObservation, PolypharmacyState + + +class PolypharmacyClient( + EnvClient[PolypharmacyAction, PolypharmacyObservation, PolypharmacyState] +): + """Typed OpenEnv client for the PolypharmacyEnv environment.""" + + def _step_payload(self, action: PolypharmacyAction) -> Dict[str, Any]: + """Convert a PolypharmacyAction to the JSON payload for the server.""" + return action.model_dump(exclude_none=True) + + def _parse_result( + self, payload: Dict[str, Any] + ) -> StepResult[PolypharmacyObservation]: + """Parse a server response into a StepResult with typed observation.""" + obs_data = payload.get("observation", payload) + obs = PolypharmacyObservation.model_validate(obs_data) + return StepResult( + observation=obs, + reward=payload.get("reward"), + done=payload.get("done", False), + ) + + def _parse_state(self, payload: Dict[str, Any]) -> PolypharmacyState: + """Parse a server state response into a typed PolypharmacyState.""" + return PolypharmacyState.model_validate(payload) diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/config.py b/openenv-polypharmacy/backend/src/polypharmacy_env/config.py new file mode 100644 index 0000000000000000000000000000000000000000..e71035ae3e075c08b7f0a9dbbef0857abb8be231 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/config.py @@ -0,0 +1,79 @@ +"""Environment configuration constants and task parameter definitions.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict + +# ── Paths ──────────────────────────────────────────────────────────────────── +PROJECT_ROOT = Path(__file__).resolve().parents[3] # openenv-polypharmacy/ +DATA_DIR = PROJECT_ROOT / "data" +LOOKUPS_DIR = DATA_DIR / "lookups" +PROCESSED_DIR = DATA_DIR / "processed" + +DDI_RULES_CSV = LOOKUPS_DIR / "ddi_rules.csv" +BEERS_CRITERIA_CSV = LOOKUPS_DIR / "beers_criteria.csv" +DRUG_METADATA_CSV = LOOKUPS_DIR / "drug_metadata.csv" +PATIENTS_CSV = PROCESSED_DIR / "patients_polypharmacy.csv" + +# ── Reward hyper-parameters ────────────────────────────────────────────────── +QUERY_COST: float = 0.01 +INTERVENTION_COST: float = 0.02 +INVALID_ACTION_PENALTY: float = 0.10 +TIMEOUT_PENALTY: float = 0.20 +SEVERE_DDI_DISCOVERY_BONUS: float = 0.03 + +# ── Task parameters ───────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class TaskConfig: + task_id: str + difficulty: str + min_drugs: int + max_drugs: int + query_budget: int + intervention_budget: int + max_steps: int + + +TASK_CONFIGS: Dict[str, TaskConfig] = { + "easy_screening": TaskConfig( + task_id="easy_screening", + difficulty="easy", + min_drugs=3, + max_drugs=5, + query_budget=4, + intervention_budget=2, + max_steps=10, + ), + "budgeted_screening": TaskConfig( + task_id="budgeted_screening", + difficulty="medium", + min_drugs=6, + max_drugs=10, + query_budget=8, + intervention_budget=3, + max_steps=20, + ), + "complex_tradeoff": TaskConfig( + task_id="complex_tradeoff", + difficulty="hard", + min_drugs=10, + max_drugs=15, + query_budget=12, + intervention_budget=5, + max_steps=30, + ), +} + +DEFAULT_TASK = "budgeted_screening" + +# ── Critical drugs (must not be stopped without substitution) ──────────────── +CRITICAL_DRUG_IDS: set[str] = { + "DRUG_WARFARIN", + "DRUG_APIXABAN", + "DRUG_INSULIN_GLARGINE", + "DRUG_METOPROLOL", + "DRUG_DIGOXIN", +} diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/data_loader.py b/openenv-polypharmacy/backend/src/polypharmacy_env/data_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..f0d08463257be1bd148912584673d345ac0c8d09 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/data_loader.py @@ -0,0 +1,142 @@ +"""Load and cache CSV lookup data for the PolypharmacyEnv.""" + +from __future__ import annotations + +import csv +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from .config import ( + BEERS_CRITERIA_CSV, + DDI_RULES_CSV, + DRUG_METADATA_CSV, + PATIENTS_CSV, +) + + +# ── Row-level data classes ─────────────────────────────────────────────────── + +@dataclass(frozen=True) +class DrugMeta: + drug_id: str + generic_name: str + atc_class: str + is_high_risk_elderly: bool + default_dose_mg: float + min_dose_mg: float + max_dose_mg: float + + +@dataclass(frozen=True) +class DDIRule: + drug_id_1: str + drug_id_2: str + severity: str + mechanism: str + recommendation: str + base_risk_score: float + + +@dataclass(frozen=True) +class BeersCriterion: + drug_id: str + criterion_type: str # avoid | caution | dose_adjust | avoid_in_condition + condition: Optional[str] + rationale: str + + +@dataclass +class PatientEpisode: + episode_id: str + age: int + sex: str + conditions: List[str] + eGFR_category: str + liver_function_category: str + medication_ids: List[str] + baseline_risk_score: float + difficulty: str + + +# ── Loaders (cached) ──────────────────────────────────────────────────────── + +def _read_csv(path: Path) -> List[Dict[str, str]]: + with open(path, newline="") as f: + return list(csv.DictReader(f)) + + +@lru_cache(maxsize=1) +def load_drug_metadata(path: Path = DRUG_METADATA_CSV) -> Dict[str, DrugMeta]: + out: Dict[str, DrugMeta] = {} + for row in _read_csv(path): + dm = DrugMeta( + drug_id=row["drug_id"], + generic_name=row["generic_name"], + atc_class=row["atc_class"], + is_high_risk_elderly=row["is_high_risk_elderly"] == "1", + default_dose_mg=float(row["default_dose_mg"]), + min_dose_mg=float(row["min_dose_mg"]), + max_dose_mg=float(row["max_dose_mg"]), + ) + out[dm.drug_id] = dm + return out + + +def _normalise_pair(a: str, b: str) -> Tuple[str, str]: + return (a, b) if a < b else (b, a) + + +@lru_cache(maxsize=1) +def load_ddi_rules(path: Path = DDI_RULES_CSV) -> Dict[Tuple[str, str], DDIRule]: + out: Dict[Tuple[str, str], DDIRule] = {} + for row in _read_csv(path): + key = _normalise_pair(row["drug_id_1"], row["drug_id_2"]) + out[key] = DDIRule( + drug_id_1=key[0], + drug_id_2=key[1], + severity=row["severity"], + mechanism=row["mechanism"], + recommendation=row["recommendation"], + base_risk_score=float(row["base_risk_score"]), + ) + return out + + +@lru_cache(maxsize=1) +def load_beers_criteria(path: Path = BEERS_CRITERIA_CSV) -> List[BeersCriterion]: + out: List[BeersCriterion] = [] + for row in _read_csv(path): + cond = row["condition"].strip() or None + out.append(BeersCriterion( + drug_id=row["drug_id"], + criterion_type=row["criterion_type"], + condition=cond, + rationale=row["rationale"], + )) + return out + + +def load_patients( + path: Path = PATIENTS_CSV, + difficulty: Optional[str] = None, +) -> List[PatientEpisode]: + rows = _read_csv(path) + eps: List[PatientEpisode] = [] + for row in rows: + d = row.get("difficulty", "medium") + if difficulty and d != difficulty: + continue + eps.append(PatientEpisode( + episode_id=row["episode_id"], + age=int(row["age"]), + sex=row["sex"], + conditions=[c.strip() for c in row["conditions"].split(";") if c.strip()], + eGFR_category=row["eGFR_category"], + liver_function_category=row["liver_function_category"], + medication_ids=[m.strip() for m in row["medication_ids"].split(";") if m.strip()], + baseline_risk_score=float(row["baseline_risk_score"]), + difficulty=d, + )) + return eps diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/ddi_simulator.py b/openenv-polypharmacy/backend/src/polypharmacy_env/ddi_simulator.py new file mode 100644 index 0000000000000000000000000000000000000000..52d390e2d530d091718414c6f86b29ca09b942da --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/ddi_simulator.py @@ -0,0 +1,115 @@ +"""Local DDI and guideline simulation using CSV lookup data.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +from .data_loader import ( + BeersCriterion, + DDIRule, + DrugMeta, + load_beers_criteria, + load_ddi_rules, + load_drug_metadata, +) + + +@dataclass(frozen=True) +class DDIResult: + severity: str + recommendation: str + base_risk_score: float + + +_NO_INTERACTION = DDIResult(severity="none", recommendation="no_action", base_risk_score=0.0) + + +class DDISimulator: + """Provides drug–drug interaction and Beers-criteria lookups.""" + + def __init__(self) -> None: + self._ddi_rules: Dict[Tuple[str, str], DDIRule] = load_ddi_rules() + self._drug_meta: Dict[str, DrugMeta] = load_drug_metadata() + self._beers: List[BeersCriterion] = load_beers_criteria() + + @staticmethod + def _normalise_pair(a: str, b: str) -> Tuple[str, str]: + return (a, b) if a < b else (b, a) + + def lookup_ddi(self, drug_id_1: str, drug_id_2: str) -> DDIResult: + key = self._normalise_pair(drug_id_1, drug_id_2) + rule = self._ddi_rules.get(key) + if rule is None: + return _NO_INTERACTION + return DDIResult( + severity=rule.severity, + recommendation=rule.recommendation, + base_risk_score=rule.base_risk_score, + ) + + def get_beers_flags( + self, + drug_id: str, + patient_conditions: List[str], + ) -> List[str]: + """Return list of Beers flags applicable to *drug_id* given patient conditions.""" + flags: List[str] = [] + for bc in self._beers: + if bc.drug_id != drug_id: + continue + if bc.condition is None: + flags.append(bc.criterion_type) + elif bc.condition in patient_conditions: + flags.append(f"{bc.criterion_type}_{bc.condition}") + return flags + + def get_drug_meta(self, drug_id: str) -> Optional[DrugMeta]: + return self._drug_meta.get(drug_id) + + def find_substitute( + self, + drug_id: str, + current_drug_ids: List[str], + ) -> Optional[str]: + """Find a safer same-class substitute not already in the regimen.""" + meta = self._drug_meta.get(drug_id) + if meta is None: + return None + candidates = [ + dm + for dm in self._drug_meta.values() + if ( + dm.atc_class == meta.atc_class + and dm.drug_id != drug_id + and dm.drug_id not in current_drug_ids + and not dm.is_high_risk_elderly + ) + ] + if not candidates: + return None + # Pick the candidate with fewest severe DDIs with current regimen + def _severe_count(cand: DrugMeta) -> int: + count = 0 + for did in current_drug_ids: + if did == drug_id: + continue + r = self.lookup_ddi(cand.drug_id, did) + if r.severity == "severe": + count += 1 + return count + + candidates.sort(key=lambda c: (_severe_count(c), c.drug_id)) + return candidates[0].drug_id + + @property + def drug_metadata(self) -> Dict[str, DrugMeta]: + return self._drug_meta + + @property + def ddi_rules(self) -> Dict[Tuple[str, str], DDIRule]: + return self._ddi_rules + + @property + def beers_criteria(self) -> List[BeersCriterion]: + return self._beers diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/env_core.py b/openenv-polypharmacy/backend/src/polypharmacy_env/env_core.py new file mode 100644 index 0000000000000000000000000000000000000000..74190ac53a1c32d8b7294987e7b9ead83238a3cf --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/env_core.py @@ -0,0 +1,416 @@ +"""PolypharmacyEnv – core environment implementing OpenEnv step / reset / state.""" + +from __future__ import annotations + +from copy import deepcopy +from itertools import combinations +from typing import Any, Dict, List, Optional, Tuple + +from openenv.core.env_server.interfaces import Environment + +from .config import CRITICAL_DRUG_IDS, TaskConfig +from .data_loader import PatientEpisode +from .ddi_simulator import DDISimulator +from .graders import ( + grade_budgeted_screening, + grade_complex_tradeoff, + grade_easy_screening, +) +from .models import ( + InteractionQueryRecord, + InterventionRecord, + MedicationEntry, + PolypharmacyAction, + PolypharmacyObservation, + PolypharmacyState, +) +from .rewards import compute_regimen_risk, compute_shaped_reward +from .tasks import get_task_config, sample_episode + + +class PolypharmacyEnv( + Environment[PolypharmacyAction, PolypharmacyObservation, PolypharmacyState] +): + """OpenEnv-compliant environment for elderly polypharmacy medication review. + + Extends openenv.core.env_server.interfaces.Environment with typed + Action/Observation/State generics. + """ + + def __init__(self) -> None: + super().__init__() + self._sim = DDISimulator() + self._task_cfg: Optional[TaskConfig] = None + self._episode: Optional[PatientEpisode] = None + self._medications: List[MedicationEntry] = [] + self._interaction_queries: List[InteractionQueryRecord] = [] + self._interventions: List[InterventionRecord] = [] + self._risk_deltas: List[float] = [] # per-intervention risk improvement + self._step_count: int = 0 + self._done: bool = True + self._baseline_risk: float = 0.0 + self._current_risk: float = 0.0 + self._remaining_query_budget: int = 0 + self._remaining_intervention_budget: int = 0 + self._severe_moderate_discovered: int = 0 + self._total_drug_changes: int = 0 + self._critical_stopped_without_sub: int = 0 + self._last_reward: float = 0.0 + + # ── reset ──────────────────────────────────────────────────────────────── + + def reset( + self, + seed: Optional[int] = None, + episode_id: Optional[str] = None, + **kwargs: Any, + ) -> PolypharmacyObservation: + task_id = kwargs.get("task_id", None) + self._task_cfg = get_task_config(task_id) + self._episode = sample_episode(task_id, seed=seed, episode_id=episode_id) + + # Build medication list + self._medications = [] + for did in self._episode.medication_ids: + meta = self._sim.get_drug_meta(did) + if meta is None: + continue + flags = self._sim.get_beers_flags(did, self._episode.conditions) + self._medications.append(MedicationEntry( + drug_id=did, + generic_name=meta.generic_name, + atc_class=meta.atc_class, + dose_mg=meta.default_dose_mg, + is_high_risk_elderly=meta.is_high_risk_elderly, + beers_flags=flags, + )) + + self._interaction_queries = [] + self._interventions = [] + self._risk_deltas = [] + self._step_count = 0 + self._done = False + self._remaining_query_budget = self._task_cfg.query_budget + self._remaining_intervention_budget = self._task_cfg.intervention_budget + self._severe_moderate_discovered = 0 + self._total_drug_changes = 0 + self._critical_stopped_without_sub = 0 + self._last_reward = 0.0 + + # Compute baseline risk + self._baseline_risk = self._compute_risk() + self._current_risk = self._baseline_risk + + return self._make_observation() + + # ── step ───────────────────────────────────────────────────────────────── + + def step( + self, + action: PolypharmacyAction, + timeout_s: Optional[float] = None, + **kwargs: Any, + ) -> PolypharmacyObservation: + if self._done: + return self._make_observation() + + assert self._task_cfg is not None + assert self._episode is not None + + reward = 0.0 + info: Dict[str, Any] = {} + + # Validate basic action structure + valid, err = self._validate_action(action) + if not valid: + reward = compute_shaped_reward( + self._current_risk, self._current_risk, + action.action_type, is_invalid=True, + ) + info["error"] = err + self._step_count += 1 + return self._check_timeout_and_build_obs(reward, info) + + if action.action_type == "query_ddi": + reward, info = self._handle_query(action) + + elif action.action_type == "propose_intervention": + reward, info = self._handle_intervention(action) + + elif action.action_type == "finish_review": + self._done = True + score = self._run_grader() + reward = score # terminal bonus + info["grader_score"] = score + + self._step_count += 1 + return self._check_timeout_and_build_obs(reward, info) + + # ── state property ─────────────────────────────────────────────────────── + + @property + def state(self) -> PolypharmacyState: + return PolypharmacyState( + episode_id=self._episode.episode_id if self._episode else None, + step_count=self._step_count, + task_id=self._task_cfg.task_id if self._task_cfg else "", + max_steps=self._task_cfg.max_steps if self._task_cfg else 0, + num_query_actions=len(self._interaction_queries), + num_interventions=len(self._interventions), + ) + + # ── Internal helpers ───────────────────────────────────────────────────── + + def _compute_risk(self) -> float: + drug_ids = [m.drug_id for m in self._medications] + return compute_regimen_risk( + drug_ids, + self._episode.conditions if self._episode else [], + self._sim.ddi_rules, + self._sim.beers_criteria, + self._sim.drug_metadata, + ) + + def _validate_action(self, action: PolypharmacyAction) -> Tuple[bool, str]: + if action.action_type == "query_ddi": + if not action.drug_id_1 or not action.drug_id_2: + return False, "query_ddi requires drug_id_1 and drug_id_2" + elif action.action_type == "propose_intervention": + if not action.target_drug_id: + return False, "propose_intervention requires target_drug_id" + if action.intervention_type in (None, "none"): + return False, "propose_intervention requires a valid intervention_type" + return True, "" + + def _handle_query(self, action: PolypharmacyAction) -> Tuple[float, Dict[str, Any]]: + info: Dict[str, Any] = {} + assert action.drug_id_1 and action.drug_id_2 + + if self._remaining_query_budget <= 0: + reward = compute_shaped_reward( + self._current_risk, self._current_risk, + "query_ddi", is_invalid=True, + ) + info["error"] = "Query budget exhausted" + return reward, info + + result = self._sim.lookup_ddi(action.drug_id_1, action.drug_id_2) + self._remaining_query_budget -= 1 + + self._interaction_queries.append(InteractionQueryRecord( + drug_id_1=action.drug_id_1, + drug_id_2=action.drug_id_2, + severity=result.severity, + recommendation=result.recommendation, + risk_score=result.base_risk_score, + step_index=self._step_count, + )) + + discovered_severe = result.severity in ("severe", "moderate") + if discovered_severe: + self._severe_moderate_discovered += 1 + + reward = compute_shaped_reward( + self._current_risk, self._current_risk, + "query_ddi", + discovered_severe=(result.severity == "severe"), + ) + info["ddi_result"] = { + "severity": result.severity, + "recommendation": result.recommendation, + "risk_score": result.base_risk_score, + } + return reward, info + + def _handle_intervention(self, action: PolypharmacyAction) -> Tuple[float, Dict[str, Any]]: + info: Dict[str, Any] = {} + assert action.target_drug_id + assert action.intervention_type and action.intervention_type != "none" + + if self._remaining_intervention_budget <= 0: + reward = compute_shaped_reward( + self._current_risk, self._current_risk, + "propose_intervention", is_invalid=True, + ) + info["error"] = "Intervention budget exhausted" + return reward, info + + # Find target medication + target_idx: Optional[int] = None + for i, m in enumerate(self._medications): + if m.drug_id == action.target_drug_id: + target_idx = i + break + + if target_idx is None: + reward = compute_shaped_reward( + self._current_risk, self._current_risk, + "propose_intervention", is_invalid=True, + ) + info["error"] = f"Drug {action.target_drug_id} not in current medications" + return reward, info + + previous_risk = self._current_risk + target_med = self._medications[target_idx] + + if action.intervention_type == "stop": + self._medications.pop(target_idx) + self._total_drug_changes += 1 + if action.target_drug_id in CRITICAL_DRUG_IDS: + self._critical_stopped_without_sub += 1 + + elif action.intervention_type == "dose_reduce": + meta = self._sim.get_drug_meta(action.target_drug_id) + if meta: + new_dose = max(meta.min_dose_mg, target_med.dose_mg * 0.5) + self._medications[target_idx] = target_med.model_copy( + update={"dose_mg": new_dose} + ) + + elif action.intervention_type == "substitute": + new_drug_id = action.proposed_new_drug_id + if not new_drug_id: + # Auto-find substitute + current_ids = [m.drug_id for m in self._medications] + new_drug_id = self._sim.find_substitute(action.target_drug_id, current_ids) + if new_drug_id: + new_meta = self._sim.get_drug_meta(new_drug_id) + if new_meta: + flags = self._sim.get_beers_flags( + new_drug_id, + self._episode.conditions if self._episode else [], + ) + self._medications[target_idx] = MedicationEntry( + drug_id=new_drug_id, + generic_name=new_meta.generic_name, + atc_class=new_meta.atc_class, + dose_mg=new_meta.default_dose_mg, + is_high_risk_elderly=new_meta.is_high_risk_elderly, + beers_flags=flags, + ) + self._total_drug_changes += 1 + # If critical drug was substituted, don't penalise + if action.target_drug_id in CRITICAL_DRUG_IDS: + pass # substitution is acceptable + else: + info["warning"] = f"Substitute {new_drug_id} not found in metadata" + # Don't consume budget for a failed substitute + self._remaining_intervention_budget += 1 + else: + info["warning"] = "No suitable substitute found" + # Don't consume budget for a failed substitute + self._remaining_intervention_budget += 1 + + elif action.intervention_type == "add_monitoring": + # Tag in metadata but don't change regimen + self._medications[target_idx] = target_med.model_copy( + update={"beers_flags": target_med.beers_flags + ["monitored"]} + ) + + self._remaining_intervention_budget -= 1 + self._current_risk = self._compute_risk() + risk_delta = previous_risk - self._current_risk + self._risk_deltas.append(risk_delta) + + self._interventions.append(InterventionRecord( + target_drug_id=action.target_drug_id, + action_type=action.intervention_type, + proposed_new_drug_id=action.proposed_new_drug_id, + rationale=action.rationale or "", + step_index=self._step_count, + )) + + reward = compute_shaped_reward(previous_risk, self._current_risk, "propose_intervention") + info["risk_delta"] = risk_delta + return reward, info + + def _run_grader(self) -> float: + assert self._task_cfg is not None + tid = self._task_cfg.task_id + + if tid == "easy_screening": + severe_pairs = self._get_severe_pairs() + return grade_easy_screening( + self._baseline_risk, + self._current_risk, + self._interventions, + severe_pairs, + ) + elif tid == "budgeted_screening": + return grade_budgeted_screening( + self._baseline_risk, + self._current_risk, + self._interventions, + self._risk_deltas, + len(self._interaction_queries), + self._severe_moderate_discovered, + ) + elif tid == "complex_tradeoff": + return grade_complex_tradeoff( + self._baseline_risk, + self._current_risk, + self._interventions, + self._total_drug_changes, + self._critical_stopped_without_sub, + ) + return 0.0 + + def _get_severe_pairs(self) -> List[Tuple[str, str]]: + """Return all severe DDI pairs present in the *initial* medication list.""" + if not self._episode: + return [] + pairs: List[Tuple[str, str]] = [] + med_ids = self._episode.medication_ids + for a, b in combinations(sorted(set(med_ids)), 2): + key = (a, b) if a < b else (b, a) + rule = self._sim.ddi_rules.get(key) + if rule and rule.severity == "severe": + pairs.append(key) + return pairs + + def _check_timeout_and_build_obs( + self, reward: float, info: Dict[str, Any] + ) -> PolypharmacyObservation: + assert self._task_cfg is not None + + if not self._done and self._step_count >= self._task_cfg.max_steps: + self._done = True + timeout_penalty = compute_shaped_reward( + self._current_risk, self._current_risk, + "finish_review", is_timeout=True, + ) + score = self._run_grader() + reward += timeout_penalty + score + info["timeout"] = True + info["grader_score"] = score + + self._last_reward = reward + info["current_risk"] = self._current_risk + info["baseline_risk"] = self._baseline_risk + + return self._make_observation(reward=reward, info=info) + + def _make_observation( + self, reward: float = 0.0, info: Optional[Dict[str, Any]] = None, + ) -> PolypharmacyObservation: + ep = self._episode + cfg = self._task_cfg + return PolypharmacyObservation( + episode_id=ep.episode_id if ep else "", + task_id=cfg.task_id if cfg else "budgeted_screening", + age=ep.age if ep else 65, + sex=ep.sex if ep else "M", + conditions=ep.conditions if ep else [], + eGFR_category=ep.eGFR_category if ep else "normal", + liver_function_category=ep.liver_function_category if ep else "normal", + current_medications=deepcopy(self._medications), + interaction_queries=deepcopy(self._interaction_queries), + interventions=deepcopy(self._interventions), + step_index=self._step_count, + remaining_query_budget=self._remaining_query_budget, + remaining_intervention_budget=self._remaining_intervention_budget, + shaped_reward=reward, + done=self._done, + reward=reward, + metadata=info or {}, + ) diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/graders.py b/openenv-polypharmacy/backend/src/polypharmacy_env/graders.py new file mode 100644 index 0000000000000000000000000000000000000000..2b9e8c121757b745a150a98958378f78cc102923 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/graders.py @@ -0,0 +1,98 @@ +"""Deterministic graders for the three PolypharmacyEnv task difficulties.""" + +from __future__ import annotations + +from itertools import combinations +from typing import Dict, List, Tuple + +from .data_loader import DDIRule +from .config import CRITICAL_DRUG_IDS +from .models import InterventionRecord + + +_EPS = 1e-8 + + +def _clip(x: float) -> float: + return max(0.0, min(x, 1.0)) + + +# ── Easy: easy_screening ───────────────────────────────────────────────────── + +def grade_easy_screening( + baseline_risk: float, + final_risk: float, + interventions: List[InterventionRecord], + severe_ddi_drug_ids: List[Tuple[str, str]], +) -> float: + """Score ∈ [0, 1] for the easy task. + + 50 % risk reduction + 50 % targeted-intervention flag. + """ + risk_reduction = max(0.0, baseline_risk - final_risk) / max(baseline_risk, _EPS) + + targeted = 0.0 + severe_drugs = set() + for a, b in severe_ddi_drug_ids: + severe_drugs.add(a) + severe_drugs.add(b) + for iv in interventions: + if iv.target_drug_id in severe_drugs: + targeted = 1.0 + break + + return _clip(0.5 * risk_reduction + 0.5 * targeted) + + +# ── Medium: budgeted_screening ─────────────────────────────────────────────── + +def grade_budgeted_screening( + baseline_risk: float, + final_risk: float, + interventions: List[InterventionRecord], + risk_deltas: List[float], + num_queries: int, + severe_moderate_discovered: int, +) -> float: + """Score ∈ [0, 1] for the medium task. + + 50 % risk reduction + 30 % intervention precision + 20 % query efficiency. + """ + risk_reduction = max(0.0, baseline_risk - final_risk) / max(baseline_risk, _EPS) + + # Intervention precision: fraction of interventions that reduced risk + if interventions: + good = sum(1 for d in risk_deltas if d > 0) + precision = good / len(interventions) + else: + precision = 0.0 + + # Query efficiency + if num_queries > 0: + query_eff = min(severe_moderate_discovered / num_queries, 1.0) + else: + query_eff = 0.0 + + return _clip(0.5 * risk_reduction + 0.3 * precision + 0.2 * query_eff) + + +# ── Hard: complex_tradeoff ─────────────────────────────────────────────────── + +def grade_complex_tradeoff( + baseline_risk: float, + final_risk: float, + interventions: List[InterventionRecord], + total_drug_changes: int, + critical_drugs_stopped_without_sub: int, +) -> float: + """Score ∈ [0, 1] for the hard task. + + Base = risk reduction; penalty for regimen disruption and critical-drug stops. + """ + risk_reduction = max(0.0, baseline_risk - final_risk) / max(baseline_risk, _EPS) + + # Regimen disruption: penalise excessive changes + disruption = 0.05 * total_drug_changes + critical_penalty = 0.20 * critical_drugs_stopped_without_sub + + return _clip(risk_reduction - disruption - critical_penalty) diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/models.py b/openenv-polypharmacy/backend/src/polypharmacy_env/models.py new file mode 100644 index 0000000000000000000000000000000000000000..69f51f1d819a3867c88d8be81093f9030d78337b --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/models.py @@ -0,0 +1,111 @@ +"""Pydantic models for the PolypharmacyEnv environment. + +Extends OpenEnv base types (Action, Observation, State) and defines +auxiliary records for medications, interactions, and interventions. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from openenv.core.env_server.types import ( + Action as OpenEnvAction, + Observation as OpenEnvObservation, + State as OpenEnvState, +) + + +# ── Auxiliary models ───────────────────────────────────────────────────────── + +class MedicationEntry(BaseModel): + model_config = ConfigDict(extra="forbid") + + drug_id: str + generic_name: str + atc_class: str + dose_mg: float + frequency: str = "qd" + route: str = "po" + is_high_risk_elderly: bool = False + beers_flags: List[str] = Field(default_factory=list) + + +class InteractionQueryRecord(BaseModel): + model_config = ConfigDict(extra="forbid") + + drug_id_1: str + drug_id_2: str + severity: Optional[str] = None + recommendation: Optional[str] = None + risk_score: Optional[float] = None + step_index: int = 0 + + +class InterventionRecord(BaseModel): + model_config = ConfigDict(extra="forbid") + + target_drug_id: str + action_type: Literal["stop", "dose_reduce", "substitute", "add_monitoring"] + proposed_new_drug_id: Optional[str] = None + rationale: str = "" + step_index: int = 0 + + +# ── OpenEnv wire models ───────────────────────────────────────────────────── + +class PolypharmacyAction(OpenEnvAction): + """Action sent by the agent each step. + + Extends openenv.core.env_server.types.Action. + """ + + action_type: Literal["query_ddi", "propose_intervention", "finish_review"] + drug_id_1: Optional[str] = None + drug_id_2: Optional[str] = None + target_drug_id: Optional[str] = None + intervention_type: Optional[ + Literal["stop", "dose_reduce", "substitute", "add_monitoring", "none"] + ] = None + proposed_new_drug_id: Optional[str] = None + rationale: Optional[str] = None + + +class PolypharmacyObservation(OpenEnvObservation): + """Observation returned to the agent. + + Extends openenv.core.env_server.types.Observation which provides: + - done: bool + - reward: float | None + - metadata: Dict[str, Any] + """ + + episode_id: str = "" + task_id: str = "budgeted_screening" + age: int = 65 + sex: str = "M" + conditions: List[str] = Field(default_factory=list) + eGFR_category: str = "normal" + liver_function_category: str = "normal" + current_medications: List[MedicationEntry] = Field(default_factory=list) + interaction_queries: List[InteractionQueryRecord] = Field(default_factory=list) + interventions: List[InterventionRecord] = Field(default_factory=list) + step_index: int = 0 + remaining_query_budget: int = 0 + remaining_intervention_budget: int = 0 + shaped_reward: float = 0.0 + + +class PolypharmacyState(OpenEnvState): + """Compact state snapshot for the /state endpoint. + + Extends openenv.core.env_server.types.State which provides: + - episode_id: str | None + - step_count: int + """ + + task_id: str = "" + max_steps: int = 0 + num_query_actions: int = 0 + num_interventions: int = 0 diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/rewards.py b/openenv-polypharmacy/backend/src/polypharmacy_env/rewards.py new file mode 100644 index 0000000000000000000000000000000000000000..6771966f34d7e90285eaaf8264e99debfd6e45b4 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/rewards.py @@ -0,0 +1,92 @@ +"""Reward shaping and regimen-risk computation.""" + +from __future__ import annotations + +from itertools import combinations +from typing import Dict, List, Optional, Tuple + +from .config import ( + INTERVENTION_COST, + INVALID_ACTION_PENALTY, + QUERY_COST, + SEVERE_DDI_DISCOVERY_BONUS, + TIMEOUT_PENALTY, +) +from .data_loader import BeersCriterion, DDIRule, DrugMeta + + +def compute_regimen_risk( + current_drug_ids: List[str], + patient_conditions: List[str], + ddi_rules: Dict[Tuple[str, str], DDIRule], + beers_criteria: List[BeersCriterion], + drug_metadata: Dict[str, DrugMeta], +) -> float: + """Compute an aggregate risk score for the current medication regimen. + + Returns a float clipped to [0.0, 1.0]. + """ + if not current_drug_ids: + return 0.0 + + risk = 0.0 + drug_set = set(current_drug_ids) + + # 1. DDI pairwise risk + for a, b in combinations(sorted(drug_set), 2): + key = (a, b) if a < b else (b, a) + rule = ddi_rules.get(key) + if rule is not None: + risk += rule.base_risk_score + + # 2. Beers violations + beers_weight = {"avoid": 0.25, "caution": 0.10, "dose_adjust": 0.08, "avoid_in_condition": 0.20} + for bc in beers_criteria: + if bc.drug_id not in drug_set: + continue + if bc.condition is None: + risk += beers_weight.get(bc.criterion_type, 0.05) + elif bc.condition in patient_conditions: + risk += beers_weight.get(bc.criterion_type, 0.05) + + # 3. High-risk elderly drugs + for did in drug_set: + dm = drug_metadata.get(did) + if dm and dm.is_high_risk_elderly: + risk += 0.05 + + # Normalise by regimen size to keep score comparable across difficulties + risk /= max(len(drug_set), 1) + return min(max(risk, 0.0), 1.0) + + +def compute_shaped_reward( + previous_risk: float, + new_risk: float, + action_type: str, + *, + is_invalid: bool = False, + is_timeout: bool = False, + discovered_severe: bool = False, +) -> float: + """Compute the step-level shaped reward.""" + reward = 0.0 + + if is_invalid: + return -INVALID_ACTION_PENALTY + + if is_timeout: + return -TIMEOUT_PENALTY + + if action_type == "query_ddi": + reward -= QUERY_COST + if discovered_severe: + reward += SEVERE_DDI_DISCOVERY_BONUS + + elif action_type == "propose_intervention": + reward += (previous_risk - new_risk) + reward -= INTERVENTION_COST + + # finish_review terminal bonus is added by the caller after grading + + return reward diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/services/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..daa00ba9855c5ea54e0e4d3fe0a29407c421340a --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/services/__init__.py @@ -0,0 +1 @@ +"""Service layer for external integrations.""" diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/services/groq_agent.py b/openenv-polypharmacy/backend/src/polypharmacy_env/services/groq_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..9f15f22ff2ec9f4710949815b29f0431b2cac63b --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/services/groq_agent.py @@ -0,0 +1,246 @@ +"""Groq-powered action suggester for PolypharmacyEnv.""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from openai import OpenAI + +from ..models import PolypharmacyAction, PolypharmacyObservation + +DEFAULT_MODEL = "llama-3.1-8b-instant" +FALLBACK_MODELS = [ + "llama-3.1-8b-instant", + "llama-3.3-70b-versatile", + "gemma2-9b-it", +] +CRITICAL_DRUG_IDS = {"DRUG_WARFARIN", "DRUG_INSULIN_GLARGINE", "DRUG_DIGOXIN"} + +SYSTEM_PROMPT = """You are a clinical medication safety assistant. +Return exactly one JSON object describing the next action. +Allowed output schema: +{ + "action_type": "query_ddi" | "propose_intervention" | "finish_review", + "drug_id_1": "optional", + "drug_id_2": "optional", + "target_drug_id": "optional", + "intervention_type": "stop|dose_reduce|substitute|add_monitoring|none", + "proposed_new_drug_id": "optional", + "rationale": "optional" +} +No markdown fences. No extra text. +Do NOT use finish_review early. First, gather evidence with query_ddi and/or +perform at least one meaningful intervention when needed. +""" + + +def _obs_to_prompt(obs: PolypharmacyObservation) -> str: + meds = ", ".join(m.drug_id for m in obs.current_medications) + conds = ", ".join(obs.conditions) + return ( + f"Task: {obs.task_id}\n" + f"Age: {obs.age}, sex: {obs.sex}\n" + f"Conditions: {conds}\n" + f"Medications: {meds}\n" + f"Query budget: {obs.remaining_query_budget}\n" + f"Intervention budget: {obs.remaining_intervention_budget}\n" + f"Step index: {obs.step_index}\n" + "Choose the single safest, most useful next action." + ) + + +def _parse_action(text: str) -> PolypharmacyAction: + raw = text.strip() + if raw.startswith("```"): + raw = raw.split("\n", 1)[-1] + if raw.endswith("```"): + raw = raw.rsplit("```", 1)[0] + raw = raw.strip() + payload: dict[str, Any] = json.loads(raw) + return PolypharmacyAction.model_validate(payload) + + +def _fallback_query_action(obs: PolypharmacyObservation) -> PolypharmacyAction: + meds = [m.drug_id for m in obs.current_medications] + if len(meds) >= 2 and obs.remaining_query_budget > 0: + return PolypharmacyAction( + action_type="query_ddi", + drug_id_1=meds[0], + drug_id_2=meds[1], + ) + return PolypharmacyAction(action_type="finish_review") + + +def _norm_pair(a: str, b: str) -> tuple[str, str]: + return (a, b) if a < b else (b, a) + + +def _pick_unseen_query_pair(obs: PolypharmacyObservation) -> tuple[str, str] | None: + meds = [m.drug_id for m in obs.current_medications] + if len(meds) < 2 or obs.remaining_query_budget <= 0: + return None + + seen = { + _norm_pair(q.drug_id_1, q.drug_id_2) + for q in obs.interaction_queries + } + # Prioritize pairs containing high-risk drugs. + high_risk = [m.drug_id for m in obs.current_medications if m.is_high_risk_elderly] + ordered = high_risk + [m for m in meds if m not in set(high_risk)] + + for i in range(len(ordered)): + for j in range(i + 1, len(ordered)): + p = _norm_pair(ordered[i], ordered[j]) + if p not in seen: + return p + return None + + +def _pick_intervention_target(obs: PolypharmacyObservation) -> str | None: + if obs.remaining_intervention_budget <= 0: + return None + med_set = {m.drug_id for m in obs.current_medications} + + # Use latest discovered severe/moderate query as intervention target. + for q in reversed(obs.interaction_queries): + if q.severity in ("severe", "moderate"): + m1 = next((m for m in obs.current_medications if m.drug_id == q.drug_id_1), None) + m2 = next((m for m in obs.current_medications if m.drug_id == q.drug_id_2), None) + candidates = [m for m in (m1, m2) if m is not None] + if not candidates: + continue + # Prefer non-critical risky drugs first. + candidates.sort( + key=lambda m: ( + m.drug_id in CRITICAL_DRUG_IDS, + 0 if any("avoid" in f for f in m.beers_flags) else 1, + 0 if m.is_high_risk_elderly else 1, + ) + ) + return candidates[0].drug_id + + # Fallback: if no severe/moderate discovered, still intervene on obviously + # risky medications (Beers/high-risk flags) when budgets permit. + risky = sorted( + obs.current_medications, + key=lambda m: ( + 0 if any("avoid" in f for f in m.beers_flags) else 1, + 0 if m.is_high_risk_elderly else 1, + 1 if m.drug_id in CRITICAL_DRUG_IDS else 0, + ), + ) + for med in risky: + if any("avoid" in f for f in med.beers_flags) or med.is_high_risk_elderly: + return med.drug_id + return None + + +def _rule_based_action(obs: PolypharmacyObservation) -> PolypharmacyAction | None: + # If we already discovered significant risk, intervene before more querying. + target = _pick_intervention_target(obs) + if target and ( + obs.step_index >= 1 + and ( + obs.remaining_query_budget <= 2 + or len(obs.interaction_queries) >= 4 + or any(q.severity in ("severe", "moderate") for q in obs.interaction_queries) + ) + ): + intervention = "stop" + rationale = "Remove likely contributor to discovered interaction risk" + if target in CRITICAL_DRUG_IDS: + # Avoid blunt stop for critical meds. + intervention = "dose_reduce" + rationale = "Critical medication: prefer dose reduction over abrupt stop" + return PolypharmacyAction( + action_type="propose_intervention", + target_drug_id=target, + intervention_type=intervention, + rationale=rationale, + ) + + pair = _pick_unseen_query_pair(obs) + if pair: + return PolypharmacyAction( + action_type="query_ddi", + drug_id_1=pair[0], + drug_id_2=pair[1], + ) + + if obs.remaining_intervention_budget > 0: + # Final fallback before finish: at least one safety action. + target = _pick_intervention_target(obs) + if target: + return PolypharmacyAction( + action_type="propose_intervention", + target_drug_id=target, + intervention_type="dose_reduce" + if target in CRITICAL_DRUG_IDS + else "stop", + rationale="Fallback intervention when query options are exhausted", + ) + + if obs.step_index >= 3: + return PolypharmacyAction(action_type="finish_review") + return None + + +def _postprocess_action( + obs: PolypharmacyObservation, action: PolypharmacyAction +) -> PolypharmacyAction: + # First apply deterministic guardrails to avoid repetitive loops. + ruled = _rule_based_action(obs) + if ruled is not None: + return ruled + + # Guardrail: prevent useless immediate finish actions. + if action.action_type == "finish_review": + if obs.step_index < 2 and obs.remaining_query_budget > 0: + return _fallback_query_action(obs) + if len(obs.interaction_queries) == 0 and obs.remaining_query_budget > 0: + return _fallback_query_action(obs) + return action + + +def suggest_action_from_observation( + observation: PolypharmacyObservation, + model_name: str | None = None, +) -> PolypharmacyAction: + """Use Groq chat completions to suggest a valid action.""" + api_key = os.getenv("GROQ_API_KEY", "").strip() + if not api_key: + raise ValueError("GROQ_API_KEY is missing. Add it to your .env file.") + + base_url = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").strip() + model = (model_name or os.getenv("GROQ_MODEL_NAME", DEFAULT_MODEL)).strip() + client = OpenAI(api_key=api_key, base_url=base_url) + + user_prompt = _obs_to_prompt(observation) + tried: list[tuple[str, str]] = [] + candidates: list[str] = [model] + [m for m in FALLBACK_MODELS if m != model] + + for candidate in candidates: + try: + resp = client.chat.completions.create( + model=candidate, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.2, + max_tokens=220, + ) + generated = (resp.choices[0].message.content or "").strip() + parsed = _parse_action(generated) + return _postprocess_action(observation, parsed) + except Exception as exc: + tried.append((candidate, str(exc))) + + tried_txt = " | ".join(f"{m}: {err}" for m, err in tried) + raise ValueError( + "No Groq model worked. Try one of: " + "llama-3.3-70b-versatile, llama-3.1-8b-instant, gemma2-9b-it. " + f"Errors: {tried_txt}" + ) diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/tasks.py b/openenv-polypharmacy/backend/src/polypharmacy_env/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..ee077d9d60e13ae19b399f3e4d452a10fb2ea2c2 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/tasks.py @@ -0,0 +1,47 @@ +"""Task setup utilities: select episodes and configure budgets per difficulty.""" + +from __future__ import annotations + +import random +from typing import Optional + +from .config import DEFAULT_TASK, TASK_CONFIGS, TaskConfig +from .data_loader import PatientEpisode, load_patients + + +# Map OpenEnv difficulty labels to the CSV difficulty tags +_DIFFICULTY_MAP = { + "easy_screening": "easy", + "budgeted_screening": "medium", + "complex_tradeoff": "hard", +} + + +def get_task_config(task_id: Optional[str] = None) -> TaskConfig: + tid = task_id or DEFAULT_TASK + cfg = TASK_CONFIGS.get(tid) + if cfg is None: + raise ValueError(f"Unknown task_id {tid!r}. Choose from {list(TASK_CONFIGS)}") + return cfg + + +def sample_episode( + task_id: Optional[str] = None, + seed: Optional[int] = None, + episode_id: Optional[str] = None, +) -> PatientEpisode: + """Return a single patient episode appropriate for *task_id*.""" + tid = task_id or DEFAULT_TASK + difficulty = _DIFFICULTY_MAP.get(tid, "medium") + episodes = load_patients(difficulty=difficulty) + if not episodes: + raise RuntimeError(f"No episodes found for difficulty={difficulty!r}") + + if episode_id: + for ep in episodes: + if ep.episode_id == episode_id: + return ep + raise ValueError(f"Episode {episode_id!r} not found for difficulty={difficulty!r}") + + rng = random.Random(seed) + return rng.choice(episodes) diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/tests/__init__.py b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d420712d8bbf0195c02e3317e4395b2c2ffeacf6 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/__init__.py @@ -0,0 +1 @@ +"""Tests.""" diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_api.py b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..6ba69ae65100a6611dc3883c4a286dfb3c821f2c --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_api.py @@ -0,0 +1,146 @@ +"""Tests for the FastAPI HTTP server (OpenEnv create_app endpoints). + +OpenEnv HTTP endpoints are *stateless*: each /reset and /step creates a +fresh environment instance. Multi-step sessions only work via WebSocket. +These tests validate single-call behaviour and schema contracts. +""" + +from __future__ import annotations + +import json + +import pytest +from fastapi.testclient import TestClient + +from polypharmacy_env.api.server import app + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +class TestHealth: + def test_health(self, client: TestClient) -> None: + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "healthy" + + +class TestReset: + def test_reset_default(self, client: TestClient) -> None: + resp = client.post("/reset", json={}) + assert resp.status_code == 200 + data = resp.json() + assert "observation" in data + assert data["done"] is False + + def test_reset_with_task(self, client: TestClient) -> None: + resp = client.post("/reset", json={"task_id": "easy_screening"}) + assert resp.status_code == 200 + obs = resp.json()["observation"] + assert obs["task_id"] == "easy_screening" + + def test_reset_observation_has_medications(self, client: TestClient) -> None: + resp = client.post("/reset", json={"task_id": "easy_screening", "seed": 42}) + assert resp.status_code == 200 + obs = resp.json()["observation"] + assert len(obs["current_medications"]) >= 3 + + +class TestStep: + """Test /step endpoint – each call is independent (stateless).""" + + def test_step_finish(self, client: TestClient) -> None: + resp = client.post( + "/step", + json={"action": {"action_type": "finish_review"}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "observation" in data + + def test_invalid_action_422(self, client: TestClient) -> None: + resp = client.post( + "/step", + json={"action": {"action_type": "invalid_type"}}, + ) + assert resp.status_code == 422 + + +class TestSchema: + def test_schema(self, client: TestClient) -> None: + resp = client.get("/schema") + assert resp.status_code == 200 + data = resp.json() + # OpenEnv schema endpoint returns keys: action, observation, state + assert "action" in data + assert "observation" in data + + +class TestWebSocketSession: + """Test multi-step sessions through the /ws WebSocket endpoint. + + OpenEnv WS protocol: + Send: {"type": "reset", "data": {"task_id": "...", "seed": ...}} + Recv: {"type": "observation", "data": {"observation": {...}, "reward": ..., "done": ...}} + Send: {"type": "step", "data": {"action_type": "...", ...}} + Recv: {"type": "observation", "data": {"observation": {...}, ...}} + Send: {"type": "state"} + Recv: {"type": "state", "data": {...state fields...}} + """ + + def test_ws_reset_step_finish(self, client: TestClient) -> None: + with client.websocket_connect("/ws") as ws: + # Reset + ws.send_json({ + "type": "reset", + "data": {"task_id": "easy_screening", "seed": 42}, + }) + reset_resp = ws.receive_json() + assert reset_resp["type"] == "observation" + reset_data = reset_resp["data"] + assert reset_data["done"] is False + obs = reset_data["observation"] + assert obs["task_id"] == "easy_screening" + meds = obs["current_medications"] + assert len(meds) >= 3 + + # Step – query DDI + if len(meds) >= 2: + ws.send_json({ + "type": "step", + "data": { + "action_type": "query_ddi", + "drug_id_1": meds[0]["drug_id"], + "drug_id_2": meds[1]["drug_id"], + }, + }) + step_resp = ws.receive_json() + assert step_resp["type"] == "observation" + assert step_resp["data"]["done"] is False + + # Finish + ws.send_json({ + "type": "step", + "data": {"action_type": "finish_review"}, + }) + finish_resp = ws.receive_json() + assert finish_resp["type"] == "observation" + assert finish_resp["data"]["done"] is True + + def test_ws_state(self, client: TestClient) -> None: + with client.websocket_connect("/ws") as ws: + ws.send_json({ + "type": "reset", + "data": {"task_id": "easy_screening", "seed": 0}, + }) + ws.receive_json() # consume reset response + + ws.send_json({"type": "state"}) + state_resp = ws.receive_json() + assert state_resp["type"] == "state" + state_data = state_resp["data"] + assert state_data["step_count"] == 0 + assert state_data["task_id"] == "easy_screening" diff --git a/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_env_core.py b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_env_core.py new file mode 100644 index 0000000000000000000000000000000000000000..f3b9fc35fb49896c88ea4fc8e72f459fc3fdea84 --- /dev/null +++ b/openenv-polypharmacy/backend/src/polypharmacy_env/tests/test_env_core.py @@ -0,0 +1,192 @@ +"""Tests for the PolypharmacyEnv core.""" + +from __future__ import annotations + +import pytest + +from polypharmacy_env.env_core import PolypharmacyEnv +from polypharmacy_env.models import ( + PolypharmacyAction, + PolypharmacyObservation, + PolypharmacyState, +) + + +class TestReset: + def test_reset_returns_observation(self) -> None: + env = PolypharmacyEnv() + obs = env.reset(task_id="easy_screening", seed=42) + assert isinstance(obs, PolypharmacyObservation) + assert obs.done is False + assert obs.step_index == 0 + assert len(obs.current_medications) >= 3 + + def test_reset_medium(self) -> None: + env = PolypharmacyEnv() + obs = env.reset(task_id="budgeted_screening", seed=0) + assert obs.remaining_query_budget == 8 + assert obs.remaining_intervention_budget == 3 + assert len(obs.current_medications) >= 6 + + def test_reset_hard(self) -> None: + env = PolypharmacyEnv() + obs = env.reset(task_id="complex_tradeoff", seed=0) + assert obs.remaining_query_budget == 12 + assert obs.remaining_intervention_budget == 5 + assert len(obs.current_medications) >= 10 + + def test_default_task(self) -> None: + env = PolypharmacyEnv() + obs = env.reset(seed=0) + assert obs.task_id == "budgeted_screening" + + +class TestStep: + def test_query_ddi(self) -> None: + env = PolypharmacyEnv() + obs = env.reset(task_id="easy_screening", seed=42) + meds = obs.current_medications + assert len(meds) >= 2 + + action = PolypharmacyAction( + action_type="query_ddi", + drug_id_1=meds[0].drug_id, + drug_id_2=meds[1].drug_id, + ) + obs = env.step(action) + assert isinstance(obs, PolypharmacyObservation) + assert obs.done is False + assert obs.step_index == 1 + assert len(obs.interaction_queries) == 1 + + def test_invalid_action_penalised(self) -> None: + env = PolypharmacyEnv() + env.reset(task_id="easy_screening", seed=42) + + action = PolypharmacyAction( + action_type="propose_intervention", + target_drug_id=None, + intervention_type=None, + ) + obs = env.step(action) + assert obs.reward is not None + assert obs.reward < 0 # penalty + + def test_finish_review(self) -> None: + env = PolypharmacyEnv() + env.reset(task_id="easy_screening", seed=42) + + action = PolypharmacyAction(action_type="finish_review") + obs = env.step(action) + assert obs.done is True + assert "grader_score" in obs.metadata + score = obs.metadata["grader_score"] + assert 0.0 <= score <= 1.0 + + def test_intervention_stop(self) -> None: + env = PolypharmacyEnv() + obs = env.reset(task_id="easy_screening", seed=42) + target = obs.current_medications[0].drug_id + n_meds = len(obs.current_medications) + + action = PolypharmacyAction( + action_type="propose_intervention", + target_drug_id=target, + intervention_type="stop", + rationale="test stop", + ) + obs = env.step(action) + assert len(obs.current_medications) == n_meds - 1 + + def test_budget_exhaustion(self) -> None: + env = PolypharmacyEnv() + obs = env.reset(task_id="easy_screening", seed=42) + meds = obs.current_medications + + # Exhaust query budget (4 for easy) + for i in range(4): + a_idx = i % len(meds) + b_idx = (i + 1) % len(meds) + action = PolypharmacyAction( + action_type="query_ddi", + drug_id_1=meds[a_idx].drug_id, + drug_id_2=meds[b_idx].drug_id, + ) + obs = env.step(action) + if obs.done: + break + + if not obs.done: + assert obs.remaining_query_budget == 0 + # Trying another query should be penalised + action = PolypharmacyAction( + action_type="query_ddi", + drug_id_1=meds[0].drug_id, + drug_id_2=meds[1].drug_id, + ) + obs = env.step(action) + assert obs.reward is not None + assert obs.reward < 0 + + def test_max_steps_timeout(self) -> None: + env = PolypharmacyEnv() + obs = env.reset(task_id="easy_screening", seed=42) # max_steps=10 + meds = obs.current_medications + + # Keep querying until timeout + for i in range(15): + if obs.done: + break + a = meds[i % len(meds)].drug_id + b = meds[(i + 1) % len(meds)].drug_id + action = PolypharmacyAction( + action_type="query_ddi", + drug_id_1=a, + drug_id_2=b, + ) + obs = env.step(action) + + assert obs.done is True + + +class TestState: + def test_state_after_reset(self) -> None: + env = PolypharmacyEnv() + env.reset(task_id="easy_screening", seed=42) + st = env.state + assert isinstance(st, PolypharmacyState) + assert st.step_count == 0 + assert st.episode_id is not None + + +class TestGraderDeterminism: + def test_same_trajectory_same_score(self) -> None: + scores = [] + for _ in range(3): + env = PolypharmacyEnv() + env.reset(task_id="easy_screening", seed=99) + obs = env.step(PolypharmacyAction(action_type="finish_review")) + scores.append(obs.metadata.get("grader_score", 0.0)) + assert all(s == scores[0] for s in scores) + + def test_intervention_changes_score(self) -> None: + # No intervention + env = PolypharmacyEnv() + env.reset(task_id="budgeted_screening", seed=42) + obs = env.step(PolypharmacyAction(action_type="finish_review")) + score_noop = obs.metadata.get("grader_score", 0.0) + + # With intervention + env2 = PolypharmacyEnv() + obs_init2 = env2.reset(task_id="budgeted_screening", seed=42) + if obs_init2.current_medications: + env2.step(PolypharmacyAction( + action_type="propose_intervention", + target_drug_id=obs_init2.current_medications[0].drug_id, + intervention_type="stop", + rationale="test", + )) + obs2 = env2.step(PolypharmacyAction(action_type="finish_review")) + score_act = obs2.metadata.get("grader_score", 0.0) + + assert score_noop != score_act diff --git a/openenv-polypharmacy/data/lookups/beers_criteria.csv b/openenv-polypharmacy/data/lookups/beers_criteria.csv new file mode 100644 index 0000000000000000000000000000000000000000..019b1ecfc2c7935e9c03a761a3df704dc3993f32 --- /dev/null +++ b/openenv-polypharmacy/data/lookups/beers_criteria.csv @@ -0,0 +1,16 @@ +drug_id,criterion_type,condition,rationale +DRUG_DIAZEPAM,avoid,,"Long-acting benzodiazepine: falls, fractures, cognitive impairment in elderly" +DRUG_ALPRAZOLAM,avoid,,"Benzodiazepine: falls, fractures, cognitive impairment in elderly" +DRUG_AMITRIPTYLINE,avoid,,"Strongly anticholinergic TCA: sedation, confusion, urinary retention in elderly" +DRUG_GLIPIZIDE,caution,,Sulfonylurea: hypoglycemia risk higher in elderly +DRUG_NAPROXEN,avoid,CKD,"NSAID contraindicated in CKD – renal deterioration, fluid retention" +DRUG_IBUPROFEN,avoid,CKD,"NSAID contraindicated in CKD – renal deterioration, fluid retention" +DRUG_NAPROXEN,caution,,NSAID: GI bleeding and renal risk in elderly +DRUG_IBUPROFEN,caution,,NSAID: GI bleeding and renal risk in elderly +DRUG_DIGOXIN,dose_adjust,,Avoid doses > 0.125 mg/day in elderly – toxicity risk +DRUG_TRAMADOL,avoid,,"Opioid: CNS depression, falls, constipation in elderly" +DRUG_METFORMIN,dose_adjust,CKD,Reduce dose or avoid if eGFR < 30 – lactic acidosis risk +DRUG_INSULIN_GLARGINE,caution,,Tight glycemic control increases hypoglycemia risk in elderly +DRUG_PREDNISONE,avoid_in_condition,DM,Corticosteroid worsens glycemic control in diabetes +DRUG_DONEPEZIL,avoid_in_condition,dementia,"Limited benefit, GI side effects; reassess regularly" +DRUG_CIPROFLOXACIN,caution,,"Fluoroquinolone: tendon rupture, QT prolongation risk in elderly" diff --git a/openenv-polypharmacy/data/lookups/ddi_rules.csv b/openenv-polypharmacy/data/lookups/ddi_rules.csv new file mode 100644 index 0000000000000000000000000000000000000000..dc8adc9362d8da83517374a94044c0ebd06b2e74 --- /dev/null +++ b/openenv-polypharmacy/data/lookups/ddi_rules.csv @@ -0,0 +1,25 @@ +drug_id_1,drug_id_2,severity,mechanism,recommendation,base_risk_score +DRUG_NAPROXEN,DRUG_WARFARIN,severe,Increased bleeding risk – NSAID inhibits platelet + anticoagulant,avoid_combination,0.9 +DRUG_IBUPROFEN,DRUG_WARFARIN,severe,Increased bleeding risk – NSAID + anticoagulant synergy,avoid_combination,0.88 +DRUG_ASPIRIN,DRUG_WARFARIN,moderate,Additive antiplatelet + anticoagulant bleeding risk,monitor_closely,0.55 +DRUG_FLUOXETINE,DRUG_WARFARIN,moderate,SSRI increases serotonin and may potentiate bleeding,monitor_closely,0.45 +DRUG_CIPROFLOXACIN,DRUG_WARFARIN,moderate,CYP1A2 inhibition raises warfarin levels,dose_adjust,0.5 +DRUG_APIXABAN,DRUG_NAPROXEN,severe,DOAC + NSAID – high bleeding risk,avoid_combination,0.85 +DRUG_APIXABAN,DRUG_ASPIRIN,moderate,Additive bleeding risk with antiplatelet,monitor_closely,0.5 +DRUG_AMIODARONE,DRUG_DIGOXIN,severe,Amiodarone increases digoxin levels – toxicity risk,dose_adjust,0.8 +DRUG_DIGOXIN,DRUG_SPIRONOLACTONE,moderate,Spironolactone may raise digoxin levels,monitor_closely,0.4 +DRUG_CIPROFLOXACIN,DRUG_METFORMIN,moderate,Fluoroquinolone may cause dysglycemia with metformin,monitor_closely,0.35 +DRUG_DIAZEPAM,DRUG_TRAMADOL,severe,CNS depression – benzodiazepine + opioid,avoid_combination,0.92 +DRUG_ALPRAZOLAM,DRUG_TRAMADOL,severe,CNS depression – benzodiazepine + opioid,avoid_combination,0.91 +DRUG_LISINOPRIL,DRUG_SPIRONOLACTONE,moderate,Hyperkalemia risk – ACE-I + K-sparing diuretic,monitor_closely,0.48 +DRUG_LISINOPRIL,DRUG_NAPROXEN,moderate,"NSAID reduces ACE-I efficacy, renal risk",monitor_closely,0.42 +DRUG_AMLODIPINE,DRUG_SIMVASTATIN,moderate,CYP3A4 interaction increases statin exposure,dose_adjust,0.38 +DRUG_ATORVASTATIN,DRUG_CIPROFLOXACIN,mild,Minor CYP interaction raising statin levels,no_action,0.15 +DRUG_CLOPIDOGREL,DRUG_OMEPRAZOLE,moderate,PPI reduces clopidogrel activation via CYP2C19,dose_adjust,0.45 +DRUG_GLIPIZIDE,DRUG_INSULIN_GLARGINE,moderate,Additive hypoglycemia risk,monitor_closely,0.5 +DRUG_FLUOXETINE,DRUG_TRAMADOL,severe,Serotonin syndrome risk – SSRI + serotonergic opioid,avoid_combination,0.82 +DRUG_AMITRIPTYLINE,DRUG_TRAMADOL,severe,Serotonin syndrome + CNS depression,avoid_combination,0.85 +DRUG_DIGOXIN,DRUG_METOPROLOL,moderate,Additive bradycardia,monitor_closely,0.4 +DRUG_DIGOXIN,DRUG_FUROSEMIDE,moderate,Loop diuretic causes hypokalemia increasing digoxin toxicity risk,monitor_closely,0.45 +DRUG_NAPROXEN,DRUG_PREDNISONE,moderate,GI bleeding risk – corticosteroid + NSAID,monitor_closely,0.5 +DRUG_PREDNISONE,DRUG_WARFARIN,mild,Corticosteroid may alter INR,monitor_closely,0.25 diff --git a/openenv-polypharmacy/data/lookups/drug_metadata.csv b/openenv-polypharmacy/data/lookups/drug_metadata.csv new file mode 100644 index 0000000000000000000000000000000000000000..f3ba924648540f5981feaecfc63f608ecac2ecba --- /dev/null +++ b/openenv-polypharmacy/data/lookups/drug_metadata.csv @@ -0,0 +1,34 @@ +drug_id,generic_name,atc_class,is_high_risk_elderly,default_dose_mg,min_dose_mg,max_dose_mg +DRUG_WARFARIN,warfarin,B01AA,1,5.0,1.0,10.0 +DRUG_APIXABAN,apixaban,B01AF,1,5.0,2.5,10.0 +DRUG_METFORMIN,metformin,A10BA,0,1000,500,2000 +DRUG_GLIPIZIDE,glipizide,A10BB,1,5.0,2.5,20.0 +DRUG_LISINOPRIL,lisinopril,C09AA,0,10.0,2.5,40.0 +DRUG_AMLODIPINE,amlodipine,C08CA,0,5.0,2.5,10.0 +DRUG_METOPROLOL,metoprolol,C07AB,0,50.0,25.0,200.0 +DRUG_DIGOXIN,digoxin,C01AA,1,0.25,0.0625,0.5 +DRUG_FUROSEMIDE,furosemide,C03CA,0,40.0,20.0,160.0 +DRUG_SPIRONOLACTONE,spironolactone,C03DA,0,25.0,12.5,50.0 +DRUG_ATORVASTATIN,atorvastatin,C10AA,0,20.0,10.0,80.0 +DRUG_SIMVASTATIN,simvastatin,C10AA,0,20.0,10.0,40.0 +DRUG_OMEPRAZOLE,omeprazole,A02BC,0,20.0,10.0,40.0 +DRUG_DIAZEPAM,diazepam,N05BA,1,5.0,2.0,10.0 +DRUG_ALPRAZOLAM,alprazolam,N05BA,1,0.5,0.25,2.0 +DRUG_AMITRIPTYLINE,amitriptyline,N06AA,1,25.0,10.0,75.0 +DRUG_INSULIN_GLARGINE,insulin glargine,A10AE,1,20.0,10.0,60.0 +DRUG_PREDNISONE,prednisone,H02AB,0,10.0,5.0,60.0 +DRUG_NAPROXEN,naproxen,M01AE,1,500,250,1000 +DRUG_IBUPROFEN,ibuprofen,M01AE,1,400,200,800 +DRUG_CLOPIDOGREL,clopidogrel,B01AC,0,75.0,75.0,75.0 +DRUG_ASPIRIN,aspirin,B01AC,0,81.0,81.0,325.0 +DRUG_HYDROCHLOROTHIAZIDE,HCTZ,C03AA,0,25.0,12.5,50.0 +DRUG_DONEPEZIL,donepezil,N06DA,0,5.0,5.0,10.0 +DRUG_GABAPENTIN,gabapentin,N03AX,0,300,100,1200 +DRUG_TRAMADOL,tramadol,N02AX,1,50.0,25.0,200.0 +DRUG_FLUOXETINE,fluoxetine,N06AB,0,20.0,10.0,60.0 +DRUG_SERTRALINE,sertraline,N06AB,0,50.0,25.0,200.0 +DRUG_CIPROFLOXACIN,ciprofloxacin,J01MA,0,500,250,750 +DRUG_TAMSULOSIN,tamsulosin,G04CA,0,0.4,0.4,0.8 +DRUG_CELECOXIB,celecoxib,M01AE,0,200,100,400 +DRUG_NORTRIPTYLINE,nortriptyline,N06AA,0,25.0,10.0,75.0 +DRUG_LOSARTAN,losartan,C09AA,0,50.0,25.0,100.0 diff --git a/openenv-polypharmacy/data/processed/patients_polypharmacy.csv b/openenv-polypharmacy/data/processed/patients_polypharmacy.csv new file mode 100644 index 0000000000000000000000000000000000000000..3dfd4b83053958e03a785dd8bd057c7c5879c74b --- /dev/null +++ b/openenv-polypharmacy/data/processed/patients_polypharmacy.csv @@ -0,0 +1,121 @@ +episode_id,age,sex,conditions,eGFR_category,liver_function_category,medication_ids,baseline_risk_score,difficulty +EP_0001,72,F,HTN,moderate,normal,DRUG_AMLODIPINE;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_FUROSEMIDE;DRUG_AMIODARONE,0.25,easy +EP_0002,67,M,OA;COPD;neuropathy,normal,normal,DRUG_IBUPROFEN;DRUG_TRAMADOL;DRUG_AMITRIPTYLINE,0.2833,easy +EP_0003,73,F,HTN;HF,normal,normal,DRUG_FUROSEMIDE;DRUG_FLUOXETINE;DRUG_TRAMADOL,0.2733,easy +EP_0004,74,M,CKD,mild,impaired,DRUG_AMITRIPTYLINE;DRUG_AMLODIPINE;DRUG_TRAMADOL,0.2833,easy +EP_0005,76,F,OA;neuropathy;CKD,mild,normal,DRUG_IBUPROFEN;DRUG_GABAPENTIN;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_AMITRIPTYLINE,0.17,easy +EP_0006,74,M,HTN;OA,normal,impaired,DRUG_IBUPROFEN;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_FUROSEMIDE;DRUG_AMIODARONE,0.25,easy +EP_0007,90,M,BPH;OA,moderate,normal,DRUG_WARFARIN;DRUG_NAPROXEN;DRUG_TAMSULOSIN;DRUG_GABAPENTIN,0.225,easy +EP_0008,77,F,CKD;OA;depression,mild,normal,DRUG_AMITRIPTYLINE;DRUG_IBUPROFEN;DRUG_SERTRALINE;DRUG_TRAMADOL;DRUG_FUROSEMIDE,0.17,easy +EP_0009,67,M,COPD;GERD;BPH,mild,normal,DRUG_WARFARIN;DRUG_IBUPROFEN;DRUG_OMEPRAZOLE;DRUG_TAMSULOSIN,0.22,easy +EP_0010,75,M,dementia;HTN;depression,normal,impaired,DRUG_TRAMADOL;DRUG_SERTRALINE;DRUG_AMITRIPTYLINE,0.2833,easy +EP_0011,83,F,AF,moderate,normal,DRUG_ALPRAZOLAM;DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_TRAMADOL,0.2275,easy +EP_0012,71,F,HTN;GERD;depression,normal,normal,DRUG_DIAZEPAM;DRUG_AMLODIPINE;DRUG_FLUOXETINE;DRUG_LISINOPRIL;DRUG_TRAMADOL,0.348,easy +EP_0013,70,F,HF;HTN;AF,mild,normal,DRUG_ALPRAZOLAM;DRUG_FUROSEMIDE;DRUG_TRAMADOL,0.3033,easy +EP_0014,82,F,dementia,normal,normal,DRUG_DONEPEZIL;DRUG_NAPROXEN;DRUG_APIXABAN;DRUG_SPIRONOLACTONE;DRUG_FUROSEMIDE,0.17,easy +EP_0015,84,F,dementia;neuropathy,normal,normal,DRUG_DONEPEZIL;DRUG_GABAPENTIN;DRUG_AMITRIPTYLINE;DRUG_CELECOXIB;DRUG_TRAMADOL,0.17,easy +EP_0016,83,M,HTN,normal,normal,DRUG_ALPRAZOLAM;DRUG_METOPROLOL;DRUG_TRAMADOL,0.3033,easy +EP_0017,83,F,CKD,severe,normal,DRUG_DIAZEPAM;DRUG_AMLODIPINE;DRUG_TRAMADOL,0.3067,easy +EP_0018,70,F,CKD;HF;HTN,mild,normal,DRUG_SPIRONOLACTONE;DRUG_AMLODIPINE;DRUG_ALPRAZOLAM;DRUG_METOPROLOL;DRUG_TRAMADOL,0.182,easy +EP_0019,84,M,DM;depression,normal,normal,DRUG_INSULIN_GLARGINE;DRUG_FLUOXETINE;DRUG_AMITRIPTYLINE;DRUG_GLIPIZIDE;DRUG_TRAMADOL,0.434,easy +EP_0020,90,F,neuropathy;BPH;AF,normal,normal,DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_AMIODARONE;DRUG_TAMSULOSIN,0.2,easy +EP_0021,87,M,HTN;BPH;HF,normal,normal,DRUG_SPIRONOLACTONE;DRUG_APIXABAN;DRUG_NAPROXEN;DRUG_AMLODIPINE,0.2125,easy +EP_0022,90,M,AF;GERD;DM,normal,impaired,DRUG_OMEPRAZOLE;DRUG_DIAZEPAM;DRUG_METOPROLOL;DRUG_TRAMADOL,0.23,easy +EP_0023,90,F,HF,normal,normal,DRUG_DIAZEPAM;DRUG_METOPROLOL;DRUG_TRAMADOL,0.3067,easy +EP_0024,71,F,OA,mild,normal,DRUG_IBUPROFEN;DRUG_GABAPENTIN;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_APIXABAN,0.17,easy +EP_0025,71,M,COPD;AF;neuropathy,mild,normal,DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_AMIODARONE;DRUG_GABAPENTIN,0.2,easy +EP_0026,88,M,GERD;dementia,severe,normal,DRUG_DONEPEZIL;DRUG_OMEPRAZOLE;DRUG_APIXABAN;DRUG_NAPROXEN,0.2125,easy +EP_0027,76,M,AF,normal,normal,DRUG_DIGOXIN;DRUG_METOPROLOL;DRUG_WARFARIN;DRUG_APIXABAN;DRUG_NAPROXEN,0.43,easy +EP_0028,73,F,CKD,moderate,normal,DRUG_AMLODIPINE;DRUG_FUROSEMIDE;DRUG_METFORMIN;DRUG_AMITRIPTYLINE;DRUG_TRAMADOL,0.17,easy +EP_0029,70,F,CKD;OA,mild,normal,DRUG_IBUPROFEN;DRUG_AMLODIPINE;DRUG_AMITRIPTYLINE;DRUG_GABAPENTIN;DRUG_TRAMADOL,0.17,easy +EP_0030,87,F,dementia;HF;depression,normal,normal,DRUG_DIGOXIN;DRUG_FLUOXETINE;DRUG_DONEPEZIL;DRUG_FUROSEMIDE;DRUG_AMIODARONE,0.25,easy +EP_0031,69,M,HF,severe,normal,DRUG_SPIRONOLACTONE;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_FUROSEMIDE;DRUG_AMIODARONE,0.426,easy +EP_0032,89,F,neuropathy,mild,normal,DRUG_AMITRIPTYLINE;DRUG_GABAPENTIN;DRUG_PREDNISONE;DRUG_TRAMADOL,0.2125,easy +EP_0033,68,F,dementia,mild,impaired,DRUG_DONEPEZIL;DRUG_OMEPRAZOLE;DRUG_SPIRONOLACTONE;DRUG_TRAMADOL;DRUG_ALPRAZOLAM,0.182,easy +EP_0034,84,F,CKD;HF;HTN,moderate,normal,DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_NAPROXEN;DRUG_HYDROCHLOROTHIAZIDE,0.225,easy +EP_0035,74,M,HTN;DM,normal,impaired,DRUG_FLUOXETINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_GLIPIZIDE;DRUG_METOPROLOL;DRUG_TRAMADOL,0.164,easy +EP_0036,80,F,DM;neuropathy;HTN,severe,normal,DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_AMIODARONE;DRUG_AMITRIPTYLINE,0.2,easy +EP_0037,78,M,HF,normal,normal,DRUG_LISINOPRIL;DRUG_AMITRIPTYLINE;DRUG_FUROSEMIDE;DRUG_TRAMADOL,0.2125,easy +EP_0038,89,F,HTN;AF,moderate,normal,DRUG_AMITRIPTYLINE;DRUG_FUROSEMIDE;DRUG_TRAMADOL,0.2833,easy +EP_0039,78,F,OA;depression,moderate,normal,DRUG_GABAPENTIN;DRUG_FLUOXETINE;DRUG_TRAMADOL;DRUG_SERTRALINE,0.205,easy +EP_0040,72,F,neuropathy;COPD;BPH,normal,normal,DRUG_ALPRAZOLAM;DRUG_AMITRIPTYLINE;DRUG_TRAMADOL;DRUG_TAMSULOSIN,0.44,easy +EP_0041,89,F,AF;BPH;DM;HF;HTN,mild,normal,DRUG_GLIPIZIDE;DRUG_DIGOXIN;DRUG_METOPROLOL;DRUG_WARFARIN;DRUG_METFORMIN;DRUG_AMLODIPINE;DRUG_INSULIN_GLARGINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_APIXABAN,0.1,medium +EP_0042,66,F,HTN;AF;CKD,moderate,normal,DRUG_METOPROLOL;DRUG_AMLODIPINE;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_FUROSEMIDE;DRUG_WARFARIN;DRUG_APIXABAN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_IBUPROFEN;DRUG_SERTRALINE,0.173,medium +EP_0043,70,F,OA;HTN;dementia,moderate,normal,DRUG_TRAMADOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_GABAPENTIN;DRUG_IBUPROFEN;DRUG_DONEPEZIL;DRUG_FUROSEMIDE;DRUG_NAPROXEN;DRUG_LISINOPRIL;DRUG_METOPROLOL,0.0467,medium +EP_0044,77,M,HF;HTN;GERD;COPD;neuropathy,normal,normal,DRUG_OMEPRAZOLE;DRUG_AMLODIPINE;DRUG_PREDNISONE;DRUG_LISINOPRIL;DRUG_SPIRONOLACTONE;DRUG_METOPROLOL;DRUG_GABAPENTIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_DIGOXIN,0.1422,medium +EP_0045,78,M,CKD;depression;dementia;GERD;OA,severe,normal,DRUG_FLUOXETINE;DRUG_NAPROXEN;DRUG_GABAPENTIN;DRUG_IBUPROFEN;DRUG_OMEPRAZOLE;DRUG_TRAMADOL;DRUG_SERTRALINE;DRUG_DONEPEZIL;DRUG_FUROSEMIDE;DRUG_AMITRIPTYLINE,0.167,medium +EP_0046,82,M,BPH;DM;CKD;dementia;HF,moderate,normal,DRUG_GLIPIZIDE;DRUG_INSULIN_GLARGINE;DRUG_METOPROLOL;DRUG_METFORMIN;DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_TAMSULOSIN;DRUG_SPIRONOLACTONE;DRUG_DONEPEZIL;DRUG_LISINOPRIL,0.178,medium +EP_0047,83,F,depression;HTN;BPH;neuropathy;AF,normal,impaired,DRUG_SERTRALINE;DRUG_GABAPENTIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_METOPROLOL;DRUG_LISINOPRIL;DRUG_APIXABAN;DRUG_TAMSULOSIN;DRUG_AMLODIPINE;DRUG_FUROSEMIDE,0.0,medium +EP_0048,85,F,AF;DM;OA,severe,impaired,DRUG_WARFARIN;DRUG_GLIPIZIDE;DRUG_INSULIN_GLARGINE;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_GABAPENTIN;DRUG_DIGOXIN;DRUG_METFORMIN;DRUG_IBUPROFEN;DRUG_METOPROLOL,0.268,medium +EP_0049,65,F,BPH;COPD;neuropathy,normal,normal,DRUG_TAMSULOSIN;DRUG_GABAPENTIN;DRUG_AMITRIPTYLINE;DRUG_PREDNISONE;DRUG_WARFARIN;DRUG_FLUOXETINE;DRUG_AMLODIPINE;DRUG_TRAMADOL,0.2963,medium +EP_0050,86,M,dementia;depression;OA;neuropathy,mild,impaired,DRUG_AMITRIPTYLINE;DRUG_GABAPENTIN;DRUG_SERTRALINE;DRUG_NAPROXEN;DRUG_DONEPEZIL;DRUG_IBUPROFEN;DRUG_TRAMADOL,0.1214,medium +EP_0051,90,M,OA;HF;HTN;DM,normal,normal,DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_IBUPROFEN;DRUG_GLIPIZIDE;DRUG_TRAMADOL;DRUG_METOPROLOL;DRUG_AMLODIPINE;DRUG_METFORMIN;DRUG_NAPROXEN,0.085,medium +EP_0052,70,M,AF;depression;GERD,moderate,normal,DRUG_FLUOXETINE;DRUG_METOPROLOL;DRUG_SERTRALINE;DRUG_AMITRIPTYLINE;DRUG_OMEPRAZOLE;DRUG_APIXABAN;DRUG_WARFARIN;DRUG_DIGOXIN,0.1063,medium +EP_0053,65,F,HF;DM;GERD;neuropathy;BPH,moderate,impaired,DRUG_INSULIN_GLARGINE;DRUG_GLIPIZIDE;DRUG_OMEPRAZOLE;DRUG_TAMSULOSIN;DRUG_FUROSEMIDE;DRUG_METFORMIN;DRUG_SPIRONOLACTONE;DRUG_AMITRIPTYLINE;DRUG_LISINOPRIL;DRUG_GABAPENTIN,0.098,medium +EP_0054,82,F,OA;neuropathy;AF;DM,mild,normal,DRUG_GABAPENTIN;DRUG_TRAMADOL;DRUG_APIXABAN;DRUG_INSULIN_GLARGINE;DRUG_IBUPROFEN;DRUG_NAPROXEN,0.1417,medium +EP_0055,74,M,GERD;HTN;CKD,moderate,normal,DRUG_LISINOPRIL;DRUG_AMLODIPINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_OMEPRAZOLE;DRUG_METOPROLOL;DRUG_FUROSEMIDE;DRUG_SPIRONOLACTONE;DRUG_INSULIN_GLARGINE,0.06,medium +EP_0056,67,F,HTN;GERD;COPD;AF,moderate,normal,DRUG_PREDNISONE;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_OMEPRAZOLE;DRUG_LISINOPRIL;DRUG_METOPROLOL;DRUG_APIXABAN;DRUG_WARFARIN;DRUG_AMLODIPINE,0.1222,medium +EP_0057,74,F,DM;HTN;BPH,normal,normal,DRUG_INSULIN_GLARGINE;DRUG_LISINOPRIL;DRUG_AMLODIPINE;DRUG_GLIPIZIDE;DRUG_METFORMIN;DRUG_FUROSEMIDE;DRUG_TAMSULOSIN;DRUG_METOPROLOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_APIXABAN,0.05,medium +EP_0058,90,F,AF;OA;BPH,moderate,normal,DRUG_DIGOXIN;DRUG_METOPROLOL;DRUG_GABAPENTIN;DRUG_WARFARIN;DRUG_IBUPROFEN;DRUG_NAPROXEN;DRUG_TAMSULOSIN;DRUG_TRAMADOL;DRUG_APIXABAN;DRUG_CLOPIDOGREL,0.303,medium +EP_0059,85,F,BPH;HTN;depression;dementia;COPD,mild,normal,DRUG_PREDNISONE;DRUG_FUROSEMIDE;DRUG_AMLODIPINE;DRUG_SERTRALINE;DRUG_FLUOXETINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_TAMSULOSIN;DRUG_AMITRIPTYLINE,0.0,medium +EP_0060,80,F,HF;CKD;neuropathy;HTN,moderate,normal,DRUG_AMLODIPINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_SPIRONOLACTONE;DRUG_GABAPENTIN;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_AMITRIPTYLINE;DRUG_METOPROLOL;DRUG_FUROSEMIDE;DRUG_ATORVASTATIN,0.173,medium +EP_0061,90,M,GERD;BPH;HF;HTN;CKD,mild,normal,DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_LISINOPRIL;DRUG_METOPROLOL;DRUG_TAMSULOSIN;DRUG_FUROSEMIDE,0.1417,medium +EP_0062,65,M,neuropathy;COPD;GERD;BPH;AF,moderate,normal,DRUG_PREDNISONE;DRUG_TAMSULOSIN;DRUG_DIGOXIN;DRUG_METOPROLOL;DRUG_OMEPRAZOLE;DRUG_GABAPENTIN;DRUG_AMITRIPTYLINE;DRUG_WARFARIN;DRUG_APIXABAN,0.0722,medium +EP_0063,76,M,depression;COPD;OA,mild,normal,DRUG_IBUPROFEN;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_AMITRIPTYLINE;DRUG_FLUOXETINE;DRUG_SERTRALINE;DRUG_GABAPENTIN,0.2386,medium +EP_0064,88,M,BPH;GERD;COPD,mild,normal,DRUG_OMEPRAZOLE;DRUG_PREDNISONE;DRUG_TAMSULOSIN;DRUG_METOPROLOL;DRUG_DIGOXIN;DRUG_SIMVASTATIN;DRUG_AMLODIPINE;DRUG_ATORVASTATIN;DRUG_ALPRAZOLAM,0.0867,medium +EP_0065,75,M,HTN;HF;AF,mild,normal,DRUG_FUROSEMIDE;DRUG_WARFARIN;DRUG_METOPROLOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_APIXABAN;DRUG_SPIRONOLACTONE;DRUG_DIGOXIN,0.1786,medium +EP_0066,66,M,HF;dementia;GERD;OA;DM,moderate,normal,DRUG_INSULIN_GLARGINE;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_METFORMIN;DRUG_DONEPEZIL;DRUG_OMEPRAZOLE,0.0563,medium +EP_0067,70,F,CKD;HTN;AF;HF,moderate,normal,DRUG_APIXABAN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_LISINOPRIL;DRUG_METOPROLOL;DRUG_FUROSEMIDE;DRUG_WARFARIN;DRUG_AMLODIPINE,0.0,medium +EP_0068,85,M,depression;dementia;neuropathy;HF,normal,impaired,DRUG_FLUOXETINE;DRUG_SERTRALINE;DRUG_GABAPENTIN;DRUG_AMITRIPTYLINE;DRUG_SPIRONOLACTONE;DRUG_DONEPEZIL;DRUG_LISINOPRIL;DRUG_METOPROLOL;DRUG_DIGOXIN;DRUG_FUROSEMIDE,0.173,medium +EP_0069,74,F,OA;CKD;AF,mild,normal,DRUG_IBUPROFEN;DRUG_FUROSEMIDE;DRUG_GABAPENTIN;DRUG_NAPROXEN;DRUG_AMLODIPINE;DRUG_APIXABAN;DRUG_METOPROLOL;DRUG_TRAMADOL;DRUG_DIGOXIN,0.1889,medium +EP_0070,75,F,dementia;GERD;COPD;OA,mild,normal,DRUG_DONEPEZIL;DRUG_PREDNISONE;DRUG_OMEPRAZOLE;DRUG_IBUPROFEN;DRUG_TRAMADOL;DRUG_GABAPENTIN,0.0,medium +EP_0071,68,M,BPH;DM;COPD;neuropathy,normal,normal,DRUG_AMITRIPTYLINE;DRUG_INSULIN_GLARGINE;DRUG_METFORMIN;DRUG_TAMSULOSIN;DRUG_GABAPENTIN;DRUG_PREDNISONE;DRUG_GLIPIZIDE,0.0714,medium +EP_0072,92,F,CKD;BPH;COPD;AF,normal,normal,DRUG_PREDNISONE;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_TAMSULOSIN;DRUG_METOPROLOL;DRUG_APIXABAN,0.1214,medium +EP_0073,88,F,OA;GERD;HTN;depression,mild,normal,DRUG_AMITRIPTYLINE;DRUG_OMEPRAZOLE;DRUG_NAPROXEN;DRUG_METOPROLOL;DRUG_IBUPROFEN;DRUG_FUROSEMIDE;DRUG_LISINOPRIL;DRUG_SERTRALINE,0.0525,medium +EP_0074,80,F,neuropathy;OA;CKD;depression,mild,normal,DRUG_AMLODIPINE;DRUG_SERTRALINE;DRUG_IBUPROFEN;DRUG_FUROSEMIDE;DRUG_TRAMADOL;DRUG_AMITRIPTYLINE;DRUG_NAPROXEN,0.1214,medium +EP_0075,68,F,dementia;AF;COPD;HTN;neuropathy,mild,normal,DRUG_GABAPENTIN;DRUG_AMLODIPINE;DRUG_DIGOXIN;DRUG_FUROSEMIDE;DRUG_DONEPEZIL;DRUG_METOPROLOL;DRUG_WARFARIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_LISINOPRIL,0.0944,medium +EP_0076,71,M,HF;DM;dementia,severe,normal,DRUG_SPIRONOLACTONE;DRUG_DONEPEZIL;DRUG_INSULIN_GLARGINE;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_METFORMIN;DRUG_GLIPIZIDE;DRUG_FUROSEMIDE,0.2287,medium +EP_0077,75,F,AF;BPH;dementia,mild,impaired,DRUG_METOPROLOL;DRUG_TAMSULOSIN;DRUG_APIXABAN;DRUG_DONEPEZIL;DRUG_DIGOXIN;DRUG_WARFARIN;DRUG_SPIRONOLACTONE,0.1143,medium +EP_0078,81,F,OA;depression;DM;neuropathy;CKD,normal,normal,DRUG_GLIPIZIDE;DRUG_NAPROXEN;DRUG_FUROSEMIDE;DRUG_AMLODIPINE;DRUG_SERTRALINE;DRUG_INSULIN_GLARGINE;DRUG_IBUPROFEN,0.0714,medium +EP_0079,74,F,DM;OA;GERD;CKD,mild,impaired,DRUG_GLIPIZIDE;DRUG_AMLODIPINE;DRUG_INSULIN_GLARGINE;DRUG_METFORMIN;DRUG_IBUPROFEN;DRUG_GABAPENTIN;DRUG_NAPROXEN,0.0714,medium +EP_0080,72,M,GERD;HF;OA;CKD,normal,normal,DRUG_AMLODIPINE;DRUG_TRAMADOL;DRUG_LISINOPRIL;DRUG_OMEPRAZOLE;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_METOPROLOL;DRUG_IBUPROFEN,0.1063,medium +EP_0081,84,M,neuropathy;CKD;depression;OA,normal,impaired,DRUG_FLUOXETINE;DRUG_AMLODIPINE;DRUG_IBUPROFEN;DRUG_NAPROXEN;DRUG_SERTRALINE;DRUG_GABAPENTIN;DRUG_FUROSEMIDE;DRUG_AMITRIPTYLINE;DRUG_TRAMADOL;DRUG_METOPROLOL;DRUG_SIMVASTATIN;DRUG_SPIRONOLACTONE;DRUG_DIGOXIN;DRUG_WARFARIN,0.395,hard +EP_0082,75,M,OA;COPD;neuropathy;CKD;GERD;HTN;depression,severe,impaired,DRUG_SERTRALINE;DRUG_FUROSEMIDE;DRUG_METOPROLOL;DRUG_FLUOXETINE;DRUG_LISINOPRIL;DRUG_NAPROXEN;DRUG_GABAPENTIN;DRUG_OMEPRAZOLE;DRUG_AMLODIPINE;DRUG_AMITRIPTYLINE;DRUG_TRAMADOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_IBUPROFEN;DRUG_PREDNISONE;DRUG_DIGOXIN,0.2293,hard +EP_0083,82,F,DM;dementia;OA;HF;neuropathy;COPD,moderate,normal,DRUG_SPIRONOLACTONE;DRUG_LISINOPRIL;DRUG_DONEPEZIL;DRUG_TRAMADOL;DRUG_AMITRIPTYLINE;DRUG_FUROSEMIDE;DRUG_GLIPIZIDE;DRUG_PREDNISONE;DRUG_IBUPROFEN;DRUG_GABAPENTIN;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_WARFARIN,0.2931,hard +EP_0084,80,F,CKD;neuropathy;COPD;BPH;dementia;HTN;OA,moderate,normal,DRUG_GABAPENTIN;DRUG_METOPROLOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_PREDNISONE;DRUG_TRAMADOL;DRUG_NAPROXEN;DRUG_LISINOPRIL;DRUG_AMLODIPINE;DRUG_AMITRIPTYLINE;DRUG_TAMSULOSIN;DRUG_DIGOXIN;DRUG_WARFARIN,0.2767,hard +EP_0085,79,F,OA;DM;CKD;GERD;BPH;HF;neuropathy,severe,normal,DRUG_OMEPRAZOLE;DRUG_AMLODIPINE;DRUG_SPIRONOLACTONE;DRUG_METFORMIN;DRUG_TRAMADOL;DRUG_METOPROLOL;DRUG_IBUPROFEN;DRUG_FUROSEMIDE;DRUG_GABAPENTIN;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.2008,hard +EP_0086,82,M,AF;DM;GERD;COPD;OA,moderate,impaired,DRUG_PREDNISONE;DRUG_METFORMIN;DRUG_APIXABAN;DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_OMEPRAZOLE;DRUG_INSULIN_GLARGINE;DRUG_NAPROXEN;DRUG_GABAPENTIN;DRUG_GLIPIZIDE;DRUG_METOPROLOL;DRUG_TRAMADOL;DRUG_IBUPROFEN;DRUG_LOSARTAN,0.3057,hard +EP_0087,90,M,HTN;GERD;DM;AF;CKD,mild,impaired,DRUG_APIXABAN;DRUG_FUROSEMIDE;DRUG_GLIPIZIDE;DRUG_OMEPRAZOLE;DRUG_WARFARIN;DRUG_METOPROLOL;DRUG_INSULIN_GLARGINE;DRUG_LISINOPRIL;DRUG_AMLODIPINE;DRUG_HYDROCHLOROTHIAZIDE,0.05,hard +EP_0088,86,F,HF;AF;COPD;HTN;OA;GERD,normal,impaired,DRUG_PREDNISONE;DRUG_AMLODIPINE;DRUG_METOPROLOL;DRUG_APIXABAN;DRUG_IBUPROFEN;DRUG_OMEPRAZOLE;DRUG_SPIRONOLACTONE;DRUG_NAPROXEN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_WARFARIN;DRUG_LISINOPRIL;DRUG_FUROSEMIDE;DRUG_TRAMADOL;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE,0.3687,hard +EP_0089,75,M,DM;CKD;HTN;HF;BPH;neuropathy;GERD,mild,normal,DRUG_HYDROCHLOROTHIAZIDE;DRUG_AMLODIPINE;DRUG_METFORMIN;DRUG_OMEPRAZOLE;DRUG_METOPROLOL;DRUG_LISINOPRIL;DRUG_AMITRIPTYLINE;DRUG_GLIPIZIDE;DRUG_GABAPENTIN;DRUG_TAMSULOSIN;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.0417,hard +EP_0090,87,M,AF;depression;DM;COPD;OA,mild,normal,DRUG_NAPROXEN;DRUG_FLUOXETINE;DRUG_APIXABAN;DRUG_SERTRALINE;DRUG_WARFARIN;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE;DRUG_TRAMADOL;DRUG_PREDNISONE;DRUG_GLIPIZIDE;DRUG_GABAPENTIN;DRUG_IBUPROFEN;DRUG_METFORMIN;DRUG_METOPROLOL;DRUG_AMITRIPTYLINE,0.4267,hard +EP_0091,83,M,OA;dementia;GERD;depression;DM;HF,severe,normal,DRUG_GLIPIZIDE;DRUG_DIGOXIN;DRUG_IBUPROFEN;DRUG_SPIRONOLACTONE;DRUG_METFORMIN;DRUG_FLUOXETINE;DRUG_FUROSEMIDE;DRUG_SERTRALINE;DRUG_OMEPRAZOLE;DRUG_AMITRIPTYLINE;DRUG_INSULIN_GLARGINE;DRUG_NAPROXEN;DRUG_METOPROLOL;DRUG_WARFARIN,0.2843,hard +EP_0092,78,F,CKD;OA;AF;COPD;depression,severe,normal,DRUG_METOPROLOL;DRUG_TRAMADOL;DRUG_SERTRALINE;DRUG_NAPROXEN;DRUG_AMITRIPTYLINE;DRUG_APIXABAN;DRUG_AMLODIPINE;DRUG_IBUPROFEN;DRUG_DIGOXIN;DRUG_WARFARIN;DRUG_FUROSEMIDE;DRUG_GABAPENTIN;DRUG_FLUOXETINE;DRUG_PREDNISONE,0.4536,hard +EP_0093,93,F,HTN;DM;BPH;OA;dementia,severe,impaired,DRUG_LISINOPRIL;DRUG_DONEPEZIL;DRUG_METFORMIN;DRUG_FUROSEMIDE;DRUG_INSULIN_GLARGINE;DRUG_GABAPENTIN;DRUG_IBUPROFEN;DRUG_METOPROLOL;DRUG_GLIPIZIDE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_TAMSULOSIN;DRUG_DIGOXIN,0.1125,hard +EP_0094,94,F,BPH;GERD;COPD;HF,moderate,impaired,DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_TAMSULOSIN;DRUG_PREDNISONE;DRUG_FUROSEMIDE;DRUG_OMEPRAZOLE;DRUG_METOPROLOL;DRUG_SPIRONOLACTONE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_WARFARIN,0.198,hard +EP_0095,90,M,HF;neuropathy;COPD;BPH;dementia;DM;CKD,normal,normal,DRUG_GABAPENTIN;DRUG_METFORMIN;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_AMITRIPTYLINE;DRUG_FUROSEMIDE;DRUG_GLIPIZIDE;DRUG_AMLODIPINE;DRUG_LISINOPRIL;DRUG_DONEPEZIL;DRUG_PREDNISONE;DRUG_SPIRONOLACTONE;DRUG_TAMSULOSIN;DRUG_METOPROLOL;DRUG_SERTRALINE,0.1487,hard +EP_0096,85,F,DM;COPD;HTN;CKD;depression;dementia,severe,impaired,DRUG_AMITRIPTYLINE;DRUG_INSULIN_GLARGINE;DRUG_PREDNISONE;DRUG_METOPROLOL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_FUROSEMIDE;DRUG_AMLODIPINE;DRUG_FLUOXETINE;DRUG_LISINOPRIL;DRUG_DONEPEZIL;DRUG_DIGOXIN,0.0773,hard +EP_0097,70,M,BPH;COPD;neuropathy;CKD;GERD;depression,severe,normal,DRUG_PREDNISONE;DRUG_AMITRIPTYLINE;DRUG_AMLODIPINE;DRUG_FLUOXETINE;DRUG_GABAPENTIN;DRUG_TAMSULOSIN;DRUG_FUROSEMIDE;DRUG_SERTRALINE;DRUG_OMEPRAZOLE;DRUG_IBUPROFEN;DRUG_LOSARTAN;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_WARFARIN,0.145,hard +EP_0098,81,F,COPD;depression;GERD;BPH;OA,severe,impaired,DRUG_GABAPENTIN;DRUG_FLUOXETINE;DRUG_OMEPRAZOLE;DRUG_NAPROXEN;DRUG_SERTRALINE;DRUG_IBUPROFEN;DRUG_TRAMADOL;DRUG_TAMSULOSIN;DRUG_PREDNISONE;DRUG_AMITRIPTYLINE;DRUG_NORTRIPTYLINE;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_LOSARTAN;DRUG_HYDROCHLOROTHIAZIDE,0.1447,hard +EP_0099,91,M,neuropathy;OA;AF;CKD,mild,normal,DRUG_TRAMADOL;DRUG_APIXABAN;DRUG_WARFARIN;DRUG_AMLODIPINE;DRUG_METOPROLOL;DRUG_IBUPROFEN;DRUG_AMITRIPTYLINE;DRUG_GABAPENTIN;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_NAPROXEN;DRUG_SIMVASTATIN;DRUG_FLUOXETINE;DRUG_INSULIN_GLARGINE,0.4271,hard +EP_0100,90,F,DM;OA;HTN;HF,mild,impaired,DRUG_INSULIN_GLARGINE;DRUG_SPIRONOLACTONE;DRUG_METFORMIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_METOPROLOL;DRUG_AMLODIPINE;DRUG_DIGOXIN;DRUG_GLIPIZIDE;DRUG_LISINOPRIL;DRUG_FUROSEMIDE;DRUG_NAPROXEN;DRUG_IBUPROFEN;DRUG_GABAPENTIN;DRUG_TRAMADOL;DRUG_AMITRIPTYLINE,0.2333,hard +EP_0101,88,M,BPH;neuropathy;dementia;OA;CKD;DM;HF,severe,impaired,DRUG_AMLODIPINE;DRUG_TAMSULOSIN;DRUG_TRAMADOL;DRUG_GLIPIZIDE;DRUG_FUROSEMIDE;DRUG_AMITRIPTYLINE;DRUG_LISINOPRIL;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_METFORMIN;DRUG_IBUPROFEN;DRUG_SPIRONOLACTONE;DRUG_METOPROLOL;DRUG_GABAPENTIN;DRUG_NAPROXEN,0.2333,hard +EP_0102,74,M,BPH;HF;dementia;CKD;DM;GERD,moderate,normal,DRUG_OMEPRAZOLE;DRUG_LISINOPRIL;DRUG_INSULIN_GLARGINE;DRUG_METOPROLOL;DRUG_FUROSEMIDE;DRUG_GLIPIZIDE;DRUG_DONEPEZIL;DRUG_SPIRONOLACTONE;DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_METFORMIN;DRUG_TAMSULOSIN;DRUG_WARFARIN,0.1715,hard +EP_0103,70,F,AF;GERD;depression;CKD,mild,impaired,DRUG_DIGOXIN;DRUG_AMLODIPINE;DRUG_FLUOXETINE;DRUG_FUROSEMIDE;DRUG_APIXABAN;DRUG_METOPROLOL;DRUG_WARFARIN;DRUG_SERTRALINE;DRUG_OMEPRAZOLE;DRUG_AMITRIPTYLINE;DRUG_LOSARTAN;DRUG_GLIPIZIDE;DRUG_DIAZEPAM;DRUG_TRAMADOL;DRUG_HYDROCHLOROTHIAZIDE,0.2593,hard +EP_0104,84,F,OA;DM;dementia;AF;GERD;COPD;BPH,mild,normal,DRUG_METOPROLOL;DRUG_DONEPEZIL;DRUG_METFORMIN;DRUG_GABAPENTIN;DRUG_APIXABAN;DRUG_OMEPRAZOLE;DRUG_TAMSULOSIN;DRUG_NAPROXEN;DRUG_GLIPIZIDE;DRUG_TRAMADOL;DRUG_IBUPROFEN;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN;DRUG_WARFARIN,0.2521,hard +EP_0105,88,F,neuropathy;BPH;HTN;COPD;DM,severe,impaired,DRUG_AMITRIPTYLINE;DRUG_AMLODIPINE;DRUG_METOPROLOL;DRUG_LISINOPRIL;DRUG_FUROSEMIDE;DRUG_PREDNISONE;DRUG_GABAPENTIN;DRUG_INSULIN_GLARGINE;DRUG_METFORMIN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_TAMSULOSIN;DRUG_WARFARIN,0.0208,hard +EP_0106,73,M,HTN;DM;CKD;OA;depression,normal,normal,DRUG_LISINOPRIL;DRUG_NAPROXEN;DRUG_AMITRIPTYLINE;DRUG_SERTRALINE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_METFORMIN;DRUG_FLUOXETINE;DRUG_INSULIN_GLARGINE;DRUG_AMLODIPINE;DRUG_GLIPIZIDE;DRUG_TRAMADOL;DRUG_METOPROLOL;DRUG_IBUPROFEN;DRUG_WARFARIN,0.3443,hard +EP_0107,82,M,HTN;GERD;BPH;depression;AF,mild,impaired,DRUG_WARFARIN;DRUG_LISINOPRIL;DRUG_FUROSEMIDE;DRUG_SERTRALINE;DRUG_FLUOXETINE;DRUG_OMEPRAZOLE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_TAMSULOSIN;DRUG_METOPROLOL;DRUG_AMITRIPTYLINE;DRUG_AMLODIPINE;DRUG_DIGOXIN;DRUG_APIXABAN;DRUG_ATORVASTATIN;DRUG_CLOPIDOGREL,0.1167,hard +EP_0108,72,F,DM;dementia;GERD;BPH;neuropathy;OA;depression,severe,normal,DRUG_FLUOXETINE;DRUG_GABAPENTIN;DRUG_TAMSULOSIN;DRUG_AMITRIPTYLINE;DRUG_NAPROXEN;DRUG_DONEPEZIL;DRUG_OMEPRAZOLE;DRUG_METFORMIN;DRUG_SERTRALINE;DRUG_IBUPROFEN;DRUG_INSULIN_GLARGINE;DRUG_GLIPIZIDE;DRUG_TRAMADOL;DRUG_NORTRIPTYLINE;DRUG_WARFARIN,0.2933,hard +EP_0109,91,M,COPD;dementia;HF;OA;HTN;DM,mild,normal,DRUG_DONEPEZIL;DRUG_GLIPIZIDE;DRUG_LISINOPRIL;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE;DRUG_SPIRONOLACTONE;DRUG_AMLODIPINE;DRUG_FUROSEMIDE;DRUG_NAPROXEN;DRUG_METFORMIN;DRUG_WARFARIN,0.2864,hard +EP_0110,84,M,HF;GERD;dementia;CKD;COPD,severe,impaired,DRUG_DONEPEZIL;DRUG_SPIRONOLACTONE;DRUG_AMLODIPINE;DRUG_FUROSEMIDE;DRUG_LISINOPRIL;DRUG_PREDNISONE;DRUG_OMEPRAZOLE;DRUG_METOPROLOL;DRUG_DIGOXIN;DRUG_AMITRIPTYLINE;DRUG_ASPIRIN;DRUG_NORTRIPTYLINE;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.1807,hard +EP_0111,91,F,dementia;CKD;DM;AF;BPH,normal,impaired,DRUG_GLIPIZIDE;DRUG_DONEPEZIL;DRUG_TAMSULOSIN;DRUG_WARFARIN;DRUG_APIXABAN;DRUG_FUROSEMIDE;DRUG_METFORMIN;DRUG_AMLODIPINE;DRUG_METOPROLOL;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE;DRUG_CELECOXIB;DRUG_AMITRIPTYLINE;DRUG_ATORVASTATIN;DRUG_IBUPROFEN,0.1487,hard +EP_0112,87,F,AF;HF;CKD;neuropathy;HTN;depression,moderate,impaired,DRUG_HYDROCHLOROTHIAZIDE;DRUG_GABAPENTIN;DRUG_WARFARIN;DRUG_FLUOXETINE;DRUG_AMLODIPINE;DRUG_SPIRONOLACTONE;DRUG_FUROSEMIDE;DRUG_METOPROLOL;DRUG_AMITRIPTYLINE;DRUG_APIXABAN;DRUG_SERTRALINE;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE,0.1308,hard +EP_0113,92,M,OA;HF;COPD;dementia;neuropathy;CKD,moderate,normal,DRUG_AMLODIPINE;DRUG_DONEPEZIL;DRUG_LISINOPRIL;DRUG_GABAPENTIN;DRUG_METOPROLOL;DRUG_IBUPROFEN;DRUG_SPIRONOLACTONE;DRUG_AMITRIPTYLINE;DRUG_DIGOXIN;DRUG_NAPROXEN;DRUG_FUROSEMIDE;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.3023,hard +EP_0114,72,M,depression;COPD;neuropathy;dementia;AF,moderate,normal,DRUG_DIGOXIN;DRUG_APIXABAN;DRUG_SERTRALINE;DRUG_METOPROLOL;DRUG_FLUOXETINE;DRUG_DONEPEZIL;DRUG_AMITRIPTYLINE;DRUG_PREDNISONE;DRUG_WARFARIN;DRUG_GABAPENTIN;DRUG_LISINOPRIL;DRUG_NORTRIPTYLINE;DRUG_AMLODIPINE,0.0846,hard +EP_0115,75,M,HTN;OA;dementia;HF;depression;CKD;AF,mild,normal,DRUG_WARFARIN;DRUG_SERTRALINE;DRUG_METOPROLOL;DRUG_SPIRONOLACTONE;DRUG_FUROSEMIDE;DRUG_TRAMADOL;DRUG_AMLODIPINE;DRUG_NAPROXEN;DRUG_GABAPENTIN;DRUG_IBUPROFEN;DRUG_AMITRIPTYLINE;DRUG_DIGOXIN,0.3233,hard +EP_0116,76,F,OA;depression;neuropathy;HF,severe,impaired,DRUG_FLUOXETINE;DRUG_NAPROXEN;DRUG_FUROSEMIDE;DRUG_METOPROLOL;DRUG_SPIRONOLACTONE;DRUG_AMITRIPTYLINE;DRUG_TRAMADOL;DRUG_LISINOPRIL;DRUG_GABAPENTIN;DRUG_DIGOXIN;DRUG_IBUPROFEN;DRUG_SERTRALINE;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.4321,hard +EP_0117,85,M,depression;GERD;neuropathy;HTN,normal,normal,DRUG_GABAPENTIN;DRUG_SERTRALINE;DRUG_OMEPRAZOLE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_FUROSEMIDE;DRUG_AMLODIPINE;DRUG_AMITRIPTYLINE;DRUG_METOPROLOL;DRUG_FLUOXETINE;DRUG_LISINOPRIL;DRUG_SPIRONOLACTONE;DRUG_IBUPROFEN;DRUG_NAPROXEN;DRUG_WARFARIN;DRUG_INSULIN_GLARGINE,0.2087,hard +EP_0118,70,F,DM;depression;HTN;HF;AF;neuropathy;CKD,severe,impaired,DRUG_FUROSEMIDE;DRUG_HYDROCHLOROTHIAZIDE;DRUG_METFORMIN;DRUG_AMLODIPINE;DRUG_SPIRONOLACTONE;DRUG_GABAPENTIN;DRUG_FLUOXETINE;DRUG_GLIPIZIDE;DRUG_WARFARIN;DRUG_METOPROLOL;DRUG_INSULIN_GLARGINE;DRUG_DIGOXIN,0.1833,hard +EP_0119,86,F,AF;HTN;HF;OA;dementia,normal,impaired,DRUG_APIXABAN;DRUG_HYDROCHLOROTHIAZIDE;DRUG_IBUPROFEN;DRUG_SPIRONOLACTONE;DRUG_GABAPENTIN;DRUG_NAPROXEN;DRUG_DONEPEZIL;DRUG_LISINOPRIL;DRUG_FUROSEMIDE;DRUG_DIGOXIN;DRUG_INSULIN_GLARGINE,0.2364,hard +EP_0120,94,F,HF;COPD;dementia;HTN;CKD,mild,normal,DRUG_AMLODIPINE;DRUG_METOPROLOL;DRUG_SPIRONOLACTONE;DRUG_DONEPEZIL;DRUG_HYDROCHLOROTHIAZIDE;DRUG_DIGOXIN;DRUG_LISINOPRIL;DRUG_PREDNISONE;DRUG_FUROSEMIDE;DRUG_AMITRIPTYLINE;DRUG_INSULIN_GLARGINE;DRUG_WARFARIN,0.165,hard diff --git a/openenv-polypharmacy/docker-compose.yml b/openenv-polypharmacy/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..2b2bde3a4b6870ab496787ea9e355ab209a085f9 --- /dev/null +++ b/openenv-polypharmacy/docker-compose.yml @@ -0,0 +1,35 @@ +version: "3.9" + +services: + backend: + build: + context: . + dockerfile: backend/Dockerfile + container_name: polypharmacy-backend + env_file: + - .env + ports: + - "7860:7860" + volumes: + - ./backend/src:/app/backend/src + - ./data:/app/data + - ./scripts:/app/scripts + - ./backend:/app/backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:7860/health"] + interval: 20s + timeout: 5s + retries: 5 + + frontend: + build: + context: . + dockerfile: frontend/Dockerfile + container_name: polypharmacy-frontend + depends_on: + - backend + ports: + - "5173:5173" + volumes: + - ./frontend:/app + - /app/node_modules diff --git a/openenv-polypharmacy/frontend/Dockerfile b/openenv-polypharmacy/frontend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..aa04742e98c70c50c11a21ee578eabd0f9269e15 --- /dev/null +++ b/openenv-polypharmacy/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ ./ + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] diff --git a/openenv-polypharmacy/frontend/index.html b/openenv-polypharmacy/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..a879a0927092473c247198a104ca9a5a4ab0dac9 --- /dev/null +++ b/openenv-polypharmacy/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Polypharmacy Control Center + + +
+ + + diff --git a/openenv-polypharmacy/frontend/package-lock.json b/openenv-polypharmacy/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..891d0adb22e236c2a759f3266ee843269f1bec5e --- /dev/null +++ b/openenv-polypharmacy/frontend/package-lock.json @@ -0,0 +1,1677 @@ +{ + "name": "polypharmacy-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "polypharmacy-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001786", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", + "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/openenv-polypharmacy/frontend/package.json b/openenv-polypharmacy/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..858ae22b50ac1a6732557b4815e882047b198712 --- /dev/null +++ b/openenv-polypharmacy/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "polypharmacy-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --port 4173" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.2" + } +} diff --git a/openenv-polypharmacy/frontend/src/App.jsx b/openenv-polypharmacy/frontend/src/App.jsx new file mode 100644 index 0000000000000000000000000000000000000000..dd59ecb3e10109445a7b2e9be47177e7cd2d3233 --- /dev/null +++ b/openenv-polypharmacy/frontend/src/App.jsx @@ -0,0 +1,371 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +const API_BASE = "http://localhost:7860"; +const WS_URL = "ws://localhost:7860/ws"; +const TASKS = ["easy_screening", "budgeted_screening", "complex_tradeoff"]; + +async function apiPost(path, body) { + const res = await fetch(`${API_BASE}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const msg = await res.text(); + throw new Error(msg || `HTTP ${res.status}`); + } + return res.json(); +} + +export default function App() { + const [taskId, setTaskId] = useState("budgeted_screening"); + const [obs, setObs] = useState(null); + const [log, setLog] = useState([]); + const [loading, setLoading] = useState(false); + const [action, setAction] = useState({ + action_type: "query_ddi", + drug_id_1: "", + drug_id_2: "", + target_drug_id: "", + intervention_type: "stop", + proposed_new_drug_id: "", + rationale: "", + }); + + const medIds = useMemo( + () => (obs?.current_medications || []).map((m) => m.drug_id), + [obs] + ); + const hasValidEpisode = Boolean(obs?.episode_id) && (obs?.current_medications?.length || 0) > 0; + const isDone = Boolean(obs?.done); + const finalScore = + typeof obs?.metadata?.grader_score === "number" ? obs.metadata.grader_score : null; + const noBudgetsLeft = + hasValidEpisode && + (obs?.remaining_query_budget ?? 0) <= 0 && + (obs?.remaining_intervention_budget ?? 0) <= 0; + const wsRef = useRef(null); + const pendingRef = useRef([]); + + const wsEnsure = async () => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return wsRef.current; + if (wsRef.current && wsRef.current.readyState === WebSocket.CONNECTING) { + await new Promise((r) => setTimeout(r, 80)); + return wsEnsure(); + } + + const ws = new WebSocket(WS_URL); + wsRef.current = ws; + + ws.onmessage = (evt) => { + try { + const msg = JSON.parse(evt.data); + const pending = pendingRef.current.shift(); + if (pending) pending.resolve(msg); + } catch (e) { + const pending = pendingRef.current.shift(); + if (pending) pending.reject(e); + } + }; + ws.onerror = (err) => { + const pending = pendingRef.current.shift(); + if (pending) pending.reject(err); + }; + ws.onclose = () => { + wsRef.current = null; + }; + + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("WebSocket connect timeout")), 2500); + ws.onopen = () => { + clearTimeout(t); + resolve(); + }; + }); + return ws; + }; + + const wsSend = async (type, data) => { + const ws = await wsEnsure(); + return await new Promise((resolve, reject) => { + pendingRef.current.push({ resolve, reject }); + ws.send(JSON.stringify({ type, data })); + }); + }; + + useEffect(() => { + return () => { + try { + wsRef.current?.close(); + } catch { + // ignore + } + }; + }, []); + + const appendLog = (text) => { + setLog((prev) => [`${new Date().toLocaleTimeString()} ${text}`, ...prev].slice(0, 20)); + }; + + const normalizeObsFromWs = (packetData) => { + const observation = packetData?.observation || {}; + const mergedMetadata = { + ...(observation?.metadata || {}), + ...(packetData?.info || {}), + }; + return { + ...observation, + done: Boolean(packetData?.done ?? observation?.done ?? false), + reward: packetData?.reward ?? observation?.reward ?? null, + metadata: mergedMetadata, + }; + }; + + const handleReset = async () => { + setLoading(true); + try { + const msg = await wsSend("reset", { task_id: taskId }); + const data = msg?.data || {}; + const normalized = normalizeObsFromWs(data); + setObs(normalized); + const ids = (normalized?.current_medications || []).map((m) => m.drug_id); + setAction((prev) => ({ + ...prev, + drug_id_1: ids[0] || "", + drug_id_2: ids[1] || "", + target_drug_id: ids[0] || "", + })); + appendLog(`Reset task=${taskId}`); + } catch (err) { + appendLog(`Reset failed: ${err.message}`); + } finally { + setLoading(false); + } + }; + + const buildActionPayload = () => { + if (noBudgetsLeft) { + return { action_type: "finish_review" }; + } + if (action.action_type === "query_ddi") { + return { + action_type: "query_ddi", + drug_id_1: action.drug_id_1, + drug_id_2: action.drug_id_2, + }; + } + if (action.action_type === "propose_intervention") { + return { + action_type: "propose_intervention", + target_drug_id: action.target_drug_id, + intervention_type: action.intervention_type, + proposed_new_drug_id: action.proposed_new_drug_id || undefined, + rationale: action.rationale || undefined, + }; + } + return { action_type: "finish_review" }; + }; + + const isActionValid = () => { + if (!hasValidEpisode) return false; + if (isDone) return false; + if (noBudgetsLeft) return true; + if (action.action_type === "query_ddi") { + return Boolean(action.drug_id_1 && action.drug_id_2); + } + if (action.action_type === "propose_intervention") { + return Boolean(action.target_drug_id && action.intervention_type); + } + return true; + }; + + const handleStep = async (overrideAction = null) => { + if (!hasValidEpisode) { + appendLog("Run Reset Episode before stepping."); + return; + } + setLoading(true); + try { + const payload = overrideAction || buildActionPayload(); + const msg = await wsSend("step", payload); + const data = msg?.data || {}; + const normalized = normalizeObsFromWs(data); + setObs(normalized); + appendLog(`Step: ${payload.action_type} -> reward=${data.reward ?? 0}`); + } catch (err) { + appendLog(`Step failed: ${err.message}`); + } finally { + setLoading(false); + } + }; + + const askAi = async () => { + if (!hasValidEpisode) { + appendLog("Run Reset Episode before asking AI."); + return; + } + setLoading(true); + try { + const data = await apiPost("/agent/suggest", { observation: obs }); + appendLog(`AI suggestion: ${data.action.action_type}`); + await handleStep(data.action); + } catch (err) { + appendLog(`AI suggestion failed: ${err.message}`); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ +
+
+
+

Polypharmacy Control Center

+
+
+ {hasValidEpisode ? "Session Live" : "Waiting for reset"} +
+
+ + + +
+
+ +
+
+

Episode

+ {hasValidEpisode ? ( +
+
Episode{obs.episode_id}
+
Task{obs.task_id}
+
Age / Sex{obs.age} / {obs.sex}
+
Step{obs.step_index}
+
Query budget{obs.remaining_query_budget}
+
Intervention budget{obs.remaining_intervention_budget}
+
+ ) : ( +

Start with Reset Episode. Until then, step actions are blocked.

+ )} + {noBudgetsLeft && ( +

Query and intervention budgets are exhausted. Finish review to get final score.

+ )} + {isDone && ( +

+ Episode complete + {finalScore !== null ? ` • final score: ${finalScore.toFixed(3)}` : ""}. + Click Reset Episode to start a new case. +

+ )} +
+ +
+

Action Console

+
+ + +
+ + {action.action_type === "query_ddi" && ( +
+ setAction((a) => ({ ...a, drug_id_1: e.target.value }))} + /> + setAction((a) => ({ ...a, drug_id_2: e.target.value }))} + /> +
+ )} + + {action.action_type === "propose_intervention" && ( +
+ + + + setAction((a) => ({ ...a, proposed_new_drug_id: e.target.value })) + } + /> + setAction((a) => ({ ...a, rationale: e.target.value }))} + /> +
+ )} + +
+ +
+

Current Medications

+
+ {(obs?.current_medications || []).map((m) => ( +
+ {m.drug_id} +

{m.generic_name}

+ {m.dose_mg} mg • {m.atc_class} +
+ ))} +
+
+ +
+

Event Log

+
+ {log.map((line, idx) => ( +
{line}
+ ))} +
+
+
+
+
+ ); +} diff --git a/openenv-polypharmacy/frontend/src/main.jsx b/openenv-polypharmacy/frontend/src/main.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5733886c4cff3e43049f0daddcb06387ffd341d1 --- /dev/null +++ b/openenv-polypharmacy/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + +); diff --git a/openenv-polypharmacy/frontend/src/styles.css b/openenv-polypharmacy/frontend/src/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..4370b6a96b9e10ccdbcdfc945481998770023f90 --- /dev/null +++ b/openenv-polypharmacy/frontend/src/styles.css @@ -0,0 +1,304 @@ +:root { + --bg: #eef5ff; + --panel: rgba(255, 255, 255, 0.82); + --panel-solid: #ffffff; + --text: #0b2445; + --muted: #5b7596; + --primary: #1f8bff; + --primary-2: #69beff; + --accent: #0dd3ff; + --border: rgba(93, 156, 219, 0.22); + --shadow: 0 20px 50px rgba(25, 83, 143, 0.12); + --shadow-strong: 0 20px 42px rgba(31, 112, 182, 0.24); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Inter", "SF Pro Text", "Segoe UI", sans-serif; + background: + radial-gradient(circle at 8% 0%, #cce7ff 0%, rgba(204, 231, 255, 0) 42%), + radial-gradient(circle at 92% 100%, #d5efff 0%, rgba(213, 239, 255, 0) 42%), + var(--bg); + color: var(--text); +} + +.shell { + min-height: 100vh; + position: relative; + padding: 28px 22px; + overflow: hidden; +} + +.container { + width: min(1300px, 100%); + margin: 0 auto; + position: relative; + z-index: 1; +} + +.bg-orb { + position: absolute; + border-radius: 50%; + filter: blur(18px); + opacity: 0.9; +} +.orb-a { + width: 420px; + height: 420px; + right: -120px; + top: -100px; + background: radial-gradient(circle, rgba(72, 168, 255, 0.5), rgba(72, 168, 255, 0.1)); +} +.orb-b { + width: 360px; + height: 360px; + left: -100px; + bottom: -120px; + background: radial-gradient(circle, rgba(110, 200, 255, 0.4), rgba(141, 205, 255, 0.06)); +} + +.glass { + backdrop-filter: blur(12px); + border: 1px solid var(--border); + background: var(--panel); + box-shadow: var(--shadow); +} + +.topbar { + border-radius: 20px; + padding: 18px; + display: grid; + grid-template-columns: 1.2fr auto 1fr; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.title-wrap h1 { + margin: 0; + font-size: clamp(1.1rem, 1.5vw, 1.45rem); + letter-spacing: 0.01em; +} + +.title-wrap p { + margin: 4px 0 0; + color: var(--muted); + font-size: 0.92rem; +} + +.status-chip { + justify-self: center; + border-radius: 999px; + padding: 7px 12px; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + border: 1px solid transparent; +} + +.status-chip.live { + color: #0d6a3f; + background: rgba(130, 245, 195, 0.18); + border-color: rgba(70, 199, 142, 0.3); +} + +.status-chip.idle { + color: #24527f; + background: rgba(114, 194, 255, 0.18); + border-color: rgba(62, 152, 223, 0.28); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +button, +select, +input { + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px 13px; + font-size: 0.92rem; + background: #fff; + color: var(--text); + min-height: 42px; +} + +button { + cursor: pointer; + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-2) 78%, var(--accent) 100%); + color: #fff; + border: none; + font-weight: 700; + box-shadow: var(--shadow-strong); + transition: transform 120ms ease, filter 120ms ease; +} + +button:hover { + transform: translateY(-1px); + filter: brightness(1.02); +} + +button.secondary { + background: linear-gradient(135deg, #68c2ff, #9dd9ff); +} + +button:disabled { + opacity: 0.58; + cursor: not-allowed; + transform: none; +} + +.layout { + margin-top: 18px; + display: grid; + gap: 16px; + grid-template-columns: 1.15fr 0.85fr; + align-items: start; +} + +.panel { + border-radius: 18px; + padding: 18px; +} + +.panel-wide { + grid-column: 1 / -1; +} + +.panel h2 { + margin: 0 0 12px; + font-size: 1rem; + letter-spacing: 0.01em; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.kpi-grid div { + background: rgba(255, 255, 255, 0.9); + border: 1px solid var(--border); + border-radius: 14px; + padding: 12px; +} + +.kpi-grid span { + display: block; + font-size: 0.74rem; + color: var(--muted); + margin-bottom: 4px; +} + +.kpi-grid strong { + font-size: 1.05rem; +} + +.action-row, +.stack { + display: grid; + gap: 10px; + margin-bottom: 12px; +} + +.stack-two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.med-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + max-height: 420px; + overflow: auto; + padding-right: 2px; +} + +.med-card { + border: 1px solid var(--border); + border-radius: 14px; + padding: 12px; + background: var(--panel-solid); + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.med-card:hover { + transform: translateY(-1px); + box-shadow: 0 10px 25px rgba(44, 105, 165, 0.12); +} + +.med-card p { + margin: 6px 0 4px; + color: var(--muted); + text-transform: capitalize; +} + +.logs { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 0.85rem; + max-height: 300px; + overflow: auto; + display: grid; + gap: 6px; + padding-right: 2px; +} + +.logs div { + background: rgba(255, 255, 255, 0.78); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 10px; +} + +.muted { + color: var(--muted); + margin: 0; +} + +.budget-note { + margin-top: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(255, 255, 255, 0.78); +} + +@media (max-width: 1120px) { + .layout { + grid-template-columns: 1fr; + } + + .topbar { + grid-template-columns: 1fr; + } + + .status-chip { + justify-self: start; + } + + .actions { + justify-content: flex-start; + } +} + +@media (max-width: 760px) { + .shell { + padding: 18px 12px; + } + + .kpi-grid, + .med-grid, + .stack-two { + grid-template-columns: 1fr; + } +} diff --git a/openenv-polypharmacy/frontend/vite.config.js b/openenv-polypharmacy/frontend/vite.config.js new file mode 100644 index 0000000000000000000000000000000000000000..2a3f20a36de78020e9832ce22b3b8234ae1e5a13 --- /dev/null +++ b/openenv-polypharmacy/frontend/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + host: "0.0.0.0", + }, +}); diff --git a/openenv-polypharmacy/inference.py b/openenv-polypharmacy/inference.py new file mode 100644 index 0000000000000000000000000000000000000000..a6809184af7dbc299ba4b8799eff00636647fb81 --- /dev/null +++ b/openenv-polypharmacy/inference.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Baseline LLM inference script for the PolypharmacyEnv. + +Uses Groq's OpenAI-compatible Chat Completions API to drive an LLM agent through the +PolypharmacyEnv HTTP API. Emits structured stdout logs in the +[START], [STEP], [END] format required by the OpenEnv evaluation spec. + +Environment variables: + GROQ_API_KEY – required + GROQ_BASE_URL – optional (default: https://api.groq.com/openai/v1) + GROQ_MODEL_NAME – model to use (default: llama-3.1-8b-instant) + POLYPHARMACY_ENV_URL – environment HTTP base URL (default: http://localhost:7860) +""" + +from __future__ import annotations + +import json +import os +import sys +import uuid +from typing import Any, Dict, List + +import requests +from openai import OpenAI + +# ── Configuration ──────────────────────────────────────────────────────────── + +MODEL = os.environ.get("GROQ_MODEL_NAME", "llama-3.1-8b-instant") +API_KEY = os.environ.get("GROQ_API_KEY", "") +API_BASE = os.environ.get("GROQ_BASE_URL", "https://api.groq.com/openai/v1") +ENV_URL = os.environ.get("POLYPHARMACY_ENV_URL", "http://localhost:7860") + +TASKS = ["easy_screening", "budgeted_screening", "complex_tradeoff"] +EPISODES_PER_TASK = 5 + +client = OpenAI(api_key=API_KEY, base_url=API_BASE) + +# ── Logging helpers ────────────────────────────────────────────────────────── + +def _log(tag: str, payload: Dict[str, Any]) -> None: + print(f"[{tag}] {json.dumps(payload, default=str)}", flush=True) + + +def _err(msg: str) -> None: + print(msg, file=sys.stderr, flush=True) + + +# ── Environment HTTP helpers ───────────────────────────────────────────────── + +def env_reset(task_id: str) -> Dict[str, Any]: + resp = requests.post(f"{ENV_URL}/reset", json={"task_id": task_id}, timeout=30) + resp.raise_for_status() + return resp.json() + + +def env_step(action: Dict[str, Any]) -> Dict[str, Any]: + resp = requests.post(f"{ENV_URL}/step", json={"action": action}, timeout=30) + resp.raise_for_status() + return resp.json() + + +# ── Observation → prompt ───────────────────────────────────────────────────── + +SYSTEM_PROMPT = """\ +You are a clinical pharmacist AI assistant reviewing an elderly patient's medication regimen. +You must reduce drug-interaction risk and address Beers-criteria violations while minimising +unnecessary medication changes. + +Available actions (respond with STRICT JSON, no extra text): +1. Query a drug pair for interactions: + {"action_type": "query_ddi", "drug_id_1": "...", "drug_id_2": "..."} + +2. Propose an intervention: + {"action_type": "propose_intervention", "target_drug_id": "...", + "intervention_type": "stop|dose_reduce|substitute|add_monitoring", + "proposed_new_drug_id": "...(optional)", "rationale": "..."} + +3. Finish the review: + {"action_type": "finish_review"} + +Respond with EXACTLY ONE JSON object per turn. No markdown, no explanation outside JSON. +""" + + +def _summarise_obs(obs: Dict[str, Any]) -> str: + meds = obs.get("current_medications", []) + med_summary = "; ".join( + f"{m['drug_id']}({m['generic_name']},{m['dose_mg']}mg)" + for m in meds + ) + queries = obs.get("interaction_queries", []) + q_summary = "; ".join( + f"{q['drug_id_1']}+{q['drug_id_2']}={q.get('severity','?')}" + for q in queries + ) + interventions = obs.get("interventions", []) + iv_summary = "; ".join( + f"{iv['action_type']}({iv['target_drug_id']})" + for iv in interventions + ) + return ( + f"Patient: age={obs.get('age')}, sex={obs.get('sex')}, " + f"conditions={obs.get('conditions')}, " + f"eGFR={obs.get('eGFR_category')}, liver={obs.get('liver_function_category')}\n" + f"Medications: {med_summary}\n" + f"Queries so far: {q_summary or 'none'}\n" + f"Interventions so far: {iv_summary or 'none'}\n" + f"Remaining query budget: {obs.get('remaining_query_budget')}\n" + f"Remaining intervention budget: {obs.get('remaining_intervention_budget')}\n" + f"Step: {obs.get('step_index')}" + ) + + +# ── LLM call ───────────────────────────────────────────────────────────────── + +def _ask_llm(obs_summary: str) -> Dict[str, Any]: + """Call the LLM and parse a PolypharmacyAction JSON.""" + try: + chat_resp = client.chat.completions.create( + model=MODEL, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": obs_summary}, + ], + max_tokens=256, + temperature=0.2, + ) + text = (chat_resp.choices[0].message.content or "").strip() + # Strip markdown fences if present + text = text.strip() + if text.startswith("```"): + text = text.split("\n", 1)[-1] + if text.endswith("```"): + text = text.rsplit("```", 1)[0] + text = text.strip() + return json.loads(text) + except Exception as e: + _err(f"LLM parse error: {e}") + return {"action_type": "finish_review"} + + +# ── Main loop ──────────────────────────────────────────────────────────────── + +def main() -> None: + if not API_KEY: + _err("GROQ_API_KEY is required") + sys.exit(1) + + run_id = str(uuid.uuid4())[:8] + + for task_id in TASKS: + task_scores: List[float] = [] + task_rewards: List[float] = [] + + _log("START", { + "run_id": run_id, + "task_id": task_id, + "model": MODEL, + "episodes": EPISODES_PER_TASK, + }) + + for ep_idx in range(EPISODES_PER_TASK): + reset_resp = env_reset(task_id) + obs = reset_resp["observation"] + done = reset_resp.get("done", False) + episode_id = obs.get("episode_id", f"ep_{ep_idx}") + total_reward = 0.0 + step_idx = 0 + + while not done: + obs_summary = _summarise_obs(obs) + action_payload = _ask_llm(obs_summary) + + step_resp = env_step(action_payload) + obs = step_resp["observation"] + reward = step_resp.get("reward", 0.0) + done = step_resp.get("done", False) + total_reward += reward + + _log("STEP", { + "run_id": run_id, + "task_id": task_id, + "episode_id": episode_id, + "step_index": step_idx, + "observation_summary": obs_summary[:200], + "action_payload": action_payload, + "reward": reward, + "done": done, + }) + + step_idx += 1 + + grader_score = step_resp.get("info", {}).get("grader_score", 0.0) + task_scores.append(grader_score) + task_rewards.append(total_reward) + + _log("END", { + "run_id": run_id, + "task_id": task_id, + "episodes": EPISODES_PER_TASK, + "avg_grader_score": sum(task_scores) / max(len(task_scores), 1), + "avg_total_reward": sum(task_rewards) / max(len(task_rewards), 1), + "per_episode_scores": task_scores, + }) + + _err("Inference complete.") + + +if __name__ == "__main__": + main() diff --git a/openenv-polypharmacy/openenv.yaml b/openenv-polypharmacy/openenv.yaml new file mode 100644 index 0000000000000000000000000000000000000000..695032aa05514c2e804d3199ef5a7ba417974f72 --- /dev/null +++ b/openenv-polypharmacy/openenv.yaml @@ -0,0 +1,30 @@ +spec_version: 1 +name: polypharmacy_env +version: "0.1.0" +description: > + An OpenEnv environment that simulates elderly polypharmacy medication review. + An RL agent acts as a clinical pharmacist assistant, identifying dangerous + drug-drug interactions, Beers-criteria violations, and proposing safe + interventions (stop, dose-reduce, substitute, monitor). +author: "PolypharmacyEnv Team" +tags: + - healthcare + - polypharmacy + - openenv +type: space +runtime: fastapi +app: backend.main:app +port: 7860 + +tasks: + - id: easy_screening + description: "Small regimen (3-5 drugs) with one severe DDI. Identify and resolve it." + difficulty: easy + + - id: budgeted_screening + description: "Medium regimen (6-10 drugs) with multiple DDIs and Beers issues under query/intervention budgets." + difficulty: medium + + - id: complex_tradeoff + description: "Large regimen (10-15 drugs) including critical drugs. Balance risk reduction against regimen disruption." + difficulty: hard diff --git a/openenv-polypharmacy/pyproject.toml b/openenv-polypharmacy/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..9bd219ea59455a8765851931c923b726ea32d1d9 --- /dev/null +++ b/openenv-polypharmacy/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "polypharmacy-env" +version = "0.1.0" +description = "OpenEnv environment for elderly polypharmacy medication-review safety" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.104.0", + "uvicorn>=0.24.0", + "pydantic>=2.0.0", + "requests>=2.31.0", + "openai>=1.0.0", + "python-dotenv>=1.0.0", + "openenv-core>=0.2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "httpx>=0.25.0", + "black", + "isort", +] + +[tool.setuptools.packages.find] +where = ["backend/src"] + +[tool.pytest.ini_options] +testpaths = ["backend/src/polypharmacy_env/tests"] +pythonpath = ["backend/src"] + +[tool.black] +line-length = 99 +target-version = ["py310"] + +[tool.isort] +profile = "black" +line_length = 99 diff --git a/openenv-polypharmacy/requirements.txt b/openenv-polypharmacy/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..82f21ff0993d8328c16385ebf07082f3318ecf27 --- /dev/null +++ b/openenv-polypharmacy/requirements.txt @@ -0,0 +1 @@ +-r backend/requirements.txt diff --git a/openenv-polypharmacy/scripts/dev_backend.sh b/openenv-polypharmacy/scripts/dev_backend.sh new file mode 100755 index 0000000000000000000000000000000000000000..83c2586f4d2a8ded9eed3a1c9817ef7bcc710c30 --- /dev/null +++ b/openenv-polypharmacy/scripts/dev_backend.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +uvicorn backend.main:app --reload --host 0.0.0.0 --port 7860 diff --git a/openenv-polypharmacy/scripts/dev_frontend.sh b/openenv-polypharmacy/scripts/dev_frontend.sh new file mode 100755 index 0000000000000000000000000000000000000000..d840f21d1f981bb07e20b9b055641ee198888cc2 --- /dev/null +++ b/openenv-polypharmacy/scripts/dev_frontend.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd frontend +npm run dev diff --git a/openenv-polypharmacy/scripts/preprocess_data.py b/openenv-polypharmacy/scripts/preprocess_data.py new file mode 100644 index 0000000000000000000000000000000000000000..22a8cb64172f111daaf900e1d878857e638f8070 --- /dev/null +++ b/openenv-polypharmacy/scripts/preprocess_data.py @@ -0,0 +1,301 @@ +"""Synthetic data generator for the PolypharmacyEnv. + +Generates: + - data/lookups/drug_metadata.csv + - data/lookups/ddi_rules.csv + - data/lookups/beers_criteria.csv + - data/processed/patients_polypharmacy.csv +""" + +from __future__ import annotations + +import csv +import random +import sys +from itertools import combinations +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +LOOKUPS = ROOT / "data" / "lookups" +PROCESSED = ROOT / "data" / "processed" + +# ── Drug catalogue ─────────────────────────────────────────────────────────── + +DRUGS = [ + # drug_id, generic_name, atc_class, high_risk, default, min, max + ("DRUG_WARFARIN", "warfarin", "B01AA", 1, 5.0, 1.0, 10.0), + ("DRUG_APIXABAN", "apixaban", "B01AF", 1, 5.0, 2.5, 10.0), + ("DRUG_METFORMIN", "metformin", "A10BA", 0, 1000, 500, 2000), + ("DRUG_GLIPIZIDE", "glipizide", "A10BB", 1, 5.0, 2.5, 20.0), + ("DRUG_LISINOPRIL", "lisinopril", "C09AA", 0, 10.0, 2.5, 40.0), + ("DRUG_AMLODIPINE", "amlodipine", "C08CA", 0, 5.0, 2.5, 10.0), + ("DRUG_METOPROLOL", "metoprolol", "C07AB", 0, 50.0, 25.0,200.0), + ("DRUG_DIGOXIN", "digoxin", "C01AA", 1, 0.25, 0.0625,0.5), + ("DRUG_FUROSEMIDE", "furosemide", "C03CA", 0, 40.0, 20.0,160.0), + ("DRUG_SPIRONOLACTONE", "spironolactone", "C03DA", 0, 25.0, 12.5, 50.0), + ("DRUG_ATORVASTATIN", "atorvastatin", "C10AA", 0, 20.0, 10.0, 80.0), + ("DRUG_SIMVASTATIN", "simvastatin", "C10AA", 0, 20.0, 10.0, 40.0), + ("DRUG_OMEPRAZOLE", "omeprazole", "A02BC", 0, 20.0, 10.0, 40.0), + ("DRUG_DIAZEPAM", "diazepam", "N05BA", 1, 5.0, 2.0, 10.0), + ("DRUG_ALPRAZOLAM", "alprazolam", "N05BA", 1, 0.5, 0.25, 2.0), + ("DRUG_AMITRIPTYLINE", "amitriptyline", "N06AA", 1, 25.0, 10.0, 75.0), + ("DRUG_INSULIN_GLARGINE","insulin glargine", "A10AE", 1, 20.0, 10.0, 60.0), + ("DRUG_PREDNISONE", "prednisone", "H02AB", 0, 10.0, 5.0, 60.0), + ("DRUG_NAPROXEN", "naproxen", "M01AE", 1, 500, 250, 1000), + ("DRUG_IBUPROFEN", "ibuprofen", "M01AE", 1, 400, 200, 800), + ("DRUG_CLOPIDOGREL", "clopidogrel", "B01AC", 0, 75.0, 75.0, 75.0), + ("DRUG_ASPIRIN", "aspirin", "B01AC", 0, 81.0, 81.0, 325.0), + ("DRUG_HYDROCHLOROTHIAZIDE","HCTZ", "C03AA", 0, 25.0, 12.5, 50.0), + ("DRUG_DONEPEZIL", "donepezil", "N06DA", 0, 5.0, 5.0, 10.0), + ("DRUG_GABAPENTIN", "gabapentin", "N03AX", 0, 300, 100, 1200), + ("DRUG_TRAMADOL", "tramadol", "N02AX", 1, 50.0, 25.0, 200.0), + ("DRUG_FLUOXETINE", "fluoxetine", "N06AB", 0, 20.0, 10.0, 60.0), + ("DRUG_SERTRALINE", "sertraline", "N06AB", 0, 50.0, 25.0, 200.0), + ("DRUG_CIPROFLOXACIN", "ciprofloxacin", "J01MA", 0, 500, 250, 750), + ("DRUG_TAMSULOSIN", "tamsulosin", "G04CA", 0, 0.4, 0.4, 0.8), + ("DRUG_CELECOXIB", "celecoxib", "M01AE", 0, 200, 100, 400), + ("DRUG_NORTRIPTYLINE", "nortriptyline", "N06AA", 0, 25.0, 10.0, 75.0), + ("DRUG_LOSARTAN", "losartan", "C09AA", 0, 50.0, 25.0, 100.0), +] + +# ── DDI rules ──────────────────────────────────────────────────────────────── + +DDI_PAIRS: list[tuple[str, str, str, str, str, float]] = [ + # id1, id2, severity, mechanism, recommendation, base_risk_score + ("DRUG_WARFARIN", "DRUG_NAPROXEN", "severe", "Increased bleeding risk – NSAID inhibits platelet + anticoagulant", "avoid_combination", 0.90), + ("DRUG_WARFARIN", "DRUG_IBUPROFEN", "severe", "Increased bleeding risk – NSAID + anticoagulant synergy", "avoid_combination", 0.88), + ("DRUG_WARFARIN", "DRUG_ASPIRIN", "moderate", "Additive antiplatelet + anticoagulant bleeding risk", "monitor_closely", 0.55), + ("DRUG_WARFARIN", "DRUG_FLUOXETINE", "moderate", "SSRI increases serotonin and may potentiate bleeding", "monitor_closely", 0.45), + ("DRUG_WARFARIN", "DRUG_CIPROFLOXACIN","moderate","CYP1A2 inhibition raises warfarin levels", "dose_adjust", 0.50), + ("DRUG_APIXABAN", "DRUG_NAPROXEN", "severe", "DOAC + NSAID – high bleeding risk", "avoid_combination", 0.85), + ("DRUG_APIXABAN", "DRUG_ASPIRIN", "moderate", "Additive bleeding risk with antiplatelet", "monitor_closely", 0.50), + ("DRUG_DIGOXIN", "DRUG_AMIODARONE", "severe", "Amiodarone increases digoxin levels – toxicity risk", "dose_adjust", 0.80), + ("DRUG_DIGOXIN", "DRUG_SPIRONOLACTONE","moderate","Spironolactone may raise digoxin levels", "monitor_closely", 0.40), + ("DRUG_METFORMIN", "DRUG_CIPROFLOXACIN","moderate","Fluoroquinolone may cause dysglycemia with metformin", "monitor_closely", 0.35), + ("DRUG_DIAZEPAM", "DRUG_TRAMADOL", "severe", "CNS depression – benzodiazepine + opioid", "avoid_combination", 0.92), + ("DRUG_ALPRAZOLAM", "DRUG_TRAMADOL", "severe", "CNS depression – benzodiazepine + opioid", "avoid_combination", 0.91), + ("DRUG_LISINOPRIL", "DRUG_SPIRONOLACTONE","moderate","Hyperkalemia risk – ACE-I + K-sparing diuretic", "monitor_closely", 0.48), + ("DRUG_LISINOPRIL", "DRUG_NAPROXEN", "moderate", "NSAID reduces ACE-I efficacy, renal risk", "monitor_closely", 0.42), + ("DRUG_SIMVASTATIN","DRUG_AMLODIPINE", "moderate", "CYP3A4 interaction increases statin exposure", "dose_adjust", 0.38), + ("DRUG_ATORVASTATIN","DRUG_CIPROFLOXACIN","mild", "Minor CYP interaction raising statin levels", "no_action", 0.15), + ("DRUG_CLOPIDOGREL","DRUG_OMEPRAZOLE", "moderate", "PPI reduces clopidogrel activation via CYP2C19", "dose_adjust", 0.45), + ("DRUG_INSULIN_GLARGINE","DRUG_GLIPIZIDE","moderate","Additive hypoglycemia risk", "monitor_closely", 0.50), + ("DRUG_FLUOXETINE", "DRUG_TRAMADOL", "severe", "Serotonin syndrome risk – SSRI + serotonergic opioid", "avoid_combination", 0.82), + ("DRUG_AMITRIPTYLINE","DRUG_TRAMADOL", "severe", "Serotonin syndrome + CNS depression", "avoid_combination", 0.85), + ("DRUG_METOPROLOL", "DRUG_DIGOXIN", "moderate", "Additive bradycardia", "monitor_closely", 0.40), + ("DRUG_FUROSEMIDE", "DRUG_DIGOXIN", "moderate", "Loop diuretic causes hypokalemia increasing digoxin toxicity risk", "monitor_closely", 0.45), + ("DRUG_PREDNISONE", "DRUG_NAPROXEN", "moderate", "GI bleeding risk – corticosteroid + NSAID", "monitor_closely", 0.50), + ("DRUG_PREDNISONE", "DRUG_WARFARIN", "mild", "Corticosteroid may alter INR", "monitor_closely", 0.25), +] + +# ── Beers criteria ─────────────────────────────────────────────────────────── + +BEERS_ENTRIES: list[tuple[str, str, str | None, str]] = [ + # drug_id, criterion_type, condition, rationale + ("DRUG_DIAZEPAM", "avoid", None, "Long-acting benzodiazepine: falls, fractures, cognitive impairment in elderly"), + ("DRUG_ALPRAZOLAM", "avoid", None, "Benzodiazepine: falls, fractures, cognitive impairment in elderly"), + ("DRUG_AMITRIPTYLINE", "avoid", None, "Strongly anticholinergic TCA: sedation, confusion, urinary retention in elderly"), + ("DRUG_GLIPIZIDE", "caution", None, "Sulfonylurea: hypoglycemia risk higher in elderly"), + ("DRUG_NAPROXEN", "avoid", "CKD", "NSAID contraindicated in CKD – renal deterioration, fluid retention"), + ("DRUG_IBUPROFEN", "avoid", "CKD", "NSAID contraindicated in CKD – renal deterioration, fluid retention"), + ("DRUG_NAPROXEN", "caution", None, "NSAID: GI bleeding and renal risk in elderly"), + ("DRUG_IBUPROFEN", "caution", None, "NSAID: GI bleeding and renal risk in elderly"), + ("DRUG_DIGOXIN", "dose_adjust", None, "Avoid doses > 0.125 mg/day in elderly – toxicity risk"), + ("DRUG_TRAMADOL", "avoid", None, "Opioid: CNS depression, falls, constipation in elderly"), + ("DRUG_METFORMIN", "dose_adjust", "CKD", "Reduce dose or avoid if eGFR < 30 – lactic acidosis risk"), + ("DRUG_INSULIN_GLARGINE","caution", None, "Tight glycemic control increases hypoglycemia risk in elderly"), + ("DRUG_PREDNISONE", "avoid_in_condition", "DM", "Corticosteroid worsens glycemic control in diabetes"), + ("DRUG_DONEPEZIL", "avoid_in_condition", "dementia", "Limited benefit, GI side effects; reassess regularly"), + ("DRUG_CIPROFLOXACIN", "caution", None, "Fluoroquinolone: tendon rupture, QT prolongation risk in elderly"), +] + +# ── Conditions pool & constraints ──────────────────────────────────────────── + +ALL_CONDITIONS = ["HTN", "DM", "HF", "CKD", "AF", "COPD", "OA", "depression", "dementia", "GERD", "BPH", "neuropathy"] +EGFR_CATS = ["normal", "mild", "moderate", "severe"] +LIVER_CATS = ["normal", "impaired"] + +# Drugs that make clinical sense per condition +CONDITION_DRUG_MAP: dict[str, list[str]] = { + "HTN": ["DRUG_LISINOPRIL", "DRUG_AMLODIPINE", "DRUG_METOPROLOL", "DRUG_HYDROCHLOROTHIAZIDE", "DRUG_FUROSEMIDE"], + "DM": ["DRUG_METFORMIN", "DRUG_GLIPIZIDE", "DRUG_INSULIN_GLARGINE"], + "HF": ["DRUG_FUROSEMIDE", "DRUG_SPIRONOLACTONE", "DRUG_METOPROLOL", "DRUG_LISINOPRIL", "DRUG_DIGOXIN"], + "CKD": ["DRUG_FUROSEMIDE", "DRUG_AMLODIPINE"], + "AF": ["DRUG_WARFARIN", "DRUG_APIXABAN", "DRUG_METOPROLOL", "DRUG_DIGOXIN"], + "COPD": ["DRUG_PREDNISONE"], + "OA": ["DRUG_NAPROXEN", "DRUG_IBUPROFEN", "DRUG_TRAMADOL", "DRUG_GABAPENTIN"], + "depression": ["DRUG_FLUOXETINE", "DRUG_SERTRALINE", "DRUG_AMITRIPTYLINE"], + "dementia": ["DRUG_DONEPEZIL"], + "GERD": ["DRUG_OMEPRAZOLE"], + "BPH": ["DRUG_TAMSULOSIN"], + "neuropathy": ["DRUG_GABAPENTIN", "DRUG_AMITRIPTYLINE"], +} + + +def _normalise_pair(a: str, b: str) -> tuple[str, str]: + return (a, b) if a < b else (b, a) + + +def _gen_drug_metadata(out: Path) -> None: + out.parent.mkdir(parents=True, exist_ok=True) + with open(out, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["drug_id", "generic_name", "atc_class", "is_high_risk_elderly", + "default_dose_mg", "min_dose_mg", "max_dose_mg"]) + for row in DRUGS: + w.writerow(row) + + +def _gen_ddi_rules(out: Path) -> None: + out.parent.mkdir(parents=True, exist_ok=True) + with open(out, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["drug_id_1", "drug_id_2", "severity", "mechanism", + "recommendation", "base_risk_score"]) + for pair in DDI_PAIRS: + a, b = _normalise_pair(pair[0], pair[1]) + w.writerow([a, b, pair[2], pair[3], pair[4], pair[5]]) + + +def _gen_beers(out: Path) -> None: + out.parent.mkdir(parents=True, exist_ok=True) + with open(out, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["drug_id", "criterion_type", "condition", "rationale"]) + for row in BEERS_ENTRIES: + w.writerow([row[0], row[1], row[2] or "", row[3]]) + + +def _gen_patients(out: Path, n_easy: int = 40, n_med: int = 40, n_hard: int = 40) -> None: + """Generate synthetic patient episodes tagged by difficulty.""" + out.parent.mkdir(parents=True, exist_ok=True) + rng = random.Random(42) + drug_ids = [d[0] for d in DRUGS] + + # Build severity lookup for quick reference + severe_pairs: set[tuple[str, str]] = set() + for pair in DDI_PAIRS: + if pair[2] == "severe": + severe_pairs.add(_normalise_pair(pair[0], pair[1])) + + rows: list[list[str]] = [] + ep_counter = 0 + + def _pick_conditions(n: int) -> list[str]: + return rng.sample(ALL_CONDITIONS, min(n, len(ALL_CONDITIONS))) + + def _drugs_for_conditions(conds: list[str], target_n: int) -> list[str]: + pool: list[str] = [] + for c in conds: + pool.extend(CONDITION_DRUG_MAP.get(c, [])) + pool = list(dict.fromkeys(pool)) # deduplicate preserving order + rng.shuffle(pool) + selected = pool[:target_n] + # Pad with random drugs if needed + remaining = [d for d in drug_ids if d not in selected] + while len(selected) < target_n and remaining: + pick = rng.choice(remaining) + remaining.remove(pick) + selected.append(pick) + return selected + + def _count_severe(meds: list[str]) -> int: + count = 0 + for a, b in combinations(meds, 2): + if _normalise_pair(a, b) in severe_pairs: + count += 1 + return count + + def _baseline_risk(meds: list[str]) -> float: + risk = 0.0 + for pair in DDI_PAIRS: + a, b = _normalise_pair(pair[0], pair[1]) + if a in meds and b in meds: + risk += pair[5] + return min(risk / max(len(meds), 1), 1.0) + + # Easy episodes: 3-5 drugs, exactly 1 severe DDI + for _ in range(n_easy): + ep_counter += 1 + n_drugs = rng.randint(3, 5) + conds = _pick_conditions(rng.randint(1, 3)) + # Ensure at least one severe DDI pair is present + for attempt in range(50): + meds = _drugs_for_conditions(conds, n_drugs) + if _count_severe(meds) >= 1: + break + else: + # Force a known severe pair + sp = rng.choice(list(severe_pairs)) + meds = list(set(meds[:n_drugs - 2]) | {sp[0], sp[1]})[:n_drugs] + + age = rng.randint(65, 90) + sex = rng.choice(["M", "F"]) + egfr = rng.choices(EGFR_CATS, weights=[4, 3, 2, 1])[0] + liver = rng.choices(LIVER_CATS, weights=[8, 2])[0] + br = round(_baseline_risk(meds), 4) + rows.append([ + f"EP_{ep_counter:04d}", str(age), sex, ";".join(conds), + egfr, liver, ";".join(meds), str(br), "easy", + ]) + + # Medium episodes: 6-10 drugs, multiple DDIs + for _ in range(n_med): + ep_counter += 1 + n_drugs = rng.randint(6, 10) + conds = _pick_conditions(rng.randint(3, 5)) + meds = _drugs_for_conditions(conds, n_drugs) + age = rng.randint(65, 92) + sex = rng.choice(["M", "F"]) + egfr = rng.choices(EGFR_CATS, weights=[3, 3, 3, 1])[0] + liver = rng.choices(LIVER_CATS, weights=[7, 3])[0] + br = round(_baseline_risk(meds), 4) + rows.append([ + f"EP_{ep_counter:04d}", str(age), sex, ";".join(conds), + egfr, liver, ";".join(meds), str(br), "medium", + ]) + + # Hard episodes: 10-15 drugs, many issues, include critical drugs + for _ in range(n_hard): + ep_counter += 1 + n_drugs = rng.randint(10, 15) + conds = _pick_conditions(rng.randint(4, 7)) + meds = _drugs_for_conditions(conds, n_drugs) + # Ensure some critical drugs are present + critical = ["DRUG_WARFARIN", "DRUG_INSULIN_GLARGINE", "DRUG_DIGOXIN"] + for cd in rng.sample(critical, min(2, len(critical))): + if cd not in meds and len(meds) < 15: + meds.append(cd) + age = rng.randint(70, 95) + sex = rng.choice(["M", "F"]) + egfr = rng.choices(EGFR_CATS, weights=[2, 2, 3, 3])[0] + liver = rng.choices(LIVER_CATS, weights=[6, 4])[0] + br = round(_baseline_risk(meds), 4) + rows.append([ + f"EP_{ep_counter:04d}", str(age), sex, ";".join(conds), + egfr, liver, ";".join(meds), str(br), "hard", + ]) + + with open(out, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["episode_id", "age", "sex", "conditions", "eGFR_category", + "liver_function_category", "medication_ids", + "baseline_risk_score", "difficulty"]) + for r in rows: + w.writerow(r) + + +def main() -> None: + print("Generating drug_metadata.csv …") + _gen_drug_metadata(LOOKUPS / "drug_metadata.csv") + print("Generating ddi_rules.csv …") + _gen_ddi_rules(LOOKUPS / "ddi_rules.csv") + print("Generating beers_criteria.csv …") + _gen_beers(LOOKUPS / "beers_criteria.csv") + print("Generating patients_polypharmacy.csv …") + _gen_patients(PROCESSED / "patients_polypharmacy.csv") + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/openenv-polypharmacy/scripts/run_validation.sh b/openenv-polypharmacy/scripts/run_validation.sh new file mode 100755 index 0000000000000000000000000000000000000000..903c8176803d36fa91643db5aa55b521f7d725e5 --- /dev/null +++ b/openenv-polypharmacy/scripts/run_validation.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Run validation: tests, server smoke test, and heuristic baseline +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "=== Running unit tests ===" +PYTHONPATH=backend/src python3 -m pytest backend/src/polypharmacy_env/tests/ -v + +echo "" +echo "=== Running heuristic baseline ===" +PYTHONPATH=backend/src python3 -m polypharmacy_env.baselines.heuristic_agent + +echo "" +echo "=== Validation complete ==="