diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7a0d35f --- /dev/null +++ b/.env.example @@ -0,0 +1,57 @@ +# ============================================================ +# MediVault AI — Environment Configuration +# ============================================================ +# Copy this file to .env and fill in your values. +# All inference is fully air-gapped — zero cloud dependencies. + +# ============================================================ +# Flowise — AI Orchestration (Docker service — auto-starts) +# ============================================================ +# First-time setup (one-time): +# 1. Open http://localhost:3001 +# 2. Complete the "Setup Account" form (any username/password) +# 3. Log in → avatar (top-right) → API Keys → Add New Key +# 4. Paste the key below and restart: docker compose restart medivault-api +FLOWISE_ENDPOINT=http://medivault-flowise:3001 +FLOWISE_API_KEY= + +# ============================================================ +# Ollama — Sole LLM + Embeddings Provider (runs on host) +# ============================================================ +# Install: https://ollama.com/download +# macOS: brew install ollama (or download the .app from ollama.com) +# Then pull the required models (one-time, ~5.0 GB total): +# ollama pull llama3.1:8b # chat model (~4.7 GB) +# ollama pull nomic-embed-text # embeddings (~274 MB) +# +# Ollama serves on host:11434 — Docker reaches it via host.docker.internal. +OLLAMA_BASE_URL=http://host.docker.internal:11434 +OLLAMA_MODEL=llama3.1:8b +OLLAMA_EMBED_MODEL=nomic-embed-text + +# ============================================================ +# ChromaDB — Vector Store (Docker service — auto-managed) +# ============================================================ +# No setup needed — starts automatically with docker compose up. +CHROMA_HOST=medivault-chromadb +CHROMA_PORT=8000 + +# ============================================================ +# Whisper — Speech-to-Text (Docker service — fully automatic) +# ============================================================ +# No installation needed — model downloads automatically on first run. +# Model sizes: tiny (75MB) | base (145MB) | small (460MB) | medium (1.5GB) + +WHISPER_ENDPOINT=http://medivault-whisper:9000 +WHISPER_MODEL=small + +# ============================================================ +# File Size Limits +# ============================================================ +MAX_AUDIO_SIZE=26214400 +MAX_FILE_SIZE=10485760 + +# ============================================================ +# Server +# ============================================================ +BACKEND_PORT=5001 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..b93e50b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,39 @@ +## Summary + + + +- + +## Type of Change + + + +- [ ] Bug fix +- [ ] New feature / enhancement +- [ ] Documentation update +- [ ] Refactor (no behavior change) +- [ ] Chore (dependencies, CI, tooling) + +## Changes Made + + + +Resolves # + +## How to Test + + + +1. + +## Checklist + +- [ ] I have read the [Contributing Guide](../CONTRIBUTING.md) +- [ ] My branch is up to date with `main` +- [ ] New environment variables (if any) are documented in `.env.example` and the README +- [ ] No secrets, API keys, or credentials are included in this PR +- [ ] I have tested my changes locally + +## Screenshots (if applicable) + + diff --git a/.github/workflows/code-scans.yaml b/.github/workflows/code-scans.yaml new file mode 100644 index 0000000..940d9b7 --- /dev/null +++ b/.github/workflows/code-scans.yaml @@ -0,0 +1,104 @@ +name: SDLE Scans + +on: + workflow_dispatch: + inputs: + PR_number: + description: 'Pull request number' + required: true + push: + branches: [ main ] + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: sdle-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + +# ----------------------------- +# 1) Trivy Scan +# ----------------------------- + trivy_scan: + name: Trivy Vulnerability Scan + runs-on: ubuntu-latest + env: + TRIVY_REPORT_FORMAT: table + TRIVY_SCAN_TYPE: fs + TRIVY_SCAN_PATH: . + TRIVY_EXIT_CODE: '1' + TRIVY_VULN_TYPE: os,library + TRIVY_SEVERITY: CRITICAL,HIGH + steps: + - uses: actions/checkout@v4 + + - name: Create report directory + run: mkdir -p trivy-reports + + - name: Run Trivy FS Scan + uses: aquasecurity/trivy-action@0.35.0 + with: + scan-type: 'fs' + scan-ref: '.' + scanners: 'vuln,misconfig,secret,license' + ignore-unfixed: true + format: 'table' + exit-code: '1' + output: 'trivy-reports/trivy_scan_report.txt' + vuln-type: 'os,library' + severity: 'CRITICAL,HIGH' + + - name: Upload Trivy Report + uses: actions/upload-artifact@v4 + with: + name: trivy-report + path: trivy-reports/trivy_scan_report.txt + + - name: Show Trivy Report in Logs + if: failure() + run: | + echo "========= TRIVY FINDINGS =========" + cat trivy-reports/trivy_scan_report.txt + echo "=================================" + +# ----------------------------- +# 2) Bandit Scan +# ----------------------------- + bandit_scan: + name: Bandit security scan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: 'recursive' + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install Bandit + run: pip install bandit + + - name: Create Bandit configuration + shell: bash + run: | + cat > .bandit << 'EOF' + [bandit] + exclude_dirs = tests,test,venv,.venv,node_modules + skips = B101 + EOF + + - name: Run Bandit scan + run: | + bandit -r . -ll -iii -f screen + bandit -r . -ll -iii -f html -o bandit-report.html + + - name: Upload Bandit Report + uses: actions/upload-artifact@v4 + with: + name: bandit-report + path: bandit-report.html + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fb65ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,86 @@ +# ============================================ +# ENVIRONMENT & SECRETS +# ============================================ +.env +.env.* +!.env.example + +# ============================================ +# PYTHON +# ============================================ +__pycache__/ +*.py[cod] +*$py.class +*.so + +venv/ +env/ +ENV/ +.venv/ + +.idea/ +.vscode/ +*.swp +*.swo + +.pytest_cache/ +.coverage +htmlcov/ + +.mypy_cache/ +.dmypy.json +dmypy.json + +*.egg-info/ +dist/ +build/ + +# ============================================ +# NODE.JS / REACT +# ============================================ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +ui/dist/ +ui/build/ + +.env.development.local +.env.test.local +.env.production.local + +# ============================================ +# VECTOR DATABASE +# ============================================ +chroma/ +*.sqlite3 +*.sqlite + +# ============================================ +# TEST ASSETS & SAMPLE DATA +# ============================================ +docs/test-assets/ +*.wav +*.mp3 + +# ============================================ +# BUILD & RUNTIME ARTIFACTS +# ============================================ +*.log +logs/ + +# ============================================ +# TEMPORARY FILES +# ============================================ +tmp/ +temp/ +api/tmp/ +api/temp/ + +# ============================================ +# OS FILES +# ============================================ +.DS_Store +Thumbs.db +desktop.ini diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0751391 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,338 @@ +# Contributing to MediVault AI + +Thanks for your interest in contributing to MediVault AI. + +MediVault AI is an open-source offline clinical intelligence platform built with a FastAPI backend, a React frontend, Flowise for LLM chain orchestration, ChromaDB for vector storage, and Whisper ASR for speech-to-text — all running locally with no cloud dependencies. We welcome improvements across the codebase, documentation, bug reports, design feedback, and workflow polish. + +Before you start, read the relevant section below. It helps keep contributions focused, reviewable, and aligned with the current project setup. + +--- + +## Quick Setup Checklist + +Before you dive in, make sure you have these installed: + +```bash +# Check Python (3.11+ recommended) +python --version + +# Check Node.js (18+ recommended) +node --version + +# Check npm +npm --version + +# Check Docker +docker --version +docker compose version + +# Check Git +git --version +``` + +New to contributing? + +1. Open an issue or pick an existing one to work on. +2. Fork the repo and create a branch from `main`. +3. Follow the local setup guide below. +4. Run the app locally and verify your change before opening a PR. + +## Table of contents + +- [How do I...?](#how-do-i) + - [Get help or ask a question?](#get-help-or-ask-a-question) + - [Report a bug?](#report-a-bug) + - [Suggest a new feature?](#suggest-a-new-feature) + - [Fork and clone the repo?](#fork-and-clone-the-repo) + - [Set up MediVault AI locally?](#set-up-medivault-ai-locally) + - [Start contributing code?](#start-contributing-code) + - [Improve the documentation?](#improve-the-documentation) + - [Submit a pull request?](#submit-a-pull-request) +- [Branching model](#branching-model) +- [Commit conventions](#commit-conventions) +- [Code guidelines](#code-guidelines) +- [Pull request checklist](#pull-request-checklist) +- [Thank you](#thank-you) + +--- + +## How do I... + +### Get help or ask a question? + +- Start with the main project docs in [`README.md`](./README.md), [`TROUBLESHOOTING.md`](./TROUBLESHOOTING.md), [`SECURITY.md`](./SECURITY.md), and [`.env.example`](./.env.example). +- If something is unclear, open a GitHub issue with your question and the context you already checked. + +### Report a bug? + +1. Search existing issues first. +2. If the bug is new, open a GitHub issue. +3. Include your environment, what happened, what you expected, and exact steps to reproduce. +4. Add screenshots, logs, request details, or response payloads if relevant. + +### Suggest a new feature? + +1. Open a GitHub issue describing the feature. +2. Explain the problem, who it helps, and how it fits MediVault AI. +3. If the change is large, get alignment in the issue before writing code. + +### Fork and clone the repo? + +All contributions should come from a **fork** of the repository. This keeps the upstream repo clean and lets maintainers review changes via pull requests. + +#### Step 1: Fork the repository + +Click the **Fork** button at the top-right of the [MediVault AI repo](https://github.com/cld2labs/MediVaultAI) to create a copy under your GitHub account. + +#### Step 2: Clone your fork + +```bash +git clone https://github.com//MediVaultAI.git +cd MediVaultAI +``` + +#### Step 3: Add the upstream remote + +```bash +git remote add upstream https://github.com/cld2labs/MediVaultAI.git +``` + +This lets you pull in the latest changes from the original repo. + +#### Step 4: Create a branch + +Always branch off `main`. See [Branching model](#branching-model) for naming conventions. + +```bash +git checkout main +git pull upstream main +git checkout -b / +``` + +### Set up MediVault AI locally? + +#### Prerequisites + +- Python 3.11+ +- Node.js 18+ and npm +- Git +- Docker with Docker Compose v2 +- Ollama installed and running on the host machine with the required models: + +```bash +ollama pull llama3.1:8b +ollama pull nomic-embed-text +``` + +#### Option 1: Local development + +##### Step 1: Configure environment variables + +Create a root `.env` file from the example: + +```bash +cp .env.example .env +``` + +At minimum, confirm the Ollama and service URLs match your environment: + +```env +OLLAMA_BASE_URL=http://host.docker.internal:11434 +OLLAMA_MODEL=llama3.1:8b +OLLAMA_EMBED_MODEL=nomic-embed-text +``` + +For local backend development (outside Docker), set ChromaDB and Whisper to localhost: + +```env +CHROMA_HOST=localhost +WHISPER_ENDPOINT=http://localhost:9000 +``` + +##### Step 2: Install backend dependencies + +```bash +cd api +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cd .. +``` + +##### Step 3: Install frontend dependencies + +```bash +cd ui +npm install +cd .. +``` + +##### Step 4: Start the required containers + +```bash +docker compose up medivault-chromadb medivault-whisper medivault-flowise +``` + +##### Step 5: Start the backend + +```bash +cd api +source .venv/bin/activate +uvicorn server:app --reload --port 5001 +``` + +The backend runs at `http://localhost:5001`. + +##### Step 6: Start the frontend + +Open a second terminal: + +```bash +cd ui +npm run dev +``` + +The Vite dev server runs at `http://localhost:5173`. + +##### Step 7: Access the application + +- Frontend: `http://localhost:5173` +- Backend health check: `http://localhost:5001/health` +- API docs: `http://localhost:5001/docs` + +#### Option 2: Docker + +From the repository root: + +```bash +cp .env.example .env +docker compose up --build +``` + +This starts: + +- Frontend on `http://localhost:3000` +- Backend on `http://localhost:5001` +- Flowise on `http://localhost:3001` +- ChromaDB on `http://localhost:8100` +- Whisper ASR on `http://localhost:9000` + +#### Common troubleshooting + +- If ports `3000`, `3001`, `5001`, `8100`, or `9000` are already in use, stop the conflicting process before starting MediVault AI. +- If Ollama is unreachable from containers, confirm `host.docker.internal` resolves. On Linux, add `extra_hosts: ["host.docker.internal:host-gateway"]` to the affected services in `docker-compose.yaml`. +- If the Whisper container shows `whisper_connected: false`, wait up to 5 minutes on first run for the model to download. +- If Docker fails to build, rebuild with `docker compose up --build`. +- If Python packages fail to install, confirm you are using a supported Python version. + +### Start contributing code? + +1. Open or choose an issue. +2. [Fork the repo](#fork-and-clone-the-repo) and create a feature branch from `main`. +3. Keep the change focused on a single problem. +4. Run the app locally and verify the affected workflow. +5. Update docs when behavior, setup, configuration, or architecture changes. +6. Open a pull request back to upstream `main`. + +### Improve the documentation? + +Documentation updates are welcome. Relevant files currently live in: + +- [`README.md`](./README.md) +- [`CONTRIBUTING.md`](./CONTRIBUTING.md) +- [`TROUBLESHOOTING.md`](./TROUBLESHOOTING.md) +- [`SECURITY.md`](./SECURITY.md) +- [`DISCLAIMER.md`](./DISCLAIMER.md) + +### Submit a pull request? + +1. Push your branch to your fork. +2. Go to the [MediVault AI repo](https://github.com/cld2labs/MediVaultAI) and click **Compare & pull request**. +3. Set the base branch to `main`. +4. Fill in the PR template (it loads automatically). +5. Submit the pull request. + +A maintainer will review your PR. You may be asked to make changes — push additional commits to the same branch and they will be added to the PR automatically. + +Before opening your PR, sync with upstream to avoid merge conflicts: + +```bash +git fetch upstream +git rebase upstream/main +``` + +Follow the checklist below and the [Pull request checklist](#pull-request-checklist) section. + +--- + +## Branching model + +- Fork the repo and base new work from `main`. +- Open pull requests against upstream `main`. +- Use descriptive branch names with a type prefix: + +| Prefix | Use | +|---|---| +| `feat/` | New features or enhancements | +| `fix/` | Bug fixes | +| `docs/` | Documentation changes | +| `refactor/` | Code restructuring (no behavior change) | +| `chore/` | Dependency updates, CI changes, tooling | + +Examples: `feat/add-specialty-selector`, `fix/whisper-timeout`, `docs/update-api-reference` + +--- + +## Commit conventions + +Use [Conventional Commits](https://www.conventionalcommits.org/) format: + +``` +(): +``` + +Examples: + +```bash +git commit -m "feat(api): add billing codes endpoint" +git commit -m "fix(ui): resolve diarization label alignment" +git commit -m "docs: update environment variables table" +``` + +Keep commits focused — one logical change per commit. + +--- + +## Code guidelines + +- Follow the existing project structure and patterns before introducing new abstractions. +- Keep frontend changes consistent with the React + Vite + Tailwind setup already in use. +- Keep backend changes consistent with the FastAPI service structure in [`api`](./api). +- Avoid unrelated refactors in the same pull request. +- Do not commit secrets, API keys, audio files, local `.env` files, or generated artifacts. +- Do not include real patient data in any issue, log snippet, attachment, or test asset. +- Prefer clear, small commits and descriptive pull request summaries. +- Update documentation when contributor setup, behavior, environment variables, or API usage changes. + +--- + +## Pull request checklist + +Before submitting your pull request, confirm the following: + +- You tested the affected flow locally. +- The application still starts successfully in the environment you changed. +- You removed debug code, stray logs, and commented-out experiments. +- You documented any new setup steps, environment variables, or behavior changes. +- You kept the pull request scoped to one issue or topic. +- You added screenshots for UI changes when relevant. +- You did not commit secrets, patient data, or local generated data. +- You are opening the pull request against `main`. + +If one or more of these are missing, the pull request may be sent back for changes before review. + +--- + +## Thank you + +Thanks for contributing to MediVault AI. Whether you're fixing a bug, improving the docs, or refining the product experience, your work helps make the project more useful and easier to maintain. diff --git a/DISCLAIMER.md b/DISCLAIMER.md new file mode 100644 index 0000000..5506cfd --- /dev/null +++ b/DISCLAIMER.md @@ -0,0 +1,22 @@ +# Disclaimer + +This blueprint is provided by Cloud2 Labs "as is" and "as available" for +educational and demonstration purposes only. + +The **MediVault AI — Offline Clinical Intelligence Platform** blueprint is a reference +implementation and does not constitute a production-ready system or +regulatory-compliant solution. + +This software is not designed to provide professional medical, clinical, legal, +or compliance advice. All AI-generated outputs produced by this blueprint — +including SOAP notes, ICD-10 diagnosis codes, CPT procedure codes, and clinical +Q&A responses — are drafts only and require independent review and approval by +a licensed clinician before use in any clinical context or patient record. + +Cloud2 Labs does not assume responsibility or liability for any clinical harm, +data loss, security incident, service disruption, regulatory non-compliance, or +adverse outcome resulting from the use or modification of this blueprint. + +Do not use this system with real patient data in any environment unless your +organisation has implemented all applicable compliance measures required by +HIPAA, GDPR, and any other relevant health information privacy regulations. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..ad2904b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +© 2026 cld2labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..eb1605c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +The **MediVault AI — Offline Clinical Intelligence Platform** blueprint does not include +production-grade security controls. + +This repository is not secure by default and must not be used in production +without a comprehensive security review. + +## Known Considerations + +- **Flowise API key**: `FLOWISE_API_KEY` is loaded from `.env`. + Never commit `.env` to version control. Leave blank only in local development. +- **Flowise authentication**: `FLOWISE_USERNAME` and `FLOWISE_PASSWORD` default to + `admin` / `changeme`. Change these before any non-local deployment. +- **CORS**: The FastAPI backend defaults to permissive CORS in development. Restrict + allowed origins in any non-local deployment. +- **ChromaDB exposure**: ChromaDB is exposed on host port `8100` with no authentication. + Do not expose this port outside a trusted local network. +- **Ollama exposure**: Ollama runs on the host at port `11434` with no authentication. + Restrict access at the network level in any shared or production environment. +- **Real patient data**: Never use real patient data in development, staging, or + demonstration environments without full regulatory compliance measures in place. + +## User Responsibilities + +Users are responsible for implementing appropriate: + +- Authentication and authorization mechanisms for the UI and API +- TLS termination via a reverse proxy for any non-localhost deployment +- Network-level access controls and firewall rules +- Encryption and secure data storage for any persisted clinical data +- Monitoring, logging, and auditing +- HIPAA, GDPR, and regulatory compliance safeguards relevant to their deployment environment + +## Reporting a Vulnerability + +If you discover a security vulnerability in this blueprint, please report it +privately to the Cloud2 Labs maintainers rather than opening a public issue. diff --git a/TERMS_AND_CONDITIONS.md b/TERMS_AND_CONDITIONS.md new file mode 100644 index 0000000..dee8c60 --- /dev/null +++ b/TERMS_AND_CONDITIONS.md @@ -0,0 +1,19 @@ +# Terms and Conditions + +This repository contains the **MediVault AI — Offline Clinical Intelligence Platform** blueprint +maintained by Cloud2 Labs. + +By accessing or using this blueprint, you acknowledge and agree that: + +- This blueprint is provided solely for educational and demonstration purposes +- You are solely responsible for deployment, configuration, and usage +- You are responsible for all data handling, security controls, and compliance +- Every AI-generated output — including SOAP notes, ICD-10 codes, CPT codes, and clinical Q&A + responses — must be reviewed and approved by a licensed clinician before clinical use +- You are responsible for ensuring compliance with HIPAA, GDPR, and any applicable health + information privacy regulations in your deployment environment +- Real patient data must not be used in any environment without full compliance measures in place +- Cloud2 Labs provides no warranties or guarantees of any kind + +Cloud2 Labs does not support or recommend production deployment of this blueprint +without a thorough security review and appropriate hardening. diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 0000000..1b69d42 --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,164 @@ +# Troubleshooting Guide + +This document contains all common issues encountered during development and their solutions. + +## Table of Contents + +- [API Common Issues](#api-common-issues) +- [UI Common Issues](#ui-common-issues) + +### API Common Issues + +#### Flowise shows `connecting` or `flowise_connected: false` + +**Solution**: + +1. Wait up to 30 seconds after `docker compose up` — Flowise requires time to initialise before flows can be provisioned +2. Verify the Flowise container is running: `docker compose ps` +3. Check the API provisioning log: `docker compose logs medivault-api | grep -i "provision\|flowise\|error"` +4. If `FLOWISE_API_KEY` is set in `.env`, confirm the key matches one generated in the Flowise UI at `http://localhost:3001` — an incorrect key causes all provisioning calls to return 401 +5. Restart the API after correcting the key: `docker compose restart medivault-api` + +#### Whisper shows `whisper_connected: false` + +**Solution**: + +1. On first startup the Whisper container downloads the `small` model (~500 MB) — allow up to 5 minutes and monitor progress: `docker compose logs -f medivault-whisper` +2. Confirm the container is running: `docker compose ps medivault-whisper` +3. Verify the Whisper endpoint is reachable from the host: `curl http://localhost:9000/health` +4. If the container exited, inspect the logs and restart: `docker compose restart medivault-whisper` + +#### Transcription returns empty or fails + +**Solution**: + +1. Confirm the Whisper service is healthy before submitting audio +2. Verify the audio file plays correctly on the host machine before uploading +3. Ensure the file is WAV or MP3 and does not exceed 25 MB +4. If using a non-standard codec, convert to WAV before uploading: + ```bash + ffmpeg -i input.m4a -ar 16000 -ac 1 output.wav + ``` +5. Check the API logs for the specific error: `docker compose logs medivault-api | grep -i "transcri\|whisper\|error"` + +#### SOAP generation fails or returns malformed JSON + +**Solution**: + +1. Confirm `/health` reports `flowise_connected: true` and `flows_provisioned: true`: `curl http://localhost:5001/health` +2. Verify Ollama is running and the model is available: `ollama list` +3. Test Ollama directly: `curl http://localhost:11434/api/tags` +4. If Flowise flows are missing, restart the API to re-trigger provisioning: `docker compose restart medivault-api` +5. Inspect the Flowise SOAP Generator flow at `http://localhost:3001` and confirm the `ChatOllama` node base URL is `http://host.docker.internal:11434` + +#### No matching information in Clinical QA after approving notes + +**Solution**: + +1. Confirm `nomic-embed-text` is pulled: `ollama list` +2. Pull the model if missing: `ollama pull nomic-embed-text` +3. Verify the approve operation succeeded by checking the API logs: `docker compose logs medivault-api | grep -i "approve\|chroma\|embed\|error"` +4. Confirm ChromaDB is reachable and the collection exists: `curl http://localhost:8100/api/v1/collections` +5. Re-approve the note after confirming Ollama and ChromaDB are both healthy + +#### Billing codes not appearing + +**Solution**: + +1. Confirm a complete SOAP note with all sections populated was submitted before requesting billing codes +2. Check the API logs for the billing request: `docker compose logs medivault-api | grep -i "billing\|icd\|cpt\|error"` +3. Verify Ollama is responsive: `curl http://localhost:11434/api/tags` +4. If Ollama is slow on CPU hardware, wait for the previous LLM call to complete before submitting the billing request — concurrent requests may time out + +#### PDF ingestion fails + +**Solution**: + +1. Confirm the file is under 10 MB +2. Verify the PDF contains a text layer: `pdftotext document.pdf -` +3. If the output is empty, the PDF is image-only — run OCR before ingesting: `ocrmypdf input.pdf output.pdf` +4. Check the API logs for the rejection reason: `docker compose logs medivault-api | grep -i "ingest\|pdf\|error"` + +#### ChromaDB connection errors + +**Solution**: + +1. Confirm ChromaDB is running: `docker compose ps medivault-chromadb` +2. Verify ChromaDB responds: `curl http://localhost:8100/api/v1/heartbeat` +3. Confirm `CHROMA_HOST` is set to `medivault-chromadb` and `CHROMA_PORT` to `8000` in `.env` — the backend communicates on the internal Docker network port, not 8100 +4. Restart ChromaDB if the container exited: `docker compose restart medivault-chromadb` + +#### Ollama not responding + +**Solution**: + +1. Verify Ollama is running on the host: `ollama list` and `curl http://localhost:11434/api/tags` +2. Start Ollama if it is not running: `ollama serve` +3. Confirm the required models are pulled: + ```bash + ollama pull llama3.1:8b + ollama pull nomic-embed-text + ``` +4. On Linux, confirm `extra_hosts` is present in `docker-compose.yaml` for services that need to reach the host: + ```yaml + extra_hosts: + - "host.docker.internal:host-gateway" + ``` +5. Confirm `OLLAMA_BASE_URL` in `.env` is set to `http://host.docker.internal:11434` + +#### Import errors or server won't start + +**Solution**: + +1. Ensure all dependencies are installed: `pip install -r requirements.txt` +2. Verify you are using Python 3.11 or higher: `python --version` +3. Activate your virtual environment if using one +4. Check if port 5001 is already in use: `lsof -i :5001` (Unix) or `netstat -ano | findstr :5001` (Windows) +5. Use a different port by updating `BACKEND_PORT` in `.env` + +## UI Common Issues + +### API Connection Issues + +**Problem**: "Failed to transcribe", "Failed to generate SOAP note", or API shows offline + +**Solution**: + +1. Ensure the API server is running on `http://localhost:5001` +2. Check browser console for detailed errors +3. Verify CORS is enabled in the API +4. Test API directly: `curl http://localhost:5001/health` + +### Build Issues + +**Problem**: Build fails with dependency errors + +**Solution**: + +```bash +# Clear node_modules and reinstall +rm -rf node_modules package-lock.json +npm install +``` + +### Styling Issues + +**Problem**: Styles not applying + +**Solution**: + +```bash +# Rebuild Tailwind CSS +npm run dev +``` + +### UI Shows Blank Page + +**Problem**: Blank page on load or UI container shows errors + +**Solution**: + +1. Check the UI container logs: `docker compose logs medivault-ui` +2. Check the API container logs: `docker compose logs medivault-api` +3. Rebuild the UI container: `docker compose up --build medivault-ui` +4. Confirm the API is reachable: `curl http://localhost:5001/health` diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..94f0e15 --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.env +.env.* +!.env.example +*.egg-info/ +dist/ +build/ +.git/ +.gitignore diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..2cb809b --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN groupadd -r appuser && useradd -r -g appuser -u 1000 appuser +USER appuser + +EXPOSE 5001 + +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "5001"] diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..6a37662 --- /dev/null +++ b/api/config.py @@ -0,0 +1,52 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +# ── Flowise ──────────────────────────────────────────────────────────────── +FLOWISE_ENDPOINT = os.getenv("FLOWISE_ENDPOINT", "http://medivault-flowise:3001") +FLOWISE_API_KEY = os.getenv("FLOWISE_API_KEY", "") + +SOAP_FLOW_ID = os.getenv("SOAP_FLOW_ID", "") +QA_FLOW_ID = os.getenv("QA_FLOW_ID", "") +UPSERT_FLOW_ID = os.getenv("UPSERT_FLOW_ID", "") + +# ── Ollama ───────────────────────────────────────────────────────────────── +OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434") +OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1:8b") +OLLAMA_EMBED_MODEL = os.getenv("OLLAMA_EMBED_MODEL", "nomic-embed-text") + +# ── ChromaDB ─────────────────────────────────────────────────────────────── +CHROMA_HOST = os.getenv("CHROMA_HOST", "host.docker.internal") +CHROMA_PORT = int(os.getenv("CHROMA_PORT", "8100")) + +# ── Whisper ──────────────────────────────────────────────────────────────── +WHISPER_ENDPOINT = os.getenv("WHISPER_ENDPOINT", "http://host.docker.internal:8080") + +# ── Limits ───────────────────────────────────────────────────────────────── +MAX_AUDIO_SIZE = int(os.getenv("MAX_AUDIO_SIZE", str(50 * 1024 * 1024))) +MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", str(10 * 1024 * 1024))) +BACKEND_PORT = int(os.getenv("BACKEND_PORT", "5001")) + +# ── App ──────────────────────────────────────────────────────────────────── +CORS_ALLOW_ORIGINS = ["*"] +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_METHODS = ["*"] +CORS_ALLOW_HEADERS = ["*"] + +APP_TITLE = "MediVault AI API" +APP_DESCRIPTION = "Offline clinical intelligence — SOAP note generation and clinical decision support" +APP_VERSION = "2.0.0" + +SUPPORTED_SPECIALTIES = [ + "general", "emergency", "cardiology", "pediatrics", + "psychiatry", "orthopedics", "dermatology", "neurology", + "oncology", "gastroenterology", +] + +ALLOWED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".ogg", ".webm", ".flac"} +ALLOWED_PDF_EXTENSIONS = {".pdf"} + +SOAP_FLOW_NAME = "MediVault SOAP Generator" +QA_FLOW_NAME = "MediVault Clinical QA" +UPSERT_FLOW_NAME = "MediVault KB Upsert" diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..54651e4 --- /dev/null +++ b/api/models.py @@ -0,0 +1,116 @@ +from pydantic import BaseModel, Field +from typing import Optional, Literal + + +class DiarizedSegment(BaseModel): + speaker: Literal["Doctor", "Patient"] + text: str + start_ms: int = 0 + + +class TranscribeResponse(BaseModel): + transcript: str + segments: list[DiarizedSegment] = [] + + +class SoapNote(BaseModel): + chief_complaint: str = "" + subjective: str = "" + objective: str = "" + assessment: str = "" + plan: str = "" + + +class SoapKeywords(BaseModel): + symptoms: list[str] = [] + medications: list[str] = [] + diagnoses: list[str] = [] + + +class BillingCode(BaseModel): + code: str + description: str + + +class BillingCodesResponse(BaseModel): + cpt: list[BillingCode] = [] + icd10: list[BillingCode] = [] + + +class GenerateBillingRequest(BaseModel): + soap: SoapNote + specialty: str = "general" + + +class GenerateSoapRequest(BaseModel): + transcript: str = Field(..., min_length=1) + segments: list[DiarizedSegment] = [] + specialty: str = Field(default="general") + + +class GenerateSoapResponse(BaseModel): + soap: SoapNote + keywords: SoapKeywords = Field(default_factory=SoapKeywords) + specialty: str + raw_response: Optional[str] = None + + +class ApproveNoteRequest(BaseModel): + soap: SoapNote + specialty: str = "general" + patient_ref: str = "" + billing: Optional[BillingCodesResponse] = None + keywords: Optional[SoapKeywords] = None + + +class ApproveNoteResponse(BaseModel): + message: str + document_id: str + status: str + + +class ChatRequest(BaseModel): + question: str = Field(..., min_length=1) + session_id: Optional[str] = None + + +class CitedSource(BaseModel): + document: str + chunk: str + doc_type: str = "guideline" + score: Optional[float] = None + + +class ChatResponse(BaseModel): + answer: str + sources: list[CitedSource] = [] + session_id: Optional[str] = None + + +class IngestDocumentResponse(BaseModel): + message: str + document_id: str + status: str + + +class DocumentRecord(BaseModel): + id: str + filename: str + size_bytes: int + ingested_at: str + doc_type: str = "guideline" + patient_ref: str = "" + specialty: str = "" + + +class DocumentsListResponse(BaseModel): + documents: list[DocumentRecord] + total: int + + +class HealthResponse(BaseModel): + status: str + flowise_connected: bool + whisper_connected: bool + flows_provisioned: bool + version: str diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..aaa90fe --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.5 +uvicorn==0.32.1 +pydantic==2.10.3 +python-multipart>=0.0.18 +httpx==0.28.1 +pypdf==6.1.1 +python-dotenv==1.0.1 +tenacity==9.0.0 +chromadb-client==1.0.9 diff --git a/api/server.py b/api/server.py new file mode 100644 index 0000000..a9b539e --- /dev/null +++ b/api/server.py @@ -0,0 +1,543 @@ +import os +import json +import uuid +import tempfile +import logging +from datetime import datetime, timezone +from contextlib import asynccontextmanager + +from fastapi import FastAPI, File, UploadFile, HTTPException, status +from fastapi.middleware.cors import CORSMiddleware + +import config +from models import ( + TranscribeResponse, + GenerateSoapRequest, GenerateSoapResponse, SoapNote, SoapKeywords, + GenerateBillingRequest, BillingCodesResponse, BillingCode, + ApproveNoteRequest, ApproveNoteResponse, + ChatRequest, ChatResponse, CitedSource, + IngestDocumentResponse, DocumentRecord, DocumentsListResponse, + HealthResponse, +) +from services import ( + get_flowise_client, get_whisper_client, + provision, is_provisioned, + extract_text_from_pdf, validate_pdf_file, + generate_soap as llm_generate_soap, + generate_billing_codes as llm_generate_billing_codes, + answer_question as llm_answer_question, + diarize_segments, + upsert_document, query_documents, + delete_document as chroma_delete_document, + chroma_is_connected, +) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +_documents_store: list[DocumentRecord] = [] + + +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.flowise = get_flowise_client() + app.state.whisper = get_whisper_client() + logger.info("Starting Flowise flow provisioning...") + provision() + logger.info(f"Flows provisioned: {is_provisioned()} — SOAP={config.SOAP_FLOW_ID} QA={config.QA_FLOW_ID}") + yield + logger.info("MediVault AI API shutdown") + + +app = FastAPI( + title=config.APP_TITLE, + description=config.APP_DESCRIPTION, + version=config.APP_VERSION, + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=config.CORS_ALLOW_ORIGINS, + allow_credentials=config.CORS_ALLOW_CREDENTIALS, + allow_methods=config.CORS_ALLOW_METHODS, + allow_headers=config.CORS_ALLOW_HEADERS, +) + + +@app.get("/") +def root(): + return { + "message": "MediVault AI API", + "version": config.APP_VERSION, + "status": "healthy", + } + + +@app.get("/health", response_model=HealthResponse) +def health_check(): + return HealthResponse( + status="healthy", + flowise_connected=app.state.flowise.is_connected(), + whisper_connected=app.state.whisper.is_connected(), + flows_provisioned=is_provisioned(), + version=config.APP_VERSION, + ) + + +@app.post("/transcribe", response_model=TranscribeResponse) +async def transcribe_audio(file: UploadFile = File(...)): + content = await file.read() + file_size = len(content) + + if file_size > config.MAX_AUDIO_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Audio file too large ({file_size / 1024 / 1024:.1f} MB). Max {config.MAX_AUDIO_SIZE / 1024 / 1024:.0f} MB.", + ) + + ext = os.path.splitext(file.filename or "audio.wav")[1].lower() + if ext not in config.ALLOWED_AUDIO_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported audio format '{ext}'.", + ) + + try: + logger.info(f"Transcribing: {file.filename} ({file_size / 1024:.1f} KB)") + transcript, segments = app.state.whisper.transcribe(content, file.filename or "audio.wav") + if not transcript.strip(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Transcription produced no output. Check audio quality.", + ) + logger.info(f"Transcribed {len(transcript)} chars, {len(segments)} segments") + return TranscribeResponse(transcript=transcript, segments=segments) + except HTTPException: + raise + except Exception as e: + logger.error(f"Transcription error: {e}", exc_info=True) + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) + + +@app.post("/generate-soap", response_model=GenerateSoapResponse) +def generate_soap_endpoint(request: GenerateSoapRequest): + diarized_text = "\n".join( + f"{seg.speaker}: {seg.text}" + for seg in request.segments + ) if request.segments else request.transcript + + prompt = ( + f"Specialty: {request.specialty}\n\n" + f"Consultation Transcript:\n{diarized_text}\n\n" + "Generate the SOAP note JSON now." + ) + + raw = None + + # Primary: Flowise SOAP LLMChain flow + if config.SOAP_FLOW_ID: + try: + logger.info("Generating SOAP note via Flowise — flow=%s specialty=%s", config.SOAP_FLOW_ID, request.specialty) + result = app.state.flowise.predict(config.SOAP_FLOW_ID, prompt) + # LLMChain returns {"text": "..."} + raw = result.get("text") or result.get("output") or result.get("answer") or "" + if raw: + logger.info("SOAP generated via Flowise (%d chars)", len(raw)) + except Exception as fe: + logger.warning("Flowise SOAP generation failed (%s) — falling back to direct Ollama", fe) + raw = None + + # Fallback: direct Ollama call + if not raw: + try: + logger.info("Generating SOAP note via direct Ollama — model=%s specialty=%s", config.OLLAMA_MODEL, request.specialty) + raw = llm_generate_soap(prompt) + except Exception as e: + logger.error("SOAP generation error: %s", e, exc_info=True) + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) + + try: + soap, keywords = _parse_soap_json(raw) + logger.info("SOAP note generated — symptoms=%d medications=%d diagnoses=%d", + len(keywords.symptoms), len(keywords.medications), len(keywords.diagnoses)) + return GenerateSoapResponse(soap=soap, keywords=keywords, specialty=request.specialty, raw_response=raw) + except Exception as e: + logger.error("SOAP parse error: %s", e, exc_info=True) + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) + + +def _coerce_str(value, _depth: int = 0) -> str: + """Normalise a value that may be a str, list, or dict into a readable plain string.""" + if isinstance(value, str): + return value.strip() + if isinstance(value, list): + parts = [_coerce_str(v, _depth + 1) for v in value if v not in (None, "", [], {})] + if _depth == 0: + # Top-level list → join as sentences + return " ".join(p.rstrip(".") + "." for p in parts if p).strip() + return ", ".join(p for p in parts if p) + if isinstance(value, dict): + # Try common clinical key patterns for readable output + parts = [] + # Render key: value pairs, skipping empty values + for k, v in value.items(): + coerced = _coerce_str(v, _depth + 1) + if not coerced: + continue + # Use readable key names + key_label = k.replace("_", " ").strip() + if key_label in ("description", "text", "name", "diagnosis", "finding"): + # Lead with the main value, no key prefix + parts.insert(0, coerced) + elif key_label in ("differential diagnoses", "referrals", "treatment", "follow up", + "medications", "history", "symptoms", "tests ordered"): + # Skip empty sub-lists + if coerced and coerced not in (".", ""): + parts.append(coerced) + else: + parts.append(f"{key_label}: {coerced}") + return " ".join(parts).strip() + return str(value).strip() if value else "" + + +def _strip_inline_sources(text: str) -> str: + """ + Remove inline [Source: ...] tags the LLM embeds in answers. + The UI renders sources separately as citation boxes below the answer, + so inline tags are redundant and visually messy. + Handles: [Source: X], (Source: X), [Source: X, Y], **[Source: X]** + """ + import re + # Remove markdown-bold wrapped variants first + text = re.sub(r'\*+\[Source:[^\]]*\]\*+', '', text, flags=re.IGNORECASE) + # Remove bracketed [Source: ...] + text = re.sub(r'\[Source:[^\]]*\]', '', text, flags=re.IGNORECASE) + # Remove parenthesised (Source: ...) + text = re.sub(r'\(Source:[^)]*\)', '', text, flags=re.IGNORECASE) + # Remove "Refer to ..." trailing citations + text = re.sub(r'\(Refer to [^)]*\)', '', text, flags=re.IGNORECASE) + return text.strip() + + +def _strip_fences(raw: str) -> str: + """Remove markdown code fences from LLM output.""" + text = raw.strip() + if not text.startswith("```"): + return text + # Remove opening fence + optional language tag (e.g. ```json) + lines = text.splitlines() + # Drop first line (the opening fence) + lines = lines[1:] + # Drop last line if it's a closing fence + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + return "\n".join(lines).strip() + + +def _parse_soap_json(raw: str) -> tuple[SoapNote, SoapKeywords]: + """ + Parse raw LLM output into a (SoapNote, SoapKeywords) tuple. + Strips markdown fences if the model wraps its response despite instructions. + Coerces array/dict fields to strings (llama3.2 sometimes returns arrays for list-like fields). + Falls back to SoapNote(subjective=raw) with empty keywords on parse failure. + """ + try: + cleaned = _strip_fences(raw) + start = cleaned.find("{") + end = cleaned.rfind("}") + 1 + if start != -1 and end > start: + data = json.loads(cleaned[start:end]) + soap = SoapNote( + chief_complaint=_coerce_str(data.get("chief_complaint", "")), + subjective=_coerce_str(data.get("subjective", "")), + objective=_coerce_str(data.get("objective", "")), + assessment=_coerce_str(data.get("assessment", data.get("assessment", ""))), + plan=_coerce_str(data.get("plan", "")), + ) + raw_kw = data.get("keywords", {}) + if isinstance(raw_kw, dict): + keywords = SoapKeywords( + symptoms=[str(s) for s in raw_kw.get("symptoms", []) if s], + medications=[str(m) for m in raw_kw.get("medications", []) if m], + diagnoses=[str(d) for d in raw_kw.get("diagnoses", []) if d], + ) + else: + keywords = SoapKeywords() + return soap, keywords + except Exception as e: + logger.warning("SOAP JSON parse failed: %s — using raw text as subjective", e) + return SoapNote(subjective=raw), SoapKeywords() + + +def _parse_billing_json(raw: str) -> BillingCodesResponse: + """ + Parse raw LLM output into BillingCodesResponse. + Strips markdown fences and extracts the first JSON object found. + """ + try: + cleaned = _strip_fences(raw) + start = cleaned.find("{") + end = cleaned.rfind("}") + 1 + if start != -1 and end > start: + data = json.loads(cleaned[start:end]) + cpt = [BillingCode(code=c["code"], description=c["description"]) for c in data.get("cpt", []) if "code" in c] + icd10 = [BillingCode(code=c["code"], description=c["description"]) for c in data.get("icd10", []) if "code" in c] + return BillingCodesResponse(cpt=cpt, icd10=icd10) + except Exception as e: + logger.warning("Billing JSON parse failed: %s", e) + return BillingCodesResponse() + + +@app.post("/generate-billing", response_model=BillingCodesResponse) +def generate_billing_endpoint(request: GenerateBillingRequest): + """ + Generate ICD-10 and CPT billing codes from an approved SOAP note. + Called after the clinician has reviewed the SOAP note — not during generation. + Returns 1-3 CPT codes (E&M / procedures) and 1-3 ICD-10 codes (diagnoses). + All codes are AI suggestions only and must be verified by a qualified medical coder. + """ + soap_text = ( + f"Specialty: {request.specialty}\n\n" + f"Chief Complaint: {request.soap.chief_complaint}\n" + f"Subjective: {request.soap.subjective}\n" + f"Objective: {request.soap.objective}\n" + f"Assessment: {request.soap.assessment}\n" + f"Plan: {request.soap.plan}" + ) + + try: + logger.info("Generating billing codes — model=%s, specialty=%s", config.OLLAMA_MODEL, request.specialty) + raw = llm_generate_billing_codes(soap_text) + result = _parse_billing_json(raw) + logger.info("Billing codes generated — CPT=%d ICD-10=%d", len(result.cpt), len(result.icd10)) + return result + except HTTPException: + raise + except Exception as e: + logger.error("Billing code generation error: %s", e, exc_info=True) + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) + + +@app.post("/approve-note", response_model=ApproveNoteResponse) +def approve_note(request: ApproveNoteRequest): + doc_id = str(uuid.uuid4()) + patient_label = request.patient_ref or "Anonymous" + + soap_text = ( + f"SOAP Note\n" + f"Patient Reference: {patient_label}\n" + f"Specialty: {request.specialty}\n\n" + f"Chief Complaint:\n{request.soap.chief_complaint}\n\n" + f"Subjective:\n{request.soap.subjective}\n\n" + f"Objective:\n{request.soap.objective}\n\n" + f"Assessment:\n{request.soap.assessment}\n\n" + f"Plan:\n{request.soap.plan}" + ) + + # Append keywords if provided + if request.keywords: + kw = request.keywords + if kw.symptoms or kw.medications or kw.diagnoses: + soap_text += ( + f"\n\nKeywords:\n" + f"Symptoms: {', '.join(kw.symptoms)}\n" + f"Medications: {', '.join(kw.medications)}\n" + f"Diagnoses: {', '.join(kw.diagnoses)}" + ) + + # Append billing codes if provided — makes ICD/CPT searchable in Clinical QA + if request.billing: + icd_lines = "\n".join(f" {c.code} — {c.description}" for c in request.billing.icd10) + cpt_lines = "\n".join(f" {c.code} — {c.description}" for c in request.billing.cpt) + soap_text += ( + f"\n\nBilling Codes:\n" + f"ICD-10 Diagnoses:\n{icd_lines}\n" + f"CPT Procedures:\n{cpt_lines}" + ) + + metadata = { + "doc_type": "soap_note", + "doc_id": doc_id, + "patient_ref": request.patient_ref or "Anonymous", + "specialty": request.specialty, + "ingested_at": datetime.now(timezone.utc).isoformat(), + } + + try: + logger.info(f"Ingesting approved SOAP note — patient_ref={request.patient_ref}, specialty={request.specialty}") + + # Always use direct ChromaDB client for reliable upserts + upsert_document(soap_text, metadata) + logger.info("SOAP note upserted via direct ChromaDB client") + + _documents_store.append( + DocumentRecord( + id=doc_id, + filename=f"SOAP_{request.specialty}_{request.patient_ref or 'anon'}.txt", + size_bytes=len(soap_text.encode()), + ingested_at=datetime.now(timezone.utc).isoformat(), + doc_type="soap_note", + patient_ref=request.patient_ref or "", + specialty=request.specialty, + ) + ) + logger.info(f"SOAP note ingested into knowledge base: {doc_id}") + return ApproveNoteResponse( + message="SOAP note approved and added to knowledge base.", + document_id=doc_id, + status="success", + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Approve note error: {e}", exc_info=True) + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) + + +@app.post("/chat", response_model=ChatResponse) +def clinical_chat(request: ChatRequest): + """ + Clinical QA — Flowise ConversationalRetrievalQAChain (primary) reading from the + shared clinical_kb ChromaDB collection. + + All reads and writes use the direct Python chromadb client for consistent embeddings. + Fallback to direct RAG if the primary path fails. + """ + session_id = request.session_id or str(uuid.uuid4()) + + # Direct ChromaDB + Ollama RAG + # All reads and writes go through the Python chromadb client to keep embeddings consistent. + try: + logger.info("Clinical QA via direct RAG — session=%s question=%s", session_id, request.question[:80]) + retrieved = query_documents(request.question, n_results=8) + + if retrieved: + context = "\n\n---\n\n".join( + f"[Source: {r['metadata'].get('patient_ref') or r['metadata'].get('source', 'Unknown')} " + f"({r['metadata'].get('doc_type', 'document')})]\n{r['text']}" + for r in retrieved + ) + answer = _strip_inline_sources(llm_answer_question(context, request.question)) + + # If the LLM issued a refusal (non-medical or not in KB), show no citations — + # the retrieved docs were not actually used to form the answer. + _REFUSAL_PHRASES = ( + "this assistant only answers clinical", + "no matching information found", + ) + is_refusal = any(p in answer.lower() for p in _REFUSAL_PHRASES) + sources = [] if is_refusal else [ + CitedSource( + document=r["metadata"].get("patient_ref") or r["metadata"].get("source", "Unknown"), + chunk=r["text"][:300], + doc_type=r["metadata"].get("doc_type", "guideline"), + score=r.get("score"), + ) + for r in retrieved + ] + else: + logger.info("Clinical QA — knowledge base is empty or no relevant documents found") + answer = ( + "No matching information found in the knowledge base. " + "Try approving relevant SOAP notes or uploading clinical guidelines." + ) + sources = [] + + return ChatResponse(answer=answer, sources=sources, session_id=session_id) + except HTTPException: + raise + except Exception as e: + logger.error("Clinical chat error: %s", e, exc_info=True) + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) + + +@app.post("/ingest-document", response_model=IngestDocumentResponse) +async def ingest_document(file: UploadFile = File(...)): + content = await file.read() + file_size = len(content) + + try: + validate_pdf_file(file.filename or "doc.pdf", file_size) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + tmp_path = None + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp: + tmp.write(content) + tmp_path = tmp.name + + doc_id = str(uuid.uuid4()) + logger.info(f"Ingesting document: {file.filename} ({file_size / 1024:.1f} KB)") + + extracted_text = extract_text_from_pdf(tmp_path) + if not extracted_text.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No text could be extracted from this PDF.", + ) + + metadata = { + "doc_type": "guideline", + "doc_id": doc_id, + "source": file.filename or "document.pdf", + "ingested_at": datetime.now(timezone.utc).isoformat(), + } + + # Always use direct ChromaDB client for reliable upserts + logger.info("Ingesting document via direct ChromaDB: %s", file.filename) + upsert_document(extracted_text, metadata) + + _documents_store.append( + DocumentRecord( + id=doc_id, + filename=file.filename or "document.pdf", + size_bytes=file_size, + ingested_at=datetime.now(timezone.utc).isoformat(), + doc_type="guideline", + ) + ) + + logger.info(f"Document ingested into ChromaDB: {file.filename} → {doc_id}") + return IngestDocumentResponse( + message=f"'{file.filename}' ingested into knowledge base.", + document_id=doc_id, + status="success", + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Ingest error: {e}", exc_info=True) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + finally: + if tmp_path and os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except Exception: + pass + + +@app.get("/documents", response_model=DocumentsListResponse) +def list_documents(): + return DocumentsListResponse(documents=_documents_store, total=len(_documents_store)) + + +@app.delete("/documents/{doc_id}") +def delete_document(doc_id: str): + global _documents_store + before = len(_documents_store) + _documents_store = [d for d in _documents_store if d.id != doc_id] + if len(_documents_store) == before: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found.") + chroma_delete_document(doc_id) + logger.info(f"Document deleted: {doc_id}") + return {"message": "Document removed.", "document_id": doc_id} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=config.BACKEND_PORT) # nosec B104 diff --git a/api/services/__init__.py b/api/services/__init__.py new file mode 100644 index 0000000..ce9cc04 --- /dev/null +++ b/api/services/__init__.py @@ -0,0 +1,6 @@ +from .flowise_client import FlowiseClient, get_flowise_client +from .flowise_provisioner import provision, is_provisioned +from .whisper_client import WhisperClient, get_whisper_client +from .pdf_service import extract_text_from_pdf, validate_pdf_file +from .llm_client import generate_soap, generate_billing_codes, diarize_segments, answer_question +from .chroma_client import upsert_document, query_documents, delete_document, is_connected as chroma_is_connected diff --git a/api/services/chroma_client.py b/api/services/chroma_client.py new file mode 100644 index 0000000..6db95cc --- /dev/null +++ b/api/services/chroma_client.py @@ -0,0 +1,100 @@ +""" +chroma_client.py +Direct ChromaDB HTTP client for the clinical_kb collection. + +Embeddings are generated via Ollama using the configured embedding model. +""" + +import logging +import uuid + +import config +from services.llm_client import embed + +logger = logging.getLogger(__name__) + +_COLLECTION_NAME = "clinical_kb" + + +def _get_client(): + import chromadb + return chromadb.HttpClient(host=config.CHROMA_HOST, port=config.CHROMA_PORT) + + +def upsert_document(text: str, metadata: dict) -> str: + """ + Embed and upsert a document into the clinical_kb collection. + Returns the document ID. + """ + doc_id = metadata.get("doc_id") or str(uuid.uuid4()) + try: + client = _get_client() + collection = client.get_or_create_collection(name=_COLLECTION_NAME) + embeddings = embed([text]) + collection.upsert( + ids=[doc_id], + documents=[text], + embeddings=embeddings, + metadatas=[metadata], + ) + logger.info("Upserted document %s into %s", doc_id, _COLLECTION_NAME) + return doc_id + except Exception as e: + logger.error("ChromaDB upsert failed: %s", e, exc_info=True) + raise + + +def query_documents(query: str, n_results: int = 5) -> list[dict]: + """ + Query the clinical_kb collection and return matching chunks with metadata. + """ + try: + client = _get_client() + collection = client.get_or_create_collection(name=_COLLECTION_NAME) + + count = collection.count() + if count == 0: + logger.info("clinical_kb is empty — no documents to query") + return [] + + query_embeddings = embed([query]) + results = collection.query( + query_embeddings=query_embeddings, + n_results=min(n_results, count), + include=["documents", "metadatas", "distances"], + ) + docs = results.get("documents", [[]])[0] + metas = results.get("metadatas", [[]])[0] + distances = results.get("distances", [[]])[0] + return [ + { + "text": doc, + "metadata": meta, + "score": round(max(0.0, 1 - dist), 4), + } + for doc, meta, dist in zip(docs, metas, distances) + ] + except Exception as e: + logger.error("ChromaDB query failed: %s", e, exc_info=True) + return [] + + +def delete_document(doc_id: str) -> bool: + """Delete a document from the collection by ID.""" + try: + client = _get_client() + collection = client.get_or_create_collection(name=_COLLECTION_NAME) + collection.delete(ids=[doc_id]) + logger.info("Deleted document %s from %s", doc_id, _COLLECTION_NAME) + return True + except Exception as e: + logger.error("ChromaDB delete failed: %s", e, exc_info=True) + return False + + +def is_connected() -> bool: + try: + _get_client().heartbeat() + return True + except Exception: + return False diff --git a/api/services/flowise_client.py b/api/services/flowise_client.py new file mode 100644 index 0000000..68f3071 --- /dev/null +++ b/api/services/flowise_client.py @@ -0,0 +1,90 @@ +import logging +from typing import Optional +import httpx +import config + +logger = logging.getLogger(__name__) + + +class FlowiseClient: + def __init__(self): + self.endpoint = config.FLOWISE_ENDPOINT.rstrip("/") + self.api_key = config.FLOWISE_API_KEY + self._headers = {"Content-Type": "application/json"} + if self.api_key: + self._headers["Authorization"] = f"Bearer {self.api_key}" + + def predict(self, flow_id: str, question: str, overrides: Optional[dict] = None) -> dict: + """ + Send a question to a Flowise chatflow and return the full response dict. + The ConversationalRetrievalQAChain returns: + { "text": "...", "sourceDocuments": [...], "chatId": "..." } + """ + payload: dict = {"question": question} + if overrides: + payload["overrideConfig"] = overrides + + url = f"{self.endpoint}/api/v1/prediction/{flow_id}" + logger.info("Flowise predict → flow=%s question_len=%d", flow_id, len(question)) + with httpx.Client(timeout=180.0) as client: + response = client.post(url, json=payload, headers=self._headers) + if not response.is_success: + logger.error( + "Flowise predict failed: %s — %s", + response.status_code, + response.text[:500], + ) + response.raise_for_status() + return response.json() + + def upsert(self, flow_id: str, text: str, metadata: Optional[dict] = None) -> dict: + """ + Upsert a document into the Flowise QA flow's vector store (ChromaDB). + + Flowise upsert endpoint: POST /api/v1/vector/upsert/{flow_id} + - `question` : the raw text to embed and store + - `overrideConfig`: passed through to the vector store node as runtime config. + We flatten metadata keys here so Flowise passes them to + the ChromaDB document's metadata dict. + """ + url = f"{self.endpoint}/api/v1/vector/upsert/{flow_id}" + override: dict = {} + if metadata: + override.update(metadata) + + payload: dict = {"question": text} + if override: + payload["overrideConfig"] = override + + logger.info("Flowise upsert → flow=%s text_len=%d", flow_id, len(text)) + with httpx.Client(timeout=180.0) as client: + response = client.post(url, json=payload, headers=self._headers) + if not response.is_success: + logger.error( + "Flowise upsert failed: %s — %s", + response.status_code, + response.text[:500], + ) + response.raise_for_status() + return response.json() + + def is_connected(self) -> bool: + try: + with httpx.Client(timeout=5.0) as client: + response = client.get( + f"{self.endpoint}/api/v1/ping", + headers=self._headers, + ) + return response.status_code == 200 + except Exception: + return False + + +_flowise_client: Optional[FlowiseClient] = None + + +def get_flowise_client() -> FlowiseClient: + global _flowise_client + if _flowise_client is None: + _flowise_client = FlowiseClient() + return _flowise_client diff --git a/api/services/flowise_provisioner.py b/api/services/flowise_provisioner.py new file mode 100644 index 0000000..727ae22 --- /dev/null +++ b/api/services/flowise_provisioner.py @@ -0,0 +1,663 @@ +""" +flowise_provisioner.py +Auto-creates AND updates Flowise flows on every FastAPI startup. + +Ollama only (ChatOllama + OllamaEmbeddings). No cloud credentials required. + +On every restart the provisioner: + - Creates the flow if it doesn't exist + - Updates it if it does (so .env changes are always reflected) +""" + +import json +import logging +import time +import uuid + +import httpx + +import config + +logger = logging.getLogger(__name__) + +_provisioned = False + + +# ── HTTP helpers ──────────────────────────────────────────────────────────── + +def _headers() -> dict: + h = {"Content-Type": "application/json"} + if config.FLOWISE_API_KEY: + h["Authorization"] = f"Bearer {config.FLOWISE_API_KEY}" + return h + + +def _wait_for_flowise(max_attempts: int = 15, delay: float = 4.0) -> bool: + url = f"{config.FLOWISE_ENDPOINT}/api/v1/ping" + for attempt in range(1, max_attempts + 1): + try: + with httpx.Client(timeout=5.0) as client: + r = client.get(url) + if r.status_code == 200: + logger.info("Flowise is ready") + return True + except Exception: + pass + logger.info("Waiting for Flowise... attempt %d/%d", attempt, max_attempts) + time.sleep(delay) + logger.error("Flowise did not become ready after %d attempts", max_attempts) + return False + + +def _list_chatflows() -> list[dict]: + url = f"{config.FLOWISE_ENDPOINT}/api/v1/chatflows" + with httpx.Client(timeout=15.0) as client: + r = client.get(url, headers=_headers()) + r.raise_for_status() + return r.json() + + +def _create_chatflow(name: str, flow_data: dict) -> str: + url = f"{config.FLOWISE_ENDPOINT}/api/v1/chatflows" + payload = { + "name": name, + "flowData": json.dumps(flow_data), + "deployed": True, + "isPublic": False, + "type": "CHATFLOW", + } + with httpx.Client(timeout=30.0) as client: + r = client.post(url, json=payload, headers=_headers()) + r.raise_for_status() + return r.json()["id"] + + +def _update_chatflow(flow_id: str, name: str, flow_data: dict) -> None: + url = f"{config.FLOWISE_ENDPOINT}/api/v1/chatflows/{flow_id}" + payload = { + "name": name, + "flowData": json.dumps(flow_data), + "deployed": True, + "isPublic": False, + "type": "CHATFLOW", + } + with httpx.Client(timeout=30.0) as client: + r = client.put(url, json=payload, headers=_headers()) + r.raise_for_status() + + + +# ── Node ID helpers ───────────────────────────────────────────────────────── + +def _make_id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex[:8]}" + + +# ── Provider-specific node builders ──────────────────────────────────────── + +def _chat_node(node_id: str, position: dict, soap_temp: float = 0.1) -> dict: + """Build a ChatOllama node for a Flowise flow.""" + input_params = [ + {"label": "Base URL", "name": "baseUrl", "type": "string", "default": "http://localhost:11434"}, + {"label": "Model Name", "name": "modelName", "type": "string", "placeholder": "llama3.1:8b"}, + {"label": "Temperature", "name": "temperature","type": "number", "optional": True}, + {"label": "Top P", "name": "topP", "type": "number", "optional": True, "additionalParams": True}, + {"label": "Top K", "name": "topK", "type": "number", "optional": True, "additionalParams": True}, + {"label": "Keep Alive", "name": "keepAlive", "type": "string", "optional": True, "additionalParams": True}, + ] + return { + "id": node_id, + "type": "genericNode", + "position": position, + "data": { + "id": node_id, + "label": "ChatOllama", + "name": "chatOllama", + "version": 2, + "type": "ChatOllama", + "baseClasses": [ + "ChatOllama", "SimpleChatModel", + "BaseChatModel", "BaseLanguageModel", "Runnable", + ], + "category": "Chat Models", + "inputs": { + "baseUrl": config.OLLAMA_BASE_URL, + "modelName": config.OLLAMA_MODEL, + "temperature": soap_temp, + }, + "inputParams": input_params, + "outputs": {}, + "selected": False, + }, + } + + +def _chat_source_handle(node_id: str) -> str: + """Return the sourceHandle string for the ChatOllama node output.""" + return ( + f"{node_id}-output-chatOllama-" + "ChatOllama|SimpleChatModel|BaseChatModel|BaseLanguageModel|Runnable" + ) + + +def _embed_node(node_id: str, position: dict) -> dict: + """Build an OllamaEmbeddings node for a Flowise flow.""" + input_params = [ + {"label": "Base URL", "name": "baseUrl", "type": "string", "default": "http://localhost:11434"}, + {"label": "Model Name", "name": "modelName", "type": "string", "placeholder": "nomic-embed-text"}, + {"label": "Num GPU", "name": "numGpu", "type": "number", "optional": True, "additionalParams": True}, + {"label": "Keep Alive", "name": "keepAlive", "type": "string", "optional": True, "additionalParams": True}, + ] + return { + "id": node_id, + "type": "genericNode", + "position": position, + "data": { + "id": node_id, + "label": "Ollama Embeddings", + "name": "ollamaEmbedding", + "version": 1, + "type": "OllamaEmbeddings", + "baseClasses": ["OllamaEmbeddings", "Embeddings"], + "category": "Embeddings", + "inputs": { + "baseUrl": config.OLLAMA_BASE_URL, + "modelName": config.OLLAMA_EMBED_MODEL, + }, + "inputParams": input_params, + "outputs": {}, + "selected": False, + }, + } + + +def _embed_source_handle(node_id: str) -> str: + """Return the sourceHandle string for the OllamaEmbeddings node output.""" + return f"{node_id}-output-ollamaEmbedding-OllamaEmbeddings|Embeddings" + + +# ── Flow data builders ────────────────────────────────────────────────────── + + +def _build_soap_flow_data() -> dict: + """ + SOAP Generator flow — LLM Chain (no vector store dependency). + + PromptTemplate ──► LLMChain ◄── ChatModel + │ + ▼ + output (SOAP JSON) + + The transcript is the full context — no retrieval needed. + Using LLMChain avoids the ChromaDB dependency at prediction time + and works even when ChromaDB is not running. + """ + chat_id = _make_id("chat") + prompt_id = _make_id("prompt") + chain_id = _make_id("chain") + + soap_system_prompt = ( + "You are a clinical documentation specialist.\n" + "You will receive a diarized doctor-patient consultation transcript " + "where speakers are labeled Doctor and Patient.\n\n" + "Generate a structured SOAP note as valid JSON with EXACTLY these keys:\n" + " chief_complaint, subjective, objective, assessment, plan, keywords\n\n" + "STRICT CONTENT RULES:\n" + "- Use ONLY information explicitly stated in the transcript. Do not invent vitals, medications, or findings not mentioned.\n" + "- If objective findings were not mentioned in the transcript, write: Not documented in this consultation.\n" + "- If a field has no information from the transcript, write: Not reported.\n" + "- Every field except keywords MUST be a plain STRING.\n" + "- keywords MUST be an object with three flat string arrays.\n\n" + "OUTPUT FORMAT — return valid JSON matching this exact structure:\n" + "{{\n" + " \"chief_complaint\": \"One sentence summarising the patient's main complaint from the transcript.\",\n" + " \"subjective\": \"Prose paragraph summarising what the patient reported: symptoms, duration, severity, history.\",\n" + " \"objective\": \"Prose paragraph of clinician findings: vitals, exam findings, test results. If none mentioned, write: Not documented in this consultation.\",\n" + " \"assessment\": \"Prose paragraph with clinical impression and differential diagnoses with ICD-10 codes where applicable.\",\n" + " \"plan\": \"Prose paragraph with treatment, tests ordered, medications, and follow-up instructions.\",\n" + " \"keywords\": {{\n" + " \"symptoms\": [\"list\", \"of\", \"symptoms\"],\n" + " \"medications\": [\"list\", \"of\", \"medications\"],\n" + " \"diagnoses\": [\"list\", \"of\", \"diagnoses\"]\n" + " }}\n" + "}}\n\n" + "Use proper medical terminology. Return ONLY valid JSON — no markdown fences, no explanation." + ) + + # Human message template — {input} is replaced by the transcript at prediction time + human_template = "{input}" + + chat_node = _chat_node(chat_id, {"x": 400, "y": 200}, soap_temp=0.1) + + prompt_input_params = [ + {"label": "System Message", "name": "systemMessagePrompt", "type": "string", "rows": 4}, + {"label": "Human Message", "name": "humanMessagePrompt", "type": "string", "rows": 4}, + {"label": "Format Prompt Values", "name": "promptValues", "type": "json", "optional": True, "acceptVariable": True, "list": True}, + ] + + prompt_node = { + "id": prompt_id, + "type": "genericNode", + "position": {"x": 100, "y": 200}, + "data": { + "id": prompt_id, + "label": "Chat Prompt Template", + "name": "chatPromptTemplate", + "version": 1, + "type": "ChatPromptTemplate", + "baseClasses": ["ChatPromptTemplate", "BaseChatPromptTemplate", "BasePromptTemplate", "Runnable"], + "category": "Prompts", + "inputs": { + "systemMessagePrompt": soap_system_prompt, + "humanMessagePrompt": human_template, + "inputVariables": "input", + }, + "inputParams": prompt_input_params, + "outputs": {}, + "selected": False, + }, + } + + llmchain_input_params = [ + {"label": "Language Model", "name": "model", "type": "BaseLanguageModel"}, + {"label": "Prompt", "name": "prompt", "type": "BasePromptTemplate"}, + {"label": "Output Key", "name": "outputKey", "type": "string", "default": "text", "optional": True, "additionalParams": True}, + {"label": "Chain Name", "name": "chainName", "type": "string", "optional": True, "additionalParams": True}, + ] + + chain_node = { + "id": chain_id, + "type": "genericNode", + "position": {"x": 700, "y": 200}, + "data": { + "id": chain_id, + "label": "LLM Chain", + "name": "llmChain", + "version": 3, + "type": "LLMChain", + "baseClasses": ["LLMChain", "BaseChain", "Runnable"], + "category": "Chains", + "inputs": { + "model": f"{{{{{chat_id}.data.instance}}}}", + "prompt": f"{{{{{prompt_id}.data.instance}}}}", + "outputKey": "text", + }, + "inputParams": llmchain_input_params, + "outputs": {"output": "llmChain"}, + "selected": False, + }, + } + + nodes = [prompt_node, chat_node, chain_node] + edges = [ + { + "id": f"e_{prompt_id}_{chain_id}", + "source": prompt_id, + "target": chain_id, + "sourceHandle": ( + f"{prompt_id}-output-chatPromptTemplate-" + "ChatPromptTemplate|BaseChatPromptTemplate|BasePromptTemplate|Runnable" + ), + "targetHandle": f"{chain_id}-input-prompt-BasePromptTemplate", + "type": "buttonedge", + }, + { + "id": f"e_{chat_id}_{chain_id}", + "source": chat_id, + "target": chain_id, + "sourceHandle": _chat_source_handle(chat_id), + "targetHandle": f"{chain_id}-input-model-BaseLanguageModel", + "type": "buttonedge", + }, + ] + + return {"nodes": nodes, "edges": edges} + + +def _build_qa_flow_data() -> dict: + """ + Clinical QA flow. + ChatModel ──────────────────────────────► ConversationalRetrievalQAChain + Embeddings ──► Chroma(clinical_kb) ──► + BufferMemory ───────────────────────────► + + returnSourceDocuments: True → citations in chat responses. + """ + chat_id = _make_id("chat") + embed_id = _make_id("embed") + chroma_id = _make_id("chroma") + memory_id = _make_id("memory") + chain_id = _make_id("chain") + + chroma_url = f"http://{config.CHROMA_HOST}:{config.CHROMA_PORT}" + + rephrase_prompt = ( + "Given the conversation history and the follow-up question, " + "rephrase the follow-up question to be a standalone search query " + "that retrieves the most relevant clinical documents.\n\n" + "Chat History: {chat_history}\n" + "Follow Up: {question}\n" + "Standalone question:" + ) + + response_prompt = ( + "You are a clinical knowledge base assistant. Answer ONLY from the retrieved context provided.\n\n" + "Rules:\n" + "- If the question is not clinical or medical, respond exactly: 'This assistant only answers clinical and medical questions from the knowledge base.'\n" + "- If the answer is not present in the context, respond exactly: 'No matching information found in the knowledge base. Try approving relevant SOAP notes or uploading clinical guidelines.'\n" + "- Never use outside knowledge. If context does not contain the answer, use the fallback above.\n" + "- Give a direct, factual answer in 1-3 sentences. No preamble, no apologies, no 'unfortunately'.\n" + "- Do not include source tags in your answer — sources are shown separately.\n\n" + "Context: {context}\n\n" + "Question: {question}\n\n" + "Answer:" + ) + + chat_node = _chat_node(chat_id, {"x": 100, "y": 100}, soap_temp=0.3) + embed_node = _embed_node(embed_id, {"x": 100, "y": 500}) + + chroma_qa_input_params = [ + {"label": "Document", "name": "document", "type": "Document", "list": True, "optional": True}, + {"label": "Embeddings", "name": "embeddings", "type": "Embeddings"}, + {"label": "Record Manager", "name": "recordManager", "type": "RecordManager", "optional": True}, + {"label": "Collection Name", "name": "collectionName", "type": "string"}, + {"label": "Chroma URL", "name": "chromaURL", "type": "string", "optional": True}, + {"label": "Chroma Metadata Filter", "name": "chromaMetadataFilter", "type": "json", "optional": True, "additionalParams": True}, + {"label": "Top K", "name": "topK", "type": "number", "optional": True, "additionalParams": True}, + ] + + chroma_node = { + "id": chroma_id, + "type": "genericNode", + "position": {"x": 500, "y": 500}, + "data": { + "id": chroma_id, + "label": "Chroma", + "name": "chroma", + "version": 1, + "type": "Chroma", + "baseClasses": ["Chroma", "VectorStoreRetriever", "BaseRetriever"], + "category": "Vector Stores", + "inputs": { + "embeddings": f"{{{{{embed_id}.data.instance}}}}", + "collectionName": "clinical_kb", + "chromaURL": chroma_url, + "topK": 8, + }, + "inputParams": chroma_qa_input_params, + "outputs": {"output": "retriever"}, + "selected": False, + }, + } + + memory_input_params = [ + {"label": "Session Id", "name": "sessionId", "type": "string", "optional": True, "description": "If not specified, a random id will be used"}, + {"label": "Memory Key", "name": "memoryKey", "type": "string", "default": "chat_history"}, + ] + + memory_node = { + "id": memory_id, + "type": "genericNode", + "position": {"x": 100, "y": 300}, + "data": { + "id": memory_id, + "label": "Buffer Memory", + "name": "bufferMemory", + "version": 2, + "type": "BufferMemory", + "baseClasses": ["BufferMemory", "BaseChatMemory", "BaseMemory"], + "category": "Memory", + "inputs": { + "sessionId": "", + "memoryKey": "chat_history", + }, + "inputParams": memory_input_params, + "outputs": {}, + "selected": False, + }, + } + + chain_input_params = [ + {"label": "Chat Model", "name": "model", "type": "BaseChatModel"}, + {"label": "Vector Store Retriever", "name": "vectorStoreRetriever", "type": "BaseRetriever"}, + {"label": "Memory", "name": "memory", "type": "BaseMemory", "optional": True}, + {"label": "Return Source Documents","name": "returnSourceDocuments", "type": "boolean", "optional": True}, + {"label": "Rephrase Prompt", "name": "rephrasePrompt", "type": "string", "optional": True, "additionalParams": True}, + {"label": "Response Prompt", "name": "responsePrompt", "type": "string", "optional": True, "additionalParams": True}, + ] + + chain_node = { + "id": chain_id, + "type": "genericNode", + "position": {"x": 500, "y": 100}, + "data": { + "id": chain_id, + "label": "Conversational Retrieval QA Chain", + "name": "conversationalRetrievalQAChain", + "version": 3, + "type": "ConversationalRetrievalQAChain", + "baseClasses": ["ConversationalRetrievalQAChain", "BaseChain", "Runnable"], + "category": "Chains", + "inputs": { + "model": f"{{{{{chat_id}.data.instance}}}}", + "vectorStoreRetriever": f"{{{{{chroma_id}.data.instance}}}}", + "memory": f"{{{{{memory_id}.data.instance}}}}", + "returnSourceDocuments": True, + "rephrasePrompt": rephrase_prompt, + "responsePrompt": response_prompt, + }, + "inputParams": chain_input_params, + "outputs": {}, + "selected": False, + }, + } + + nodes = [chat_node, embed_node, chroma_node, memory_node, chain_node] + edges = [ + { + "id": f"e_{chat_id}_{chain_id}", + "source": chat_id, + "target": chain_id, + "sourceHandle": _chat_source_handle(chat_id), + "targetHandle": f"{chain_id}-input-model-BaseChatModel", + "type": "buttonedge", + }, + { + "id": f"e_{embed_id}_{chroma_id}", + "source": embed_id, + "target": chroma_id, + "sourceHandle": _embed_source_handle(embed_id), + "targetHandle": f"{chroma_id}-input-embeddings-Embeddings", + "type": "buttonedge", + }, + { + "id": f"e_{chroma_id}_{chain_id}", + "source": chroma_id, + "target": chain_id, + "sourceHandle": f"{chroma_id}-output-retriever-Chroma|VectorStoreRetriever|BaseRetriever", + "targetHandle": f"{chain_id}-input-vectorStoreRetriever-BaseRetriever", + "type": "buttonedge", + }, + { + "id": f"e_{memory_id}_{chain_id}", + "source": memory_id, + "target": chain_id, + "sourceHandle": f"{memory_id}-output-bufferMemory-BufferMemory|BaseChatMemory|BaseMemory", + "targetHandle": f"{chain_id}-input-memory-BaseMemory", + "type": "buttonedge", + }, + ] + + return {"nodes": nodes, "edges": edges} + + +def _build_upsert_flow_data() -> dict: + """ + KB Upsert flow — PlainText Document Loader → Chroma (upsert mode) ← Embeddings + + PlainText ──► Chroma ◄── Embeddings + │ + ▼ + (writes to clinical_kb collection) + """ + plaintext_id = _make_id("plaintext") + embed_id = _make_id("embed") + chroma_id = _make_id("chroma") + + chroma_url = f"http://{config.CHROMA_HOST}:{config.CHROMA_PORT}" + + embed_node = _embed_node(embed_id, {"x": 100, "y": 400}) + + # inputParams must mirror the node class's inputs array. + # The Flowise upsert handler iterates nodeData.inputs and calls + # nodeData.inputParams.find(...) — if inputParams is absent the call crashes. + # The Flowise UI populates this automatically; the provisioner must do it explicitly. + plaintext_input_params = [ + {"label": "Text", "name": "text", "type": "string", "rows": 4}, + {"label": "Text Splitter", "name": "textSplitter", "type": "TextSplitter", "optional": True}, + {"label": "Additional Metadata", "name": "metadata", "type": "json", "optional": True, "additionalParams": True}, + {"label": "Omit Metadata Keys", "name": "omitMetadataKeys", "type": "string", "optional": True, "additionalParams": True}, + ] + + plaintext_node = { + "id": plaintext_id, + "type": "genericNode", + "position": {"x": 100, "y": 100}, + "data": { + "id": plaintext_id, + "label": "Plain Text", + "name": "plainText", + "version": 2, + "type": "Document", + "baseClasses": ["Document"], + "category": "Document Loaders", + # text is overridden at upsert time via overrideConfig.text + "inputs": {"text": "", "metadata": ""}, + # inputParams mirrors the node class inputs array required by Flowise + "inputParams": plaintext_input_params, + "outputAnchors": [{"id": f"{plaintext_id}-output-document-Document", "name": "document", "label": "Document", "description": "Array of document objects", "type": "Document"}], + "outputs": {"output": "document"}, + "selected": False, + }, + } + + chroma_input_params = [ + {"label": "Document", "name": "document", "type": "Document", "list": True, "optional": True}, + {"label": "Embeddings", "name": "embeddings", "type": "Embeddings"}, + {"label": "Record Manager", "name": "recordManager", "type": "RecordManager", "optional": True}, + {"label": "Collection Name", "name": "collectionName", "type": "string"}, + {"label": "Chroma URL", "name": "chromaURL", "type": "string", "optional": True}, + {"label": "Chroma Metadata Filter", "name": "chromaMetadataFilter", "type": "json", "optional": True, "additionalParams": True}, + {"label": "Top K", "name": "topK", "type": "number", "optional": True, "additionalParams": True}, + ] + + chroma_node = { + "id": chroma_id, + "type": "genericNode", + "position": {"x": 500, "y": 200}, + "data": { + "id": chroma_id, + "label": "Chroma", + "name": "chroma", + "version": 2, + "type": "Chroma", + "baseClasses": ["Chroma", "VectorStoreRetriever", "BaseRetriever"], + "category": "Vector Stores", + "inputs": { + # Flowise resolves node-to-node connections via {{nodeId.data.instance}} syntax + "document": f"{{{{{plaintext_id}.data.instance}}}}", + "embeddings": f"{{{{{embed_id}.data.instance}}}}", + "collectionName": "clinical_kb", + "chromaURL": chroma_url, + }, + "inputParams": chroma_input_params, + "outputs": {"output": "retriever"}, + "selected": False, + }, + } + + nodes = [plaintext_node, embed_node, chroma_node] + edges = [ + { + "id": f"e_{plaintext_id}_{chroma_id}", + "source": plaintext_id, + "target": chroma_id, + "sourceHandle": f"{plaintext_id}-output-document-Document", + "targetHandle": f"{chroma_id}-input-document-Document", + "type": "buttonedge", + }, + { + "id": f"e_{embed_id}_{chroma_id}", + "source": embed_id, + "target": chroma_id, + "sourceHandle": _embed_source_handle(embed_id), + "targetHandle": f"{chroma_id}-input-embeddings-Embeddings", + "type": "buttonedge", + }, + ] + + return {"nodes": nodes, "edges": edges} + + +# ── Public API ────────────────────────────────────────────────────────────── + +def provision() -> bool: + """ + Called once at FastAPI startup. + 1. Wait for Flowise + 2. Provision API credentials for the active LLM provider + 3. Create or update both flows with current .env values + """ + global _provisioned + + if not _wait_for_flowise(): + logger.error("Flowise did not become ready — skipping flow provisioning") + return False + + logger.info("Provisioning flows — Ollama model=%s embed=%s", config.OLLAMA_MODEL, config.OLLAMA_EMBED_MODEL) + + try: + # flows + existing = _list_chatflows() + existing_by_name = {f.get("name"): f.get("id") for f in existing} + + # SOAP Generator + soap_data = _build_soap_flow_data() + if config.SOAP_FLOW_NAME in existing_by_name: + config.SOAP_FLOW_ID = existing_by_name[config.SOAP_FLOW_NAME] + _update_chatflow(config.SOAP_FLOW_ID, config.SOAP_FLOW_NAME, soap_data) + logger.info("SOAP flow updated: %s", config.SOAP_FLOW_ID) + else: + config.SOAP_FLOW_ID = _create_chatflow(config.SOAP_FLOW_NAME, soap_data) + logger.info("SOAP flow created: %s", config.SOAP_FLOW_ID) + + # Clinical QA + qa_data = _build_qa_flow_data() + if config.QA_FLOW_NAME in existing_by_name: + config.QA_FLOW_ID = existing_by_name[config.QA_FLOW_NAME] + _update_chatflow(config.QA_FLOW_ID, config.QA_FLOW_NAME, qa_data) + logger.info("QA flow updated: %s", config.QA_FLOW_ID) + else: + config.QA_FLOW_ID = _create_chatflow(config.QA_FLOW_NAME, qa_data) + logger.info("QA flow created: %s", config.QA_FLOW_ID) + + # KB Upsert (PlainText → Chroma) — separate from QA flow + upsert_data = _build_upsert_flow_data() + if config.UPSERT_FLOW_NAME in existing_by_name: + config.UPSERT_FLOW_ID = existing_by_name[config.UPSERT_FLOW_NAME] + _update_chatflow(config.UPSERT_FLOW_ID, config.UPSERT_FLOW_NAME, upsert_data) + logger.info("Upsert flow updated: %s", config.UPSERT_FLOW_ID) + else: + config.UPSERT_FLOW_ID = _create_chatflow(config.UPSERT_FLOW_NAME, upsert_data) + logger.info("Upsert flow created: %s", config.UPSERT_FLOW_ID) + + _provisioned = True + return True + + except Exception as e: + logger.error("Flow provisioning failed: %s", e, exc_info=True) + return False + + +def is_provisioned() -> bool: + return _provisioned and bool(config.SOAP_FLOW_ID) and bool(config.QA_FLOW_ID) and bool(config.UPSERT_FLOW_ID) diff --git a/api/services/llm_client.py b/api/services/llm_client.py new file mode 100644 index 0000000..f325955 --- /dev/null +++ b/api/services/llm_client.py @@ -0,0 +1,179 @@ +""" +llm_client.py +Ollama client for SOAP note generation, speaker diarization, billing code suggestion, +clinical QA, and text embedding. + +Ollama runs on the host machine and is reachable from Docker containers +via host.docker.internal. +""" + +import json +import logging + +import httpx + +import config + +logger = logging.getLogger(__name__) + +# ── Timeouts ──────────────────────────────────────────────────────────────── +_CHAT_TIMEOUT = 600.0 # seconds — 10 min budget for llama3.1:8b on CPU (2-3 min per call) +_EMBED_TIMEOUT = 30.0 + +# ── System prompts ────────────────────────────────────────────────────────── + +SOAP_SYSTEM_PROMPT = """\ +You are a clinical documentation specialist. +You will receive a diarized doctor-patient consultation transcript +where speakers are labeled Doctor and Patient. + +Generate a structured SOAP note as valid JSON with EXACTLY these keys: + chief_complaint, subjective, objective, assessment, plan, keywords + +STRICT OUTPUT RULES — read carefully: +- Every field except keywords MUST be a plain STRING (not an object, not an array). +- Write each field as flowing prose sentences, NOT as JSON sub-objects or lists. +- keywords MUST be an object with three flat string arrays. + +STRICT CONTENT RULES: +- Use ONLY information explicitly stated in the transcript. Do not invent vitals, medications, or findings not mentioned. +- If objective findings (BP, exam results) were not mentioned in the transcript, write "Not documented in this consultation." +- If a field has no information from the transcript, write "Not reported." + +OUTPUT FORMAT — return valid JSON matching this exact structure: +{ + "chief_complaint": "One sentence summarising the patient's main complaint from the transcript.", + "subjective": "Prose paragraph summarising what the patient reported: symptoms, duration, severity, history.", + "objective": "Prose paragraph of clinician findings from the transcript: vitals, exam findings, test results. If none mentioned, write: Not documented in this consultation.", + "assessment": "Prose paragraph with clinical impression and differential diagnoses with ICD-10 codes where applicable.", + "plan": "Prose paragraph with treatment, tests ordered, medications, and follow-up instructions.", + "keywords": { + "symptoms": ["list", "of", "symptoms"], + "medications": ["list", "of", "medications"], + "diagnoses": ["list", "of", "diagnoses"] + } +} + +Use proper medical terminology. Return ONLY valid JSON — no markdown fences, no explanation.""" + + +BILLING_SYSTEM_PROMPT = """\ +You are an expert medical coder. Review the provided SOAP note and suggest +1-3 appropriate CPT codes (procedures/E&M visits) and 1-3 ICD-10 codes (diagnoses). + +Return ONLY valid JSON matching this exact schema — no markdown, no explanation: +{ + "cpt": [{"code": "string", "description": "string"}], + "icd10": [{"code": "string", "description": "string"}] +}""" + + +QA_SYSTEM_PROMPT = """\ +You are a clinical knowledge base assistant. You answer ONLY from the retrieved context provided. + +Rules: +- If the question is not clinical or medical, respond exactly: "This assistant only answers clinical and medical questions from the knowledge base." +- If the answer is not present in the context, respond exactly: "No matching information found in the knowledge base. Try approving relevant SOAP notes or uploading clinical guidelines." +- Never use outside knowledge. If context does not contain the answer, use the fallback above. +- Give a direct, factual answer in 1-3 sentences. No preamble, no apologies, no "unfortunately". +- Do not include source tags in your answer — sources are shown separately.""" + + +DIARIZATION_SYSTEM_PROMPT = """\ +You are a medical AI assistant. You are given a transcript of a clinical visit broken into numbered audio segments. + +Reconstruct the dialogue: determine if each segment is spoken by the "Doctor" or "Patient" based on context. +Combine adjacent segments from the same speaker into a single utterance. + +Rules: +- The Doctor always speaks first (greeting/opening). +- Doctor: greetings, clinical questions, exam findings (BP, heart sounds), diagnoses, test orders, instructions. +- Patient: symptom descriptions, answering questions, personal/family history, own medications, concern/worry. +- Combine consecutive segments from the same speaker into one utterance with combined text. +- Use the start time of the first segment in each combined group as the utterance start. + +You MUST respond with ONLY a valid JSON array — no markdown, no explanation: +[{"speaker": "Doctor"|"Patient", "text": "combined text of all segments in this turn", "start": 0.0}]""" + + +# ── Public API ────────────────────────────────────────────────────────────── + +def generate_soap(transcript: str) -> str: + """ + Generate a SOAP note JSON string from a diarized transcript. + Returns raw JSON string. Raises on failure. + """ + logger.info("Generating SOAP note via Ollama model=%s", config.OLLAMA_MODEL) + return _chat(transcript, SOAP_SYSTEM_PROMPT) + + +def generate_billing_codes(soap_json: str) -> str: + """ + Generate ICD-10 and CPT codes from a SOAP note JSON string. + Returns raw JSON string. Raises on failure. + """ + logger.info("Generating billing codes via Ollama model=%s", config.OLLAMA_MODEL) + return _chat(soap_json, BILLING_SYSTEM_PROMPT) + + +def answer_question(context: str, question: str) -> str: + """ + Answer a clinical question using retrieved context from the knowledge base. + Returns the answer string. Raises on failure. + """ + logger.info("Answering clinical question via Ollama model=%s", config.OLLAMA_MODEL) + user_message = f"Context from knowledge base:\n\n{context}\n\nQuestion: {question}" + return _chat(user_message, QA_SYSTEM_PROMPT) + + +def diarize_segments(segments_text: str) -> str: + """ + Classify Whisper segments into Doctor/Patient turns. + segments_text: formatted string of numbered segments. + Returns raw JSON array string. Raises on failure. + Uses num_predict=1024 to prevent truncation of the JSON array output. + """ + logger.info("Diarizing segments via Ollama model=%s", config.OLLAMA_MODEL) + return _chat(segments_text, DIARIZATION_SYSTEM_PROMPT, num_predict=1024) + + +def embed(texts: list[str]) -> list[list[float]]: + """ + Generate embeddings via Ollama embeddings endpoint. + Returns list of float vectors, one per input text. + """ + url = f"{config.OLLAMA_BASE_URL}/api/embed" + payload = {"model": config.OLLAMA_EMBED_MODEL, "input": texts} + with httpx.Client(timeout=_EMBED_TIMEOUT) as client: + r = client.post(url, json=payload) + r.raise_for_status() + data = r.json() + # Ollama /api/embed returns {"embeddings": [[...]]} + return data["embeddings"] + + +# ── Internal ──────────────────────────────────────────────────────────────── + +def _chat(user_message: str, system_prompt: str, num_predict: int = -1) -> str: + """ + Call Ollama /api/chat (non-streaming) and return the assistant content string. + num_predict: max tokens to generate. -1 = model default (unlimited). + """ + url = f"{config.OLLAMA_BASE_URL}/api/chat" + payload = { + "model": config.OLLAMA_MODEL, + "stream": False, + "options": {"temperature": 0.1, "num_predict": num_predict}, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message}, + ], + } + with httpx.Client(timeout=_CHAT_TIMEOUT) as client: + r = client.post(url, json=payload) + r.raise_for_status() + data = r.json() + content = data.get("message", {}).get("content", "") + if not content: + raise ValueError("Ollama returned empty content") + return content diff --git a/api/services/pdf_service.py b/api/services/pdf_service.py new file mode 100644 index 0000000..feed806 --- /dev/null +++ b/api/services/pdf_service.py @@ -0,0 +1,26 @@ +import os +import config + + +def validate_pdf_file(filename: str, file_size: int, max_size: int = None) -> None: + if max_size is None: + max_size = config.MAX_FILE_SIZE + ext = os.path.splitext(filename)[1].lower() + if ext not in config.ALLOWED_PDF_EXTENSIONS: + raise ValueError(f"Invalid file type '{ext}'. Only PDF files are accepted.") + if file_size > max_size: + raise ValueError( + f"File too large ({file_size / 1024 / 1024:.1f} MB). " + f"Maximum allowed size is {max_size / 1024 / 1024:.0f} MB." + ) + + +def extract_text_from_pdf(file_path: str) -> str: + from pypdf import PdfReader + reader = PdfReader(file_path) + pages = [] + for page in reader.pages: + text = page.extract_text() + if text: + pages.append(text.strip()) + return "\n\n".join(pages) diff --git a/api/services/whisper_client.py b/api/services/whisper_client.py new file mode 100644 index 0000000..3786185 --- /dev/null +++ b/api/services/whisper_client.py @@ -0,0 +1,251 @@ +import json +import logging +from typing import Optional +import httpx +import config +from models import DiarizedSegment + +logger = logging.getLogger(__name__) + + +class WhisperClient: + def __init__(self): + self.endpoint = config.WHISPER_ENDPOINT.rstrip("/") + # Detect which server type based on endpoint + # onerahmet/openai-whisper-asr-webservice uses /asr?output=json + # whisper.cpp binary uses /inference + self._is_asr_webservice = ( + "9000" in self.endpoint + or "medivault-whisper" in self.endpoint + ) + + def transcribe(self, audio_bytes: bytes, filename: str) -> tuple[str, list[DiarizedSegment]]: + if self._is_asr_webservice: + return self._transcribe_asr_webservice(audio_bytes, filename) + else: + return self._transcribe_whisper_cpp(audio_bytes, filename) + + def _transcribe_asr_webservice(self, audio_bytes: bytes, filename: str) -> tuple[str, list[DiarizedSegment]]: + """onerahmet/openai-whisper-asr-webservice — /asr endpoint with output=json.""" + url = f"{self.endpoint}/asr" + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "wav" + mime_map = { + "wav": "audio/wav", "mp3": "audio/mpeg", "m4a": "audio/mp4", + "ogg": "audio/ogg", "webm": "audio/webm", "flac": "audio/flac", + } + mime = mime_map.get(ext, "audio/wav") + + # output=json returns segments with start/end times — needed for LLM diarization + files = {"audio_file": (filename, audio_bytes, mime)} + params = {"output": "json", "encode": "true", "task": "transcribe", "language": "en"} + + logger.info("Transcribing via whisper-asr-webservice: %s (%d bytes)", filename, len(audio_bytes)) + with httpx.Client(timeout=600.0) as client: + response = client.post(url, files=files, params=params) + response.raise_for_status() + result = response.json() + + raw_segments = result.get("segments", []) + full_text = result.get("text", "").strip() + + if raw_segments: + segments = self._diarize_with_llm(raw_segments) + else: + segments = self._diarize_flat(full_text) + + return full_text, segments + + def _transcribe_whisper_cpp(self, audio_bytes: bytes, filename: str) -> tuple[str, list[DiarizedSegment]]: + """whisper.cpp server binary — /inference endpoint.""" + url = f"{self.endpoint}/inference" + files = {"file": (filename, audio_bytes, "audio/wav")} + data = {"response_format": "verbose_json"} + + logger.info("Transcribing via whisper.cpp: %s (%d bytes)", filename, len(audio_bytes)) + with httpx.Client(timeout=600.0) as client: + response = client.post(url, files=files, data=data) + response.raise_for_status() + result = response.json() + + raw_segments = result.get("segments", []) + full_text = result.get("text", "").strip() + + if raw_segments: + segments = self._diarize_with_llm(raw_segments) + else: + segments = self._diarize_flat(full_text) + + return full_text, segments + + def _diarize_with_llm(self, raw_segments: list[dict]) -> list[DiarizedSegment]: + """ + Format segments as numbered timestamped entries for LLM diarization + and send all at once to the LLM. + + The LLM returns utterances with speaker, combined text, and start timestamp. + Falls back to gap-heuristic if LLM fails. + """ + valid_segs = [s for s in raw_segments if s.get("text", "").strip()] + if not valid_segs: + full_text = " ".join(s.get("text", "") for s in raw_segments) + return self._diarize_flat(full_text) + + from services.llm_client import diarize_segments as llm_diarize + + lines = [] + for idx, seg in enumerate(valid_segs): + start = float(seg.get("start", 0.0)) + text = seg["text"].strip() + lines.append(f"[ID: {idx}] (Start: {start:.1f}s): {text}") + + segments_text = "\n".join(lines) + logger.info("LLM diarization: %d segments", len(valid_segs)) + + try: + raw_json = llm_diarize(segments_text) + + # Strip markdown fences if model wraps output + cleaned = raw_json.strip() + if cleaned.startswith("```"): + cleaned = cleaned.split("```", 2)[-1] if cleaned.count("```") >= 2 else cleaned + if "\n" in cleaned: + cleaned = cleaned.split("\n", 1)[1] + cleaned = cleaned.rstrip("`").strip() + + # Extract JSON array + start_idx = cleaned.find("[") + end_idx = cleaned.rfind("]") + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + cleaned = cleaned[start_idx: end_idx + 1] + + utterances = json.loads(cleaned) + if not isinstance(utterances, list): + raise ValueError("Expected JSON array") + + all_turns: list[DiarizedSegment] = [] + for utt in utterances: + speaker = utt.get("speaker", "") + text = utt.get("text", "").strip() + start_s = float(utt.get("start", 0.0)) + if speaker in ("Doctor", "Patient") and text: + all_turns.append(DiarizedSegment( + speaker=speaker, + text=text, + start_ms=int(start_s * 1000), + )) + + if all_turns: + all_turns = self._correct_diarization(all_turns) + logger.info("LLM diarization complete: %d turns", len(all_turns)) + return all_turns + + raise ValueError("LLM returned empty utterances list") + + except Exception as exc: + logger.warning("LLM diarization failed (%s) — falling back to gap-heuristic", exc) + return self._diarize_gap(raw_segments) + + def _correct_diarization(self, turns: list[DiarizedSegment]) -> list[DiarizedSegment]: + """ + Post-processing: only enforce that Doctor speaks first. + If the LLM assigned Patient to turn 0, swap ALL speakers globally. + No run-length correction — consecutive same-speaker turns are valid + (e.g. doctor narrating exam findings across multiple turns). + """ + if not turns: + return turns + + if turns[0].speaker == "Patient": + logger.info("Diarization correction: turn 0 was Patient — swapping all speakers") + opposite = {"Doctor": "Patient", "Patient": "Doctor"} + turns = [ + DiarizedSegment(speaker=opposite[t.speaker], text=t.text, start_ms=t.start_ms) + for t in turns + ] + + return turns + + def _diarize_gap(self, raw_segments: list[dict]) -> list[DiarizedSegment]: + """ + Gap-heuristic fallback diarization. + Assigns speakers based on silence gaps (>1200 ms = speaker change). + Used only when LLM diarization fails. + """ + diarized = [] + current_speaker: Optional[str] = None + last_end_ms = 0 + + for seg in raw_segments: + start_ms = int(seg.get("start", 0) * 1000) + end_ms = int(seg.get("end", 0) * 1000) + text = seg.get("text", "").strip() + + if not text: + last_end_ms = end_ms + continue + + text_lower = text.lower() + if text_lower.startswith("doctor") or text_lower.startswith("(speaker ?) doctor"): + current_speaker = "Doctor" + for prefix in ["(speaker ?) doctor,", "(speaker ?) doctor:", "doctor,", "doctor:"]: + if text_lower.startswith(prefix): + text = text[len(prefix):].strip() + break + elif text_lower.startswith("patient") or text_lower.startswith("(speaker ?) patient"): + current_speaker = "Patient" + for prefix in ["(speaker ?) patient,", "(speaker ?) patient:", "patient,", "patient:"]: + if text_lower.startswith(prefix): + text = text[len(prefix):].strip() + break + else: + gap_ms = start_ms - last_end_ms + if current_speaker is None: + current_speaker = "Doctor" + elif gap_ms > 1200: + current_speaker = "Patient" if current_speaker == "Doctor" else "Doctor" + + if text: + diarized.append(DiarizedSegment( + speaker=current_speaker, + text=text, + start_ms=start_ms, + )) + last_end_ms = end_ms + + return diarized if diarized else self._diarize_flat( + " ".join(s.get("text", "") for s in raw_segments) + ) + + def _diarize_flat(self, text: str) -> list[DiarizedSegment]: + """Last-resort fallback: split full transcript into alternating speaker turns by sentence.""" + if not text.strip(): + return [] + sentences = [s.strip() for s in text.replace("?", "?.").replace("!", "!.").split(".") if s.strip()] + segments = [] + speaker = "Doctor" + for i, sentence in enumerate(sentences): + segments.append(DiarizedSegment(speaker=speaker, text=sentence, start_ms=i * 3000)) + if i % 2 == 1: + speaker = "Patient" if speaker == "Doctor" else "Doctor" + return segments + + def is_connected(self) -> bool: + try: + with httpx.Client(timeout=5.0) as client: + # asr-webservice: GET / returns 200 with API info + # whisper.cpp: GET /health returns 200 + check_url = self.endpoint + ("/" if self._is_asr_webservice else "/health") + response = client.get(check_url, follow_redirects=True) + return response.status_code in (200, 404) # 404 on /health is still "server up" + except Exception: + return False + + +_whisper_client: Optional[WhisperClient] = None + + +def get_whisper_client() -> WhisperClient: + global _whisper_client + if _whisper_client is None: + _whisper_client = WhisperClient() + return _whisper_client diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..a45d7a9 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,142 @@ +services: + medivault-api: + build: + context: ./api + dockerfile: Dockerfile + container_name: medivault-api + ports: + - "5001:5001" + env_file: + - .env + environment: + # Flowise + - FLOWISE_ENDPOINT=${FLOWISE_ENDPOINT:-http://medivault-flowise:3001} + - FLOWISE_API_KEY=${FLOWISE_API_KEY:-} + - SOAP_FLOW_ID=${SOAP_FLOW_ID:-} + - QA_FLOW_ID=${QA_FLOW_ID:-} + # Ollama — sole LLM provider (fully air-gapped, runs on host for GPU acceleration) + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.1:8b} + - OLLAMA_EMBED_MODEL=${OLLAMA_EMBED_MODEL:-nomic-embed-text} + # ChromaDB (Docker service — auto-managed) + - CHROMA_HOST=${CHROMA_HOST:-medivault-chromadb} + - CHROMA_PORT=${CHROMA_PORT:-8000} + # Whisper — Docker service (medivault-whisper) or host fallback + - WHISPER_ENDPOINT=${WHISPER_ENDPOINT:-http://host.docker.internal:8080} + # Limits + - MAX_AUDIO_SIZE=${MAX_AUDIO_SIZE:-52428800} + - MAX_FILE_SIZE=${MAX_FILE_SIZE:-10485760} + networks: + - medivault-network + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + - medivault-flowise + - medivault-whisper + - medivault-chromadb + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + medivault-ui: + build: + context: ./ui + dockerfile: Dockerfile + container_name: medivault-ui + ports: + - "3000:8080" + depends_on: + - medivault-api + networks: + - medivault-network + restart: unless-stopped + + medivault-chromadb: + image: chromadb/chroma:latest + container_name: medivault-chromadb + ports: + - "8100:8000" + volumes: + - chroma-data:/chroma/chroma + environment: + - IS_PERSISTENT=TRUE + - ANONYMIZED_TELEMETRY=FALSE + networks: + - medivault-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/localhost/8000 && echo -e \"GET /api/v2/heartbeat HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n\" >&3 && grep -q nanosecond <&3'"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + + medivault-flowise: + image: flowiseai/flowise:latest + container_name: medivault-flowise + ports: + - "3001:3001" + volumes: + - flowise-data:/root/.flowise + environment: + - PORT=3001 + networks: + - medivault-network + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/ping | grep -q pong"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + medivault-whisper: + # Whisper.cpp-based ASR — 4-8x faster than Python Whisper on CPU. + # Same /asr endpoint and request format — zero code changes needed. + # Model downloaded automatically on first run to the whisper-models volume. + # + # GPU (NVIDIA): change tag to :latest-gpu and add nvidia device reservations. + image: onerahmet/openai-whisper-asr-webservice:latest + container_name: medivault-whisper + ports: + - "9000:9000" + volumes: + - whisper-models:/root/.cache/whisper + environment: + # Model size: tiny | base | small | medium | large-v3 + # Recommended: small (good accuracy, ~460 MB, ~30-45s for 2-min audio on CPU) + - ASR_MODEL=${WHISPER_MODEL:-small} + - ASR_ENGINE=faster_whisper + networks: + - medivault-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -sf -o /dev/null -w '%{http_code}' http://localhost:9000/docs | grep -q 200 || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 300s + + # ── Host services (NOT in Docker) ────────────────────────────────────────── + # + # Ollama :11434 — LLM inference (runs on host for GPU acceleration) + # Install: https://ollama.com/download + # Models: ollama pull llama3.1:8b && ollama pull nomic-embed-text + +networks: + medivault-network: + driver: bridge + +volumes: + flowise-data: + chroma-data: + whisper-models: + # Persists downloaded Whisper model across container restarts. + # First run downloads ~460 MB (small) to ~3 GB (large-v3). + # Subsequent starts are instant. diff --git a/docs/assets/InnovationHub-HeaderImage.png b/docs/assets/InnovationHub-HeaderImage.png new file mode 100644 index 0000000..0558e37 Binary files /dev/null and b/docs/assets/InnovationHub-HeaderImage.png differ diff --git a/ui/.dockerignore b/ui/.dockerignore new file mode 100644 index 0000000..25e0123 --- /dev/null +++ b/ui/.dockerignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.git/ +.gitignore +*.log diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..09e201d --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,27 @@ +FROM node:18-alpine AS builder + +WORKDIR /app + +COPY package.json . +RUN npm install + +COPY . . +RUN npm run build + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +RUN addgroup -S appuser && adduser -S -G appuser -u 1000 appuser \ + && chown -R appuser:appuser /usr/share/nginx/html \ + && chown -R appuser:appuser /var/cache/nginx \ + && chown -R appuser:appuser /var/log/nginx \ + && touch /var/run/nginx.pid \ + && chown appuser:appuser /var/run/nginx.pid + +USER appuser + +EXPOSE 8080 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..f5d6577 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,17 @@ + + + + + + + + MediVault AI + + + + + +
+ + + diff --git a/ui/nginx.conf b/ui/nginx.conf new file mode 100644 index 0000000..ee28ffb --- /dev/null +++ b/ui/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + location /api/ { + rewrite ^/api/(.*) /$1 break; + proxy_pass http://medivault-api:5001; + 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_read_timeout 600s; + proxy_send_timeout 600s; + proxy_connect_timeout 60s; + client_max_body_size 55m; + # Stream large file uploads directly — don't buffer in nginx temp files + proxy_request_buffering off; + proxy_buffering off; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..ed77bf6 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,3251 @@ +{ + "name": "medivault-ai-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "medivault-ai-ui", + "version": "1.0.0", + "dependencies": { + "@xyflow/react": "^12.3.6", + "axios": "^1.7.9", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "vite": "^6.0.3" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@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/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/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-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "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-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "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/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "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.1", + "caniuse-lite": "^1.0.30001774", + "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/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "dev": true, + "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/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==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/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/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/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/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==", + "devOptional": true, + "license": "MIT" + }, + "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-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "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-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-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "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/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "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/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "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/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "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.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/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==", + "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==", + "license": "MIT", + "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==", + "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==", + "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/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/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/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/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/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/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "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==", + "dev": true, + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "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==", + "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==", + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.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-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-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/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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/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/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.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "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==", + "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/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/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==", + "dev": true, + "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/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==", + "dev": true, + "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/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/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-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==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "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/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/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/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/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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-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/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/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "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-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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.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/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/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/vite/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/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/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..d7f5f10 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,27 @@ +{ + "name": "medivault-ai-ui", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@xyflow/react": "^12.3.6", + "axios": "^1.7.9", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "vite": "^6.0.3" + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/ui/public/cloud2labs-logo.png b/ui/public/cloud2labs-logo.png new file mode 100644 index 0000000..2a0ef60 Binary files /dev/null and b/ui/public/cloud2labs-logo.png differ diff --git a/ui/src/App.jsx b/ui/src/App.jsx new file mode 100644 index 0000000..ee8633a --- /dev/null +++ b/ui/src/App.jsx @@ -0,0 +1,67 @@ +import { useState, useEffect } from 'react' +import Header from './components/Header' +import LandingPage from './components/LandingPage' +import ConsultationRecorder from './components/ConsultationRecorder' +import ClinicalChat from './components/ClinicalChat' +import KnowledgeBase from './components/KnowledgeBase' +import { Mic, MessageSquare, Database } from 'lucide-react' + +const TABS = [ + { id: 'record', label: 'SOAP Notes', icon: Mic }, + { id: 'chat', label: 'Clinical QA', icon: MessageSquare }, + { id: 'knowledge', label: 'Knowledge Base', icon: Database }, +] + +function App() { + const [darkMode, setDarkMode] = useState(() => { + const saved = localStorage.getItem('darkMode') + return saved !== null ? JSON.parse(saved) : true + }) + const [launched, setLaunched] = useState(false) + const [activeTab, setActiveTab] = useState('record') + + useEffect(() => { + localStorage.setItem('darkMode', JSON.stringify(darkMode)) + if (darkMode) { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + }, [darkMode]) + + if (!launched) { + return setLaunched(true)} /> + } + + return ( +
+
setDarkMode(d => !d)} onHome={() => setLaunched(false)} /> + +
+
+ {TABS.map(({ id, label, icon: Icon }) => ( + + ))} +
+ + {/* Keep all tabs mounted to preserve state when switching — only hide inactive ones */} +
+
+
+
+
+ ) +} + +export default App diff --git a/ui/src/components/AudioRecorder.jsx b/ui/src/components/AudioRecorder.jsx new file mode 100644 index 0000000..756aa0c --- /dev/null +++ b/ui/src/components/AudioRecorder.jsx @@ -0,0 +1,175 @@ +import { useState, useRef } from 'react' +import axios from 'axios' +import { Mic, MicOff, Upload, Square, Loader2 } from 'lucide-react' +import SoapNoteEditor from './SoapNoteEditor' +import StatusBadge from './StatusBadge' + +const API_URL = import.meta.env.VITE_API_URL || '/api' + +const SPECIALTIES = ['general', 'emergency', 'cardiology', 'pediatrics', 'psychiatry', 'orthopedics', 'dermatology'] + +export default function AudioRecorder() { + const [mode, setMode] = useState('upload') + const [specialty, setSpecialty] = useState('general') + const [recording, setRecording] = useState(false) + const [status, setStatus] = useState('idle') + const [transcript, setTranscript] = useState('') + const [soap, setSoap] = useState(null) + const [error, setError] = useState('') + + const mediaRef = useRef(null) + const chunksRef = useRef([]) + const fileInputRef = useRef(null) + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const recorder = new MediaRecorder(stream) + chunksRef.current = [] + recorder.ondataavailable = e => chunksRef.current.push(e.data) + recorder.onstop = () => processAudioBlob(new Blob(chunksRef.current, { type: 'audio/webm' }), 'recording.webm') + recorder.start() + mediaRef.current = recorder + setRecording(true) + setError('') + } catch (e) { + setError('Microphone access denied.') + } + } + + const stopRecording = () => { + mediaRef.current?.stop() + mediaRef.current?.stream?.getTracks().forEach(t => t.stop()) + setRecording(false) + } + + const handleFileUpload = (e) => { + const file = e.target.files?.[0] + if (file) processAudioBlob(file, file.name) + } + + const handleDrop = (e) => { + e.preventDefault() + const file = e.dataTransfer.files?.[0] + if (file) processAudioBlob(file, file.name) + } + + const processAudioBlob = async (blob, filename) => { + setStatus('loading') + setTranscript('') + setSoap(null) + setError('') + + try { + const formData = new FormData() + formData.append('file', blob, filename) + + const transcribeRes = await axios.post(`${API_URL}/transcribe`, formData) + const text = transcribeRes.data.transcript + setTranscript(text) + + const soapRes = await axios.post(`${API_URL}/generate-soap`, { + transcript: text, + specialty, + }) + setSoap(soapRes.data.soap) + setStatus('success') + } catch (e) { + setError(e.response?.data?.detail || 'Processing failed.') + setStatus('error') + } + } + + return ( +
+
+
+

Audio → SOAP Note

+ +
+ +
+
+ {['upload', 'record'].map(m => ( + + ))} +
+ + +
+ + {mode === 'upload' ? ( +
e.preventDefault()} + onClick={() => fileInputRef.current?.click()} + className="border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors dark:border-slate-700/50 dark:hover:border-purple-500/50 border-gray-200 hover:border-purple-400" + > + +

Drop audio file or click to browse

+

WAV · MP3 · M4A · OGG · WEBM · FLAC — max 50 MB

+ +
+ ) : ( +
+ +

+ {recording ? 'Recording… click to stop' : 'Click to start recording'} +

+
+ )} + + {error && ( +

{error}

+ )} +
+ + {status === 'loading' && ( +
+ +

Transcribing audio and generating SOAP note…

+
+ )} + + {transcript && soap && ( +
+
+

Transcript

+

{transcript}

+
+ +
+ )} +
+ ) +} diff --git a/ui/src/components/ClinicalChat.jsx b/ui/src/components/ClinicalChat.jsx new file mode 100644 index 0000000..32bc31f --- /dev/null +++ b/ui/src/components/ClinicalChat.jsx @@ -0,0 +1,163 @@ +import { useState, useRef, useEffect } from 'react' +import axios from 'axios' +import { Send, Loader2, BookOpen, FileText, ClipboardList, AlertTriangle } from 'lucide-react' + +const API_URL = import.meta.env.VITE_API_URL || '/api' + +function generateSessionId() { + return crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2) +} + +const DocTypeBadge = ({ type }) => { + const isSoap = type === 'soap_note' + return ( + + {isSoap ? : } + {isSoap ? 'SOAP Note' : 'Guideline'} + + ) +} + +export default function ClinicalChat() { + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [loading, setLoading] = useState(false) + const [sessionId] = useState(generateSessionId) + const bottomRef = useRef(null) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + const send = async () => { + if (!input.trim() || loading) return + const question = input.trim() + setInput('') + setMessages(prev => [...prev, { role: 'user', content: question, sources: [] }]) + setLoading(true) + try { + const res = await axios.post(`${API_URL}/chat`, { question, session_id: sessionId }) + setMessages(prev => [...prev, { + role: 'assistant', + content: res.data.answer, + sources: res.data.sources || [], + }]) + } catch (e) { + const status = e.response?.status + let userMessage = 'Something went wrong. Please try again.' + if (status === 503) { + userMessage = 'The AI service is temporarily unavailable. Please check that Flowise and the LLM provider are running.' + } else if (status === 500) { + userMessage = 'The AI could not process your question. Please try rephrasing or check the knowledge base has documents.' + } else if (status === 422) { + userMessage = 'Your question could not be processed. Please try again with a different phrasing.' + } else if (!e.response) { + userMessage = 'Cannot reach the MediVault API. Please check your connection.' + } + setMessages(prev => [...prev, { + role: 'assistant', + content: userMessage, + sources: [], + error: true, + }]) + } finally { + setLoading(false) + } + } + + return ( +
+
+

Clinical QA

+

+ RAG over clinical guidelines and approved SOAP notes — all inference runs offline +

+
+ +
+ {messages.length === 0 && ( +
+ +

Ask a clinical question about guidelines or past consultations

+
+ + +
+
+ )} + + {messages.map((msg, i) => ( +
+
+
+ {msg.content} +
+ + {msg.sources?.length > 0 && ( +
+ {msg.sources.map((src, j) => ( +
+ + + {src.document} + {src.chunk && — {src.chunk.slice(0, 100)}…} + +
+ ))} +
+ )} +
+
+ ))} + + {loading && ( +
+
+ +
+
+ )} +
+
+ +
+
+