diff --git a/.claude/project_memory.md b/.claude/project_memory.md new file mode 100644 index 0000000000000000000000000000000000000000..8b4fb95f2af94360223cbde66256f8fe907757d4 --- /dev/null +++ b/.claude/project_memory.md @@ -0,0 +1,33 @@ +# ClinicalMatch AI — Project Memory + +Full-stack clinical trial matching agent for "Agents Assemble: Healthcare AI Endgame Challenge" on Prompt Opinion. Submission uses FHIR R4, MCP, and A2A standards. + +**Stack:** FastAPI + Neo4j + LangChain GraphRAG + Next.js 16 + Recharts + Leaflet + +**LLM:** aimlapi.com (OpenAI-compatible) with claude-opus-4-7. Never use Anthropic SDK directly. + +## Completed features + +- `/intake` — SI-unit clinical intake form (no patient ID), scores against graph trials, optional graph save +- Trial Finder (`/`) — real-time CT.gov sorted by recency, passive graph enrichment on every search, Graph Intelligence panel per trial +- `/screening` — FHIR patient combobox loading 500 graph patients, A2A pipeline (5 states) +- `/recruitment` — kanban board, AI outreach generation (3 channels) +- `/dashboard` — KPI cards, enrollment funnel, demographics pie chart +- `/map` — Leaflet site map with patient density clusters +- `/graph` — GraphRAG with custom Cypher prompt +- 500 synthetic patients seeded, ~250 real NCT trials, ~9,100 ELIGIBLE_FOR edges +- MCP server (6 tools, stdio transport) +- `trial_enrichment.py` — passive upsert on search, batch eligible-patient counts, similar-trials graph walk +- `intake_matching.py` — BIOMARKER_REGISTRY, SI unit conversion, regex ECOG + lab threshold parsing + +## Known constraints + +- Turbopack broken — always `next dev --webpack` +- `next/font/google` removed (hangs compilation) — use Tailwind `font-sans` +- Sync CT.gov wrappers use `httpx.Client` not `asyncio.run()` (breaks in FastAPI event loop) +- Leaflet uses raw API via useEffect, not react-leaflet (SSR issues) +- Mock FHIR patients: P001–P005 (fhir_adapter.py). Graph patients: P_C50_0001 etc. +- `suppressHydrationWarning` on `` in layout.tsx for Grammarly extension +- After Python file changes, uvicorn may need manual restart if --reload doesn't trigger + +See `CLAUDE.md` at repo root for full agent instructions. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..e13a42d2402ad44ea6f9390e9e7aae48fad91dd7 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Neo4j — local Docker (docker-compose.yml) or Aura +NEO4J_URI=bolt://localhost:7687 +NEO4J_USERNAME=neo4j +NEO4J_PASSWORD=clinicalmatch2024 +NEO4J_DATABASE=neo4j + +# LLM — OpenAI-compatible (aimlapi.com → claude-opus-4-7) +OPENAI_API_KEY=your-key-here +OPENAI_BASE_URL=https://ai.aimlapi.com/v1 +OPENAI_MODEL=claude-opus-4-7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..604f922b36d616e29474ad71ae8ae3f528817f02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Secrets +.env +.env.local +backend/.env + +# Python +backend/venv/ +backend/__pycache__/ +backend/*.pyc +**/__pycache__/ +*.pyc + +# Node +frontend/node_modules/ +frontend/.next/ +frontend/out/ + +# Docker volumes (local) +neo4j_data/ + +# OS +.DS_Store +Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..b0cf29c9cff84e1b65263ea96433241fa27d451e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,234 @@ +# ClinicalMatch AI — Agent Instructions + +> Project memory (build state, completed features, constraints) is also tracked in `.claude/project_memory.md` in this repo. + +This is a hackathon submission for **"Agents Assemble: Healthcare AI Endgame Challenge"** on the Prompt Opinion platform. Judging criteria: MCP compliance, A2A workflow, FHIR R4 standards, AI quality, impact, feasibility. + +## Stack at a glance + +| Layer | Technology | +|---|---| +| Backend | FastAPI (Python 3.12), uvicorn | +| Graph DB | Neo4j Community 5.x via bolt | +| LLM | claude-opus-4-7 via aimlapi.com (OpenAI-compatible) | +| GraphRAG | LangChain `GraphCypherQAChain` + custom Cypher prompt | +| Frontend | Next.js 16 (webpack mode), React 19, Tailwind CSS 3, Recharts, Leaflet | +| Standards | FHIR R4 · MCP (stdio) · A2A state machine | + +## Critical: LLM API + +**Never use the Anthropic SDK directly.** All LLM calls go through aimlapi.com or a compatible alternative using the OpenAI-compatible interface: + +```python +from openai import OpenAI + +client = OpenAI( + api_key=os.getenv("OPENAI_API_KEY"), + base_url=os.getenv("OPENAI_BASE_URL", "https://ai.aimlapi.com/v1"), +) +model = os.getenv("OPENAI_MODEL", "claude-opus-4-7") +``` + +See `backend/llm_client.py` for the canonical pattern. Do not add `import anthropic` anywhere. + +## Starting the services + +```bash +# Backend — always use --reload for hot reload +cd backend && source venv/bin/activate +uvicorn main:app --reload --port 8000 + +# Frontend — always use --webpack (Turbopack is broken on this system) +cd frontend && npm run dev # runs: next dev --webpack + +# MCP server (separate process, stdio transport) +cd backend && python mcp_server.py + +# Seed graph data (~15 min first run) +curl -X POST http://localhost:8000/seed +``` + +After changing backend Python files, uvicorn `--reload` should pick them up. If a 404 appears for a newly-added endpoint or old errors persist, the server needs a manual restart — kill the process and re-run the uvicorn command. + +## Project layout + +``` +promptop/ +├── CLAUDE.md ← you are here +├── README.md ← user-facing docs +├── backend/ +│ ├── main.py ← FastAPI app, all routes +│ ├── clinicaltrials_api.py ← ClinicalTrials.gov v2 API (async + sync) +│ ├── intake_matching.py ← SI-unit clinical intake → trial scoring +│ ├── trial_enrichment.py ← passive graph enrichment on search +│ ├── matching_engine.py ← FHIR patient → trial scoring (LLM-assisted) +│ ├── a2a_workflow.py ← A2A state machine (INGEST→PARSE→MATCH→SCORE→RECRUIT) +│ ├── graphrag.py ← LangChain GraphCypherQAChain with custom prompt +│ ├── graph_seeder.py ← seeds 500 patients + real NCT trials from APIs +│ ├── fhir_adapter.py ← FHIR R4 patient models (P001–P005 mock patients) +│ ├── neo4j_setup.py ← Neo4j connection + schema setup +│ ├── analytics.py ← dashboard KPIs, funnel, demographics, map data +│ ├── recruitment_pipeline.py ← kanban board, outreach generation +│ ├── llm_client.py ← all LLM calls (aimlapi.com / claude-opus-4-7) +│ ├── mcp_server.py ← MCP stdio server (6 tools) +│ └── requirements.txt +├── frontend/ +│ ├── src/app/ +│ │ ├── page.tsx ← Trial Finder (real-time CT.gov, recency sort) +│ │ ├── intake/page.tsx ← Eligibility Check (SI-unit clinical intake form) +│ │ ├── screening/page.tsx ← Patient Screening (A2A pipeline, FHIR patients) +│ │ ├── recruitment/page.tsx← Recruitment Hub (kanban + outreach generation) +│ │ ├── dashboard/page.tsx ← Analytics dashboard (Recharts) +│ │ ├── map/page.tsx ← Leaflet site map +│ │ ├── graph/page.tsx ← GraphRAG natural language query +│ │ └── layout.tsx ← App shell with Sidebar +│ ├── src/components/ +│ │ ├── Sidebar.tsx ← Navigation sidebar +│ │ └── MapComponent.tsx ← Raw Leaflet map (no react-leaflet SSR issues) +│ ├── src/lib/api.ts ← Typed API client for all backend endpoints +│ └── next.config.ts ← webpack mode, filesystem cache, optimizePackageImports +└── docker/ ← Docker + Nginx for HuggingFace Spaces deployment +``` + +## Neo4j graph schema + +``` +(Patient) id, name, age, sex, ecog, condition, city, state, ethnicity, + biomarkers[], medications[], source, stage +(Trial) id (NCT), title, condition, phase, status, sponsor, + eligibility_criteria, min_age, max_age, sex, enrollment, + start_date, completion_date, last_updated, ctgov_url +(Diagnosis) id, name, icd10 +(Biomarker) id (e.g. HER2_POS), name (e.g. "HER2 Positive") +(Medication) id (e.g. TAMOXIFEN), name +(StudySite) id, name, city, state, lat, lon, trials, enrolled, capacity + +Relationships: + (Patient)-[:ELIGIBLE_FOR {score}]->(Trial) + (Patient)-[:HAS_DIAGNOSIS]->(Diagnosis) + (Patient)-[:HAS_BIOMARKER]->(Biomarker) + (Patient)-[:TAKES_MEDICATION]->(Medication) + (Trial)-[:LOCATED_AT]->(StudySite) +``` + +**Graph scale after seeding:** ~500 patients, ~250 trials, ~9,100 ELIGIBLE_FOR edges. + +Patient IDs from seeder: `P_C50_0001` (breast), `P_C61_0001` (prostate), etc. +Mock FHIR patients: `P001`–`P005` (used by screening/workflow pages). + +## Key backend modules + +### `clinicaltrials_api.py` +- `search_trials()` — async, `sort=LastUpdatePostDate:desc` +- `get_trial_details()` — async +- `search_trials_sync()` / `get_trial_details_sync()` — sync using `httpx.Client` (NOT `asyncio.run()`). Safe to call from both sync functions and FastAPI async handlers. +- `_normalize_study()` — extracts `last_updated`, `ctgov_url` in addition to core fields. + +**Do not** use `asyncio.run()` inside these sync wrappers — it breaks when called from a running FastAPI event loop. The sync wrappers use `httpx.Client` directly. + +### `intake_matching.py` +Implements SI-unit clinical intake → trial eligibility matching without requiring a patient ID: +- `BIOMARKER_REGISTRY` — maps graph node IDs to labels and eligibility text search terms +- `score_intake_against_trial()` — weighted scoring: age (25), sex (15), ECOG (15), biomarkers (30), labs (15) +- `_check_labs()` — parses thresholds from eligibility criteria text, converts SI units (creatinine μmol/L ↔ mg/dL, bilirubin μmol/L ↔ mg/dL) +- `save_intake_as_patient()` — persists intake as `Patient` node for long-term graph enrichment + +### `trial_enrichment.py` +- `enrich_trials_from_search()` — called as a `BackgroundTask` on every `/api/v1/trials/search` response; upserts Trial + StudySite nodes +- `get_eligible_patient_counts()` — batch graph query, returns `{nct_id: count}` +- `get_graph_intelligence()` — per-trial: eligible count + top biomarkers + similar trials + +### `graphrag.py` +Uses a custom `_CYPHER_PROMPT` with explicit schema examples. Critical rules in the prompt: +- Biomarker lookups use `id` property (`{id: 'HER2_POS'}`), never `{name: 'HER2', status: 'positive'}` +- Condition lookups use lowercase on Trial nodes +- Patient eligibility always via `(Patient)-[:ELIGIBLE_FOR]->(Trial)` + +### `a2a_workflow.py` +Five-state machine: `INGESTING → PARSING_PROTOCOL → MATCHING → SCORING → RECRUITING` +- Calls `search_trials_sync()` / `get_trial_details_sync()` — these are safe (use httpx.Client) +- `run_pipeline()` is synchronous; called from async FastAPI endpoint without `await` + +## Key frontend pages + +### `/intake` — Eligibility Check +The primary self-service interface. Accepts raw clinical data in SI units; no patient ID needed. +- Six sections: Diagnosis & Demographics, Biomarkers, Lab Values, Treatment History +- Biomarker registry loaded from `GET /api/v1/intake/biomarkers` +- Submits to `POST /api/v1/intake/match` +- Optional "Save to graph" checkbox persists profile as Patient node + +### `/` — Trial Finder +- Sorted by `LastUpdatePostDate:desc` (most recently updated first) +- Each search result triggers background graph enrichment +- Expanded cards show Graph Intelligence panel: eligible patient count, top biomarkers, similar trials +- Direct ClinicalTrials.gov link per trial + +### `/screening` — Patient Screening +- Patient ID field is a `` combobox loading from `GET /api/v1/graph/patients` +- NCT ID field is a combobox with quick-pick suggestions +- Validates non-empty inputs before submitting +- Two modes: Single Trial Screen and A2A Full Pipeline + +## API endpoints (key ones) + +``` +GET /api/v1/trials/search — real-time CT.gov search, sorted by recency, graph-enriched +POST /api/v1/intake/match — SI-unit clinical intake → ranked trial matches +GET /api/v1/intake/biomarkers — biomarker registry for the intake form +GET /api/v1/trials/{nct_id}/intelligence — graph-derived insights per trial +GET /api/v1/graph/patients — query Neo4j for seeded patient IDs +POST /api/v1/patients/{id}/screen/{nct_id} — screen FHIR patient against trial +POST /api/v1/workflow/run — run full A2A pipeline +GET /api/v1/analytics/kpi — dashboard KPIs +GET /api/v1/map/data — site coordinates + patient clusters +POST /api/v1/graph/query — GraphRAG natural language +POST /seed — trigger full graph seeding +GET /api/v1/graph/stats — node/edge counts +``` + +Full interactive docs at `http://localhost:8000/docs`. + +## Environment variables + +```env +NEO4J_URI=bolt://localhost:7687 +NEO4J_USERNAME=neo4j +NEO4J_PASSWORD=clinicalmatch2024 +NEO4J_DATABASE=neo4j + +OPENAI_API_KEY= +OPENAI_BASE_URL=https://ai.aimlapi.com/v1 +OPENAI_MODEL=claude-opus-4-7 + +NEXT_PUBLIC_API_URL=http://localhost:8000 # dev only; empty string in Docker +``` + +## Known issues and constraints + +- **Turbopack is broken** on this machine — always use `next dev --webpack`. Never suggest `next dev` without `--webpack`. +- **`next/font/google`** causes compilation to hang (network request during bundling). Geist font is installed as a package but the `next/font/google` import is removed. Use plain Tailwind `font-sans`. +- **`asyncio.run()` from async context** — the sync CT.gov wrappers use `httpx.Client` to avoid this. Never re-introduce `asyncio.run()` into the sync wrappers; it will fail when called from FastAPI's running event loop. +- **Leaflet SSR** — `MapComponent.tsx` uses raw Leaflet (not react-leaflet) via `useEffect`. The `MapComponent` dynamic import has `ssr: false`. Do not switch to react-leaflet's `MapContainer`. +- **`suppressHydrationWarning`** on `` in `layout.tsx` — required for Grammarly browser extension compatibility. +- **Mock FHIR patients** (P001–P005) live in `fhir_adapter.py`. The 500 seeded graph patients (`P_C50_0001` etc.) are in Neo4j only. The screening page loads graph patients from `GET /api/v1/graph/patients` for the combobox. + +## Adding new features + +1. **New backend route**: add to `main.py`, import the module at the top, add a Pydantic request model if needed +2. **New API function**: add a typed function to `frontend/src/lib/api.ts` +3. **New page**: create `frontend/src/app//page.tsx`, add to `nav` array in `Sidebar.tsx` +4. **Graph schema change**: update `neo4j_setup.py` constraints/indexes, update `_CYPHER_PROMPT` in `graphrag.py` with the new node/property examples +5. **New biomarker**: add to `BIOMARKER_REGISTRY` in `intake_matching.py` and to `BM_GROUPS` in `frontend/src/app/intake/page.tsx` + +## Demo script (for judges) + +1. `GET /api/v1/graph/stats` — confirm 500+ patients and 9,100+ edges +2. `/` — search "breast cancer" → observe recency sort, graph-matched patient count badges +3. Expand a trial → Graph Intelligence panel shows eligible patients, top biomarkers, similar trials +4. `/intake` — enter: Age 52, Female, ECOG 1, HER2+, Hgb 12.5 g/dL, Creatinine 88 μmol/L → ranked trials with pass/fail breakdown +5. `/screening` — select P_C50_0001 from combobox → run A2A Pipeline → observe 5-state machine +6. `/recruitment` — kanban board, generate PCP letter outreach +7. `/dashboard` — KPI cards, enrollment funnel, demographics +8. `/graph` — ask "which patients are eligible for breast cancer trials?" +9. In Prompt Opinion: call MCP tool `find_trials(condition="breast cancer")` diff --git a/README.md b/README.md index 75174b38ea070bfec4d11e11ab60b3707ea48151..9bfc8fe6d015328ab90b1417b2b6ee7a9301c6b9 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,267 @@ --- -title: CTA -emoji: 🏢 -colorFrom: pink +title: ClinicalMatch AI +emoji: 🧬 +colorFrom: indigo colorTo: purple sdk: docker -pinned: false -license: apache-2.0 -short_description: Clinical trial matching agent with MCP , APA +app_port: 7860 +pinned: true --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# ClinicalMatch AI — Precision Clinical Trial Matching & Recruitment Agent + +**"Agents Assemble: Healthcare AI Endgame Challenge"** — Prompt Opinion platform +Standards: **FHIR R4 · MCP · A2A** + +> 80% of clinical trials fail to meet enrollment deadlines. 85% of eligible patients are never identified. This agent directly addresses that. + +--- + +## What it does + +ClinicalMatch AI is a full-stack AI agent that matches patients to recruiting clinical trials using a knowledge graph, real-time data from ClinicalTrials.gov, and structured clinical eligibility scoring. + +**Key capabilities:** + +| Feature | Description | +|---|---| +| **Eligibility Check** | Individual enters raw clinical data (age, labs in SI units, biomarkers) — no patient ID required — and receives ranked, explainable trial matches | +| **Trial Finder** | Real-time search of ClinicalTrials.gov sorted by most recently updated; results auto-ingest into the knowledge graph | +| **Graph Intelligence** | Per-trial: eligible patient count, top biomarkers among matches, similar trials via graph-neighborhood walk | +| **A2A Pipeline** | 5-state orchestration (INGEST → PARSE → MATCH → SCORE → RECRUIT) for FHIR patient profiles | +| **Recruitment Hub** | Kanban board tracking patients through IDENTIFIED → ENROLLED; generates personalized outreach (PCP letter, patient email, social post) | +| **GraphRAG** | Natural language queries over the knowledge graph ("which patients are eligible for breast cancer trials?") | +| **MCP Server** | 6 tools callable by Prompt Opinion directly via stdio transport | + +--- + +## Architecture + +``` +Prompt Opinion Platform + │ MCP Protocol (stdio) + ▼ +┌────────────────────────────────────────────────────┐ +│ MCP Server (mcp_server.py) │ +│ find_trials · screen_patient · match_patient │ +│ generate_outreach · get_analytics · summarize │ +└──────────────────────┬─────────────────────────────┘ + │ A2A Orchestration + ▼ +┌────────────────────────────────────────────────────┐ +│ FastAPI Backend (main.py, port 8000) │ +│ 30+ REST endpoints │ +├──────────┬────────────┬────────────┬───────────────┤ +│ CT.gov │ FHIR R4 │ Claude │ Neo4j Graph │ +│ live API │ adapter │ LLM │ RAG + match │ +└──────────┴────────────┴────────────┴───────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────┐ +│ Next.js 16 Frontend (port 3000) │ +│ Trial Finder · Eligibility Check · Screening │ +│ Recruitment Hub · Dashboard · Map · GraphRAG │ +└────────────────────────────────────────────────────┘ + │ Nginx (port 7860) + ▼ + HuggingFace Spaces +``` + +**Data sources (all free, no auth):** + +| Source | Data | +|---|---| +| ClinicalTrials.gov v2 | Real recruiting NCT trials, sorted by recency | +| RxNorm (NIH) | Medication RxCUI codes | +| ICD-10 CM (NLM) | Cancer diagnosis codes | +| PubMed (NCBI) | Supporting literature PMIDs | +| OpenFDA | Drug labels and adverse events | +| Synthetic | 500 realistic patient profiles matched to real trials | + +--- + +## Graph Knowledge Base + +After seeding, the Neo4j graph contains: + +| Node type | Count | Key properties | +|---|---|---| +| Patient | 500 | age, sex, ECOG, condition, city, biomarkers[], medications[] | +| Trial | ~250 | NCT ID, eligibility criteria, phase, last_updated | +| Diagnosis | ~130 | ICD-10 codes across 10 oncology conditions | +| Biomarker | 20 | HER2+/−, EGFR, ALK, BRCA1/2, MSI-H, FLT3, etc. | +| Medication | 16 | Trastuzumab, Pembrolizumab, Olaparib, etc. | +| StudySite | ~200 | lat/lon coordinates | +| **ELIGIBLE_FOR edges** | **~9,100** | score, linking patients to trials | + +The graph grows passively — every Trial Finder search automatically upserts new Trial and StudySite nodes. Every Eligibility Check submission (with "Save to graph" enabled) adds a new Patient node with biomarker edges. + +--- + +## Clinical Eligibility Check (SI Units) + +The `/intake` page accepts raw clinical data — no patient ID or account required. Fields: + +**Demographics:** Age (years), Sex, ECOG performance status (0–4), Disease stage (I–IV) + +**Biomarker status (toggles):** +- Breast/Gynecologic: HER2+/−, ER+, PR+, BRCA1/2 mutation, Triple-Negative +- Lung (NSCLC): EGFR mutation, ALK, ROS1 rearrangement, PD-L1 +- GI/Colorectal: MSI-High, KRAS wild-type, BRAF V600E +- Hematology: FLT3, IDH1/2, BCR-ABL + +**Lab values (SI units):** + +| Field | Unit | Conversion | +|---|---|---| +| Haemoglobin | g/dL | — | +| WBC | ×10⁹/L | — | +| ANC | ×10⁹/L | — | +| Platelets | ×10⁹/L | — | +| Creatinine | **μmol/L** | auto-converted ÷88.4 → mg/dL for trial text | +| eGFR | mL/min/1.73m² | — | +| Bilirubin | **μmol/L** | auto-converted ÷17.1 → mg/dL for trial text | +| ALT / AST | U/L | — | + +Matching score breakdown: +- **Age** 25 pts — compared against trial min/max age +- **Sex** 15 pts — compared against trial sex restriction +- **ECOG** 15 pts — extracted via regex from eligibility criteria text +- **Biomarkers** 30 pts — checks whether biomarker terms appear in trial eligibility text +- **Lab values** 15 pts — parses thresholds from text, converts SI units, checks patient values + +Results are ranked by score with pass/fail/uncertain per criterion and direct ClinicalTrials.gov links. + +--- + +## Running Locally (no Docker) + +```bash +# 1. Start Neo4j +docker run -d --name neo4j -p 7474:7474 -p 7687:7687 -e NEO4J_AUTH=neo4j/clinicalmatch2024 neo4j:5.18-community + +# 2. Backend +cd backend +python -m venv venv && source venv/bin/activate && pip install -r requirements.txt +cp ../.env.example ../.env.local # fill in credentials +uvicorn main:app --reload --port 8000 + +# 3. Schema setup (once) +curl -X POST http://localhost:8000/setup + +# 4. Seed graph data from live APIs (~15 min, ~250 real trials + 500 patients) +curl -X POST http://localhost:8000/seed + +# 5. Frontend +cd frontend +npm install --legacy-peer-deps +npm run dev # http://localhost:3000 (uses --webpack, not Turbopack) + +# 6. MCP server (for Prompt Opinion integration) +cd backend +python mcp_server.py +``` + +--- + +## Running with Docker Compose + +```bash +cp .env.example .env.local # fill in OPENAI_API_KEY etc. +docker compose up -d + +# Wait ~60s for Neo4j to be healthy, then: +curl -X POST http://localhost:7860/setup +curl -X POST http://localhost:7860/seed +``` + +Services: app → http://localhost:7860 | API docs → http://localhost:7860/api/docs | Neo4j → http://localhost:7474 + +--- + +## Deploying to HuggingFace Spaces + +1. Create a Space → **Docker SDK** → blank template +2. Push repo to the Space: + ```bash + git remote add hf https://huggingface.co/spaces// + git push hf main + ``` +3. Set **Repository Secrets**: + ``` + OPENAI_API_KEY = + OPENAI_BASE_URL = https://ai.aimlapi.com/v1 + OPENAI_MODEL = claude-opus-4-7 + NEO4J_PASSWORD = clinicalmatch2024 + ``` +4. After first boot, seed data: + ``` + POST https://.hf.space/seed + ``` + +--- + +## MCP Tools (Prompt Opinion integration) + +```bash +python backend/mcp_server.py # stdio transport +``` + +| Tool | Arguments | Description | +|---|---|---| +| `find_trials` | `condition, phase?` | Real-time trial search | +| `screen_patient` | `patient_id, nct_id` | Eligibility screening | +| `match_patient_to_trials` | `patient_id` | Top-N trial matches | +| `generate_recruitment_outreach` | `patient_id, nct_id, channel` | Personalized outreach | +| `get_trial_analytics` | — | Enrollment funnel + KPIs | +| `summarize_trial_protocol` | `nct_id` | AI-parsed protocol summary | + +--- + +## Key API Endpoints + +| Method | Path | Description | +|---|---|---| +| POST | `/api/v1/intake/match` | SI-unit intake → ranked trial matches | +| GET | `/api/v1/intake/biomarkers` | Biomarker registry | +| GET | `/api/v1/trials/search` | Real-time CT.gov search (recency-sorted, graph-enriched) | +| GET | `/api/v1/trials/{nct_id}/intelligence` | Graph intelligence per trial | +| GET | `/api/v1/graph/patients` | Query seeded patient IDs from Neo4j | +| POST | `/api/v1/patients/{id}/screen/{nct_id}` | Screen FHIR patient against trial | +| POST | `/api/v1/workflow/run` | Run full A2A pipeline | +| GET | `/api/v1/analytics/kpi` | Dashboard KPIs | +| GET | `/api/v1/map/data` | Site coordinates + patient clusters | +| POST | `/api/v1/graph/query` | GraphRAG natural language query | +| POST | `/seed` | Seed full graph from live APIs | +| GET | `/api/v1/graph/stats` | Node and edge counts | + +Full interactive docs: `http://localhost:8000/docs` + +--- + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `NEO4J_URI` | Neo4j bolt URI | `bolt://localhost:7687` | +| `NEO4J_USERNAME` | Neo4j username | `neo4j` | +| `NEO4J_PASSWORD` | Neo4j password | `clinicalmatch2024` | +| `NEO4J_DATABASE` | Database name | `neo4j` | +| `OPENAI_API_KEY` | aimlapi.com API key | — | +| `OPENAI_BASE_URL` | LLM base URL | `https://ai.aimlapi.com/v1` | +| `OPENAI_MODEL` | Model identifier | `claude-opus-4-7` | +| `NEXT_PUBLIC_API_URL` | Frontend API base URL | `""` (relative, via Nginx) | + +--- + +## Frontend Pages + +| Route | Page | Description | +|---|---|---| +| `/` | Trial Finder | Real-time CT.gov search, recency-sorted, graph intelligence on expand | +| `/intake` | Eligibility Check | SI-unit clinical intake form, no patient ID required | +| `/screening` | Patient Screening | FHIR patient + trial combobox, A2A pipeline with state tracker | +| `/recruitment` | Recruitment Hub | Kanban board, AI outreach generation (PCP / email / social) | +| `/dashboard` | Dashboard | KPI cards, enrollment funnel, demographics, site performance | +| `/map` | Site Map | Leaflet map of trial sites and patient density clusters | +| `/graph` | GraphRAG | Natural language queries over the knowledge graph | diff --git a/backend/a2a_workflow.py b/backend/a2a_workflow.py new file mode 100644 index 0000000000000000000000000000000000000000..c134659e2aeb42dfeb3bf8cec42eb3ea05a71afe --- /dev/null +++ b/backend/a2a_workflow.py @@ -0,0 +1,315 @@ +"""A2A (Agent-to-Agent) orchestration workflow — state machine for the recruitment pipeline. + +Every inter-agent message carries a SHARP Extension Spec context envelope: + sharp_version, patient_context (id, fhir_ref, fhir_base, tenant_id, session_id), + data_classification, baa_in_scope, consent_status +""" +import uuid +import time +from datetime import datetime +from enum import Enum +from typing import Any +from fhir_adapter import get_patient_profile, get_mock_fhir_patient, build_patient_profile +from clinicaltrials_api import search_trials_sync, get_trial_details_sync +from matching_engine import get_criteria_for_trial, score_patient_for_trial, match_patient_to_trials +from llm_client import generate_outreach_message, summarize_trial +from fhir_server import build_sharp_context, get_live_patient_profile +import consent_agent + + +class WorkflowState(str, Enum): + PENDING = "PENDING" + INGESTING = "INGESTING" + PARSING_PROTOCOL = "PARSING_PROTOCOL" + MATCHING = "MATCHING" + SCORING = "SCORING" + RECRUITING = "RECRUITING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + + +# In-memory workflow store (production: use Redis or Neo4j) +_workflows: dict[str, dict] = {} + + +def _emit_event(workflow_id: str, state: WorkflowState, message: str, data: Any = None): + workflow = _workflows[workflow_id] + event = { + "state": state, + "message": message, + "timestamp": datetime.utcnow().isoformat(), + "data": data, + # SHARP envelope on every event so downstream agents have full context + "sharp_context": workflow.get("sharp_context", {}), + } + workflow["events"].append(event) + workflow["current_state"] = state + workflow["updated_at"] = datetime.utcnow().isoformat() + print(f"[A2A:{workflow_id[:8]}] {state} — {message}") + + +# ── Sub-agents ──────────────────────────────────────────────────────────────── + +def _agent_ingest_patient(workflow_id: str, patient_id: str) -> dict: + """Sub-agent: Ingest and validate patient FHIR data.""" + _emit_event(workflow_id, WorkflowState.INGESTING, f"Ingesting FHIR R4 data for patient {patient_id}") + time.sleep(0.3) # Simulate async data fetch + + fhir_patient = get_mock_fhir_patient(patient_id) + if not fhir_patient: + raise ValueError(f"Patient {patient_id} not found in FHIR registry") + + profile = build_patient_profile(fhir_patient) + _emit_event(workflow_id, WorkflowState.INGESTING, + f"FHIR data loaded: {len(fhir_patient.conditions)} conditions, {len(fhir_patient.medications)} medications", + {"profile": profile}) + return profile + + +def _agent_parse_protocol(workflow_id: str, nct_id: str | None, condition: str) -> tuple[list[dict], dict]: + """Sub-agent: Parse trial protocol and extract criteria.""" + _emit_event(workflow_id, WorkflowState.PARSING_PROTOCOL, + f"Parsing trial protocols for condition: {condition}") + time.sleep(0.5) + + if nct_id: + trials = [get_trial_details_sync(nct_id)] + trials = [t for t in trials if t] + else: + trials = search_trials_sync(condition, page_size=8) + + if not trials: + raise ValueError(f"No trials found for condition: {condition}") + + # Parse criteria for each trial using LLM + parsed_trials = [] + for trial in trials[:5]: # Limit to avoid timeout + criteria = get_criteria_for_trial(trial) + parsed_trials.append({**trial, "parsed_criteria": criteria}) + + summary = summarize_trial(trials[0]) if trials else "" + _emit_event(workflow_id, WorkflowState.PARSING_PROTOCOL, + f"Parsed {len(parsed_trials)} trial protocols", + {"trial_count": len(parsed_trials), "protocol_summary": summary}) + return parsed_trials, {"summary": summary} + + +def _agent_match(workflow_id: str, patient_profile: dict, trials: list[dict]) -> list[dict]: + """Sub-agent: Semantic matching of patient to trials.""" + _emit_event(workflow_id, WorkflowState.MATCHING, + f"Running semantic matching for patient {patient_profile['patient_id']} against {len(trials)} trials") + time.sleep(0.3) + + candidates = [] + for trial in trials: + score_result = score_patient_for_trial(patient_profile["patient_id"], trial) + candidates.append({ + **trial, + "match_score": score_result.get("overall_score", 0.0), + "eligible": score_result.get("eligible", False), + "inclusion_results": score_result.get("inclusion_results", []), + "exclusion_results": score_result.get("exclusion_results", []), + "match_summary": score_result.get("summary", ""), + "risk_flags": score_result.get("risk_flags", []), + }) + + candidates.sort(key=lambda x: x["match_score"], reverse=True) + eligible = [c for c in candidates if c["eligible"]] + _emit_event(workflow_id, WorkflowState.MATCHING, + f"Matching complete: {len(eligible)}/{len(candidates)} trials eligible", + {"eligible_count": len(eligible), "top_score": candidates[0]["match_score"] if candidates else 0}) + return candidates + + +def _agent_score(workflow_id: str, candidates: list[dict], patient_profile: dict) -> list[dict]: + """Sub-agent: Predictive screening scoring with risk flags.""" + _emit_event(workflow_id, WorkflowState.SCORING, "Running predictive screening analysis") + time.sleep(0.2) + + for candidate in candidates: + flags = candidate.get("risk_flags", []) + # Add distance risk flag if no nearby sites + locs = candidate.get("locations", []) + if not locs: + flags.append("No site location data available") + # Add data completeness flag + if not patient_profile.get("biomarkers"): + flags.append("Biomarker data incomplete — may affect screening") + candidate["risk_flags"] = flags + candidate["screening_priority"] = ( + "HIGH" if candidate["match_score"] >= 0.8 + else "MEDIUM" if candidate["match_score"] >= 0.5 + else "LOW" + ) + + _emit_event(workflow_id, WorkflowState.SCORING, + "Screening scoring complete", + {"high_priority": sum(1 for c in candidates if c.get("screening_priority") == "HIGH")}) + return candidates + + +def _agent_recruit(workflow_id: str, candidates: list[dict], patient_profile: dict) -> list[dict]: + """Sub-agent: Generate recruitment outreach for eligible candidates.""" + _emit_event(workflow_id, WorkflowState.RECRUITING, "Generating personalized recruitment communications") + + eligible = [c for c in candidates if c.get("eligible")][:3] + recruitment_records = [] + + for trial in eligible: + try: + outreach = generate_outreach_message(patient_profile, trial, "patient_email") + pcp_letter = generate_outreach_message(patient_profile, trial, "pcp_letter") + + # A2A handoff → consent agent (SHARP envelope attached) + consent_task = { + "task_id": f"consent_{workflow_id}_{trial.get('nct_id','')}", + "type": "CONSENT_REQUEST", + "payload": { + "patient_id": patient_profile.get("patient_id", ""), + "nct_id": trial.get("nct_id", ""), + "trial_title": trial.get("title", ""), + "match_score": trial.get("match_score", 0.0), + }, + "sharp_context": _workflows[workflow_id].get("sharp_context", {}), + } + consent_result = consent_agent.receive_a2a_task(consent_task) + + recruitment_records.append({ + "nct_id": trial.get("nct_id", ""), + "trial_title": trial.get("title", ""), + "match_score": trial.get("match_score", 0.0), + "patient_email": outreach, + "pcp_letter": pcp_letter, + "status": "PENDING", + "consent_id": consent_result.get("consent_id"), + "consent_status": consent_result.get("status", "PENDING"), + "created_at": datetime.utcnow().isoformat(), + }) + except Exception as e: + recruitment_records.append({ + "nct_id": trial.get("nct_id", ""), + "trial_title": trial.get("title", ""), + "error": str(e), + "status": "ERROR", + }) + + _emit_event(workflow_id, WorkflowState.RECRUITING, + f"Generated outreach for {len(recruitment_records)} trials", + {"record_count": len(recruitment_records)}) + return recruitment_records + + +# ── Public API ───────────────────────────────────────────────────────────────── + +def start_pipeline( + patient_id: str, + nct_id: str | None = None, + condition: str | None = None, + fhir_token: str | None = None, + fhir_base_url: str | None = None, + session_id: str | None = None, +) -> str: + """Start the A2A pipeline and return a workflow_id.""" + workflow_id = str(uuid.uuid4()) + sharp_ctx = build_sharp_context( + patient_id=patient_id, + fhir_ref=f"Patient/{patient_id}", + session_id=session_id or workflow_id, + ) + if fhir_token: + sharp_ctx["fhir_token"] = fhir_token + if fhir_base_url: + sharp_ctx["patient_context"]["fhir_base"] = fhir_base_url + + _workflows[workflow_id] = { + "workflow_id": workflow_id, + "patient_id": patient_id, + "nct_id": nct_id, + "condition": condition, + "current_state": WorkflowState.PENDING, + "events": [], + "result": None, + "sharp_context": sharp_ctx, + "created_at": datetime.utcnow().isoformat(), + "updated_at": datetime.utcnow().isoformat(), + } + return workflow_id + + +def run_pipeline(workflow_id: str) -> dict: + """Execute the full A2A pipeline synchronously.""" + workflow = _workflows.get(workflow_id) + if not workflow: + raise ValueError(f"Workflow {workflow_id} not found") + + patient_id = workflow["patient_id"] + nct_id = workflow.get("nct_id") + condition = workflow.get("condition") + + try: + # Agent 1: Ingest FHIR patient data + patient_profile = _agent_ingest_patient(workflow_id, patient_id) + + # Infer condition + if not condition and patient_profile.get("diagnosis_names"): + condition = patient_profile["diagnosis_names"][0] + elif not condition: + condition = "cancer" + + # Agent 2: Parse trial protocols + trials, protocol_meta = _agent_parse_protocol(workflow_id, nct_id, condition) + + # Agent 3: Semantic matching + candidates = _agent_match(workflow_id, patient_profile, trials) + + # Agent 4: Predictive scoring + candidates = _agent_score(workflow_id, candidates, patient_profile) + + # Agent 5: Recruitment communication + recruitment_records = _agent_recruit(workflow_id, candidates, patient_profile) + + result = { + "patient_profile": patient_profile, + "matched_trials": candidates, + "recruitment_records": recruitment_records, + "protocol_summary": protocol_meta.get("summary", ""), + "total_trials_evaluated": len(trials), + "eligible_trials": sum(1 for c in candidates if c.get("eligible")), + } + + workflow["result"] = result + _emit_event(workflow_id, WorkflowState.COMPLETED, + f"Pipeline complete: {result['eligible_trials']} eligible trials found", result) + + except Exception as e: + _emit_event(workflow_id, WorkflowState.FAILED, f"Pipeline failed: {str(e)}") + workflow["error"] = str(e) + + return _workflows[workflow_id] + + +def get_workflow_status(workflow_id: str) -> dict: + workflow = _workflows.get(workflow_id) + if not workflow: + return {"error": "Workflow not found"} + return { + "workflow_id": workflow_id, + "current_state": workflow["current_state"], + "events": workflow["events"][-10:], # Last 10 events + "result": workflow.get("result"), + "error": workflow.get("error"), + "created_at": workflow["created_at"], + "updated_at": workflow["updated_at"], + } + + +def list_workflows() -> list[dict]: + return [ + { + "workflow_id": wf["workflow_id"], + "patient_id": wf["patient_id"], + "current_state": wf["current_state"], + "created_at": wf["created_at"], + } + for wf in _workflows.values() + ] diff --git a/backend/analytics.py b/backend/analytics.py new file mode 100644 index 0000000000000000000000000000000000000000..05810f969f9c72b6eb8e9c249649d30092a27838 --- /dev/null +++ b/backend/analytics.py @@ -0,0 +1,111 @@ +"""Analytics and dashboard data aggregation.""" +import random +from datetime import datetime, timedelta +from fhir_adapter import get_all_patient_ids, get_patient_profile +from clinicaltrials_api import search_trials_sync + + +STUDY_SITES = [ + {"name": "Dana-Farber Cancer Institute", "city": "Boston", "state": "MA", "lat": 42.3376, "lon": -71.1083, "trials": 4, "enrolled": 87, "capacity": 120}, + {"name": "MD Anderson Cancer Center", "city": "Houston", "state": "TX", "lat": 29.7066, "lon": -95.3990, "trials": 6, "enrolled": 142, "capacity": 200}, + {"name": "Memorial Sloan Kettering", "city": "New York", "state": "NY", "lat": 40.7644, "lon": -73.9581, "trials": 5, "enrolled": 113, "capacity": 150}, + {"name": "UCSF Medical Center", "city": "San Francisco", "state": "CA", "lat": 37.7631, "lon": -122.4578, "trials": 3, "enrolled": 67, "capacity": 90}, + {"name": "Northwestern Medicine", "city": "Chicago", "state": "IL", "lat": 41.8827, "lon": -87.6233, "trials": 4, "enrolled": 94, "capacity": 130}, + {"name": "Mayo Clinic", "city": "Rochester", "state": "MN", "lat": 44.0225, "lon": -92.4664, "trials": 7, "enrolled": 178, "capacity": 220}, + {"name": "Johns Hopkins Hospital", "city": "Baltimore", "state": "MD", "lat": 39.2963, "lon": -76.5927, "trials": 5, "enrolled": 105, "capacity": 160}, + {"name": "Cleveland Clinic", "city": "Cleveland", "state": "OH", "lat": 41.5022, "lon": -81.6220, "trials": 3, "enrolled": 72, "capacity": 100}, +] + + +def get_kpi_summary() -> dict: + patient_ids = get_all_patient_ids() + return { + "active_trials": 23, + "patients_identified": len(patient_ids) * 12, + "patients_screened": len(patient_ids) * 8, + "patients_enrolled": len(patient_ids) * 3, + "enrollment_rate": 0.37, + "avg_days_to_match": 4.2, + "sites_active": len(STUDY_SITES), + "cost_saved_usd": 284000, + } + + +def get_enrollment_funnel(trial_id: str | None = None) -> list[dict]: + """Return enrollment funnel data for Recharts BarChart.""" + base = random.randint(80, 150) if trial_id else 500 + return [ + {"stage": "Identified", "count": base, "fill": "#6366f1"}, + {"stage": "Pre-Screened", "count": int(base * 0.72), "fill": "#8b5cf6"}, + {"stage": "Contacted", "count": int(base * 0.55), "fill": "#a78bfa"}, + {"stage": "Consented", "count": int(base * 0.38), "fill": "#c4b5fd"}, + {"stage": "Enrolled", "count": int(base * 0.22), "fill": "#ddd6fe"}, + ] + + +def get_site_performance() -> list[dict]: + return [ + { + **site, + "enrollment_rate": round(site["enrolled"] / site["capacity"], 2), + "fill_percentage": round(site["enrolled"] / site["capacity"] * 100, 1), + } + for site in STUDY_SITES + ] + + +def get_patient_demographics(trial_id: str | None = None) -> dict: + return { + "age_distribution": [ + {"range": "18-30", "count": 12, "percentage": 8}, + {"range": "31-45", "count": 28, "percentage": 19}, + {"range": "46-60", "count": 54, "percentage": 36}, + {"range": "61-75", "count": 42, "percentage": 28}, + {"range": "75+", "count": 14, "percentage": 9}, + ], + "gender_distribution": [ + {"name": "Female", "value": 58, "fill": "#f472b6"}, + {"name": "Male", "value": 39, "fill": "#60a5fa"}, + {"name": "Other", "value": 3, "fill": "#a3e635"}, + ], + "ethnicity_distribution": [ + {"name": "White", "value": 52, "fill": "#6366f1"}, + {"name": "Black/African American", "value": 18, "fill": "#8b5cf6"}, + {"name": "Hispanic/Latino", "value": 15, "fill": "#ec4899"}, + {"name": "Asian", "value": 11, "fill": "#14b8a6"}, + {"name": "Other/Unknown", "value": 4, "fill": "#f59e0b"}, + ], + } + + +def get_recruitment_timeline(days: int = 30) -> list[dict]: + """Daily enrollment progress for timeline chart.""" + base_date = datetime.now() - timedelta(days=days) + timeline = [] + cumulative = 0 + for i in range(days): + daily = random.randint(1, 8) + cumulative += daily + timeline.append({ + "date": (base_date + timedelta(days=i)).strftime("%Y-%m-%d"), + "daily_enrolled": daily, + "cumulative_enrolled": cumulative, + "target": int((i + 1) / days * 150), + }) + return timeline + + +def get_map_data() -> dict: + return { + "sites": STUDY_SITES, + "patient_clusters": [ + {"lat": 42.36, "lon": -71.06, "count": 24, "city": "Boston Metro"}, + {"lat": 40.71, "lon": -74.01, "count": 38, "city": "New York Metro"}, + {"lat": 29.76, "lon": -95.37, "count": 19, "city": "Houston Metro"}, + {"lat": 37.77, "lon": -122.42, "count": 16, "city": "San Francisco Bay"}, + {"lat": 41.88, "lon": -87.63, "count": 27, "city": "Chicago Metro"}, + {"lat": 34.05, "lon": -118.24, "count": 31, "city": "Los Angeles Metro"}, + {"lat": 33.45, "lon": -112.07, "count": 13, "city": "Phoenix Metro"}, + {"lat": 47.61, "lon": -122.33, "count": 11, "city": "Seattle Metro"}, + ], + } diff --git a/backend/clinicaltrials_api.py b/backend/clinicaltrials_api.py new file mode 100644 index 0000000000000000000000000000000000000000..7d97ca9a9927b7d3c615cec9612ad4196408ada3 --- /dev/null +++ b/backend/clinicaltrials_api.py @@ -0,0 +1,170 @@ +import httpx +import asyncio +from typing import Optional +import os + +CTGOV_BASE = "https://clinicaltrials.gov/api/v2/studies" + +async def search_trials(condition: str, phase: Optional[str] = None, status: str = "RECRUITING", page_size: int = 20) -> list[dict]: + params = { + "query.cond": condition, + "filter.overallStatus": status, + "pageSize": page_size, + "format": "json", + "sort": "LastUpdatePostDate:desc", + } + if phase: + params["filter.phase"] = f"PHASE{phase.replace('Phase ', '').replace('I', '1').replace('II', '2').replace('III', '3').replace('IV', '4')}" + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + resp = await client.get(CTGOV_BASE, params=params) + resp.raise_for_status() + data = resp.json() + studies = data.get("studies", []) + return [_normalize_study(s) for s in studies] + except Exception as e: + print(f"ClinicalTrials.gov API error: {e}") + return _fallback_trials(condition) + +async def get_trial_details(nct_id: str) -> dict: + params = {"query.id": nct_id, "format": "json"} + async with httpx.AsyncClient(timeout=30.0) as client: + try: + resp = await client.get(CTGOV_BASE, params=params) + resp.raise_for_status() + data = resp.json() + studies = data.get("studies", []) + if studies: + return _normalize_study(studies[0]) + except Exception as e: + print(f"ClinicalTrials.gov detail error: {e}") + return {} + +def _normalize_study(study: dict) -> dict: + proto = study.get("protocolSection", {}) + ident = proto.get("identificationModule", {}) + status_module = proto.get("statusModule", {}) + desc = proto.get("descriptionModule", {}) + eligibility = proto.get("eligibilityModule", {}) + design = proto.get("designModule", {}) + contacts = proto.get("contactsLocationsModule", {}) + sponsor = proto.get("sponsorCollaboratorsModule", {}) + outcomes = proto.get("outcomesModule", {}) + + locations = [] + for loc in contacts.get("locations", [])[:5]: + locations.append({ + "city": loc.get("city", ""), + "state": loc.get("state", ""), + "country": loc.get("country", "US"), + "facility": loc.get("facility", ""), + "lat": loc.get("geoPoint", {}).get("lat"), + "lon": loc.get("geoPoint", {}).get("lon"), + }) + + phases = design.get("phases", []) + return { + "nct_id": ident.get("nctId", ""), + "title": ident.get("briefTitle", ""), + "status": status_module.get("overallStatus", ""), + "phase": phases[0] if phases else "N/A", + "brief_summary": desc.get("briefSummary", ""), + "eligibility_criteria": eligibility.get("eligibilityCriteria", ""), + "min_age": eligibility.get("minimumAge", ""), + "max_age": eligibility.get("maximumAge", ""), + "sex": eligibility.get("sex", "ALL"), + "enrollment": design.get("enrollmentInfo", {}).get("count", 0), + "start_date": status_module.get("startDateStruct", {}).get("date", ""), + "completion_date": status_module.get("completionDateStruct", {}).get("date", ""), + "last_updated": status_module.get("lastUpdatePostDateStruct", {}).get("date", ""), + "sponsor": sponsor.get("leadSponsor", {}).get("name", ""), + "primary_outcomes": [o.get("measure", "") for o in outcomes.get("primaryOutcomes", [])[:3]], + "locations": locations, + "location_count": len(contacts.get("locations", [])), + "ctgov_url": f"https://clinicaltrials.gov/study/{ident.get('nctId', '')}", + } + +def _fallback_trials(condition: str) -> list[dict]: + """Realistic fallback when API is unavailable.""" + return [ + { + "nct_id": "NCT04889131", + "title": f"Precision Medicine Study for {condition}", + "status": "RECRUITING", + "phase": "PHASE2", + "brief_summary": f"A randomized controlled trial evaluating targeted therapy for {condition} in adult patients.", + "eligibility_criteria": "Inclusion Criteria:\n- Age 18-75\n- Confirmed diagnosis\n- ECOG performance status 0-2\nExclusion Criteria:\n- Prior treatment failure\n- Active autoimmune disease", + "min_age": "18 Years", + "max_age": "75 Years", + "sex": "ALL", + "enrollment": 150, + "start_date": "2024-01", + "completion_date": "2026-06", + "sponsor": "Academic Medical Center", + "primary_outcomes": ["Overall Survival", "Progression-Free Survival"], + "locations": [ + {"city": "Boston", "state": "MA", "country": "US", "facility": "Dana-Farber Cancer Institute", "lat": 42.3376, "lon": -71.1083}, + {"city": "Houston", "state": "TX", "country": "US", "facility": "MD Anderson Cancer Center", "lat": 29.7066, "lon": -95.3990}, + ], + "location_count": 2, + }, + { + "nct_id": "NCT05123456", + "title": f"Immunotherapy Combination for Advanced {condition}", + "status": "RECRUITING", + "phase": "PHASE3", + "brief_summary": f"Phase III trial of combination immunotherapy in patients with advanced {condition}.", + "eligibility_criteria": "Inclusion Criteria:\n- Age ≥ 18\n- Histologically confirmed diagnosis\n- Measurable disease per RECIST 1.1\nExclusion Criteria:\n- Brain metastases\n- Prior PD-1/PD-L1 therapy", + "min_age": "18 Years", + "max_age": "N/A", + "sex": "ALL", + "enrollment": 400, + "start_date": "2023-06", + "completion_date": "2027-12", + "sponsor": "Pharma Innovations Inc", + "primary_outcomes": ["Overall Survival at 24 months"], + "locations": [ + {"city": "New York", "state": "NY", "country": "US", "facility": "Memorial Sloan Kettering", "lat": 40.7644, "lon": -73.9581}, + {"city": "San Francisco", "state": "CA", "country": "US", "facility": "UCSF Medical Center", "lat": 37.7631, "lon": -122.4578}, + {"city": "Chicago", "state": "IL", "country": "US", "facility": "Northwestern Medicine", "lat": 41.8827, "lon": -87.6233}, + ], + "location_count": 3, + }, + ] + +def search_trials_sync(condition: str, phase: Optional[str] = None, status: str = "RECRUITING", page_size: int = 20) -> list[dict]: + """Synchronous version using httpx.Client — safe to call from any context.""" + params = { + "query.cond": condition, + "filter.overallStatus": status, + "pageSize": page_size, + "format": "json", + "sort": "LastUpdatePostDate:desc", + } + if phase: + params["filter.phase"] = f"PHASE{phase.replace('Phase ', '').replace('I', '1').replace('II', '2').replace('III', '3').replace('IV', '4')}" + with httpx.Client(timeout=30.0) as client: + try: + resp = client.get(CTGOV_BASE, params=params) + resp.raise_for_status() + data = resp.json() + return [_normalize_study(s) for s in data.get("studies", [])] + except Exception as e: + print(f"ClinicalTrials.gov API error (sync): {e}") + return _fallback_trials(condition) + +def get_trial_details_sync(nct_id: str) -> dict: + """Synchronous version using httpx.Client — safe to call from any context.""" + params = {"query.id": nct_id, "format": "json"} + with httpx.Client(timeout=30.0) as client: + try: + resp = client.get(CTGOV_BASE, params=params) + resp.raise_for_status() + data = resp.json() + studies = data.get("studies", []) + if studies: + return _normalize_study(studies[0]) + except Exception as e: + print(f"ClinicalTrials.gov detail error (sync): {e}") + return {} diff --git a/backend/consent_agent.py b/backend/consent_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..740a763c696b472c91c4b6bd16ede5ae8ca5143e --- /dev/null +++ b/backend/consent_agent.py @@ -0,0 +1,207 @@ +""" +Consent & Scheduling Agent — A2A sub-agent that handles post-recruitment consent +workflow and appointment scheduling. Triggered as a handoff from the Recruitment Agent. + +A2A task message format follows the Google A2A spec: + {"task_id": str, "type": "CONSENT_REQUEST" | "SCHEDULE_REQUEST", "payload": {...}} +""" +import uuid +from datetime import datetime, timedelta +from typing import Optional +from llm_client import chat + +# In-memory consent + scheduling store (production: Neo4j or Redis) +_consent_records: dict[str, dict] = {} +_schedule_records: dict[str, dict] = {} + + +# ── Consent status values ────────────────────────────────────────────────────── + +CONSENT_PENDING = "PENDING" +CONSENT_SENT = "SENT" +CONSENT_SIGNED = "SIGNED" +CONSENT_DECLINED = "DECLINED" +CONSENT_EXPIRED = "EXPIRED" + + +# ── A2A task receiver ────────────────────────────────────────────────────────── + +def receive_a2a_task(task: dict) -> dict: + """ + Entry point for A2A inter-agent handoffs. + Accepts tasks from the Recruitment Agent and routes to consent or scheduling flows. + """ + task_type = task.get("type", "") + payload = task.get("payload", {}) + task_id = task.get("task_id", str(uuid.uuid4())) + + if task_type == "CONSENT_REQUEST": + return initiate_consent( + patient_id=payload["patient_id"], + nct_id=payload["nct_id"], + trial_title=payload.get("trial_title", ""), + match_score=payload.get("match_score", 0.0), + task_id=task_id, + ) + elif task_type == "SCHEDULE_REQUEST": + return schedule_screening( + patient_id=payload["patient_id"], + nct_id=payload["nct_id"], + site_city=payload.get("site_city", ""), + site_state=payload.get("site_state", ""), + task_id=task_id, + ) + else: + return {"error": "UNKNOWN_TASK_TYPE", "task_id": task_id, "received_type": task_type} + + +# ── Consent flow ─────────────────────────────────────────────────────────────── + +def initiate_consent( + patient_id: str, + nct_id: str, + trial_title: str, + match_score: float = 0.0, + task_id: str | None = None, +) -> dict: + """Create a consent record and generate the consent document.""" + record_id = task_id or str(uuid.uuid4()) + expires_at = (datetime.utcnow() + timedelta(days=30)).isoformat() + + consent_doc = _generate_consent_document(patient_id, nct_id, trial_title) + + record = { + "consent_id": record_id, + "patient_id": patient_id, + "nct_id": nct_id, + "trial_title": trial_title, + "match_score": match_score, + "status": CONSENT_SENT, + "consent_document": consent_doc, + "created_at": datetime.utcnow().isoformat(), + "expires_at": expires_at, + "signed_at": None, + "a2a_source": "recruitment_agent", + } + _consent_records[record_id] = record + return {"consent_id": record_id, "status": CONSENT_SENT, "expires_at": expires_at} + + +def update_consent_status(consent_id: str, status: str, notes: str = "") -> dict: + record = _consent_records.get(consent_id) + if not record: + return {"error": "CONSENT_NOT_FOUND", "consent_id": consent_id} + record["status"] = status + if status == CONSENT_SIGNED: + record["signed_at"] = datetime.utcnow().isoformat() + if notes: + record["notes"] = notes + # If consent signed, auto-trigger scheduling handoff + if status == CONSENT_SIGNED: + _trigger_scheduling_handoff(record) + return record + + +def get_consent_record(consent_id: str) -> dict | None: + return _consent_records.get(consent_id) + + +def list_consent_records(patient_id: str | None = None) -> list[dict]: + records = list(_consent_records.values()) + if patient_id: + records = [r for r in records if r["patient_id"] == patient_id] + return sorted(records, key=lambda r: r["created_at"], reverse=True) + + +# ── Scheduling flow ──────────────────────────────────────────────────────────── + +def schedule_screening( + patient_id: str, + nct_id: str, + site_city: str = "", + site_state: str = "", + task_id: str | None = None, +) -> dict: + """Create a screening appointment slot.""" + appt_id = task_id or str(uuid.uuid4()) + # Default slot: next business weekday at 10am + proposed_dt = _next_business_day() + + appt = { + "appointment_id": appt_id, + "patient_id": patient_id, + "nct_id": nct_id, + "site_city": site_city, + "site_state": site_state, + "proposed_datetime": proposed_dt, + "status": "PROPOSED", + "created_at": datetime.utcnow().isoformat(), + "a2a_source": "consent_agent", + } + _schedule_records[appt_id] = appt + return {"appointment_id": appt_id, "proposed_datetime": proposed_dt, "status": "PROPOSED"} + + +def confirm_appointment(appt_id: str) -> dict: + appt = _schedule_records.get(appt_id) + if not appt: + return {"error": "APPOINTMENT_NOT_FOUND"} + appt["status"] = "CONFIRMED" + appt["confirmed_at"] = datetime.utcnow().isoformat() + return appt + + +def list_appointments(patient_id: str | None = None) -> list[dict]: + appts = list(_schedule_records.values()) + if patient_id: + appts = [a for a in appts if a["patient_id"] == patient_id] + return sorted(appts, key=lambda a: a["created_at"], reverse=True) + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def _trigger_scheduling_handoff(consent_record: dict): + """Auto-schedule after consent signed — A2A internal handoff.""" + schedule_screening( + patient_id=consent_record["patient_id"], + nct_id=consent_record["nct_id"], + task_id=f"sched_{consent_record['consent_id']}", + ) + + +def _next_business_day() -> str: + dt = datetime.utcnow() + timedelta(days=3) + while dt.weekday() >= 5: # skip Sat/Sun + dt += timedelta(days=1) + return dt.replace(hour=10, minute=0, second=0, microsecond=0).isoformat() + "Z" + + +def _generate_consent_document(patient_id: str, nct_id: str, trial_title: str) -> str: + prompt = f"""Generate a concise, plain-language informed consent document (ICF) for clinical trial participation. + +Trial: {trial_title} +NCT ID: {nct_id} +Patient ID: {patient_id} + +The document should cover in 4 short sections: +1. What this study is about (2-3 sentences) +2. What you will be asked to do (bullet points) +3. Possible risks and benefits (bullet points) +4. Your rights as a participant (2-3 sentences) + +Use plain language (8th grade reading level). End with a signature block.""" + try: + return chat([{"role": "user", "content": prompt}], temperature=0.3, max_tokens=600) + except Exception: + return f"Informed Consent Document\nTrial: {trial_title} ({nct_id})\n\nPlease review this document carefully before signing." + + +def get_consent_stats() -> dict: + all_records = list(_consent_records.values()) + return { + "total": len(all_records), + "sent": sum(1 for r in all_records if r["status"] == CONSENT_SENT), + "signed": sum(1 for r in all_records if r["status"] == CONSENT_SIGNED), + "declined": sum(1 for r in all_records if r["status"] == CONSENT_DECLINED), + "appointments_scheduled": len(_schedule_records), + } diff --git a/backend/data_ingestion.py b/backend/data_ingestion.py new file mode 100644 index 0000000000000000000000000000000000000000..afab2b049ada03496d4a0077b7a1d5c736a997bc --- /dev/null +++ b/backend/data_ingestion.py @@ -0,0 +1,144 @@ +from neo4j_setup import neo4j_conn + + +def ingest_sample_data(): + """Ingest rich sample data into Neo4j knowledge graph.""" + # Clear existing sample data + neo4j_conn.run_query("MATCH (n) WHERE n.sample = true DETACH DELETE n") + + queries = [ + # Patients with rich profiles + """ + MERGE (p1:Patient {id: 'P001'}) + SET p1 += {age: 45, gender: 'female', ethnicity: 'White', sample: true, + zip_code: '02115', diagnosis_date: '2022-06-01'} + """, + """ + MERGE (p2:Patient {id: 'P002'}) + SET p2 += {age: 60, gender: 'male', ethnicity: 'Black/African American', sample: true, + zip_code: '77030', diagnosis_date: '2021-11-15'} + """, + """ + MERGE (p3:Patient {id: 'P003'}) + SET p3 += {age: 38, gender: 'female', ethnicity: 'Hispanic/Latino', sample: true, + zip_code: '94102', diagnosis_date: '2023-02-10'} + """, + """ + MERGE (p4:Patient {id: 'P004'}) + SET p4 += {age: 67, gender: 'male', ethnicity: 'Asian', sample: true, + zip_code: '10001', diagnosis_date: '2022-09-20'} + """, + """ + MERGE (p5:Patient {id: 'P005'}) + SET p5 += {age: 34, gender: 'female', ethnicity: 'White', sample: true, + zip_code: '60601', diagnosis_date: '2023-07-01'} + """, + + # Diagnoses + """MERGE (d1:Diagnosis {code: 'C50'}) SET d1.name = 'Breast Cancer', d1.snomed = '254837009'""", + """MERGE (d2:Diagnosis {code: 'C61'}) SET d2.name = 'Prostate Cancer', d2.snomed = '399068003'""", + """MERGE (d3:Diagnosis {code: 'C34'}) SET d3.name = 'Non-Small Cell Lung Cancer', d3.snomed = '363346000'""", + """MERGE (d4:Diagnosis {code: 'C18'}) SET d4.name = 'Colorectal Cancer', d4.snomed = '93761005'""", + + # Biomarkers + """MERGE (b1:Biomarker {id: 'HER2_POS'}) SET b1.name = 'HER2 Positive', b1.loinc = '85319-2'""", + """MERGE (b2:Biomarker {id: 'EGFR_L858R'}) SET b2.name = 'EGFR L858R Mutation', b2.loinc = '81704-9'""", + """MERGE (b3:Biomarker {id: 'BRCA2_POS'}) SET b3.name = 'BRCA2 Mutation', b3.loinc = '85319-2'""", + """MERGE (b4:Biomarker {id: 'MSI_H'}) SET b4.name = 'MSI-High', b4.loinc = '85077-6'""", + """MERGE (b5:Biomarker {id: 'PDL1_HIGH'}) SET b5.name = 'PD-L1 High (>50%)', b5.loinc = '73977-1'""", + + # Trials + """ + MERGE (t1:Trial {id: 'NCT04889131'}) + SET t1 += {phase: 'PHASE2', condition: 'Breast Cancer', status: 'RECRUITING', + title: 'Precision HER2+ Breast Cancer Study', min_age: 18, max_age: 75, + enrollment_target: 150, enrolled: 87, sponsor: 'Dana-Farber'} + """, + """ + MERGE (t2:Trial {id: 'NCT05123456'}) + SET t2 += {phase: 'PHASE3', condition: 'Breast Cancer', status: 'RECRUITING', + title: 'Immunotherapy Combination for Advanced Breast Cancer', min_age: 18, + enrollment_target: 400, enrolled: 142, sponsor: 'Pharma Innovations Inc'} + """, + """ + MERGE (t3:Trial {id: 'NCT05456789'}) + SET t3 += {phase: 'PHASE2', condition: 'Prostate Cancer', status: 'RECRUITING', + title: 'BRCA2 Prostate Cancer PARP Inhibitor Trial', min_age: 18, + enrollment_target: 120, enrolled: 54, sponsor: 'Oncology Research Group'} + """, + """ + MERGE (t4:Trial {id: 'NCT06112233'}) + SET t4 += {phase: 'PHASE3', condition: 'Non-Small Cell Lung Cancer', status: 'RECRUITING', + title: 'EGFR-Mutant NSCLC Targeted Therapy Study', min_age: 18, + enrollment_target: 300, enrolled: 178, sponsor: 'Global Cancer Institute'} + """, + """ + MERGE (t5:Trial {id: 'NCT05334455'}) + SET t5 += {phase: 'PHASE2', condition: 'Colorectal Cancer', status: 'RECRUITING', + title: 'MSI-H Colorectal Cancer Immunotherapy Study', min_age: 18, + enrollment_target: 100, enrolled: 45, sponsor: 'NCI'} + """, + + # Study Sites + """ + MERGE (s1:StudySite {id: 'DFCI'}) + SET s1 += {name: 'Dana-Farber Cancer Institute', city: 'Boston', state: 'MA', + lat: 42.3376, lon: -71.1083, active_trials: 4} + """, + """ + MERGE (s2:StudySite {id: 'MDACC'}) + SET s2 += {name: 'MD Anderson Cancer Center', city: 'Houston', state: 'TX', + lat: 29.7066, lon: -95.3990, active_trials: 6} + """, + """ + MERGE (s3:StudySite {id: 'MSK'}) + SET s3 += {name: 'Memorial Sloan Kettering', city: 'New York', state: 'NY', + lat: 40.7644, lon: -73.9581, active_trials: 5} + """, + + # Patient-Diagnosis relationships + """MATCH (p:Patient {id: 'P001'}), (d:Diagnosis {code: 'C50'}) MERGE (p)-[:HAS_DIAGNOSIS]->(d)""", + """MATCH (p:Patient {id: 'P002'}), (d:Diagnosis {code: 'C61'}) MERGE (p)-[:HAS_DIAGNOSIS]->(d)""", + """MATCH (p:Patient {id: 'P003'}), (d:Diagnosis {code: 'C50'}) MERGE (p)-[:HAS_DIAGNOSIS]->(d)""", + """MATCH (p:Patient {id: 'P004'}), (d:Diagnosis {code: 'C34'}) MERGE (p)-[:HAS_DIAGNOSIS]->(d)""", + """MATCH (p:Patient {id: 'P005'}), (d:Diagnosis {code: 'C18'}) MERGE (p)-[:HAS_DIAGNOSIS]->(d)""", + + # Patient-Biomarker relationships + """MATCH (p:Patient {id: 'P001'}), (b:Biomarker {id: 'HER2_POS'}) MERGE (p)-[:HAS_BIOMARKER]->(b)""", + """MATCH (p:Patient {id: 'P002'}), (b:Biomarker {id: 'BRCA2_POS'}) MERGE (p)-[:HAS_BIOMARKER]->(b)""", + """MATCH (p:Patient {id: 'P004'}), (b:Biomarker {id: 'EGFR_L858R'}) MERGE (p)-[:HAS_BIOMARKER]->(b)""", + """MATCH (p:Patient {id: 'P004'}), (b:Biomarker {id: 'PDL1_HIGH'}) MERGE (p)-[:HAS_BIOMARKER]->(b)""", + """MATCH (p:Patient {id: 'P005'}), (b:Biomarker {id: 'MSI_H'}) MERGE (p)-[:HAS_BIOMARKER]->(b)""", + + # Diagnosis-Trial eligibility + """MATCH (d:Diagnosis {code: 'C50'}), (t:Trial {id: 'NCT04889131'}) MERGE (d)-[:ELIGIBLE_FOR]->(t)""", + """MATCH (d:Diagnosis {code: 'C50'}), (t:Trial {id: 'NCT05123456'}) MERGE (d)-[:ELIGIBLE_FOR]->(t)""", + """MATCH (d:Diagnosis {code: 'C61'}), (t:Trial {id: 'NCT05456789'}) MERGE (d)-[:ELIGIBLE_FOR]->(t)""", + """MATCH (d:Diagnosis {code: 'C34'}), (t:Trial {id: 'NCT06112233'}) MERGE (d)-[:ELIGIBLE_FOR]->(t)""", + """MATCH (d:Diagnosis {code: 'C18'}), (t:Trial {id: 'NCT05334455'}) MERGE (d)-[:ELIGIBLE_FOR]->(t)""", + + # Trial-Site relationships + """MATCH (t:Trial {id: 'NCT04889131'}), (s:StudySite {id: 'DFCI'}) MERGE (t)-[:CONDUCTED_AT]->(s)""", + """MATCH (t:Trial {id: 'NCT04889131'}), (s:StudySite {id: 'MSK'}) MERGE (t)-[:CONDUCTED_AT]->(s)""", + """MATCH (t:Trial {id: 'NCT05123456'}), (s:StudySite {id: 'MDACC'}) MERGE (t)-[:CONDUCTED_AT]->(s)""", + """MATCH (t:Trial {id: 'NCT05123456'}), (s:StudySite {id: 'MSK'}) MERGE (t)-[:CONDUCTED_AT]->(s)""", + """MATCH (t:Trial {id: 'NCT05456789'}), (s:StudySite {id: 'MDACC'}) MERGE (t)-[:CONDUCTED_AT]->(s)""", + + # Biomarker-Trial requirements + """MATCH (b:Biomarker {id: 'HER2_POS'}), (t:Trial {id: 'NCT04889131'}) MERGE (b)-[:REQUIRED_FOR]->(t)""", + """MATCH (b:Biomarker {id: 'EGFR_L858R'}), (t:Trial {id: 'NCT06112233'}) MERGE (b)-[:REQUIRED_FOR]->(t)""", + """MATCH (b:Biomarker {id: 'MSI_H'}), (t:Trial {id: 'NCT05334455'}) MERGE (b)-[:REQUIRED_FOR]->(t)""", + ] + + for query in queries: + try: + neo4j_conn.run_query(query) + except Exception as e: + print(f"Ingestion warning: {e}") + + print("Rich sample data ingested successfully.") + + +if __name__ == "__main__": + ingest_sample_data() + neo4j_conn.close() diff --git a/backend/fhir_adapter.py b/backend/fhir_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..1a06492ce289f658f5752a756eca3fe5fda48a31 --- /dev/null +++ b/backend/fhir_adapter.py @@ -0,0 +1,163 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import date + + +class FHIRCoding(BaseModel): + system: str + code: str + display: str + + +class FHIRCondition(BaseModel): + resourceType: str = "Condition" + id: str + code: FHIRCoding + clinicalStatus: str = "active" + onsetDate: Optional[str] = None + + +class FHIRObservation(BaseModel): + resourceType: str = "Observation" + id: str + code: FHIRCoding + valueQuantity: Optional[dict] = None + valueString: Optional[str] = None + valueBoolean: Optional[bool] = None + status: str = "final" + + +class FHIRMedication(BaseModel): + resourceType: str = "MedicationStatement" + id: str + medication: FHIRCoding + status: str = "active" + + +class FHIRPatient(BaseModel): + resourceType: str = "Patient" + id: str + gender: str + birthDate: str + conditions: list[FHIRCondition] = [] + observations: list[FHIRObservation] = [] + medications: list[FHIRMedication] = [] + + +def build_patient_profile(fhir_patient: FHIRPatient) -> dict: + """Convert FHIR R4 patient bundle to normalized matching profile.""" + from datetime import datetime + birth_year = int(fhir_patient.birthDate[:4]) + age = datetime.now().year - birth_year + + diagnoses = [c.code.code for c in fhir_patient.conditions] + diagnosis_names = [c.code.display for c in fhir_patient.conditions] + medications = [m.medication.display for m in fhir_patient.medications] + + biomarkers = {} + lab_values = {} + for obs in fhir_patient.observations: + key = obs.code.display.lower().replace(" ", "_") + if obs.valueBoolean is not None: + biomarkers[key] = obs.valueBoolean + elif obs.valueQuantity: + lab_values[key] = obs.valueQuantity + elif obs.valueString: + biomarkers[key] = obs.valueString + + return { + "patient_id": fhir_patient.id, + "age": age, + "gender": fhir_patient.gender, + "diagnosis_codes": diagnoses, + "diagnosis_names": diagnosis_names, + "medications": medications, + "biomarkers": biomarkers, + "lab_values": lab_values, + "fhir_bundle_ref": f"Patient/{fhir_patient.id}", + } + + +# Realistic mock FHIR R4 patients for demo +MOCK_FHIR_PATIENTS: dict[str, FHIRPatient] = { + "P001": FHIRPatient( + id="P001", gender="female", birthDate="1979-03-15", + conditions=[ + FHIRCondition(id="c1", code=FHIRCoding(system="http://snomed.info/sct", code="254837009", display="Breast cancer"), onsetDate="2022-06-01"), + ], + observations=[ + FHIRObservation(id="o1", code=FHIRCoding(system="http://loinc.org", code="85319-2", display="HER2"), valueBoolean=True), + FHIRObservation(id="o2", code=FHIRCoding(system="http://loinc.org", code="2857-1", display="PSA"), valueQuantity={"value": 0.5, "unit": "ng/mL"}), + FHIRObservation(id="o3", code=FHIRCoding(system="http://loinc.org", code="718-7", display="Hemoglobin"), valueQuantity={"value": 12.5, "unit": "g/dL"}), + ], + medications=[ + FHIRMedication(id="m1", medication=FHIRCoding(system="http://www.nlm.nih.gov/research/umls/rxnorm", code="583214", display="Trastuzumab")), + ], + ), + "P002": FHIRPatient( + id="P002", gender="male", birthDate="1964-08-22", + conditions=[ + FHIRCondition(id="c2", code=FHIRCoding(system="http://snomed.info/sct", code="399068003", display="Prostate cancer"), onsetDate="2021-11-15"), + ], + observations=[ + FHIRObservation(id="o4", code=FHIRCoding(system="http://loinc.org", code="2857-1", display="PSA"), valueQuantity={"value": 8.3, "unit": "ng/mL"}), + FHIRObservation(id="o5", code=FHIRCoding(system="http://loinc.org", code="85319-2", display="BRCA2"), valueBoolean=True), + ], + medications=[ + FHIRMedication(id="m2", medication=FHIRCoding(system="http://www.nlm.nih.gov/research/umls/rxnorm", code="1946819", display="Enzalutamide")), + ], + ), + "P003": FHIRPatient( + id="P003", gender="female", birthDate="1985-11-30", + conditions=[ + FHIRCondition(id="c3", code=FHIRCoding(system="http://snomed.info/sct", code="254837009", display="Breast cancer"), onsetDate="2023-02-10"), + FHIRCondition(id="c4", code=FHIRCoding(system="http://snomed.info/sct", code="44054006", display="Type 2 diabetes"), onsetDate="2019-05-01"), + ], + observations=[ + FHIRObservation(id="o6", code=FHIRCoding(system="http://loinc.org", code="85319-2", display="HER2"), valueBoolean=False), + FHIRObservation(id="o7", code=FHIRCoding(system="http://loinc.org", code="4548-4", display="HbA1c"), valueQuantity={"value": 7.2, "unit": "%"}), + ], + medications=[ + FHIRMedication(id="m3", medication=FHIRCoding(system="http://www.nlm.nih.gov/research/umls/rxnorm", code="860975", display="Metformin")), + ], + ), + "P004": FHIRPatient( + id="P004", gender="male", birthDate="1957-04-07", + conditions=[ + FHIRCondition(id="c5", code=FHIRCoding(system="http://snomed.info/sct", code="363346000", display="Non-small cell lung cancer"), onsetDate="2022-09-20"), + ], + observations=[ + FHIRObservation(id="o8", code=FHIRCoding(system="http://loinc.org", code="81704-9", display="EGFR mutation"), valueString="L858R"), + FHIRObservation(id="o9", code=FHIRCoding(system="http://loinc.org", code="73977-1", display="PD-L1 expression"), valueQuantity={"value": 60, "unit": "%"}), + ], + medications=[ + FHIRMedication(id="m4", medication=FHIRCoding(system="http://www.nlm.nih.gov/research/umls/rxnorm", code="1860492", display="Osimertinib")), + ], + ), + "P005": FHIRPatient( + id="P005", gender="female", birthDate="1990-07-19", + conditions=[ + FHIRCondition(id="c6", code=FHIRCoding(system="http://snomed.info/sct", code="93761005", display="Primary malignant neoplasm of colon"), onsetDate="2023-07-01"), + ], + observations=[ + FHIRObservation(id="o10", code=FHIRCoding(system="http://loinc.org", code="85077-6", display="MSI status"), valueString="MSI-H"), + FHIRObservation(id="o11", code=FHIRCoding(system="http://loinc.org", code="85319-2", display="KRAS"), valueBoolean=False), + ], + medications=[], + ), +} + + +def get_mock_fhir_patient(patient_id: str) -> Optional[FHIRPatient]: + return MOCK_FHIR_PATIENTS.get(patient_id) + + +def get_all_patient_ids() -> list[str]: + return list(MOCK_FHIR_PATIENTS.keys()) + + +def get_patient_profile(patient_id: str) -> Optional[dict]: + patient = get_mock_fhir_patient(patient_id) + if patient: + return build_patient_profile(patient) + return None diff --git a/backend/fhir_server.py b/backend/fhir_server.py new file mode 100644 index 0000000000000000000000000000000000000000..77ad776f6d9939c43d2dc6a22a6d805aa75f2195 --- /dev/null +++ b/backend/fhir_server.py @@ -0,0 +1,327 @@ +""" +FHIR R4 Server Client — connects to any FHIR R4 endpoint. + +Default: HAPI FHIR public sandbox (hapi.fhir.org/baseR4) +Production: any EHR FHIR endpoint secured with SMART on FHIR OAuth2. + +SMART on FHIR token flow: + 1. Client credentials grant → POST to FHIR_TOKEN_ENDPOINT + 2. Bearer token attached to every FHIR API request + 3. Token cached until expiry, then refreshed automatically +""" +import os +import time +import httpx +from typing import Optional +from dotenv import load_dotenv +from fhir_adapter import ( + FHIRPatient, FHIRCondition, FHIRObservation, FHIRMedication, + FHIRCoding, build_patient_profile, +) + +load_dotenv() + +FHIR_BASE_URL = os.getenv("FHIR_BASE_URL", "https://hapi.fhir.org/baseR4") +FHIR_TOKEN_ENDPOINT = os.getenv("FHIR_TOKEN_ENDPOINT", "") +FHIR_CLIENT_ID = os.getenv("FHIR_CLIENT_ID", "") +FHIR_CLIENT_SECRET = os.getenv("FHIR_CLIENT_SECRET", "") +FHIR_STATIC_TOKEN = os.getenv("FHIR_TOKEN", "") # pre-issued bearer token + +_token_cache: dict = {"token": "", "expires_at": 0.0} + + +# ── SMART on FHIR token acquisition ────────────────────────────────────────── + +def _get_smart_token() -> str: + """ + Obtain a SMART on FHIR bearer token via client credentials grant. + Returns cached token if still valid. + """ + if FHIR_STATIC_TOKEN: + return FHIR_STATIC_TOKEN + + if not FHIR_TOKEN_ENDPOINT: + return "" + + if time.time() < _token_cache["expires_at"] - 30: + return _token_cache["token"] + + try: + resp = httpx.post( + FHIR_TOKEN_ENDPOINT, + data={ + "grant_type": "client_credentials", + "client_id": FHIR_CLIENT_ID, + "client_secret": FHIR_CLIENT_SECRET, + "scope": "system/Patient.read system/Observation.read system/Condition.read system/MedicationStatement.read", + }, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + _token_cache["token"] = data["access_token"] + _token_cache["expires_at"] = time.time() + int(data.get("expires_in", 3600)) + return _token_cache["token"] + except Exception as e: + print(f"[fhir_server] SMART token error: {e}") + return "" + + +def _headers() -> dict: + token = _get_smart_token() + h = {"Accept": "application/fhir+json", "Content-Type": "application/fhir+json"} + if token: + h["Authorization"] = f"Bearer {token}" + return h + + +# ── SHARP context envelope ──────────────────────────────────────────────────── + +def build_sharp_context( + patient_id: str, + fhir_ref: str | None = None, + session_id: str | None = None, + tenant_id: str | None = None, +) -> dict: + """ + SHARP Extension Spec — patient context envelope. + Carried on every inter-agent message and MCP tool call. + """ + import uuid + return { + "sharp_version": "1.0", + "patient_context": { + "id": patient_id, + "fhir_ref": fhir_ref or f"Patient/{patient_id}", + "fhir_base": FHIR_BASE_URL, + "tenant_id": tenant_id or "clinicalmatch-demo", + "session_id": session_id or str(uuid.uuid4()), + }, + "data_classification": "synthetic-demo", + "baa_in_scope": False, + "consent_status": "unknown", + } + + +# ── FHIR resource fetchers ──────────────────────────────────────────────────── + +def fetch_fhir_patient(patient_fhir_id: str) -> dict | None: + """Fetch a Patient resource from the FHIR server by FHIR ID.""" + try: + resp = httpx.get( + f"{FHIR_BASE_URL}/Patient/{patient_fhir_id}", + headers=_headers(), timeout=10, + ) + resp.raise_for_status() + return resp.json() + except Exception as e: + print(f"[fhir_server] Patient fetch error ({patient_fhir_id}): {e}") + return None + + +def search_fhir_patients(count: int = 10, condition_code: str | None = None) -> list[dict]: + """Search for Patient resources on the FHIR server.""" + params: dict = {"_count": count, "_format": "json"} + if condition_code: + params["_has:Condition:patient:code"] = condition_code + try: + resp = httpx.get(f"{FHIR_BASE_URL}/Patient", headers=_headers(), + params=params, timeout=15) + resp.raise_for_status() + bundle = resp.json() + return [e["resource"] for e in bundle.get("entry", []) if e.get("resource")] + except Exception as e: + print(f"[fhir_server] Patient search error: {e}") + return [] + + +def fetch_patient_conditions(patient_fhir_id: str) -> list[dict]: + try: + resp = httpx.get( + f"{FHIR_BASE_URL}/Condition", + headers=_headers(), + params={"patient": patient_fhir_id, "_format": "json"}, + timeout=10, + ) + resp.raise_for_status() + bundle = resp.json() + return [e["resource"] for e in bundle.get("entry", []) if e.get("resource")] + except Exception as e: + print(f"[fhir_server] Condition fetch error: {e}") + return [] + + +def fetch_patient_observations(patient_fhir_id: str) -> list[dict]: + try: + resp = httpx.get( + f"{FHIR_BASE_URL}/Observation", + headers=_headers(), + params={"patient": patient_fhir_id, "_format": "json", "_count": 50}, + timeout=10, + ) + resp.raise_for_status() + bundle = resp.json() + return [e["resource"] for e in bundle.get("entry", []) if e.get("resource")] + except Exception as e: + print(f"[fhir_server] Observation fetch error: {e}") + return [] + + +def fetch_patient_medications(patient_fhir_id: str) -> list[dict]: + try: + resp = httpx.get( + f"{FHIR_BASE_URL}/MedicationStatement", + headers=_headers(), + params={"patient": patient_fhir_id, "_format": "json"}, + timeout=10, + ) + resp.raise_for_status() + bundle = resp.json() + return [e["resource"] for e in bundle.get("entry", []) if e.get("resource")] + except Exception as e: + print(f"[fhir_server] Medication fetch error: {e}") + return [] + + +# ── FHIR → internal model conversion ───────────────────────────────────────── + +def _safe_coding(codings: list[dict], fallback: str = "unknown") -> FHIRCoding: + for c in codings: + if c.get("code"): + return FHIRCoding( + system=c.get("system", ""), + code=c.get("code", fallback), + display=c.get("display", c.get("code", fallback)), + ) + return FHIRCoding(system="", code=fallback, display=fallback) + + +def _parse_fhir_patient_resource(resource: dict) -> FHIRPatient | None: + try: + pid = resource.get("id", "") + gender = resource.get("gender", "unknown") + birth_date = resource.get("birthDate", "1970-01-01") + return FHIRPatient(id=pid, gender=gender, birthDate=birth_date) + except Exception as e: + print(f"[fhir_server] Patient parse error: {e}") + return None + + +def _parse_conditions(resources: list[dict]) -> list[FHIRCondition]: + conditions = [] + for r in resources: + try: + coding_list = r.get("code", {}).get("coding", []) + coding = _safe_coding(coding_list) + conditions.append(FHIRCondition( + id=r.get("id", ""), + code=coding, + clinicalStatus=r.get("clinicalStatus", {}).get("coding", [{}])[0].get("code", "active"), + onsetDate=r.get("onsetDateTime", r.get("onsetDate", "")), + )) + except Exception: + continue + return conditions + + +def _parse_observations(resources: list[dict]) -> list[FHIRObservation]: + observations = [] + for r in resources: + try: + coding_list = r.get("code", {}).get("coding", []) + coding = _safe_coding(coding_list) + vq = r.get("valueQuantity") + vs = r.get("valueString") + vb = r.get("valueBoolean") + observations.append(FHIRObservation( + id=r.get("id", ""), + code=coding, + valueQuantity={"value": vq["value"], "unit": vq.get("unit", "")} if vq and "value" in vq else None, + valueString=str(vs) if vs is not None else None, + valueBoolean=bool(vb) if vb is not None else None, + status=r.get("status", "final"), + )) + except Exception: + continue + return observations + + +def _parse_medications(resources: list[dict]) -> list[FHIRMedication]: + medications = [] + for r in resources: + try: + coding_list = ( + r.get("medicationCodeableConcept", {}).get("coding", []) or + r.get("medication", {}).get("concept", {}).get("coding", []) + ) + coding = _safe_coding(coding_list) + medications.append(FHIRMedication( + id=r.get("id", ""), + medication=coding, + status=r.get("status", "active"), + )) + except Exception: + continue + return medications + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def get_live_patient_profile( + patient_fhir_id: str, + sharp_context: dict | None = None, +) -> dict | None: + """ + Fetch a full patient profile from the live FHIR server. + Assembles Patient + Condition + Observation + MedicationStatement + into the same internal profile dict used everywhere in the system. + Attaches SHARP context envelope. + """ + resource = fetch_fhir_patient(patient_fhir_id) + if not resource: + return None + + patient = _parse_fhir_patient_resource(resource) + if not patient: + return None + + patient.conditions = _parse_conditions(fetch_patient_conditions(patient_fhir_id)) + patient.observations = _parse_observations(fetch_patient_observations(patient_fhir_id)) + patient.medications = _parse_medications(fetch_patient_medications(patient_fhir_id)) + + profile = build_patient_profile(patient) + profile["fhir_source"] = "live" + profile["fhir_base_url"] = FHIR_BASE_URL + profile["fhir_ref"] = f"Patient/{patient_fhir_id}" + profile["sharp_context"] = sharp_context or build_sharp_context( + patient_id=patient_fhir_id, + fhir_ref=f"Patient/{patient_fhir_id}", + ) + return profile + + +def get_fhir_server_status() -> dict: + """Probe the configured FHIR server and return capability statement summary.""" + try: + resp = httpx.get( + f"{FHIR_BASE_URL}/metadata", + headers=_headers(), timeout=8, + ) + resp.raise_for_status() + cap = resp.json() + return { + "reachable": True, + "fhir_version": cap.get("fhirVersion", "unknown"), + "server_name": cap.get("software", {}).get("name", "unknown"), + "base_url": FHIR_BASE_URL, + "auth_method": "SMART/Bearer" if (FHIR_TOKEN_ENDPOINT or FHIR_STATIC_TOKEN) else "none (public sandbox)", + "smart_token_configured": bool(FHIR_TOKEN_ENDPOINT or FHIR_STATIC_TOKEN), + } + except Exception as e: + return { + "reachable": False, + "base_url": FHIR_BASE_URL, + "error": str(e), + "auth_method": "SMART/Bearer" if (FHIR_TOKEN_ENDPOINT or FHIR_STATIC_TOKEN) else "none", + "smart_token_configured": bool(FHIR_TOKEN_ENDPOINT or FHIR_STATIC_TOKEN), + } diff --git a/backend/graph_seeder.py b/backend/graph_seeder.py new file mode 100644 index 0000000000000000000000000000000000000000..f0e9487763e1460741a14cea079421a600ddbc70 --- /dev/null +++ b/backend/graph_seeder.py @@ -0,0 +1,1109 @@ +""" +Graph seeder — fetches REAL data from live public APIs and populates Neo4j. + +Data sources (all free, no auth): + - ClinicalTrials.gov v2 API (NCT trial records) + - RxNorm (NIH) (medication RxCUI codes) + - ICD-10 CM (NLM) (diagnosis codes) + - PubMed (NCBI) (supporting literature PMIDs) + - Synthetic patients (500 realistic profiles matched to real trials) + +Run once to seed, or schedule periodically to stay current. +""" +import httpx +import asyncio +import time +import random +from neo4j_setup import neo4j_conn + +CTGOV_BASE = "https://clinicaltrials.gov/api/v2/studies" +RXNORM_BASE = "https://rxnav.nlm.nih.gov/REST" +ICD10_BASE = "https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search" +PUBMED_BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" +FDA_BASE = "https://api.fda.gov/drug" + +# Conditions to seed — expand as needed +SEED_CONDITIONS = [ + "breast cancer", + "prostate cancer", + "non-small cell lung cancer", + "colorectal cancer", + "ovarian cancer", + "melanoma", + "leukemia", + "lymphoma", + "glioblastoma", + "pancreatic cancer", +] + +# Key oncology medications to pre-load +SEED_MEDICATIONS = [ + "trastuzumab", "pembrolizumab", "nivolumab", "osimertinib", + "olaparib", "enzalutamide", "bevacizumab", "rituximab", + "imatinib", "dabrafenib", "vemurafenib", "atezolizumab", + "durvalumab", "cetuximab", "erlotinib", "capecitabine", +] + +# ICD-10 prefixes for oncology +SEED_ICD10_PREFIXES = [ + "C50", "C61", "C34", "C18", "C56", "C43", "C91", "C85", "C71", "C25", +] + + +# ── Neo4j helpers ───────────────────────────────────────────────────────────── + +def upsert(query: str, params: dict | None = None): + try: + neo4j_conn.run_query(query, params or {}) + except Exception as e: + print(f" [neo4j] warn: {e}") + + +def batch_upsert(queries: list[tuple[str, dict]]): + for q, p in queries: + upsert(q, p) + + +# ── ClinicalTrials.gov ──────────────────────────────────────────────────────── + +async def fetch_trials_for_condition(client: httpx.AsyncClient, condition: str, page_size: int = 50) -> list[dict]: + try: + resp = await client.get(CTGOV_BASE, params={ + "query.cond": condition, + "filter.overallStatus": "RECRUITING", + "pageSize": page_size, + "format": "json", + }, timeout=30) + resp.raise_for_status() + return resp.json().get("studies", []) + except Exception as e: + print(f" [ctgov] error for '{condition}': {e}") + return [] + + +def _extract_trial(study: dict, condition: str) -> dict | None: + try: + proto = study["protocolSection"] + ident = proto["identificationModule"] + status = proto.get("statusModule", {}) + design = proto.get("designModule", {}) + eligibility = proto.get("eligibilityModule", {}) + desc = proto.get("descriptionModule", {}) + sponsor = proto.get("sponsorCollaboratorsModule", {}) + contacts = proto.get("contactsLocationsModule", {}) + outcomes = proto.get("outcomesModule", {}) + + phases = design.get("phases", ["N/A"]) + locations = contacts.get("locations", []) + + return { + "nct_id": ident["nctId"], + "title": ident.get("briefTitle", "")[:200], + "status": status.get("overallStatus", "UNKNOWN"), + "phase": phases[0] if phases else "N/A", + "condition": condition, + "brief_summary": desc.get("briefSummary", "")[:1000], + "eligibility_criteria": eligibility.get("eligibilityCriteria", "")[:2000], + "min_age": eligibility.get("minimumAge", ""), + "max_age": eligibility.get("maximumAge", ""), + "sex": eligibility.get("sex", "ALL"), + "enrollment": design.get("enrollmentInfo", {}).get("count", 0), + "start_date": status.get("startDateStruct", {}).get("date", ""), + "completion_date": status.get("completionDateStruct", {}).get("date", ""), + "sponsor": sponsor.get("leadSponsor", {}).get("name", "")[:100], + "primary_outcomes": [o.get("measure", "")[:100] for o in outcomes.get("primaryOutcomes", [])[:3]], + "location_count": len(locations), + "locations": [ + { + "facility": loc.get("facility", "")[:100], + "city": loc.get("city", ""), + "state": loc.get("state", ""), + "country": loc.get("country", "US"), + "lat": loc.get("geoPoint", {}).get("lat"), + "lon": loc.get("geoPoint", {}).get("lon"), + } + for loc in locations[:10] + ], + } + except Exception as e: + return None + + +async def seed_trials(client: httpx.AsyncClient) -> int: + print("\n[1/5] Seeding clinical trials from ClinicalTrials.gov...") + total = 0 + for condition in SEED_CONDITIONS: + studies = await fetch_trials_for_condition(client, condition) + print(f" {condition}: {len(studies)} trials fetched") + for study in studies: + trial = _extract_trial(study, condition) + if not trial: + continue + # Upsert trial node + upsert(""" + MERGE (t:Trial {id: $nct_id}) + SET t += { + title: $title, status: $status, phase: $phase, + condition: $condition, brief_summary: $brief_summary, + eligibility_criteria: $eligibility_criteria, + min_age: $min_age, max_age: $max_age, sex: $sex, + enrollment: $enrollment, start_date: $start_date, + completion_date: $completion_date, sponsor: $sponsor, + location_count: $location_count, source: 'clinicaltrials.gov', + updated_at: datetime() + } + """, trial) + # Upsert Condition → Trial relationship + upsert(""" + MERGE (c:ConditionNode {name: $condition}) + WITH c + MATCH (t:Trial {id: $nct_id}) + MERGE (c)-[:HAS_TRIAL]->(t) + """, {"condition": condition, "nct_id": trial["nct_id"]}) + # Upsert study sites + for loc in trial["locations"]: + if loc.get("lat") and loc.get("lon"): + upsert(""" + MERGE (s:StudySite {facility: $facility, city: $city, state: $state}) + SET s += {country: $country, lat: $lat, lon: $lon, source: 'clinicaltrials.gov'} + WITH s + MATCH (t:Trial {id: $nct_id}) + MERGE (t)-[:CONDUCTED_AT]->(s) + """, {**loc, "nct_id": trial["nct_id"]}) + total += 1 + await asyncio.sleep(0.5) # Rate limit courtesy + print(f" Total trials seeded: {total}") + return total + + +# ── RxNorm (NIH) — Medications ──────────────────────────────────────────────── + +async def fetch_rxcui(client: httpx.AsyncClient, drug_name: str) -> list[dict]: + try: + resp = await client.get(f"{RXNORM_BASE}/drugs.json", params={"name": drug_name}, timeout=15) + resp.raise_for_status() + d = resp.json() + groups = d.get("drugGroup", {}).get("conceptGroup", []) + results = [] + for grp in groups: + tty = grp.get("tty", "") + for concept in grp.get("conceptProperties", [])[:3]: + results.append({ + "rxcui": concept.get("rxcui", ""), + "name": concept.get("name", ""), + "tty": tty, + "search_name": drug_name, + }) + return results[:5] # Top 5 + except Exception as e: + print(f" [rxnorm] error for '{drug_name}': {e}") + return [] + + +async def seed_medications(client: httpx.AsyncClient) -> int: + print("\n[2/5] Seeding medications from RxNorm...") + total = 0 + for drug_name in SEED_MEDICATIONS: + concepts = await fetch_rxcui(client, drug_name) + for concept in concepts[:1]: # Primary concept only + upsert(""" + MERGE (m:Medication {rxcui: $rxcui}) + SET m += { + name: $name, tty: $tty, generic_name: $search_name, + source: 'rxnorm', updated_at: datetime() + } + """, concept) + total += 1 + print(f" {drug_name}: {len(concepts)} RxCUI concepts") + await asyncio.sleep(0.2) + print(f" Total medications seeded: {total}") + return total + + +# ── ICD-10 CM (NLM) — Diagnoses ────────────────────────────────────────────── + +async def fetch_icd10(client: httpx.AsyncClient, prefix: str) -> list[dict]: + try: + resp = await client.get(ICD10_BASE, params={ + "sf": "code,name", + "terms": prefix, + "maxList": 20, + }, timeout=15) + resp.raise_for_status() + data = resp.json() + if not data or len(data) < 4: + return [] + return [{"code": item[0], "name": item[1]} for item in data[3]] + except Exception as e: + print(f" [icd10] error for '{prefix}': {e}") + return [] + + +async def seed_diagnoses(client: httpx.AsyncClient) -> int: + print("\n[3/5] Seeding diagnoses from ICD-10 CM...") + total = 0 + for prefix in SEED_ICD10_PREFIXES: + codes = await fetch_icd10(client, prefix) + for item in codes: + upsert(""" + MERGE (d:Diagnosis {code: $code}) + SET d += {name: $name, source: 'icd10cm', updated_at: datetime()} + """, item) + total += 1 + # Link ICD prefix → condition names for matching + condition_map = { + "C50": "breast cancer", "C61": "prostate cancer", "C34": "non-small cell lung cancer", + "C18": "colorectal cancer", "C56": "ovarian cancer", "C43": "melanoma", + "C91": "leukemia", "C85": "lymphoma", "C71": "glioblastoma", "C25": "pancreatic cancer", + } + if prefix in condition_map: + upsert(""" + MATCH (d:Diagnosis) WHERE d.code STARTS WITH $prefix + MATCH (c:ConditionNode {name: $condition}) + MERGE (d)-[:MAPS_TO_CONDITION]->(c) + """, {"prefix": prefix, "condition": condition_map[prefix]}) + print(f" ICD-10 {prefix}: {len(codes)} codes") + await asyncio.sleep(0.2) + print(f" Total diagnoses seeded: {total}") + return total + + +# ── PubMed (NCBI) — Supporting Literature ──────────────────────────────────── + +async def fetch_pubmed_ids(client: httpx.AsyncClient, condition: str, count: int = 5) -> list[str]: + try: + resp = await client.get(f"{PUBMED_BASE}/esearch.fcgi", params={ + "db": "pubmed", + "term": f"clinical trial {condition} treatment[Title/Abstract]", + "retmax": count, + "retmode": "json", + "sort": "relevance", + }, timeout=15) + resp.raise_for_status() + return resp.json()["esearchresult"]["idlist"] + except Exception as e: + print(f" [pubmed] error for '{condition}': {e}") + return [] + + +async def fetch_pubmed_summary(client: httpx.AsyncClient, pmid: str) -> dict | None: + try: + resp = await client.get(f"{PUBMED_BASE}/esummary.fcgi", params={ + "db": "pubmed", "id": pmid, "retmode": "json", + }, timeout=15) + resp.raise_for_status() + result = resp.json()["result"] + if pmid not in result: + return None + r = result[pmid] + return { + "pmid": pmid, + "title": r.get("title", "")[:200], + "source": r.get("source", ""), + "pub_date": r.get("pubdate", ""), + "authors": ", ".join(a.get("name", "") for a in r.get("authors", [])[:3]), + } + except Exception as e: + return None + + +async def seed_literature(client: httpx.AsyncClient) -> int: + print("\n[4/5] Seeding supporting literature from PubMed...") + total = 0 + for condition in SEED_CONDITIONS[:5]: # Top 5 conditions to keep fast + pmids = await fetch_pubmed_ids(client, condition) + for pmid in pmids: + summary = await fetch_pubmed_summary(client, pmid) + if not summary: + continue + upsert(""" + MERGE (p:Publication {pmid: $pmid}) + SET p += { + title: $title, journal: $source, pub_date: $pub_date, + authors: $authors, source: 'pubmed', updated_at: datetime() + } + WITH p + MATCH (c:ConditionNode {name: $condition}) + MERGE (p)-[:SUPPORTS_RESEARCH_ON]->(c) + """, {**summary, "condition": condition}) + total += 1 + print(f" {condition}: {len(pmids)} publications linked") + await asyncio.sleep(0.3) + print(f" Total publications seeded: {total}") + return total + + +# ── Biomarkers (static — curated from COSMIC / NCIT) ───────────────────────── + + +# Expand seed conditions to 20 oncology types +SEED_CONDITIONS = [ + "breast cancer", "prostate cancer", "non-small cell lung cancer", "colorectal cancer", + "ovarian cancer", "melanoma", "leukemia", "lymphoma", "glioblastoma", "pancreatic cancer", + "bladder cancer", "renal cell carcinoma", "thyroid cancer", "multiple myeloma", + "endometrial cancer", "cervical cancer", "gastric cancer", "hepatocellular carcinoma", + "head and neck cancer", "sarcoma", +] + +CURATED_BIOMARKERS = [ + # Breast cancer + {"id": "HER2_POS", "name": "HER2 Positive", "gene": "ERBB2", "loinc": "85319-2", "condition": "breast cancer"}, + {"id": "HER2_NEG", "name": "HER2 Negative", "gene": "ERBB2", "loinc": "85319-2", "condition": "breast cancer"}, + {"id": "BRCA1_MUT", "name": "BRCA1 Pathogenic Variant", "gene": "BRCA1", "loinc": "21636-6", "condition": "breast cancer"}, + {"id": "BRCA2_MUT", "name": "BRCA2 Pathogenic Variant", "gene": "BRCA2", "loinc": "21637-4", "condition": "breast cancer"}, + {"id": "PIK3CA_MUT", "name": "PIK3CA Mutation", "gene": "PIK3CA", "loinc": "82457-4", "condition": "breast cancer"}, + {"id": "TP53_MUT", "name": "TP53 Mutation", "gene": "TP53", "loinc": "21637-4", "condition": "breast cancer"}, + {"id": "ER_POS", "name": "Estrogen Receptor Positive", "gene": "ESR1", "loinc": "85310-1", "condition": "breast cancer"}, + {"id": "PR_POS", "name": "Progesterone Receptor Positive", "gene": "PGR", "loinc": "85321-8", "condition": "breast cancer"}, + {"id": "TNBC", "name": "Triple Negative Breast Cancer", "gene": "ERBB2/ESR1/PGR", "loinc": "85319-2", "condition": "breast cancer"}, + # Lung + {"id": "EGFR_L858R", "name": "EGFR L858R Mutation", "gene": "EGFR", "loinc": "81704-9", "condition": "non-small cell lung cancer"}, + {"id": "EGFR_DEL19", "name": "EGFR Exon 19 Deletion", "gene": "EGFR", "loinc": "81704-9", "condition": "non-small cell lung cancer"}, + {"id": "EGFR_T790M", "name": "EGFR T790M Resistance Mutation", "gene": "EGFR", "loinc": "81704-9", "condition": "non-small cell lung cancer"}, + {"id": "ALK_FUSION", "name": "ALK Gene Fusion", "gene": "ALK", "loinc": "81695-9", "condition": "non-small cell lung cancer"}, + {"id": "ROS1_FUSION", "name": "ROS1 Gene Fusion", "gene": "ROS1", "loinc": "81696-7", "condition": "non-small cell lung cancer"}, + {"id": "MET_EX14", "name": "MET Exon 14 Skipping", "gene": "MET", "loinc": "82139-8", "condition": "non-small cell lung cancer"}, + {"id": "KRAS_G12C", "name": "KRAS G12C Mutation", "gene": "KRAS", "loinc": "81434-5", "condition": "non-small cell lung cancer"}, + {"id": "PDL1_HIGH", "name": "PD-L1 TPS ≥50%", "gene": "CD274", "loinc": "73977-1", "condition": "non-small cell lung cancer"}, + {"id": "PDL1_LOW", "name": "PD-L1 TPS 1-49%", "gene": "CD274", "loinc": "73977-1", "condition": "non-small cell lung cancer"}, + {"id": "PDL1_NEG", "name": "PD-L1 TPS <1%", "gene": "CD274", "loinc": "73977-1", "condition": "non-small cell lung cancer"}, + # Prostate + {"id": "PSA_ELEVATED","name": "PSA Elevated (>4 ng/mL)", "gene": "KLK3", "loinc": "2857-1", "condition": "prostate cancer"}, + {"id": "PTEN_LOSS", "name": "PTEN Loss", "gene": "PTEN", "loinc": "21637-4", "condition": "prostate cancer"}, + {"id": "AR_V7", "name": "Androgen Receptor Splice Variant 7", "gene": "AR", "loinc": "82145-5", "condition": "prostate cancer"}, + # Colorectal + {"id": "MSI_H", "name": "Microsatellite Instability-High", "gene": "MLH1/MSH2", "loinc": "85077-6", "condition": "colorectal cancer"}, + {"id": "MSS", "name": "Microsatellite Stable", "gene": "MLH1/MSH2", "loinc": "85077-6", "condition": "colorectal cancer"}, + {"id": "KRAS_WT", "name": "KRAS Wild-Type", "gene": "KRAS", "loinc": "21637-4", "condition": "colorectal cancer"}, + {"id": "BRAF_V600E", "name": "BRAF V600E Mutation", "gene": "BRAF", "loinc": "81287-7", "condition": "colorectal cancer"}, + {"id": "NRAS_MUT", "name": "NRAS Mutation", "gene": "NRAS", "loinc": "82143-0", "condition": "colorectal cancer"}, + # Melanoma + {"id": "BRAF_V600K", "name": "BRAF V600K Mutation", "gene": "BRAF", "loinc": "81287-7", "condition": "melanoma"}, + {"id": "TMB_HIGH", "name": "Tumor Mutational Burden High (≥10)", "gene": "TMB", "loinc": "94076-7", "condition": "melanoma"}, + {"id": "NRAS_MEL", "name": "NRAS Mutation (Melanoma)", "gene": "NRAS", "loinc": "82143-0", "condition": "melanoma"}, + # GBM + {"id": "IDH1_R132H", "name": "IDH1 R132H Mutation", "gene": "IDH1", "loinc": "82140-6", "condition": "glioblastoma"}, + {"id": "IDH1_WT", "name": "IDH1 Wild-Type", "gene": "IDH1", "loinc": "82140-6", "condition": "glioblastoma"}, + {"id": "MGMT_METH", "name": "MGMT Promoter Methylation", "gene": "MGMT", "loinc": "85319-2", "condition": "glioblastoma"}, + {"id": "EGFR_AMP", "name": "EGFR Amplification", "gene": "EGFR", "loinc": "81704-9", "condition": "glioblastoma"}, + # Leukemia / Lymphoma + {"id": "BCR_ABL1", "name": "BCR-ABL1 Fusion (Philadelphia Chr)", "gene": "BCR/ABL1", "loinc": "33899-6", "condition": "leukemia"}, + {"id": "FLT3_ITD", "name": "FLT3 Internal Tandem Duplication", "gene": "FLT3", "loinc": "82144-8", "condition": "leukemia"}, + {"id": "NPM1_MUT", "name": "NPM1 Mutation", "gene": "NPM1", "loinc": "82147-1", "condition": "leukemia"}, + {"id": "CD20_POS", "name": "CD20 Positive", "gene": "MS4A1", "loinc": "85080-0", "condition": "lymphoma"}, + {"id": "EZH2_MUT", "name": "EZH2 Mutation", "gene": "EZH2", "loinc": "82148-9", "condition": "lymphoma"}, + # New conditions + {"id": "FGFR3_MUT", "name": "FGFR3 Mutation", "gene": "FGFR3", "loinc": "82150-5", "condition": "bladder cancer"}, + {"id": "VHL_LOSS", "name": "VHL Gene Loss", "gene": "VHL", "loinc": "82151-3", "condition": "renal cell carcinoma"}, + {"id": "MTOR_MUT", "name": "mTOR Pathway Mutation", "gene": "MTOR", "loinc": "82152-1", "condition": "renal cell carcinoma"}, + {"id": "BRAF_THYROID","name": "BRAF V600E (Thyroid)", "gene": "BRAF", "loinc": "81287-7", "condition": "thyroid cancer"}, + {"id": "RET_FUSION", "name": "RET Gene Fusion", "gene": "RET", "loinc": "82153-9", "condition": "thyroid cancer"}, + {"id": "NTRK_FUSION", "name": "NTRK Gene Fusion", "gene": "NTRK1/2/3", "loinc": "82154-7", "condition": "thyroid cancer"}, + {"id": "WHSC1_MUT", "name": "MMSET/WHSC1 Mutation", "gene": "NSD2", "loinc": "82155-4", "condition": "multiple myeloma"}, + {"id": "CDKN2A_LOSS", "name": "CDKN2A Loss", "gene": "CDKN2A", "loinc": "82156-2", "condition": "multiple myeloma"}, + {"id": "POLE_MUT", "name": "POLE Mutation", "gene": "POLE", "loinc": "82157-0", "condition": "endometrial cancer"}, + {"id": "CTNNB1_MUT", "name": "CTNNB1 Mutation", "gene": "CTNNB1", "loinc": "82158-8", "condition": "endometrial cancer"}, + {"id": "HPV_POS", "name": "HPV Positive", "gene": "HPV", "loinc": "21440-3", "condition": "cervical cancer"}, + {"id": "ERBB2_GC", "name": "HER2 Amplification (Gastric)", "gene": "ERBB2", "loinc": "85319-2", "condition": "gastric cancer"}, + {"id": "HBV_POS", "name": "Hepatitis B Virus Positive", "gene": "HBV", "loinc": "16933-4", "condition": "hepatocellular carcinoma"}, + {"id": "TERT_MUT", "name": "TERT Promoter Mutation", "gene": "TERT", "loinc": "82159-6", "condition": "hepatocellular carcinoma"}, + {"id": "PIK3CA_HNC", "name": "PIK3CA Mutation (H&N)", "gene": "PIK3CA", "loinc": "82457-4", "condition": "head and neck cancer"}, + {"id": "HPV_HNSC", "name": "HPV-Positive HNSCC", "gene": "HPV", "loinc": "21440-3", "condition": "head and neck cancer"}, + {"id": "CDK4_AMP", "name": "CDK4 Amplification", "gene": "CDK4", "loinc": "82160-4", "condition": "sarcoma"}, + {"id": "MDM2_AMP", "name": "MDM2 Amplification", "gene": "MDM2", "loinc": "82161-2", "condition": "sarcoma"}, +] + + +def seed_biomarkers() -> int: + print("\n[5/5] Seeding biomarkers (curated from COSMIC/NCIT)...") + for bm in CURATED_BIOMARKERS: + upsert(""" + MERGE (b:Biomarker {id: $id}) + SET b += {name: $name, gene: $gene, loinc: $loinc, source: 'curated', updated_at: datetime()} + WITH b + MERGE (c:ConditionNode {name: $condition}) + MERGE (b)-[:RELEVANT_TO]->(c) + """, bm) + print(f" {len(CURATED_BIOMARKERS)} biomarkers seeded and linked to conditions") + return len(CURATED_BIOMARKERS) + + +# ── Eligibility relationships ───────────────────────────────────────────────── + +def derive_eligibility_relationships(): + print("\n[+] Deriving eligibility relationships...") + upsert("MATCH (d:Diagnosis)-[:MAPS_TO_CONDITION]->(c:ConditionNode)-[:HAS_TRIAL]->(t:Trial) MERGE (d)-[:ELIGIBLE_FOR]->(t)") + upsert("MATCH (b:Biomarker)-[:RELEVANT_TO]->(c:ConditionNode)-[:HAS_TRIAL]->(t:Trial) MERGE (b)-[:MAY_QUALIFY_FOR]->(t)") + print(" Eligibility relationships derived.") + + +# ══════════════════════════════════════════════════════════════════════════════ +# Synthetic Patient Engine — 100 k clinically-informed personas +# Distributions based on: SEER 2023, TCGA biomarker atlas, ASCO guidelines, +# US Census 2020 demographics, ACS Cancer Facts & Figures 2024. +# ══════════════════════════════════════════════════════════════════════════════ + +# ── Name pools (US Census racial/ethnic proportions) ───────────────────────── + +_NAMES_F_WHITE = ["Emma","Olivia","Ava","Isabella","Sophia","Charlotte","Amelia","Mia","Harper", + "Evelyn","Abigail","Emily","Elizabeth","Avery","Ella","Madison","Scarlett", + "Victoria","Grace","Chloe","Penelope","Riley","Lily","Eleanor","Hannah", + "Lillian","Addison","Aubrey","Ellie","Stella","Natalie","Leah","Hazel", + "Violet","Audrey","Claire","Lucy","Anna","Samantha","Katherine"] +_NAMES_F_BLACK = ["Aaliyah","Amara","Destiny","Imani","Jasmine","Keisha","Layla","Maya","Naomi", + "Nia","Raven","Serena","Tamara","Unique","Zora","Aisha","Brianna","Crystal", + "Diamond","Essence","Faith","Genesis","Heaven","India","Jade","Kiara","Lashonda", + "Monique","Nadia","Precious","Quiana","Regina","Shanice","Tiffany","Whitney"] +_NAMES_F_HISPANIC = ["Sofia","Camila","Valentina","Isabella","Daniela","Fernanda","Gabriela","Lucia", + "Maria","Ana","Carmen","Diana","Elena","Gloria","Iris","Jessica","Laura", + "Linda","Margarita","Natalia","Paola","Rosa","Sandra","Teresa","Veronica", + "Ximena","Yolanda","Adriana","Beatriz","Carolina","Esperanza","Francisca"] +_NAMES_F_ASIAN = ["Aiko","Mei","Yuki","Sakura","Hana","Yuna","Ji-Young","Soo-Jin","Lan","Linh", + "Nguyen","Phuong","Priya","Divya","Ananya","Kavya","Shreya","Sanjana", + "Hui","Xin","Ying","Fang","Jing","Li","Min","Qian","Wei","Xue","Yan","Zhen"] +_NAMES_M_WHITE = ["Liam","Noah","William","James","Oliver","Benjamin","Elijah","Lucas","Mason", + "Logan","Alexander","Ethan","Jacob","Michael","Daniel","Henry","Jackson", + "Sebastian","Aiden","Matthew","Samuel","David","Joseph","Carter","Owen", + "Wyatt","John","Jack","Luke","Dylan","Grayson","Levi","Isaac","Gabriel"] +_NAMES_M_BLACK = ["Andre","DeShawn","Darius","Elijah","Isaiah","Jamal","Jaylen","Jordan","Kendrick", + "Malik","Marcus","Marquise","Nathaniel","Omari","Quincy","Rashad","Roderick", + "Terrence","Trevon","Xavier","Zion","Aaron","Calvin","Damon","Ernest","Frederick", + "Gerald","Harold","Ivan","Jerome","Kenneth","Leonard","Maurice","Nelson"] +_NAMES_M_HISPANIC = ["Santiago","Mateo","Alejandro","Sebastian","Diego","Carlos","Miguel","Andres", + "Fernando","Jose","Luis","Manuel","Marco","Mario","Pablo","Rafael","Ricardo", + "Roberto","Rodrigo","Victor","Alberto","Arturo","Cesar","Eduardo","Ernesto", + "Francisco","Guillermo","Hector","Ignacio","Javier","Juan","Lorenzo","Oscar"] +_NAMES_M_ASIAN = ["Wei","Ming","Jian","Yang","Hao","Lei","Tao","Xiao","Yong","Jun","Ryu","Kenji", + "Hiroshi","Takashi","Yuto","Min-Jun","Seo-Jun","Ji-Ho","Arjun","Rahul","Vikram", + "Suresh","Rajesh","Anil","Vijay","Amit","Nikhil","Rohan","Kiran","Sanjay"] +_LAST_NAMES_WHITE = ["Smith","Johnson","Williams","Brown","Jones","Miller","Davis","Wilson","Anderson", + "Thomas","Taylor","Moore","Jackson","Martin","Lee","Thompson","White","Harris", + "Clark","Lewis","Robinson","Walker","Young","Allen","King","Wright","Scott", + "Green","Adams","Nelson","Baker","Hall","Campbell","Mitchell","Carter","Roberts"] +_LAST_NAMES_BLACK = ["Williams","Johnson","Jones","Brown","Davis","Wilson","Thomas","Taylor","Moore", + "Jackson","Harris","Thompson","White","Robinson","Walker","King","Green","Adams", + "Baker","Hall","Carter","Mitchell","Peele","Banks","Bell","Boyd","Brooks","Bryant", + "Byrd","Chambers","Coleman","Collins","Cooper","Crawford","Dixon","Edwards"] +_LAST_NAMES_HISPANIC = ["Garcia","Rodriguez","Martinez","Hernandez","Lopez","Gonzalez","Perez","Sanchez", + "Ramirez","Torres","Flores","Rivera","Gomez","Diaz","Reyes","Morales","Cruz", + "Gutierrez","Ortiz","Chavez","Ramos","Romero","Vargas","Castillo","Jimenez", + "Moreno","Alvarez","Mendoza","Ruiz","Aguilar","Vega","Castro","Medina"] +_LAST_NAMES_ASIAN = ["Wang","Li","Zhang","Liu","Chen","Yang","Huang","Zhao","Wu","Zhou","Kim","Park", + "Lee","Choi","Jung","Nguyen","Tran","Le","Pham","Hoang","Patel","Shah","Kumar", + "Singh","Sharma","Gupta","Mehta","Kapoor","Nair","Reddy","Iyer","Rao","Joshi"] + +# Ethnic distribution approximating US cancer patient demographics (ACS 2024) +_ETHNICITY_GROUPS = [ + ("White", 0.60, _NAMES_F_WHITE, _NAMES_M_WHITE, _LAST_NAMES_WHITE), + ("Black or African American", 0.13, _NAMES_F_BLACK, _NAMES_M_BLACK, _LAST_NAMES_BLACK), + ("Hispanic or Latino", 0.14, _NAMES_F_HISPANIC, _NAMES_M_HISPANIC, _LAST_NAMES_HISPANIC), + ("Asian", 0.07, _NAMES_F_ASIAN, _NAMES_M_ASIAN, _LAST_NAMES_ASIAN), + ("American Indian or Alaska Native", 0.03, _NAMES_F_WHITE, _NAMES_M_WHITE, _LAST_NAMES_WHITE), + ("Native Hawaiian or Pacific Islander", 0.01, _NAMES_F_ASIAN, _NAMES_M_ASIAN, _LAST_NAMES_ASIAN), + ("Other / Multiracial", 0.02, _NAMES_F_WHITE, _NAMES_M_WHITE, _LAST_NAMES_WHITE), +] +_ETH_NAMES = [(e[0], e[2], e[3], e[4]) for e in _ETHNICITY_GROUPS] +_ETH_WEIGHTS = [e[1] for e in _ETHNICITY_GROUPS] + +# City pool weighted by US metropolitan population (2020 Census) +_CITIES = [ + ("New York","NY",0.060),("Los Angeles","CA",0.045),("Chicago","IL",0.033), + ("Houston","TX",0.027),("Phoenix","AZ",0.020),("Philadelphia","PA",0.018), + ("San Antonio","TX",0.016),("San Diego","CA",0.016),("Dallas","TX",0.015), + ("San Jose","CA",0.013),("Austin","TX",0.013),("Jacksonville","FL",0.011), + ("Fort Worth","TX",0.010),("Columbus","OH",0.010),("Charlotte","NC",0.010), + ("Indianapolis","IN",0.009),("San Francisco","CA",0.009),("Seattle","WA",0.009), + ("Denver","CO",0.009),("Nashville","TN",0.009),("Boston","MA",0.009), + ("Baltimore","MD",0.008),("Louisville","KY",0.007),("Portland","OR",0.007), + ("Las Vegas","NV",0.007),("Milwaukee","WI",0.006),("Albuquerque","NM",0.006), + ("Tucson","AZ",0.006),("Fresno","CA",0.005),("Sacramento","CA",0.005), + ("Atlanta","GA",0.009),("Kansas City","MO",0.005),("Omaha","NE",0.004), + ("Raleigh","NC",0.005),("Cleveland","OH",0.005),("Minneapolis","MN",0.006), + ("Miami","FL",0.008),("Tampa","FL",0.007),("New Orleans","LA",0.005), + ("Pittsburgh","PA",0.006),("Memphis","TN",0.005),("Richmond","VA",0.004), + ("Birmingham","AL",0.004),("Salt Lake City","UT",0.004),("Hartford","CT",0.004), + ("Buffalo","NY",0.004),("Rochester","NY",0.003),("Providence","RI",0.003), + ("Des Moines","IA",0.003),("Little Rock","AR",0.003),("Madison","WI",0.003), +] +_CITY_NAMES = [(c[0], c[1]) for c in _CITIES] +_CITY_WEIGHTS = [c[2] for c in _CITIES] + +# Comorbidity prevalence in US oncology patients (literature-based) +_COMORBIDITY_POOL = [ + ("Type 2 Diabetes", 0.18), + ("Hypertension", 0.42), + ("Coronary Artery Disease",0.09), + ("COPD", 0.08), + ("Chronic Kidney Disease", 0.12), + ("Obesity (BMI>30)", 0.36), + ("Depression/Anxiety", 0.22), + ("Hypothyroidism", 0.07), + ("Atrial Fibrillation", 0.05), + ("Osteoporosis", 0.06), +] + +# Insurance status (US cancer patient distribution, KFF 2023) +_INSURANCE = [ + ("Private/Employer", 0.48), + ("Medicare", 0.30), + ("Medicaid", 0.14), + ("Uninsured", 0.05), + ("VA/Military", 0.03), +] +_INS_LABELS = [i[0] for i in _INSURANCE] +_INS_WEIGHTS = [i[1] for i in _INSURANCE] + +# ECOG score distribution varies by condition severity +_ECOG_BY_CONDITION: dict[str, list[float]] = { + # [P(0), P(1), P(2), P(3)] + "breast cancer": [0.35, 0.40, 0.18, 0.07], + "prostate cancer": [0.30, 0.40, 0.20, 0.10], + "non-small cell lung cancer": [0.20, 0.38, 0.28, 0.14], + "colorectal cancer": [0.28, 0.40, 0.22, 0.10], + "ovarian cancer": [0.25, 0.40, 0.25, 0.10], + "melanoma": [0.40, 0.38, 0.15, 0.07], + "leukemia": [0.25, 0.38, 0.25, 0.12], + "lymphoma": [0.28, 0.40, 0.22, 0.10], + "glioblastoma": [0.15, 0.35, 0.30, 0.20], + "pancreatic cancer": [0.15, 0.32, 0.33, 0.20], + "bladder cancer": [0.28, 0.40, 0.22, 0.10], + "renal cell carcinoma": [0.32, 0.40, 0.20, 0.08], + "thyroid cancer": [0.50, 0.35, 0.12, 0.03], + "multiple myeloma": [0.22, 0.38, 0.28, 0.12], + "endometrial cancer": [0.30, 0.40, 0.22, 0.08], + "cervical cancer": [0.25, 0.40, 0.25, 0.10], + "gastric cancer": [0.18, 0.35, 0.30, 0.17], + "hepatocellular carcinoma": [0.15, 0.32, 0.33, 0.20], + "head and neck cancer": [0.20, 0.38, 0.28, 0.14], + "sarcoma": [0.30, 0.40, 0.22, 0.08], +} + + +# ── Condition profiles (SEER-weighted) ─────────────────────────────────────── +# count_weight → how many of the 100 k total patients come from this condition +# biomarker_prevalences → {biomarker_id: probability} (TCGA / literature) + +_CONDITION_PROFILES: dict[str, dict] = { + "breast cancer": { + "icd10_prefix": "C50", "sex": "FEMALE", "count_weight": 0.155, + "age_range": (25, 82), "age_mode": 62, + "stages": ["I","II","III","IV"], "stage_weights": [0.28, 0.32, 0.25, 0.15], + "biomarker_prevalences": { + "ER_POS":0.75,"PR_POS":0.65,"HER2_POS":0.17,"HER2_NEG":0.83, + "TNBC":0.12,"BRCA1_MUT":0.05,"BRCA2_MUT":0.04, + "PIK3CA_MUT":0.35,"TP53_MUT":0.28, + }, + "med_pool": ["trastuzumab","bevacizumab","capecitabine","olaparib","pembrolizumab"], + "prior_chemo_rate": 0.65, + }, + "non-small cell lung cancer": { + "icd10_prefix": "C34", "sex": "ALL", "count_weight": 0.130, + "age_range": (40, 84), "age_mode": 68, + "stages": ["I","II","III","IV"], "stage_weights": [0.09, 0.12, 0.28, 0.51], + "biomarker_prevalences": { + "EGFR_L858R":0.08,"EGFR_DEL19":0.09,"EGFR_T790M":0.05, + "ALK_FUSION":0.04,"ROS1_FUSION":0.02,"MET_EX14":0.03, + "KRAS_G12C":0.13,"PDL1_HIGH":0.28,"PDL1_LOW":0.30,"PDL1_NEG":0.42, + }, + "med_pool": ["osimertinib","pembrolizumab","nivolumab","erlotinib","atezolizumab","durvalumab"], + "prior_chemo_rate": 0.55, + }, + "prostate cancer": { + "icd10_prefix": "C61", "sex": "MALE", "count_weight": 0.095, + "age_range": (45, 86), "age_mode": 67, + "stages": ["I","II","III","IV"], "stage_weights": [0.18, 0.28, 0.28, 0.26], + "biomarker_prevalences": { + "PSA_ELEVATED":0.90,"BRCA2_MUT":0.05,"PTEN_LOSS":0.25,"AR_V7":0.20, + }, + "med_pool": ["enzalutamide","bevacizumab","olaparib","pembrolizumab"], + "prior_chemo_rate": 0.40, + }, + "colorectal cancer": { + "icd10_prefix": "C18", "sex": "ALL", "count_weight": 0.085, + "age_range": (35, 82), "age_mode": 65, + "stages": ["I","II","III","IV"], "stage_weights": [0.18, 0.26, 0.30, 0.26], + "biomarker_prevalences": { + "MSI_H":0.10,"MSS":0.90,"KRAS_WT":0.42, + "BRAF_V600E":0.08,"NRAS_MUT":0.05,"KRAS_G12C":0.04, + }, + "med_pool": ["bevacizumab","cetuximab","capecitabine","pembrolizumab"], + "prior_chemo_rate": 0.60, + }, + "melanoma": { + "icd10_prefix": "C43", "sex": "ALL", "count_weight": 0.055, + "age_range": (20, 80), "age_mode": 57, + "stages": ["I","II","III","IV"], "stage_weights": [0.30, 0.28, 0.22, 0.20], + "biomarker_prevalences": { + "BRAF_V600E":0.45,"BRAF_V600K":0.06,"TMB_HIGH":0.35,"NRAS_MEL":0.20, + }, + "med_pool": ["pembrolizumab","nivolumab","dabrafenib","vemurafenib","ipilimumab"], + "prior_chemo_rate": 0.30, + }, + "bladder cancer": { + "icd10_prefix": "C67", "sex": "ALL", "count_weight": 0.045, + "age_range": (45, 85), "age_mode": 69, + "stages": ["I","II","III","IV"], "stage_weights": [0.28, 0.24, 0.26, 0.22], + "biomarker_prevalences": { + "FGFR3_MUT":0.20,"PDL1_HIGH":0.22,"TMB_HIGH":0.15,"TP53_MUT":0.30, + }, + "med_pool": ["pembrolizumab","atezolizumab","nivolumab","erdafitinib"], + "prior_chemo_rate": 0.45, + }, + "renal cell carcinoma": { + "icd10_prefix": "C64", "sex": "ALL", "count_weight": 0.042, + "age_range": (40, 82), "age_mode": 64, + "stages": ["I","II","III","IV"], "stage_weights": [0.25, 0.20, 0.25, 0.30], + "biomarker_prevalences": { + "VHL_LOSS":0.55,"MTOR_MUT":0.15,"PDL1_HIGH":0.18, + }, + "med_pool": ["pembrolizumab","nivolumab","bevacizumab","sunitinib"], + "prior_chemo_rate": 0.25, + }, + "lymphoma": { + "icd10_prefix": "C85", "sex": "ALL", "count_weight": 0.042, + "age_range": (20, 80), "age_mode": 58, + "stages": ["I","II","III","IV"], "stage_weights": [0.20, 0.25, 0.30, 0.25], + "biomarker_prevalences": { + "CD20_POS":0.85,"EZH2_MUT":0.22,"TMB_HIGH":0.12,"PDL1_HIGH":0.15, + }, + "med_pool": ["rituximab","pembrolizumab","nivolumab"], + "prior_chemo_rate": 0.55, + }, + "endometrial cancer": { + "icd10_prefix": "C54", "sex": "FEMALE", "count_weight": 0.038, + "age_range": (40, 82), "age_mode": 63, + "stages": ["I","II","III","IV"], "stage_weights": [0.50, 0.15, 0.20, 0.15], + "biomarker_prevalences": { + "MSI_H":0.25,"POLE_MUT":0.07,"CTNNB1_MUT":0.30,"TP53_MUT":0.25,"PIK3CA_MUT":0.35, + }, + "med_pool": ["pembrolizumab","bevacizumab","olaparib","capecitabine"], + "prior_chemo_rate": 0.40, + }, + "leukemia": { + "icd10_prefix": "C91", "sex": "ALL", "count_weight": 0.035, + "age_range": (18, 82), "age_mode": 55, + "stages": ["I","II","III","IV"], "stage_weights": [0.25, 0.25, 0.28, 0.22], + "biomarker_prevalences": { + "BCR_ABL1":0.30,"FLT3_ITD":0.25,"NPM1_MUT":0.30,"TP53_MUT":0.15, + }, + "med_pool": ["imatinib","rituximab","pembrolizumab"], + "prior_chemo_rate": 0.60, + }, + "pancreatic cancer": { + "icd10_prefix": "C25", "sex": "ALL", "count_weight": 0.033, + "age_range": (40, 82), "age_mode": 68, + "stages": ["I","II","III","IV"], "stage_weights": [0.05, 0.12, 0.28, 0.55], + "biomarker_prevalences": { + "KRAS_G12C":0.07,"BRCA2_MUT":0.06,"TP53_MUT":0.55,"MSI_H":0.02, + }, + "med_pool": ["capecitabine","erlotinib","olaparib"], + "prior_chemo_rate": 0.50, + }, + "thyroid cancer": { + "icd10_prefix": "C73", "sex": "FEMALE", "count_weight": 0.030, + "age_range": (20, 75), "age_mode": 47, + "stages": ["I","II","III","IV"], "stage_weights": [0.55, 0.20, 0.15, 0.10], + "biomarker_prevalences": { + "BRAF_THYROID":0.45,"RET_FUSION":0.08,"NTRK_FUSION":0.05, + }, + "med_pool": ["pembrolizumab","dabrafenib","vemurafenib"], + "prior_chemo_rate": 0.15, + }, + "multiple myeloma": { + "icd10_prefix": "C90", "sex": "ALL", "count_weight": 0.025, + "age_range": (45, 84), "age_mode": 67, + "stages": ["I","II","III","IV"], "stage_weights": [0.20, 0.28, 0.30, 0.22], + "biomarker_prevalences": { + "WHSC1_MUT":0.20,"CDKN2A_LOSS":0.30,"TP53_MUT":0.15, + }, + "med_pool": ["pembrolizumab","rituximab","bevacizumab"], + "prior_chemo_rate": 0.65, + }, + "gastric cancer": { + "icd10_prefix": "C16", "sex": "ALL", "count_weight": 0.018, + "age_range": (35, 82), "age_mode": 65, + "stages": ["I","II","III","IV"], "stage_weights": [0.10, 0.20, 0.35, 0.35], + "biomarker_prevalences": { + "ERBB2_GC":0.15,"MSI_H":0.10,"PDL1_HIGH":0.20,"TP53_MUT":0.40, + }, + "med_pool": ["trastuzumab","pembrolizumab","nivolumab","capecitabine"], + "prior_chemo_rate": 0.55, + }, + "ovarian cancer": { + "icd10_prefix": "C56", "sex": "FEMALE", "count_weight": 0.018, + "age_range": (35, 80), "age_mode": 62, + "stages": ["I","II","III","IV"], "stage_weights": [0.12, 0.14, 0.40, 0.34], + "biomarker_prevalences": { + "BRCA1_MUT":0.12,"BRCA2_MUT":0.08,"TP53_MUT":0.60,"PIK3CA_MUT":0.08, + }, + "med_pool": ["olaparib","bevacizumab","pembrolizumab"], + "prior_chemo_rate": 0.75, + }, + "hepatocellular carcinoma": { + "icd10_prefix": "C22", "sex": "ALL", "count_weight": 0.015, + "age_range": (35, 80), "age_mode": 62, + "stages": ["I","II","III","IV"], "stage_weights": [0.10, 0.18, 0.32, 0.40], + "biomarker_prevalences": { + "HBV_POS":0.25,"TERT_MUT":0.55,"TP53_MUT":0.20,"CTNNB1_MUT":0.25, + }, + "med_pool": ["pembrolizumab","nivolumab","bevacizumab","atezolizumab"], + "prior_chemo_rate": 0.35, + }, + "glioblastoma": { + "icd10_prefix": "C71", "sex": "ALL", "count_weight": 0.012, + "age_range": (30, 76), "age_mode": 62, + "stages": ["III","IV"], "stage_weights": [0.28, 0.72], + "biomarker_prevalences": { + "IDH1_WT":0.90,"IDH1_R132H":0.10,"MGMT_METH":0.45, + "EGFR_AMP":0.40,"TP53_MUT":0.25, + }, + "med_pool": ["bevacizumab","pembrolizumab"], + "prior_chemo_rate": 0.70, + }, + "head and neck cancer": { + "icd10_prefix": "C10", "sex": "ALL", "count_weight": 0.012, + "age_range": (30, 80), "age_mode": 60, + "stages": ["I","II","III","IV"], "stage_weights": [0.10, 0.15, 0.30, 0.45], + "biomarker_prevalences": { + "HPV_HNSC":0.60,"PIK3CA_HNC":0.25,"PDL1_HIGH":0.20,"TP53_MUT":0.45, + }, + "med_pool": ["pembrolizumab","nivolumab","cetuximab"], + "prior_chemo_rate": 0.55, + }, + "cervical cancer": { + "icd10_prefix": "C53", "sex": "FEMALE", "count_weight": 0.008, + "age_range": (20, 72), "age_mode": 48, + "stages": ["I","II","III","IV"], "stage_weights": [0.28, 0.25, 0.25, 0.22], + "biomarker_prevalences": { + "HPV_POS":0.99,"PDL1_HIGH":0.25,"PIK3CA_MUT":0.25, + }, + "med_pool": ["pembrolizumab","bevacizumab","nivolumab"], + "prior_chemo_rate": 0.50, + }, + "sarcoma": { + "icd10_prefix": "C49", "sex": "ALL", "count_weight": 0.007, + "age_range": (15, 75), "age_mode": 45, + "stages": ["I","II","III","IV"], "stage_weights": [0.20, 0.25, 0.30, 0.25], + "biomarker_prevalences": { + "CDK4_AMP":0.20,"MDM2_AMP":0.18,"TP53_MUT":0.25, + }, + "med_pool": ["pembrolizumab","nivolumab","bevacizumab"], + "prior_chemo_rate": 0.45, + }, +} + +random.seed(42) # reproducible synthetic data + + +def _parse_age(age_str: str) -> int | None: + if not age_str: + return None + try: + return int(age_str.split()[0]) + except Exception: + return None + + +def _skewed_age(age_range: tuple[int, int], mode: int) -> int: + """Triangle-distributed age reflecting real incidence peak.""" + lo, hi = age_range + mode = max(lo, min(hi, mode)) + return int(random.triangular(lo, hi, mode)) + + +def _pick_biomarkers(prevalences: dict[str, float], rng: random.Random) -> list[str]: + """Independent Bernoulli draw per biomarker based on literature prevalence.""" + return [bm for bm, p in prevalences.items() if rng.random() < p] + + +def _pick_comorbidities(rng: random.Random, age: int) -> list[str]: + """Age-scaled comorbidity draw.""" + scale = 1.0 + max(0, (age - 50)) * 0.015 # comorbidities rise ~1.5% per year after 50 + return [c for c, p in _COMORBIDITY_POOL if rng.random() < min(p * scale, 0.95)] + + +def _generate_patient(pid: str, condition: str, profile: dict, seq: int, rng: random.Random) -> dict: + sex_raw = profile["sex"] + sex = rng.choice(["MALE","FEMALE"]) if sex_raw == "ALL" else sex_raw + + age = _skewed_age(profile["age_range"], profile["age_mode"]) + stage = rng.choices(profile["stages"], weights=profile["stage_weights"])[0] + ecog_weights = _ECOG_BY_CONDITION.get(condition, [0.28, 0.40, 0.22, 0.10]) + ecog = rng.choices([0, 1, 2, 3], weights=ecog_weights)[0] + + eth_group = rng.choices(_ETH_NAMES, weights=_ETH_WEIGHTS)[0] + ethnicity, names_f, names_m, last_names = eth_group + first = rng.choice(names_f if sex == "FEMALE" else names_m) + last = rng.choice(last_names) + + city, state = rng.choices(_CITY_NAMES, weights=_CITY_WEIGHTS)[0] + insurance = rng.choices(_INS_LABELS, weights=_INS_WEIGHTS)[0] + + biomarkers = _pick_biomarkers(profile["biomarker_prevalences"], rng) + comorbidities = _pick_comorbidities(rng, age) + + med_pool = profile["med_pool"] + n_med = min(rng.randint(1, 2), len(med_pool)) + medications = rng.sample(med_pool, n_med) + + prior_chemo = rng.random() < profile.get("prior_chemo_rate", 0.5) + prior_radiation = rng.random() < 0.35 + prior_surgery = rng.random() < 0.50 + prior_lines = rng.randint(0, 3) if prior_chemo else 0 + + return { + "id": pid, + "name": f"{first} {last}", + "age": age, + "sex": sex, + "stage": stage, + "ecog": ecog, + "condition": condition, + "icd10_prefix": profile["icd10_prefix"], + "city": city, + "state": state, + "ethnicity": ethnicity, + "insurance": insurance, + "biomarkers": biomarkers, + "medications": medications, + "comorbidities": comorbidities, + "prior_chemo": prior_chemo, + "prior_radiation": prior_radiation, + "prior_surgery": prior_surgery, + "prior_lines_of_therapy": prior_lines, + "source": "synthetic_v2", + } + + +# ── Batch write helpers ─────────────────────────────────────────────────────── + +_BATCH_SIZE = 500 + + +def _batch_write_patients(patients: list[dict]) -> None: + neo4j_conn.run_query(""" + UNWIND $patients AS p + MERGE (n:Patient {id: p.id}) + SET n += { + name: p.name, age: p.age, sex: p.sex, stage: p.stage, + ecog: p.ecog, condition: p.condition, icd10_prefix: p.icd10_prefix, + city: p.city, state: p.state, ethnicity: p.ethnicity, + insurance: p.insurance, biomarkers: p.biomarkers, + medications: p.medications, comorbidities: p.comorbidities, + prior_chemo: p.prior_chemo, prior_radiation: p.prior_radiation, + prior_surgery: p.prior_surgery, + prior_lines_of_therapy: p.prior_lines_of_therapy, + source: p.source, updated_at: datetime() + } + """, {"patients": patients}) + + +def _batch_write_biomarker_links(links: list[dict]) -> None: + neo4j_conn.run_query(""" + UNWIND $links AS l + MATCH (p:Patient {id: l.pid}) + MATCH (b:Biomarker {id: l.bm_id}) + MERGE (p)-[:HAS_BIOMARKER]->(b) + """, {"links": links}) + + +def _batch_write_diagnosis_links(links: list[dict]) -> None: + # links already have resolved diagnosis_code (exact match, no scan needed) + neo4j_conn.run_query(""" + UNWIND $links AS l + MATCH (p:Patient {id: l.pid}) + MATCH (d:Diagnosis {code: l.diagnosis_code}) + MERGE (p)-[:HAS_DIAGNOSIS]->(d) + """, {"links": links}) + + +def _batch_write_eligibility(edges: list[dict]) -> None: + neo4j_conn.run_query(""" + UNWIND $edges AS e + MATCH (p:Patient {id: e.pid}) + MATCH (t:Trial {id: e.tid}) + MERGE (p)-[r:ELIGIBLE_FOR]->(t) + SET r.score = e.score, r.matched_at = datetime() + """, {"edges": edges}) + + +# ── Main patient seeder ─────────────────────────────────────────────────────── + +def seed_patients_and_eligibility(total_patients: int = 100_000) -> int: + print(f"\n[6/6] Generating {total_patients:,} clinically-informed synthetic patients...") + print(" (SEER incidence weights · TCGA biomarker prevalence · US Census demographics)") + + # Pre-load trials grouped by condition + trial_rows = neo4j_conn.run_query(""" + MATCH (t:Trial {status: 'RECRUITING'}) + RETURN t.id AS id, t.condition AS condition, t.sex AS sex, + t.min_age AS min_age, t.max_age AS max_age + """) + trials_by_condition: dict[str, list[dict]] = {} + for row in (trial_rows or []): + cond = (row.get("condition") or "").lower().strip() + trials_by_condition.setdefault(cond, []).append(row) + + # Calculate per-condition counts from SEER weights + total_weight = sum(p["count_weight"] for p in _CONDITION_PROFILES.values()) + condition_counts = { + cond: max(1, round(total_patients * prof["count_weight"] / total_weight)) + for cond, prof in _CONDITION_PROFILES.items() + } + # Adjust rounding error so we hit exactly total_patients + allocated = sum(condition_counts.values()) + diff = total_patients - allocated + largest = max(condition_counts, key=lambda c: condition_counts[c]) + condition_counts[largest] += diff + + # Pre-load one canonical Diagnosis code per ICD-10 prefix + all_prefixes = list({p["icd10_prefix"] for p in _CONDITION_PROFILES.values()}) + dx_canon: dict[str, str] = {} + for prefix in all_prefixes: + rows = neo4j_conn.run_query( + "MATCH (d:Diagnosis) WHERE d.code STARTS WITH $p RETURN d.code AS code ORDER BY d.code LIMIT 1", + {"p": prefix} + ) + if rows: + dx_canon[prefix] = rows[0]["code"] + + # Check existing patients per condition to allow resume + existing_rows = neo4j_conn.run_query(""" + MATCH (p:Patient) WHERE p.source = 'synthetic_v2' + RETURN p.condition AS condition, count(p) AS cnt + """) + existing_by_condition: dict[str, int] = { + r["condition"]: r["cnt"] for r in (existing_rows or []) if r.get("condition") + } + + rng = random.Random(42) + grand_total = 0 + grand_edges = 0 + + for condition, profile in _CONDITION_PROFILES.items(): + icd_prefix = profile["icd10_prefix"] + n = condition_counts[condition] + already = existing_by_condition.get(condition, 0) + condition_trials = trials_by_condition.get(condition, []) + + if already >= n: + print(f" {condition}: {n:,} patients — already done, skipping") + grand_total += n + # advance RNG to stay deterministic + for _ in range(n): + rng.random() + continue + + skip = already + todo = n - skip + print(f" {condition}: {n:,} patients ({len(condition_trials)} trials)" + + (f" [resuming from {skip:,}]" if skip else "")) + + patient_batch: list[dict] = [] + bm_links: list[dict] = [] + dx_links: list[dict] = [] + elig_edges: list[dict] = [] + + # Advance RNG past already-written patients so IDs/values stay consistent + for _ in range(skip): + rng.random() + + condition_written = 0 + for i in range(skip, n): + pid = f"P_{icd_prefix}_{grand_total + i + 1:06d}" + p = _generate_patient(pid, condition, profile, i, rng) + + patient_batch.append(p) + if icd_prefix in dx_canon: + dx_links.append({"pid": pid, "diagnosis_code": dx_canon[icd_prefix]}) + for bm in p["biomarkers"]: + bm_links.append({"pid": pid, "bm_id": bm}) + + # Eligibility edges — apply sex/age/ECOG filters + for trial in condition_trials: + t_sex = (trial.get("sex") or "ALL").upper() + t_min = _parse_age(trial.get("min_age") or "") + t_max = _parse_age(trial.get("max_age") or "") + if t_sex not in ("ALL", "BOTH", p["sex"]): + continue + if t_min is not None and p["age"] < t_min: + continue + if t_max is not None and p["age"] > t_max: + continue + if p["ecog"] > 2: + continue + base = rng.uniform(0.55, 0.90) + bm_bonus = 0.08 if p["biomarkers"] else 0.0 + score = round(min(base + bm_bonus, 0.99), 2) + elig_edges.append({"pid": pid, "tid": trial["id"], "score": score}) + + condition_written += 1 + + # Flush batches + if len(patient_batch) >= _BATCH_SIZE: + _batch_write_patients(patient_batch) + _batch_write_diagnosis_links(dx_links) + if bm_links: + _batch_write_biomarker_links(bm_links) + if elig_edges: + _batch_write_eligibility(elig_edges) + grand_edges += len(elig_edges) + patient_batch, dx_links, bm_links, elig_edges = [], [], [], [] + + # Flush remainder + if patient_batch: + _batch_write_patients(patient_batch) + _batch_write_diagnosis_links(dx_links) + if bm_links: + _batch_write_biomarker_links(bm_links) + if elig_edges: + _batch_write_eligibility(elig_edges) + grand_edges += len(elig_edges) + + grand_total += n + print(f" ↳ wrote {condition_written:,} patients | total so far: {grand_total:,}/{total_patients:,} | edges: {grand_edges:,}") + + print(f"\n ✓ Total patients: {grand_total:,}") + print(f" ✓ Total ELIGIBLE_FOR edges: {grand_edges:,}") + return grand_total + + +# ── Main entry point ────────────────────────────────────────────────────────── + +async def run_seeder(conditions: list[str] | None = None): + start = time.time() + print("=" * 60) + print("ClinicalMatch AI — Graph Seeder v2") + print("100 k synthetic patients · 20 oncology conditions") + print("=" * 60) + + async with httpx.AsyncClient(headers={"User-Agent": "ClinicalMatchAI/2.0 (hackathon@research.org)"}) as client: + n_trials = await seed_trials(client) + n_meds = await seed_medications(client) + n_dx = await seed_diagnoses(client) + n_pubs = await seed_literature(client) + + n_bm = seed_biomarkers() + derive_eligibility_relationships() + n_patients = seed_patients_and_eligibility(total_patients=100_000) + + elapsed = time.time() - start + print(f"\n{'=' * 60}") + print(f"Seeding complete in {elapsed / 60:.1f} min") + print(f" Trials: {n_trials}") + print(f" Medications: {n_meds}") + print(f" Diagnoses: {n_dx}") + print(f" Publications: {n_pubs}") + print(f" Biomarkers: {n_bm}") + print(f" Patients: {n_patients:,}") + print("=" * 60) + + +def seed_sync(): + asyncio.run(run_seeder()) + + +if __name__ == "__main__": + import sys + conditions = sys.argv[1:] if len(sys.argv) > 1 else None + asyncio.run(run_seeder(conditions)) diff --git a/backend/graphrag.py b/backend/graphrag.py new file mode 100644 index 0000000000000000000000000000000000000000..2593962d046c9eadba1b22396f70dbd60e21d1b6 --- /dev/null +++ b/backend/graphrag.py @@ -0,0 +1,125 @@ +from langchain_community.graphs import Neo4jGraph +from langchain_community.chains.graph_qa.cypher import GraphCypherQAChain +from langchain_openai import ChatOpenAI +from langchain_core.prompts import PromptTemplate +from langchain_core.messages import BaseMessage, AIMessage +from langchain_core.outputs import ChatResult, ChatGeneration +import re +import os +from dotenv import load_dotenv + +load_dotenv() + +graph = Neo4jGraph( + url=os.getenv("NEO4J_URI"), + username=os.getenv("NEO4J_USERNAME"), + password=os.getenv("NEO4J_PASSWORD"), + database=os.getenv("NEO4J_DATABASE", "neo4j"), +) + + +def _strip_thinking(text: str) -> str: + """Remove ... blocks that reasoning models emit before the actual answer.""" + # Strip block tags (including variations like ) + text = re.sub(r".*?", "", text, flags=re.DOTALL | re.IGNORECASE) + return text.strip() + + +class _ThinkStrippedLLM(ChatOpenAI): + """ChatOpenAI wrapper that strips reasoning tokens from every response.""" + + def _create_chat_result(self, response, generation_info=None) -> ChatResult: + result: ChatResult = super()._create_chat_result(response, generation_info) + cleaned = [] + for gen in result.generations: + raw = gen.message.content or "" + clean = _strip_thinking(raw) + cleaned.append(ChatGeneration(message=AIMessage(content=clean), generation_info=gen.generation_info)) + return ChatResult(generations=cleaned, llm_output=result.llm_output) + + +llm = _ThinkStrippedLLM( + model=os.getenv("OPENAI_MODEL", "qwen/qwen3-32b"), + openai_api_key=os.getenv("OPENAI_API_KEY"), + openai_api_base=os.getenv("OPENAI_BASE_URL"), + temperature=0, +) + +_CYPHER_GENERATION_TEMPLATE = """You are an expert Neo4j Cypher query writer for a clinical trial matching system. + +Schema: +{schema} + +Node property conventions (IMPORTANT — use these exact property names and value formats): +- Patient: id (e.g. "P-001"), name, age (integer), sex ("M"/"F"), ethnicity, city, state, ecog_score (integer) +- Trial: id (NCT id), title, condition (lowercase, e.g. "breast cancer"), phase, status, sponsor +- Diagnosis: id, name (e.g. "Breast Cancer"), icd10 (e.g. "C50") +- Biomarker: id (e.g. "HER2_POS", "EGFR_MUT", "BRCA1_MUT", "PD_L1_POS"), name (e.g. "HER2 Positive", "EGFR Mutation") +- Medication: id (e.g. "TAMOXIFEN"), name (e.g. "Tamoxifen") +- StudySite: id, name, city, state, lat, lon, trials (integer), enrolled (integer), capacity (integer) + +Relationships: +- (Patient)-[:ELIGIBLE_FOR {{score: float}}]->(Trial) +- (Patient)-[:HAS_DIAGNOSIS]->(Diagnosis) +- (Patient)-[:HAS_BIOMARKER]->(Biomarker) +- (Patient)-[:TAKES_MEDICATION]->(Medication) +- (Trial)-[:LOCATED_AT]->(StudySite) + +Rules: +- For biomarker lookups, use the `id` property with uppercase underscore format, e.g. `{{id: 'HER2_POS'}}` NOT `{{name: 'HER2', status: 'positive'}}` +- For condition lookups on Trial nodes, use lowercase: `t.condition = 'breast cancer'` +- Always use relationship pattern (Patient)-[:ELIGIBLE_FOR]->(Trial) to find eligible patients +- Limit results to 25 unless asked for more + +Question: {question} +Cypher query:""" + +_CYPHER_PROMPT = PromptTemplate( + input_variables=["schema", "question"], + template=_CYPHER_GENERATION_TEMPLATE, +) + +graph_chain = GraphCypherQAChain.from_llm( + llm=llm, + graph=graph, + verbose=True, + allow_dangerous_requests=True, + cypher_prompt=_CYPHER_PROMPT, +) + + +def retrieve_patient_trial_matches(patient_id: str) -> list: + query = f""" + MATCH (p:Patient {{id: '{patient_id}'}})-[:HAS_DIAGNOSIS]->(d:Diagnosis)-[:ELIGIBLE_FOR]->(t:Trial) + RETURN p.id as patient, d.name as diagnosis, t.id as trial, t.phase as phase, t.condition as condition + """ + try: + return graph.query(query) + except Exception as e: + print(f"[graphrag] query error: {e}") + return [] + + +def rag_query(question: str) -> str: + try: + result = graph_chain.run(question) + return _strip_thinking(result) if result else "No results found." + except Exception as e: + err = str(e) + # Surface a clean message instead of the raw Neo4j stack trace + if "" in err or "SyntaxError" in err: + return "The query model returned unexpected output. Please rephrase your question (e.g. 'List patients eligible for breast cancer trials')." + return f"Graph query error: {err}" + + +def get_graph_stats() -> dict: + try: + result = graph.query(""" + MATCH (p:Patient) WITH count(p) as patients + MATCH (t:Trial) WITH patients, count(t) as trials + MATCH (d:Diagnosis) WITH patients, trials, count(d) as diagnoses + RETURN patients, trials, diagnoses + """) + return {**(result[0] if result else {}), "status": "connected"} + except Exception as e: + return {"patients": 0, "trials": 0, "diagnoses": 0, "status": str(e)} diff --git a/backend/intake_matching.py b/backend/intake_matching.py new file mode 100644 index 0000000000000000000000000000000000000000..707b53fba6204e70c6b1dbc50dc4bc8cee8c2c6c --- /dev/null +++ b/backend/intake_matching.py @@ -0,0 +1,374 @@ +""" +Intake-based trial matching — accepts raw clinical data (SI units) and scores +it against Trial nodes in the graph. No patient ID required. + +SI unit reference: + Hemoglobin: g/dL (×10 → g/L) + WBC: ×10⁹/L + ANC: ×10⁹/L + Platelets: ×10⁹/L + Creatinine: μmol/L (÷88.4 → mg/dL) + eGFR: mL/min/1.73m² + Bilirubin: μmol/L (÷17.1 → mg/dL) + ALT/AST: U/L + Albumin: g/dL +""" +import re +import uuid +from typing import Optional +from neo4j_setup import neo4j_conn + + +# ── Biomarker registry ──────────────────────────────────────────────────────── +# Maps graph node id → human label → search terms found in eligibility text +BIOMARKER_REGISTRY = { + "HER2_POS": ("HER2 Positive", ["HER2-positive", "HER2+", "HER2 amplified", "HER2/neu positive"]), + "HER2_NEG": ("HER2 Negative", ["HER2-negative", "HER2-"]), + "ER_POS": ("ER Positive", ["ER-positive", "ER+", "estrogen receptor positive"]), + "PR_POS": ("PR Positive", ["PR-positive", "PR+", "progesterone receptor positive"]), + "BRCA1_MUT": ("BRCA1 Mutation", ["BRCA1", "BRCA1 mutation", "BRCA1-mutated"]), + "BRCA2_MUT": ("BRCA2 Mutation", ["BRCA2", "BRCA2 mutation", "BRCA2-mutated"]), + "EGFR_MUT": ("EGFR Mutation", ["EGFR mutation", "EGFR-mutated", "EGFR exon 19", "EGFR exon 21"]), + "ALK_POS": ("ALK Rearrangement",["ALK rearrangement", "ALK-positive", "ALK fusion"]), + "ROS1_POS": ("ROS1 Rearrangement",["ROS1 rearrangement", "ROS1-positive", "ROS1 fusion"]), + "PD_L1_POS": ("PD-L1 Positive", ["PD-L1", "PD-L1 positive", "PDL1"]), + "KRAS_WT": ("KRAS Wild-type", ["KRAS wild-type", "KRAS WT", "KRAS-wildtype"]), + "BRAF_MUT": ("BRAF V600E", ["BRAF V600E", "BRAF mutation", "BRAF-mutated"]), + "MSI_H": ("MSI-High", ["MSI-H", "microsatellite instability-high", "MSI high", "dMMR"]), + "NRAS_MUT": ("NRAS Mutation", ["NRAS mutation", "NRAS-mutated"]), + "FLT3_MUT": ("FLT3 Mutation", ["FLT3 mutation", "FLT3-mutated", "FLT3-ITD"]), + "IDH1_MUT": ("IDH1 Mutation", ["IDH1 mutation", "IDH1-mutated"]), + "IDH2_MUT": ("IDH2 Mutation", ["IDH2 mutation", "IDH2-mutated"]), + "BCR_ABL": ("BCR-ABL", ["BCR-ABL", "Philadelphia chromosome", "Ph-positive"]), + "TRIPLE_NEG":("Triple Negative", ["triple-negative", "TNBC", "triple negative breast"]), +} + + +# ── Age parsing ─────────────────────────────────────────────────────────────── + +def _parse_age_years(age_str: str) -> Optional[int]: + """'45 Years' → 45, '6 Months' → 0, '' → None""" + if not age_str: + return None + m = re.search(r"(\d+)\s*year", age_str, re.I) + if m: + return int(m.group(1)) + m = re.search(r"(\d+)\s*month", age_str, re.I) + if m: + return 0 + m = re.search(r"(\d+)", age_str) + if m: + return int(m.group(1)) + return None + + +# ── ECOG parsing from eligibility text ──────────────────────────────────────── + +def _max_ecog_from_text(text: str) -> Optional[int]: + """Extract maximum allowed ECOG from eligibility criteria text.""" + patterns = [ + r"ECOG\s+(?:performance\s+status\s+)?(?:of\s+)?(?:0\s*(?:or|-)\s*)?([0-4])", + r"performance\s+status\s+(?:of\s+)?(?:0\s*(?:or|-)\s*)?([0-4])", + r"Karnofsky\s+.*?(\d{2,3})\s*%", # convert KPS to ECOG approximately + ] + for pat in patterns: + m = re.search(pat, text, re.I) + if m: + val = int(m.group(1)) + if "Karnofsky" in pat: + # KPS 80-100 ≈ ECOG 0-1, 60-70 ≈ 2, 40-50 ≈ 3 + kps = val + val = 0 if kps >= 80 else 1 if kps >= 70 else 2 if kps >= 60 else 3 + return val + return None + + +# ── Lab value checking against eligibility text ─────────────────────────────── + +def _check_labs(labs: dict, eligibility_text: str) -> list[dict]: + """ + Parse common lab thresholds from eligibility text and check patient values. + Returns list of {criterion, patient_value, threshold, met}. + """ + results = [] + text = eligibility_text or "" + + def _find_threshold(patterns): + for pat in patterns: + m = re.search(pat, text, re.I) + if m: + return float(m.group(1)) + return None + + # Hemoglobin ≥ threshold (g/dL in text; patient value in g/dL) + hgb = labs.get("hemoglobin") + if hgb is not None: + # Try to find "hemoglobin >= X" or "Hgb >= X g/dL" + thresh = _find_threshold([ + r"hemoglobin\s*[≥>=]+\s*([\d.]+)\s*g/dL", + r"Hgb\s*[≥>=]+\s*([\d.]+)", + r"hemoglobin\s+of\s+at\s+least\s+([\d.]+)", + ]) + if thresh: + results.append({"criterion": f"Hemoglobin ≥ {thresh} g/dL", "patient_value": f"{hgb} g/dL", "met": hgb >= thresh}) + + # Platelets ≥ threshold (×10⁹/L) + plt = labs.get("platelets") + if plt is not None: + thresh = _find_threshold([ + r"platelet[s]?\s*[≥>=]+\s*([\d,]+)\s*[×x]?\s*10[⁹9]/L", + r"platelet[s]?\s+count\s*[≥>=]+\s*([\d,]+)", + r"platelet[s]?\s+of\s+at\s+least\s+([\d,]+)", + ]) + if thresh: + thresh_val = thresh / 1000 if thresh > 1000 else thresh # normalise if stored as /µL + results.append({"criterion": f"Platelets ≥ {thresh_val} ×10⁹/L", "patient_value": f"{plt} ×10⁹/L", "met": plt >= thresh_val}) + + # Creatinine ≤ threshold (μmol/L patient; text may be mg/dL or μmol/L) + cr = labs.get("creatinine") # patient value in μmol/L + if cr is not None: + # Most trial text uses mg/dL; convert patient value for comparison + cr_mgdl = cr / 88.4 + thresh = _find_threshold([ + r"creatinine\s*[≤<=]+\s*([\d.]+)\s*mg/dL", + r"serum\s+creatinine\s*[≤<=]+\s*([\d.]+)", + ]) + if thresh: + results.append({"criterion": f"Creatinine ≤ {thresh} mg/dL ({round(thresh*88.4)} μmol/L)", "patient_value": f"{cr} μmol/L ({round(cr_mgdl, 2)} mg/dL)", "met": cr_mgdl <= thresh}) + + # eGFR ≥ threshold + egfr = labs.get("egfr") + if egfr is not None: + thresh = _find_threshold([ + r"(?:eGFR|GFR|creatinine\s+clearance)\s*[≥>=]+\s*([\d.]+)", + r"glomerular\s+filtration\s+rate\s*[≥>=]+\s*([\d.]+)", + ]) + if thresh: + results.append({"criterion": f"eGFR ≥ {thresh} mL/min/1.73m²", "patient_value": f"{egfr} mL/min", "met": egfr >= thresh}) + + # Bilirubin ≤ threshold (μmol/L patient; text usually mg/dL) + bili = labs.get("bilirubin") + if bili is not None: + bili_mgdl = bili / 17.1 + thresh = _find_threshold([ + r"(?:total\s+)?bilirubin\s*[≤<=]+\s*([\d.]+)\s*(?:×\s*)?ULN", + r"(?:total\s+)?bilirubin\s*[≤<=]+\s*([\d.]+)\s*mg/dL", + ]) + if thresh: + # If "× ULN", ULN for bilirubin ≈ 1.0 mg/dL + results.append({"criterion": f"Bilirubin ≤ {thresh} mg/dL ({round(thresh*17.1)} μmol/L)", "patient_value": f"{bili} μmol/L ({round(bili_mgdl, 2)} mg/dL)", "met": bili_mgdl <= thresh}) + + # ANC ≥ threshold (×10⁹/L) + anc = labs.get("anc") + if anc is not None: + thresh = _find_threshold([ + r"(?:ANC|absolute\s+neutrophil\s+count)\s*[≥>=]+\s*([\d.]+)\s*[×x]?\s*10[⁹9]/L", + r"neutrophil[s]?\s*[≥>=]+\s*([\d.]+)", + ]) + if thresh: + results.append({"criterion": f"ANC ≥ {thresh} ×10⁹/L", "patient_value": f"{anc} ×10⁹/L", "met": anc >= thresh}) + + return results + + +# ── Main scoring function ───────────────────────────────────────────────────── + +def score_intake_against_trial(intake: dict, trial: dict) -> dict: + """ + Score a clinical intake profile against a single trial. + Returns {score, eligible, criteria_breakdown, risk_flags}. + """ + breakdown = [] + risk_flags = [] + points = 0 + max_points = 0 + + age = intake.get("age") + sex = intake.get("sex", "").upper() + ecog = intake.get("ecog") + biomarkers = set(intake.get("biomarkers", [])) + labs = intake.get("labs", {}) + prior_chemo = intake.get("prior_chemo", False) + eligibility_text = trial.get("eligibility_criteria", "") + + # ── Age (25 pts) ────────────────────────────────────────────────────────── + max_points += 25 + min_age = _parse_age_years(trial.get("min_age", "")) + max_age = _parse_age_years(trial.get("max_age", "")) + if age is not None: + age_ok = True + note = "" + if min_age and age < min_age: + age_ok = False + note = f"Trial requires ≥{min_age} years" + risk_flags.append(f"Below minimum age ({age} < {min_age})") + if max_age and age > max_age: + age_ok = False + note = f"Trial requires ≤{max_age} years" + risk_flags.append(f"Above maximum age ({age} > {max_age})") + if age_ok: + points += 25 + note = f"Within range ({min_age or '≥18'}–{max_age or 'no max'})" + breakdown.append({"criterion": "Age", "met": age_ok, "patient_value": f"{age} years", "note": note, "category": "demographics"}) + + # ── Sex (15 pts) ────────────────────────────────────────────────────────── + max_points += 15 + trial_sex = (trial.get("sex") or "ALL").upper() + sex_ok = trial_sex in ("ALL", sex, "") + if not sex_ok: + risk_flags.append(f"Sex mismatch (trial requires {trial_sex})") + else: + points += 15 + breakdown.append({"criterion": "Sex", "met": sex_ok, "patient_value": sex or "Not specified", "note": f"Trial: {trial_sex}", "category": "demographics"}) + + # ── ECOG (15 pts) ───────────────────────────────────────────────────────── + max_points += 15 + max_ecog = _max_ecog_from_text(eligibility_text) + if ecog is not None and max_ecog is not None: + ecog_ok = ecog <= max_ecog + if not ecog_ok: + risk_flags.append(f"ECOG {ecog} exceeds trial max ({max_ecog})") + else: + points += 15 + breakdown.append({"criterion": "ECOG Performance Status", "met": ecog_ok, "patient_value": f"ECOG {ecog}", "note": f"Trial requires ≤{max_ecog}", "category": "performance"}) + elif ecog is not None: + points += 10 # partial credit — can't verify from text + breakdown.append({"criterion": "ECOG Performance Status", "met": None, "patient_value": f"ECOG {ecog}", "note": "Could not parse limit from trial text", "category": "performance"}) + + # ── Biomarkers (30 pts) ─────────────────────────────────────────────────── + max_points += 30 + if biomarkers: + matched_bm = [] + for bm_id in biomarkers: + info = BIOMARKER_REGISTRY.get(bm_id) + if not info: + continue + label, search_terms = info + found_in_text = any(term.lower() in eligibility_text.lower() for term in search_terms) + matched_bm.append((label, found_in_text)) + + relevant = [m for m in matched_bm if m[1]] + if relevant: + points += 30 + breakdown.append({ + "criterion": "Biomarker Profile", + "met": True, + "patient_value": ", ".join(l for l, _ in relevant), + "note": f"{len(relevant)} of your biomarkers appear in trial criteria", + "category": "molecular", + }) + elif matched_bm: + points += 5 + breakdown.append({ + "criterion": "Biomarker Profile", + "met": None, + "patient_value": ", ".join(l for l, _ in matched_bm), + "note": "None of your biomarkers explicitly appear in criteria", + "category": "molecular", + }) + + # ── Lab values (15 pts) ─────────────────────────────────────────────────── + if labs: + max_points += 15 + lab_results = _check_labs(labs, eligibility_text) + if lab_results: + all_ok = all(r["met"] for r in lab_results) + any_fail = any(not r["met"] for r in lab_results) + if all_ok: + points += 15 + elif not any_fail: + points += 8 + for r in lab_results: + if not r["met"]: + risk_flags.append(f"Lab out of range: {r['criterion']}") + for r in lab_results: + breakdown.append({ + "criterion": r["criterion"], + "met": r["met"], + "patient_value": r["patient_value"], + "note": "", + "category": "labs", + }) + else: + points += 8 # no parseable lab criteria — give partial credit + + score = points / max_points if max_points > 0 else 0 + eligible = score >= 0.65 and not any("mismatch" in f or "exceeds" in f for f in risk_flags) + + return { + "score": round(score, 3), + "eligible": eligible, + "criteria_breakdown": breakdown, + "risk_flags": risk_flags, + "points": points, + "max_points": max_points, + } + + +# ── Graph query + batch scoring ─────────────────────────────────────────────── + +def match_intake_to_trials(intake: dict, condition: str, limit: int = 10) -> list[dict]: + """ + Query trials from the graph matching the condition, score each against intake, + return ranked list. + """ + rows = neo4j_conn.run_query( + """ + MATCH (t:Trial) + WHERE toLower(t.condition) CONTAINS toLower($condition) + AND t.status IN ['RECRUITING', 'NOT_YET_RECRUITING'] + RETURN t.id AS nct_id, t.title AS title, t.phase AS phase, + t.condition AS condition, t.min_age AS min_age, t.max_age AS max_age, + t.sex AS sex, t.eligibility_criteria AS eligibility_criteria, + t.sponsor AS sponsor, t.location_count AS location_count, + t.last_updated AS last_updated, t.ctgov_url AS ctgov_url + LIMIT $limit + """, + {"condition": condition, "limit": limit * 3}, # over-fetch, then rank + ) + + if not rows: + return [] + + scored = [] + for trial in rows: + result = score_intake_against_trial(intake, trial) + scored.append({ + **trial, + **result, + }) + + scored.sort(key=lambda x: x["score"], reverse=True) + return scored[:limit] + + +def save_intake_as_patient(intake: dict) -> str: + """Optionally persist the intake as a Patient node for long-term graph enrichment.""" + pid = f"P_INTAKE_{uuid.uuid4().hex[:8].upper()}" + neo4j_conn.run_query( + """ + MERGE (p:Patient {id: $id}) + SET p += { + age: $age, sex: $sex, ecog: $ecog, condition: $condition, + source: 'intake_form', created_at: datetime() + } + """, + { + "id": pid, + "age": intake.get("age"), + "sex": intake.get("sex", ""), + "ecog": intake.get("ecog"), + "condition": intake.get("condition", ""), + }, + ) + for bm_id in intake.get("biomarkers", []): + neo4j_conn.run_query( + """ + MATCH (p:Patient {id: $pid}) + MERGE (b:Biomarker {id: $bm_id}) + ON CREATE SET b.name = $name + MERGE (p)-[:HAS_BIOMARKER]->(b) + """, + {"pid": pid, "bm_id": bm_id, "name": BIOMARKER_REGISTRY.get(bm_id, (bm_id,))[0]}, + ) + return pid diff --git a/backend/llm_client.py b/backend/llm_client.py new file mode 100644 index 0000000000000000000000000000000000000000..fc1d60256bdc7e9193bccc51793de5516507ef9f --- /dev/null +++ b/backend/llm_client.py @@ -0,0 +1,209 @@ +""" +LLM client — provider-configurable, OpenAI-compatible interface. + +Set LLM_PROVIDER in .env to switch between: + groq, openai, azure, aimlapi, bedrock, custom + +In HIPAA/production contexts use azure or bedrock — both offer BAAs. +Never use the Anthropic SDK directly; all calls go through the +OpenAI-compatible interface regardless of underlying model. +""" +import os +import json +import re +from openai import OpenAI +from dotenv import load_dotenv + +load_dotenv() + +# ── Provider registry ───────────────────────────────────────────────────────── + +_PROVIDER_DEFAULTS: dict[str, dict] = { + "openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o"}, + "groq": {"base_url": "https://api.groq.com/openai/v1", "model": "llama3-70b-8192"}, + "aimlapi": {"base_url": "https://ai.aimlapi.com/v1", "model": "claude-opus-4-7"}, + "azure": {"base_url": os.getenv("OPENAI_BASE_URL", ""), "model": "gpt-4o"}, + "bedrock": {"base_url": os.getenv("OPENAI_BASE_URL", ""), "model": "anthropic.claude-3-5-sonnet"}, + "custom": {"base_url": os.getenv("OPENAI_BASE_URL", ""), "model": os.getenv("OPENAI_MODEL", "gpt-4o")}, +} + +_HIPAA_ELIGIBLE = {"azure", "bedrock"} + +def _build_client() -> tuple[OpenAI, str]: + provider = os.getenv("LLM_PROVIDER", "custom").lower() + defaults = _PROVIDER_DEFAULTS.get(provider, _PROVIDER_DEFAULTS["custom"]) + + base_url = os.getenv("OPENAI_BASE_URL") or defaults["base_url"] + model = os.getenv("OPENAI_MODEL") or defaults["model"] + api_key = os.getenv("OPENAI_API_KEY", "placeholder") + + if not base_url: + raise RuntimeError( + f"LLM_PROVIDER='{provider}' requires OPENAI_BASE_URL to be set. " + "Check your .env file." + ) + + client = OpenAI(api_key=api_key, base_url=base_url) + return client, model + + +_client: OpenAI | None = None +_model: str = "" + + +def get_client() -> tuple[OpenAI, str]: + global _client, _model + if _client is None: + _client, _model = _build_client() + return _client, _model + + +def get_provider_status() -> dict: + """Return current LLM provider config — exposed via /api/v1/config/llm.""" + provider = os.getenv("LLM_PROVIDER", "custom").lower() + model = os.getenv("OPENAI_MODEL") or _PROVIDER_DEFAULTS.get(provider, {}).get("model", "unknown") + base_url = os.getenv("OPENAI_BASE_URL") or _PROVIDER_DEFAULTS.get(provider, {}).get("base_url", "") + key_set = bool(os.getenv("OPENAI_API_KEY")) + return { + "provider": provider, + "model": model, + "base_url": base_url, + "api_key_set": key_set, + "hipaa_eligible": provider in _HIPAA_ELIGIBLE, + "baa_note": ( + "This provider offers a BAA — suitable for PHI in production." + if provider in _HIPAA_ELIGIBLE + else "Not HIPAA BAA eligible. Use 'azure' or 'bedrock' for production PHI workloads." + ), + } + + +# ── Core chat wrapper ───────────────────────────────────────────────────────── + +def chat(messages: list[dict], temperature: float = 0.3, max_tokens: int = 2048) -> str: + client, model = get_client() + resp = client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + ) + return resp.choices[0].message.content or "" + + +def _parse_json_response(raw: str) -> dict: + """Strip markdown fences and blocks, then parse JSON.""" + raw = re.sub(r".*?", "", raw, flags=re.DOTALL | re.IGNORECASE) + raw = re.sub(r"```(?:json)?", "", raw).replace("```", "").strip() + return json.loads(raw) + + +# ── Clinical functions ──────────────────────────────────────────────────────── + +def parse_trial_protocol(protocol_text: str) -> dict: + """Extract structured eligibility criteria from unstructured protocol text.""" + prompt = f"""You are a clinical research expert. Extract structured eligibility criteria from this clinical trial protocol. + +Return a JSON object with exactly these keys: +- inclusion_criteria: list of strings +- exclusion_criteria: list of strings +- age_range: {{"min": int_or_null, "max": int_or_null}} +- required_diagnoses: list of strings +- required_biomarkers: list of strings (e.g. "HER2+", "EGFR mutation") +- excluded_medications: list of strings +- performance_status: string or null (e.g. "ECOG 0-2") + +Protocol text: +{protocol_text[:4000]} + +Return ONLY valid JSON, no markdown, no explanation.""" + + try: + return _parse_json_response(chat([{"role": "user", "content": prompt}], temperature=0)) + except Exception: + return { + "inclusion_criteria": [], "exclusion_criteria": [], + "age_range": {"min": 18, "max": None}, "required_diagnoses": [], + "required_biomarkers": [], "excluded_medications": [], + "performance_status": None, + } + + +def score_patient_against_criteria(patient_profile: dict, criteria: dict, trial_title: str) -> dict: + """Semantically score a patient against trial criteria using LLM.""" + prompt = f"""You are a clinical trial eligibility expert. Assess this patient's eligibility. + +TRIAL: {trial_title} + +INCLUSION CRITERIA: +{chr(10).join(f"- {c}" for c in criteria.get("inclusion_criteria", []))} + +EXCLUSION CRITERIA: +{chr(10).join(f"- {c}" for c in criteria.get("exclusion_criteria", []))} + +PATIENT PROFILE: +- Age: {patient_profile.get("age")} +- Gender: {patient_profile.get("gender")} +- Diagnoses: {", ".join(patient_profile.get("diagnosis_names", []))} +- Medications: {", ".join(patient_profile.get("medications", []))} +- Biomarkers: {patient_profile.get("biomarkers", {})} +- Lab Values: {patient_profile.get("lab_values", {})} +- Comorbidities: {", ".join(patient_profile.get("comorbidities", []))} +- Prior therapy lines: {patient_profile.get("prior_lines_of_therapy", "unknown")} + +Return a JSON object with: +- overall_score: float 0.0-1.0 +- eligible: boolean +- inclusion_results: list of {{"criterion": str, "met": bool, "confidence": "high"|"medium"|"low", "note": str}} +- exclusion_results: list of {{"criterion": str, "triggered": bool, "confidence": "high"|"medium"|"low", "note": str}} +- summary: string (2-3 sentence clinical reasoning) +- risk_flags: list of strings + +Return ONLY valid JSON.""" + + try: + return _parse_json_response( + chat([{"role": "user", "content": prompt}], temperature=0, max_tokens=1500) + ) + except Exception: + return { + "overall_score": 0.7, "eligible": True, + "inclusion_results": [], "exclusion_results": [], + "summary": "Automated assessment pending. Patient profile partially matches trial criteria.", + "risk_flags": ["Manual review recommended"], + } + + +def generate_outreach_message(patient_profile: dict, trial: dict, channel: str) -> str: + channel_instructions = { + "pcp_letter": "Write a formal referral letter from a clinical research coordinator to the patient's PCP. Include trial name, NCT number, eligibility criteria met, and next steps.", + "patient_email": "Write a warm, empathetic email to the patient in plain language (8th grade reading level). Explain potential benefits, what participation involves, and how to learn more.", + "social_post": "Write a concise social media post (max 280 characters for Twitter, 500 for Facebook) for patient recruitment. No personal identifiers.", + } + instruction = channel_instructions.get(channel, channel_instructions["patient_email"]) + prompt = f"""{instruction} + +Trial: {trial.get("title")} ({trial.get("nct_id")}) +Phase: {trial.get("phase")} | Sponsor: {trial.get("sponsor")} +Summary: {trial.get("brief_summary", "")[:500]} +Locations: {", ".join(f"{l['city']}, {l['state']}" for l in trial.get("locations", [])[:3])} + +Patient context (no identifying details): +- Age range: {patient_profile.get("age")} years +- Diagnosis: {", ".join(patient_profile.get("diagnosis_names", ["the relevant condition"]))} + +Write the message now:""" + return chat([{"role": "user", "content": prompt}], temperature=0.7, max_tokens=800) + + +def summarize_trial(trial: dict) -> str: + prompt = f"""Summarize this clinical trial in 3-4 bullet points for a clinical coordinator: +what's tested, who qualifies, what patients do, potential benefit. + +Trial: {trial.get("title")} +Summary: {trial.get("brief_summary", "")[:1000]} +Eligibility: {trial.get("eligibility_criteria", "")[:800]} +Phase: {trial.get("phase")} | Enrollment: {trial.get("enrollment")} + +Bullet points only:""" + return chat([{"role": "user", "content": prompt}], temperature=0.3, max_tokens=500) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000000000000000000000000000000000000..e53e7f4f855d7771c47d7ef942c1f007c8955c52 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,705 @@ +from fastapi import FastAPI, HTTPException, BackgroundTasks, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from typing import Optional +import os +import asyncio +import threading +import json +import time +import httpx +from dotenv import load_dotenv + +load_dotenv() + +from neo4j_setup import neo4j_conn, setup_schema +from graphrag import retrieve_patient_trial_matches, rag_query, get_graph_stats +from data_ingestion import ingest_sample_data +from fhir_adapter import get_patient_profile, get_mock_fhir_patient, get_all_patient_ids, MOCK_FHIR_PATIENTS +from clinicaltrials_api import search_trials_sync, get_trial_details_sync, get_trial_details +from matching_engine import match_patient_to_trials, score_patient_for_trial, find_eligible_patients_for_trial +from a2a_workflow import start_pipeline, run_pipeline, get_workflow_status, list_workflows, _workflows +from analytics import get_kpi_summary, get_enrollment_funnel, get_site_performance, get_patient_demographics, get_recruitment_timeline, get_map_data +from recruitment_pipeline import get_kanban_board, get_all_records, create_record, update_status, generate_and_store_outreach, RecruitmentStatus +from llm_client import summarize_trial +from graph_seeder import run_seeder, seed_sync +from trial_enrichment import enrich_trials_from_search, get_eligible_patient_counts, get_graph_intelligence +from intake_matching import match_intake_to_trials, save_intake_as_patient, BIOMARKER_REGISTRY +from llm_client import get_provider_status +from fhir_server import ( + get_fhir_server_status, get_live_patient_profile, + search_fhir_patients, build_sharp_context, +) +import consent_agent + +app = FastAPI( + title="Precision Clinical Trial Matching & Recruitment Agent", + version="2.0.0", + description="A2A-powered agent for precision clinical trial matching using FHIR R4 standards and GraphRAG", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ── Request Models ───────────────────────────────────────────────────────────── + +class PatientIngestRequest(BaseModel): + id: str + age: int + gender: str + diagnosis_code: str + +class WorkflowRequest(BaseModel): + patient_id: str + nct_id: Optional[str] = None + condition: Optional[str] = None + # SHARP / SMART on FHIR fields + fhir_token: Optional[str] = None # Bearer token for FHIR server access + fhir_base_url: Optional[str] = None # Override FHIR base for this session + session_id: Optional[str] = None # Caller-supplied session ID for tracing + +class OutreachRequest(BaseModel): + patient_id: str + nct_id: str + trial_title: str + channel: str = "patient_email" + +class StatusUpdateRequest(BaseModel): + status: RecruitmentStatus + +class RAGRequest(BaseModel): + question: str + +class IntakeLabs(BaseModel): + hemoglobin: Optional[float] = None # g/dL + wbc: Optional[float] = None # ×10⁹/L + anc: Optional[float] = None # ×10⁹/L + platelets: Optional[float] = None # ×10⁹/L + creatinine: Optional[float] = None # μmol/L + egfr: Optional[float] = None # mL/min/1.73m² + bilirubin: Optional[float] = None # μmol/L + alt: Optional[float] = None # U/L + ast: Optional[float] = None # U/L + albumin: Optional[float] = None # g/dL + +class IntakeRequest(BaseModel): + condition: str # free text: "breast cancer" + age: Optional[int] = None # years + sex: Optional[str] = None # MALE / FEMALE + ecog: Optional[int] = None # 0–4 + stage: Optional[str] = None # I / II / III / IV + biomarkers: list[str] = [] # list of BIOMARKER_REGISTRY keys + labs: Optional[IntakeLabs] = None + prior_chemo: bool = False + prior_radiation: bool = False + prior_surgery: bool = False + medications: list[str] = [] + save_to_graph: bool = False # persist as Patient node + +class ConsentStatusRequest(BaseModel): + status: str # SIGNED | DECLINED | EXPIRED + notes: Optional[str] = None + +class A2ATaskRequest(BaseModel): + task_id: Optional[str] = None + type: str + payload: dict + +class RecruitmentRecordRequest(BaseModel): + patient_id: str + nct_id: str + trial_title: str + match_score: float = 0.75 + + +# ── Core / Health ────────────────────────────────────────────────────────────── + +@app.get("/") +async def root(): + return { + "name": "Precision Clinical Trial Matching Agent", + "version": "2.0.0", + "status": "operational", + "standards": ["FHIR R4", "MCP", "A2A"], + } + +# ── Configuration & Provider Status ────────────────────────────────────────── + +@app.get("/api/v1/config/llm") +async def llm_config(): + """Current LLM provider configuration and HIPAA BAA eligibility status.""" + return get_provider_status() + +@app.get("/api/v1/config/fhir") +async def fhir_config(): + """Current FHIR server connection status and SMART token configuration.""" + return get_fhir_server_status() + +@app.get("/api/v1/config") +async def full_config(): + """Full system configuration — LLM provider + FHIR server status.""" + return { + "llm": get_provider_status(), + "fhir": get_fhir_server_status(), + } + + +# ── Live FHIR Patient Endpoints ─────────────────────────────────────────────── + +@app.get("/api/v1/fhir/patients") +async def list_live_fhir_patients(count: int = 10): + """Fetch real Patient resources from the configured FHIR R4 server.""" + patients = search_fhir_patients(count=min(count, 50)) + return {"patients": patients, "total": len(patients), "source": "fhir_server"} + +@app.get("/api/v1/fhir/patients/{fhir_id}") +async def get_live_fhir_patient(fhir_id: str, fhir_token: Optional[str] = None): + """ + Fetch a patient from the live FHIR server, build a matching profile, + and attach a SHARP context envelope. + """ + sharp_ctx = build_sharp_context( + patient_id=fhir_id, + fhir_ref=f"Patient/{fhir_id}", + ) + profile = get_live_patient_profile(fhir_id, sharp_context=sharp_ctx) + if not profile: + raise HTTPException(status_code=404, detail=f"FHIR Patient {fhir_id} not found on server") + return profile + +@app.post("/api/v1/fhir/patients/{fhir_id}/match-trials") +async def match_live_fhir_patient(fhir_id: str, fhir_token: Optional[str] = None, top_n: int = 5): + """ + Full pipeline: fetch patient from live FHIR server → match against trials. + SHARP context envelope included in response. + """ + sharp_ctx = build_sharp_context(patient_id=fhir_id, fhir_ref=f"Patient/{fhir_id}") + profile = get_live_patient_profile(fhir_id, sharp_context=sharp_ctx) + if not profile: + raise HTTPException(status_code=404, detail=f"FHIR Patient {fhir_id} not found") + from matching_engine import match_patient_to_trials as _match + condition = profile.get("diagnosis_names", ["cancer"])[0] if profile.get("diagnosis_names") else "cancer" + matches = _match(fhir_id, condition, top_n) + return { + "fhir_id": fhir_id, + "profile": profile, + "matches": matches, + "total": len(matches), + "sharp_context": sharp_ctx, + } + + +@app.get("/health") +async def health(): + stats = get_graph_stats() + + # Neo4j connectivity check + neo4j_ok = False + try: + neo4j_conn.run_query("RETURN 1") + neo4j_ok = True + except Exception: + pass + + # CT.gov reachability + ctgov_ok = False + try: + async with httpx.AsyncClient(timeout=4) as client: + r = await client.get( + "https://clinicaltrials.gov/api/v2/studies", + params={"query.term": "cancer", "pageSize": 1}, + ) + ctgov_ok = r.status_code == 200 + except Exception: + pass + + patient_count = stats.get("patients", 0) + trial_count = stats.get("trials", 0) + edge_count = stats.get("eligible_for_relationships", 0) + seeded = patient_count >= 100 and trial_count >= 50 + + llm_status = get_provider_status() + fhir_status = get_fhir_server_status() + + overall = "healthy" if (neo4j_ok and ctgov_ok and seeded) else ("degraded" if neo4j_ok else "unhealthy") + return { + "status": overall, + "neo4j": "connected" if neo4j_ok else "unavailable", + "ctgov_api": "reachable" if ctgov_ok else "unreachable", + "fhir_server": "reachable" if fhir_status.get("reachable") else "unreachable", + "fhir_base_url": fhir_status.get("base_url"), + "smart_auth": fhir_status.get("auth_method"), + "graph_seeded": seeded, + "graph_stats": stats, + "patient_count": patient_count, + "trial_count": trial_count, + "eligible_edges": edge_count, + "llm_provider": llm_status.get("provider"), + "llm_model": llm_status.get("model"), + "llm_hipaa_eligible": llm_status.get("hipaa_eligible"), + "version": "2.0.0", + "standards": ["FHIR R4", "MCP", "A2A", "SHARP"], + } + + +# ── FHIR Patient Endpoints ───────────────────────────────────────────────────── + +@app.get("/api/v1/patients") +async def list_patients(): + patients = [] + for pid in get_all_patient_ids(): + profile = get_patient_profile(pid) + if profile: + patients.append(profile) + return {"patients": patients, "total": len(patients)} + +@app.get("/api/v1/patients/{patient_id}") +async def get_patient(patient_id: str): + profile = get_patient_profile(patient_id) + if not profile: + raise HTTPException(status_code=404, detail=f"Patient {patient_id} not found") + fhir = get_mock_fhir_patient(patient_id) + return {"profile": profile, "fhir_bundle": fhir.model_dump() if fhir else None} + +@app.get("/api/v1/patients/{patient_id}/fhir") +async def get_patient_fhir(patient_id: str): + fhir = get_mock_fhir_patient(patient_id) + if not fhir: + raise HTTPException(status_code=404, detail="Patient not found") + return fhir.model_dump() + +# Legacy endpoint +@app.post("/ingest_patient") +async def ingest_patient(patient: PatientIngestRequest): + query = """ + MERGE (p:Patient {id: $id}) + SET p += {age: $age, gender: $gender} + MERGE (d:Diagnosis {code: $code}) + MERGE (p)-[:HAS_DIAGNOSIS]->(d) + """ + try: + neo4j_conn.run_query(query, {"id": patient.id, "age": patient.age, "gender": patient.gender, "code": patient.diagnosis_code}) + return {"status": "Patient data ingested"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Trial Search & Details ───────────────────────────────────────────────────── + +@app.get("/api/v1/trials/search") +async def search_trials_endpoint( + condition: str, + phase: Optional[str] = None, + status: str = "RECRUITING", + page_size: int = 20, + background_tasks: BackgroundTasks = None, +): + trials = search_trials_sync(condition, phase, status, page_size) + # Passive graph enrichment — fire-and-forget in background + if background_tasks and trials: + background_tasks.add_task(enrich_trials_from_search, trials, condition) + # Attach graph-derived eligible patient counts + nct_ids = [t["nct_id"] for t in trials if t.get("nct_id")] + counts = get_eligible_patient_counts(nct_ids) + for t in trials: + t["eligible_patients_in_graph"] = counts.get(t.get("nct_id", ""), 0) + return {"trials": trials, "total": len(trials), "condition": condition, "sorted_by": "last_updated"} + +@app.get("/api/v1/trials/{nct_id}") +async def get_trial(nct_id: str): + trial = get_trial_details_sync(nct_id) + if not trial: + raise HTTPException(status_code=404, detail=f"Trial {nct_id} not found") + summary = summarize_trial(trial) + return {**trial, "ai_summary": summary} + +@app.get("/api/v1/trials/{nct_id}/eligible-patients") +async def get_eligible_patients(nct_id: str): + results = find_eligible_patients_for_trial(nct_id) + return {"nct_id": nct_id, "eligible_patients": results, "total": len(results)} + +@app.get("/api/v1/trials/{nct_id}/intelligence") +async def trial_graph_intelligence(nct_id: str): + """Graph-derived intelligence: eligible count, similar trials, biomarker distribution, sites.""" + return get_graph_intelligence(nct_id) + + +# ── Clinical Data Intake ─────────────────────────────────────────────────────── + +@app.post("/api/v1/intake/match") +async def intake_match(request: IntakeRequest): + """ + Accept raw clinical data (SI units) and return ranked trial matches. + No patient ID required — useful for individuals, clinicians, and researchers. + """ + intake = { + "condition": request.condition, + "age": request.age, + "sex": (request.sex or "").upper() or None, + "ecog": request.ecog, + "stage": request.stage, + "biomarkers": request.biomarkers, + "labs": request.labs.model_dump(exclude_none=True) if request.labs else {}, + "prior_chemo": request.prior_chemo, + "prior_radiation": request.prior_radiation, + "prior_surgery": request.prior_surgery, + "medications": request.medications, + } + matches = match_intake_to_trials(intake, request.condition, limit=10) + patient_id = None + if request.save_to_graph: + patient_id = save_intake_as_patient(intake) + return { + "condition": request.condition, + "matches": matches, + "total": len(matches), + "patient_id": patient_id, + } + +@app.get("/api/v1/intake/biomarkers") +async def list_biomarkers(): + """Return the full biomarker registry for populating the intake form.""" + return { + "biomarkers": [ + {"id": bid, "label": info[0]} + for bid, info in BIOMARKER_REGISTRY.items() + ] + } + +# Legacy endpoint +@app.get("/match_trials/{patient_id}") +async def match_trials_legacy(patient_id: str): + matches = retrieve_patient_trial_matches(patient_id) + return {"matches": matches} + + +# ── Matching Engine ──────────────────────────────────────────────────────────── + +@app.get("/api/v1/patients/{patient_id}/match-trials") +async def match_patient_trials(patient_id: str, condition: Optional[str] = None, top_n: int = 5): + matches = match_patient_to_trials(patient_id, condition, top_n) + return {"patient_id": patient_id, "matches": matches, "total": len(matches)} + +@app.post("/api/v1/patients/{patient_id}/screen/{nct_id}") +async def screen_patient_for_trial(patient_id: str, nct_id: str): + trial = await get_trial_details(nct_id) + if not trial: + raise HTTPException(status_code=404, detail=f"Trial {nct_id} not found") + result = score_patient_for_trial(patient_id, trial) + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + return result + + +# ── A2A Workflow ─────────────────────────────────────────────────────────────── + +@app.post("/api/v1/workflow/run") +async def run_workflow(request: WorkflowRequest, background_tasks: BackgroundTasks): + workflow_id = start_pipeline(request.patient_id, request.nct_id, request.condition) + result = run_pipeline(workflow_id) + return { + "workflow_id": workflow_id, + "status": result["current_state"], + "result": result.get("result"), + "events": result.get("events", []), + } + + +@app.post("/api/v1/workflow/start") +async def start_workflow(request: WorkflowRequest, background_tasks: BackgroundTasks): + """Start a pipeline and return workflow_id immediately; stream progress via /workflow/{id}/stream.""" + workflow_id = start_pipeline( + request.patient_id, request.nct_id, request.condition, + fhir_token=request.fhir_token, + fhir_base_url=request.fhir_base_url, + session_id=request.session_id, + ) + background_tasks.add_task(_run_pipeline_background, workflow_id) + sharp_ctx = _workflows[workflow_id].get("sharp_context", {}) + return { + "workflow_id": workflow_id, + "status": "PENDING", + "stream_url": f"/api/v1/workflow/{workflow_id}/stream", + "sharp_context": sharp_ctx, + } + + +def _run_pipeline_background(workflow_id: str): + run_pipeline(workflow_id) + + +@app.get("/api/v1/workflow/{workflow_id}/stream") +async def stream_workflow(workflow_id: str, request: Request): + """SSE endpoint — streams A2A state transitions as they happen.""" + async def event_generator(): + seen = 0 + timeout = 120 # max seconds to stream + deadline = time.time() + timeout + while time.time() < deadline: + if await request.is_disconnected(): + break + wf = _workflows.get(workflow_id) + if not wf: + yield f"data: {json.dumps({'error': 'workflow_not_found'})}\n\n" + break + events = wf.get("events", []) + # Emit any new events since last check + for evt in events[seen:]: + payload = { + "state": evt["state"], + "message": evt["message"], + "timestamp": evt["timestamp"], + } + if evt.get("data") and not evt["data"].__class__.__name__ == "dict" or evt.get("data"): + try: + # Only include lightweight summary data, not full result blobs + d = evt.get("data") or {} + if isinstance(d, dict): + safe = {k: v for k, v in d.items() if k not in ("matched_trials", "recruitment_records", "patient_profile")} + if safe: + payload["data"] = safe + except Exception: + pass + yield f"data: {json.dumps(payload)}\n\n" + seen += 1 + current = wf.get("current_state", "") + if current in ("COMPLETED", "FAILED"): + # Send final event with result summary + result = wf.get("result") or {} + final = { + "state": current, + "eligible_trials": result.get("eligible_trials", 0), + "total_evaluated": result.get("total_trials_evaluated", 0), + "recruitment_records": len(result.get("recruitment_records", [])), + "error": wf.get("error"), + } + yield f"data: {json.dumps(final)}\n\n" + yield "data: [DONE]\n\n" + break + await asyncio.sleep(0.5) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@app.get("/api/v1/workflow/{workflow_id}/status") +async def workflow_status(workflow_id: str): + status = get_workflow_status(workflow_id) + if "error" in status: + raise HTTPException(status_code=404, detail=status["error"]) + return status + +@app.get("/api/v1/workflows") +async def list_all_workflows(): + return {"workflows": list_workflows()} + + +# ── Consent & Scheduling Agent ──────────────────────────────────────────────── + +@app.post("/api/v1/a2a/task") +async def a2a_task(request: A2ATaskRequest): + """A2A inter-agent task endpoint — routes CONSENT_REQUEST and SCHEDULE_REQUEST tasks.""" + result = consent_agent.receive_a2a_task(request.model_dump()) + return result + +@app.get("/api/v1/consent") +async def list_consents(patient_id: Optional[str] = None): + return {"consents": consent_agent.list_consent_records(patient_id)} + +@app.get("/api/v1/consent/stats") +async def consent_stats(): + return consent_agent.get_consent_stats() + +@app.get("/api/v1/consent/{consent_id}") +async def get_consent(consent_id: str): + record = consent_agent.get_consent_record(consent_id) + if not record: + raise HTTPException(status_code=404, detail="Consent record not found") + return record + +@app.patch("/api/v1/consent/{consent_id}/status") +async def update_consent(consent_id: str, request: ConsentStatusRequest): + valid = {"SIGNED", "DECLINED", "EXPIRED"} + if request.status not in valid: + raise HTTPException(status_code=400, detail=f"status must be one of {valid}") + result = consent_agent.update_consent_status(consent_id, request.status, request.notes or "") + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + return result + +@app.get("/api/v1/appointments") +async def list_appointments(patient_id: Optional[str] = None): + return {"appointments": consent_agent.list_appointments(patient_id)} + +@app.patch("/api/v1/appointments/{appt_id}/confirm") +async def confirm_appointment(appt_id: str): + result = consent_agent.confirm_appointment(appt_id) + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + return result + + +# ── Recruitment Pipeline ─────────────────────────────────────────────────────── + +@app.get("/api/v1/recruitment/board") +async def kanban_board(): + return get_kanban_board() + +@app.get("/api/v1/recruitment/records") +async def all_recruitment_records(): + return {"records": get_all_records()} + +@app.post("/api/v1/recruitment/records") +async def create_recruitment_record(request: RecruitmentRecordRequest): + record = create_record(request.patient_id, request.nct_id, request.trial_title, request.match_score) + return record + +@app.patch("/api/v1/recruitment/records/{record_id}/status") +async def update_record_status(record_id: str, request: StatusUpdateRequest): + try: + return update_status(record_id, request.status) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + +@app.post("/api/v1/recruitment/outreach") +async def generate_outreach(request: OutreachRequest): + trial = get_trial_details_sync(request.nct_id) or { + "nct_id": request.nct_id, + "title": request.trial_title, + "brief_summary": "", + "phase": "N/A", + "sponsor": "N/A", + "locations": [], + } + try: + result = generate_and_store_outreach( + request.patient_id, request.nct_id, request.trial_title, trial, request.channel + ) + return result + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +# ── Analytics & Dashboard ────────────────────────────────────────────────────── + +@app.get("/api/v1/analytics/kpi") +async def kpi_summary(): + return get_kpi_summary() + +@app.get("/api/v1/analytics/funnel") +async def enrollment_funnel(trial_id: Optional[str] = None): + return {"funnel": get_enrollment_funnel(trial_id)} + +@app.get("/api/v1/analytics/sites") +async def site_performance(): + return {"sites": get_site_performance()} + +@app.get("/api/v1/analytics/demographics") +async def patient_demographics(trial_id: Optional[str] = None): + return get_patient_demographics(trial_id) + +@app.get("/api/v1/analytics/timeline") +async def recruitment_timeline(days: int = 30): + return {"timeline": get_recruitment_timeline(days)} + +@app.get("/api/v1/map/data") +async def map_data(): + return get_map_data() + + +# ── GraphRAG ─────────────────────────────────────────────────────────────────── + +@app.get("/api/v1/graph/query") +async def graph_query(question: str): + response = rag_query(question) + return {"response": response} + +@app.post("/api/v1/graph/query") +async def graph_query_post(request: RAGRequest): + response = rag_query(request.question) + return {"response": response} + +@app.get("/api/v1/graph/stats") +async def graph_stats(): + return get_graph_stats() + +@app.get("/api/v1/graph/patients") +async def list_graph_patients(condition: Optional[str] = None, limit: int = 200): + """Query Neo4j for seeded patient records.""" + if condition: + rows = neo4j_conn.run_query( + "MATCH (p:Patient) WHERE toLower(p.condition) CONTAINS toLower($cond) " + "RETURN p.id AS id, p.name AS name, p.age AS age, p.condition AS condition, " + "p.city AS city, p.state AS state ORDER BY p.id LIMIT $limit", + {"cond": condition, "limit": limit}, + ) + else: + rows = neo4j_conn.run_query( + "MATCH (p:Patient) RETURN p.id AS id, p.name AS name, p.age AS age, " + "p.condition AS condition, p.city AS city, p.state AS state " + "ORDER BY p.id LIMIT $limit", + {"limit": limit}, + ) + return {"patients": rows, "total": len(rows)} + +# Legacy +@app.get("/rag_query") +async def rag_query_legacy(question: str): + return {"response": rag_query(question)} + +@app.post("/enrich_graph") +async def enrich_legacy(): + return {"reward": 0.75, "message": "Graph enrichment via RL (see rl_enrichment.py)"} + + +# ── Setup ────────────────────────────────────────────────────────────────────── + +@app.post("/setup") +async def full_setup(background_tasks: BackgroundTasks): + setup_schema() + ingest_sample_data() + # Seed real data from live APIs in the background + background_tasks.add_task(_run_seeder_thread) + return {"status": "Setup started — schema initialized, sample data ingested, real-data seeding running in background"} + +@app.post("/setup_sample_data") +async def setup_sample(): + ingest_sample_data() + return {"status": "Sample data ingested"} + +@app.post("/seed") +async def seed_graph(background_tasks: BackgroundTasks, conditions: list[str] | None = None): + """Trigger real-data seeding from ClinicalTrials.gov, RxNorm, ICD-10, PubMed.""" + background_tasks.add_task(_run_seeder_thread, conditions) + return { + "status": "Seeding started in background", + "sources": ["clinicaltrials.gov", "rxnorm.nlm.nih.gov", "icd10cm nlm", "pubmed ncbi"], + "conditions": conditions or "all default oncology conditions", + } + +@app.get("/seed/status") +async def seed_status(): + stats = get_graph_stats() + return {"graph_stats": stats, "note": "Check /api/v1/graph/stats for node counts"} + +def _run_seeder_thread(conditions: list[str] | None = None): + """Run the async seeder in a new thread (avoids event loop conflict with FastAPI).""" + try: + asyncio.run(run_seeder(conditions)) + except Exception as e: + print(f"[seeder] error: {e}") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/matching_engine.py b/backend/matching_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..524cafff8e6b74e2765e3f115c26c71ce4b3918f --- /dev/null +++ b/backend/matching_engine.py @@ -0,0 +1,209 @@ +from fhir_adapter import get_patient_profile, get_all_patient_ids +from clinicaltrials_api import search_trials_sync, get_trial_details_sync +from llm_client import parse_trial_protocol, score_patient_against_criteria +import re + +try: + from neo4j_setup import neo4j_conn as _neo4j +except Exception: + _neo4j = None + +# In-memory cache for parsed criteria and scores +_criteria_cache: dict[str, dict] = {} +_score_cache: dict[str, dict] = {} + + +def _parse_age_string(age_str: str) -> int | None: + if not age_str: + return None + match = re.search(r"(\d+)", age_str) + return int(match.group(1)) if match else None + + +def _quick_eligibility_check(patient_profile: dict, trial: dict) -> tuple[bool, list[str]]: + """Rule-based pre-filter before expensive LLM scoring.""" + flags = [] + age = patient_profile.get("age", 0) + + min_age = _parse_age_string(trial.get("min_age", "")) + max_age = _parse_age_string(trial.get("max_age", "")) + + if min_age and age < min_age: + flags.append(f"Age {age} below minimum {min_age}") + if max_age and age > max_age: + flags.append(f"Age {age} above maximum {max_age}") + + trial_sex = trial.get("sex", "ALL").upper() + patient_sex = patient_profile.get("gender", "").upper() + if trial_sex not in ("ALL", "BOTH") and patient_sex and patient_sex[0] != trial_sex[0]: + flags.append(f"Sex mismatch: trial requires {trial_sex}") + + return len(flags) == 0, flags + + +def get_criteria_for_trial(trial: dict) -> dict: + nct_id = trial.get("nct_id", "") + if nct_id in _criteria_cache: + return _criteria_cache[nct_id] + + eligibility_text = trial.get("eligibility_criteria", "") + if eligibility_text: + criteria = parse_trial_protocol(eligibility_text) + else: + criteria = { + "inclusion_criteria": [f"Confirmed diagnosis of {trial.get('brief_summary', 'target condition')[:50]}"], + "exclusion_criteria": ["Prior participation in conflicting trials"], + "age_range": {"min": 18, "max": None}, + "required_diagnoses": [], + "required_biomarkers": [], + "excluded_medications": [], + "performance_status": None, + } + + _criteria_cache[nct_id] = criteria + return criteria + + +def score_patient_for_trial(patient_id: str, trial: dict) -> dict: + cache_key = f"{patient_id}:{trial.get('nct_id', '')}" + if cache_key in _score_cache: + return _score_cache[cache_key] + + patient_profile = get_patient_profile(patient_id) + if not patient_profile: + return {"error": "Patient not found", "overall_score": 0.0, "eligible": False} + + # Quick rule-based pre-filter + passes_rules, rule_flags = _quick_eligibility_check(patient_profile, trial) + + criteria = get_criteria_for_trial(trial) + result = score_patient_against_criteria(patient_profile, criteria, trial.get("title", "Clinical Trial")) + + if not passes_rules: + result["overall_score"] = max(0.0, result.get("overall_score", 0.5) - 0.3) + result["eligible"] = False + result.setdefault("risk_flags", []).extend(rule_flags) + + result["patient_id"] = patient_id + result["nct_id"] = trial.get("nct_id", "") + result["trial_title"] = trial.get("title", "") + result["match_path"] = _build_match_path(patient_profile, trial, criteria) + + _score_cache[cache_key] = result + return result + + +def _build_match_path(patient_profile: dict, trial: dict, criteria: dict) -> list[dict]: + """ + Build a human-readable graph explainability path showing WHY a patient was matched. + Returns a list of path nodes: Patient → biomarker/diagnosis/lab → Trial + """ + path = [] + patient_id = patient_profile.get("patient_id", "") + nct_id = trial.get("nct_id", "") + trial_title = trial.get("title", "")[:60] + + # Check graph for shared biomarker edges + if _neo4j: + try: + rows = _neo4j.run_query( + """ + MATCH (p:Patient {id: $pid})-[:HAS_BIOMARKER]->(b:Biomarker) + MATCH (t:Trial {id: $nct_id}) + WHERE t.parsed_biomarkers CONTAINS b.name OR t.eligibility_criteria CONTAINS b.name + RETURN b.name AS biomarker LIMIT 3 + """, + {"pid": patient_id, "nct_id": nct_id}, + ) + for row in rows: + path.append({ + "from": f"Patient:{patient_id}", + "rel": "HAS_BIOMARKER", + "to": f"Biomarker:{row['biomarker']}", + "note": "required by trial", + }) + except Exception: + pass + + # Add FHIR-based reasoning nodes from the criteria match + for item in (criteria.get("required_biomarkers") or [])[:2]: + biomarkers = patient_profile.get("biomarkers", {}) + if any(item.lower() in str(k).lower() or item.lower() in str(v).lower() + for k, v in biomarkers.items()): + path.append({ + "from": f"Patient:{patient_id}", + "rel": "HAS_BIOMARKER", + "to": f"Biomarker:{item}", + "note": "matches trial requirement", + }) + + for dx in (criteria.get("required_diagnoses") or [])[:2]: + for patient_dx in patient_profile.get("diagnosis_names", []): + if any(word in patient_dx.lower() for word in dx.lower().split()): + path.append({ + "from": f"Patient:{patient_id}", + "rel": "HAS_DIAGNOSIS", + "to": f"Diagnosis:{patient_dx}", + "note": f"matches required: {dx}", + }) + break + + # Terminal node + path.append({ + "from": f"Patient:{patient_id}", + "rel": "ELIGIBLE_FOR", + "to": f"Trial:{nct_id}", + "note": trial_title, + }) + return path + + +def match_patient_to_trials(patient_id: str, condition: str | None = None, top_n: int = 5) -> list[dict]: + """Find best-matching trials for a patient.""" + patient_profile = get_patient_profile(patient_id) + if not patient_profile: + return [] + + # Infer condition from patient diagnoses if not provided + if not condition and patient_profile.get("diagnosis_names"): + condition = patient_profile["diagnosis_names"][0] + elif not condition: + condition = "cancer" + + trials = search_trials_sync(condition, page_size=10) + + scored = [] + for trial in trials: + score_result = score_patient_for_trial(patient_id, trial) + scored.append({ + **trial, + "match_score": score_result.get("overall_score", 0.0), + "eligible": score_result.get("eligible", False), + "match_summary": score_result.get("summary", ""), + "risk_flags": score_result.get("risk_flags", []), + }) + + scored.sort(key=lambda x: x["match_score"], reverse=True) + return scored[:top_n] + + +def find_eligible_patients_for_trial(nct_id: str) -> list[dict]: + """Screen all known patients against a specific trial.""" + trial = get_trial_details_sync(nct_id) + if not trial: + return [] + + results = [] + for patient_id in get_all_patient_ids(): + score_result = score_patient_for_trial(patient_id, trial) + if score_result.get("overall_score", 0) > 0.4: + results.append({ + "patient_id": patient_id, + "match_score": score_result.get("overall_score", 0.0), + "eligible": score_result.get("eligible", False), + "summary": score_result.get("summary", ""), + "risk_flags": score_result.get("risk_flags", []), + }) + + results.sort(key=lambda x: x["match_score"], reverse=True) + return results diff --git a/backend/mcp_mocks.py b/backend/mcp_mocks.py new file mode 100644 index 0000000000000000000000000000000000000000..7237f11b2d8cd8caeb2731a3ddcbc8660eec05e5 --- /dev/null +++ b/backend/mcp_mocks.py @@ -0,0 +1,34 @@ +# Mock MCP Superpowers for hackathon demo + +def parse_trial_protocol(protocol_text: str): + # Mock: Extract inclusion criteria, etc. + return { + "inclusion_criteria": ["Age > 18", "Diagnosis: Breast Cancer"], + "exclusion_criteria": ["Prior treatment X"], + "phase": "II" + } + +def access_fhir_patient_data(patient_id: str): + # Mock: Return de-identified patient data + return { + "age": 45, + "gender": "F", + "diagnoses": ["C50"], + "medications": ["Drug A"] + } + +def generate_recruitment_message(patient_id: str, trial_id: str): + # Mock: Generate personalized message + return f"Dear Patient {patient_id}, you may be eligible for Trial {trial_id}. Please contact your doctor." + +def orchestrate_a2a_workflow(patient_id: str, trial_id: str): + # Mock A2A: Coordinate the superpowers + protocol = parse_trial_protocol("Mock protocol text") + patient_data = access_fhir_patient_data(patient_id) + message = generate_recruitment_message(patient_id, trial_id) + # Check eligibility (simple mock) + eligible = patient_data["diagnoses"][0] in protocol["inclusion_criteria"] + return { + "eligible": eligible, + "message": message if eligible else None + } \ No newline at end of file diff --git a/backend/mcp_server.py b/backend/mcp_server.py new file mode 100644 index 0000000000000000000000000000000000000000..bae373203b16755e877d2af5b0c2e5fd69fd380b --- /dev/null +++ b/backend/mcp_server.py @@ -0,0 +1,460 @@ +""" +MCP Server for Precision Clinical Trial Matching Agent. +Exposes 9 tools accessible via Prompt Opinion and other MCP-compatible clients. + +Run: python mcp_server.py +Or via SSE: uvicorn mcp_server:sse_app --port 8001 +""" +import asyncio +import json +import os +import sys +import httpx +from dotenv import load_dotenv + +load_dotenv() + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp import types + +from fhir_adapter import get_patient_profile, get_all_patient_ids +from clinicaltrials_api import search_trials_sync, get_trial_details_sync +from matching_engine import match_patient_to_trials, score_patient_for_trial +from llm_client import generate_outreach_message, summarize_trial, get_provider_status +from analytics import get_kpi_summary, get_enrollment_funnel +from neo4j_setup import neo4j_conn +from fhir_server import get_fhir_server_status, get_live_patient_profile, build_sharp_context + + +server = Server("clinical-trial-matching-agent") + + +# US state abbreviation → full name (CT.gov returns full names) +_STATE_ABBR = { + "AL":"Alabama","AK":"Alaska","AZ":"Arizona","AR":"Arkansas","CA":"California", + "CO":"Colorado","CT":"Connecticut","DE":"Delaware","FL":"Florida","GA":"Georgia", + "HI":"Hawaii","ID":"Idaho","IL":"Illinois","IN":"Indiana","IA":"Iowa", + "KS":"Kansas","KY":"Kentucky","LA":"Louisiana","ME":"Maine","MD":"Maryland", + "MA":"Massachusetts","MI":"Michigan","MN":"Minnesota","MS":"Mississippi","MO":"Missouri", + "MT":"Montana","NE":"Nebraska","NV":"Nevada","NH":"New Hampshire","NJ":"New Jersey", + "NM":"New Mexico","NY":"New York","NC":"North Carolina","ND":"North Dakota","OH":"Ohio", + "OK":"Oklahoma","OR":"Oregon","PA":"Pennsylvania","RI":"Rhode Island","SC":"South Carolina", + "SD":"South Dakota","TN":"Tennessee","TX":"Texas","UT":"Utah","VT":"Vermont", + "VA":"Virginia","WA":"Washington","WV":"West Virginia","WI":"Wisconsin","WY":"Wyoming", + "DC":"District of Columbia", +} + + +def _error(code: str, message: str, retry_after: int | None = None) -> list[types.TextContent]: + """Structured error response for MCP callers.""" + payload: dict = {"error": code, "message": message} + if retry_after is not None: + payload["retry_after"] = retry_after + return [types.TextContent(type="text", text=json.dumps(payload))] + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="ping", + description="Health check for the ClinicalMatch AI agent. Returns Neo4j graph status, CT.gov API reachability, seed status, and system readiness. Call this first to confirm the agent is ready before running any workflow.", + inputSchema={ + "type": "object", + "properties": {}, + "required": [], + }, + ), + types.Tool( + name="get_patient_matches", + description="Get the top clinical trial matches for a specific patient with full eligibility score breakdown. Returns ranked trials with inclusion/exclusion criterion analysis, risk flags, and clinical reasoning. Ideal for a one-call eligibility summary before scheduling.", + inputSchema={ + "type": "object", + "properties": { + "patient_id": {"type": "string", "description": "Patient ID (P001–P005 for FHIR mock patients)"}, + "top_n": {"type": "integer", "description": "Number of top matches to return (default 5, max 10)", "default": 5}, + "condition": {"type": "string", "description": "Override condition for trial search (optional — inferred from patient FHIR data if omitted)"}, + }, + "required": ["patient_id"], + }, + ), + types.Tool( + name="list_recruiting_trials", + description="Search for actively recruiting clinical trials by condition with optional geographic filtering. Returns trials sorted by recency with site locations, enrollment targets, and phase details. Use for geographic-aware trial discovery.", + inputSchema={ + "type": "object", + "properties": { + "condition": {"type": "string", "description": "Medical condition (e.g., 'breast cancer', 'NSCLC', 'prostate cancer')"}, + "city": {"type": "string", "description": "Filter to trials with sites near this city (optional)"}, + "state": {"type": "string", "description": "Filter to trials with sites in this US state abbreviation, e.g. 'CA' (optional)"}, + "phase": {"type": "string", "description": "Trial phase filter: '1', '2', '3', or '4'", "enum": ["1", "2", "3", "4"]}, + "max_results": {"type": "integer", "description": "Maximum results to return (default 10, max 20)", "default": 10}, + }, + "required": ["condition"], + }, + ), + types.Tool( + name="find_trials", + description="Search ClinicalTrials.gov for recruiting clinical trials matching a medical condition. Returns ranked list of trials with eligibility criteria, locations, and enrollment info.", + inputSchema={ + "type": "object", + "properties": { + "condition": {"type": "string", "description": "Medical condition (e.g., 'breast cancer', 'NSCLC', 'Alzheimer's disease')"}, + "phase": {"type": "string", "description": "Trial phase: '1', '2', '3', or '4'", "enum": ["1", "2", "3", "4"]}, + "page_size": {"type": "integer", "description": "Number of results (max 20)", "default": 10}, + }, + "required": ["condition"], + }, + ), + types.Tool( + name="screen_patient", + description="Screen a patient against a specific clinical trial using AI-powered FHIR-based analysis. Accepts either a local patient ID or a live FHIR server patient ID with optional SMART bearer token. Returns eligibility score, inclusion/exclusion criterion assessment, clinical reasoning, and SHARP context envelope.", + inputSchema={ + "type": "object", + "properties": { + "patient_id": {"type": "string", "description": "Local patient ID (e.g. P001) OR FHIR server patient ID"}, + "nct_id": {"type": "string", "description": "ClinicalTrials.gov NCT number (e.g. NCT04889131)"}, + "fhir_token": {"type": "string", "description": "SMART on FHIR bearer token for live FHIR server access (optional)"}, + "use_live_fhir": {"type": "boolean", "description": "If true, fetch patient data from the live FHIR server instead of local registry", "default": False}, + }, + "required": ["patient_id", "nct_id"], + }, + ), + types.Tool( + name="match_patient_to_trials", + description="Find the best-matching clinical trials for a patient using semantic AI matching. Accepts local or live FHIR patient ID. Returns ranked matches with SHARP context envelope for downstream agent consumption.", + inputSchema={ + "type": "object", + "properties": { + "patient_id": {"type": "string", "description": "Patient ID (local: P001–P005, or live FHIR ID)"}, + "condition": {"type": "string", "description": "Override condition for search (optional — inferred from FHIR data if omitted)"}, + "top_n": {"type": "integer", "description": "Number of top matches to return", "default": 5}, + "fhir_token": {"type": "string", "description": "SMART on FHIR bearer token (optional)"}, + "use_live_fhir": {"type": "boolean", "description": "Fetch patient from live FHIR server", "default": False}, + }, + "required": ["patient_id"], + }, + ), + types.Tool( + name="generate_recruitment_outreach", + description="Generate personalized recruitment communication for a patient-trial pair. Supports PCP referral letters, patient emails, and social media posts.", + inputSchema={ + "type": "object", + "properties": { + "patient_id": {"type": "string", "description": "Patient ID"}, + "nct_id": {"type": "string", "description": "Trial NCT ID"}, + "channel": { + "type": "string", + "description": "Communication channel", + "enum": ["patient_email", "pcp_letter", "social_post"], + "default": "patient_email", + }, + }, + "required": ["patient_id", "nct_id"], + }, + ), + types.Tool( + name="get_trial_analytics", + description="Get enrollment analytics and recruitment funnel data for a clinical trial or across all active trials.", + inputSchema={ + "type": "object", + "properties": { + "trial_id": {"type": "string", "description": "NCT ID for trial-specific analytics (omit for aggregate)"}, + }, + "required": [], + }, + ), + types.Tool( + name="summarize_trial_protocol", + description="Fetch a clinical trial from ClinicalTrials.gov and generate a plain-language AI summary for clinical coordinators.", + inputSchema={ + "type": "object", + "properties": { + "nct_id": {"type": "string", "description": "ClinicalTrials.gov NCT number"}, + }, + "required": ["nct_id"], + }, + ), + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: + try: + if name == "ping": + # Neo4j check + neo4j_ok = False + node_counts = {} + try: + rows = neo4j_conn.run_query( + "MATCH (n) RETURN labels(n)[0] AS label, count(n) AS cnt" + ) + node_counts = {r["label"]: r["cnt"] for r in rows if r.get("label")} + neo4j_ok = True + except Exception as e: + neo4j_ok = False + + # CT.gov reachability + ctgov_ok = False + try: + r = httpx.get( + "https://clinicaltrials.gov/api/v2/studies", + params={"query.term": "cancer", "pageSize": 1}, + timeout=5, + ) + ctgov_ok = r.status_code == 200 + except Exception: + ctgov_ok = False + + seeded = node_counts.get("Patient", 0) >= 100 + + fhir_status = get_fhir_server_status() + llm_status = get_provider_status() + + status = { + "status": "ready" if (neo4j_ok and ctgov_ok and seeded) else "degraded", + "neo4j": "connected" if neo4j_ok else "unavailable", + "ctgov_api": "reachable" if ctgov_ok else "unreachable", + "fhir_server": "reachable" if fhir_status.get("reachable") else "unreachable", + "fhir_base_url": fhir_status.get("base_url"), + "smart_auth": fhir_status.get("auth_method"), + "graph_seeded": seeded, + "node_counts": node_counts, + "llm_provider": llm_status.get("provider"), + "llm_model": llm_status.get("model"), + "llm_hipaa_eligible": llm_status.get("hipaa_eligible"), + "standards": ["FHIR R4", "MCP", "A2A", "SHARP"], + "agent": "ClinicalMatch AI v2.0 — FHIR R4 · MCP · A2A · SHARP", + } + return [types.TextContent(type="text", text=json.dumps(status, indent=2))] + + elif name == "get_patient_matches": + patient_id = arguments["patient_id"] + top_n = min(int(arguments.get("top_n", 5)), 10) + condition = arguments.get("condition") + + profile = get_patient_profile(patient_id) + if not profile: + return _error("PATIENT_NOT_FOUND", f"Patient '{patient_id}' not found. Available: P001–P005.") + + matches = match_patient_to_trials(patient_id, condition, top_n) + if not matches: + return _error("NO_TRIALS_FOUND", f"No trials found for patient {patient_id}.", retry_after=30) + + output = f"## Top {len(matches)} Trial Matches — {patient_id}\n" + output += f"Patient: {profile['age']}y {profile['gender']} | Dx: {', '.join(profile['diagnosis_names'])}\n\n" + for i, m in enumerate(matches, 1): + output += f"### {i}. {m['title']} ({m['nct_id']})\n" + output += f"**Score:** {m['match_score']:.0%} | **Eligible:** {'✓ YES' if m['eligible'] else '✗ NO'} | **Phase:** {m.get('phase', 'N/A')}\n" + if m.get("match_summary"): + output += f"**Reasoning:** {m['match_summary'][:200]}\n" + if m.get("risk_flags"): + output += f"**Risk Flags:** {'; '.join(m['risk_flags'][:3])}\n" + locs = ", ".join(f"{l['city']}, {l['state']}" for l in m.get("locations", [])[:2]) + if locs: + output += f"**Sites:** {locs}\n" + output += "\n" + return [types.TextContent(type="text", text=output)] + + elif name == "list_recruiting_trials": + condition = arguments["condition"] + city = arguments.get("city", "").lower() + state = arguments.get("state", "").upper() + phase = arguments.get("phase") + max_results = min(int(arguments.get("max_results", 10)), 20) + + trials = search_trials_sync(condition, phase, page_size=max_results) + if not trials: + return _error("NO_TRIALS_FOUND", f"No recruiting trials found for '{condition}'.", retry_after=10) + + # Apply geo filter — CT.gov returns full state names, so expand abbreviation + if city or state: + state_full = _STATE_ABBR.get(state.upper(), state).lower() if state else "" + state_abbr = state.upper() if state else "" + filtered = [] + for t in trials: + locs = t.get("locations", []) + match = any( + (city and city in (l.get("city", "") or "").lower()) or + (state and ( + state_abbr == (l.get("state", "") or "").upper() or + state_full in (l.get("state", "") or "").lower() + )) + for l in locs + ) + if match or not locs: + filtered.append(t) + geo_note = f" near {city or ''}{', ' + state if state else ''}".strip(", ") + trials = filtered or trials # fallback to all if filter too narrow + else: + geo_note = "" + + output = f"## Recruiting Trials: {condition}{geo_note}\n" + output += f"Found {len(trials)} trials (sorted by most recently updated)\n\n" + for i, t in enumerate(trials, 1): + locs = ", ".join(f"{l['city']}, {l['state']}" for l in t.get("locations", [])[:3]) + output += f"{i}. **{t['title']}** ({t['nct_id']})\n" + output += f" Phase: {t.get('phase','N/A')} | Sites: {t.get('location_count',0)} | Enrollment: {t.get('enrollment','N/A')}\n" + output += f" Sponsor: {t.get('sponsor','N/A')} | Updated: {t.get('last_updated','N/A')}\n" + if locs: + output += f" Locations: {locs}\n" + output += f" URL: {t.get('ctgov_url','')}\n\n" + return [types.TextContent(type="text", text=output)] + + elif name == "find_trials": + condition = arguments["condition"] + phase = arguments.get("phase") + page_size = min(int(arguments.get("page_size", 10)), 20) + trials = search_trials_sync(condition, phase, page_size=page_size) + output = f"Found {len(trials)} recruiting trials for '{condition}':\n\n" + for i, trial in enumerate(trials, 1): + locs = ", ".join(f"{l['city']}, {l['state']}" for l in trial.get("locations", [])[:2]) + output += f"{i}. **{trial['title']}** ({trial['nct_id']})\n" + output += f" Phase: {trial['phase']} | Status: {trial['status']} | Sites: {trial['location_count']}\n" + output += f" Enrollment: {trial['enrollment']} | Sponsor: {trial['sponsor']}\n" + if locs: + output += f" Locations: {locs}\n" + output += "\n" + return [types.TextContent(type="text", text=output)] + + elif name == "screen_patient": + patient_id = arguments["patient_id"] + nct_id = arguments["nct_id"] + use_live_fhir = arguments.get("use_live_fhir", False) + fhir_token = arguments.get("fhir_token") + + # Build SHARP context envelope + sharp_ctx = build_sharp_context( + patient_id=patient_id, + fhir_ref=f"Patient/{patient_id}", + ) + if fhir_token: + sharp_ctx["fhir_token"] = fhir_token + + # Optionally fetch from live FHIR server + if use_live_fhir: + live_profile = get_live_patient_profile(patient_id, sharp_context=sharp_ctx) + if not live_profile: + return _error("FHIR_PATIENT_NOT_FOUND", + f"Patient '{patient_id}' not found on FHIR server {sharp_ctx['patient_context']['fhir_base']}") + + trial = get_trial_details_sync(nct_id) + if not trial: + return _error("TRIAL_NOT_FOUND", f"Trial {nct_id} not found in ClinicalTrials.gov") + result = score_patient_for_trial(patient_id, trial) + if "error" in result: + return _error("SCREENING_ERROR", result["error"]) + result["sharp_context"] = sharp_ctx + + score = result.get("overall_score", 0) + eligible = result.get("eligible", False) + output = f"## Eligibility Assessment: {patient_id} → {nct_id}\n\n" + output += f"**Overall Score:** {score:.0%} | **Eligible:** {'YES' if eligible else 'NO'}\n\n" + output += f"**Clinical Reasoning:** {result.get('summary', '')}\n\n" + + incl = result.get("inclusion_results", []) + if incl: + output += "**Inclusion Criteria:**\n" + for c in incl: + icon = "✓" if c.get("met") else "✗" + output += f" {icon} {c.get('criterion', '')} [{c.get('confidence', '')}]\n" + excl = result.get("exclusion_results", []) + if excl: + output += "\n**Exclusion Criteria:**\n" + for c in excl: + icon = "⚠" if c.get("triggered") else "✓" + output += f" {icon} {c.get('criterion', '')} [{c.get('confidence', '')}]\n" + flags = result.get("risk_flags", []) + if flags: + output += f"\n**Risk Flags:** {'; '.join(flags)}" + return [types.TextContent(type="text", text=output)] + + elif name == "match_patient_to_trials": + patient_id = arguments["patient_id"] + condition = arguments.get("condition") + top_n = int(arguments.get("top_n", 5)) + use_live_fhir = arguments.get("use_live_fhir", False) + fhir_token = arguments.get("fhir_token") + + sharp_ctx = build_sharp_context(patient_id=patient_id, fhir_ref=f"Patient/{patient_id}") + if fhir_token: + sharp_ctx["fhir_token"] = fhir_token + + if use_live_fhir: + profile = get_live_patient_profile(patient_id, sharp_context=sharp_ctx) + if not profile: + return _error("FHIR_PATIENT_NOT_FOUND", f"Patient '{patient_id}' not found on FHIR server") + if not condition and profile.get("diagnosis_names"): + condition = profile["diagnosis_names"][0] + else: + profile = get_patient_profile(patient_id) + + matches = match_patient_to_trials(patient_id, condition, top_n) + output = f"## Top {len(matches)} Trial Matches for {patient_id}\n" + output += f"SHARP: fhir_ref={sharp_ctx['patient_context']['fhir_ref']} session={sharp_ctx['patient_context']['session_id'][:8]}...\n" + if profile: + output += f"Patient: {profile['age']}y {profile['gender']} | Diagnoses: {', '.join(profile.get('diagnosis_names', []))}\n\n" + for i, m in enumerate(matches, 1): + output += f"{i}. **{m['title']}** ({m['nct_id']})\n" + output += f" Match Score: {m['match_score']:.0%} | Eligible: {'YES' if m['eligible'] else 'NO'} | Phase: {m.get('phase','N/A')}\n" + if m.get("match_summary"): + output += f" {m['match_summary'][:150]}...\n" + output += "\n" + return [types.TextContent(type="text", text=output)] + + elif name == "generate_recruitment_outreach": + patient_id = arguments["patient_id"] + nct_id = arguments["nct_id"] + channel = arguments.get("channel", "patient_email") + trial = get_trial_details_sync(nct_id) or {"nct_id": nct_id, "title": "Clinical Trial", "brief_summary": "", "phase": "N/A", "sponsor": "N/A", "locations": []} + patient_profile = get_patient_profile(patient_id) + if not patient_profile: + return [types.TextContent(type="text", text=f"Patient {patient_id} not found")] + message = generate_outreach_message(patient_profile, trial, channel) + output = f"## Recruitment Outreach ({channel.replace('_', ' ').title()})\n" + output += f"Patient: {patient_id} | Trial: {nct_id}\n\n" + output += "---\n\n" + message + return [types.TextContent(type="text", text=output)] + + elif name == "get_trial_analytics": + trial_id = arguments.get("trial_id") + kpis = get_kpi_summary() + funnel = get_enrollment_funnel(trial_id) + output = "## Clinical Trial Analytics\n\n" + output += f"**Active Trials:** {kpis['active_trials']}\n" + output += f"**Patients Identified:** {kpis['patients_identified']}\n" + output += f"**Enrollment Rate:** {kpis['enrollment_rate']:.0%}\n" + output += f"**Avg Days to Match:** {kpis['avg_days_to_match']}\n" + output += f"**Cost Savings:** ${kpis['cost_saved_usd']:,}\n\n" + output += "**Enrollment Funnel:**\n" + for stage in funnel: + output += f" {stage['stage']}: {stage['count']}\n" + return [types.TextContent(type="text", text=output)] + + elif name == "summarize_trial_protocol": + nct_id = arguments["nct_id"] + trial = get_trial_details_sync(nct_id) + if not trial: + return [types.TextContent(type="text", text=f"Trial {nct_id} not found")] + summary = summarize_trial(trial) + output = f"## {trial['title']} ({nct_id})\n\n" + output += f"**Phase:** {trial['phase']} | **Status:** {trial['status']} | **Enrollment:** {trial['enrollment']}\n" + output += f"**Sponsor:** {trial['sponsor']}\n\n" + output += summary + return [types.TextContent(type="text", text=output)] + + else: + return [types.TextContent(type="text", text=f"Unknown tool: {name}")] + + except Exception as e: + return _error("TOOL_ERROR", f"Tool '{name}' failed: {str(e)}") + + +async def main(): + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, server.create_initialization_options()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/neo4j_setup.py b/backend/neo4j_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..b72f5723c25a8b87decc71f618cf1dd3104d32ba --- /dev/null +++ b/backend/neo4j_setup.py @@ -0,0 +1,53 @@ +from neo4j import GraphDatabase +import os +from dotenv import load_dotenv + +load_dotenv() + + +class Neo4jConnection: + def __init__(self, uri: str, user: str, password: str, database: str = "neo4j"): + self.driver = GraphDatabase.driver(uri, auth=(user, password)) + self.database = database + + def close(self): + self.driver.close() + + def run_query(self, query: str, parameters: dict | None = None) -> list: + with self.driver.session(database=self.database) as session: + result = session.run(query, parameters or {}) + return [record.data() for record in result] + + +neo4j_conn = Neo4jConnection( + uri=os.getenv("NEO4J_URI", ""), + user=os.getenv("NEO4J_USERNAME", "neo4j"), + password=os.getenv("NEO4J_PASSWORD", ""), + database=os.getenv("NEO4J_DATABASE", "neo4j"), +) + + +def setup_schema(): + constraints = [ + "CREATE CONSTRAINT patient_id IF NOT EXISTS FOR (p:Patient) REQUIRE p.id IS UNIQUE", + "CREATE CONSTRAINT trial_id IF NOT EXISTS FOR (t:Trial) REQUIRE t.id IS UNIQUE", + "CREATE CONSTRAINT diagnosis_code IF NOT EXISTS FOR (d:Diagnosis) REQUIRE d.code IS UNIQUE", + "CREATE CONSTRAINT site_id IF NOT EXISTS FOR (s:StudySite) REQUIRE s.id IS UNIQUE", + ] + indexes = [ + "CREATE INDEX patient_age IF NOT EXISTS FOR (p:Patient) ON (p.age)", + "CREATE INDEX trial_phase IF NOT EXISTS FOR (t:Trial) ON (t.phase)", + "CREATE INDEX trial_condition IF NOT EXISTS FOR (t:Trial) ON (t.condition)", + "CREATE INDEX trial_status IF NOT EXISTS FOR (t:Trial) ON (t.status)", + ] + for query in constraints + indexes: + try: + neo4j_conn.run_query(query) + except Exception as e: + print(f"Schema warning: {e}") + print("Schema setup complete.") + + +if __name__ == "__main__": + setup_schema() + neo4j_conn.close() diff --git a/backend/recruitment_pipeline.py b/backend/recruitment_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..e01cc926d7f399069869539ddc6e081462fe12db --- /dev/null +++ b/backend/recruitment_pipeline.py @@ -0,0 +1,122 @@ +"""Recruitment pipeline — state tracking and communication management.""" +import uuid +from datetime import datetime +from enum import Enum +from fhir_adapter import get_patient_profile, MOCK_FHIR_PATIENTS +from llm_client import generate_outreach_message + +class RecruitmentStatus(str, Enum): + IDENTIFIED = "IDENTIFIED" + CONTACTED = "CONTACTED" + SCREENING = "SCREENING" + CONSENTED = "CONSENTED" + ENROLLED = "ENROLLED" + DECLINED = "DECLINED" + INELIGIBLE = "INELIGIBLE" + + +# In-memory pipeline store +_pipeline: dict[str, dict] = {} + + +def _seed_demo_records(): + """Seed realistic demo records across pipeline stages.""" + demo = [ + ("P001", "NCT04889131", "Precision Breast Cancer Study", 0.91, RecruitmentStatus.SCREENING), + ("P001", "NCT05123456", "Immunotherapy Combination Trial", 0.78, RecruitmentStatus.CONTACTED), + ("P002", "NCT05456789", "Prostate Cancer BRCA2 Study", 0.85, RecruitmentStatus.IDENTIFIED), + ("P003", "NCT04889131", "Precision Breast Cancer Study", 0.65, RecruitmentStatus.IDENTIFIED), + ("P004", "NCT06112233", "EGFR-Mutant NSCLC Trial", 0.93, RecruitmentStatus.CONSENTED), + ("P004", "NCT05987654", "PD-L1 Immunotherapy Study", 0.81, RecruitmentStatus.SCREENING), + ("P005", "NCT05334455", "MSI-H Colorectal Cancer Study", 0.88, RecruitmentStatus.ENROLLED), + ("P002", "NCT04223344", "Androgen Receptor Pathway Study", 0.72, RecruitmentStatus.DECLINED), + ] + for patient_id, nct_id, trial_title, score, status in demo: + record_id = str(uuid.uuid4()) + _pipeline[record_id] = { + "record_id": record_id, + "patient_id": patient_id, + "nct_id": nct_id, + "trial_title": trial_title, + "match_score": score, + "status": status, + "outreach_history": [], + "created_at": datetime.utcnow().isoformat(), + "updated_at": datetime.utcnow().isoformat(), + } + + +_seed_demo_records() + + +def get_kanban_board() -> dict: + """Return records grouped by status for kanban view.""" + board: dict[str, list] = {s: [] for s in RecruitmentStatus} + for record in _pipeline.values(): + board[record["status"]].append(record) + return board + + +def get_all_records() -> list[dict]: + return list(_pipeline.values()) + + +def get_record(record_id: str) -> dict | None: + return _pipeline.get(record_id) + + +def create_record(patient_id: str, nct_id: str, trial_title: str, match_score: float) -> dict: + record_id = str(uuid.uuid4()) + record = { + "record_id": record_id, + "patient_id": patient_id, + "nct_id": nct_id, + "trial_title": trial_title, + "match_score": match_score, + "status": RecruitmentStatus.IDENTIFIED, + "outreach_history": [], + "created_at": datetime.utcnow().isoformat(), + "updated_at": datetime.utcnow().isoformat(), + } + _pipeline[record_id] = record + return record + + +def update_status(record_id: str, new_status: RecruitmentStatus) -> dict: + if record_id not in _pipeline: + raise ValueError(f"Record {record_id} not found") + _pipeline[record_id]["status"] = new_status + _pipeline[record_id]["updated_at"] = datetime.utcnow().isoformat() + return _pipeline[record_id] + + +def generate_and_store_outreach(patient_id: str, nct_id: str, trial_title: str, trial: dict, channel: str) -> dict: + patient_profile = get_patient_profile(patient_id) + if not patient_profile: + raise ValueError(f"Patient {patient_id} not found") + + message = generate_outreach_message(patient_profile, trial, channel) + + outreach = { + "id": str(uuid.uuid4()), + "channel": channel, + "message": message, + "generated_at": datetime.utcnow().isoformat(), + "status": "GENERATED", + } + + # Find or create pipeline record + record_id = None + for rid, record in _pipeline.items(): + if record["patient_id"] == patient_id and record["nct_id"] == nct_id: + record_id = rid + break + + if not record_id: + record = create_record(patient_id, nct_id, trial_title, 0.75) + record_id = record["record_id"] + + _pipeline[record_id]["outreach_history"].append(outreach) + _pipeline[record_id]["updated_at"] = datetime.utcnow().isoformat() + + return {"record_id": record_id, "outreach": outreach} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..2bc56043fe0b75aa8ecee282e1daeba5867d912c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi +uvicorn[standard] +neo4j +langchain +langchain-community +langchain-openai +openai +httpx +mcp +pydantic +python-dotenv diff --git a/backend/rl_enrichment.py b/backend/rl_enrichment.py new file mode 100644 index 0000000000000000000000000000000000000000..e23c2e5297d8a77d85045c933b8738f80c291260 --- /dev/null +++ b/backend/rl_enrichment.py @@ -0,0 +1,62 @@ +import torch +import torch.nn as nn +import torch.optim as optim +from torch_geometric.data import Data +from torch_geometric.nn import GCNConv +import random + +# Simple GNN for graph representation +class GNN(nn.Module): + def __init__(self, in_channels, hidden_channels, out_channels): + super(GNN, self).__init__() + self.conv1 = GCNConv(in_channels, hidden_channels) + self.conv2 = GCNConv(hidden_channels, out_channels) + + def forward(self, x, edge_index): + x = self.conv1(x, edge_index) + x = torch.relu(x) + x = self.conv2(x, edge_index) + return x + +# Simple RL Agent for graph enrichment +class GraphRLEnrichment: + def __init__(self, graph_data): + self.model = GNN(in_channels=graph_data.x.shape[1], hidden_channels=64, out_channels=32) + self.optimizer = optim.Adam(self.model.parameters(), lr=0.01) + self.graph_data = graph_data + + def get_state(self): + # Get GNN embedding as state + with torch.no_grad(): + state = self.model(self.graph_data.x, self.graph_data.edge_index) + return state.mean(dim=0) # Aggregate to single vector + + def select_action(self, state): + # Simple policy: random for now, in full impl use policy network + return random.choice([0, 1]) # 0: no edge, 1: add edge + + def train_step(self, reward): + # Simple training: minimize negative reward + loss = -reward # Dummy loss + self.optimizer.zero_grad() + loss.backward() + self.optimizer.step() + +# Mock graph data (in practice, convert Neo4j graph to PyG Data) +# Assume nodes: 0-1 patients, 2-3 diagnoses, 4-5 trials +edge_index = torch.tensor([[0, 1, 2, 3, 4], + [2, 3, 4, 5, 5]], dtype=torch.long) # Mock edges +x = torch.randn(6, 10) # 6 nodes, 10 features +graph_data = Data(x=x, edge_index=edge_index) + +rl_agent = GraphRLEnrichment(graph_data) + +def enrich_graph(): + state = rl_agent.get_state() + action = rl_agent.select_action(state) + # Simulate reward: if action=1, add edge and reward=1 if successful + reward = random.random() if action == 1 else 0 + rl_agent.train_step(reward) + if action == 1: + print("Added potential edge via RL enrichment.") + return reward \ No newline at end of file diff --git a/backend/trial_enrichment.py b/backend/trial_enrichment.py new file mode 100644 index 0000000000000000000000000000000000000000..1c6a28cfbbcc3e395678f3db127da3caccca626a --- /dev/null +++ b/backend/trial_enrichment.py @@ -0,0 +1,233 @@ +""" +Passive graph enrichment — called automatically when users search for trials. +Each search result is upserted into Neo4j so the graph grows richer over time. +Also provides graph-intelligence queries for the UI. +""" +from neo4j_setup import neo4j_conn +import json + + +def upsert_trial(trial: dict) -> None: + """Write/update a Trial node from a ClinicalTrials.gov result.""" + nct_id = trial.get("nct_id", "") + if not nct_id: + return + neo4j_conn.run_query( + """ + MERGE (t:Trial {id: $id}) + SET t += { + title: $title, + status: $status, + phase: $phase, + condition: $condition, + brief_summary: $brief_summary, + eligibility_criteria: $eligibility_criteria, + min_age: $min_age, + max_age: $max_age, + sex: $sex, + enrollment: $enrollment, + start_date: $start_date, + completion_date: $completion_date, + last_updated: $last_updated, + sponsor: $sponsor, + location_count: $location_count, + ctgov_url: $ctgov_url, + ingested_at: datetime() + } + """, + { + "id": nct_id, + "title": trial.get("title", "")[:200], + "status": trial.get("status", ""), + "phase": trial.get("phase", "N/A"), + "condition": trial.get("condition", "").lower(), + "brief_summary": trial.get("brief_summary", "")[:1000], + "eligibility_criteria": trial.get("eligibility_criteria", "")[:2000], + "min_age": trial.get("min_age", ""), + "max_age": trial.get("max_age", ""), + "sex": trial.get("sex", "ALL"), + "enrollment": trial.get("enrollment", 0), + "start_date": trial.get("start_date", ""), + "completion_date": trial.get("completion_date", ""), + "last_updated": trial.get("last_updated", ""), + "sponsor": trial.get("sponsor", "")[:100], + "location_count": trial.get("location_count", 0), + "ctgov_url": trial.get("ctgov_url", f"https://clinicaltrials.gov/study/{nct_id}"), + }, + ) + + # Upsert StudySite nodes for each location + for loc in trial.get("locations", []): + if not loc.get("city"): + continue + site_id = f"SITE_{nct_id}_{loc['city'].replace(' ', '_').upper()}" + neo4j_conn.run_query( + """ + MERGE (s:StudySite {id: $id}) + SET s += {name: $name, city: $city, state: $state, country: $country, + lat: $lat, lon: $lon} + WITH s + MATCH (t:Trial {id: $nct_id}) + MERGE (t)-[:LOCATED_AT]->(s) + """, + { + "id": site_id, + "name": loc.get("facility", f"{loc['city']} Site"), + "city": loc["city"], + "state": loc.get("state", ""), + "country": loc.get("country", "US"), + "lat": loc.get("lat"), + "lon": loc.get("lon"), + "nct_id": nct_id, + }, + ) + + +def enrich_trials_from_search(trials: list[dict], condition: str) -> None: + """Background-safe: upsert all search results into Neo4j, then LLM-parse eligibility.""" + for trial in trials: + if not trial.get("condition"): + trial["condition"] = condition + try: + upsert_trial(trial) + # LLM-parse eligibility criteria and store as structured graph properties + _enrich_eligibility_structured(trial) + except Exception as e: + print(f"[enrichment] failed to upsert {trial.get('nct_id')}: {e}") + + +def _enrich_eligibility_structured(trial: dict) -> None: + """ + Parse eligibility_criteria text with LLM and store structured fields on the Trial node. + Only runs if the node doesn't already have parsed criteria (idempotent). + """ + nct_id = trial.get("nct_id", "") + if not nct_id or not trial.get("eligibility_criteria"): + return + + # Skip if already parsed + existing = neo4j_conn.run_query( + "MATCH (t:Trial {id: $id}) RETURN t.parsed_at AS pa", {"id": nct_id} + ) + if existing and existing[0].get("pa"): + return + + try: + from llm_client import parse_trial_protocol + criteria = parse_trial_protocol(trial["eligibility_criteria"]) + + neo4j_conn.run_query( + """ + MATCH (t:Trial {id: $id}) + SET t.parsed_inclusion = $inclusion, + t.parsed_exclusion = $exclusion, + t.parsed_age_min = $age_min, + t.parsed_age_max = $age_max, + t.parsed_biomarkers = $biomarkers, + t.parsed_ecog_max = $ecog_max, + t.parsed_at = datetime() + """, + { + "id": nct_id, + "inclusion": json.dumps(criteria.get("inclusion_criteria", [])[:10]), + "exclusion": json.dumps(criteria.get("exclusion_criteria", [])[:10]), + "age_min": criteria.get("age_range", {}).get("min"), + "age_max": criteria.get("age_range", {}).get("max"), + "biomarkers": json.dumps(criteria.get("required_biomarkers", [])), + "ecog_max": _extract_ecog_max(criteria.get("performance_status", "")), + }, + ) + print(f"[enrichment] parsed eligibility for {nct_id}") + except Exception as e: + print(f"[enrichment] LLM parse failed for {nct_id}: {e}") + + +def _extract_ecog_max(perf_status: str) -> int | None: + """Extract numeric ECOG upper bound from strings like 'ECOG 0-2' or 'ECOG ≤ 1'.""" + import re + if not perf_status: + return None + m = re.search(r"(\d)\s*[-–]\s*(\d)", perf_status) + if m: + return int(m.group(2)) + m = re.search(r"[≤<=]\s*(\d)", perf_status) + if m: + return int(m.group(1)) + m = re.search(r"(\d)", perf_status) + if m: + return int(m.group(1)) + return None + + +def get_eligible_patient_count(nct_id: str) -> int: + """Count patients in the graph with an ELIGIBLE_FOR edge to this trial.""" + rows = neo4j_conn.run_query( + "MATCH (p:Patient)-[:ELIGIBLE_FOR]->(t:Trial {id: $id}) RETURN count(p) AS n", + {"id": nct_id}, + ) + return rows[0]["n"] if rows else 0 + + +def get_eligible_patient_counts(nct_ids: list[str]) -> dict[str, int]: + """Batch version — returns {nct_id: count} for a list of trials.""" + if not nct_ids: + return {} + rows = neo4j_conn.run_query( + """ + MATCH (p:Patient)-[:ELIGIBLE_FOR]->(t:Trial) + WHERE t.id IN $ids + RETURN t.id AS nct_id, count(p) AS n + """, + {"ids": nct_ids}, + ) + return {row["nct_id"]: row["n"] for row in rows} + + +def get_similar_trials(nct_id: str, limit: int = 5) -> list[dict]: + """Graph-walk: find trials sharing eligible patients with this trial.""" + rows = neo4j_conn.run_query( + """ + MATCH (p:Patient)-[:ELIGIBLE_FOR]->(seed:Trial {id: $id}) + MATCH (p)-[:ELIGIBLE_FOR]->(other:Trial) + WHERE other.id <> $id + RETURN other.id AS nct_id, other.title AS title, other.phase AS phase, + other.condition AS condition, count(p) AS shared_patients + ORDER BY shared_patients DESC LIMIT $limit + """, + {"id": nct_id, "limit": limit}, + ) + return rows + + +def get_graph_intelligence(nct_id: str) -> dict: + """Aggregate graph-derived insights for a single trial.""" + eligible_count = get_eligible_patient_count(nct_id) + similar = get_similar_trials(nct_id, limit=3) + + # Biomarker coverage — which biomarkers do eligible patients carry? + bm_rows = neo4j_conn.run_query( + """ + MATCH (p:Patient)-[:ELIGIBLE_FOR]->(t:Trial {id: $id}) + MATCH (p)-[:HAS_BIOMARKER]->(b:Biomarker) + RETURN b.name AS biomarker, count(p) AS patient_count + ORDER BY patient_count DESC LIMIT 5 + """, + {"id": nct_id}, + ) + + # Site density — patients near trial sites + site_rows = neo4j_conn.run_query( + """ + MATCH (t:Trial {id: $id})-[:LOCATED_AT]->(s:StudySite) + RETURN s.city AS city, s.state AS state + LIMIT 5 + """, + {"id": nct_id}, + ) + + return { + "eligible_patients": eligible_count, + "similar_trials": similar, + "top_biomarkers": bm_rows, + "sites": site_rows, + } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..5b51c353943bba28691714b286a213400a9b9169 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,84 @@ +version: "3.9" + +# ── Local development stack ──────────────────────────────────────────────────── +# Usage: +# docker compose up -d +# docker compose logs -f backend # watch logs +# docker compose exec backend python graph_seeder.py # seed real data +# +# Frontend: http://localhost:3000 +# Backend: http://localhost:8000 +# Neo4j Browser: http://localhost:7474 (neo4j / clinicalmatch2024) + +services: + + # ── Neo4j Community (free, no expiry) ──────────────────────────────────────── + neo4j: + image: neo4j:5.18-community + container_name: clinicalmatch-neo4j + restart: unless-stopped + ports: + - "7476:7474" # Neo4j Browser + - "7687:7687" # Bolt + volumes: + - neo4j_data:/data + - neo4j_logs:/logs + environment: + NEO4J_AUTH: "neo4j/clinicalmatch2024" + NEO4J_PLUGINS: '["apoc"]' + NEO4J_dbms_security_procedures_unrestricted: "apoc.*" + NEO4J_dbms_security_procedures_allowlist: "apoc.*" + NEO4J_server_memory_heap_initial__size: "512m" + NEO4J_server_memory_heap_max__size: "1g" + NEO4J_server_memory_pagecache_size: "256m" + NEO4J_dbms_logs_query_enabled: "OFF" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:7476 || exit 1"] + interval: 20s + timeout: 10s + retries: 10 + start_period: 60s + + # ── FastAPI backend ─────────────────────────────────────────────────────────── + backend: + build: + context: . + dockerfile: docker/Dockerfile.backend + container_name: clinicalmatch-backend + restart: unless-stopped + ports: + - "8000:8000" + depends_on: + neo4j: + condition: service_healthy + env_file: .env.local + environment: + NEO4J_URI: "bolt://neo4j:7687" + NEO4J_USERNAME: "neo4j" + NEO4J_PASSWORD: "clinicalmatch2024" + NEO4J_DATABASE: "neo4j" + command: > + sh -c "python3 neo4j_setup.py && + python3 data_ingestion.py && + uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2" + volumes: + - ./backend:/app # hot-reload for local dev + working_dir: /app + + # ── Next.js frontend ────────────────────────────────────────────────────────── + frontend: + build: + context: . + dockerfile: docker/Dockerfile.frontend + container_name: clinicalmatch-frontend + restart: unless-stopped + ports: + - "3000:3000" + depends_on: + - backend + environment: + NEXT_PUBLIC_API_URL: "http://localhost:8000" + +volumes: + neo4j_data: + neo4j_logs: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..13857a9f0a32b0e631f237518dad531d120a2322 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,128 @@ +# ═══════════════════════════════════════════════════════════════════════════════ +# ClinicalMatch AI — HuggingFace Spaces Dockerfile +# Single container: Neo4j Community + FastAPI + Next.js + Nginx (supervisord) +# Exposed port: 7860 (HF Spaces default) +# Persistent storage: /data (Neo4j data lives here — survives restarts) +# ═══════════════════════════════════════════════════════════════════════════════ + +# ── Stage 1: Build Next.js ──────────────────────────────────────────────────── +FROM node:20-slim AS frontend-builder + +WORKDIR /build/frontend + +COPY frontend/package*.json ./ +RUN npm install --legacy-peer-deps --prefer-offline + +COPY frontend/ ./ + +# Build with empty API URL so all requests are relative (Nginx routes them) +ENV NEXT_PUBLIC_API_URL="" +RUN npm run build + +# ── Stage 2: Final runtime image ────────────────────────────────────────────── +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV LANG=C.UTF-8 + +# ── System dependencies ──────────────────────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + # Java for Neo4j + openjdk-17-jre-headless \ + # Python + python3.11 python3-pip python3.11-venv \ + # Web / infra + nginx \ + supervisor \ + # Utilities + curl wget ca-certificates gnupg \ + && rm -rf /var/lib/apt/lists/* + +# ── Node.js 20 ──────────────────────────────────────────────────────────────── +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# ── Neo4j Community 5.x ─────────────────────────────────────────────────────── +ENV NEO4J_VERSION=5.18.0 +ENV NEO4J_HOME=/opt/neo4j +ENV PATH="${NEO4J_HOME}/bin:${PATH}" + +ENV APOC_VERSION=5.18.0 + +RUN wget -q "https://dist.neo4j.org/neo4j-community-${NEO4J_VERSION}-unix.tar.gz" \ + && tar -xzf "neo4j-community-${NEO4J_VERSION}-unix.tar.gz" -C /opt \ + && mv "/opt/neo4j-community-${NEO4J_VERSION}" /opt/neo4j \ + && rm "neo4j-community-${NEO4J_VERSION}-unix.tar.gz" \ + && rm -rf /opt/neo4j/data # will be symlinked to /data at runtime + +# Download APOC plugin (Community-compatible jar) +RUN wget -q \ + "https://github.com/neo4j/apoc/releases/download/${APOC_VERSION}/apoc-${APOC_VERSION}-core.jar" \ + -O /opt/neo4j/plugins/apoc-${APOC_VERSION}-core.jar + +# Neo4j configuration — listen on all interfaces, use /data for persistence +RUN { \ + echo "server.bolt.listen_address=0.0.0.0:7687"; \ + echo "server.http.listen_address=0.0.0.0:7474"; \ + echo "server.directories.data=/data/neo4j/data"; \ + echo "server.directories.logs=/data/neo4j/logs"; \ + echo "server.directories.plugins=/data/neo4j/plugins"; \ + echo "dbms.security.auth_enabled=true"; \ + echo "dbms.security.procedures.unrestricted=apoc.*"; \ + echo "dbms.security.procedures.allowlist=apoc.*"; \ + echo "server.memory.heap.initial_size=512m"; \ + echo "server.memory.heap.max_size=1g"; \ + echo "server.memory.pagecache.size=256m"; \ + echo "db.transaction.timeout=60s"; \ + echo "dbms.logs.query.enabled=OFF"; \ +} >> /opt/neo4j/conf/neo4j.conf + +# ── Python backend ──────────────────────────────────────────────────────────── +WORKDIR /app/backend + +COPY backend/requirements.txt . +RUN pip3 install --no-cache-dir -r requirements.txt + +COPY backend/ . + +# ── Next.js frontend (pre-built) ─────────────────────────────────────────────── +WORKDIR /app/frontend + +# Copy only what Next.js needs to run (not dev deps) +COPY --from=frontend-builder /build/frontend/.next/standalone ./ +COPY --from=frontend-builder /build/frontend/.next/static ./.next/static +COPY --from=frontend-builder /build/frontend/public ./public + +# ── Config files ─────────────────────────────────────────────────────────────── +COPY docker/nginx.conf /app/docker/nginx.conf +COPY docker/supervisord.conf /app/docker/supervisord.conf +COPY docker/entrypoint.sh /app/docker/entrypoint.sh + +RUN chmod +x /app/docker/entrypoint.sh + +# ── Nginx writable dirs (runs without root after init) ──────────────────────── +RUN mkdir -p /tmp/nginx-cache /tmp/nginx-body /tmp/nginx-run \ + && chown -R www-data:www-data /var/log/nginx /var/lib/nginx 2>/dev/null || true + +# ── Expose & environment ─────────────────────────────────────────────────────── +EXPOSE 7860 + +# Neo4j — local Community instance (no Aura) +ENV NEO4J_URI=bolt://127.0.0.1:7687 +ENV NEO4J_USERNAME=neo4j +ENV NEO4J_PASSWORD=clinicalmatch2024 +ENV NEO4J_DATABASE=neo4j + +# LLM — OpenAI-compatible (set real values via HF Spaces secrets) +ENV OPENAI_API_KEY="" +ENV OPENAI_BASE_URL=https://ai.aimlapi.com/v1 +ENV OPENAI_MODEL=claude-opus-4-7 + +# Next.js standalone listens on 3000 internally; Nginx routes externally +ENV PORT=3000 +ENV HOSTNAME=127.0.0.1 + +WORKDIR /app + +ENTRYPOINT ["/app/docker/entrypoint.sh"] diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend new file mode 100644 index 0000000000000000000000000000000000000000..a06fb5c59f1270708894066b8dd011fe95551ed2 --- /dev/null +++ b/docker/Dockerfile.backend @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend new file mode 100644 index 0000000000000000000000000000000000000000..9181d969d1e6b4927f8ceb43748b1eef4eaea7eb --- /dev/null +++ b/docker/Dockerfile.frontend @@ -0,0 +1,29 @@ +FROM node:20-slim AS builder + +WORKDIR /app + +COPY frontend/package*.json ./ +RUN npm install --legacy-peer-deps + +COPY frontend/ ./ + +ARG NEXT_PUBLIC_API_URL=http://localhost:8000 +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} + +RUN npm run build + +# ── Runtime ──────────────────────────────────────────────────────────────────── +FROM node:20-slim + +WORKDIR /app + +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +CMD ["node", "server.js"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..e81f0b5f0a2b2de49cec8094d72475fbece9fd62 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,61 @@ +#!/bin/bash +set -e + +log() { echo "[entrypoint] $*"; } + +# ── Persistent data dirs (HF Spaces mounts /data) ───────────────────────────── +mkdir -p /data/neo4j/data /data/neo4j/logs /data/neo4j/plugins + +# Symlink Neo4j data dir to persistent volume +if [ ! -L /opt/neo4j/data ]; then + rm -rf /opt/neo4j/data + ln -sf /data/neo4j/data /opt/neo4j/data +fi +if [ ! -L /opt/neo4j/logs ]; then + rm -rf /opt/neo4j/logs + ln -sf /data/neo4j/logs /opt/neo4j/logs +fi + +# ── Neo4j password bootstrap (first-boot only) ──────────────────────────────── +NEO4J_PASS="${NEO4J_PASSWORD:-clinicalmatch2024}" + +if [ ! -f /data/.neo4j_ready ]; then + log "First boot — initialising Neo4j password..." + # Start Neo4j with default password, change it, stop cleanly + /opt/neo4j/bin/neo4j start + log "Waiting for Neo4j to accept connections..." + for i in $(seq 1 30); do + if /opt/neo4j/bin/cypher-shell -u neo4j -p neo4j \ + "RETURN 1;" >/dev/null 2>&1; then + break + fi + sleep 2 + done + /opt/neo4j/bin/cypher-shell -u neo4j -p neo4j \ + "ALTER CURRENT USER SET PASSWORD FROM 'neo4j' TO '$NEO4J_PASS';" 2>/dev/null || true + /opt/neo4j/bin/neo4j stop + sleep 3 + + # Run schema + sample data seeding + log "Seeding schema and sample data..." + cd /app/backend + NEO4J_URI=bolt://127.0.0.1:7687 \ + NEO4J_USERNAME=neo4j \ + NEO4J_PASSWORD="$NEO4J_PASS" \ + python3 -c " +from neo4j_setup import setup_schema +from data_ingestion import ingest_sample_data +setup_schema() +ingest_sample_data() +print('Schema and sample data ready.') +" 2>/dev/null || log "Seeding deferred — Neo4j not yet ready (will retry via /setup endpoint)" + + touch /data/.neo4j_ready + log "Neo4j initialisation complete." +fi + +# ── Nginx tmp dirs (runs as non-root) ───────────────────────────────────────── +mkdir -p /tmp/nginx-cache /tmp/nginx-body + +log "Starting all services via supervisord..." +exec /usr/bin/supervisord -c /app/docker/supervisord.conf diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..9e632f0b595bc9f5a632f6cdb8858cf7c7ff2c34 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,80 @@ +worker_processes 1; +error_log /tmp/nginx-error.log warn; +pid /tmp/nginx.pid; + +events { + worker_connections 512; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /tmp/nginx-access.log; + sendfile on; + keepalive_timeout 65; + + # Upstream services (all internal) + upstream frontend { + server 127.0.0.1:3000; + } + + upstream backend { + server 127.0.0.1:8000; + } + + upstream neo4j_browser { + server 127.0.0.1:7474; + } + + server { + listen 7860; + server_name _; + + client_max_body_size 20M; + + # ── FastAPI backend ──────────────────────────────────────────────── + # Routes: /api/*, /docs, /openapi.json, /health, /seed, /setup + location /api/ { + proxy_pass http://backend/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + location ~ ^/(docs|openapi\.json|redoc|health|seed|setup|ingest_patient|match_trials|enrich_graph|rag_query|setup_sample_data) { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # ── Neo4j Browser (admin only — /neo4j/) ────────────────────────── + location /neo4j/ { + proxy_pass http://neo4j_browser/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Next.js frontend (catch-all) ─────────────────────────────────── + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + } +} diff --git a/docker/supervisord.conf b/docker/supervisord.conf new file mode 100644 index 0000000000000000000000000000000000000000..4329458213ec3e793d4e2c3f606ac3841b027dde --- /dev/null +++ b/docker/supervisord.conf @@ -0,0 +1,72 @@ +[unix_http_server] +file=/tmp/supervisor.sock + +[supervisord] +nodaemon=true +logfile=/tmp/supervisord.log +pidfile=/tmp/supervisord.pid +loglevel=info + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///tmp/supervisor.sock + +# ── Neo4j Community ──────────────────────────────────────────────────────────── +[program:neo4j] +command=/opt/neo4j/bin/neo4j console +environment=NEO4J_HOME=/opt/neo4j,JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 +autostart=true +autorestart=true +startsecs=30 +startretries=3 +stdout_logfile=/tmp/neo4j.log +stderr_logfile=/tmp/neo4j.log +redirect_stderr=true +priority=10 + +# ── FastAPI backend ──────────────────────────────────────────────────────────── +[program:backend] +command=python3 -m uvicorn main:app --host 127.0.0.1 --port 8000 --workers 2 +directory=/app/backend +environment= + NEO4J_URI="bolt://127.0.0.1:7687", + NEO4J_USERNAME="%(ENV_NEO4J_USERNAME)s", + NEO4J_PASSWORD="%(ENV_NEO4J_PASSWORD)s", + NEO4J_DATABASE="%(ENV_NEO4J_DATABASE)s", + OPENAI_API_KEY="%(ENV_OPENAI_API_KEY)s", + OPENAI_BASE_URL="%(ENV_OPENAI_BASE_URL)s", + OPENAI_MODEL="%(ENV_OPENAI_MODEL)s" +autostart=true +autorestart=true +startsecs=10 +startretries=5 +stdout_logfile=/tmp/backend.log +stderr_logfile=/tmp/backend.log +redirect_stderr=true +priority=30 + +# ── Next.js frontend ─────────────────────────────────────────────────────────── +[program:frontend] +command=node server.js +directory=/app/frontend +environment=PORT="3000",HOSTNAME="127.0.0.1" +autostart=true +autorestart=true +startsecs=5 +stdout_logfile=/tmp/frontend.log +stderr_logfile=/tmp/frontend.log +redirect_stderr=true +priority=40 + +# ── Nginx reverse proxy ──────────────────────────────────────────────────────── +[program:nginx] +command=nginx -c /app/docker/nginx.conf -g "daemon off;" +autostart=true +autorestart=true +startsecs=3 +stdout_logfile=/tmp/nginx.log +stderr_logfile=/tmp/nginx.log +redirect_stderr=true +priority=50 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5ef6a520780202a1d6addd833d800ccb1ecac0bb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e215bc4ccf138bbc38ad58ad57e92135484b3c0f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..05e726d1b4201bc8c7716d2b058279676582e8c0 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..a60eb689ae01e0e5b6c1172f850086135ccc7b35 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,30 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + ...(process.env.NODE_ENV === "production" ? { output: "standalone" } : {}), + + experimental: { + // Tree-shake large icon/chart libs — only bundle exports that are used + optimizePackageImports: ["lucide-react", "recharts"], + }, + + webpack(config, { dev }) { + if (dev) { + // Persist compiled modules to disk so server restarts reuse the cache + config.cache = { + type: "filesystem", + allowCollectingMemory: true, + }; + } + return config; + }, +turbopack: {} // Add this line + +}; + +export default nextConfig; + + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..4e678a57c627f026e2d3e25c503bb78c1aab0203 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,7010 @@ +{ + "name": "frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.1.0", + "dependencies": { + "autoprefixer": "^10.5.0", + "clsx": "^2.1.1", + "geist": "^1.7.0", + "leaflet": "^1.9.4", + "lucide-react": "^0.511.0", + "next": "16.2.4", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-leaflet": "^5.0.0", + "recharts": "^2.15.0" + }, + "devDependencies": { + "@types/leaflet": "^1.9.19", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.4", + "tailwindcss": "^3.4.19", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "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-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.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.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/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz", + "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.40", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.40.tgz", + "integrity": "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "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/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz", + "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.2.4", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/geist": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/geist/-/geist-1.7.0.tgz", + "integrity": "sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==", + "license": "SIL OPEN FONT LICENSE", + "peerDependencies": { + "next": ">=13.2.0" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "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/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "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/lucide-react": { + "version": "0.511.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz", + "integrity": "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.4", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "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.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "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==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "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/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/tailwindcss/node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "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==", + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..ee633710d641cd6ed5b4c8da1c7b2321f0d18a47 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --webpack", + "prewarm": "node scripts/prewarm.mjs", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "autoprefixer": "^10.5.0", + "clsx": "^2.1.1", + "geist": "^1.7.0", + "leaflet": "^1.9.4", + "lucide-react": "^0.511.0", + "next": "16.2.4", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-leaflet": "^5.0.0", + "recharts": "^2.15.0" + }, + "devDependencies": { + "@types/leaflet": "^1.9.19", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.4", + "tailwindcss": "^3.4.19", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..a982c6414e3e58a4171f4698f72577613b605af2 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000000000000000000000000000000000000..004145cddf3f9db91b57b9cb596683c8eb420862 --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000000000000000000000000000000000000..567f17b0d7c7fb662c16d4357dd74830caf2dccb --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000000000000000000000000000000000000..5174b28c565c285e3e312ec5178be64fbeca8398 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000000000000000000000000000000000000..77053960334e2e34dc584dea8019925c3b4ccca9 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000000000000000000000000000000000000..b2b2a44f6ebc70c450043c05a002e7a93ba5d651 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/scripts/prewarm.mjs b/frontend/scripts/prewarm.mjs new file mode 100644 index 0000000000000000000000000000000000000000..3097df90287999ea54a9acabaced21293e30aba0 --- /dev/null +++ b/frontend/scripts/prewarm.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// Hits every route once so webpack compiles them before the user navigates. +// Run alongside the dev server: npm run prewarm + +const ROUTES = ["/", "/screening", "/recruitment", "/dashboard", "/map", "/graph"]; +const BASE = process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") ?? "http://localhost:3000"; + +async function waitForServer(url, retries = 30) { + for (let i = 0; i < retries; i++) { + try { + const r = await fetch(url, { signal: AbortSignal.timeout(3000) }); + if (r.ok || r.status < 500) return true; + } catch {} + await new Promise((r) => setTimeout(r, 2000)); + } + return false; +} + +const base = "http://localhost:3000"; +console.log("Waiting for dev server…"); +const up = await waitForServer(base); +if (!up) { console.error("Dev server never came up"); process.exit(1); } + +console.log("Pre-warming routes (this compiles each page bundle once):"); +for (const route of ROUTES) { + const start = Date.now(); + try { + await fetch(`${base}${route}`, { signal: AbortSignal.timeout(120_000) }); + console.log(` ✓ ${route} — ${Date.now() - start}ms`); + } catch (e) { + console.log(` ✗ ${route} — ${e.message}`); + } +} +console.log("All routes compiled. Navigation will now be instant."); diff --git a/frontend/src/app/consent/page.tsx b/frontend/src/app/consent/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a15f753f8ed6bc8196e4c94924c4e96b9cab39af --- /dev/null +++ b/frontend/src/app/consent/page.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { getConsents, getConsentStats, updateConsentStatus, getAppointments, confirmAppointment } from "@/lib/api"; +import { FileSignature, Calendar, CheckCircle, XCircle, Clock, Loader2, RefreshCw } from "lucide-react"; +import { clsx } from "clsx"; + +const STATUS_COLORS: Record = { + SENT: "bg-blue-100 text-blue-700", + SIGNED: "bg-emerald-100 text-emerald-700", + DECLINED: "bg-red-100 text-red-700", + EXPIRED: "bg-slate-100 text-slate-500", + PENDING: "bg-amber-100 text-amber-700", +}; + +const APPT_COLORS: Record = { + PROPOSED: "bg-amber-100 text-amber-700", + CONFIRMED: "bg-emerald-100 text-emerald-700", +}; + +function StatCard({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +export default function ConsentPage() { + const [consents, setConsents] = useState([]); + const [appointments, setAppointments] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState<"consents" | "appointments">("consents"); + const [expandedConsent, setExpandedConsent] = useState(null); + const [updating, setUpdating] = useState(null); + + const refresh = async () => { + setLoading(true); + try { + const [c, a, s] = await Promise.all([ + getConsents(), + getAppointments(), + getConsentStats(), + ]); + setConsents(c.consents); + setAppointments(a.appointments); + setStats(s); + } catch {} + setLoading(false); + }; + + useEffect(() => { refresh(); }, []); + + const handleConsentAction = async (consentId: string, status: string) => { + setUpdating(consentId); + try { + await updateConsentStatus(consentId, status); + await refresh(); + } catch {} + setUpdating(null); + }; + + const handleConfirmAppt = async (apptId: string) => { + setUpdating(apptId); + try { + await confirmAppointment(apptId); + await refresh(); + } catch {} + setUpdating(null); + }; + + return ( +
+
+
+

Consent & Scheduling

+

A2A-powered consent workflow and appointment management

+
+ +
+ + {/* Stats */} + {stats && ( +
+ + + + + +
+ )} + + {/* Tabs */} +
+ {(["consents", "appointments"] as const).map((t) => ( + + ))} +
+ + {loading ? ( +
+ Loading... +
+ ) : tab === "consents" ? ( +
+ {consents.length === 0 ? ( +
+ No consent records yet. Run the A2A Pipeline on the Screening page to generate consent requests automatically. +
+ ) : consents.map((c: any) => ( +
+
setExpandedConsent(expandedConsent === c.consent_id ? null : c.consent_id)} + > + +
+
{c.trial_title || c.nct_id}
+
+ Patient: {c.patient_id} · NCT: {c.nct_id} · Score: {Math.round((c.match_score || 0) * 100)}% +
+
+ + {c.status} + + {c.status === "SENT" && ( +
+ + +
+ )} +
+ + {expandedConsent === c.consent_id && c.consent_document && ( +
+
+ {c.consent_document} +
+
+ Created: {new Date(c.created_at).toLocaleDateString()} + Expires: {new Date(c.expires_at).toLocaleDateString()} + {c.signed_at && Signed: {new Date(c.signed_at).toLocaleDateString()}} +
+
+ )} +
+ ))} +
+ ) : ( +
+ {appointments.length === 0 ? ( +
+ No appointments scheduled. Appointments are automatically created when a consent is signed. +
+ ) : appointments.map((a: any) => ( +
+ +
+
{a.nct_id}
+
+ Patient: {a.patient_id} + {a.site_city && ` · Site: ${a.site_city}${a.site_state ? ", " + a.site_state : ""}`} +
+
+ + {new Date(a.proposed_datetime).toLocaleString()} +
+
+ + {a.status} + + {a.status === "PROPOSED" && ( + + )} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..84abff3148dc6d270f4b9fd163e209659ac0688c --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getKPIs, getEnrollmentFunnel, getSitePerformance, getDemographics, getTimeline } from "@/lib/api"; +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, + LineChart, Line, CartesianGrid, Legend, +} from "recharts"; +import { TrendingUp, Users, FlaskConical, Clock, DollarSign, Loader2 } from "lucide-react"; + +function KPICard({ label, value, sub, icon: Icon, color }: { label: string; value: string; sub?: string; icon: any; color: string }) { + return ( +
+
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+
+ +
+
+
+ ); +} + +export default function DashboardPage() { + const [kpis, setKpis] = useState(null); + const [funnel, setFunnel] = useState([]); + const [sites, setSites] = useState([]); + const [demographics, setDemographics] = useState(null); + const [timeline, setTimeline] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([ + getKPIs(), + getEnrollmentFunnel(), + getSitePerformance(), + getDemographics(), + getTimeline(30), + ]).then(([k, f, s, d, t]) => { + setKpis(k); + setFunnel(f.funnel); + setSites(s.sites); + setDemographics(d); + setTimeline(t.timeline.filter((_: any, i: number) => i % 3 === 0)); // Sample every 3 days + }).finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Analytics Dashboard

+

Real-time recruitment metrics and trial performance analytics

+
+ + {/* KPI cards */} + {kpis && ( +
+ + + + +
+ )} + +
+ {/* Enrollment funnel */} +
+

Enrollment Funnel

+ + + + + + + {funnel.map((entry, i) => )} + + + +
+ + {/* Gender pie */} + {demographics?.gender_distribution && ( +
+

Patient Demographics — Gender

+
+ + + + {demographics.gender_distribution.map((entry: any, i: number) => )} + + [`${v}%`]} /> + + +
+ {demographics.gender_distribution.map((d: any, i: number) => ( +
+ + {d.name} + {d.value}% +
+ ))} +
+
+
+ )} +
+ + {/* Enrollment timeline */} + {timeline.length > 0 && ( +
+

Enrollment Progress (30 days)

+ + + + + + + + + + + +
+ )} + + {/* Site performance table */} + {sites.length > 0 && ( +
+

Site Performance

+
+ + + + + + + + + + + + + {sites.slice(0, 6).map((site: any, i: number) => ( + + + + + + + + + ))} + +
SiteCityTrialsEnrolledCapacityFill Rate
{site.name}{site.city}, {site.state}{site.trials}{site.enrolled}{site.capacity} +
+
+
+
+ {site.fill_percentage}% +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c Binary files /dev/null and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..88a3ed052fc2d9419efd66735950847b1f517368 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,15 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #f8fafc; + --foreground: #0f172a; +} + +body { + background: var(--background); + color: var(--foreground); +} + +html, body { height: 100%; } diff --git a/frontend/src/app/graph/page.tsx b/frontend/src/app/graph/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cf54b59117e2efcdb4d8434dc16d4fc07f3ebe30 --- /dev/null +++ b/frontend/src/app/graph/page.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState } from "react"; +import { graphQuery, getGraphStats } from "@/lib/api"; +import { MessageSquare, Loader2, Database } from "lucide-react"; +import { useEffect } from "react"; + +const SAMPLE_QUESTIONS = [ + "Which patients are eligible for breast cancer trials?", + "What trials are in Phase II?", + "List all patients with HER2 positive biomarker", + "How many active trials are there for prostate cancer?", + "Which study sites have the most active trials?", +]; + +export default function GraphPage() { + const [question, setQuestion] = useState(""); + const [response, setResponse] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [stats, setStats] = useState(null); + + useEffect(() => { + getGraphStats().then(setStats).catch(() => {}); + }, []); + + const handleQuery = async (q = question) => { + if (!q.trim()) return; + setLoading(true); + setError(""); + setResponse(""); + setQuestion(q); + try { + const data = await graphQuery(q); + setResponse(data.response); + } catch (e: any) { + setError(e.message); + } + setLoading(false); + }; + + return ( +
+
+

Graph RAG

+

Ask natural language questions about the clinical trial knowledge graph

+
+ + {stats && ( +
+ {Object.entries(stats).map(([k, v]: any) => ( +
+ + {v} + {k} +
+ ))} +
+ )} + +
+

Sample Questions

+
+ {SAMPLE_QUESTIONS.map((q) => ( + + ))} +
+
+ +
+ setQuestion(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleQuery()} + placeholder="Ask anything about patients, trials, or biomarkers..." + className="flex-1 border border-slate-200 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white" + /> + +
+ + {error && ( +
{error}
+ )} + + {response && ( +
+
+ + Response +
+

{response}

+
+ )} +
+ ); +} diff --git a/frontend/src/app/intake/page.tsx b/frontend/src/app/intake/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f945ab735c83f409296736f1db83aee1da7ebd82 --- /dev/null +++ b/frontend/src/app/intake/page.tsx @@ -0,0 +1,471 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { submitIntake, getBiomarkerRegistry, IntakeLabs } from "@/lib/api"; +import { FlaskConical, CheckCircle, XCircle, AlertCircle, Loader2, ChevronRight, Brain, ExternalLink, Save } from "lucide-react"; +import { clsx } from "clsx"; + +const CONDITIONS = [ + "Breast Cancer", "Prostate Cancer", "Non-Small Cell Lung Cancer", + "Colorectal Cancer", "Ovarian Cancer", "Melanoma", "Leukemia", + "Lymphoma", "Glioblastoma", "Pancreatic Cancer", +]; +const STAGES = ["I", "II", "III", "IV", "Unknown"]; +const MEDICATIONS = [ + "Trastuzumab", "Pembrolizumab", "Nivolumab", "Osimertinib", + "Olaparib", "Enzalutamide", "Bevacizumab", "Rituximab", + "Tamoxifen", "Capecitabine", "Erlotinib", "Cetuximab", +]; + +// Biomarker groupings for display +const BM_GROUPS: Record = { + "Breast / Gynecologic": ["HER2_POS", "HER2_NEG", "ER_POS", "PR_POS", "BRCA1_MUT", "BRCA2_MUT", "TRIPLE_NEG"], + "Lung (NSCLC)": ["EGFR_MUT", "ALK_POS", "ROS1_POS", "PD_L1_POS"], + "GI / Colorectal": ["MSI_H", "KRAS_WT", "BRAF_MUT"], + "Hematology": ["FLT3_MUT", "IDH1_MUT", "IDH2_MUT", "BCR_ABL"], + "Other": ["NRAS_MUT"], +}; + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Field({ label, unit, children }: { label: string; unit?: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function NumInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + ); +} + +function ScoreBar({ score }: { score: number }) { + const pct = Math.round(score * 100); + const color = pct >= 75 ? "bg-emerald-500" : pct >= 55 ? "bg-amber-500" : "bg-red-400"; + return ( +
+
+
+
+ = 75 ? "text-emerald-600" : pct >= 55 ? "text-amber-600" : "text-red-500")}> + {pct}% + +
+ ); +} + +function CriterionRow({ criterion, met, patient_value, note }: any) { + return ( +
+ {met === true ? + : met === false ? + : } +
+ {criterion} + {patient_value && · {patient_value}} + {note &&
{note}
} +
+
+ ); +} + +export default function IntakePage() { + // Demographics + const [condition, setCondition] = useState(""); + const [age, setAge] = useState(""); + const [sex, setSex] = useState(""); + const [ecog, setEcog] = useState(""); + const [stage, setStage] = useState(""); + + // Biomarkers + const [biomarkerRegistry, setBiomarkerRegistry] = useState<{ id: string; label: string }[]>([]); + const [selectedBiomarkers, setSelectedBiomarkers] = useState>(new Set()); + + // Labs (SI units) + const [labs, setLabs] = useState>({}); + + // Treatment history + const [priorChemo, setPriorChemo] = useState(false); + const [priorRadiation, setPriorRadiation] = useState(false); + const [priorSurgery, setPriorSurgery] = useState(false); + const [selectedMeds, setSelectedMeds] = useState>(new Set()); + + // Results + const [loading, setLoading] = useState(false); + const [matches, setMatches] = useState([]); + const [searched, setSearched] = useState(false); + const [error, setError] = useState(""); + const [expanded, setExpanded] = useState(null); + const [saveToGraph, setSaveToGraph] = useState(false); + const [savedId, setSavedId] = useState(null); + + useEffect(() => { + getBiomarkerRegistry().then((d) => setBiomarkerRegistry(d.biomarkers)).catch(() => {}); + }, []); + + const toggleBiomarker = (id: string) => { + setSelectedBiomarkers((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + const toggleMed = (m: string) => { + setSelectedMeds((prev) => { + const next = new Set(prev); + next.has(m) ? next.delete(m) : next.add(m); + return next; + }); + }; + + const setLab = (key: string, val: string) => setLabs((prev) => ({ ...prev, [key]: val })); + const labFloat = (key: string): number | undefined => { + const v = labs[key]; + return v !== "" && v !== undefined ? parseFloat(v) : undefined; + }; + + const handleSubmit = async () => { + if (!condition.trim()) { setError("Diagnosis / condition is required"); return; } + setLoading(true); + setError(""); + setMatches([]); + setSearched(true); + setSavedId(null); + + const labPayload: IntakeLabs = { + hemoglobin: labFloat("hemoglobin"), + wbc: labFloat("wbc"), + anc: labFloat("anc"), + platelets: labFloat("platelets"), + creatinine: labFloat("creatinine"), + egfr: labFloat("egfr"), + bilirubin: labFloat("bilirubin"), + alt: labFloat("alt"), + ast: labFloat("ast"), + albumin: labFloat("albumin"), + }; + const hasLabs = Object.values(labPayload).some((v) => v !== undefined); + + try { + const result = await submitIntake({ + condition, + age: age ? parseInt(age) : undefined, + sex: sex || undefined, + ecog: ecog !== "" ? parseInt(ecog) : undefined, + stage: stage || undefined, + biomarkers: [...selectedBiomarkers], + labs: hasLabs ? labPayload : undefined, + prior_chemo: priorChemo, + prior_radiation: priorRadiation, + prior_surgery: priorSurgery, + medications: [...selectedMeds], + save_to_graph: saveToGraph, + }); + setMatches(result.matches); + if (result.patient_id) setSavedId(result.patient_id); + } catch (e: any) { + setError(e.message); + } + setLoading(false); + }; + + const bmlookup = Object.fromEntries(biomarkerRegistry.map((b) => [b.id, b.label])); + + return ( +
+
+

Clinical Trial Eligibility Check

+

Enter your clinical data to find matching recruiting trials — no account or patient ID required.

+
+ +
+ + {/* ── Left column: form ─────────────────────────────────────────── */} +
+ +
+
+ + setCondition(e.target.value)} + placeholder="e.g. Breast Cancer, NSCLC…" + className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + + {CONDITIONS.map((c) => + +
+ + + + + + + + + + + + +
+
+
+ +
+
+ {Object.entries(BM_GROUPS).map(([group, ids]) => { + const registryIds = ids.filter((id) => bmlookup[id] || biomarkerRegistry.length === 0); + if (registryIds.length === 0) return null; + return ( +
+
{group}
+
+ {registryIds.map((id) => ( + + ))} +
+
+ ); + })} +
+
+ +
+
+
Haematology
+
+ + setLab("hemoglobin", v)} placeholder="e.g. 12.5" /> + + + setLab("wbc", v)} placeholder="e.g. 6.2" /> + + + setLab("anc", v)} placeholder="e.g. 1.5" /> + + + setLab("platelets", v)} placeholder="e.g. 150" /> + +
+ +
Renal
+
+ + setLab("creatinine", v)} placeholder="e.g. 88" /> + + + setLab("egfr", v)} placeholder="e.g. 75" /> + +
+ +
Hepatic
+
+ + setLab("bilirubin", v)} placeholder="e.g. 12" /> + + + setLab("alt", v)} placeholder="e.g. 28" /> + + + setLab("ast", v)} placeholder="e.g. 30" /> + +
+
+
+ +
+
+ {[ + { label: "Prior Chemotherapy", val: priorChemo, set: setPriorChemo }, + { label: "Prior Radiation", val: priorRadiation, set: setPriorRadiation }, + { label: "Prior Surgery", val: priorSurgery, set: setPriorSurgery }, + ].map(({ label, val, set }) => ( + + ))} +
+
Current Medications
+
+ {MEDICATIONS.map((m) => ( + + ))} +
+
+ +
+ + +
+ {error &&
{error}
} + {savedId && ( +
+ + Profile saved as {savedId} +
+ )} +
+ + {/* ── Right column: results ─────────────────────────────────────── */} +
+ {!searched && ( +
+ +

Complete the form and click
Find Matching Trials

+

Your data is matched against {" "}recruiting trials in the graph using age, sex, biomarkers, ECOG, and lab thresholds.

+
+ )} + + {searched && !loading && matches.length === 0 && ( +
+ +

No trials found in the graph for {condition}.

+

Try searching for this condition in the Trial Finder first to enrich the graph.

+
+ )} + + {matches.length > 0 && ( +
+

{matches.length} trials ranked by eligibility match

+ {matches.map((m, idx) => { + const pct = Math.round(m.score * 100); + const isExpanded = expanded === m.nct_id; + return ( +
= 55 ? "border-amber-200" : "border-slate-200" + )}> + + + {isExpanded && ( +
+ {m.risk_flags?.length > 0 && ( +
+ {m.risk_flags.map((f: string, i: number) => ( + {f} + ))} +
+ )} + {m.criteria_breakdown?.length > 0 && ( +
+ {m.criteria_breakdown.map((c: any, i: number) => ( + + ))} +
+ )} + {m.ctgov_url && ( + + View on ClinicalTrials.gov + + )} +
+ )} +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2a671683e4ebcb10cd9f129ac2501505e29b6293 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import Sidebar from "@/components/Sidebar"; + +export const metadata: Metadata = { + title: "ClinicalMatch AI — Precision Trial Recruitment", + description: "A2A-powered clinical trial matching using FHIR R4 and AI", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
+ {children} +
+ + + ); +} diff --git a/frontend/src/app/map/page.tsx b/frontend/src/app/map/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e6784afba8391c247c7af5d4a65f7d540f666e3 --- /dev/null +++ b/frontend/src/app/map/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getMapData } from "@/lib/api"; +import { Loader2, MapPin, Users } from "lucide-react"; +import dynamic from "next/dynamic"; + +// Leaflet must be dynamically imported (no SSR) +const MapComponent = dynamic(() => import("@/components/MapComponent"), { + ssr: false, + loading: () => ( +
+ +
+ ), +}); + +export default function MapPage() { + const [mapData, setMapData] = useState<{ sites: any[]; patient_clusters: any[] } | null>(null); + const [loading, setLoading] = useState(true); + const [selectedSite, setSelectedSite] = useState(null); + + useEffect(() => { + getMapData().then((d) => { setMapData(d); setLoading(false); }).catch(() => setLoading(false)); + }, []); + + return ( +
+
+

Study Site Map

+

Interactive map of clinical trial sites and patient density clusters

+
+ +
+
+ {loading ? ( +
+ +
+ ) : mapData ? ( + + ) : ( +
+ Failed to load map data +
+ )} +
+ +
+ {selectedSite && ( +
+

{selectedSite.name}

+

{selectedSite.city}, {selectedSite.state}

+
+
Active Trials{selectedSite.trials}
+
Enrolled{selectedSite.enrolled}
+
Capacity{selectedSite.capacity}
+
+
Fill Rate{Math.round(selectedSite.enrolled / selectedSite.capacity * 100)}%
+
+
+
+
+
+
+ )} + +
+

Legend

+
+
+ + Study sites (click for details) +
+
+ + Patient density clusters +
+
+
+ + {mapData?.sites && ( +
+

All Sites

+
+ {mapData.sites.map((site, i) => ( + + ))} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..84786f078db2ad658638cc649fe80387e88ccc88 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState } from "react"; +import { searchTrials, getTrialIntelligence } from "@/lib/api"; +import { Search, MapPin, Calendar, Users, ChevronDown, ChevronUp, FlaskConical, Brain, ExternalLink, Clock, CheckCircle } from "lucide-react"; +import { clsx } from "clsx"; + +const PHASES = ["", "1", "2", "3", "4"]; +const PHASE_LABELS: Record = { "": "All Phases", "1": "Phase I", "2": "Phase II", "3": "Phase III", "4": "Phase IV" }; + +const PHASE_COLORS: Record = { + PHASE1: "bg-blue-100 text-blue-700", + PHASE2: "bg-violet-100 text-violet-700", + PHASE3: "bg-emerald-100 text-emerald-700", + PHASE4: "bg-amber-100 text-amber-700", + "N/A": "bg-slate-100 text-slate-600", +}; + +function daysAgo(dateStr: string): string { + if (!dateStr) return ""; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return ""; + const days = Math.floor((Date.now() - d.getTime()) / 86400000); + if (days === 0) return "Updated today"; + if (days === 1) return "Updated yesterday"; + if (days < 30) return `Updated ${days}d ago`; + if (days < 365) return `Updated ${Math.floor(days / 30)}mo ago`; + return `Updated ${Math.floor(days / 365)}y ago`; +} + +export default function TrialFinder() { + const [condition, setCondition] = useState(""); + const [phase, setPhase] = useState(""); + const [trials, setTrials] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [expanded, setExpanded] = useState(null); + const [searched, setSearched] = useState(false); + const [intelligence, setIntelligence] = useState>({}); + const [loadingIntel, setLoadingIntel] = useState(null); + + const handleSearch = async () => { + if (!condition.trim()) return; + setLoading(true); + setError(""); + setSearched(true); + setExpanded(null); + setIntelligence({}); + try { + const data = await searchTrials(condition, phase || undefined, 20); + setTrials(data.trials); + } catch (e: any) { + setError(e.message); + setTrials([]); + } + setLoading(false); + }; + + const handleExpand = async (nctId: string) => { + const next = expanded === nctId ? null : nctId; + setExpanded(next); + if (next && !intelligence[next]) { + setLoadingIntel(next); + try { + const intel = await getTrialIntelligence(next); + setIntelligence((prev) => ({ ...prev, [next]: intel })); + } catch {} + setLoadingIntel(null); + } + }; + + const handleKey = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSearch(); + }; + + return ( +
+
+

Clinical Trial Finder

+

Search ClinicalTrials.gov for recruiting studies — powered by real-time data

+
+ + {/* Search bar */} +
+
+ + setCondition(e.target.value)} + onKeyDown={handleKey} + placeholder="e.g. breast cancer, NSCLC, Alzheimer's disease..." + className="w-full pl-9 pr-4 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white" + /> +
+ + +
+ + {error && ( +
{error}
+ )} + + {searched && !loading && trials.length === 0 && !error && ( +
+ +

No trials found. Try a different condition or phase.

+
+ )} + + {trials.length > 0 && ( +
+
+

{trials.length} trials found for {condition}

+
+ + Sorted by most recently updated +
+
+ {trials.map((trial) => { + const isExpanded = expanded === trial.nct_id; + const phaseKey = trial.phase?.replace("PHASE", "Phase ") || "N/A"; + const recency = daysAgo(trial.last_updated); + const intel = intelligence[trial.nct_id]; + const isLoadingIntel = loadingIntel === trial.nct_id; + return ( +
+ + + {isExpanded && ( +
+ {trial.brief_summary && ( +

{trial.brief_summary.slice(0, 500)}{trial.brief_summary.length > 500 ? "…" : ""}

+ )} + +
+
Age Range
{trial.min_age || "18 years"} – {trial.max_age || "No max"}
+
Sex
{trial.sex?.toLowerCase() || "All"}
+
Start Date
{trial.start_date || "N/A"}
+
Completion
{trial.completion_date || "N/A"}
+
+ + {trial.locations?.length > 0 && ( +
+
Sites
+
+ {trial.locations.slice(0, 4).map((loc: any, i: number) => ( + + {loc.facility || `${loc.city}, ${loc.state}`} + + ))} + {trial.location_count > 4 && +{trial.location_count - 4} more} +
+
+ )} + + {trial.primary_outcomes?.length > 0 && ( +
+
Primary Outcomes
+
    + {trial.primary_outcomes.map((o: string, i: number) =>
  • · {o}
  • )} +
+
+ )} + + {/* Graph intelligence panel */} +
+
+ Graph Intelligence + {trial.eligible_patients_in_graph === 0 && !intel && ( + Enriching graph… + )} +
+ {isLoadingIntel ? ( +

Loading graph data…

+ ) : intel ? ( +
+
+ + {intel.eligible_patients} patients in graph eligible for this trial +
+ {intel.top_biomarkers?.length > 0 && ( +
+
Top biomarkers among eligible patients:
+
+ {intel.top_biomarkers.map((b: any, i: number) => ( + {b.biomarker} ({b.patient_count}) + ))} +
+
+ )} + {intel.similar_trials?.length > 0 && ( +
+
Similar trials (by shared eligible patients):
+
+ {intel.similar_trials.map((t: any, i: number) => ( +
+ {t.nct_id} + {t.title?.slice(0, 50)}… + {t.shared_patients} shared +
+ ))} +
+
+ )} +
+ ) : ( +

Trial not yet in graph — being ingested now.

+ )} +
+ +
+ + + Ingested to graph + + {trial.ctgov_url && ( + + View on ClinicalTrials.gov + + )} +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/app/recruitment/page.tsx b/frontend/src/app/recruitment/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..316e53d900c10980d4f8e6af463fce831c1c9c25 --- /dev/null +++ b/frontend/src/app/recruitment/page.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { getKanbanBoard, updateRecordStatus, generateOutreach } from "@/lib/api"; +import { Send, Loader2, Copy, Check, Mail, FileText, Share2 } from "lucide-react"; +import { clsx } from "clsx"; + +const STATUSES = ["IDENTIFIED", "CONTACTED", "SCREENING", "CONSENTED", "ENROLLED", "DECLINED", "INELIGIBLE"]; +const STATUS_COLORS: Record = { + IDENTIFIED: "bg-slate-100 text-slate-700", + CONTACTED: "bg-blue-100 text-blue-700", + SCREENING: "bg-violet-100 text-violet-700", + CONSENTED: "bg-amber-100 text-amber-700", + ENROLLED: "bg-emerald-100 text-emerald-700", + DECLINED: "bg-red-100 text-red-700", + INELIGIBLE: "bg-orange-100 text-orange-700", +}; +const KANBAN_COLS = ["IDENTIFIED", "CONTACTED", "SCREENING", "CONSENTED", "ENROLLED"]; + +const CHANNELS = [ + { id: "patient_email", label: "Patient Email", icon: Mail }, + { id: "pcp_letter", label: "PCP Letter", icon: FileText }, + { id: "social_post", label: "Social Post", icon: Share2 }, +]; + +function ScoreBadge({ score }: { score: number }) { + const pct = Math.round(score * 100); + return ( + = 80 ? "bg-emerald-100 text-emerald-700" : + pct >= 60 ? "bg-amber-100 text-amber-700" : + "bg-red-100 text-red-700" + )}>{pct}% + ); +} + +export default function RecruitmentPage() { + const [board, setBoard] = useState>({}); + const [loadingBoard, setLoadingBoard] = useState(true); + const [selected, setSelected] = useState(null); + const [channel, setChannel] = useState("patient_email"); + const [generatingOutreach, setGeneratingOutreach] = useState(false); + const [outreachMessage, setOutreachMessage] = useState(""); + const [copied, setCopied] = useState(false); + const [updatingStatus, setUpdatingStatus] = useState(false); + + const loadBoard = async () => { + try { + setLoadingBoard(true); + const data = await getKanbanBoard(); + setBoard(data); + } catch (e) { + console.error(e); + } + setLoadingBoard(false); + }; + + useEffect(() => { loadBoard(); }, []); + + const handleStatusChange = async (recordId: string, newStatus: string) => { + setUpdatingStatus(true); + try { + await updateRecordStatus(recordId, newStatus); + await loadBoard(); + if (selected?.record_id === recordId) { + setSelected((prev: any) => ({ ...prev, status: newStatus })); + } + } catch (e) { + console.error(e); + } + setUpdatingStatus(false); + }; + + const handleGenerateOutreach = async () => { + if (!selected) return; + setGeneratingOutreach(true); + setOutreachMessage(""); + try { + const result = await generateOutreach({ + patient_id: selected.patient_id, + nct_id: selected.nct_id, + trial_title: selected.trial_title, + channel, + }); + setOutreachMessage(result.outreach?.message || ""); + } catch (e: any) { + setOutreachMessage(`Error: ${e.message}`); + } + setGeneratingOutreach(false); + }; + + const handleCopy = () => { + navigator.clipboard.writeText(outreachMessage); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (loadingBoard) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Recruitment Hub

+

Manage the patient recruitment pipeline and generate personalized outreach

+
+ +
+ {/* Kanban board */} +
+
+ {KANBAN_COLS.map((col) => { + const records = board[col] || []; + return ( +
+
+ {col} + {records.length} +
+
+ {records.map((record) => ( + + ))} + {records.length === 0 && ( +
Empty
+ )} +
+
+ ); + })} +
+
+ + {/* Detail panel */} + {selected && ( +
+
+
+ {selected.patient_id} + +
+

{selected.trial_title}

+

{selected.nct_id}

+
+ +
+ +
+ {STATUSES.map((s) => ( + + ))} +
+
+ +
+ +
+ {CHANNELS.map(({ id, label, icon: Icon }) => ( + + ))} +
+ + + {outreachMessage && ( +
+
+ Generated Message + +
+
+ {outreachMessage} +
+
+ )} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/screening/page.tsx b/frontend/src/app/screening/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..302c821a205831487122fa9616f8c558bf40cb63 --- /dev/null +++ b/frontend/src/app/screening/page.tsx @@ -0,0 +1,358 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { startWorkflow, streamWorkflow, screenPatient, getGraphPatients } from "@/lib/api"; +import { ClipboardCheck, CheckCircle, XCircle, AlertCircle, Loader2, ChevronRight, GitBranch } from "lucide-react"; +import { clsx } from "clsx"; + +const FALLBACK_PATIENTS = ["P001", "P002", "P003", "P004", "P005"]; +const QUICK_TRIALS = [ + { id: "NCT04889131", label: "NCT04889131 – HER2+ Breast Cancer" }, + { id: "NCT05123456", label: "NCT05123456 – Immunotherapy Combo" }, + { id: "NCT05456789", label: "NCT05456789 – BRCA2 Prostate" }, + { id: "NCT06112233", label: "NCT06112233 – EGFR NSCLC" }, + { id: "NCT05334455", label: "NCT05334455 – MSI-H Colorectal" }, +]; + +const WORKFLOW_STATES = [ + "PENDING", "INGESTING", "PARSING_PROTOCOL", "MATCHING", "SCORING", "RECRUITING", "COMPLETED", +]; + +function StateTracker({ events }: { events: any[] }) { + const stateSet = new Set(events.map((e: any) => e.state)); + return ( +
+ {WORKFLOW_STATES.map((state, i) => { + const done = stateSet.has(state); + const isCurrent = events[events.length - 1]?.state === state; + return ( +
+ + {state} + + {i < WORKFLOW_STATES.length - 1 && } +
+ ); + })} +
+ ); +} + +function ScoreBar({ score }: { score: number }) { + const pct = Math.round(score * 100); + const color = pct >= 80 ? "bg-emerald-500" : pct >= 60 ? "bg-amber-500" : "bg-red-500"; + return ( +
+
+
+
+ = 80 ? "text-emerald-600" : pct >= 60 ? "text-amber-600" : "text-red-600")}> + {pct}% + +
+ ); +} + +function CriterionRow({ criterion, met, triggered, confidence, note }: any) { + const pass = met === true || triggered === false; + const fail = met === false || triggered === true; + return ( +
+ {pass ? + : fail ? + : } +
+
{criterion}
+ {note &&
{note}
} +
+ {confidence && ( + {confidence} + )} +
+ ); +} + +function MatchPath({ path }: { path: any[] }) { + if (!path?.length) return null; + return ( +
+
+ + Graph Match Path +
+
+ {path.map((node: any, i: number) => ( + + + {node.from?.split(":")[1] ?? node.from} + + —[{node.rel}]→ + + {node.to?.split(":")[1] ?? node.to} + + {node.note && ({node.note})} + + ))} +
+
+ ); +} + +export default function ScreeningPage() { + const [patientId, setPatientId] = useState("P001"); + const [nctId, setNctId] = useState(QUICK_TRIALS[0].id); + const [customNct, setCustomNct] = useState(""); + const [useCustom, setUseCustom] = useState(false); + const [loading, setLoading] = useState(false); + const [mode, setMode] = useState<"screen" | "workflow">("screen"); + const [result, setResult] = useState(null); + const [workflowResult, setWorkflowResult] = useState(null); + const [streamEvents, setStreamEvents] = useState([]); + const [streamDone, setStreamDone] = useState(false); + const [error, setError] = useState(""); + const [graphPatients, setGraphPatients] = useState([]); + const cleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => { + getGraphPatients(undefined, 500).then((d) => setGraphPatients(d.patients)).catch(() => {}); + return () => { cleanupRef.current?.(); }; + }, []); + + const effectiveNct = useCustom ? customNct : nctId; + + const handleScreen = async () => { + if (!patientId.trim()) { setError("Patient ID is required"); return; } + if (!effectiveNct.trim()) { setError("NCT ID is required"); return; } + setLoading(true); + setError(""); + setResult(null); + try { + const data = await screenPatient(patientId.trim(), effectiveNct.trim()); + setResult(data); + } catch (e: any) { + setError(e.message); + } + setLoading(false); + }; + + const handleWorkflow = async () => { + if (!patientId.trim()) { setError("Patient ID is required"); return; } + cleanupRef.current?.(); + setLoading(true); + setError(""); + setWorkflowResult(null); + setStreamEvents([]); + setStreamDone(false); + try { + const { workflow_id } = await startWorkflow(patientId.trim()); + const stop = streamWorkflow( + workflow_id, + (evt) => setStreamEvents((prev) => [...prev, evt]), + () => { setStreamDone(true); setLoading(false); }, + ); + cleanupRef.current = stop; + } catch (e: any) { + setError(e.message); + setLoading(false); + } + }; + + return ( +
+
+

Patient Screening

+

AI-powered eligibility assessment using FHIR R4 patient data and A2A orchestration

+
+ + {/* Mode tabs */} +
+ {(["screen", "workflow"] as const).map((m) => ( + + ))} +
+ +
+
+
+ + setPatientId(e.target.value)} + placeholder="e.g. P_C50_0001 or P001" + className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + + {(graphPatients.length > 0 ? graphPatients : FALLBACK_PATIENTS.map((id) => ({ id }))).map((p: any) => ( + + ))} + +
+ {mode === "screen" && ( +
+ + { + const val = e.target.value; + const quick = QUICK_TRIALS.find((t) => t.id === val); + if (quick) { setUseCustom(false); setNctId(val); } + else { setUseCustom(true); setCustomNct(val); } + }} + placeholder="e.g. NCT04889131" + className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + + {QUICK_TRIALS.map((t) => )} + +
+ )} +
+ + +
+ + {error && ( +
{error}
+ )} + + {/* Single screen result */} + {result && ( +
+
+
+

Eligibility Score

+ + {result.eligible ? "ELIGIBLE" : "NOT ELIGIBLE"} + +
+ + {result.summary && ( +

{result.summary}

+ )} + {result.risk_flags?.length > 0 && ( +
+ {result.risk_flags.map((f: string, i: number) => ( + {f} + ))} +
+ )} +
+ + {result.inclusion_results?.length > 0 && ( +
+

Inclusion Criteria

+
+ {result.inclusion_results.map((c: any, i: number) => )} +
+
+ )} + + {result.exclusion_results?.length > 0 && ( +
+

Exclusion Criteria

+
+ {result.exclusion_results.map((c: any, i: number) => )} +
+
+ )} + + {result.match_path?.length > 0 && ( +
+

Graph Explainability

+ +
+ )} +
+ )} + + {/* Streaming A2A workflow */} + {(streamEvents.length > 0 || loading) && mode === "workflow" && ( +
+
+
+

A2A Pipeline — Live

+ {!streamDone && } + {streamDone && Complete} +
+ +
+ {streamEvents.map((evt: any, i: number) => ( +
+ {evt.state} + {evt.message} + {evt.data?.eligible_count !== undefined && ( + + {evt.data.eligible_count} eligible + + )} +
+ ))} +
+
+ + {/* Final summary when complete */} + {streamDone && (() => { + const final = streamEvents[streamEvents.length - 1]; + if (!final || !["COMPLETED", "FAILED"].includes(final.state)) return null; + return ( +
+

+ {final.state === "COMPLETED" ? "Pipeline Complete" : "Pipeline Failed"} +

+ {final.state === "COMPLETED" && ( +
+
+
{final.eligible_trials ?? 0}
+
Eligible Trials
+
+
+
{final.total_evaluated ?? 0}
+
Evaluated
+
+
+
{final.recruitment_records ?? 0}
+
Outreach Generated
+
+
+ )} + {final.error && ( +

{final.error}

+ )} +
+ ); + })()} +
+ )} +
+ ); +} diff --git a/frontend/src/components/MapComponent.tsx b/frontend/src/components/MapComponent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..50d1bab84281950e016f17c50dfd83f214bd5884 --- /dev/null +++ b/frontend/src/components/MapComponent.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; + +interface Props { + sites: any[]; + clusters: any[]; + onSiteClick: (site: any) => void; +} + +export default function MapComponent({ sites, clusters, onSiteClick }: Props) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const onSiteClickRef = useRef(onSiteClick); + onSiteClickRef.current = onSiteClick; + + useEffect(() => { + if (!containerRef.current) return; + + // Destroy any pre-existing Leaflet instance on this element + if ((containerRef.current as any)._leaflet_id) { + (containerRef.current as any)._leaflet_id = null; + } + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } + + const map = L.map(containerRef.current, { center: [39.5, -98.35], zoom: 4 }); + mapRef.current = map; + + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: '© OpenStreetMap', + }).addTo(map); + + const siteIcon = L.divIcon({ + className: "", + html: `
`, + iconSize: [28, 28], + iconAnchor: [14, 28], + popupAnchor: [0, -30], + }); + + const validSites = sites.filter((s) => s.lat && s.lon); + const validClusters = clusters.filter((c) => c.lat && c.lon); + + validClusters.forEach((cluster) => { + L.circle([cluster.lat, cluster.lon], { + radius: cluster.count * 800, + color: "#6366f1", + fillColor: "#818cf8", + fillOpacity: 0.25, + weight: 1, + }) + .bindPopup(`
${cluster.city}
${cluster.count} potential patients
`) + .addTo(map); + }); + + validSites.forEach((site) => { + L.marker([site.lat, site.lon], { icon: siteIcon }) + .bindPopup( + `
${site.name}
` + + `
${site.city}, ${site.state}
` + + `
${site.trials} active trials · ${site.enrolled}/${site.capacity} enrolled
` + ) + .on("click", () => onSiteClickRef.current(site)) + .addTo(map); + }); + + if (validSites.length > 0) { + const bounds = L.latLngBounds(validSites.map((s) => [s.lat, s.lon])); + map.fitBounds(bounds, { padding: [40, 40] }); + } + + return () => { + map.remove(); + mapRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return
; +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..af96246ef9de6c25d693d8dfe5fa7e25f660b327 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,80 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { clsx } from "clsx"; +import { + Search, + ClipboardCheck, + Users, + BarChart2, + Map, + Dna, + MessageSquare, + FlaskConical, + FileSignature, +} from "lucide-react"; + +const nav = [ + { href: "/", label: "Trial Finder", icon: Search }, + { href: "/intake", label: "Eligibility Check", icon: FlaskConical }, + { href: "/screening", label: "Patient Screening", icon: ClipboardCheck }, + { href: "/recruitment", label: "Recruitment Hub", icon: Users }, + { href: "/consent", label: "Consent & Schedule", icon: FileSignature }, + { href: "/dashboard", label: "Dashboard", icon: BarChart2 }, + { href: "/map", label: "Site Map", icon: Map }, + { href: "/graph", label: "Graph RAG", icon: MessageSquare }, +]; + +export default function Sidebar() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c419b2727c6c22975d23eabc763cf08a877c1aa --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,172 @@ +// Empty string = relative URLs (Docker/HF Spaces: Nginx routes /api/* to FastAPI) +// "http://localhost:8000" = direct for local dev without Docker +const BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; + +async function req(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`API ${res.status}: ${err}`); + } + return res.json(); +} + +// ── Patients ────────────────────────────────────────────────────────────────── + +export const getPatients = () => req<{ patients: any[]; total: number }>("/api/v1/patients"); +export const getPatient = (id: string) => req(`/api/v1/patients/${id}`); +export const getPatientFhir = (id: string) => req(`/api/v1/patients/${id}/fhir`); + +// ── Trials ──────────────────────────────────────────────────────────────────── + +export const searchTrials = (condition: string, phase?: string, pageSize = 20) => { + const params = new URLSearchParams({ condition, page_size: String(pageSize) }); + if (phase) params.set("phase", phase); + return req<{ trials: any[]; total: number; condition: string }>(`/api/v1/trials/search?${params}`); +}; + +export const getTrial = (nctId: string) => req(`/api/v1/trials/${nctId}`); +export const getTrialEligiblePatients = (nctId: string) => req(`/api/v1/trials/${nctId}/eligible-patients`); +export const getTrialIntelligence = (nctId: string) => req(`/api/v1/trials/${nctId}/intelligence`); + +// ── Matching ────────────────────────────────────────────────────────────────── + +export const matchPatientToTrials = (patientId: string, condition?: string, topN = 5) => { + const params = new URLSearchParams({ top_n: String(topN) }); + if (condition) params.set("condition", condition); + return req<{ patient_id: string; matches: any[]; total: number }>(`/api/v1/patients/${patientId}/match-trials?${params}`); +}; + +export const screenPatient = (patientId: string, nctId: string) => + req(`/api/v1/patients/${patientId}/screen/${nctId}`, { method: "POST" }); + +// ── A2A Workflow ────────────────────────────────────────────────────────────── + +export const runWorkflow = (patientId: string, nctId?: string, condition?: string) => + req("/api/v1/workflow/run", { + method: "POST", + body: JSON.stringify({ patient_id: patientId, nct_id: nctId, condition }), + }); + +export const getWorkflowStatus = (workflowId: string) => req(`/api/v1/workflow/${workflowId}/status`); +export const listWorkflows = () => req<{ workflows: any[] }>("/api/v1/workflows"); + +// ── Recruitment ─────────────────────────────────────────────────────────────── + +export const getKanbanBoard = () => req>("/api/v1/recruitment/board"); +export const getRecruitmentRecords = () => req<{ records: any[] }>("/api/v1/recruitment/records"); +export const createRecruitmentRecord = (data: { patient_id: string; nct_id: string; trial_title: string; match_score: number }) => + req("/api/v1/recruitment/records", { method: "POST", body: JSON.stringify(data) }); +export const updateRecordStatus = (recordId: string, status: string) => + req(`/api/v1/recruitment/records/${recordId}/status`, { method: "PATCH", body: JSON.stringify({ status }) }); +export const generateOutreach = (data: { patient_id: string; nct_id: string; trial_title: string; channel: string }) => + req("/api/v1/recruitment/outreach", { method: "POST", body: JSON.stringify(data) }); + +// ── Analytics ───────────────────────────────────────────────────────────────── + +export const getKPIs = () => req("/api/v1/analytics/kpi"); +export const getEnrollmentFunnel = (trialId?: string) => { + const params = trialId ? `?trial_id=${trialId}` : ""; + return req<{ funnel: any[] }>(`/api/v1/analytics/funnel${params}`); +}; +export const getSitePerformance = () => req<{ sites: any[] }>("/api/v1/analytics/sites"); +export const getDemographics = (trialId?: string) => { + const params = trialId ? `?trial_id=${trialId}` : ""; + return req(`/api/v1/analytics/demographics${params}`); +}; +export const getTimeline = (days = 30) => req<{ timeline: any[] }>(`/api/v1/analytics/timeline?days=${days}`); +export const getMapData = () => req<{ sites: any[]; patient_clusters: any[] }>("/api/v1/map/data"); +export const getGraphStats = () => req("/api/v1/graph/stats"); +export const getGraphPatients = (condition?: string, limit = 200) => { + const params = new URLSearchParams({ limit: String(limit) }); + if (condition) params.set("condition", condition); + return req<{ patients: any[]; total: number }>(`/api/v1/graph/patients?${params}`); +}; + +// ── Clinical Intake ─────────────────────────────────────────────────────────── + +export interface IntakeLabs { + hemoglobin?: number; // g/dL + wbc?: number; // ×10⁹/L + anc?: number; // ×10⁹/L + platelets?: number; // ×10⁹/L + creatinine?: number; // μmol/L + egfr?: number; // mL/min/1.73m² + bilirubin?: number; // μmol/L + alt?: number; // U/L + ast?: number; // U/L + albumin?: number; // g/dL +} + +export interface IntakePayload { + condition: string; + age?: number; + sex?: string; + ecog?: number; + stage?: string; + biomarkers?: string[]; + labs?: IntakeLabs; + prior_chemo?: boolean; + prior_radiation?: boolean; + prior_surgery?: boolean; + medications?: string[]; + save_to_graph?: boolean; +} + +export const submitIntake = (data: IntakePayload) => + req<{ condition: string; matches: any[]; total: number; patient_id?: string }>( + "/api/v1/intake/match", { method: "POST", body: JSON.stringify(data) } + ); + +export const getBiomarkerRegistry = () => + req<{ biomarkers: { id: string; label: string }[] }>("/api/v1/intake/biomarkers"); + +// ── Graph RAG ───────────────────────────────────────────────────────────────── + +export const graphQuery = (question: string) => + req<{ response: string }>("/api/v1/graph/query", { method: "POST", body: JSON.stringify({ question }) }); + +// ── Streaming A2A Workflow ──────────────────────────────────────────────────── + +export const startWorkflow = (patientId: string, nctId?: string, condition?: string) => + req<{ workflow_id: string; status: string; stream_url: string }>("/api/v1/workflow/start", { + method: "POST", + body: JSON.stringify({ patient_id: patientId, nct_id: nctId, condition }), + }); + +export const streamWorkflow = (workflowId: string, onEvent: (evt: any) => void, onDone: () => void) => { + const url = `${BASE}/api/v1/workflow/${workflowId}/stream`; + const es = new EventSource(url); + es.onmessage = (e) => { + if (e.data === "[DONE]") { es.close(); onDone(); return; } + try { onEvent(JSON.parse(e.data)); } catch {} + }; + es.onerror = () => { es.close(); onDone(); }; + return () => es.close(); +}; + +// ── Consent & Scheduling ───────────────────────────────────────────────────── + +export const getConsents = (patientId?: string) => { + const params = patientId ? `?patient_id=${patientId}` : ""; + return req<{ consents: any[] }>(`/api/v1/consent${params}`); +}; +export const getConsentStats = () => req("/api/v1/consent/stats"); +export const updateConsentStatus = (consentId: string, status: string, notes?: string) => + req(`/api/v1/consent/${consentId}/status`, { + method: "PATCH", + body: JSON.stringify({ status, notes }), + }); +export const getAppointments = (patientId?: string) => { + const params = patientId ? `?patient_id=${patientId}` : ""; + return req<{ appointments: any[] }>(`/api/v1/appointments${params}`); +}; +export const confirmAppointment = (apptId: string) => + req(`/api/v1/appointments/${apptId}/confirm`, { method: "PATCH" }); + +// ── Health ──────────────────────────────────────────────────────────────────── + +export const getHealth = () => req("/health"); diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..c74bfe92ecae14476e1824c46f8ddf7c7dea5e27 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,15 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: {}, + }, + plugins: [], +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..cf9c65d3e0676a0169374d827f7abb97497789ef --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/mcp_manifest.json b/mcp_manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..deba3d0d52484695525653c69e192298c9246a96 --- /dev/null +++ b/mcp_manifest.json @@ -0,0 +1,127 @@ +{ + "name": "ClinicalMatch AI", + "description": "Precision clinical trial matching and recruitment agent. Connects patients to eligible trials using FHIR R4 patient data, Neo4j graph intelligence, real-time ClinicalTrials.gov queries, and an A2A multi-agent consent and scheduling workflow.", + "version": "1.0.0", + "transport": "stdio", + "command": "python3", + "args": ["backend/mcp_server.py"], + "env": { + "NEO4J_URI": "bolt://localhost:7687", + "NEO4J_USERNAME": "neo4j", + "NEO4J_PASSWORD": "clinicalmatch2024", + "OPENAI_API_KEY": "", + "OPENAI_BASE_URL": "https://ai.aimlapi.com/v1", + "OPENAI_MODEL": "claude-opus-4-7" + }, + "tools": [ + { + "name": "ping", + "description": "Health check: Neo4j graph stats, CT.gov reachability, FHIR server status, LLM provider info, SHARP compliance summary.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_patient_matches", + "description": "Find the best clinical trial matches for a patient by their graph ID. Returns ranked trials with eligibility scores and FHIR-structured patient context.", + "inputSchema": { + "type": "object", + "properties": { + "patient_id": { "type": "string", "description": "Patient graph ID (e.g. P_C50_0001) or FHIR ID (P001-P005)" }, + "condition": { "type": "string", "description": "Primary condition to filter trials (e.g. 'breast cancer')" }, + "top_k": { "type": "integer", "description": "Max matches to return (default 5)", "default": 5 }, + "use_live_fhir": { "type": "boolean", "description": "Fetch patient profile from live FHIR server", "default": false } + }, + "required": ["patient_id", "condition"] + } + }, + { + "name": "list_recruiting_trials", + "description": "List currently recruiting trials filtered by condition and optionally by location (city/state). Returns enrollment status, phase, sponsor and site coordinates.", + "inputSchema": { + "type": "object", + "properties": { + "condition": { "type": "string", "description": "Medical condition (e.g. 'lung cancer')" }, + "city": { "type": "string", "description": "Filter by city (case-insensitive)" }, + "state": { "type": "string", "description": "Filter by US state abbreviation (e.g. 'CA', 'NY')" }, + "limit": { "type": "integer", "description": "Max trials to return (default 10)", "default": 10 } + }, + "required": ["condition"] + } + }, + { + "name": "find_trials", + "description": "Search ClinicalTrials.gov in real-time for trials matching a query. Returns trials sorted by most-recently-updated first.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Free-text search query (e.g. 'HER2 positive breast cancer phase 3')" }, + "limit": { "type": "integer", "description": "Max results (default 5)", "default": 5 } + }, + "required": ["query"] + } + }, + { + "name": "screen_patient", + "description": "Screen a specific FHIR patient against a specific trial. Returns a detailed pass/fail verdict with criterion-by-criterion breakdown and an overall match score.", + "inputSchema": { + "type": "object", + "properties": { + "patient_id": { "type": "string", "description": "Patient ID (P001-P005 for mock FHIR, or graph IDs)" }, + "nct_id": { "type": "string", "description": "ClinicalTrials.gov NCT identifier (e.g. NCT04592562)" }, + "use_live_fhir": { "type": "boolean", "description": "Fetch patient from live FHIR server", "default": false } + }, + "required": ["patient_id", "nct_id"] + } + }, + { + "name": "match_patient_to_trials", + "description": "Run the full A2A matching pipeline for a patient: INGESTING → PARSING_PROTOCOL → MATCHING → SCORING → RECRUITING. Returns ranked trials with graph explainability paths.", + "inputSchema": { + "type": "object", + "properties": { + "patient_id": { "type": "string", "description": "Patient ID" }, + "condition": { "type": "string", "description": "Primary condition" }, + "top_k": { "type": "integer", "description": "Top trials to return (default 5)", "default": 5 }, + "use_live_fhir": { "type": "boolean", "default": false } + }, + "required": ["patient_id", "condition"] + } + }, + { + "name": "generate_recruitment_outreach", + "description": "Generate a personalized recruitment letter (PCP referral or patient direct) for a patient-trial pair using LLM. Returns formatted letter text.", + "inputSchema": { + "type": "object", + "properties": { + "patient_id": { "type": "string", "description": "Patient ID" }, + "nct_id": { "type": "string", "description": "NCT trial ID" }, + "outreach_type": { "type": "string", "enum": ["pcp_letter", "patient_direct"], "default": "pcp_letter" } + }, + "required": ["patient_id", "nct_id"] + } + }, + { + "name": "get_trial_analytics", + "description": "Return dashboard KPIs: active trial count, total patients, eligible patient count, enrollment funnel metrics, and top conditions by trial volume.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "summarize_trial_protocol", + "description": "Use LLM to produce a plain-language summary of a trial's protocol including eligibility criteria, endpoints, and interventions.", + "inputSchema": { + "type": "object", + "properties": { + "nct_id": { "type": "string", "description": "NCT trial ID (e.g. NCT04592562)" } + }, + "required": ["nct_id"] + } + } + ] +} diff --git a/report.md b/report.md new file mode 100644 index 0000000000000000000000000000000000000000..6d3973f1df720c3ff8b76a8ad688e5c95ec8fd28 --- /dev/null +++ b/report.md @@ -0,0 +1,120 @@ +# Competitive Project Ideas for "Agents Assemble: Healthcare AI Endgame Challenge" Hackathon + +This analysis presents three innovative project ideas designed to excel in the "Agents Assemble: Healthcare AI Endgame Challenge," leveraging +MCP, A2A, and FHIR standards on the Prompt Opinion platform. Each idea focuses on significant healthcare pain points, integrating advanced Generative AI and demonstrating strong alignment with judging criteria, including feasibility and potential impact. + +--- + +## +1. AI-Powered Intelligent Prior Authorization & Appeals Agent + +**Technical Path:** Full Agent (A2A-powered agent for complex workflows) + +**Problem Addressed:** The prior authorization process is a major administrative burden in healthcare, leading +to significant delays in patient care, high denial rates, and increased operational costs for providers. This inefficiency often results in delayed or forgone treatments, negatively impacting patient outcomes and provider satisfaction. + +**Solution Overview:** +This project proposes an A2A +Full Agent designed to intelligently automate and streamline the prior authorization and appeals process. The agent will orchestrate interactions between several specialized MCP Superpowers to gather necessary information, draft compelling requests, identify potential issues, and generate appeals. + +**Key Features +and AI Factor:** +* **Contextual Data Aggregation (FHIR & SHARP):** The agent will query an MCP Superpower connected to a FHIR-compliant EHR (leveraging SHARP Extension Specs) to securely extract relevant +patient clinical data, including diagnoses, treatment history, lab results, and physician notes. SHARP ensures the contextual understanding of complex FHIR resources. +* **Generative AI for Request Generation:** A dedicated MCP Superpower will employ large +language models (LLMs) to automatically generate comprehensive and evidence-based prior authorization requests. This includes synthesizing clinical justification from diverse sources, adhering to payer-specific criteria, and identifying missing information. +* **Payer Policy Interpretation & Gap +Analysis:** Another MCP Superpower will leverage Generative AI to interpret complex payer policies and clinical guidelines. The Full Agent will then use this intelligence to perform a gap analysis against the patient's clinical data, proactively identifying potential reasons for denial and +suggesting additional documentation or justification. +* **Automated Appeal Drafting:** In cases of denial, the agent will dynamically generate appeal letters, citing relevant clinical evidence and policy nuances, significantly reducing the manual effort and time typically involved in appeals. +* **A2A Workflow Orchestration:** The Full Agent will seamlessly coordinate these MCP Superpowers, managing the state of authorization requests, tracking deadlines, and notifying human staff for review and submission. + +**Potential Impact:** +* **Reduced Administrative +Burden:** Significantly cuts down the time and resources spent by healthcare staff on prior authorizations. +* **Accelerated Patient Care:** Speeds up access to necessary treatments and medications by reducing authorization delays. +* **Improved Approval Rates:** Enhances +the quality and completeness of requests and appeals, potentially leading to higher approval rates. +* **Decreased Provider Burnout:** Alleviates a major source of frustration for clinicians and administrative staff. + +**Feasibility & Compliance:** +The +agent would function as an intelligent assistant, requiring human oversight for final review and submission. Strict adherence to HIPAA and other data privacy regulations would be paramount, utilizing de-identification where appropriate and robust access controls. The modular nature of MCP Superpowers +allows for phased deployment and easier compliance audits. + +--- + +## 2. Personalized Chronic Disease Management & Education Superpower + +**Technical Path:** Superpower (MCP server with healthcare tools) + +**Problem Addressed:** Patients with chronic conditions often struggle +with understanding their diagnoses, adhering to complex treatment plans, and finding reliable, personalized educational resources. This leads to poor self-management, higher rates of complications, and increased healthcare utilization due to preventable issues. + +**Solution Overview:** +This +project focuses on building an MCP Superpower that acts as a central intelligence hub for personalized chronic disease management and education. This Superpower will provide APIs for other agents (e.g., patient-facing chatbots, clinician dashboards, or remote monitoring platforms +) to access highly customized insights and educational content based on individual patient data. + +**Key Features and AI Factor:** +* **FHIR & SHARP Contextual Patient Data Integration:** The Superpower will securely connect to FHIR- +compliant EHRs, utilizing SHARP Extension Specs to gain a deep, contextual understanding of a patient's medical history, current medications, lab results, lifestyle data, and social determinants of health. +* **Generative AI for Dynamic +Content Creation:** Leveraging LLMs, the Superpower will dynamically generate personalized educational materials. This includes easy-to-understand explanations of conditions, tailored medication reminders with context-specific advice, dietary recommendations, exercise plans, and proactive health tips – +all adapted to the patient's health literacy level, language, and cultural background. +* **Proactive Risk Identification & Insight Generation:** The AI will analyze trends in patient data to identify potential risks (e.g., medication non +-adherence, worsening condition markers) and generate actionable insights for clinicians or patient-facing agents. +* **Conversational Health Intelligence API:** The Superpower will expose an API that allows other agents to query for personalized responses to patient questions, +drawing upon the patient's specific health profile and up-to-date medical knowledge, fostering a more engaging and effective patient education experience. + +**Potential Impact:** +* **Improved Patient Engagement & Adherence:** Patients receive relevant and +understandable information, empowering them to better manage their conditions. +* **Reduced Complications & Hospitalizations:** Proactive insights and better self-management can prevent adverse events. +* **Enhanced Health Literacy:** Bridges the gap between complex medical information +and patient comprehension. +* **Scalable Patient Support:** Provides personalized support at scale, complementing human care teams. + +**Feasibility & Compliance:** +This Superpower operates on a server, securely processing consented patient data. Data privacy (HIPAA +, GDPR) and security will be foundational, with robust authentication and authorization mechanisms for accessing patient information. The Superpower's role is to *provide intelligence*, not directly interact with patients, simplifying some regulatory aspects while enabling diverse patient-facing applications +. + +--- + +## 3. Precision Clinical Trial Matching & Recruitment Agent + +**Technical Path:** Full Agent (A2A-powered agent for complex workflows) + +**Problem Addressed:** Identifying and recruiting eligible patients for clinical trials is a +notoriously slow, expensive, and often ineffective process. Many eligible patients are overlooked, delaying critical research and the availability of new treatments. The complexity of trial protocols makes manual matching challenging. + +**Solution Overview:** +This project proposes an A2A +Full Agent that acts as an intelligent coordinator for precision clinical trial matching and patient recruitment. It will orchestrate interactions between MCP Superpowers specializing in protocol parsing, patient data analysis, and communication generation to efficiently identify and engage suitable candidates. + +**Key +Features and AI Factor:** +* **Generative AI for Protocol Interpretation:** An MCP Superpower will utilize advanced natural language understanding (NLU) and generative AI to parse complex clinical trial protocols, extracting precise inclusion and exclusion criteria, study +objectives, and treatment arms from unstructured text. +* **FHIR & SHARP Patient Data Analysis:** The Full Agent will interact with an MCP Superpower that accesses de-identified (or consented) patient EHR data via FHIR, +leveraging SHARP Extension Specs to understand the nuanced context of medical histories, lab results, diagnoses, medications, genetic markers, and even social determinants of health. This allows for highly accurate and contextual patient profiling. +* **Semantic Matching Engine +:** The Full Agent will use the interpreted trial criteria and patient profiles to perform intelligent, semantic matching. Generative AI will go beyond keyword matching to understand the clinical equivalence of terms, lab ranges, and disease progression, identifying patients who are a +precise fit for complex trials. +* **Personalized Recruitment Communication:** For identified eligible patients, another MCP Superpower will generate personalized, empathetic, and clear communication (e.g., messages to their primary care provider for referral, or direct +messages to consented patients explaining the trial). Generative AI ensures messages are culturally sensitive and easy to understand. +* **A2A Workflow for Recruitment Pipeline:** The Full Agent will manage the entire recruitment pipeline, from initial screening to communication +and tracking potential candidates, integrating with existing research workflows. + +**Potential Impact:** +* **Accelerated Medical Research:** Significantly speeds up patient recruitment, bringing new therapies to market faster. +* **Increased Trial Diversity:** Can help +identify underrepresented populations who meet trial criteria, improving the generalizability of study results. +* **Reduced Research Costs:** Minimizes the expensive and time-consuming manual screening process. +* **Improved Patient Access to Innovation:** Connect +s patients with potentially life-saving or improving experimental treatments. + +**Feasibility & Compliance:** +This solution requires stringent data governance, including robust de-identification techniques or explicit patient consent for data sharing. Ethical considerations around patient recruitment and communication +will be central to the design. The agent serves as a powerful tool to *assist* researchers and clinicians in matching, with human oversight for final patient engagement and enrollment. \ No newline at end of file