diff --git a/.dockerignore b/.dockerignore index 73a6653..8ef0d5e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,3 +10,12 @@ tests/ docs/ *.md !README.md + +.medcheck/ +artifacts/ +output/ +data/ +scans/ +*.dcm +*.dicom +*.zip diff --git a/.env.example b/.env.example index 191052d..f297512 100644 --- a/.env.example +++ b/.env.example @@ -1,73 +1,51 @@ -# ============================================================================= -# MedCheck Configuration -# ============================================================================= -# Copy this file to .env and fill in your values: -# cp .env.example .env - -# ============================================================================= -# LLM API Keys (at least one required for Vision analysis) -# ============================================================================= - -# Anthropic - Claude Opus 4.8 (recommended, best quality) -# Get your key at: https://console.anthropic.com/settings/keys +# Copy to .env for Docker Compose. For a local shell, export the values explicitly. ANTHROPIC_API_KEY= - -# OpenAI - GPT-5.5 (best image resolution at 10.2MP) -# Get your key at: https://platform.openai.com/api-keys OPENAI_API_KEY= - -# Google - Gemini 3.5 Flash (fastest and cheapest) -# Get your key at: https://aistudio.google.com/apikey GOOGLE_API_KEY= -# ============================================================================= -# Server Configuration -# ============================================================================= - -# Bind address. Defaults to 127.0.0.1 (localhost only). Set to 0.0.0.0 to expose -# on the network — only do this behind a firewall/reverse proxy AND with -# MEDCHECK_API_KEY set, since this server handles patient PHI. +# Single-user local workbench; set a key before exposing the server to a network. MEDCHECK_HOST=127.0.0.1 MEDCHECK_PORT=8080 - -# Optional API key. When set, /api endpoints require a matching X-API-Key header. -# Strongly recommended whenever MEDCHECK_HOST is not 127.0.0.1. MEDCHECK_API_KEY= - -# Only when a trusted reverse proxy fronts the server: set to 1 so the rate -# limiter keys on the first X-Forwarded-For hop instead of the proxy's IP. -# Leave off otherwise — without a proxy the header is client-spoofable. MEDCHECK_TRUST_PROXY_HEADERS= +MEDCHECK_RATE_LIMIT=10 +MEDCHECK_LANGUAGE=en -# ============================================================================= -# Default Settings -# ============================================================================= +# Server paths are restricted to DATA_ROOT. Browser uploads use opaque IDs. +MEDCHECK_DATA_ROOT=./scans +MEDCHECK_STATE_DIR=./.medcheck +MEDCHECK_MAX_UPLOAD_BYTES=536870912 +MEDCHECK_MAX_DICOM_BYTES=536870912 +MEDCHECK_MAX_JOBS=20 +MEDCHECK_JOB_WORKERS=1 +MEDCHECK_MAX_DOWNLOAD_BYTES=2147483648 -# Default LLM provider: claude | openai | gemini -# Falls back automatically if the selected provider has no API key configured. -# Note: "local" (offline LLaVA-Med) is not yet implemented — see -# https://github.com/Liohtml/MedCheck/issues/18 +# CLI vision default; local statistical analysis needs no model or key. MEDCHECK_LLM_PROVIDER=claude - -# Default report language: en | de -MEDCHECK_LANGUAGE=en - -# Consent to sending patient-derived data to external cloud LLM APIs -# (Claude/GPT/Gemini). Off by default — vision analysis refuses to transmit -# until you opt in here, via --allow-cloud-llm, or the interactive prompt. MEDCHECK_ALLOW_EXTERNAL_LLM= - -# ============================================================================= -# Data Provider Configuration (optional) -# ============================================================================= - -# easyRadiology portal - no API key needed. -# Authentication uses the access code provided per exam. A date of birth may be -# requested by the portal but is NOT verified by MedCheck (--dob is optional). -# Example usage: -# medcheck analyze \ -# --source "https://portal.easyradiology.net/View/your-exam-hash" \ -# --code "A2C-AB3-4BC-1BC" -# -# The access code is provided by your radiology clinic -# (usually via SMS, email, or printed letter). +MEDCHECK_MAX_VISION_IMAGES=12 +MEDCHECK_LLM_TIMEOUT=120 +MEDCHECK_LLM_RETRIES=2 +# Model IDs are configurable; access/availability depends on your provider account. +MEDCHECK_CLAUDE_MODEL=claude-opus-4-8 +MEDCHECK_OPENAI_MODEL=gpt-5.5 +MEDCHECK_GEMINI_MODEL=gemini-3.5-flash + +# Optional preinstalled local vision server. Only loopback IP URLs accepted. +MEDCHECK_LOCAL_URL= +MEDCHECK_LOCAL_MODEL= +# Example: MEDCHECK_LOCAL_URL=http://127.0.0.1:11434/v1 +# Set MEDCHECK_LOCAL_MODEL to the exact model ID returned by that server. + +# Conservative per-analysis USD estimates supplied by the operator. +# Include selected image budget, tokens and retries. Empty means unknown. +# A user-entered budget blocks unknown/over-budget estimates, not provider billing. +MEDCHECK_CLAUDE_ESTIMATED_COST_USD= +MEDCHECK_OPENAI_ESTIMATED_COST_USD= +MEDCHECK_GEMINI_ESTIMATED_COST_USD= + +# Optional ResNet feature extraction for CLI; browser local mode uses statistics. +MEDCHECK_ML_DEVICE=cpu +MEDCHECK_ML_BATCH_SIZE=16 +# OCR: install medcheck[privacy] AND the local Tesseract executable. +# Explicit pixel review is still required before cloud transmission. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 563cc5c..a9c78f9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,7 @@ updates: directory: "/" schedule: interval: weekly + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0de821..b419af2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v7 - - run: uv sync --all-extras --locked + - run: uv sync --extra dev --locked - run: uv run ruff check src/ tests/ - run: uv run ruff format --check src/ tests/ @@ -25,7 +25,7 @@ jobs: steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v7 - - run: uv sync --all-extras --locked + - run: uv sync --extra dev --locked - run: uv run mypy src/medcheck security: @@ -33,7 +33,7 @@ jobs: steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v7 - - run: uv sync --all-extras --locked + - run: uv sync --extra dev --locked - run: uv run bandit -r src/medcheck -ll -q test: @@ -55,3 +55,54 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml + + provider-install: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - extra: claude + module: anthropic + - extra: openai + module: openai + - extra: gemini + module: google.genai + - extra: cloud + module: anthropic, openai, google.genai + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.10" + - run: uv sync --no-dev --extra ${{ matrix.extra }} --locked + - run: uv run --no-sync python -c "import ${{ matrix.module }}; import medcheck.llm.router" + + browser: + runs-on: ubuntu-latest + env: + MEDCHECK_API_KEY: browser-test-only + MEDCHECK_RATE_LIMIT: "0" + MEDCHECK_STATE_DIR: /tmp/medcheck-browser-state + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.13" + - run: uv sync --extra dev --locked + - run: uv run --with playwright playwright install --with-deps chromium + - name: Test the real browser workflow with synthetic DICOM + shell: bash + run: | + .venv/bin/medcheck serve --port 8765 > /tmp/medcheck-browser.log 2>&1 & + medcheck_server_pid=$! + trap 'kill "$medcheck_server_pid"' EXIT + for attempt in {1..30}; do + if curl --fail --silent http://127.0.0.1:8765/health; then break; fi + sleep 1 + done + uv run --with playwright python tests/browser/web_user_journey.py --api-key browser-test-only + - uses: actions/upload-artifact@v7 + if: always() + with: + name: browser-screenshots + path: artifacts/ui/ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5bada30..66a51e4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,7 +13,7 @@ jobs: security-events: write steps: - uses: actions/checkout@v7 - - uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4 + - uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: languages: python - - uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4 + - uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 diff --git a/.gitignore b/.gitignore index 4221e1f..ae1c3dc 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,8 @@ Thumbs.db # Report output (may contain patient PHI — never commit) output/ +.medcheck/ +artifacts/ # Logs *.log diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7310659..a678a02 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,8 @@ repos: - id: check-yaml - id: check-toml - id: end-of-file-fixer + # Preserve exact vendored bytes: index.html pins the script with SRI. + exclude: "^src/medcheck/web/static/htmx\\.min\\.js$" - id: trailing-whitespace - id: no-commit-to-branch args: [--branch, main] diff --git a/CHANGELOG.md b/CHANGELOG.md index f779036..871dca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Working browser upload/study/analysis workflow, bounded jobs, progress, cancellation, + durable reports and slice viewer, authenticated downloads and explicit cleanup. +- Finding image references, review edits/audit trail, local reference-report comparison, + preliminary FHIR R4 and DICOM Basic Text SR exports, regression evaluation CLI. +- Conservative metadata de-identification, explicit pixel-review gate, optional local + OCR/manual masks; known-identifier free-text cleanup without anonymity guarantees. +- DICOMDIR/single-image imports, UID study selection, geometry and quality checks. +- Configured loopback vision server and installable cloud/per-provider SDK extras. +- Browser user-journey tests across desktop/mobile, four UI locales and failure states. + +### Fixed +- Explicitly close viewer memory maps before deletion/shutdown, copy viewer slices + under the store lock, and retain failed deletions for retry instead of reporting + success while files remain on Windows. +- Series collisions, slice ordering/decoding, ResNet evaluation mode/batching, signal + metric semantics, unfair image allocation and overwritten processing warnings. +- Missing cloud SDKs; Gemini now uses google-genai. Docker preserves installed extras. +- Report filename collisions and PDF text escaping/wrapping; confidence is explicitly + uncalibrated and reviewed findings retain their original audit context. + +### Changed +- Docker dependency automation, leaner CI install checks, CodeQL 4.38.0 pins. +- Browser defaults to statistics without model downloads; cloud requires both consent + and pixel review. Unsupported multiframe/color data is reported instead of guessed. +- Research scope, model limitations, storage retention and costs documented explicitly. + ## [0.3.0] - 2026-07-02 ### Added diff --git a/Dockerfile b/Dockerfile index 7718950..75d98ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,20 +17,22 @@ COPY workflows/ workflows/ # === Lite (cloud APIs only, ~500MB) === FROM base AS lite -RUN uv sync --no-dev --locked --no-cache \ +RUN uv sync --no-dev --extra cloud --locked --no-cache \ + && mkdir -p /app/.medcheck \ && chown -R medcheck:medcheck /app EXPOSE 8080 ENV MEDCHECK_HOST=0.0.0.0 ENV MEDCHECK_PORT=8080 USER medcheck -CMD ["uv", "run", "medcheck", "serve"] +CMD ["/app/.venv/bin/medcheck", "serve"] # === Full (with local ML models, ~10GB) === FROM base AS full -RUN uv sync --no-dev --extra local-models --locked --no-cache \ +RUN uv sync --no-dev --extra cloud --extra local-models --locked --no-cache \ + && mkdir -p /app/.medcheck \ && chown -R medcheck:medcheck /app EXPOSE 8080 ENV MEDCHECK_HOST=0.0.0.0 ENV MEDCHECK_PORT=8080 USER medcheck -CMD ["uv", "run", "medcheck", "serve"] +CMD ["/app/.venv/bin/medcheck", "serve"] diff --git a/README.md b/README.md index de05916..036c005 100644 --- a/README.md +++ b/README.md @@ -30,249 +30,193 @@ and generate structured, radiology-style reports — from the CLI, a web UI, or ## Features -- **Plug & Play Docker** — single `docker run` command, no local setup required -- **Multiple data sources** — local DICOM folders/ZIPs, easyRadiology portal links, and custom plugins -- **Local ML analysis** — on-device anomaly detection and feature extraction; no API key required (one-time model download on first use, or pre-fetch with `medcheck download-models` for offline environments) -- **Vision-LLM analysis** — Claude Opus 4.8, GPT-5.5, and Gemini 3.5 Flash (opt-in, consent-gated) -- **Privacy by default** — nothing leaves your machine without explicit consent; `--deidentify` pseudonymizes reports -- **Clinical context input** — attach symptoms, trauma history, and suspected diagnosis to guide the analysis -- **Professional reports** — structured PDF/HTML/JSON with findings tables, impression, and limitations -- **YAML workflow engine** — compose and version-control custom analysis pipelines as code -- **Web UI + CLI + REST API** — scriptable CLI today; the 3-step browser wizard and the HTTP analysis endpoint are a preview — running an analysis from the browser/API is not yet wired up ([#157](https://github.com/Liohtml/MedCheck/issues/157)) - ---- +- DICOM folders, individual files, ZIP archives and DICOMDIR media; explicit study selection. +- Local image quality checks and relative image statistics without cloud keys or model downloads in the web workbench. +- Optional Claude, OpenAI, Gemini or user-managed local vision inference. +- Browser uploads, background progress, cancellation, slice viewer and downloadable reports. +- Findings review with an audit trail, analysis provenance and reference-report text comparison. +- JSON, HTML, PDF, preliminary FHIR DiagnosticReport and unverified DICOM SR exports. +- Metadata de-identification, explicit pixel review and optional local OCR masking. +- YAML workflows and saved-report regression evaluation against independent reference labels. ## Quick Start -### Option 1 — Docker (recommended, ~1 minute) +### From this checkout ```bash -docker run -p 8080:8080 \ - -e ANTHROPIC_API_KEY=your_key_here \ - -v $(pwd)/scans:/data/scans \ - ghcr.io/liohtml/medcheck:latest +git clone https://github.com/Liohtml/MedCheck.git +cd MedCheck +uv sync +uv run medcheck serve ``` -Open [http://localhost:8080](http://localhost:8080) to browse the web UI (preview). Note that -**running an analysis from the browser is not yet available** — the wizard's Analyze step -returns `501 Not Implemented` until [#157](https://github.com/Liohtml/MedCheck/issues/157) -lands. To run an analysis today, use the CLI inside the container: +Open [http://localhost:8080](http://localhost:8080). Upload a DICOM ZIP or individual +DICOM file, inspect the study and run a local analysis. No API key is required. +The local statistics mode generates no diagnostic findings. For cloud vision, +install the corresponding SDK first: ```bash -docker run --rm \ - -v $(pwd)/scans:/data/scans \ - ghcr.io/liohtml/medcheck:latest \ - uv run medcheck analyze /data/scans +uv sync --extra cloud # or --extra claude, --extra openai, --extra gemini ``` -### Option 2 — pip install +### Docker -```bash -pip install medcheck -medcheck serve # web UI on http://localhost:8080 -``` - -### Option 3 — From source +Build the current checkout, then bind the published port to localhost: ```bash -git clone https://github.com/Liohtml/MedCheck.git -cd MedCheck -uv sync -uv run medcheck serve +docker build --target lite -t medcheck:lite . +docker run --rm -p 127.0.0.1:8080:8080 \ + -v medcheck-state:/app/.medcheck medcheck:lite ``` -### Your first analysis (CLI) +The lite image includes cloud SDKs and local statistics. The `full` target adds +PyTorch/torchvision for feature extraction; it does not bundle a vision language +model. Keep the state volume to retain jobs and reports between container runs. +See [Quick Start](docs/quickstart.md) and [Workbench](docs/workbench.md). -```bash -# Fully local, no API key needed (ML analysis + JSON report): -medcheck analyze ./my-dicom-folder --steps ingest,preprocess,ml_analysis,report - -# Full analysis with a cloud Vision-LLM (requires a key + explicit consent): -medcheck analyze ./my-dicom-folder \ - --model claude --allow-cloud-llm \ - --symptoms "Medial knee pain after sports injury" \ - --report pdf --lang en +### CLI -# Not sure what to type? Let MedCheck ask you: -medcheck analyze ./my-dicom-folder --interactive +```bash +# Local image statistics and JSON report: +uv run medcheck analyze ./my-dicom-folder \ + --steps ingest,preprocess,ml_analysis,report --report json --deidentify + +# Cloud vision: install the SDK, set ANTHROPIC_API_KEY, review the input, +# then explicitly permit transmission: +uv run medcheck analyze ./my-dicom-folder \ + --model claude --allow-cloud-llm --pixels-reviewed --deidentify \ + --symptoms "Medial knee pain after sports injury" --report pdf --lang en + +# Prompt for missing inputs: +uv run medcheck analyze ./my-dicom-folder --interactive ``` -Reports land in `./output/` (they contain patient data unless you pass `--deidentify` — see [Privacy & Security](#privacy--security)). +Reports are written to `./output/`. `--deidentify` reduces identifying metadata; +it does not guarantee anonymous pixels or free text. ---- +## How it works -## How It Works - -``` -┌─────────┐ ┌────────────┐ ┌────────────┐ ┌───────────┐ ┌────────┐ -│ Ingest │───▶│ Preprocess │───▶│ ML Analyze │───▶│ Vision AI │───▶│ Report │ -│ │ │ │ │ │ │ │ │ │ -│ DICOM / │ │ Normalize │ │ LLaVA-Med │ │ Claude / │ │ PDF / │ -│ easyRad │ │ Resize │ │ MONAI │ │ GPT / │ │ HTML │ -│ Plugins │ │ Anonymize │ │ Anomaly │ │ Gemini │ │ + PNG │ -└─────────┘ └────────────┘ └────────────┘ └───────────┘ └────────┘ -``` +1. Load and select a study using DICOM study and series identifiers. +2. Optionally remove identifying metadata and remap instance identifiers. +3. Check image geometry, normalize pixels and assemble volumes. +4. Compute relative image statistics or optional image features. +5. Optionally select images across series for a chosen vision provider. +6. Record provenance, limitations and findings in a research report. -1. **Ingest** — load studies from local paths, the easyRadiology portal, or third-party plugins. -2. **Preprocess** — normalize pixel values, detect anatomy/planes, build volumes. -3. **ML Analyze** — run local anomaly-detection models to find suspicious slices (no API key required). -4. **Vision AI** — send the top slices to a Vision-LLM for structured findings *(only with your consent)*. -5. **Report** — render a structured radiology-style report as PDF, HTML, or JSON. - ---- +A high within-series image score is not a disease probability. LLM confidence is +an uncalibrated self-assessment. Neither is an estimate of clinical accuracy. ## Usage -### CLI reference - ```bash -medcheck analyze SOURCE [OPTIONS] # run an analysis pipeline -medcheck serve # start the web UI / REST API -medcheck providers # list data providers -medcheck models # list LLM providers and availability +medcheck analyze SOURCE [OPTIONS] +medcheck serve +medcheck providers +medcheck models +medcheck evaluate manifest.json --output evaluation.json ``` -The most useful `analyze` options: - -| Option | Description | +| Analyze option | Purpose | |---|---| -| `--model, -m` | LLM provider: `claude`, `openai`, `gemini`, `local` | -| `--allow-cloud-llm` | Consent to send imaging data to an external cloud LLM | -| `--deidentify` | Replace patient name/ID/DOB with a pseudonym in reports | -| `--symptoms`, `--trauma`, `--diagnosis` | Clinical context to guide the analysis | -| `--report, -r` | Report format: `pdf`, `html`, `json` | -| `--lang, -l` | Report language: `en`, `de`, `fr`, `es` | -| `--steps` | Comma-separated pipeline steps (skip what you don't need) | -| `--workflow, -w` | Run a YAML-defined pipeline instead | -| `--interactive, -i` | Prompt for missing inputs | - -Run `medcheck analyze --help` for the full list. +| `--model` | Vision provider: `claude`, `openai`, `gemini`, `local` | +| `--allow-cloud-llm` | Permit external image/context transmission | +| `--pixels-reviewed` | Confirm independent review for identifiers before cloud transmission | +| `--deidentify` | Remove identifying metadata and pseudonymize report identity | +| `--study-uid` | Select one study from a multi-study source | +| `--symptoms`, `--trauma`, `--diagnosis` | Supply context | +| `--official-report` | Read a UTF-8 reference report for local text comparison | +| `--ocr-redact` | Mask OCR-detected text using optional local Tesseract | +| `--report` | `json`, `html`, `pdf`, `fhir`, `dicom-sr` | +| `--lang` | `en`, `de`, `fr`, `es` | +| `--steps`, `--workflow` | Choose steps or a YAML workflow | + +Run `medcheck analyze --help` for the full interface. ### REST API -`medcheck serve` exposes: - -| Endpoint | Description | +| Endpoint | Purpose | |---|---| -| `GET /health` | Liveness probe (always public) | -| `POST /api/analyze` | **Not yet implemented — returns `501`** ([#157](https://github.com/Liohtml/MedCheck/issues/157)). Validates the JSON body (`source`, `anatomy`, `report_format`, `language`, `allow_cloud_llm`, …) and enforces auth/rate limits, but does not run an analysis; use `medcheck analyze` instead | - -When `MEDCHECK_API_KEY` is set, `/api/*` requires an `X-API-Key` header. Requests -are rate-limited per client IP (`MEDCHECK_RATE_LIMIT`, default 10/min). - ---- - -## Supported Models - -| Model | Provider | Best For | -|---|---|---| -| **Claude Opus 4.8** | Anthropic | Highest diagnostic quality and reasoning depth | -| **GPT-5.5** | OpenAI | High-resolution image understanding | -| **Gemini 3.5 Flash** | Google | Speed-optimized, cost-effective batch processing | -| **LLaVA-Med** | Local | Fully offline, no API key required *(coming soon — [#18](https://github.com/Liohtml/MedCheck/issues/18))* | - -Default model IDs are overridable via `MEDCHECK_CLAUDE_MODEL`, `MEDCHECK_OPENAI_MODEL`, and `MEDCHECK_GEMINI_MODEL`. - ---- - -## Data Sources - -| Source | Type | Notes | -|---|---|---| -| **Local DICOM** | Folder / ZIP | Point to any directory or ZIP of DICOM files | -| **easyRadiology** | Portal link | Authenticates with the access code from your clinic (date of birth optional) | -| **Custom providers** | Plugin | See [docs/providers.md](docs/providers.md) | - ---- - -## Configuration - -Copy `.env.example` and fill in your API keys: - -```bash -cp .env.example .env -``` - -| Variable | Default | Description | -|---|---|---| -| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GOOGLE_API_KEY` | — | LLM keys (at least one for cloud Vision analysis) | -| `MEDCHECK_LLM_PROVIDER` | `claude` | Default LLM provider (`claude` \| `openai` \| `gemini` \| `local`) | -| `MEDCHECK_ALLOW_EXTERNAL_LLM` | off | Consent to external LLM transmission (`1` to enable) | -| `MEDCHECK_LANGUAGE` | `en` | Default report language | -| `MEDCHECK_HOST` | `127.0.0.1` | Bind address; set `0.0.0.0` to expose on the network | -| `MEDCHECK_PORT` | `8080` | Bind port | -| `MEDCHECK_API_KEY` | — | When set, `/api` requires an `X-API-Key` header | -| `MEDCHECK_RATE_LIMIT` | `10` | `POST /api/analyze` requests per IP per minute (`0` = off) | -| `MEDCHECK_TRUST_PROXY_HEADERS` | off | Key the rate limiter on the first `X-Forwarded-For` hop (`1` — only behind a trusted reverse proxy) | -| `MEDCHECK_MAX_VISION_IMAGES` | `12` | Max slice images sent to the LLM per analysis | -| `MEDCHECK_MAX_DOWNLOAD_BYTES` | 2 GiB | Cap on portal exam-ZIP downloads | - ---- +| `GET /health` | Public liveness check | +| `GET /api/capabilities` | Provider availability and configured limits | +| `POST /api/upload`, `POST /api/inspect` | Upload and inspect study contents | +| `POST /api/preview` | Show provider, transmission and configured cost estimate | +| `POST /api/analyze` | Queue analysis; returns HTTP 202 and a job ID | +| `GET /api/jobs/{id}` | Progress, errors and completed result | +| `POST /api/jobs/{id}/cancel` | Request cancellation between pipeline steps | +| `GET /api/jobs/{id}/images/{series}/{slice}` | View a normalized PNG slice | +| `GET /api/jobs/{id}/report?format=json` | Download a completed report | +| `PATCH /api/jobs/{id}/findings/{index}` | Review or edit a finding with audit history | +| `DELETE /api/jobs/{id}` | Remove a finished job and its artifacts | + +All `/api/*` routes require `X-API-Key` when `MEDCHECK_API_KEY` is configured. +Uploads, inspection and analysis share the per-client request rate limit. +Server-side sources must stay within `MEDCHECK_DATA_ROOT`; uploaded files use +opaque identifiers. See [Workbench](docs/workbench.md) for operation and retention. + +## Providers and configuration + +Cloud SDKs are optional; availability requires both installation and a key. +Default model IDs can be changed through `MEDCHECK_CLAUDE_MODEL`, +`MEDCHECK_OPENAI_MODEL` and `MEDCHECK_GEMINI_MODEL`. No provider is ranked by +clinical accuracy: MedCheck has not established such a comparison. + +Local vision uses an explicitly configured OpenAI-compatible server on a literal +loopback IP address. Set `MEDCHECK_LOCAL_URL` and `MEDCHECK_LOCAL_MODEL`. MedCheck +does not download or launch a vision model. See [Models](docs/models.md). + +| Variable | Purpose | +|---|---| +| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY` | Cloud credentials | +| `MEDCHECK_LLM_PROVIDER` | Default CLI vision provider | +| `MEDCHECK_HOST`, `MEDCHECK_PORT` | Server bind address and port; defaults `127.0.0.1:8080` | +| `MEDCHECK_API_KEY` | API authentication | +| `MEDCHECK_DATA_ROOT` | Allowed server-side source directory | +| `MEDCHECK_STATE_DIR` | Jobs, uploads and report storage | +| `MEDCHECK_MAX_UPLOAD_BYTES`, `MEDCHECK_MAX_JOBS` | Upload and job retention limits | +| `MEDCHECK_MAX_VISION_IMAGES` | Maximum images selected for vision analysis | +| `MEDCHECK_RATE_LIMIT` | Requests per client per minute; `0` disables | + +Use environment variables or a container's `--env-file`; copying `.env.example` +alone does not load environment variables into a shell process. ## Privacy & Security -MedCheck handles patient data (PHI), so the defaults are deliberately conservative: - -- **Nothing leaves your machine without consent.** Cloud Vision analysis requires - `--allow-cloud-llm`, `MEDCHECK_ALLOW_EXTERNAL_LLM=1`, or the interactive prompt. - If the requested LLM provider is unavailable, MedCheck never silently reroutes - data to a different cloud provider. -- **Reports contain PHI by default.** Pass `--deidentify` to replace patient - name/ID/DOB with a stable pseudonym. Report files are written with owner-only - permissions. -- **Localhost by default.** The server binds to `127.0.0.1`; network exposure - requires an explicit opt-in and should always be combined with `MEDCHECK_API_KEY`. -- **Logs are pseudonymized**, ZIP extraction is hardened, and the web UI ships a - strict Content-Security-Policy. +Cloud analysis requires explicit transmission consent and pixel review. Metadata +allow-listing, UID remapping, known-identifier text replacement and optional OCR +reduce exposure; embedded text, recognizable anatomy and unknown free-text +identifiers still need independent review. Review annotations do not certify a +report or make the model clinically validated. -Details: [SECURITY.md](SECURITY.md) · vulnerability reports via [private advisory](https://github.com/Liohtml/MedCheck/security/advisories/new). +The web workbench defaults to de-identification. CLI de-identification is enabled +with `--deidentify`. Treat stored uploads, images, reports and review history as +sensitive. Delete finished jobs and unneeded uploads when no longer needed. ---- +The server binds to localhost by default. Use authentication for network access. +The local vision transport rejects remote URLs, redirects and environment +proxies; its user-managed server must itself be configured for local inference. -## Custom Workflows - -Define analysis pipelines as YAML and commit them alongside your code: - -```yaml -# workflows/full_analysis.yml -name: full_analysis -description: Complete MRI analysis with ML and Vision-LLM - -steps: - - ingest: - - preprocess: - normalize: true - auto_detect_anatomy: true - - ml_analysis: - models: [anomaly_detection, feature_extraction] - - vision_analysis: - provider: claude - clinical_context: - symptoms: "Medial knee pain after sports injury" - trauma: "Valgus stress, 10 days ago" - - report: - format: pdf - language: en -``` +See [SECURITY.md](SECURITY.md), [Model Card](docs/model-card.md) and +[Intended Use](docs/intended-use.md). -Run a workflow: +## Custom workflows ```bash -medcheck analyze --source ./dicoms --workflow workflows/default.yml +medcheck analyze ./dicoms --workflow workflows/default.yml ``` ---- +See [Workflow Reference](docs/workflows.md) for step configuration. Workflow and +explicit step choices determine whether vision inference runs; select local +statistics when external inference is not required. ## Documentation -| Topic | Link | -|---|---| -| Quickstart guide | [docs/quickstart.md](docs/quickstart.md) | -| Data providers & plugins | [docs/providers.md](docs/providers.md) | -| Workflow engine reference | [docs/workflows.md](docs/workflows.md) | -| Supported models | [docs/models.md](docs/models.md) | -| Intended use & positioning | [docs/intended-use.md](docs/intended-use.md) | -| Model card (limitations & risks) | [docs/model-card.md](docs/model-card.md) | +- [Quick Start](docs/quickstart.md) +- [Web Workbench](docs/workbench.md) +- [Data Providers](docs/providers.md) +- [Models and SDK installation](docs/models.md) +- [Report Regression Evaluation](docs/evaluation.md) +- [Intended Use](docs/intended-use.md) +- [Model Card](docs/model-card.md) --- @@ -283,7 +227,7 @@ Contributions of every size are welcome — from typo fixes to new data provider **Where to start:** - 🟢 [`good first issue`](https://github.com/Liohtml/MedCheck/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) — small, well-scoped tasks with pointers -- 🙋 [`help wanted`](https://github.com/Liohtml/MedCheck/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) — features we'd love help with (new providers, local LLaVA-Med, …) +- 🙋 [`help wanted`](https://github.com/Liohtml/MedCheck/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) — features we'd love help with (new providers, evaluation datasets, …) - 🗺️ [Roadmap epic #51](https://github.com/Liohtml/MedCheck/issues/51) — validation & enhancement pipeline stages **Dev setup:** @@ -291,11 +235,11 @@ Contributions of every size are welcome — from typo fixes to new data provider ```bash git clone https://github.com/Liohtml/MedCheck.git cd MedCheck -uv sync --all-extras +uv sync --extra dev pre-commit install # Quality gates (same as CI): -uv run pytest # tests (coverage floor: 80%) +uv run pytest --cov=medcheck --cov-fail-under=85 uv run ruff check src tests && uv run ruff format --check src tests uv run mypy src uv run bandit -r src/medcheck -ll -q diff --git a/docker-compose.yml b/docker-compose.yml index eae3642..8667cc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,13 @@ services: # network, change to "8080:8080" AND set MEDCHECK_API_KEY in .env. - "127.0.0.1:8080:8080" env_file: .env + environment: + MEDCHECK_DATA_ROOT: /data + MEDCHECK_STATE_DIR: /app/.medcheck volumes: - ./data:/data:ro - ./output:/output + - medcheck-state:/app/.medcheck + +volumes: + medcheck-state: diff --git a/docs/evaluation.md b/docs/evaluation.md new file mode 100644 index 0000000..9376804 --- /dev/null +++ b/docs/evaluation.md @@ -0,0 +1,81 @@ +# Report regression evaluation + +Evaluate saved JSON reports against independently supplied reference labels. +This operation never calls an LLM, creates reference labels, or transmits data. +Keep the reference cohort fixed when comparing pipeline or model versions. + +Create a JSON manifest: + +```json +[ + { + "id": "case-001", + "report": "reports/case-001.json", + "subgroup": "knee", + "expected": [ + {"name": "ACL", "status": "normal"}, + {"name": "PCL", "status": "abnormal"} + ] + } +] +``` + +Report paths resolve relative to the manifest directory, regardless of the +working directory. Absolute paths are accepted for trusted local manifests. +Each report must contain a `findings` array of objects with nonempty `name` and +`status` strings. Case IDs and structure names within each case must be unique. +Duplicate/conflicting labels are rejected, including names that differ only by +case or surrounding whitespace. `expected` must be supplied explicitly; an +empty list means there are no expected labels, not an unlabelled case. + +```bash +medcheck evaluate manifest.json --output evaluation.json +medcheck evaluate manifest.json --output candidate.json --baseline evaluation.json +``` + +For Python usage: + +```python +from pathlib import Path +from medcheck.evaluation import evaluate_manifest + +result = evaluate_manifest(Path("manifest.json"), baseline=Path("evaluation.json")) +``` + +## Interpretation + +Names and statuses are compared after stripping surrounding whitespace and +Unicode case folding. There is no synonym matching, inference or free-text +interpretation. Every exact `(name, status)` match counts as one true positive; +an unexpected pair counts as a false positive and a missing reference pair as a +false negative. A wrong status for the right structure therefore produces both +one false positive and one false negative. These are **label agreement counts**, +not counts of clinically diagnosed diseases. + +The output provides each case's matches, missing and unexpected labels, precision, +recall, F1 and exact set agreement. Aggregate precision, recall and F1 use pooled +micro counts. Subgroup metrics use the same definitions; cases without a subgroup +are grouped under `ungrouped`. Empty denominators produce JSON `null`. An empty +reference and empty prediction match exactly, but have undefined precision/recall. +An empty cohort has no exact-match rate. Output is deterministic and omits +run timestamps and report paths. + +Baseline comparison requires the same normalized case IDs, reference labels and +subgroups, verified by a cohort fingerprint. Predictions may change. Increases +in false-positive or missing-label counts, decreases in matches or exact case +matches, either overall or within any subgroup, are flagged in `regressions`. +An unchanged aggregate cannot hide a subgroup regression. A baseline mismatch +or malformed input is an error. `passed` means no configured regression was +found; it does not mean the reports are correct or the model is safe. Without a +baseline, `passed` simply means the evaluation completed. + +## Limits + +Supply complete labels prepared independently of model predictions. Partial +reference annotation incorrectly counts unannotated predictions as false +positives. No true-negative universe is defined, so specificity and overall +clinical accuracy are intentionally absent. Confidence scores are uncalibrated +model self-assessments and are not used as accuracy estimates. This tool does not +measure image localization, free-text correctness, clinical severity, calibration, +statistical significance or generalization. Small or selected subgroups are +particularly limited. Clinical validation requires a separately designed study. diff --git a/docs/intended-use.md b/docs/intended-use.md index fbc1a5f..29c7a72 100644 --- a/docs/intended-use.md +++ b/docs/intended-use.md @@ -1,102 +1,56 @@ # Intended Use & Positioning -This document states what MedCheck **is** and **is not**, and the boundary the -project deliberately stays on the safe side of. It exists so contributors, users, -and reviewers share one understanding of scope — and so a well-meaning feature -doesn't quietly turn MedCheck into something it must not be. +MedCheck is a research and educational imaging toolkit. It has no regulatory +clearance and must not be used to diagnose, screen for or rule out a condition. +Outputs are unvalidated and must not replace qualified clinical assessment. -> **MedCheck is a research and educational tool. It is NOT a medical device, is -> NOT cleared or approved by any regulator (FDA, CE/EU MDR, BfArM, or otherwise), -> and must NOT be used to diagnose, screen for, or rule out any condition.** +## Supported research use -## Why the boundary is strict +Researchers and developers can inspect DICOM studies, investigate image quality, +compare processing configurations, experiment with vision providers and evaluate +saved report labels against independent reference annotations. The browser and +CLI support these activities with traceable settings and research reports. -Regulators classify software by its **intended use and actual function**, not by -the disclaimer attached to it: +Local statistics describe relative image differences; they generate no diagnostic +findings. Vision output is experimental model output. A reference-report text +comparison may help identify passages for human review but does not determine +whether either report is clinically correct. -- **United States (FDA).** The 21st Century Cures Act "Clinical Decision Support" - carve-out only applies if the software, among other criteria, does **not - acquire, process, or analyze a medical image**, and is directed at a **health - care professional** (not a patient). Software that analyzes an MRI fails the - first criterion outright; patient-facing software fails the professional-user - criterion. So image-analysis software presenting findings is a regulated - Software-as-a-Medical-Device (SaMD). -- **European Union (MDR).** There is no equivalent carve-out. Under MDR Rule 11, - software that provides information used for diagnostic or therapeutic decisions - is at least Class IIa, and diagnostic imaging software is commonly Class IIb. -- **Disclaimers do not change this.** "Educational only / not a diagnosis" wording - reduces enforcement *risk* and clarifies intent, but it does not change the - legal classification if the function and claims are diagnostic. +## Boundaries -MedCheck is an open-source research project with no regulatory clearance. It -therefore must not be operated, marketed, or extended as a diagnostic product. +- Do not present image-derived output to a patient as a diagnosis, reassurance, + screening result or exclusion of disease. +- Do not substitute generated text for a radiologist's report or treat an + automated comparison as correction of that report. +- Do not claim measured clinical accuracy, sensitivity, specificity or calibrated + confidence without an appropriate independent validation study. +- Do not interpret user review labels, FHIR formatting or DICOM SR export as + clinical verification. Exports remain preliminary and unverified. +- Any future educational explanation of an existing clinical report must preserve + its meaning and uncertainty and avoid introducing or dropping findings. -## Two tracks +## Responsibilities -MedCheck is developed along two clearly separated tracks. Keeping them distinct is -what keeps the project on the right side of the boundary. +Operators control access, source permissions, retention, provider configuration +and authorization to process or transmit patient data. Metadata filtering and OCR +are aids, not a guarantee of anonymization. Review images and text independently +before cloud transmission; treat stored artifacts as sensitive. -### 1. Research / developer track (the existing default) +Contributors should keep behavior and documentation consistent with this research +scope. A disclaimer alone does not determine the requirements for deploying a +product in a regulated setting; MedCheck does not provide such a determination. -For researchers, developers, and technically-skilled users exploring medical -imaging pipelines. It may run the full pipeline — local ML analysis and -Vision-LLM analysis — and produce a professional radiology-style report. Every -output carries the "not a medical device, research use, must be reviewed by a -qualified radiologist" disclaimer. **Outputs are not validated and must never be -relied upon clinically.** +## Current evidence and limitations -### 2. Patient-education track (opt-in, in development) - -A layer that helps a person **understand an existing radiologist's report** and -**prepare questions for their doctor** — explaining terminology and anatomy in -plain language. This track is built to a deliberately narrow scope. - -## The do / don't boundary - -**MedCheck does NOT, and contributions must not make it:** - -- Tell a patient that an image or region "looks normal/fine" or - "looks concerning/abnormal." (False reassurance is the single most-documented - patient harm, and presenting an image-derived finding to a patient as truth - crosses the device line.) -- Present autonomous, image-derived findings to a patient as a diagnosis, - screening result, or rule-out. -- Replace, contradict, or "correct" a radiologist's report. -- Add findings to, or drop findings from, a source report when simplifying it. -- Invert or soften hedged clinical language (e.g. "cannot exclude malignancy"). -- Make any claim of diagnostic accuracy, sensitivity, or specificity. - -**MedCheck may, within the educational track:** - -- Explain medical terminology and anatomy generically. -- Re-state an **existing** radiologist's report in plain language, preserving its - meaning and uncertainty verbatim. -- Help a user formulate questions to ask their clinician. -- Surface, for a technical/research user, model output that is clearly labeled as - unvalidated AI assistance, always paired with uncertainty and error-rate - context, and never framed as a conclusion. - -## Responsibilities of operators and contributors - -- **Operators** who deploy MedCheck are responsible for the legal and ethical use - of patient data in their jurisdiction (HIPAA/GDPR/BfASG and equivalents), for - de-identification, and for ensuring a qualified professional reviews all output. -- **Contributors** must keep changes inside the boundary above. A change that adds - patient-facing diagnostic claims, accuracy claims, or autonomous findings is out - of scope regardless of how useful it seems. - -## Trustworthy-AI alignment (FUTURE-AI) - -MedCheck aims to align with the FUTURE-AI guiding principles for trustworthy -medical AI, and tracks the gaps honestly: - -| Principle | Status | +| Area | Status | |---|---| -| **Fairness** | No subgroup/bias evaluation has been performed. Treat all output as unvalidated across demographics. | -| **Universality** | Uses open standards (DICOM, FHIR-planned) and permissive components; not validated across scanners/protocols. | -| **Traceability** | Reports state the model used and carry disclaimers; full provenance/logging is a work in progress. | -| **Usability** | CLI + Web UI + reports; patient-education readability work is in progress. | -| **Robustness** | LLM calls have timeout + retry; no clinical robustness validation exists. | -| **Explainability** | Findings carry confidence scores and limitations; AI saliency is intentionally **not** presented to patients as ground-truth localization. | - -See also the [model card](model-card.md) and [SECURITY.md](../SECURITY.md). +| Clinical performance | Not established; software tests are not clinical validation | +| Subgroups | Evaluation supports supplied subgroup labels; no demographic performance claim | +| Interoperability | DICOM input, preliminary FHIR output and unverified DICOM SR; integration must be tested with recipients | +| Traceability | Run settings, vision provenance, selected images and finding review history | +| Usability | Working browser upload, progress, cancellation, viewer, review and export workflows | +| Robustness | Input limits, error handling and provider retries; clinical robustness remains unvalidated | +| Confidence | Uncalibrated model self-assessment; relative image scores are not disease probabilities | + +See the [Model Card](model-card.md), [Evaluation](evaluation.md) and +[Security Policy](../SECURITY.md) for details. diff --git a/docs/model-card.md b/docs/model-card.md index 15020f4..428a86f 100644 --- a/docs/model-card.md +++ b/docs/model-card.md @@ -1,102 +1,93 @@ # Model Card — MedCheck -MedCheck is not a single trained model; it is a **pipeline** that orchestrates -third-party models and its own heuristics. This card describes that system, its -data flow, and — most importantly — its limitations. It follows the spirit of -model cards (Mitchell et al., 2019) adapted for a multi-component tool. - -> **Bottom line:** MedCheck produces **unvalidated, research-grade** output. No -> component has been clinically validated. All output must be reviewed by a -> qualified radiologist. See [intended-use.md](intended-use.md). +MedCheck combines image processing, optional feature extraction and third-party +vision models. It does not train a diagnostic model. Its outputs are unvalidated +research artifacts; see [Intended Use](intended-use.md). ## System overview -``` -Ingest → Preprocess → ML Analyze → Vision AI → Report -``` - -- **Ingest / Preprocess** — load DICOM (local files/ZIP or the easyRadiology - portal), normalize pixel data, select representative slices. Deterministic; no - ML. -- **ML Analyze** — local, on-device analysis (e.g. anomaly/feature heuristics). - Runs without any API key. Not a trained diagnostic classifier. -- **Vision AI** — sends selected slices + clinical context to a **third-party - Vision-LLM** (see below) and parses a structured response. This step is - **gated by explicit consent** before any external transmission. -- **Report** — renders PDF/HTML/JSON with findings, confidence, limitations, and - a mandatory disclaimer. - -## Models used +- **Import and preparation:** DICOM files, folders, ZIP and DICOMDIR media; study + and series UID grouping; geometry checks; normalized image volumes. +- **Privacy processing:** optional metadata allow-listing, UID remapping and + known-identifier text removal. Optional OCR and explicit rectangles can mask + pixels. None guarantees anonymous pixels or free text. +- **Local statistics:** relative within-series image differences and quality + checks. The browser uses a statistical backend without model downloads. + Optional ResNet features use generic image weights, not a medical classifier. +- **Vision:** selected images and supplied context are sent to the chosen cloud + provider with consent, or to an explicitly configured loopback vision server. +- **Reports:** JSON, HTML, PDF, preliminary FHIR DiagnosticReport and unverified + DICOM SR. Findings may include validated references to selected images, + limitations, analysis provenance and review history. + +## Model configuration + +| Provider | Model selection | Execution | +|---|---|---| +| Claude | `MEDCHECK_CLAUDE_MODEL` | Anthropic API; optional SDK and API key | +| OpenAI | `MEDCHECK_OPENAI_MODEL` | OpenAI API; optional SDK and API key | +| Gemini | `MEDCHECK_GEMINI_MODEL` | Google Gen AI SDK and API key | +| Local | `MEDCHECK_LOCAL_MODEL` | User-managed OpenAI-compatible loopback server | + +No vision weights are bundled. MedCheck does not establish the accuracy of a +provider or model. Operators control the local server and must ensure it performs +local inference rather than forwarding data. Its availability probe verifies +that the selected model is listed; it cannot prove vision capability. See +[Models](models.md) for installation and the endpoint contract. + +## Provenance and review + +Reports record analysis settings and, for vision runs, model/provider identifiers, +prompt version and selected/omitted image information. Finding references are +checked against the supplied image set. A valid reference only establishes that +an image was supplied; it does not verify the finding. + +Review actions record before/after finding values, time and notes. Confirming or +editing a finding does not convert an export into a verified clinical report. +Reference-report comparison is local text comparison, not semantic adjudication +of which report is correct. -MedCheck does not ship trained weights. It calls external providers chosen by the -operator: +## Evaluation -| Provider | Model (default, overridable) | Hosting | Notes | -|---|---|---|---| -| Anthropic | `claude-opus-4-8` (`MEDCHECK_CLAUDE_MODEL`) | Cloud API | Frontier general VLM; not medical-specialized or validated. | -| OpenAI | `gpt-5.5` (`MEDCHECK_OPENAI_MODEL`) | Cloud API | Frontier general VLM. | -| Google | `gemini-3.5-flash` (`MEDCHECK_GEMINI_MODEL`) | Cloud API | Frontier general VLM. | -| Local | LLaVA-Med (planned, see [#18](https://github.com/Liohtml/MedCheck/issues/18)) | On-device | Offline path; not yet implemented. | +No clinical sensitivity, specificity, calibration or subgroup performance has +been established. Software tests check parsing, geometry handling, provider +contracts, security controls and workflow behavior, not clinical correctness. -These are **general-purpose** models. They are not medical devices, are not -trained or tuned by MedCheck, and their medical-imaging output is known to -include hallucinations and omissions even in state-of-the-art systems. +The [evaluation utility](evaluation.md) compares saved report labels with +independently supplied reference labels, with aggregate and subgroup regression +checks. It does not provide a validated dataset, generate ground truth, assess +free-text correctness or establish generalization. -## Intended use +LLM confidence is an **uncalibrated model self-assessment**, not a probability of +correctness. Image scores are relative comparisons within a series, not disease +probabilities, and should not be compared as calibrated severity across studies. -Research and education only. See [intended-use.md](intended-use.md) for the full -scope and the do/don't boundary. **Not for diagnosis, screening, or rule-out.** +## Known limitations -## Out-of-scope use +- Vision models can fabricate findings, omit abnormalities and express high + confidence in incorrect output. Clamping scores cannot detect these errors. +- Image selection is bounded; omitted slices can contain relevant information. +- Normalization and 2D slices do not reproduce a complete clinical imaging review. +- Geometry warnings, unsupported encodings or inconsistent dimensions may leave + series unavailable; these limitations must remain visible in the report. +- No performance guarantees exist across anatomy, demographics, scanner vendors + or acquisition protocols. +- Metadata filtering, identifier replacement and OCR can miss identifying data. + Source uploads and saved artifacts remain sensitive even after processing. +- Operator cost estimates are not billing guarantees; retries may incur cost. -- Any clinical decision-making without independent radiologist review. -- Presenting image-derived findings to a patient as a diagnosis or reassurance. -- Use as a substitute for professional medical advice. -- Any use implying regulatory clearance — there is none. +## Controls -## Training data & provenance +Cloud transmission requires explicit consent and independent pixel review. +Unavailable cloud providers are not silently replaced with another cloud +provider. Local transport rejects remote endpoints, redirects and environment +proxies. Requests use bounded timeouts and retry policies. Reports preserve +limitations and research disclaimers; exports remain preliminary/unverified. -MedCheck trains **no models**, so it has no training data of its own. The -behavior of the Vision-LLM step is determined entirely by the third-party model -selected, whose training data and properties are controlled by that provider and -are outside MedCheck's knowledge or control. +These controls reduce specific software and data-handling risks. They do not +establish clinical safety or authorization for diagnostic use. -## Evaluation +## Feedback -**None for clinical accuracy.** MedCheck has not been evaluated for diagnostic -sensitivity, specificity, calibration, or subgroup fairness. The test suite covers -software correctness (parsing, pipeline behavior, security controls), **not -clinical performance.** Confidence scores in reports come from the LLM's -self-reported values and are not calibrated probabilities. - -## Known limitations & risks - -- **Hallucination / omission.** The Vision-LLM can report findings not present in - the image, or miss findings that are. Confidence scores may be high on - fabricated findings. MedCheck validates and clamps these values - ([#71](https://github.com/Liohtml/MedCheck/issues/71)) but cannot detect a - plausible-but-wrong finding. -- **No subgroup validation.** Performance across age, sex, body habitus, scanner - vendor, field strength, and protocol is unknown. -- **PHI handling.** Imaging data is sensitive. External transmission is - consent-gated, and robust de-identification (including burned-in pixel PHI) is - in progress ([#57](https://github.com/Liohtml/MedCheck/issues/57)). Operators - remain responsible for HIPAA/GDPR compliance. -- **AI saliency is not trustworthy localization.** Saliency/heatmaps are not - presented to patients as ground truth; published evidence shows they are - unreliable for localization. -- **General, not medical, models.** The default Vision-LLMs are not radiology - models and have no medical clearance. - -## Mitigations in place - -- Explicit consent gate before any external LLM transmission, with an offline - path planned. -- Validation + confidence clamping of LLM-returned findings. -- Mandatory disclaimer on every report; logs pseudonymize patient identifiers. -- Configurable timeout + retry so a provider failure degrades gracefully. - -## Maintainers & feedback - -Issues and security reports: see [SECURITY.md](../SECURITY.md) and the project -issue tracker. This card is updated as the system evolves. +Report software issues through the project issue tracker and sensitive findings +through [SECURITY.md](../SECURITY.md). diff --git a/docs/models.md b/docs/models.md index 23c4987..02d01bb 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,74 +1,76 @@ -# Supported LLM Models +# LLM providers and local vision -## Model table +MedCheck supports Claude, OpenAI, Gemini and a user-managed local vision server. +Model output and confidence scores are not clinically validated. Provider or model +selection does not establish diagnostic accuracy. -| Model | Provider | Context window | Vision | Approx. cost (per 1 M tokens) | Notes | -|-------|----------|---------------|--------|-------------------------------|-------| -| `claude-opus-4-8` | Anthropic | 200 K | Yes | $15 in / $75 out | Best accuracy; recommended for complex cases | -| `gpt-5-5` | OpenAI | 128 K | Yes | $10 in / $30 out | Strong general performance | -| `gemini-3-5-flash` | Google | 1 M | Yes | $0.35 in / $1.05 out | Fastest; good for high-volume screening | -| `local` | On-device | varies | Yes | Free (hardware cost) | Requires `full` Docker image; no data leaves your network | +## Installation -> Pricing is approximate and subject to change. Check provider pricing pages for current rates. - ---- - -## Configuration - -Set the default model in `.env` or environment variables: - -```bash -MEDCHECK_DEFAULT_MODEL=claude-opus-4-8 -``` - -Or per-request via CLI: +Install only the SDKs you need: ```bash -medcheck analyze image.dcm --anatomy knee --model gemini-3-5-flash +uv sync --extra claude +uv sync --extra openai +uv sync --extra gemini +# Or install all cloud SDKs: +uv sync --extra cloud ``` -Or in a workflow YAML (see [workflows.md](workflows.md)): +Both Docker targets install the cloud SDK bundle. `full` additionally installs +PyTorch/torchvision for image feature extraction; it does **not** bundle a vision +language model. Cloud availability requires both an installed SDK and an API key. +The availability check does not validate credentials with the remote service. +Gemini uses the maintained [Google Gen AI SDK](https://googleapis.github.io/python-genai/). -```yaml -- id: analyze - uses: analyze - with: - model: gpt-5-5 -``` - -### Provider API keys +## Configuration -| Provider | Environment variable | -|----------|---------------------| -| Anthropic | `ANTHROPIC_API_KEY` | -| OpenAI | `OPENAI_API_KEY` | -| Google | `GOOGLE_API_KEY` | -| Local | _(none required)_ | +| Provider selection | API key | Model override | +|---|---|---| +| `claude` | `ANTHROPIC_API_KEY` | `MEDCHECK_CLAUDE_MODEL` | +| `openai` | `OPENAI_API_KEY` | `MEDCHECK_OPENAI_MODEL` | +| `gemini` | `GOOGLE_API_KEY` | `MEDCHECK_GEMINI_MODEL` | +| `local` | None | `MEDCHECK_LOCAL_MODEL` (required) | ---- +Set `MEDCHECK_LLM_PROVIDER` to the desired provider name. Model identifiers are +provider-specific and should be checked against the account's available models. +Timeout is controlled with `MEDCHECK_LLM_TIMEOUT` (seconds, default 120); transient +request retries with `MEDCHECK_LLM_RETRIES` (default 2). MedCheck does not silently +switch from one cloud provider to another. -## Local model setup +## Local vision setup -The `full` Docker image bundles a quantized vision-language model suitable for offline inference. +Run an OpenAI-compatible server with a vision-capable model you have installed +and selected yourself. Configure its loopback IP address and exact model ID: ```bash -docker build --target full -t medcheck:full . -docker run -p 8080:8080 -e MEDCHECK_DEFAULT_MODEL=local medcheck:full +export MEDCHECK_LLM_PROVIDER=local +export MEDCHECK_LOCAL_URL=http://127.0.0.1:11434/v1 +export MEDCHECK_LOCAL_MODEL=your-installed-vision-model ``` -To run local inference on GPU, pass `--gpus all` to `docker run` and ensure the NVIDIA Container Toolkit is installed. - ---- - -## Pricing comparison - -For a typical batch of 100 knee MRI studies (~500 images, ~2 M tokens total): - -| Model | Estimated cost | -|-------|---------------| -| `claude-opus-4-8` | ~$120 | -| `gpt-5-5` | ~$60 | -| `gemini-3-5-flash` | ~$2 | -| `local` | $0 (hardware only) | - -Gemini Flash is the most cost-effective option for high-volume screening workflows. Claude Opus is recommended when diagnostic accuracy is the top priority. +The server must expose `GET /v1/models` and `POST /v1/chat/completions`, supporting +base64 PNG `image_url` message parts. Availability verifies the configured model +appears in the server's models list; that listing cannot prove image support. +An incompatible model will fail at inference. No models are downloaded or servers +started by MedCheck. Local vision works without the `local-models` extra, which is +only needed for the separate PyTorch feature extractor. + +Only literal loopback IP addresses are accepted (`127.0.0.1` or `::1`); hostnames, +remote endpoints, credentials in URLs, redirects and environment proxies are +rejected or disabled. The local server remains user-managed: configure it for +local inference, since MedCheck cannot establish whether it relays data elsewhere. +Inside Docker, loopback means the container itself. Run the model server in the +same network namespace; a remote host or `host.docker.internal` is deliberately +not accepted as a local provider. + +## Privacy tools and cost + +`uv sync --extra privacy` installs the optional `pytesseract` Python wrapper. +OCR also needs the separately installed Tesseract executable; installing the +extra does not install it. Review de-identification and the selected images +before permitting cloud transmission. + +Prices vary by selected model, image processing and token usage. Consult the +provider's published pricing and your account usage. Static per-study prices or +accuracy rankings are not supplied because they would imply precision that the +project has not measured. diff --git a/docs/quickstart.md b/docs/quickstart.md index 5d52811..ad07f57 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,70 +1,77 @@ # Quick Start -## Docker (recommended) - -### Lite image (~500 MB — cloud APIs only) +## Install the checkout ```bash -docker build --target lite -t medcheck:lite . -docker run -p 8080:8080 --env-file .env medcheck:lite +git clone https://github.com/Liohtml/MedCheck.git +cd MedCheck +uv sync +uv run medcheck serve ``` -Or with Docker Compose: +Open http://localhost:8080. Upload a DICOM file or ZIP, inspect available studies, +select a study and start local analysis. Follow progress, inspect slices and +download a report. Local statistics do not generate diagnostic findings and need +no cloud account. The web application defaults to metadata de-identification. -```bash -docker compose up -``` - -### Full image (~10 GB — includes local ML models) +## Docker ```bash -docker build --target full -t medcheck:full . -docker run -p 8080:8080 --env-file .env medcheck:full +docker build --target lite -t medcheck:lite . +docker run --rm -p 127.0.0.1:8080:8080 \ + -v medcheck-state:/app/.medcheck medcheck:lite ``` ---- - -## pip / uv install +To read server-side folders, additionally mount a source directory and configure +the allowed root: ```bash -pip install medcheck -# or -uv add medcheck +docker run --rm -p 127.0.0.1:8080:8080 \ + -v "$PWD/scans:/data/scans:ro" \ + -v medcheck-state:/app/.medcheck \ + -e MEDCHECK_DATA_ROOT=/data/scans medcheck:lite ``` ---- +The lite target includes cloud SDKs and local image statistics. The full target +adds PyTorch/torchvision for feature extraction; neither downloads or bundles a +vision language model. State storage contains sensitive data. See +[Workbench](workbench.md) for retention and deployment settings. -## CLI examples - -### Analyze a local DICOM/NIfTI file +## CLI ```bash -medcheck analyze path/to/knee.dcm --anatomy knee -``` +# Local DICOM file, folder, ZIP or DICOMDIR: +uv run medcheck analyze ./scans \ + --steps ingest,preprocess,ml_analysis,report --deidentify --report json -### Analyze via a PACS/portal URL - -```bash -medcheck analyze https://portal.example.com/study/12345 --anatomy shoulder +# Interactive input: +uv run medcheck analyze ./scans --interactive ``` -### Interactive mode (prompt-driven) +Use `--study-uid` when the source contains multiple studies. Reports are written +to `./output/` unless `--output` is specified. + +For cloud vision, install the relevant SDK and set its API key in the environment: ```bash -medcheck interactive +uv sync --extra claude +export ANTHROPIC_API_KEY=your_key +uv run medcheck analyze ./scans --model claude \ + --deidentify --allow-cloud-llm --pixels-reviewed --report pdf ``` ---- +Only confirm `--pixels-reviewed` after inspecting the images and text for +identifiers. De-identification and optional OCR cannot guarantee anonymous input. +For another provider use `--extra openai` or `--extra gemini`; `--extra cloud` +installs all three. See [Models](models.md) for a user-managed local vision server. -## Web UI (preview) +## Evaluate saved reports -Once the server is running (`medcheck serve` or `docker compose up`), open: +Prepare independently labelled reference cases and run: -``` -http://localhost:8080 +```bash +uv run medcheck evaluate manifest.json --output evaluation.json ``` -> **Note:** the web wizard is a preview. Running an analysis from the browser is -> not yet available — the Analyze step returns `501 Not Implemented` until -> [#157](https://github.com/Liohtml/MedCheck/issues/157) lands. Use the CLI -> (`medcheck analyze SOURCE`, see above) to run analyses today. +See [Evaluation](evaluation.md) for manifest format, baseline comparisons and +limitations. These metrics measure label agreement, not clinical accuracy. diff --git a/docs/workbench.md b/docs/workbench.md new file mode 100644 index 0000000..ab4e013 --- /dev/null +++ b/docs/workbench.md @@ -0,0 +1,138 @@ +# Local research workbench + +Run `uv sync --extra dev`, then `uv run medcheck serve`. Open +http://127.0.0.1:8080. Upload a DICOM ZIP or a single `.dcm`/`.dicom` file, +select the study, check metadata warnings, and start a local analysis. Local +browser analysis uses statistics and never downloads model weights. It does +not generate diagnostic findings. DICOMDIR is supported through the CLI or +an archive containing the directory and referenced files. + +The result includes quality warnings, a grayscale slice viewer, relative +image-difference scores, JSON/HTML/PDF downloads, and analysis provenance. +Vision results can reference only images actually submitted to the model. +Clicking a valid reference selects that image; an absent reference does not +imply that localization has been established. Windowing, diagnostic display +calibration, 3-D reconstruction and clinical viewing are outside this viewer's +scope. + +## Vision and privacy + +Install a provider extra or `uv sync --extra cloud`. A locally installed +vision model server is configured separately; see [models](models.md). +Before cloud analysis the UI displays provider, image limit, populated clinical +context fields and any configured cost estimate. Consent and explicit review +of pixels for embedded identifiers/recognizable anatomy are both required. +Review the images externally or perform a local analysis first. This is an +operator attestation; MedCheck cannot certify that the review was adequate. + +Metadata de-identification is on by default in the browser. It creates copies, +replaces identifiers and UIDs, removes unapproved/private fields recursively, +replaces free-text series labels, and removes known identifiers from contextual +text. Original uploaded files remain unchanged. Unknown identifying text may +remain in free-text inputs. Optional local Tesseract OCR masks detected text, +but can miss it; optional explicit rectangular masks apply to normalized +volumes. Neither mechanism is certified anonymization or automatic facial +defacing. See the privacy flags in JSON and the [model card](model-card.md). + +OCR appears in the UI only when the `privacy` extra and Tesseract are present. +CLI: `medcheck analyze scans --deidentify --ocr-redact`. For the API, use +`ocr_redact: true`, or `redactions: {"Series 1": [[x, y, width, height]]}`; +mask coordinates refer to preprocessed images, and de-identified series use +`Series 1`, `Series 2`, etc. An OCR failure stops analysis rather than silently +sending unmasked data. + +Cloud cost estimates come from `MEDCHECK__ESTIMATED_COST_USD` and +must be set by the operator using current provider rates and conservative +image/token/retry assumptions. A requested budget rejects an unknown or larger +estimate. This is an estimated-spend gate, not a hard billing cap; provider-side +account limits should enforce actual spending. MedCheck makes no model quality +claims and never silently switches to a different cloud vendor. + +## Jobs and retained data + +The service is a single-process, single-user workbench. Run one uvicorn worker. +An API key is shared access control, not tenant isolation or user identity. +All `/api` routes, including downloads and images, require that key when set. +Browser API keys stay in memory. Session storage contains only opaque job/upload +IDs and a selected study ID; refresh prompts for the key again if configured. + +`MEDCHECK_STATE_DIR` (default `.medcheck`) contains original uploads, +normalized viewer volumes, results and review history. Completed viewer volumes +are loaded through memory maps rather than retaining original datasets in RAM. +Completed results and images survive restart. Interrupted jobs are marked +failed and must be rerun. Cancellation is cooperative between pipeline steps; +an already running model request completes or times out before cancellation +finishes. Closing the browser does not cancel a job. + +Delete an analysis and its upload using the UI when finished. Deleting a job +removes its normalized images/reports/audit trail; deleting its original upload +is a separate operation. The UI performs both when possible. There is no +automatic expiry or backup deletion. A configured state directory must be +private to the service account; generated state directories use owner access. +Reports and uploads can still contain sensitive information. Docker Compose +persists state in the `medcheck-state` volume. Removing the container alone +does not delete that volume. + +Resource defaults: 512 MiB per upload and aggregate DICOM input, 128 million +decoded pixels per study, 20 stored jobs/uploads, one job worker. Allowed server +paths are restricted to `MEDCHECK_DATA_ROOT`; symlinks outside it are refused. +ZIP traversal, symlink members, member count and expansion are checked. + +## Review, comparison and exports + +Each finding can be confirmed, rejected or edited, with a note and before/after +history. This records user actions without authenticating professional identity; +it never marks a clinical report verified. Editing a finding does not regenerate +the model's original summary. JSON retains full history; HTML/PDF show review +annotations. The report comparison matches structure names in a supplied UTF-8 +reference report and presents relevant passages for review. It is lexical, not +a clinical agreement/contradiction detector; negation and synonyms require review. + +FHIR R4 DiagnosticReport contains preliminary Observation resources and an +embedded JSON report. DICOM Basic Text SR is PARTIAL, UNVERIFIED and PRELIMINARY. +These are research exports, not a certified PACS or electronic health record integration or a claim of +full receiver-specific conformance. Format round trips are tested; validate +against the intended receiving system before integration. + +## API outline + +- `POST /api/upload`: multipart `file`; returns an opaque `source`. +- `POST /api/inspect`: `{source}`; returns study IDs, series and warnings. +- `POST /api/preview`: analysis options; no inference or transmission. +- `POST /api/analyze`: returns HTTP 202 with job ID. +- `GET /api/jobs/{id}`: status/progress/error/result. +- `POST /api/jobs/{id}/cancel`: request cooperative cancellation. +- `GET /api/jobs/{id}/images/{series_index}/{slice_index}`: normalized PNG. +- `PATCH /api/jobs/{id}/findings/{index}`: review status, optional edit and note. +- `GET /api/jobs/{id}/report?format=json|html|pdf|fhir|dicom-sr`. +- `DELETE /api/jobs/{id}` and `DELETE /api/uploads/{upload_id}`: explicit cleanup. + +See `/docs` for request schemas. Evaluation and regression checking are described +in [evaluation.md](evaluation.md). + +## Development verification + +```bash +uv sync --extra dev +uv run ruff check src tests +uv run ruff format --check src tests +uv run mypy src/medcheck +uv run bandit -r src/medcheck -ll -q +uv run pytest tests --cov=medcheck --cov-fail-under=85 +``` + +The real browser journey is in `tests/browser/web_user_journey.py`. Start a server +on port 8765, install Playwright Chromium, then run: + +```bash +uv run --with playwright playwright install chromium +uv run --with playwright python tests/browser/web_user_journey.py +``` + +Pass `--api-key` when your test server requires one. Use only a dedicated test +server: the script uploads synthetic images and exercises deletion. Real upload, +analysis, viewer and downloads use the backend; deterministic cancellation, +connection recovery and finding-edit scenarios explicitly mock API responses. +Backend integration tests separately exercise real cancellation and review +persistence. CI runs both layers. The optional ResNet test uses seeded random +weights and never downloads model weights; it runs when local-models is installed. diff --git a/pyproject.toml b/pyproject.toml index 50c95ba..6a576c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ ] dependencies = [ "fastapi>=0.115.0", + "python-multipart>=0.0.20", "uvicorn[standard]>=0.32.0", "typer>=0.15.0", "pydicom>=3.0.0", @@ -44,6 +45,11 @@ dependencies = [ ] [project.optional-dependencies] +claude = ["anthropic>=0.49.0"] +openai = ["openai>=1.66.0"] +gemini = ["google-genai>=1.30.0"] +cloud = ["anthropic>=0.49.0", "openai>=1.66.0", "google-genai>=1.30.0"] +privacy = ["pytesseract>=0.3.13"] local-models = [ "torch>=2.5.0", "torchvision>=0.20.0", diff --git a/src/medcheck/core/config.py b/src/medcheck/core/config.py index 5319f09..415a14d 100644 --- a/src/medcheck/core/config.py +++ b/src/medcheck/core/config.py @@ -30,6 +30,11 @@ def _env_int(name: str, default: int) -> int: @dataclass class Settings: + data_root: str = field(default_factory=lambda: os.environ.get("MEDCHECK_DATA_ROOT", "./scans")) + state_dir: str = field(default_factory=lambda: os.environ.get("MEDCHECK_STATE_DIR", "./.medcheck")) + max_upload_bytes: int = field(default_factory=lambda: _env_int("MEDCHECK_MAX_UPLOAD_BYTES", 512 * 1024 * 1024)) + max_jobs: int = field(default_factory=lambda: _env_int("MEDCHECK_MAX_JOBS", 20)) + job_workers: int = field(default_factory=lambda: _env_int("MEDCHECK_JOB_WORKERS", 1)) # Bind to localhost by default; operators must opt into 0.0.0.0 explicitly # via MEDCHECK_HOST for network deployments (this app handles patient PHI). host: str = field(default_factory=lambda: os.environ.get("MEDCHECK_HOST", "127.0.0.1")) diff --git a/src/medcheck/core/context.py b/src/medcheck/core/context.py index 1f8eb32..a31b677 100644 --- a/src/medcheck/core/context.py +++ b/src/medcheck/core/context.py @@ -49,6 +49,8 @@ class StructureFinding: confidence: float = 0.0 slices_evaluated: int = 0 secondary_signs: list[str] = field(default_factory=list) + image_references: list[dict[str, Any]] = field(default_factory=list) + review_status: str = "unreviewed" @dataclass @@ -95,3 +97,12 @@ class PipelineContext: report_language: str = "en" output_dir: str = "" step_config: dict[str, Any] = field(default_factory=dict) + study_instance_uid: str = "" + slice_references: dict[str, list[dict[str, Any]]] = field(default_factory=dict) + quality_checks: dict[str, list[str]] = field(default_factory=dict) + analysis_provenance: dict[str, Any] = field(default_factory=dict) + review_history: list[dict[str, Any]] = field(default_factory=list) + reconciliation: dict[str, Any] = field(default_factory=dict) + official_report: str = "" + pixels_reviewed: bool = False + redactions: dict[str, list[list[int]]] = field(default_factory=dict) diff --git a/src/medcheck/core/workflow.py b/src/medcheck/core/workflow.py index 7541f30..66e2c8e 100644 --- a/src/medcheck/core/workflow.py +++ b/src/medcheck/core/workflow.py @@ -1,5 +1,7 @@ from __future__ import annotations +import time +from datetime import datetime, timezone from typing import Any import yaml @@ -58,16 +60,40 @@ def run( KeyError: If a step name is not found in the registry. """ step_configs = step_configs or {} + from medcheck import __version__ + + context.analysis_provenance.setdefault("app_version", __version__) + context.analysis_provenance.setdefault("started_at", datetime.now(timezone.utc).isoformat()) for name in steps: + if ( + name in {"preprocess", "report"} + and context.deidentify + and "deidentification" not in context.analysis_provenance + ): + from medcheck.pipeline.privacy import DeidentifyStep + + context = DeidentifyStep().run(context) step_class = self.registry.get(name) # raises KeyError if unknown step_instance = step_class() console.print(f"[bold blue]▶ Running step:[/bold blue] {name}") if not step_instance.validate(context): console.print(f"[yellow]Skipping {name}: prerequisites not met[/yellow]") + context.limitations.append(f"Step '{name}' was not run: prerequisites not met.") continue - context.step_config = step_configs.get(name, {}) + context.step_config = dict(step_configs.get(name, {})) + started = time.monotonic() context = step_instance.run(context) + context.analysis_provenance.setdefault("steps", []).append( + {"name": name, "seconds": time.monotonic() - started} + ) + if name == "preprocess": + from medcheck.pipeline.privacy import apply_pixel_redactions + + context.step_config["ocr"] = bool( + context.step_config.get("ocr") or context.analysis_provenance.get("ocr_requested") + ) + apply_pixel_redactions(context) console.print(f"[bold green]✔ Completed step:[/bold green] {name}") return context diff --git a/src/medcheck/evaluation.py b/src/medcheck/evaluation.py new file mode 100644 index 0000000..d122b01 --- /dev/null +++ b/src/medcheck/evaluation.py @@ -0,0 +1,162 @@ +"""Deterministic label agreement against independently supplied reference labels.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +_LIMITATIONS = [ + "Measures exact structure/status label agreement, not clinical diagnostic accuracy.", + "Reference labels must be independently supplied and complete; omitted labels count as false positives.", + "Confidence scores are uncalibrated model self-assessments and are not used as accuracy estimates.", + "No true-negative universe is defined: specificity and overall diagnostic accuracy are not computed.", + "Small or selected cohorts and subgroups cannot establish generalization or clinical safety.", +] + + +def _read_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError) as exc: + raise ValueError(f"Cannot read valid JSON from {path}: {exc}") from exc + + +def _labels(value: Any, where: str) -> set[tuple[str, str]]: + if not isinstance(value, list): + raise ValueError(f"{where} must be a list of name/status objects") + labels: set[tuple[str, str]] = set() + names = set() + for item in value: + if not isinstance(item, dict) or any( + not isinstance(item.get(key), str) or not item[key].strip() for key in ("name", "status") + ): + raise ValueError(f"{where} requires nonempty string name and status") + name, status = item["name"].strip().casefold(), item["status"].strip().casefold() + if name in names: + raise ValueError(f"{where} contains duplicate structure name: {name}") + names.add(name) + labels.add((name, status)) + return labels + + +def _metrics(tp: int, fp: int, fn: int, count: int, exact: int) -> dict[str, Any]: + return { + "true_positives": tp, + "false_positives": fp, + "false_negatives": fn, + "precision": tp / (tp + fp) if tp + fp else None, + "recall": tp / (tp + fn) if tp + fn else None, + "f1": 2 * tp / (2 * tp + fp + fn) if 2 * tp + fp + fn else None, + "case_count": count, + "exact_match_count": exact, + "exact_match_rate": exact / count if count else None, + } + + +def _aggregate(cases: list[dict[str, Any]]) -> dict[str, Any]: + return _metrics( + sum(c["metrics"]["true_positives"] for c in cases), + sum(c["metrics"]["false_positives"] for c in cases), + sum(c["metrics"]["false_negatives"] for c in cases), + len(cases), + sum(c["exact_match"] for c in cases), + ) + + +def _label_objects(labels: set[tuple[str, str]]) -> list[dict[str, str]]: + return [{"name": name, "status": status} for name, status in sorted(labels)] + + +def _evaluate_case(entry: Any, directory: Path) -> dict[str, Any]: + if not isinstance(entry, dict) or not isinstance(entry.get("id"), str) or not entry["id"].strip(): + raise ValueError("Each manifest case requires a nonempty string id") + case_id = entry["id"].strip() + report_name = entry.get("report") + if not isinstance(report_name, str) or not report_name.strip(): + raise ValueError(f"Case {case_id} requires a report path") + subgroup = entry.get("subgroup", "ungrouped") + if not isinstance(subgroup, str) or not subgroup.strip(): + raise ValueError(f"Case {case_id} subgroup must be a nonempty string") + path = Path(report_name) + if not path.is_absolute(): + path = directory / path + report = _read_json(path) + if not isinstance(report, dict): + raise ValueError(f"Case {case_id} report must be an object") + expected = _labels(entry.get("expected"), f"Case {case_id} expected") + actual = _labels(report.get("findings"), f"Case {case_id} findings") + matches, unexpected, missing = expected & actual, actual - expected, expected - actual + return { + "id": case_id, + "subgroup": subgroup.strip(), + "expected": _label_objects(expected), + "actual": _label_objects(actual), + "matches": _label_objects(matches), + "unexpected": _label_objects(unexpected), + "missing": _label_objects(missing), + "exact_match": expected == actual, + "metrics": _metrics(len(matches), len(unexpected), len(missing), 1, int(expected == actual)), + } + + +def _regressions(result: dict[str, Any], baseline: Any) -> list[dict[str, Any]]: + if ( + not isinstance(baseline, dict) + or baseline.get("schema_version") != 1 + or baseline.get("cohort_fingerprint") != result["cohort_fingerprint"] + ): + raise ValueError("Baseline must be an evaluation with the same cases, reference labels and subgroups") + regressions = [] + scopes = [("aggregate", result["aggregate"], baseline.get("aggregate"))] + previous_groups = baseline.get("subgroups", {}) + if not isinstance(previous_groups, dict): + raise ValueError("Baseline subgroups must be an object") + scopes += [(f"subgroup:{name}", values, previous_groups.get(name)) for name, values in result["subgroups"].items()] + for scope, current, previous in scopes: + if not isinstance(previous, dict): + raise ValueError(f"Baseline missing metrics for {scope}") + for key in ("true_positives", "false_positives", "false_negatives", "exact_match_count"): + old = previous.get(key) + if type(old) is not int or old < 0: + raise ValueError(f"Baseline has invalid {scope}.{key}") + decreased_is_bad = key in {"true_positives", "exact_match_count"} + if (current[key] < old) if decreased_is_bad else (current[key] > old): + regressions.append({"scope": scope, "metric": key, "baseline": old, "current": current[key]}) + return regressions + + +def evaluate_manifest(manifest: Path, baseline: Path | None = None) -> dict[str, Any]: + """Evaluate reports without inference. Compare to a prior evaluation if supplied. + + Relative report paths resolve against the manifest's directory. Output omits + timestamps and absolute paths for reproducibility. Missing denominators are + represented as None, never silently scored as perfect agreement. + """ + manifest = Path(manifest) + entries = _read_json(manifest) + if not isinstance(entries, list): + raise ValueError("Manifest must be a JSON array") + cases = sorted((_evaluate_case(entry, manifest.parent) for entry in entries), key=lambda case: case["id"]) + if len({case["id"] for case in cases}) != len(cases): + raise ValueError("Manifest contains duplicate case ids") + cohort = [{key: case[key] for key in ("id", "subgroup", "expected")} for case in cases] + result: dict[str, Any] = { + "schema_version": 1, + "cohort_fingerprint": hashlib.sha256(json.dumps(cohort, sort_keys=True).encode()).hexdigest(), + "aggregate": _aggregate(cases), + "cases": cases, + "subgroups": { + group: _aggregate([case for case in cases if case["subgroup"] == group]) + for group in sorted({case["subgroup"] for case in cases}) + }, + "limitations": list(_LIMITATIONS), + "baseline_compared": baseline is not None, + "regressions": [], + "passed": True, + } + if baseline is not None: + result["regressions"] = _regressions(result, _read_json(Path(baseline))) + result["passed"] = not result["regressions"] + return result diff --git a/src/medcheck/i18n/de.json b/src/medcheck/i18n/de.json index 920fb8f..9e62519 100644 --- a/src/medcheck/i18n/de.json +++ b/src/medcheck/i18n/de.json @@ -38,8 +38,8 @@ "ui_upload_heading": "Bilddatei hochladen", "ui_dropzone_cta": "Klicken zum Auswählen", "ui_dropzone_or": "oder Datei hierher ziehen", - "ui_dropzone_formats": "DICOM, NIfTI, PNG, JPEG, TIFF — bis 500 MB", - "ui_autodetect": "Automatische Erkennung aktiv — das Dateiformat wird selbstständig identifiziert", + "ui_dropzone_formats": "DICOM (.dcm), ZIP — max. 500 MB", + "ui_autodetect": "Datei geprüft. Untersuchung auswählen, um fortzufahren.", "ui_or": "ODER", "ui_remote_url": "Externe URL", "ui_url_hint": "Link zu einer DICOM-Datei oder einem easyRadiology-Portal", @@ -73,10 +73,10 @@ "ui_mod_compare": "Voraufnahmen-Vergleich", "ui_mod_report": "Berichterstellung", "ui_model": "KI-Modell", - "ui_model_claude": "Claude Opus 4.8 (beste Qualität)", - "ui_model_openai": "GPT-5.5 (beste Auflösung)", - "ui_model_gemini": "Gemini 3.5 Flash (am schnellsten)", - "ui_model_local": "Lokales LLaVA-Med (offline)", + "ui_model_claude": "Anthropic Claude", + "ui_model_openai": "OpenAI", + "ui_model_gemini": "Google Gemini", + "ui_model_local": "Lokale Qualitätsprüfung", "ui_report_language": "Berichtssprache", "ui_report_format": "Berichtsformat", "ui_fmt_pdf": "PDF (strukturierter Radiologiebericht)", @@ -106,5 +106,64 @@ "ui_footer_github": "GitHub-Repository", "ui_version": "Version", "ui_error_generic": "Die Anfrage ist fehlgeschlagen (Netzwerkfehler oder Zeitüberschreitung). Bitte versuchen Sie es erneut.", - "ui_noscript": "Für die Analyse im Browser ist JavaScript erforderlich. Bitte aktivieren Sie JavaScript oder nutzen Sie die Kommandozeile: medcheck analyze /pfad/zu/scans" + "ui_noscript": "Für die Analyse im Browser ist JavaScript erforderlich. Bitte aktivieren Sie JavaScript oder nutzen Sie die Kommandozeile: medcheck analyze /pfad/zu/scans", + "ui_local_notice": "Beginne mit einer lokalen Qualitätsprüfung. Deine Dateien bleiben auf diesem Server; eine Cloud-Analyse erfordert eine eigene Auswahl und Zustimmung.", + "ui_skip": "Zum Inhalt springen", + "ui_study": "Untersuchung auswählen", + "ui_connection": "Server-Verbindung einstellen", + "ui_api_key": "Server-API-Schlüssel (falls erforderlich)", + "ui_api_hint": "Nur für diese Seitensitzung. Schlüssel vor dem Hochladen eingeben.", + "ui_cancel": "Analyse abbrechen", + "ui_resume": "Analyse erneut verbinden", + "ui_viewer": "Bilder ansehen", + "ui_series": "Bildserie", + "ui_slice_alt": "DICOM-Schichtvorschau", + "ui_slice": "Schicht", + "ui_viewer_hint": "Vorschau zur Orientierung. Mit dem Regler oder den Pfeiltasten durch die Schichten blättern.", + "ui_review": "Aussagen prüfen", + "ui_review_notes": "Prüfnotiz", + "ui_save_review": "Prüfung speichern", + "ui_uploading": "Datei wird hochgeladen und geprüft …", + "ui_uploaded": "Datei geprüft. Untersuchung auswählen, um fortzufahren.", + "ui_choose_file": "Wähle zuerst eine DICOM-Datei oder ein ZIP-Archiv.", + "ui_bad_file": "Wähle eine .dcm-Datei oder ein .zip-Archiv mit DICOM-Dateien.", + "ui_file_large": "Diese Datei überschreitet die Upload-Grenze von 500 MB.", + "ui_running": "Analyse läuft …", + "ui_complete": "Analyse abgeschlossen", + "ui_cancelled": "Analyse abgebrochen. Du kannst eine neue Analyse starten.", + "ui_confirmed": "Bestätigt", + "ui_rejected": "Verworfen", + "ui_edited": "Bearbeitet", + "ui_unreviewed": "Ungeprüft", + "ui_saved": "Prüfung gespeichert", + "ui_findings_text": "Aussagetext", + "ui_findings": "Aussagen", + "ui_limitations": "Grenzen und Warnungen", + "ui_quality": "Qualitätsprüfung", + "ui_provenance": "Analyseprotokoll", + "ui_no_findings_local": "Die lokale Qualitätsprüfung erstellt keine diagnostischen Aussagen. Prüfe die Qualitätsinformationen und Bildserien unten.", + "ui_external_notice": "Externer Anbieter:", + "ui_cost_unknown": "Keine Kostenschätzung verfügbar. Kosten können nicht zugesichert werden.", + "ui_pixel_check": "Ich habe die Bilder vor der Cloud-Übertragung auf eingebrannte Patientendaten geprüft.", + "ui_pixel_required": "Prüfe die Bilder vor der Cloud-Analyse auf eingebrannte Patientendaten.", + "ui_budget": "Maximale geschätzte Kosten (USD, optional)", + "ui_official_report": "Vorhandener radiologischer Befund (optional)", + "ui_deidentify": "Identifizierende DICOM-Metadaten vor der Cloud-Analyse entfernen", + "ui_local_label": "Lokale Qualitätsprüfung (keine Diagnose)", + "ui_unavailable": "Nicht eingerichtet", + "ui_retry_upload": "Upload fehlgeschlagen. Verbindung oder Server-Schlüssel prüfen und Datei erneut auswählen.", + "ui_retry_job": "Verbindung unterbrochen. Die Analyse läuft möglicherweise weiter. Erneut verbinden, um den Status abzurufen.", + "ui_images": "Bilder", + "ui_series_count": "Serien", + "ui_download": "Herunterladen", + "ui_processing": "Verarbeitung", + "ui_pending": "Analyse wird vorbereitet …", + "ui_delete": "Analyse und hochgeladene Datei löschen", + "ui_deleted": "Analyse und hochgeladene Datei gelöscht.", + "ui_open_image": "Referenziertes Bild öffnen", + "ui_reconciliation": "Abgleich mit vorhandenem Befund", + "ui_model_local_vision": "Lokales Bildmodell", + "ui_budget_hint": "Ein Kostenlimit benötigt eine hinterlegte Schätzung. Falls diese fehlt, nutze die lokale Prüfung oder bitte die Administration, Anbieterpreise zu hinterlegen.", + "ui_ocr": "Lokale Texterkennung zum Abdecken von Text vor der Cloud-Übertragung nutzen", + "ui_ocr_hint": "Texterkennung kann Text übersehen. Prüfe die Bilder selbst, bevor du zustimmst." } diff --git a/src/medcheck/i18n/en.json b/src/medcheck/i18n/en.json index f354dea..6646776 100644 --- a/src/medcheck/i18n/en.json +++ b/src/medcheck/i18n/en.json @@ -38,8 +38,8 @@ "ui_upload_heading": "Upload imaging file", "ui_dropzone_cta": "Click to browse", "ui_dropzone_or": "or drag & drop a file here", - "ui_dropzone_formats": "DICOM, NIfTI, PNG, JPEG, TIFF — up to 500 MB", - "ui_autodetect": "Auto-detect active — the file format will be identified automatically", + "ui_dropzone_formats": "DICOM (.dcm), ZIP — max. 500 MB", + "ui_autodetect": "File checked. Select a study to continue.", "ui_or": "OR", "ui_remote_url": "Remote URL", "ui_url_hint": "Link to a DICOM file or an easyRadiology portal", @@ -73,10 +73,10 @@ "ui_mod_compare": "Prior comparison", "ui_mod_report": "Report generation", "ui_model": "AI model", - "ui_model_claude": "Claude Opus 4.8 (best quality)", - "ui_model_openai": "GPT-5.5 (best resolution)", - "ui_model_gemini": "Gemini 3.5 Flash (fastest)", - "ui_model_local": "Local LLaVA-Med (offline)", + "ui_model_claude": "Anthropic Claude", + "ui_model_openai": "OpenAI", + "ui_model_gemini": "Google Gemini", + "ui_model_local": "Local quality check", "ui_report_language": "Report language", "ui_report_format": "Report format", "ui_fmt_pdf": "PDF (structured radiology report)", @@ -106,5 +106,64 @@ "ui_footer_github": "GitHub repository", "ui_version": "Version", "ui_error_generic": "The request failed (network error or timeout). Please try again.", - "ui_noscript": "JavaScript is required to run an analysis from the browser. Please enable JavaScript or use the CLI: medcheck analyze /path/to/scans" + "ui_noscript": "JavaScript is required to run an analysis from the browser. Please enable JavaScript or use the CLI: medcheck analyze /path/to/scans", + "ui_local_notice": "Start with a local quality check. Your files stay on this server; cloud analysis requires a separate selection and consent.", + "ui_skip": "Skip to content", + "ui_study": "Study to analyze", + "ui_connection": "Server connection settings", + "ui_api_key": "Server API key (if required)", + "ui_api_hint": "Used only for this page session. Enter the key before uploading.", + "ui_cancel": "Cancel analysis", + "ui_resume": "Reconnect to analysis", + "ui_viewer": "Explore the images", + "ui_series": "Image series", + "ui_slice_alt": "DICOM slice preview", + "ui_slice": "Slice", + "ui_viewer_hint": "Preview for orientation only. Use the slider or arrow keys to browse slices.", + "ui_review": "Review findings", + "ui_review_notes": "Review note", + "ui_save_review": "Save review", + "ui_uploading": "Uploading and checking your file …", + "ui_uploaded": "File checked. Select a study to continue.", + "ui_choose_file": "Choose a DICOM file or ZIP archive first.", + "ui_bad_file": "Choose one .dcm file or a .zip archive containing DICOM files.", + "ui_file_large": "This file exceeds the 500 MB upload limit.", + "ui_running": "Analysis in progress …", + "ui_complete": "Analysis complete", + "ui_cancelled": "Analysis cancelled. You can start a new analysis.", + "ui_confirmed": "Confirmed", + "ui_rejected": "Rejected", + "ui_edited": "Edited", + "ui_unreviewed": "Not reviewed", + "ui_saved": "Review saved", + "ui_findings_text": "Finding text", + "ui_findings": "Findings", + "ui_limitations": "Limitations and warnings", + "ui_quality": "Quality checks", + "ui_provenance": "Analysis record", + "ui_no_findings_local": "The local quality check does not produce diagnostic findings. Review the quality checks and image series below.", + "ui_external_notice": "External provider:", + "ui_cost_unknown": "Cost estimate unavailable. No charge estimate can be guaranteed.", + "ui_pixel_check": "I checked the images for burned-in patient identifiers before cloud transmission.", + "ui_pixel_required": "Check the images for burned-in patient identifiers before cloud analysis.", + "ui_budget": "Maximum estimated cost (USD, optional)", + "ui_official_report": "Existing radiology report (optional)", + "ui_deidentify": "Remove identifying DICOM metadata before cloud analysis", + "ui_local_label": "Local quality check (no diagnosis)", + "ui_unavailable": "Not configured", + "ui_retry_upload": "Upload failed. Check the connection or server key, then choose the file again.", + "ui_retry_job": "Connection lost. The analysis may still be running. Reconnect to retrieve its status.", + "ui_images": "images", + "ui_series_count": "series", + "ui_download": "Download", + "ui_processing": "Processing", + "ui_pending": "Preparing analysis …", + "ui_delete": "Delete analysis and uploaded file", + "ui_deleted": "Analysis and uploaded file deleted.", + "ui_open_image": "Open referenced image", + "ui_reconciliation": "Existing report comparison", + "ui_model_local_vision": "Local vision model", + "ui_budget_hint": "A cost limit needs a configured estimate. If the estimate is unavailable, use the local check or ask your administrator to configure provider pricing.", + "ui_ocr": "Try local OCR to mask text in cloud-bound images", + "ui_ocr_hint": "OCR can miss text. Inspect the images yourself before giving consent." } diff --git a/src/medcheck/i18n/es.json b/src/medcheck/i18n/es.json index 591946c..7a03fb0 100644 --- a/src/medcheck/i18n/es.json +++ b/src/medcheck/i18n/es.json @@ -38,8 +38,8 @@ "ui_upload_heading": "Subir archivo de imagen", "ui_dropzone_cta": "Haga clic para explorar", "ui_dropzone_or": "o arrastre y suelte un archivo aquí", - "ui_dropzone_formats": "DICOM, NIfTI, PNG, JPEG, TIFF — hasta 500 MB", - "ui_autodetect": "Detección automática activa — el formato del archivo se identificará automáticamente", + "ui_dropzone_formats": "DICOM (.dcm), ZIP — max. 500 MB", + "ui_autodetect": "Archivo comprobado. Selecciona un estudio para continuar.", "ui_or": "O", "ui_remote_url": "URL remota", "ui_url_hint": "Enlace a un archivo DICOM o a un portal easyRadiology", @@ -73,10 +73,10 @@ "ui_mod_compare": "Comparación con estudios previos", "ui_mod_report": "Generación de informes", "ui_model": "Modelo de IA", - "ui_model_claude": "Claude Opus 4.8 (mejor calidad)", - "ui_model_openai": "GPT-5.5 (mejor resolución)", - "ui_model_gemini": "Gemini 3.5 Flash (el más rápido)", - "ui_model_local": "LLaVA-Med local (sin conexión)", + "ui_model_claude": "Anthropic Claude", + "ui_model_openai": "OpenAI", + "ui_model_gemini": "Google Gemini", + "ui_model_local": "Revisión de calidad local", "ui_report_language": "Idioma del informe", "ui_report_format": "Formato del informe", "ui_fmt_pdf": "PDF (informe radiológico estructurado)", @@ -106,5 +106,64 @@ "ui_footer_github": "Repositorio de GitHub", "ui_version": "Versión", "ui_error_generic": "La solicitud falló (error de red o tiempo de espera agotado). Inténtelo de nuevo.", - "ui_noscript": "Se requiere JavaScript para ejecutar un análisis desde el navegador. Active JavaScript o use la línea de comandos: medcheck analyze /ruta/a/escaneos" + "ui_noscript": "Se requiere JavaScript para ejecutar un análisis desde el navegador. Active JavaScript o use la línea de comandos: medcheck analyze /ruta/a/escaneos", + "ui_local_notice": "Empieza con una revisión de calidad local. Tus archivos permanecen en este servidor; el análisis en la nube requiere selección y consentimiento específicos.", + "ui_skip": "Ir al contenido", + "ui_study": "Estudio para analizar", + "ui_connection": "Conexión al servidor", + "ui_api_key": "Clave API del servidor (si se requiere)", + "ui_api_hint": "Solo se utiliza en esta sesión. Introduce la clave antes de subir archivos.", + "ui_cancel": "Cancelar análisis", + "ui_resume": "Reconectar al análisis", + "ui_viewer": "Explorar imágenes", + "ui_series": "Serie de imágenes", + "ui_slice_alt": "Vista previa de un corte DICOM", + "ui_slice": "Corte", + "ui_viewer_hint": "Vista previa orientativa. Usa el control deslizante o las flechas para recorrer los cortes.", + "ui_review": "Revisar observaciones", + "ui_review_notes": "Nota de revisión", + "ui_save_review": "Guardar revisión", + "ui_uploading": "Subiendo y comprobando el archivo…", + "ui_uploaded": "Archivo comprobado. Selecciona un estudio para continuar.", + "ui_choose_file": "Primero elige un archivo DICOM o un archivo ZIP.", + "ui_bad_file": "Elige un archivo .dcm o un .zip que contenga archivos DICOM.", + "ui_file_large": "Este archivo supera el límite de 500 MB.", + "ui_running": "Análisis en curso…", + "ui_complete": "Análisis completado", + "ui_cancelled": "Análisis cancelado. Puedes iniciar otro análisis.", + "ui_confirmed": "Confirmado", + "ui_rejected": "Rechazado", + "ui_edited": "Editado", + "ui_unreviewed": "Sin revisar", + "ui_saved": "Revisión guardada", + "ui_findings_text": "Texto de la observación", + "ui_findings": "Observaciones", + "ui_limitations": "Limitaciones y advertencias", + "ui_quality": "Revisión de calidad", + "ui_provenance": "Registro del análisis", + "ui_no_findings_local": "La revisión de calidad local no produce observaciones diagnósticas. Consulta la información de calidad y las series de imágenes.", + "ui_external_notice": "Proveedor externo:", + "ui_cost_unknown": "Estimación de coste no disponible. No se puede garantizar ningún importe.", + "ui_pixel_check": "He comprobado si las imágenes contienen identificadores del paciente incrustados antes de enviarlas a la nube.", + "ui_pixel_required": "Comprueba los identificadores del paciente incrustados antes del análisis en la nube.", + "ui_budget": "Coste máximo estimado (USD, opcional)", + "ui_official_report": "Informe radiológico existente (opcional)", + "ui_deidentify": "Eliminar metadatos DICOM identificativos antes del análisis en la nube", + "ui_local_label": "Revisión de calidad local (sin diagnóstico)", + "ui_unavailable": "Sin configurar", + "ui_retry_upload": "Error de carga. Comprueba la conexión o la clave del servidor y selecciona de nuevo el archivo.", + "ui_retry_job": "Conexión interrumpida. El análisis puede seguir en curso. Reconecta para consultar su estado.", + "ui_images": "imágenes", + "ui_series_count": "series", + "ui_download": "Descargar", + "ui_processing": "Procesamiento", + "ui_pending": "Preparando el análisis…", + "ui_delete": "Eliminar análisis y archivo subido", + "ui_deleted": "Análisis y archivo subido eliminados.", + "ui_open_image": "Abrir imagen referenciada", + "ui_reconciliation": "Comparación con el informe existente", + "ui_model_local_vision": "Modelo visual local", + "ui_budget_hint": "Un límite de coste requiere una estimación configurada. Si no está disponible, usa la revisión local o pide al administrador que configure las tarifas.", + "ui_ocr": "Usar reconocimiento local para ocultar texto antes de enviarlo a la nube", + "ui_ocr_hint": "El reconocimiento puede omitir texto. Revisa las imágenes antes de dar tu consentimiento." } diff --git a/src/medcheck/i18n/fr.json b/src/medcheck/i18n/fr.json index 33ffa05..88bf54d 100644 --- a/src/medcheck/i18n/fr.json +++ b/src/medcheck/i18n/fr.json @@ -38,8 +38,8 @@ "ui_upload_heading": "Téléverser un fichier d'imagerie", "ui_dropzone_cta": "Cliquer pour parcourir", "ui_dropzone_or": "ou glisser-déposer un fichier ici", - "ui_dropzone_formats": "DICOM, NIfTI, PNG, JPEG, TIFF — jusqu'à 500 Mo", - "ui_autodetect": "Détection automatique active — le format du fichier sera identifié automatiquement", + "ui_dropzone_formats": "DICOM (.dcm), ZIP — max. 500 MB", + "ui_autodetect": "Fichier vérifié. Sélectionnez un examen pour continuer.", "ui_or": "OU", "ui_remote_url": "URL distante", "ui_url_hint": "Lien vers un fichier DICOM ou un portail easyRadiology", @@ -73,10 +73,10 @@ "ui_mod_compare": "Comparaison antérieure", "ui_mod_report": "Génération de rapport", "ui_model": "Modèle d'IA", - "ui_model_claude": "Claude Opus 4.8 (meilleure qualité)", - "ui_model_openai": "GPT-5.5 (meilleure résolution)", - "ui_model_gemini": "Gemini 3.5 Flash (le plus rapide)", - "ui_model_local": "LLaVA-Med local (hors ligne)", + "ui_model_claude": "Anthropic Claude", + "ui_model_openai": "OpenAI", + "ui_model_gemini": "Google Gemini", + "ui_model_local": "Contrôle qualité local", "ui_report_language": "Langue du rapport", "ui_report_format": "Format du rapport", "ui_fmt_pdf": "PDF (rapport radiologique structuré)", @@ -106,5 +106,64 @@ "ui_footer_github": "Dépôt GitHub", "ui_version": "Version", "ui_error_generic": "La requête a échoué (erreur réseau ou délai dépassé). Veuillez réessayer.", - "ui_noscript": "JavaScript est requis pour lancer une analyse depuis le navigateur. Activez JavaScript ou utilisez la ligne de commande : medcheck analyze /chemin/vers/scans" + "ui_noscript": "JavaScript est requis pour lancer une analyse depuis le navigateur. Activez JavaScript ou utilisez la ligne de commande : medcheck analyze /chemin/vers/scans", + "ui_local_notice": "Commencez par un contrôle qualité local. Vos fichiers restent sur ce serveur ; une analyse cloud nécessite une sélection et un consentement distincts.", + "ui_skip": "Aller au contenu", + "ui_study": "Examen à analyser", + "ui_connection": "Connexion au serveur", + "ui_api_key": "Clé API du serveur (si nécessaire)", + "ui_api_hint": "Utilisée uniquement pendant cette session. Saisissez la clé avant le transfert.", + "ui_cancel": "Annuler l’analyse", + "ui_resume": "Reprendre la connexion", + "ui_viewer": "Explorer les images", + "ui_series": "Série d’images", + "ui_slice_alt": "Aperçu d’une coupe DICOM", + "ui_slice": "Coupe", + "ui_viewer_hint": "Aperçu indicatif. Utilisez le curseur ou les touches fléchées pour parcourir les coupes.", + "ui_review": "Vérifier les observations", + "ui_review_notes": "Note de vérification", + "ui_save_review": "Enregistrer la vérification", + "ui_uploading": "Transfert et vérification du fichier…", + "ui_uploaded": "Fichier vérifié. Sélectionnez un examen pour continuer.", + "ui_choose_file": "Choisissez d’abord un fichier DICOM ou une archive ZIP.", + "ui_bad_file": "Choisissez un fichier .dcm ou une archive .zip contenant des fichiers DICOM.", + "ui_file_large": "Ce fichier dépasse la limite de 500 Mo.", + "ui_running": "Analyse en cours…", + "ui_complete": "Analyse terminée", + "ui_cancelled": "Analyse annulée. Vous pouvez lancer une nouvelle analyse.", + "ui_confirmed": "Confirmé", + "ui_rejected": "Rejeté", + "ui_edited": "Modifié", + "ui_unreviewed": "Non vérifié", + "ui_saved": "Vérification enregistrée", + "ui_findings_text": "Texte de l’observation", + "ui_findings": "Observations", + "ui_limitations": "Limites et avertissements", + "ui_quality": "Contrôles qualité", + "ui_provenance": "Historique de l’analyse", + "ui_no_findings_local": "Le contrôle qualité local ne produit pas d’observations diagnostiques. Consultez les contrôles qualité et les séries ci-dessous.", + "ui_external_notice": "Fournisseur externe :", + "ui_cost_unknown": "Estimation du coût indisponible. Aucun montant ne peut être garanti.", + "ui_pixel_check": "J’ai vérifié l’absence d’identifiants patient incrustés dans les images avant leur transfert vers le cloud.", + "ui_pixel_required": "Vérifiez les identifiants patient incrustés avant l’analyse cloud.", + "ui_budget": "Coût maximal estimé (USD, facultatif)", + "ui_official_report": "Compte rendu radiologique existant (facultatif)", + "ui_deidentify": "Retirer les métadonnées DICOM identifiantes avant l’analyse cloud", + "ui_local_label": "Contrôle qualité local (sans diagnostic)", + "ui_unavailable": "Non configuré", + "ui_retry_upload": "Échec du transfert. Vérifiez la connexion ou la clé du serveur, puis sélectionnez à nouveau le fichier.", + "ui_retry_job": "Connexion interrompue. L’analyse peut être encore en cours. Reconnectez-vous pour consulter son état.", + "ui_images": "images", + "ui_series_count": "séries", + "ui_download": "Télécharger", + "ui_processing": "Traitement", + "ui_pending": "Préparation de l’analyse…", + "ui_delete": "Supprimer l’analyse et le fichier transféré", + "ui_deleted": "Analyse et fichier transféré supprimés.", + "ui_open_image": "Ouvrir l’image référencée", + "ui_reconciliation": "Comparaison du compte rendu existant", + "ui_model_local_vision": "Modèle visuel local", + "ui_budget_hint": "Une limite de coût nécessite une estimation configurée. Si elle est indisponible, utilisez le contrôle local ou demandez à votre administrateur de configurer les tarifs.", + "ui_ocr": "Utiliser la reconnaissance locale pour masquer le texte avant le transfert cloud", + "ui_ocr_hint": "La reconnaissance peut manquer du texte. Vérifiez vous-même les images avant de consentir." } diff --git a/src/medcheck/llm/base.py b/src/medcheck/llm/base.py index 587c613..ec22341 100644 --- a/src/medcheck/llm/base.py +++ b/src/medcheck/llm/base.py @@ -154,6 +154,8 @@ def _coerce_structure_finding(s: dict[str, Any]) -> StructureFinding | None: finding.slices_evaluated = 0 signs = s.get("secondary_signs", []) finding.secondary_signs = [str(x) for x in signs] if isinstance(signs, list) else [] + references = s.get("image_references", []) + finding.image_references = [r for r in references if isinstance(r, dict)] if isinstance(references, list) else [] # Drop entries with no identifying content at all. if not finding.name and not finding.findings: return None @@ -170,13 +172,19 @@ def parse_llm_response(raw: str) -> AnalysisResult: structures = [f for f in (_coerce_structure_finding(s) for s in raw_structures) if f is not None] return AnalysisResult( structures=structures, - overall_impression=data.get("overall_impression", ""), - clinical_correlation=data.get("clinical_correlation", ""), - limitations=data.get("limitations", []), + overall_impression=str(data.get("overall_impression", "") or ""), + clinical_correlation=str(data.get("clinical_correlation", "") or ""), + limitations=[str(x) for x in data.get("limitations", [])] + if isinstance(data.get("limitations", []), list) + else ["Invalid limitations returned by model"], raw_response=raw, ) except (ValueError, json.JSONDecodeError, TypeError): - return AnalysisResult(overall_impression=raw, raw_response=raw) + return AnalysisResult( + overall_impression=raw, + raw_response=raw, + limitations=["Model response was not valid structured JSON; unstructured output requires review."], + ) class LLMProvider(ABC): diff --git a/src/medcheck/llm/claude.py b/src/medcheck/llm/claude.py index da234b3..8249c1a 100644 --- a/src/medcheck/llm/claude.py +++ b/src/medcheck/llm/claude.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import importlib import os from typing import Any @@ -27,7 +28,13 @@ def __init__(self, model: str | None = None) -> None: self.model = model or os.environ.get("MEDCHECK_CLAUDE_MODEL", "claude-opus-4-8") def check_available(self) -> bool: - return bool(os.environ.get("ANTHROPIC_API_KEY")) + if not os.environ.get("ANTHROPIC_API_KEY"): + return False + try: + importlib.import_module("anthropic") + except ImportError: + return False + return True def analyze_images( self, diff --git a/src/medcheck/llm/gemini.py b/src/medcheck/llm/gemini.py index f14f17c..c2e2525 100644 --- a/src/medcheck/llm/gemini.py +++ b/src/medcheck/llm/gemini.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib import os from typing import Any @@ -25,7 +26,13 @@ def __init__(self, model: str | None = None) -> None: self.model = model or os.environ.get("MEDCHECK_GEMINI_MODEL", "gemini-3.5-flash") def check_available(self) -> bool: - return bool(os.environ.get("GOOGLE_API_KEY")) + if not os.environ.get("GOOGLE_API_KEY"): + return False + try: + importlib.import_module("google.genai") + except ImportError: + return False + return True def analyze_images( self, @@ -33,25 +40,34 @@ def analyze_images( prompt: str, context: ClinicalContext | None, ) -> AnalysisResult: - import google.generativeai as genai + from google import genai + from google.genai import types api_key = os.environ.get("GOOGLE_API_KEY") if not api_key: raise RuntimeError("GOOGLE_API_KEY not set") - genai.configure(api_key=api_key) - model = genai.GenerativeModel(self.model) + client = genai.Client( + api_key=api_key, + http_options=types.HttpOptions( + timeout=int(llm_timeout() * 1000), + retry_options=types.HttpRetryOptions(attempts=1), + ), + ) parts: list[Any] = [] for img in images: - parts.append({"mime_type": "image/png", "data": img.image_bytes}) + parts.append(types.Part.from_bytes(data=img.image_bytes, mime_type="image/png")) if img.description: parts.append(img.description) parts.append(prompt) def _request() -> str: - response = model.generate_content(parts, request_options={"timeout": llm_timeout()}) - return str(response.text) + response = client.models.generate_content(model=self.model, contents=parts) + return str(response.text or "") - raw = call_with_retries(_request, provider=self.name) - return parse_llm_response(raw) + try: + raw = call_with_retries(_request, provider=self.name) + return parse_llm_response(raw) + finally: + client.close() diff --git a/src/medcheck/llm/local.py b/src/medcheck/llm/local.py index dd15da1..ac185f5 100644 --- a/src/medcheck/llm/local.py +++ b/src/medcheck/llm/local.py @@ -1,35 +1,75 @@ -"""Local (offline) LLM provider stub. - -The README advertises a fully-offline LLaVA-Med provider. The actual on-device -model is tracked in https://github.com/Liohtml/MedCheck/issues/18; until it lands -this stub keeps the router's ``"local"`` fallback entry honest: -it reports itself as unavailable so the router skips it cleanly instead of the -pipeline failing with a confusing error, and raises an actionable message if it -is ever invoked directly. +"""Vision inference through an explicitly configured loopback model server. + +MedCheck never downloads models or starts servers. A models listing confirms +availability, not medical accuracy or vision support; the configured model must +support OpenAI-compatible image_url chat messages. """ from __future__ import annotations -from medcheck.core.context import ClinicalContext -from medcheck.llm.base import AnalysisResult, AnnotatedImage, LLMProvider +import base64 +import ipaddress +import os +from typing import Any +from urllib.parse import urlsplit -_NOT_IMPLEMENTED_MESSAGE = ( - "Local LLM provider (LLaVA-Med) is not yet implemented. " - "Configure a cloud provider (ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY) " - "or follow https://github.com/Liohtml/MedCheck/issues/18 for offline support." +import httpx + +from medcheck.core.context import ClinicalContext +from medcheck.llm.base import ( + AnalysisResult, + AnnotatedImage, + LLMProvider, + call_with_retries, + llm_timeout, + parse_llm_response, ) class LocalLLMProvider(LLMProvider): - """Placeholder for the planned on-device LLaVA-Med vision provider.""" + """A user-managed OpenAI-compatible vision server on this machine.""" name = "local" supports_vision = True - model = "llava-med (not installed)" + + def __init__(self, model: str | None = None, base_url: str | None = None) -> None: + self.model = model or os.environ.get("MEDCHECK_LOCAL_MODEL", "") + self.base_url = base_url or os.environ.get("MEDCHECK_LOCAL_URL", "") + + def _validated_url(self) -> str: + parsed = urlsplit(self.base_url) + try: + loopback = ipaddress.ip_address(parsed.hostname or "").is_loopback + valid_port = parsed.port is None or 0 < parsed.port <= 65535 + except ValueError: + loopback = False + valid_port = False + if ( + parsed.scheme not in {"http", "https"} + or not loopback + or not valid_port + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or not self.model.strip() + ): + raise ValueError( + "Set MEDCHECK_LOCAL_MODEL and MEDCHECK_LOCAL_URL to a loopback IP endpoint " + "such as http://127.0.0.1:11434/v1. Hostnames and remote endpoints are not allowed." + ) + return self.base_url.rstrip("/") def check_available(self) -> bool: - # Not implemented yet — the router treats this as "skip me". - return False + try: + url = self._validated_url() + with httpx.Client(timeout=2.0, trust_env=False, follow_redirects=False) as client: + response = client.get(f"{url}/models") + response.raise_for_status() + data = response.json() + return any(item.get("id") == self.model for item in data.get("data", []) if isinstance(item, dict)) + except (ValueError, TypeError, AttributeError, httpx.HTTPError): + return False def analyze_images( self, @@ -37,4 +77,26 @@ def analyze_images( prompt: str, context: ClinicalContext | None, ) -> AnalysisResult: - raise NotImplementedError(_NOT_IMPLEMENTED_MESSAGE) + url = self._validated_url() + content: list[dict[str, Any]] = [] + for img in images: + b64 = base64.standard_b64encode(img.image_bytes).decode() + content.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}) + if img.description: + content.append({"type": "text", "text": img.description}) + content.append({"type": "text", "text": prompt}) + with httpx.Client(timeout=llm_timeout(), trust_env=False, follow_redirects=False) as client: + + def _request() -> str: + response = client.post( + f"{url}/chat/completions", + json={"model": self.model, "messages": [{"role": "user", "content": content}], "stream": False}, + ) + response.raise_for_status() + result = response.json()["choices"][0]["message"]["content"] + if not isinstance(result, str): + raise ValueError("Local model returned no text; configure a vision-capable chat model.") + return result + + raw = call_with_retries(_request, provider=self.name) + return parse_llm_response(raw) diff --git a/src/medcheck/llm/openai_provider.py b/src/medcheck/llm/openai_provider.py index 77ded0d..0e43f32 100644 --- a/src/medcheck/llm/openai_provider.py +++ b/src/medcheck/llm/openai_provider.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import importlib import os from typing import Any @@ -26,7 +27,13 @@ def __init__(self, model: str | None = None) -> None: self.model = model or os.environ.get("MEDCHECK_OPENAI_MODEL", "gpt-5.5") def check_available(self) -> bool: - return bool(os.environ.get("OPENAI_API_KEY")) + if not os.environ.get("OPENAI_API_KEY"): + return False + try: + importlib.import_module("openai") + except ImportError: + return False + return True def analyze_images( self, diff --git a/src/medcheck/main.py b/src/medcheck/main.py index 2d25d27..2536fbe 100644 --- a/src/medcheck/main.py +++ b/src/medcheck/main.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Annotated, Any import typer from rich.console import Console @@ -20,7 +20,7 @@ # Accepted report formats and languages. Kept in sync with ReportStep (report.py) # and the i18n catalogs (medcheck/i18n/*.json) so an invalid CLI value fails fast # instead of silently falling through to the JSON branch. -_REPORT_FORMATS = ("pdf", "html", "json") +_REPORT_FORMATS = ("pdf", "html", "json", "fhir", "dicom-sr") _REPORT_LANGUAGES = ("en", "de", "fr", "es") @@ -59,6 +59,8 @@ def _build_registry() -> Any: from medcheck.pipeline.ingest import IngestStep from medcheck.pipeline.ml_analysis import MLAnalysisStep from medcheck.pipeline.preprocess import PreprocessStep + from medcheck.pipeline.privacy import DeidentifyStep + from medcheck.pipeline.reconcile import ReconcileStep from medcheck.pipeline.report import ReportStep from medcheck.pipeline.vision_analysis import VisionAnalysisStep @@ -68,6 +70,8 @@ def _build_registry() -> Any: registry.register("ml_analysis", MLAnalysisStep) registry.register("vision_analysis", VisionAnalysisStep) registry.register("report", ReportStep) + registry.register("deidentify", DeidentifyStep) + registry.register("reconcile", ReconcileStep) return registry @@ -102,7 +106,7 @@ def _print_summary(ctx: Any) -> None: @app.command() -def analyze( +def analyze( # noqa: C901 - CLI option assembly source: str = typer.Argument(..., help="DICOM folder, ZIP file, or portal URL"), provider: str | None = typer.Option(None, "--provider", "-p", help="Data provider (auto-detected if omitted)"), code: str | None = typer.Option(None, "--code", help="Access code (for portal providers)"), @@ -132,6 +136,19 @@ def analyze( "--deidentify", help="Replace patient name, ID and birth date with a pseudonym in generated reports", ), + study_uid: str = typer.Option("", "--study-uid", help="Select one DICOM StudyInstanceUID"), + pixels_reviewed: bool = typer.Option( + False, "--pixels-reviewed", help="Confirm pixels and text were reviewed for identifiers before cloud transfer" + ), + official_report: Annotated[ + Path | None, + typer.Option( + "--official-report", exists=True, dir_okay=False, help="UTF-8 reference report for local text comparison" + ), + ] = None, + ocr_redact: bool = typer.Option( + False, "--ocr-redact", help="Locally mask OCR-detected text; requires privacy extra and Tesseract" + ), ) -> None: """Analyze medical images from DICOM files or radiology portals.""" from medcheck.core.config import Settings @@ -161,6 +178,10 @@ def analyze( # Consent to external LLM transmission via flag or MEDCHECK_ALLOW_EXTERNAL_LLM env. ctx.allow_external_llm = allow_cloud_llm or settings.allow_external_llm ctx.deidentify = deidentify + ctx.study_instance_uid = study_uid + ctx.pixels_reviewed = pixels_reviewed + ctx.official_report = official_report.read_text(encoding="utf-8") if official_report else "" + ctx.analysis_provenance["ocr_requested"] = ocr_redact # Clinical context if symptoms or trauma or diagnosis: @@ -194,6 +215,13 @@ def analyze( console.print(f" Language: {lang}") console.print(f" Output: {output}") + if not steps and not workflow: + selected = ["ingest", "preprocess", "ml_analysis"] + if model or allow_cloud_llm: + selected.append("vision_analysis") + if official_report: + selected.append("reconcile") + steps = ",".join([*selected, "report"]) ctx = _run_pipeline(ctx, workflow, steps) _print_summary(ctx) @@ -310,3 +338,24 @@ def download_models() -> None: console.print(f"[red]Download failed:[/red] {exc}") raise typer.Exit(code=1) from None console.print("[green]Done — model weights are cached; local ML analysis now runs fully offline.[/green]") + + +@app.command() +def evaluate( + manifest: Annotated[Path, typer.Argument(exists=True, dir_okay=False)], + output: Annotated[Path, typer.Option("--output")] = Path("evaluation.json"), + baseline: Annotated[Path | None, typer.Option("--baseline", exists=True, dir_okay=False)] = None, +) -> None: + """Compare report labels with an explicit reference dataset; detect regressions.""" + import json + + from medcheck.evaluation import evaluate_manifest + + try: + result = evaluate_manifest(manifest, baseline=baseline) + except ValueError as exc: + raise typer.BadParameter(str(exc)) from exc + output.write_text(json.dumps(result, indent=2), encoding="utf-8") + console.print(f"Evaluation written to {output}") + if not result["passed"]: + raise typer.Exit(1) diff --git a/src/medcheck/pipeline/exports.py b/src/medcheck/pipeline/exports.py new file mode 100644 index 0000000..23d223c --- /dev/null +++ b/src/medcheck/pipeline/exports.py @@ -0,0 +1,120 @@ +"""Preliminary FHIR R4 DiagnosticReport and DICOM Basic Text SR export. + +References: https://hl7.org/fhir/R4/diagnosticreport.html and DICOM PS3.3 C.17.2. +Exports remain research drafts regardless of user review annotations. +""" + +from __future__ import annotations + +import base64 +import io +import json +from datetime import datetime, timezone +from typing import Any + +from pydicom.dataset import Dataset, FileDataset, FileMetaDataset +from pydicom.sequence import Sequence +from pydicom.uid import BasicTextSRStorage, ExplicitVRLittleEndian, generate_uid + +from medcheck.core.context import PipelineContext + +DISCLAIMER = "Research output, not a diagnosis. Unvalidated AI findings require independent qualified review." + + +def fhir_report(report: dict[str, Any]) -> dict[str, Any]: + observations = [] + for index, finding in enumerate(report["findings"]): + observations.append( + { + "resourceType": "Observation", + "id": f"finding-{index}", + "status": "preliminary", + "code": {"text": finding["name"] or "Image observation"}, + "valueString": finding["findings"], + "note": [{"text": f"Review: {finding.get('review_status', 'unreviewed')}. {DISCLAIMER}"}], + } + ) + result: dict[str, Any] = { + "resourceType": "DiagnosticReport", + "status": "preliminary", + "code": {"text": "MedCheck research imaging analysis"}, + "issued": report["generated_at"], + "conclusion": f"{report['overall_impression']}\n{DISCLAIMER}", + "presentedForm": [ + { + "contentType": "application/json", + "title": "MedCheck research report", + "data": base64.b64encode(json.dumps(report).encode()).decode(), + } + ], + } + if observations: + result["contained"] = observations + result["result"] = [{"reference": f"#{item['id']}"} for item in observations] + return result + + +def _code(value: str, meaning: str, scheme: str = "DCM") -> Sequence: + item = Dataset() + item.CodeValue = value + item.CodingSchemeDesignator = scheme + item.CodeMeaning = meaning + return Sequence([item]) + + +def dicom_sr(context: PipelineContext, report: dict[str, Any]) -> bytes: + meta = FileMetaDataset() + meta.MediaStorageSOPClassUID = BasicTextSRStorage + meta.MediaStorageSOPInstanceUID = generate_uid() + meta.TransferSyntaxUID = ExplicitVRLittleEndian + ds = FileDataset(io.BytesIO(), {}, file_meta=meta, preamble=b"\0" * 128) + ds.SOPClassUID = meta.MediaStorageSOPClassUID + ds.SOPInstanceUID = meta.MediaStorageSOPInstanceUID + ds.SpecificCharacterSet = "ISO_IR 192" + ds.PatientName = report["patient"]["name"] + ds.PatientID = report["patient"]["patient_id"] + ds.PatientBirthDate = report["patient"]["birth_date"] + ds.PatientSex = report["patient"]["sex"] + ds.StudyInstanceUID = generate_uid() if report.get("deidentified") else context.study_instance_uid or generate_uid() + if report.get("deidentified"): + ds.PatientIdentityRemoved = "YES" + ds.DeidentificationMethod = "Pseudonymous research report; source images not included" + ds.SeriesInstanceUID = generate_uid() + ds.StudyDate = report["study"]["date"] + ds.StudyTime = "" + ds.ReferringPhysicianName = "" + ds.StudyID = "" + ds.AccessionNumber = "" + ds.Modality = "SR" + ds.SeriesNumber = 900 + ds.InstanceNumber = 1 + ds.Manufacturer = "MedCheck" + ds.SeriesDescription = "Research analysis - unverified" + ds.ReferencedPerformedProcedureStepSequence = Sequence([]) + ds.PerformedProcedureCodeSequence = Sequence([]) + now = datetime.now(timezone.utc) + ds.ContentDate = now.strftime("%Y%m%d") + ds.ContentTime = now.strftime("%H%M%S") + ds.TimezoneOffsetFromUTC = "+0000" + ds.CompletionFlag = "PARTIAL" + ds.VerificationFlag = "UNVERIFIED" + ds.PreliminaryFlag = "PRELIMINARY" + ds.ValueType = "CONTAINER" + ds.ContinuityOfContent = "SEPARATE" + ds.ConceptNameCodeSequence = _code("126000", "Imaging Measurement Report") + texts = [DISCLAIMER, report["overall_impression"], *report["limitations"]] + texts += [ + f"{f['name']}: {f['findings']} (review: {f.get('review_status', 'unreviewed')})" for f in report["findings"] + ] + items = [] + for text in filter(None, texts): + item = Dataset() + item.RelationshipType = "CONTAINS" + item.ValueType = "TEXT" + item.ConceptNameCodeSequence = _code("121071", "Finding") + item.TextValue = text + items.append(item) + ds.ContentSequence = Sequence(items) + output = io.BytesIO() + ds.save_as(output, enforce_file_format=True) + return output.getvalue() diff --git a/src/medcheck/pipeline/ingest.py b/src/medcheck/pipeline/ingest.py index 1df08fa..f8b4abe 100644 --- a/src/medcheck/pipeline/ingest.py +++ b/src/medcheck/pipeline/ingest.py @@ -23,7 +23,7 @@ def run(self, context: PipelineContext) -> PipelineContext: registry.register(LocalProvider) registry.register(EasyRadiologyProvider) - _console.print(f"[bold cyan]IngestStep[/] Resolving provider for source: {context.source!r}") + _console.print("[bold cyan]IngestStep[/] Resolving data provider") if context.provider_name and context.provider_name != "local": provider = registry.get(context.provider_name) @@ -42,6 +42,16 @@ def run(self, context: PipelineContext) -> PipelineContext: ) series_list = provider.fetch(context.source, context.credentials) + study_ids = {str(s.metadata.get("study_instance_uid", "")) for s in series_list} + if context.study_instance_uid: + series_list = [ + s for s in series_list if s.metadata.get("study_instance_uid", "") == context.study_instance_uid + ] + elif len(study_ids) > 1: + raise ValueError("Multiple studies found. Select one study using --study-uid before analysis.") + if not series_list: + raise ValueError("No readable DICOM images found for the selected study.") + context.study_instance_uid = str(series_list[0].metadata.get("study_instance_uid", "")) context.dicom_series = series_list _console.print(f"[bold cyan]IngestStep[/] Loaded {len(series_list)} series") diff --git a/src/medcheck/pipeline/ml_analysis.py b/src/medcheck/pipeline/ml_analysis.py index 9f929f3..a1c176d 100644 --- a/src/medcheck/pipeline/ml_analysis.py +++ b/src/medcheck/pipeline/ml_analysis.py @@ -6,6 +6,7 @@ from __future__ import annotations +import os import threading from typing import Any @@ -33,6 +34,8 @@ def _build_feature_extractor() -> Any: model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) extractor = torch.nn.Sequential(*list(model.children())[:-1]) extractor.requires_grad_(False) + extractor.eval() + extractor.to(os.environ.get("MEDCHECK_ML_DEVICE", "cpu")) return extractor @@ -60,10 +63,12 @@ def compute_anomaly_scores(features: np.ndarray[Any, np.dtype[Any]]) -> np.ndarr def analyze_signal_intensity(volume: np.ndarray) -> SignalStats: - """Analyze per-slice signal intensity. High signal on PD FS = fluid/edema.""" + """Describe intensity using one volume-wide foreground threshold; not diagnostic.""" mean_int = [float(volume[i].mean()) for i in range(volume.shape[0])] max_int = [float(volume[i].max()) for i in range(volume.shape[0])] - high_ratio = [float((volume[i] > np.percentile(volume[i], 95)).mean()) for i in range(volume.shape[0])] + foreground = volume[volume > volume.min()] + threshold = float(np.percentile(foreground, 95)) if foreground.size else float(volume.max()) + high_ratio = [float((volume[i] > threshold).mean()) for i in range(volume.shape[0])] mean_hr = np.mean(high_ratio) std_hr = np.std(high_ratio) @@ -92,16 +97,18 @@ def _resnet_features(volume: np.ndarray) -> np.ndarray: ] ) + batch_size = max(1, min(128, int(os.environ.get("MEDCHECK_ML_BATCH_SIZE", "16")))) + device = next(feature_extractor.parameters()).device features = [] - with torch.no_grad(): - for i in range(volume.shape[0]): - sl = volume[i] - sl_uint8 = ((sl - sl.min()) / (sl.max() - sl.min() + 1e-8) * 255).astype(np.uint8) - img = Image.fromarray(sl_uint8).convert("RGB") - tensor = transform(img).unsqueeze(0) - feat = feature_extractor(tensor).squeeze().numpy() - features.append(feat) - return np.array(features) + with torch.inference_mode(): + for start in range(0, volume.shape[0], batch_size): + tensors = [] + for sl in volume[start : start + batch_size]: + sl_uint8 = (np.clip(sl, 0, 1) * 255).astype(np.uint8) + tensors.append(transform(Image.fromarray(sl_uint8).convert("RGB"))) + batch = torch.stack(tensors).to(device) + features.append(feature_extractor(batch).flatten(1).cpu().numpy()) + return np.concatenate(features, axis=0) def _statistical_features(volume: np.ndarray) -> np.ndarray: @@ -153,11 +160,26 @@ def validate(self, context: PipelineContext) -> bool: return bool(context.volumes) def run(self, context: PipelineContext) -> PipelineContext: + note = ( + "Local anomaly scores are relative within each series, not disease probabilities. " + "Signal statistics describe brightness and do not establish a diagnosis." + ) + if note not in context.limitations: + context.limitations.append(note) + backend = context.step_config.get("backend", "auto") + if backend not in {"auto", "statistical", "resnet"}: + raise ValueError("ML backend must be auto, statistical, or resnet") + context.analysis_provenance["ml_backend_requested"] = backend for series_name, volume in context.volumes.items(): console.print(f" [blue]Analyzing {series_name} ({volume.shape[0]} slices)...[/blue]") # Feature extraction + anomaly scores - features = extract_features(volume) + if backend == "statistical": + features = _statistical_features(volume) + elif backend == "resnet": + features = _resnet_features(volume) + else: + features = extract_features(volume) scores = compute_anomaly_scores(features) context.anomaly_scores[series_name] = scores.tolist() diff --git a/src/medcheck/pipeline/preprocess.py b/src/medcheck/pipeline/preprocess.py index 834f384..625612c 100644 --- a/src/medcheck/pipeline/preprocess.py +++ b/src/medcheck/pipeline/preprocess.py @@ -78,60 +78,82 @@ def _sort_key(ds: Any) -> float: return 0.0 -def _extract_pixel_array(ds: Any) -> np.ndarray[Any, np.dtype[Any]]: - """Extract the 2-D pixel array from a DICOM dataset. - - Tries ``ds.pixel_array`` first (requires pydicom with correct transfer - syntax). Falls back to reconstructing from raw ``PixelData`` bytes when - the standard path raises an exception (e.g. missing file meta in pure - in-memory ``Dataset`` objects used in tests). - """ +def _slice_normal(ds: Any) -> np.ndarray | None: try: - arr_f: np.ndarray[Any, np.dtype[Any]] = ds.pixel_array.astype(np.float32) - return arr_f - except Exception: - rows = int(ds.Rows) - cols = int(ds.Columns) - bits = int(getattr(ds, "BitsAllocated", 16)) - dtype = np.uint16 if bits == 16 else np.uint8 - raw = bytes(ds.PixelData) - arr = np.frombuffer(raw, dtype=dtype).reshape(rows, cols) - return arr.astype(np.float32) + orientation = np.asarray(ds.ImageOrientationPatient, dtype=float) + normal = np.cross(orientation[:3], orientation[3:]) + length = np.linalg.norm(normal) + return normal / length if length > 0 and np.isfinite(normal).all() else None + except (AttributeError, TypeError, ValueError): + return None -def _build_volume(series: DicomSeries) -> np.ndarray: - """Sort slices, extract pixel arrays, stack, and normalise to [0, 1]. +def _position(ds: Any, normal: np.ndarray) -> float | None: + try: + result = float(np.dot(np.asarray(ds.ImagePositionPatient, dtype=float), normal)) + return result if np.isfinite(result) else None + except (AttributeError, TypeError, ValueError): + return None - Slices whose dimensions deviate from the series' dominant shape (mixed - matrix sizes, embedded localizers) are dropped with a warning instead of - letting ``np.stack`` fail; an empty series raises a descriptive error. - """ - sorted_slices = sorted(series.slices, key=_sort_key) - if not sorted_slices: - raise ValueError("series contains no slices") - arrays = [_extract_pixel_array(ds) for ds in sorted_slices] - shape_counts = Counter(arr.shape for arr in arrays) - if len(shape_counts) > 1: - dominant_shape, _count = shape_counts.most_common(1)[0] - kept = [arr for arr in arrays if arr.shape == dominant_shape] - dropped = len(arrays) - len(kept) - console.print( - f"[yellow]Series '{series.description}': dropped {dropped} slice(s) " - f"with deviating dimensions (kept {len(kept)} at {dominant_shape}).[/yellow]" - ) - arrays = kept +def _extract_pixel_array(ds: Any) -> np.ndarray[Any, np.dtype[Any]]: + """Decode with pydicom's transfer syntax handling; never guess raw encoding.""" + from pydicom.pixels.processing import apply_modality_lut - volume = np.stack(arrays, axis=0) # shape: (N, H, W) + array = np.asarray(apply_modality_lut(ds.pixel_array, ds), dtype=np.float32) + if array.ndim != 2 or int(getattr(ds, "SamplesPerPixel", 1)) != 1: + raise ValueError("only single-frame grayscale DICOM images are supported") + if not np.isfinite(array).all(): + raise ValueError("pixel values are non-finite") + if getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1": + array = array.max() + array.min() - array + return array - v_min = volume.min() - v_max = volume.max() - if v_max > v_min: - volume = (volume - v_min) / (v_max - v_min) - else: - volume = np.zeros_like(volume) - return volume +def _build_volume(series: DicomSeries) -> np.ndarray: + """Build a normalized volume while retaining quality warnings and source mapping.""" + if not series.slices: + raise ValueError("series contains no slices") + warnings: list[str] = [] + normal = _slice_normal(series.slices[0]) + positions = [_position(ds, normal) for ds in series.slices] if normal is not None else [] + normals = [_slice_normal(ds) for ds in series.slices] + orientation_ok = normal is not None and all(n is not None and np.allclose(n, normal, atol=1e-3) for n in normals) + if normal is not None and not orientation_ok: + raise ValueError("inconsistent image orientations; split the localizers from this series") + use_geometry = orientation_ok and all(pos is not None for pos in positions) + if not use_geometry: + warnings.append("Missing image geometry; slice ordering uses SliceLocation/InstanceNumber.") + ordered = sorted( + enumerate(series.slices), + key=lambda item: float(positions[item[0]] or 0) if use_geometry else _sort_key(item[1]), + ) + decoded = [(index, ds, _extract_pixel_array(ds)) for index, ds in ordered] + counts = Counter(arr.shape for _, _, arr in decoded) + if len(counts) > 1: + dominant = counts.most_common(1)[0][0] + kept = [item for item in decoded if item[2].shape == dominant] + warnings.append(f"Dropped {len(decoded) - len(kept)} slice(s) with deviating dimensions.") + decoded = kept + if use_geometry and len(decoded) > 1: + spacing = np.diff([positions[index] for index, _, _ in decoded]) + if np.any(spacing < 1e-4): + warnings.append("Duplicate slice positions detected; volume may contain repeated acquisitions.") + positive = spacing[spacing > 1e-4] + if len(positive) > 1 and not np.allclose(positive, np.median(positive), rtol=0.1, atol=0.1): + warnings.append("Irregular slice spacing or missing slices detected.") + series.metadata["quality_checks"] = warnings + series.metadata["slice_references"] = [ + { + "original_index": index, + "sop_instance_uid": str(getattr(ds, "SOPInstanceUID", "")), + "instance_number": str(getattr(ds, "InstanceNumber", "")), + } + for index, ds, _ in decoded + ] + volume = np.stack([arr for _, _, arr in decoded], axis=0) + lo, hi = volume.min(), volume.max() + return (volume - lo) / (hi - lo) if hi > lo else np.zeros_like(volume) def _series_keys(series_list: list[DicomSeries]) -> list[str]: @@ -170,12 +192,16 @@ def run(self, context: PipelineContext) -> PipelineContext: for key, series in zip(keys, context.dicom_series, strict=True): try: context.volumes[key] = _build_volume(series) + context.slice_references[key] = series.metadata["slice_references"] + context.quality_checks[key] = series.metadata["quality_checks"] + context.limitations.extend(f"Series '{key}': {warning}" for warning in context.quality_checks[key]) except Exception as exc: # One malformed series must not abort the whole study; surface # the gap in the report's limitations instead. message = f"Series '{key}' skipped during preprocessing: {exc}" console.print(f"[yellow]{message}[/yellow]") context.limitations.append(message) + context.quality_checks[key] = [message] # Detect anatomy from the first series description if context.dicom_series: @@ -185,6 +211,14 @@ def run(self, context: PipelineContext) -> PipelineContext: # Detect plane for every series (from the real description, keyed by # the same unique key as the volume) for key, series in zip(keys, context.dicom_series, strict=True): - context.detected_planes[key] = detect_plane(series.description) + normal = _slice_normal(series.slices[0]) if series.slices else None + context.detected_planes[key] = ( + ("sagittal", "coronal", "axial")[int(np.argmax(np.abs(normal)))] + if normal is not None + else detect_plane(series.description) + ) + + if context.clinical_context and context.clinical_context.anatomy: + context.detected_anatomy = context.clinical_context.anatomy return context diff --git a/src/medcheck/pipeline/privacy.py b/src/medcheck/pipeline/privacy.py new file mode 100644 index 0000000..b66da20 --- /dev/null +++ b/src/medcheck/pipeline/privacy.py @@ -0,0 +1,253 @@ +"""Conservative in-memory DICOM de-identification and explicit pixel review. + +Source files are never modified. This is a metadata allow-list, not a claim of +certified anonymization: burned-in text and recognizable anatomy need review. +""" + +from __future__ import annotations + +import copy +import importlib +import re +import secrets +from dataclasses import fields +from numbers import Integral +from typing import Any + +import numpy as np +from pydicom.dataset import Dataset +from pydicom.uid import generate_uid + +from medcheck.core.context import ClinicalContext, PatientInfo, PipelineContext, StudyInfo +from medcheck.core.step import PipelineStep + +# Retain only image decoding, acquisition geometry and non-free-text identifiers. +_KEEP = frozenset( + "SOPClassUID SOPInstanceUID StudyInstanceUID SeriesInstanceUID FrameOfReferenceUID " + "Modality SeriesNumber InstanceNumber Rows Columns SamplesPerPixel PhotometricInterpretation " + "PlanarConfiguration NumberOfFrames BitsAllocated BitsStored HighBit PixelRepresentation " + "PixelData FloatPixelData DoubleFloatPixelData PixelSpacing SliceThickness SpacingBetweenSlices " + "ImagePositionPatient ImageOrientationPatient SliceLocation RescaleIntercept RescaleSlope " + "RescaleType ModalityLUTSequence LUTDescriptor LUTData WindowCenter WindowWidth VOILUTFunction BurnedInAnnotation " + "SharedFunctionalGroupsSequence PerFrameFunctionalGroupsSequence " + "PixelMeasuresSequence PlanePositionSequence PlaneOrientationSequence PixelValueTransformationSequence " + "FrameVOILUTSequence FrameContentSequence DimensionIndexValues InStackPositionNumber " + "TemporalPositionIndex StackID ImagePositionVolume ImageOrientationVolume".split() +) + + +def _scrub_dataset(ds: Dataset, uid_map: dict[str, str]) -> None: + # pydicom datasets support deletion during iteration over a list snapshot. + for element in list(ds): + if element.keyword not in _KEEP: + del ds[element.tag] + elif element.VR == "SQ": + for item in element.value: + _scrub_dataset(item, uid_map) + elif element.VR == "UI" and element.keyword != "SOPClassUID": + original = str(element.value) + element.value = uid_map.setdefault(original, generate_uid()) + + +def redact_known_identifiers(text: str, identifiers: list[str]) -> str: + variants = set(filter(None, identifiers)) + for identifier in list(variants): + if "^" in identifier: + parts = [part for part in identifier.split("^") if part] + variants.update([" ".join(parts), " ".join(reversed(parts)), *[p for p in parts if len(p) > 1]]) + for value in sorted(variants, key=len, reverse=True): + text = re.sub(re.escape(value), "[redacted]", text, flags=re.IGNORECASE) + return text + + +def _preserve_anatomy_hint(context: PipelineContext) -> None: + from medcheck.pipeline.preprocess import detect_anatomy + + anatomy = next( + (hint for series in context.dicom_series if (hint := detect_anatomy(series.description)) != "unknown"), "" + ) + if anatomy: + if context.clinical_context is None: + context.clinical_context = ClinicalContext(anatomy=anatomy) + elif not context.clinical_context.anatomy: + context.clinical_context.anatomy = anatomy + + +def _scrub_file_meta(ds: Dataset) -> None: + if getattr(ds, "file_meta", None): + ds.file_meta.MediaStorageSOPInstanceUID = ds.SOPInstanceUID if hasattr(ds, "SOPInstanceUID") else generate_uid() + for tag in list(ds.file_meta.keys()): + if tag.group != 2 or tag.element not in {0, 1, 2, 3, 16, 18}: + del ds.file_meta[tag] + + +def _redact_value(value: Any, identifiers: list[str], aliases: dict[str, str] | None = None) -> Any: + """Scrub nested report values without retaining the identifier list anywhere.""" + if isinstance(value, str): + return (aliases or {}).get(value, redact_known_identifiers(value, identifiers)) + if isinstance(value, dict): + return {key: _redact_value(item, identifiers, aliases) for key, item in value.items()} + if isinstance(value, list): + return [_redact_value(item, identifiers, aliases) for item in value] + return value + + +def _redact_existing_results(context: PipelineContext, identifiers: list[str]) -> None: + keyed_fields = ( + "volumes", + "detected_planes", + "anomaly_scores", + "top_slices", + "signal_analysis", + "annotated_images", + "slice_references", + "quality_checks", + "redactions", + ) + aliases: dict[str, str] = {} + used: set[str] = set() + for attr in keyed_fields: + for name in getattr(context, attr): + if name in aliases: + continue + base = redact_known_identifiers(name, identifiers) + label, suffix = base, 2 + while label in used: + label = f"{base} ({suffix})" + suffix += 1 + used.add(label) + aliases[name] = label + for finding in context.findings: + for finding_field in fields(finding): + setattr( + finding, finding_field.name, _redact_value(getattr(finding, finding_field.name), identifiers, aliases) + ) + for attr in ( + "overall_impression", + "clinical_correlation", + "limitations", + "review_history", + "reconciliation", + "analysis_provenance", + ): + setattr(context, attr, _redact_value(getattr(context, attr), identifiers, aliases)) + # Series labels can be free text. Keep dictionary keys and image references aligned. + for attr in keyed_fields: + current = getattr(context, attr) + setattr( + context, attr, {aliases[key]: _redact_value(value, identifiers, aliases) for key, value in current.items()} + ) + + +class DeidentifyStep(PipelineStep): + name = "deidentify" + + def run(self, context: PipelineContext) -> PipelineContext: + if not context.deidentify: + return context + identifiers = [context.patient.name, context.patient.patient_id, context.patient.birth_date] + # Random per-run pseudonyms cannot be reversed by guessing common patient IDs. + pseudo = secrets.token_hex(8) + _preserve_anatomy_hint(context) + context.dicom_series = copy.deepcopy(context.dicom_series) + uid_map: dict[str, str] = {} + for index, series in enumerate(context.dicom_series): + for ds in series.slices: + identifiers.extend( + str(getattr(ds, attr, "")) for attr in ("PatientName", "PatientID", "PatientBirthDate") + ) + _scrub_dataset(ds, uid_map) + ds.PatientIdentityRemoved = "YES" + ds.DeidentificationMethod = "MedCheck metadata allow-list; pixels require independent review" + ds.PatientName = f"Subject^{pseudo}" + ds.PatientID = pseudo + _scrub_file_meta(ds) + # Descriptions are free text and can carry identifiers: replace, do not guess. + series.description = f"Series {index + 1}" + series.metadata = { + key: uid_map.setdefault(str(value), generate_uid()) if value else "" + for key, value in series.metadata.items() + if key in {"study_instance_uid", "series_instance_uid"} + } + if context.clinical_context: + for attr in ("symptoms", "trauma", "trauma_date", "suspected_diagnosis", "anatomy"): + value = getattr(context.clinical_context, attr) + setattr(context.clinical_context, attr, redact_known_identifiers(value, identifiers)) + context.official_report = redact_known_identifiers(context.official_report, identifiers) + _redact_existing_results(context, identifiers) + context.patient = PatientInfo(name=f"Subject^{pseudo}", patient_id=pseudo) + context.study = StudyInfo() + context.study_instance_uid = uid_map.get(context.study_instance_uid, "") + context.analysis_provenance["deidentification"] = { + "metadata": "allow-list", + "metadata_deidentified": True, + "pixels_reviewed": context.pixels_reviewed, + "free_text_anonymization_guaranteed": False, + "certified_anonymization": False, + } + context.limitations.append( + "Metadata de-identification does not guarantee anonymous pixels or free text. " + "Embedded text and recognizable anatomy require independent review." + ) + return context + + +def _validated_rectangle(rect: list[int], shape: tuple[int, ...]) -> tuple[int, int, int, int]: + if len(rect) != 4 or any(not isinstance(value, Integral) or isinstance(value, bool) for value in rect): + raise ValueError("Redaction must contain integer x, y, width, height") + x, y, width, height = (int(value) for value in rect) + if min(x, y) < 0 or min(width, height) <= 0 or x + width > shape[2] or y + height > shape[1]: + raise ValueError("Redaction rectangle is outside the image") + return x, y, width, height + + +def _ocr_rectangles(slice_array: np.ndarray) -> list[list[int]]: + """Detect text locally. No OCR text is retained or sent to another service.""" + try: + pytesseract = importlib.import_module("pytesseract") + except ImportError as exc: + raise RuntimeError("OCR requires the privacy extra and a local Tesseract installation") from exc + image = (np.clip(slice_array, 0, 1) * 255).astype(np.uint8) + data = pytesseract.image_to_data(image, output_type=pytesseract.Output.DICT, timeout=15) + rectangles = [] + for index, text in enumerate(data["text"]): + if not str(text).strip(): + continue + # Pad the bounding box; OCR can miss the first/last pixels of a glyph. + x = max(0, int(data["left"][index]) - 2) + y = max(0, int(data["top"][index]) - 2) + right = min(image.shape[1], int(data["left"][index]) + int(data["width"][index]) + 2) + bottom = min(image.shape[0], int(data["top"][index]) + int(data["height"][index]) + 2) + if right > x and bottom > y: + rectangles.append([x, y, right - x, bottom - y]) + return rectangles + + +def apply_pixel_redactions(context: PipelineContext) -> None: + """Validate every mask before changing pixels; optional local OCR is only an aid.""" + validated: dict[str, list[tuple[int, int, int, int]]] = {} + for series, rectangles in context.redactions.items(): + if series not in context.volumes: + raise ValueError("Redaction refers to an unknown series") + validated[series] = [_validated_rectangle(rect, context.volumes[series].shape) for rect in rectangles] + ocr_enabled = context.step_config.get("ocr", False) + if not isinstance(ocr_enabled, bool): + raise ValueError("OCR must be a boolean") + # Copy volumes so consumers holding original arrays keep untouched pixel data. + volumes = {name: volume.copy() for name, volume in context.volumes.items()} + detected: dict[str, list[dict[str, Any]]] = {} + for name, volume in volumes.items(): + for x, y, width, height in validated.get(name, []): + volume[:, y : y + height, x : x + width] = 0 + if ocr_enabled: + detected[name] = [] + for index, slice_array in enumerate(volume): + for rectangle in _ocr_rectangles(slice_array): + x, y, width, height = _validated_rectangle(rectangle, volume.shape) + volume[index, y : y + height, x : x + width] = 0 + detected[name].append({"slice_index": index, "rectangle": rectangle}) + context.volumes = volumes + context.analysis_provenance["pixel_redactions"] = copy.deepcopy(context.redactions) + if ocr_enabled: + context.analysis_provenance["ocr_redactions"] = detected + context.limitations.append("OCR masking may miss embedded text; independent pixel review remains required.") diff --git a/src/medcheck/pipeline/reconcile.py b/src/medcheck/pipeline/reconcile.py new file mode 100644 index 0000000..a2573ae --- /dev/null +++ b/src/medcheck/pipeline/reconcile.py @@ -0,0 +1,44 @@ +"""Local, explicitly lexical report comparison; never a diagnostic adjudicator.""" + +from __future__ import annotations + +import re +from typing import Any + +from medcheck.core.context import PipelineContext +from medcheck.core.step import PipelineStep + + +def compare_reports(context: PipelineContext) -> dict[str, Any]: + reference = context.official_report.strip() + if not reference: + return {} + sentences = [s.strip() for s in re.split(r"[\n.!?]+", reference) if s.strip()] + comparisons = [] + for index, finding in enumerate(context.findings): + name = finding.name.casefold() + matches = [sentence for sentence in sentences if name and name in sentence.casefold()] + comparisons.append( + { + "finding_index": index, + "structure": finding.name, + "reference_passages": matches, + "status": "requires_review" if matches else "not_matched", + } + ) + return { + "method": "lexical-structure-match-v1", + "comparisons": comparisons, + "reference_report": reference, + "limitation": ( + "Text matches do not establish agreement or correctness. Synonyms and negations require human review." + ), + } + + +class ReconcileStep(PipelineStep): + name = "reconcile" + + def run(self, context: PipelineContext) -> PipelineContext: + context.reconciliation = compare_reports(context) + return context diff --git a/src/medcheck/pipeline/report.py b/src/medcheck/pipeline/report.py index 0736faa..763a5fc 100644 --- a/src/medcheck/pipeline/report.py +++ b/src/medcheck/pipeline/report.py @@ -9,6 +9,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any +from uuid import uuid4 from medcheck.core.context import PatientInfo, PipelineContext from medcheck.core.step import PipelineStep @@ -16,15 +17,8 @@ def _display_patient(ctx: PipelineContext) -> PatientInfo: - """Return the patient info to embed in reports. - - With ``ctx.deidentify`` set, direct identifiers (name, patient ID, birth - date) are replaced by a stable SHA-256 pseudonym — the same scheme the - ingest step uses for log output — so reports can be shared without - exposing PHI. Sex and age are retained as they are clinically relevant - and not directly identifying. - """ - if not ctx.deidentify: + """Return display identifiers; pseudonymization alone does not sanitize free text or pixels.""" + if not ctx.deidentify or "deidentification" in ctx.analysis_provenance: return ctx.patient basis = ctx.patient.patient_id or ctx.patient.name pseudo = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:12] if basis else "unknown" @@ -54,6 +48,8 @@ def generate_json_report(ctx: PipelineContext) -> str: "confidence": f.confidence, "slices_evaluated": f.slices_evaluated, "secondary_signs": f.secondary_signs, + "image_references": f.image_references, + "review_status": f.review_status, } ) @@ -75,6 +71,12 @@ def generate_json_report(ctx: PipelineContext) -> str: "generated_at": datetime.now(timezone.utc).isoformat(), "language": ctx.report_language, "deidentified": ctx.deidentify, + "privacy": { + "metadata_deidentified": "deidentification" in ctx.analysis_provenance, + "pixels_reviewed": ctx.pixels_reviewed, + "certified_anonymization": False, + }, + "study_instance_uid": ctx.study_instance_uid, "patient": { "name": patient.name, "patient_id": patient.patient_id, @@ -97,6 +99,14 @@ def generate_json_report(ctx: PipelineContext) -> str: "overall_impression": ctx.overall_impression, "clinical_correlation": ctx.clinical_correlation, "limitations": ctx.limitations, + "disclaimer": "Research output, not a diagnosis. Independent qualified review required.", + "confidence_interpretation": "Uncalibrated model self-assessment; not a probability of correctness.", + "score_interpretation": "Relative within-series image differences; not disease probabilities.", + "quality_checks": ctx.quality_checks, + "analysis_provenance": ctx.analysis_provenance, + "review_history": ctx.review_history, + "reconciliation": ctx.reconciliation, + "series": [{"key": key, "slices": int(volume.shape[0])} for key, volume in ctx.volumes.items()], } return json.dumps(report, indent=2) @@ -123,7 +133,7 @@ def generate_pdf_report(ctx: PipelineContext) -> str: ) output_dir = ctx.output_dir or "." - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + "_" + uuid4().hex[:10] pdf_path = str(Path(output_dir) / f"report_{timestamp}.pdf") doc = SimpleDocTemplate(pdf_path, pagesize=A4) @@ -178,10 +188,10 @@ def generate_pdf_report(ctx: PipelineContext) -> str: ] rows = [headers] + [ [ - f.name, - f.status, + Paragraph(html.escape(f.name), styles["Normal"]), + Paragraph(html.escape(f"{f.status} / {f.review_status}"), styles["Normal"]), f"{f.confidence:.0%}", - f.findings, + Paragraph(html.escape(f.findings), styles["Normal"]), ] for f in ctx.findings ] @@ -209,22 +219,39 @@ def generate_pdf_report(ctx: PipelineContext) -> str: story.append(Paragraph(i18n["no_findings"], styles["Normal"])) story.append(Spacer(1, 0.4 * cm)) + story.append( + Paragraph( + "Confidence is an uncalibrated model self-assessment, not a probability of correctness.", styles["Normal"] + ) + ) + if ctx.review_history: + story.append(Paragraph("Review history (automated summary is unchanged)", styles["Heading2"])) + for event in ctx.review_history: + story.append( + Paragraph( + html.escape( + f"{event['at']}: finding {event['finding_index'] + 1}, " + f"{event['after']['review_status']}. {event.get('note', '')}" + ), + styles["Normal"], + ) + ) # Overall Impression story.append(Paragraph(i18n["headings_impression"], styles["Heading2"])) - story.append(Paragraph(ctx.overall_impression or "—", styles["Normal"])) + story.append(Paragraph(html.escape(ctx.overall_impression or "—"), styles["Normal"])) story.append(Spacer(1, 0.3 * cm)) # Clinical Correlation if ctx.clinical_correlation: story.append(Paragraph(i18n["headings_correlation"], styles["Heading2"])) - story.append(Paragraph(ctx.clinical_correlation, styles["Normal"])) + story.append(Paragraph(html.escape(ctx.clinical_correlation), styles["Normal"])) story.append(Spacer(1, 0.3 * cm)) # Limitations if ctx.limitations: story.append(Paragraph(i18n["headings_limitations"], styles["Heading2"])) for lim in ctx.limitations: - story.append(Paragraph(f"• {lim}", styles["Normal"])) + story.append(Paragraph(html.escape(f"• {lim}"), styles["Normal"])) story.append(Spacer(1, 0.3 * cm)) # Disclaimer @@ -250,7 +277,7 @@ def generate_html_report(ctx: PipelineContext) -> str: """Generate a simple HTML report. Returns the file path.""" i18n = get_strings(ctx.report_language) output_dir = ctx.output_dir or "." - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + "_" + uuid4().hex[:10] html_path = str(Path(output_dir) / f"report_{timestamp}.html") patient = _display_patient(ctx) @@ -274,6 +301,12 @@ def generate_html_report(ctx: PipelineContext) -> str: _lang = (ctx.report_language or "en").lower().strip() resolved_lang = _lang if _lang in {"en", "de", "fr", "es"} else "en" + audit_html = "".join( + f"
  • {html.escape(str(event.get('at', '')))}: " + f"{html.escape(str(event.get('after', {}).get('review_status', '')))} — " + f"{html.escape(str(event.get('note', '')))}
  • " + for event in ctx.review_history + ) html_content = f""" @@ -326,6 +359,10 @@ def generate_html_report(ctx: PipelineContext) -> str:

    {html.escape(i18n["headings_limitations"])}

    +

    Confidence is an uncalibrated model self-assessment, not a probability of correctness.

    +

    Review history

    +

    Review edits do not automatically regenerate the original model summary.

    +

    {html.escape(i18n["disclaimer"])}

    @@ -359,10 +396,20 @@ def run(self, context: PipelineContext) -> PipelineContext: path = generate_pdf_report(context) elif fmt == "html": path = generate_html_report(context) + elif fmt in {"fhir", "dicom-sr"}: + from medcheck.pipeline.exports import dicom_sr, fhir_report + + report = json.loads(generate_json_report(context)) + suffix = "json" if fmt == "fhir" else "dcm" + path = str(Path(output_dir) / f"report_{uuid4().hex}.{suffix}") + if fmt == "fhir": + Path(path).write_text(json.dumps(fhir_report(report), indent=2), encoding="utf-8") + else: + Path(path).write_bytes(dicom_sr(context, report)) else: # Default: JSON json_str = generate_json_report(context) - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + "_" + uuid4().hex[:10] path = str(Path(output_dir) / f"report_{timestamp}.json") Path(path).write_text(json_str, encoding="utf-8") diff --git a/src/medcheck/pipeline/vision_analysis.py b/src/medcheck/pipeline/vision_analysis.py index 0a255dd..d7c9709 100644 --- a/src/medcheck/pipeline/vision_analysis.py +++ b/src/medcheck/pipeline/vision_analysis.py @@ -6,6 +6,7 @@ from __future__ import annotations +import hashlib import io import re from functools import lru_cache @@ -121,7 +122,8 @@ def load_anatomy_instructions(anatomy: str) -> str: "findings": "", "confidence": <0.0-1.0>, "slices_evaluated": , - "secondary_signs": ["", ...] + "secondary_signs": ["", ...], + "image_references": [{"series_name": "", "slice_index": }] } ], "overall_impression": "", @@ -194,7 +196,8 @@ def build_prompt(clinical_context: ClinicalContext | None, anatomy: str | None) "", "## Important Notes", "- Base findings solely on the images provided.", - "- Assign a confidence score (0.0-1.0) per structure.", + "- Assign a confidence score (0.0-1.0) per structure; this is an uncalibrated self-assessment.", + "- Cite only supplied images using their exact series_name and zero-based slice_index.", "- List any technical limitations (field strength, motion artefact, slice thickness, etc.).", "- Do NOT hallucinate findings not visible in the images.", "- This report is generated by an AI assistant and MUST be reviewed by a qualified radiologist" @@ -221,36 +224,31 @@ def _volume_slice_to_png_bytes(slice_array: np.ndarray) -> bytes: def _select_slice_indices(volume: np.ndarray, top_slices: list[int] | None, n: int = 5) -> list[int]: """Return up to *n* slice indices: prefer top_slices, else evenly spaced.""" n_slices = volume.shape[0] - if top_slices: - return list(top_slices[:n]) - # Evenly-spaced fallback - step = max(1, n_slices // n) - return list(range(0, n_slices, step))[:n] + if n_slices == 0 or n <= 0: + return [] + candidates = list(top_slices or []) + np.linspace(0, n_slices - 1, min(n, n_slices), dtype=int).tolist() + return list(dict.fromkeys(int(i) for i in candidates if isinstance(i, (int, np.integer)) and 0 <= i < n_slices))[:n] def _collect_images(context: PipelineContext, max_images: int = _MAX_VISION_IMAGES) -> list[AnnotatedImage]: - """Build the slice images to send to the LLM, capped at *max_images* total. - - Slices are gathered per series (preferring each series' top/suspicious slices); - once *max_images* is reached collection stops, so a study with many series can't - blow past the cap. Series are visited in insertion order for deterministic output. - """ + """Round-robin selection gives every series coverage before adding more slices.""" + candidates = { + name: _select_slice_indices(volume, context.top_slices.get(name)) for name, volume in context.volumes.items() + } images: list[AnnotatedImage] = [] - if max_images <= 0: - return images - for series_name, volume in context.volumes.items(): - top = context.top_slices.get(series_name) - indices = _select_slice_indices(volume, list(top) if top else None) - for idx in indices: + for offset in range(5): + for name, indices in candidates.items(): if len(images) >= max_images: return images - png_bytes = _volume_slice_to_png_bytes(volume[idx]) + if offset >= len(indices): + continue + index = indices[offset] images.append( AnnotatedImage( - series_name=series_name, - slice_index=idx, - image_bytes=png_bytes, - description=f"Series: {series_name} | Slice {idx + 1}/{volume.shape[0]}", + series_name=name, + slice_index=index, + image_bytes=_volume_slice_to_png_bytes(context.volumes[name][index]), + description=f"series_name={name} | slice_index={index} (zero-based)", ) ) return images @@ -293,13 +291,53 @@ def run(self, context: PipelineContext) -> PipelineContext: "'local' provider. See SECURITY.md for details." ) + if provider.name not in _LOCAL_PROVIDERS and not context.pixels_reviewed: + raise PermissionError( + "External vision analysis requires explicit pixel review for embedded identifiers. " + "Review and redact the images, then confirm pixels_reviewed before transmission." + ) + if not images: + raise ValueError("No images available for vision analysis") + selected = [{"series_name": image.series_name, "slice_index": image.slice_index} for image in images] + from medcheck import __version__ + + model = getattr(provider, "model", "") + context.analysis_provenance.update( + { + "vision_provider": provider.name, + "vision_model": model if isinstance(model, str) else "unknown", + "medcheck_version": __version__, + "prompt_sha256": hashlib.sha256(prompt.encode()).hexdigest(), + "selected_images": selected, + } + ) + covered = {image.series_name for image in images} + for name, volume in context.volumes.items(): + count = sum(image.series_name == name for image in images) + if count < len(volume): + context.limitations.append(f"Series '{name}': vision evaluated {count}/{len(volume)} slices.") + if len(covered) < len(context.volumes): + context.limitations.append("Image budget excluded entire series from vision analysis.") result = provider.analyze_images(images, prompt, context.clinical_context) + allowed = {(image.series_name, image.slice_index) for image in images} + for finding in result.structures: + valid = [ + ref + for ref in finding.image_references + if isinstance(ref, dict) + and isinstance(ref.get("series_name"), str) + and isinstance(ref.get("slice_index"), int) + and (ref["series_name"], ref["slice_index"]) in allowed + ] + if len(valid) != len(finding.image_references): + context.limitations.append(f"Removed invalid image references for '{finding.name}'.") + finding.image_references = valid # Populate context from result context.findings = result.structures context.overall_impression = result.overall_impression context.clinical_correlation = result.clinical_correlation - context.limitations = result.limitations + context.limitations = list(dict.fromkeys([*context.limitations, *result.limitations])) return context diff --git a/src/medcheck/providers/local.py b/src/medcheck/providers/local.py index f17978a..f2e8e46 100644 --- a/src/medcheck/providers/local.py +++ b/src/medcheck/providers/local.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import stat import tempfile import zipfile @@ -29,6 +30,14 @@ def authenticate(self, credentials: dict[str, str]) -> bool: def fetch(self, target: str, credentials: dict[str, str]) -> list[DicomSeries]: target_path = Path(target) + if target_path.is_file() and target_path.name.upper() == "DICOMDIR": + if target_path.stat().st_size > self._byte_limit(): + raise ValueError("DICOMDIR exceeds MEDCHECK_MAX_DICOM_BYTES byte limit") + return self._scan_files(self._dicomdir_files(target_path)) + + if target_path.is_file() and target_path.suffix.lower() in {".dcm", ".dicom"}: + return self._scan_files([target_path]) + if target_path.is_dir(): return self._scan_directory(target_path) @@ -42,21 +51,64 @@ def fetch(self, target: str, credentials: dict[str, str]) -> list[DicomSeries]: # ------------------------------------------------------------------ def _scan_directory(self, directory: Path) -> list[DicomSeries]: - series_map: dict[str, dict[str, Any]] = {} + root = directory.resolve() + files = [p for p in directory.rglob("*") if p.resolve().is_relative_to(root)] + return self._scan_files(files) + + @staticmethod + def _dicomdir_files(path: Path) -> list[Path]: + """Resolve DICOMDIR records strictly inside the media directory.""" + dataset = pydicom.dcmread(path) + root = path.parent.resolve() + files: list[Path] = [] + for record in getattr(dataset, "DirectoryRecordSequence", []): + reference = getattr(record, "ReferencedFileID", None) + if reference is None: + continue + parts = reference.split("\\") if isinstance(reference, str) else list(reference) + if any(part in {"", ".", ".."} or "/" in part or "\\" in part for part in parts): + raise ValueError("Unsafe referenced path in DICOMDIR") + candidate = root.joinpath(*parts).resolve() + if not candidate.is_relative_to(root): + raise ValueError("DICOMDIR reference escapes media directory") + if not candidate.is_file(): + raise ValueError("DICOMDIR references a missing image file") + if candidate not in files: + files.append(candidate) + return files - for file in directory.rglob("*"): + @staticmethod + def _byte_limit() -> int: + limit = int(os.environ.get("MEDCHECK_MAX_DICOM_BYTES", str(512 * 1024**2))) + if limit <= 0: + raise ValueError("MEDCHECK_MAX_DICOM_BYTES must be positive") + return limit + + def _scan_files(self, files: list[Path]) -> list[DicomSeries]: + series_map: dict[tuple[str, str], dict[str, Any]] = {} + + total_bytes = 0 + byte_limit = self._byte_limit() + for file in sorted(files): if not file.is_file(): continue if file.suffix.lower() in _SKIP_SUFFIXES: continue + total_bytes += file.stat().st_size + if total_bytes > byte_limit: + raise ValueError("DICOM input exceeds MEDCHECK_MAX_DICOM_BYTES byte limit") ds = self._try_read(file) if ds is None: continue desc = getattr(ds, "SeriesDescription", "") or "" series_num = int(getattr(ds, "SeriesNumber", 0) or 0) - key = desc or str(series_num) + study_uid = str(getattr(ds, "StudyInstanceUID", "") or "") + series_uid = str(getattr(ds, "SeriesInstanceUID", "") or "") + # Legacy data without UIDs stays separated by directory and series number. + study_key = study_uid or f"legacy:{file.parent.resolve()}" + key = (study_key, series_uid or f"legacy:{series_num}:{desc}") if key not in series_map: series_map[key] = { @@ -64,6 +116,7 @@ def _scan_directory(self, directory: Path) -> list[DicomSeries]: "series_number": series_num, "modality": getattr(ds, "Modality", "") or "", "slices": [], + "metadata": {"study_instance_uid": study_uid, "series_instance_uid": series_uid}, } series_map[key]["slices"].append(ds) @@ -76,6 +129,7 @@ def _scan_directory(self, directory: Path) -> list[DicomSeries]: series_number=s["series_number"], modality=s["modality"], slices=s["slices"], + metadata=s["metadata"], ) for s in results ] @@ -100,7 +154,7 @@ def _scan_zip(self, zip_path: Path) -> list[DicomSeries]: raise ValueError(f"ZIP member is a symlink (not allowed): {info.filename}") # ZIP-bomb guards: cap total size and per-member compression ratio. total_uncompressed += info.file_size - if total_uncompressed > _ZIP_MAX_TOTAL_UNCOMPRESSED: + if total_uncompressed > min(_ZIP_MAX_TOTAL_UNCOMPRESSED, self._byte_limit()): raise ValueError("ZIP uncompressed size exceeds the allowed limit") if info.compress_size > 0 and info.file_size / info.compress_size > _ZIP_MAX_COMPRESSION_RATIO: raise ValueError(f"ZIP member has suspicious compression ratio: {info.filename}") diff --git a/src/medcheck/web/api.py b/src/medcheck/web/api.py new file mode 100644 index 0000000..b1236b6 --- /dev/null +++ b/src/medcheck/web/api.py @@ -0,0 +1,191 @@ +"""Authenticated local workbench API, with bounded uploads and background jobs.""" + +from __future__ import annotations + +import importlib.util +import io +import json +import shutil +import zipfile +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse, Response +from PIL import Image + +from medcheck.core.config import Settings +from medcheck.pipeline.exports import dicom_sr, fhir_report +from medcheck.pipeline.report import generate_html_report, generate_pdf_report +from medcheck.web.jobs import AnalyzeRequest, JobStore, ReviewRequest, SourceRequest, context_from_report, preview + + +def install_api(app: FastAPI, settings: Settings, guard: Any, rate_limit: Any) -> None: # noqa: C901 - route factory + router = APIRouter(prefix="/api", dependencies=[Depends(guard)]) + + def store(request: Request) -> JobStore: + # Initialized by app lifespan in real deployments; tests may skip lifespan. + with app.state.store_lock: + if getattr(app.state, "jobs", None) is None: + app.state.jobs = JobStore(settings) + result: JobStore = app.state.jobs + return result + + @router.get("/capabilities") + def capabilities() -> dict[str, Any]: + from medcheck.llm.claude import ClaudeProvider + from medcheck.llm.gemini import GeminiProvider + from medcheck.llm.local import LocalLLMProvider + from medcheck.llm.openai_provider import OpenAIProvider + + providers = [ClaudeProvider(), OpenAIProvider(), GeminiProvider(), LocalLLMProvider()] + return { + "ocr_available": importlib.util.find_spec("pytesseract") is not None + and shutil.which("tesseract") is not None, + "providers": [ + {"name": p.name, "model": getattr(p, "model", ""), "available": p.check_available()} for p in providers + ], + "max_vision_images": settings.max_vision_images, + "max_upload_bytes": settings.max_upload_bytes, + "supported_formats": ["json", "html", "pdf", "fhir", "dicom-sr"], + "pricing": {"source": "operator-configured estimates", "billing_guarantee": False}, + } + + @router.post("/upload", dependencies=[Depends(rate_limit)]) + async def upload(file: UploadFile, request: Request) -> dict[str, str]: + db = store(request) + name = Path(file.filename or "scan.zip").name + suffix = Path(name).suffix.lower() + if suffix not in {".zip", ".dcm", ".dicom"}: + raise HTTPException(422, "Upload a DICOM ZIP archive or a .dcm file.") + if len(list(db.uploads.iterdir())) >= settings.max_jobs: + raise HTTPException(409, "Upload storage is full. Delete previous uploads before adding another.") + token = uuid4().hex + # Single DICOM files live in their own directory, preventing mixed uploads. + destination = db.uploads / f"{token}{suffix}" + total = 0 + try: + with destination.open("xb") as output: + while chunk := await file.read(1024 * 1024): + total += len(chunk) + if total > settings.max_upload_bytes: + raise HTTPException(413, "Upload exceeds the configured size limit.") + output.write(chunk) + if total == 0: + raise HTTPException(422, "The uploaded file is empty.") + except BaseException: + destination.unlink(missing_ok=True) + raise + finally: + await file.close() + return {"source": f"upload:{token}", "filename": name} + + @router.delete("/uploads/{token}") + def delete_upload(token: str, request: Request) -> dict[str, str]: + db = store(request) + try: + path = db.resolve_source(f"upload:{token}") + # Jobs read source files only during ingest; refuse cleanup while anything is active. + if any(j["status"] in {"queued", "running"} for j in db.jobs.values()): + raise ValueError("Wait for active analyses to stop before deleting uploads.") + path.unlink() + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + return {"status": "deleted"} + + @router.post("/inspect", dependencies=[Depends(rate_limit)]) + def inspect_source(req: SourceRequest, request: Request) -> dict[str, Any]: + try: + return store(request).inspect(req.source) + except (ValueError, OSError, zipfile.BadZipFile) as exc: + raise HTTPException(422, str(exc)) from exc + + @router.post("/preview") + def preview_analysis(req: AnalyzeRequest) -> dict[str, Any]: + return preview(req, settings) + + @router.post("/analyze", status_code=202, dependencies=[Depends(rate_limit)]) + def analyze(req: AnalyzeRequest, request: Request) -> dict[str, Any]: + try: + return store(request).submit(req) + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc + + @router.get("/jobs") + def list_jobs(request: Request) -> list[dict[str, Any]]: + db = store(request) + with db.lock: + return [{k: v for k, v in job.items() if k != "result"} for job in db.jobs.values()] + + @router.get("/jobs/{job_id}") + def job(job_id: str, request: Request) -> dict[str, Any]: + try: + return store(request).get(job_id) + except KeyError as exc: + raise HTTPException(404, "Analysis not found.") from exc + + @router.post("/jobs/{job_id}/cancel") + def cancel(job_id: str, request: Request) -> dict[str, Any]: + job(job_id, request) + return store(request).cancel(job_id) + + @router.delete("/jobs/{job_id}") + def delete(job_id: str, request: Request) -> dict[str, str]: + try: + store(request).delete(job_id) + except KeyError as exc: + raise HTTPException(404, "Analysis not found.") from exc + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + except OSError as exc: + raise HTTPException(409, "Analysis files could not be deleted. Check storage access and retry.") from exc + return {"status": "deleted"} + + @router.patch("/jobs/{job_id}/findings/{index}") + def review(job_id: str, index: int, req: ReviewRequest, request: Request) -> dict[str, Any]: + job(job_id, request) + try: + return store(request).review(job_id, index, req) + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc + + @router.get("/jobs/{job_id}/images/{series_index}/{slice_index}") + def image(job_id: str, series_index: int, slice_index: int, request: Request) -> Response: + try: + arr = store(request).read_slice(job_id, series_index, slice_index) + except KeyError as exc: + raise HTTPException(404, "Analysis not found.") from exc + except IndexError as exc: + raise HTTPException(404, "Image not found.") from exc + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + png = io.BytesIO() + Image.fromarray((arr.clip(0, 1) * 255).astype("uint8")).save(png, format="PNG") + return Response(png.getvalue(), media_type="image/png") + + @router.get("/jobs/{job_id}/report") + def download(job_id: str, request: Request, format: str = "json") -> Response: + current = job(job_id, request) + if current["status"] != "completed": + raise HTTPException(409, "Wait for the analysis to finish before downloading.") + db = store(request) + report = current["result"] + ctx = context_from_report(report, db.root / job_id) + headers = {"Content-Disposition": f'attachment; filename="medcheck-{job_id}.{format}"'} + if format == "json": + return Response(json.dumps(report, indent=2), media_type="application/json", headers=headers) + if format == "fhir": + headers["Content-Disposition"] = f'attachment; filename="medcheck-{job_id}-fhir.json"' + return Response( + json.dumps(fhir_report(report), indent=2), media_type="application/fhir+json", headers=headers + ) + if format == "dicom-sr": + headers["Content-Disposition"] = f'attachment; filename="medcheck-{job_id}-sr.dcm"' + return Response(dicom_sr(ctx, report), media_type="application/dicom", headers=headers) + if format in {"pdf", "html"}: + path = generate_pdf_report(ctx) if format == "pdf" else generate_html_report(ctx) + return FileResponse(path, filename=f"medcheck-{job_id}.{format}") + raise HTTPException(422, "Unsupported report format.") + + app.include_router(router) diff --git a/src/medcheck/web/app.py b/src/medcheck/web/app.py index 46364f1..bb4c187 100644 --- a/src/medcheck/web/app.py +++ b/src/medcheck/web/app.py @@ -16,12 +16,12 @@ from fastapi.security import APIKeyHeader from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from pydantic import BaseModel, Field from starlette.middleware.base import BaseHTTPMiddleware from medcheck import __version__ from medcheck.core.config import Settings from medcheck.i18n import get_strings +from medcheck.web.api import install_api TEMPLATES_DIR = Path(__file__).parent / "templates" STATIC_DIR = Path(__file__).parent / "static" @@ -33,24 +33,6 @@ UI_LANGUAGES = ("en", "de", "fr", "es") -class AnalyzeRequest(BaseModel): - """Validated request body for POST /api/analyze.""" - - source: str = Field(..., min_length=1, description="DICOM folder/ZIP path or portal URL") - provider: str | None = Field(default=None, description="Data provider name (auto-detected if omitted)") - anatomy: str | None = Field(default=None, description="Anatomy region hint, e.g. 'knee'") - report_format: str = Field(default="json", pattern="^(json|pdf|html)$") - # Keep in sync with the CLI (_REPORT_LANGUAGES) and the i18n catalogs - # (medcheck/i18n/*.json) so fr/es requests aren't rejected with 422. - language: str = Field(default="en", pattern="^(en|de|fr|es)$") - # Per-request consent to transmit patient-derived data to external cloud - # LLM APIs — mirrors the CLI --allow-cloud-llm flag. - allow_cloud_llm: bool = Field( - default=False, - description="Consent to sending patient-derived data to external cloud LLM APIs", - ) - - def _make_api_key_guard(expected_key: str | None) -> Any: """Build a dependency that enforces the X-API-Key header when configured. @@ -82,6 +64,7 @@ class _SecurityHeadersMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Any) -> Any: response = await call_next(request) + response.headers["Cache-Control"] = "no-store" response.headers["X-Frame-Options"] = "DENY" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "no-referrer" @@ -155,7 +138,7 @@ def allow(self, key: str) -> bool: hits.append(now) # Opportunistically drop empty buckets so the map can't grow unbounded. if len(self._hits) > 10_000: - for k in [k for k, v in self._hits.items() if not v]: + for k in [k for k, v in self._hits.items() if not v or now - v[-1] > self.window]: del self._hits[k] return True @@ -179,7 +162,17 @@ def _client_key(request: Request, trust_proxy_headers: bool) -> str: def create_app(settings: Settings | None = None) -> FastAPI: settings = settings or Settings() - app = FastAPI(title="MedCheck", version=__version__) + from contextlib import asynccontextmanager + + @asynccontextmanager + async def lifespan(app: FastAPI) -> Any: + yield + if getattr(app.state, "jobs", None) is not None: + app.state.jobs.close() + + app = FastAPI(title="MedCheck", version=__version__, lifespan=lifespan) + app.state.store_lock = threading.Lock() + app.state.jobs = None require_api_key = _make_api_key_guard(settings.api_key) app.add_middleware(_SecurityHeadersMiddleware) app.add_middleware(_OriginCheckMiddleware) @@ -223,18 +216,5 @@ def index(request: Request, lang: str | None = None) -> Any: }, ) - @app.post("/api/analyze") - def analyze( - req: AnalyzeRequest, - _: None = Depends(require_api_key), - __: None = Depends(enforce_rate_limit), - ) -> dict[str, Any]: - # Pipeline execution is not wired up yet. Return 501 (not 200) so clients, - # health checks, and CI can detect that no analysis was performed; the - # validated source is echoed in the detail so callers can confirm parsing/auth. - raise HTTPException( - status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail=f"Pipeline execution is not implemented yet (source={req.source!r}).", - ) - + install_api(app, settings, require_api_key, enforce_rate_limit) return app diff --git a/src/medcheck/web/jobs.py b/src/medcheck/web/jobs.py new file mode 100644 index 0000000..49800dd --- /dev/null +++ b/src/medcheck/web/jobs.py @@ -0,0 +1,460 @@ +"""Bounded single-process jobs and study storage for the local research workbench.""" + +from __future__ import annotations + +import json +import os +import shutil +import threading +from concurrent.futures import ThreadPoolExecutor +from dataclasses import fields +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Literal, cast +from uuid import uuid4 + +import numpy as np +from pydantic import BaseModel, Field, model_validator + +from medcheck import __version__ +from medcheck.core.config import Settings +from medcheck.core.context import ClinicalContext, PatientInfo, PipelineContext, StructureFinding, StudyInfo +from medcheck.pipeline.ingest import IngestStep +from medcheck.pipeline.ml_analysis import MLAnalysisStep +from medcheck.pipeline.preprocess import PreprocessStep +from medcheck.pipeline.privacy import DeidentifyStep, apply_pixel_redactions +from medcheck.pipeline.reconcile import ReconcileStep +from medcheck.pipeline.report import generate_json_report +from medcheck.pipeline.vision_analysis import VisionAnalysisStep +from medcheck.providers.local import LocalProvider + + +class AnalyzeRequest(BaseModel): + source: str = Field(min_length=1, max_length=2048) + provider: Literal["local"] | None = None + study_uid: str = Field(default="", max_length=128) + mode: Literal["local", "vision"] = "local" + llm_provider: Literal["local", "claude", "openai", "gemini"] = "local" + anatomy: str = Field(default="", max_length=80) + symptoms: str = Field(default="", max_length=10000) + trauma: str = Field(default="", max_length=10000) + suspected_diagnosis: str = Field(default="", max_length=10000) + report_format: Literal["json", "html", "pdf", "fhir", "dicom-sr"] = "json" + language: Literal["en", "de", "fr", "es"] = "en" + allow_cloud_llm: bool = False + deidentify: bool = True + pixels_reviewed: bool = False + ocr_redact: bool = False + budget_usd: float | None = Field(default=None, gt=0, le=10000, allow_inf_nan=False) + official_report: str = Field(default="", max_length=100000) + redactions: dict[str, list[list[int]]] = Field(default_factory=dict) + + +class SourceRequest(BaseModel): + source: str = Field(min_length=1, max_length=2048) + + +class ReviewRequest(BaseModel): + status: Literal["confirmed", "rejected", "edited"] + findings: str | None = Field(default=None, max_length=20000) + note: str = Field(default="", max_length=5000) + + @model_validator(mode="after") + def require_edit(self) -> ReviewRequest: + if self.status == "edited" and not (self.findings or "").strip(): + raise ValueError("An edited finding requires replacement text.") + return self + + +def study_warnings(slices: list[Any]) -> list[str]: + """Cheap metadata preflight before any decoding or model invocation.""" + warnings = [] + if any(not hasattr(ds, "ImagePositionPatient") or not hasattr(ds, "ImageOrientationPatient") for ds in slices): + warnings.append("Some images have no complete position/orientation metadata; slice order needs review.") + if any(int(getattr(ds, "NumberOfFrames", 1)) != 1 or int(getattr(ds, "SamplesPerPixel", 1)) != 1 for ds in slices): + warnings.append("Multiframe or color images are not supported and will be excluded.") + shapes = {(getattr(ds, "Rows", 0), getattr(ds, "Columns", 0)) for ds in slices} + if len(shapes) > 1: + warnings.append("Mixed image dimensions detected; images outside the dominant shape will be excluded.") + positions = [tuple(ds.ImagePositionPatient) for ds in slices if hasattr(ds, "ImagePositionPatient")] + if len(set(positions)) != len(positions): + warnings.append("Repeated image positions detected; review for duplicate acquisitions.") + if any(str(getattr(ds, "BurnedInAnnotation", "")) != "NO" for ds in slices): + warnings.append("Embedded patient text has not been ruled out. Review pixels before sharing images.") + return warnings + + +def preview(req: AnalyzeRequest, settings: Settings) -> dict[str, Any]: + external = req.mode == "vision" and req.llm_provider != "local" + # Operator-supplied conservative per-analysis estimate, never a fabricated price. + raw = os.environ.get(f"MEDCHECK_{req.llm_provider.upper()}_ESTIMATED_COST_USD", "") + estimate = None + if external and raw: + try: + number = float(raw) + if 0 <= number < 10000: + estimate = number + except ValueError: + pass + if not external: + estimate = 0.0 + return { + "provider": req.llm_provider if req.mode == "vision" else "statistical", + "external": external, + "image_limit": settings.max_vision_images if req.mode == "vision" else 0, + "estimated_cost_usd": estimate, + "context_fields": [key for key in ("symptoms", "trauma", "suspected_diagnosis") if getattr(req, key)], + "warnings": ["Cost is an operator estimate, not a billing guarantee; retries may incur additional charges."] + if external + else [], + } + + +def context_from_report(report: dict[str, Any], folder: Path) -> PipelineContext: + ctx = PipelineContext(output_dir=str(folder), report_language=report.get("language", "en")) + ctx.study_instance_uid = report.get("study_instance_uid", "") + ctx.deidentify = report.get("deidentified", False) + ctx.pixels_reviewed = report.get("privacy", {}).get("pixels_reviewed", False) + ctx.patient = PatientInfo(**report["patient"]) + ctx.study = StudyInfo(**report["study"]) + allowed = {f.name for f in fields(StructureFinding)} + ctx.findings = [StructureFinding(**{k: v for k, v in f.items() if k in allowed}) for f in report["findings"]] + for name in ( + "overall_impression", + "clinical_correlation", + "limitations", + "review_history", + "analysis_provenance", + "quality_checks", + "reconciliation", + ): + setattr(ctx, name, report.get(name, getattr(ctx, name))) + return ctx + + +def _close_volumes(context: PipelineContext) -> None: + """Release owned mmap handles explicitly, including when NumPy views survive. + + Store callers hold the lock; read_slice only exposes independent copies so + a viewer request cannot use a mapping while it is being closed. + """ + for volume in context.volumes.values(): + mapping = getattr(volume, "_mmap", None) + if mapping is not None: + mapping.close() + context.volumes.clear() + + +class JobStore: + def __init__(self, settings: Settings) -> None: + self.settings = settings + self.root = Path(settings.state_dir).resolve() + self.root.mkdir(mode=0o700, parents=True, exist_ok=True) + self.uploads = self.root / "uploads" + self.uploads.mkdir(mode=0o700, exist_ok=True) + self.data_root = Path(settings.data_root).resolve() + self.lock = threading.RLock() + self.jobs: dict[str, dict[str, Any]] = {} + self.contexts: dict[str, PipelineContext] = {} + self.cancels: dict[str, threading.Event] = {} + self.pool = ThreadPoolExecutor(max_workers=max(1, settings.job_workers), thread_name_prefix="medcheck") + # Interrupted jobs are recorded explicitly on restart; raw images aren't persisted twice. + for path in self.root.glob("*/status.json"): + try: + job = json.loads(path.read_text()) + if job["status"] in {"queued", "running"}: + job.update( + status="failed", error="Server restarted before analysis finished. Start a new analysis." + ) + job["viewer_available"] = False + if job["id"] != path.parent.name: + continue + self.jobs[job["id"]] = job + if job["status"] == "completed" and (path.parent / "volumes.json").exists(): + ctx = context_from_report(job["result"], path.parent) + keys = json.loads((path.parent / "volumes.json").read_text()) + ctx.volumes = { + key: np.load(path.parent / f"volume-{index}.npy", mmap_mode="r", allow_pickle=False) + for index, key in enumerate(keys) + } + self.contexts[job["id"]] = ctx + job["viewer_available"] = True + except (OSError, ValueError, KeyError): + continue + + def resolve_source(self, source: str) -> Path: + if source.startswith("upload:"): + token = source.removeprefix("upload:") + if len(token) != 32 or any(c not in "0123456789abcdef" for c in token): + raise ValueError("Invalid upload identifier") + matches = list(self.uploads.glob(f"{token}.*")) + if len(matches) != 1: + raise ValueError("Upload not found. Upload the file again.") + return matches[0] + path = Path(source).expanduser().resolve() + if not path.is_relative_to(self.data_root): + raise ValueError("Choose an uploaded file or a path inside MEDCHECK_DATA_ROOT.") + if not path.exists(): + raise ValueError("Source does not exist. Upload a DICOM ZIP or check the server data folder.") + # Disallow symlinks below approved root too (LocalProvider recursively reads files). + if path.is_dir() and any(p.is_symlink() for p in path.rglob("*")): + raise ValueError("Symbolic links are not accepted in server data directories.") + return path + + def inspect(self, source: str) -> dict[str, Any]: + series = LocalProvider().fetch(str(self.resolve_source(source)), {}) + studies: dict[str, dict[str, Any]] = {} + for item in series: + uid = str(item.metadata.get("study_instance_uid", "")) + ds = item.slices[0] + study = studies.setdefault( + uid, + { + "study_uid": uid, + "description": str(getattr(ds, "StudyDescription", "Study")), + "date": str(getattr(ds, "StudyDate", "")), + "series": [], + "warnings": [], + }, + ) + study["series"].append( + { + "name": item.description, + "series_uid": item.metadata.get("series_instance_uid", ""), + "slices": len(item.slices), + "modality": item.modality, + } + ) + study["warnings"] = list(dict.fromkeys([*study["warnings"], *study_warnings(item.slices)])) + if not studies: + raise ValueError("No readable DICOM images found in this source.") + return {"source": source, "studies": list(studies.values())} + + def _save(self, job_id: str) -> None: + folder = self.root / job_id + folder.mkdir(mode=0o700, exist_ok=True) + temp = folder / "status.tmp" + temp.write_text(json.dumps(self.jobs[job_id]), encoding="utf-8") + temp.replace(folder / "status.json") + + def submit(self, req: AnalyzeRequest) -> dict[str, Any]: + if req.mode == "vision" and req.llm_provider != "local": + if not req.allow_cloud_llm or not req.pixels_reviewed: + raise ValueError("Cloud analysis requires consent and review of images for identifying information.") + source = self.resolve_source(req.source) + plan = preview(req, self.settings) + estimate = plan["estimated_cost_usd"] + if req.budget_usd is not None and (estimate is None or estimate > req.budget_usd): + raise ValueError( + "Cost estimate is unavailable or exceeds your budget. Configure an estimate or adjust the budget." + ) + with self.lock: + if len(self.jobs) >= self.settings.max_jobs: + raise ValueError("Job storage is full. Delete completed analyses before starting another.") + job_id = uuid4().hex + self.jobs[job_id] = { + "id": job_id, + "status": "queued", + "step": "queued", + "progress": 0, + "created_at": datetime.now(timezone.utc).isoformat(), + "error": None, + "result": None, + "viewer_available": False, + "cancel_requested": False, + } + self.cancels[job_id] = threading.Event() + self._save(job_id) + self.pool.submit(self._run, job_id, req, str(source), plan) + return dict(self.jobs[job_id]) + + def _run(self, job_id: str, req: AnalyzeRequest, source: str, plan: dict[str, Any]) -> None: # noqa: C901 + ctx = PipelineContext( + source=source, + provider_name="local", + study_instance_uid=req.study_uid, + clinical_context=ClinicalContext( + symptoms=req.symptoms, + trauma=req.trauma, + suspected_diagnosis=req.suspected_diagnosis, + anatomy=req.anatomy, + ), + report_format=req.report_format, + report_language=req.language, + llm_provider=req.llm_provider, + deidentify=req.deidentify, + allow_external_llm=req.allow_cloud_llm, + pixels_reviewed=req.pixels_reviewed, + official_report=req.official_report, + redactions=req.redactions, + output_dir=str(self.root / job_id), + ) + ctx.analysis_provenance.update( + { + "app_version": __version__, + "run_id": job_id, + "started_at": datetime.now(timezone.utc).isoformat(), + "transmission_preview": plan, + "settings": { + "mode": req.mode, + "deidentify": req.deidentify, + "image_limit": self.settings.max_vision_images, + }, + } + ) + steps = [IngestStep(), DeidentifyStep(), PreprocessStep(), MLAnalysisStep()] + if req.mode == "vision": + steps.append(VisionAnalysisStep()) + steps.append(ReconcileStep()) + try: + for index, step in enumerate(steps): + if self.cancels[job_id].is_set(): + break + with self.lock: + self.jobs[job_id].update(status="running", step=step.name, progress=int(index / len(steps) * 95)) + self._save(job_id) + # Web default must never trigger model weight downloads. + ctx.step_config = {"backend": "statistical"} if step.name == "ml_analysis" else {} + ctx = step.run(ctx) + if step.name == "ingest": + pixels = sum( + int(getattr(ds, "Rows", 0)) + * int(getattr(ds, "Columns", 0)) + * int(getattr(ds, "NumberOfFrames", 1)) + for s in ctx.dicom_series + for ds in s.slices + ) + if pixels > 128 * 1024 * 1024: + raise ValueError("Study exceeds the decoded image limit. Select a smaller study.") + if step.name == "preprocess": + if not ctx.volumes: + raise ValueError("No usable image volumes. Check the DICOM encoding and quality warnings.") + ctx.step_config["ocr"] = req.ocr_redact + apply_pixel_redactions(ctx) + with self.lock: + if self.cancels[job_id].is_set(): + self.jobs[job_id].update(status="cancelled", step="cancelled") + else: + if req.mode == "local": + ctx.overall_impression = ( + "Local image quality and relative image statistics completed. " + "No diagnostic findings were generated." + ) + ctx.analysis_provenance["completed_at"] = datetime.now(timezone.utc).isoformat() + folder = self.root / job_id + for index, (key, volume) in enumerate(ctx.volumes.items()): + path = folder / f"volume-{index}.npy" + np.save(path, volume, allow_pickle=False) + ctx.volumes[key] = np.load(path, mmap_mode="r", allow_pickle=False) + (folder / "volumes.json").write_text(json.dumps(list(ctx.volumes)), encoding="utf-8") + ctx.dicom_series.clear() + self.contexts[job_id] = ctx + self.jobs[job_id].update( + status="completed", + step="completed", + progress=100, + result=json.loads(generate_json_report(ctx)), + viewer_available=True, + ) + self._save(job_id) + except Exception as exc: + # Known input failures are actionable; provider responses can contain PHI/secrets. + message = ( + str(exc) + if isinstance(exc, (ValueError, PermissionError)) + else f"{type(exc).__name__}: analysis failed. Check local configuration and retry." + ) + with self.lock: + cancelled = self.cancels[job_id].is_set() + self.jobs[job_id].update( + status="cancelled" if cancelled else "failed", error=None if cancelled else message + ) + self._save(job_id) + + def get(self, job_id: str) -> dict[str, Any]: + with self.lock: + if job_id not in self.jobs: + raise KeyError("Analysis not found") + return cast( + dict[str, Any], json.loads(json.dumps(self.jobs[job_id])) + ) # detach response from worker mutation + + def cancel(self, job_id: str) -> dict[str, Any]: + with self.lock: + job = self.get(job_id) + if job["status"] in {"queued", "running"}: + self.cancels[job_id].set() + self.jobs[job_id]["cancel_requested"] = True + self._save(job_id) + return self.get(job_id) + + def read_slice(self, job_id: str, series_index: int, slice_index: int) -> np.ndarray[Any, np.dtype[Any]]: + """Copy one image under the deletion lock; no file-backed views escape.""" + with self.lock: + if job_id not in self.jobs: + raise KeyError("Analysis not found") + context = self.contexts.get(job_id) + if context is None: + raise ValueError("Image viewer unavailable. Run the analysis again.") + volumes = list(context.volumes.values()) + if not 0 <= series_index < len(volumes) or not 0 <= slice_index < volumes[series_index].shape[0]: + raise IndexError("Image not found") + return np.array(volumes[series_index][slice_index], copy=True) + + def delete(self, job_id: str) -> None: + with self.lock: + job = self.get(job_id) + if job["status"] in {"queued", "running"}: + raise ValueError("Cancel the analysis and wait for it to stop before deleting it.") + context = self.contexts.pop(job_id, None) + if context: + _close_volumes(context) + self.jobs[job_id]["viewer_available"] = False + try: + shutil.rmtree(self.root / job_id) + except OSError: + # A partial deletion must not disappear from the catalog or be + # reported as successful. Restore status for a later retry. + self._save(job_id) + raise ValueError("Analysis files could not be deleted. Close other file users and retry.") from None + self.jobs.pop(job_id) + self.cancels.pop(job_id, None) + + def review(self, job_id: str, index: int, req: ReviewRequest) -> dict[str, Any]: + with self.lock: + job = self.get(job_id) + if job["status"] != "completed": + raise ValueError("Only completed analyses can be reviewed.") + report = job["result"] + if not 0 <= index < len(report["findings"]): + raise ValueError("Finding does not exist.") + finding = report["findings"][index] + before = dict(finding) + finding["review_status"] = req.status + if req.status == "edited": + finding["findings"] = req.findings + event = { + "finding_index": index, + "at": datetime.now(timezone.utc).isoformat(), + "before": before, + "after": dict(finding), + "note": req.note, + } + report["review_history"].append(event) + self.jobs[job_id]["result"] = report + if job_id in self.contexts: + ctx = self.contexts[job_id] + ctx.findings[index].review_status = req.status + ctx.findings[index].findings = finding["findings"] + ctx.review_history = report["review_history"] + self._save(job_id) + return cast(dict[str, Any], report) + + def close(self) -> None: + with self.lock: + for event in self.cancels.values(): + event.set() + self.pool.shutdown(wait=False, cancel_futures=True) + for context in self.contexts.values(): + _close_volumes(context) + self.contexts.clear() diff --git a/src/medcheck/web/static/app.js b/src/medcheck/web/static/app.js index 24015fb..0ae3d47 100644 --- a/src/medcheck/web/static/app.js +++ b/src/medcheck/web/static/app.js @@ -1,208 +1,787 @@ -// MedCheck UI logic. Kept in a separate file (no inline scripts/handlers) so the -// Content-Security-Policy can exclude 'unsafe-inline' from script-src. +// Same-origin UI: only opaque job/upload references survive refresh in sessionStorage. +// Credentials and patient context are never persisted in browser storage. (function () { - 'use strict'; - - var CLOUD_MODELS = ['claude', 'openai', 'gemini']; - - // Wizard step switching (stepper buttons + prev/next buttons use data-goto) - function showStep(n) { - document.querySelectorAll('.tab-pane').forEach(function (p) { - p.classList.remove('active'); - }); - var pane = document.getElementById('pane-' + n); - if (pane) pane.classList.add('active'); - - document.querySelectorAll('.pdp-stepper-step').forEach(function (s) { - var step = parseInt(s.getAttribute('data-step'), 10); - s.classList.toggle('active', step === n); - s.classList.toggle('completed', step < n); - if (step === n) { - s.setAttribute('aria-current', 'step'); - } else { - s.removeAttribute('aria-current'); + "use strict"; + var source = null, + studies = [], + selectedStudy = "", + jobId = null, + result = null, + busy = false, + uploadVersion = 0; + var currentStep = 1, + previewVersion = 0, + imageVersion = 0; + var $ = function (id) { + return document.getElementById(id); + }; + function message(key) { + return ( + $("uiStrings").getAttribute("data-ui-" + key.replace(/_/g, "-")) || key + ); + } + function node(tag, text, cls) { + var el = document.createElement(tag); + if (text != null) el.textContent = text; + if (cls) el.className = cls; + return el; + } + function headers() { + var h = {}; + if ($("apiKey").value) h["X-API-Key"] = $("apiKey").value; + return h; + } + async function request(url, options) { + options = options || {}; + options.headers = Object.assign(headers(), options.headers || {}); + var controller = new AbortController(); + options.signal = controller.signal; + var timeout = setTimeout(function () { + controller.abort(); + }, 120000); + try { + var response = await fetch(url, options); + if (!response.ok) { + var detail; + try { + detail = (await response.json()).detail; + } catch (_) { + detail = null; + } + var error = new Error( + typeof detail === "string" ? detail : "HTTP " + response.status, + ); + error.status = response.status; + throw error; } - }); + return response; + } finally { + clearTimeout(timeout); + } + } + async function json(url, body, method) { + var options = + body === undefined + ? {} + : { + method: method || "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; + return (await request(url, options)).json(); } - - // File dropzone - use textContent for safe DOM updates - function fileSelected(input) { - if (input.files && input.files[0]) { - var f = input.files[0]; - var subtext = document.getElementById('dropzoneSubtext'); - var sizeMB = (f.size / 1024 / 1024).toFixed(2); - subtext.textContent = f.name + ' (' + sizeMB + ' MB)'; - var badge = document.getElementById('autoDetectBadge'); - if (badge) badge.classList.add('is-visible'); - } - } - - function isCloudModel() { - var select = document.getElementById('modelSelect'); - return !!select && CLOUD_MODELS.indexOf(select.value) !== -1; - } - - function syncConsentVisibility() { - var block = document.getElementById('consentBlock'); - if (block) block.classList.toggle('is-visible', isCloudModel()); - } - - // Replace the results area content with a kit alert (text set via textContent, - // never innerHTML — the server response echoes user input). - function showResultAlert(kind, text) { - var results = document.getElementById('resultsContent'); - if (!results) return; - results.textContent = ''; - results.classList.remove('results-empty'); - var alert = document.createElement('div'); - alert.className = 'alert alert-' + kind; - alert.setAttribute('role', 'alert'); - var body = document.createElement('p'); - body.textContent = text; - alert.appendChild(body); - results.appendChild(alert); - alert.style.marginBottom = '1.5rem'; - } - - function setProgress(percent, labelText) { - var fill = document.getElementById('progressFill'); - var label = document.getElementById('progressLabel'); - if (fill) fill.style.width = percent + '%'; - if (label && labelText) label.textContent = labelText; - } - - function submitAnalysis(form) { - var startBtn = document.getElementById('startBtn'); - var consent = document.getElementById('consentCheck'); - var cloud = isCloudModel(); - - if (cloud && (!consent || !consent.checked)) { - showResultAlert('danger', form.getAttribute('data-msg-consent')); - showStep(3); - if (consent) consent.focus(); + function alertText(text) { + $("formAlert").textContent = text; + $("formAlert").hidden = !text; + if (text) $("formAlert").focus(); + } + function showStep(n) { + if (n > 1 && !source) { + alertText(message("choose_file")); return; } - - var urlInput = document.getElementById('sourceUrl'); - var fileInput = document.getElementById('fileInput'); - var source = (urlInput && urlInput.value.trim()) || - (fileInput && fileInput.files && fileInput.files[0] && fileInput.files[0].name) || - 'browser-upload'; - - var anatomy = document.getElementById('anatomy'); - var language = document.getElementById('reportLanguage'); - var format = document.getElementById('reportFormat'); - - var body = { + alertText(""); + currentStep = n; + document.querySelectorAll(".tab-pane").forEach(function (p, i) { + p.classList.toggle("active", i + 1 === n); + }); + document.querySelectorAll(".pdp-stepper-step").forEach(function (s) { + var step = Number(s.dataset.step); + s.classList.toggle("active", step === n); + s.classList.toggle("completed", step < n); + if (step === n) s.setAttribute("aria-current", "step"); + else s.removeAttribute("aria-current"); + }); + var heading = $("pane-" + n).querySelector("h2"); + heading.tabIndex = -1; + heading.focus({ preventScroll: true }); + if (n === 3) preview(); + } + function remember() { + try { + sessionStorage.setItem( + "medcheckJob", + JSON.stringify({ + id: jobId, + source: source, + study: $("studySelect").value || selectedStudy, + }), + ); + } catch (_) {} + } + function forget() { + try { + sessionStorage.removeItem("medcheckJob"); + } catch (_) {} + } + function cloud() { + return ( + ["claude", "openai", "gemini"].indexOf($("modelSelect").value) !== -1 + ); + } + function syncModel() { + $("consentBlock").classList.toggle("is-visible", cloud()); + $("modelHint").hidden = true; + if (currentStep === 3) preview(); + } + function body() { + var value = { source: source, - report_format: format ? format.value : 'json', - language: language ? language.value : 'en', - allow_cloud_llm: !!(cloud && consent && consent.checked) + study_uid: $("studySelect").value || selectedStudy || undefined, + mode: $("modelSelect").value === "local" ? "local" : "vision", + llm_provider: + $("modelSelect").value === "local-vision" + ? "local" + : $("modelSelect").value, + report_format: "json", + language: $("reportLanguage").value, + allow_cloud_llm: cloud() && $("consentCheck").checked, + deidentify: $("deidentify").checked, + pixels_reviewed: $("pixelsReviewed").checked, + ocr_redact: cloud() && !$("ocrBlock").hidden && $("ocrRedact").checked, }; - if (anatomy && anatomy.value) body.anatomy = anatomy.value; - - if (startBtn) startBtn.disabled = true; - setProgress(30, form.getAttribute('data-msg-sending')); - - // Abort a stalled request so the submit button cannot stay disabled forever. - var controller = new AbortController(); - var timeoutId = window.setTimeout(function () { controller.abort(); }, 120000); - - fetch(form.getAttribute('action'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal: controller.signal - }) - .then(function (resp) { - // Read defensively: a proxy error page or empty body is not JSON. - return resp.text().then(function (raw) { - var data = null; - try { data = JSON.parse(raw); } catch (e) { data = null; } - return { ok: resp.ok, status: resp.status, data: data }; - }); - }) - .then(function (result) { - var detail = result.data && result.data.detail; - if (typeof detail !== 'string') detail = 'HTTP ' + result.status; - if (result.ok) { - setProgress(100, ''); - showResultAlert('success', detail); - } else { - setProgress(0, form.getAttribute('data-msg-waiting')); - // 501 = known preview limitation -> warning; anything else -> danger. - showResultAlert(result.status === 501 ? 'warning' : 'danger', detail); - } - }) - .catch(function () { - setProgress(0, form.getAttribute('data-msg-waiting')); - showResultAlert('danger', form.getAttribute('data-msg-error')); - }) - .then(function () { - window.clearTimeout(timeoutId); - if (startBtn) startBtn.disabled = false; + [ + ["anatomy", "anatomy"], + ["symptoms", "symptoms"], + ["trauma", "trauma"], + ["suspected_diagnosis", "suspectedDx"], + ["official_report", "officialReport"], + ].forEach(function (pair) { + var v = $(pair[1]).value.trim(); + if (v) value[pair[0]] = v; + }); + if ($("budget").value !== "") value.budget_usd = Number($("budget").value); + return value; + } + async function preview() { + if (!source || busy) return; + var version = ++previewVersion; + try { + var data = await json("/api/preview", body()); + if (version !== previewVersion) return; + $("analysisPreview").replaceChildren(); + $("analysisPreview").appendChild( + node( + "p", + data.external + ? message("external_notice") + + " " + + data.provider + + " · " + + data.image_limit + + " " + + message("images") + : $("modelSelect").value === "local-vision" + ? message("model_local_vision") + + " · " + + data.image_limit + + " " + + message("images") + : message("local_notice"), + ), + ); + if (data.external) + $("analysisPreview").appendChild( + node( + "p", + data.estimated_cost_usd == null + ? message("cost_unknown") + : "≈ $" + Number(data.estimated_cost_usd).toFixed(4), + ), + ); + (data.warnings || []).forEach(function (warning) { + $("analysisPreview").appendChild(node("p", warning)); }); + } catch (e) { + if (version === previewVersion) + $("analysisPreview").textContent = e.message; + } } - - document.addEventListener('DOMContentLoaded', function () { - // Sticky header — must NOT be sticky at page load; scroll adds the class. - var siteHeader = document.getElementById('site-header'); - if (siteHeader) { - var onScroll = function () { - siteHeader.classList.toggle('is-sticky', window.scrollY > 0); - }; - window.addEventListener('scroll', onScroll, { passive: true }); - onScroll(); - } - - // Any element with data-goto="N" switches to wizard step N. - document.querySelectorAll('[data-goto]').forEach(function (el) { - el.addEventListener('click', function () { - showStep(parseInt(el.getAttribute('data-goto'), 10)); + function studySummary() { + var study = studies.find(function (s) { + return s.study_uid === $("studySelect").value; + }); + if (!study) return; + selectedStudy = study.study_uid; + var count = study.series.reduce(function (n, s) { + return n + s.slices; + }, 0); + $("studySummary").className = "study-summary"; + $("studySummary").replaceChildren( + node( + "strong", + study.series.length + + " " + + message("series_count") + + " · " + + count + + " " + + message("images"), + ), + ); + var list = node("ul"); + study.series.forEach(function (s) { + list.appendChild( + node("li", s.name + " · " + s.slices + " " + message("images")), + ); + }); + $("studySummary").appendChild(list); + if ((study.warnings || []).length) { + var warnings = node("div", null, "alert alert-warning"); + warnings.setAttribute("role", "note"); + var warningList = node("ul"); + study.warnings.forEach(function (warning) { + warningList.appendChild(node("li", warning)); }); + warnings.appendChild(warningList); + $("studySummary").appendChild(warnings); + } + } + async function upload() { + if (busy) return; + var version = ++uploadVersion, + file = $("fileInput").files[0]; + source = null; + $("studyBlock").hidden = true; + $("autoDetectBadge").classList.remove("is-visible"); + alertText(""); + if (!file) return; + if (!/\.(zip|dcm)$/i.test(file.name)) { + alertText(message("bad_file")); + return; + } + if (file.size > 500 * 1024 * 1024) { + alertText(message("file_large")); + return; + } + $("dropzoneSubtext").textContent = + file.name + " · " + (file.size / 1024 / 1024).toFixed(2) + " MB"; + $("uploadStatus").textContent = message("uploading"); + $("dropzone").setAttribute("aria-busy", "true"); + try { + var data = new FormData(); + data.append("file", file); + var uploaded = await ( + await request("/api/upload", { method: "POST", body: data }) + ).json(); + var inspected = await json("/api/inspect", { source: uploaded.source }); + if (version !== uploadVersion) return; + if (!inspected.studies.length) throw new Error(message("bad_file")); + source = uploaded.source; + studies = inspected.studies; + $("studySelect").replaceChildren(); + studies.forEach(function (s, i) { + var option = node( + "option", + (s.description || message("study") + " " + (i + 1)) + + (s.date ? " · " + s.date : ""), + ); + option.value = s.study_uid; + $("studySelect").appendChild(option); + }); + $("studyBlock").hidden = false; + studySummary(); + $("uploadStatus").textContent = message("uploaded"); + $("autoDetectBadge").classList.add("is-visible"); + } catch (e) { + if (version === uploadVersion) { + $("uploadStatus").textContent = message("retry_upload"); + alertText(e.message); + } + } finally { + if (version === uploadVersion) $("dropzone").removeAttribute("aria-busy"); + } + } + function progress(percent, text) { + $("progressFill").style.width = percent + "%"; + $("progressBar").setAttribute("aria-valuenow", percent); + $("progressLabel").textContent = text; + } + function setBusy(value) { + busy = value; + $("startBtn").disabled = value; + $("fileInput").disabled = value; + $("cancelBtn").hidden = !value; + $("analyzeForm").setAttribute("aria-busy", String(value)); + } + function section(title, text) { + var el = node("section", null, "result-section"); + el.appendChild(node("h3", title)); + if (text) el.appendChild(node("p", text)); + $("resultsContent").appendChild(el); + return el; + } + async function download(format) { + try { + var response = await request( + "/api/jobs/" + jobId + "/report?format=" + encodeURIComponent(format), + ); + var url = URL.createObjectURL(await response.blob()); + var a = node("a"); + a.href = url; + a.download = + "medcheck-report." + + (format === "dicom-sr" ? "dcm" : format === "fhir" ? "json" : format); + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { + URL.revokeObjectURL(url); + }, 1000); + } catch (e) { + alertText(e.message); + } + } + function findingReview(finding, index, container) { + var form = node("div", null, "field-stack result-finding"); + form.appendChild( + node( + "strong", + finding.name || + finding.structure || + finding.anatomy || + (index + 1).toString(), + ), + ); + var findingText = node( + "p", + finding.findings || finding.description || finding.status || "", + ); + form.appendChild(findingText); + (finding.image_references || []).forEach(function (reference) { + var seriesIndex = (result.series || []).findIndex(function (series) { + return series.key === reference.series_name; + }); + if (seriesIndex < 0) return; + var open = node( + "button", + message("open_image") + " · " + (Number(reference.slice_index) + 1), + "btn btn-secondary", + ); + open.type = "button"; + open.addEventListener("click", function () { + $("seriesSelect").value = seriesIndex; + changeSeries(); + $("sliceRange").value = reference.slice_index; + image(); + $("viewer").scrollIntoView({ block: "center" }); + }); + form.appendChild(open); }); - - var dropzone = document.getElementById('dropzone'); - var fileInput = document.getElementById('fileInput'); - if (dropzone && fileInput) { - dropzone.addEventListener('click', function () { fileInput.click(); }); - dropzone.addEventListener('keydown', function (e) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - fileInput.click(); - } + var id = "finding-" + index; + var label = node("label", message("review")); + label.htmlFor = id; + form.appendChild(label); + var select = node("select", null, "form-control"); + select.id = id; + ["unreviewed", "confirmed", "rejected", "edited"].forEach(function (value) { + var option = node("option", message(value)); + option.value = value; + select.appendChild(option); + }); + select.value = finding.review_status || "unreviewed"; + form.appendChild(select); + var editLabel = node("label", message("findings_text")); + editLabel.htmlFor = id + "-text"; + form.appendChild(editLabel); + var edit = node("textarea", null, "form-control"); + edit.id = id + "-text"; + edit.value = finding.findings || finding.description || ""; + edit.rows = 3; + form.appendChild(edit); + var noteLabel = node("label", message("review_notes")); + noteLabel.htmlFor = id + "-note"; + form.appendChild(noteLabel); + var note = node("textarea", null, "form-control"); + note.id = id + "-note"; + note.rows = 2; + form.appendChild(note); + var save = node("button", message("save_review"), "btn btn-secondary"); + save.type = "button"; + save.disabled = true; + select.addEventListener("change", function () { + save.disabled = select.value === "unreviewed"; + edit.disabled = select.value !== "edited"; + }); + edit.disabled = select.value !== "edited"; + edit.addEventListener("input", function () { + save.disabled = select.value !== "edited"; + }); + note.addEventListener("input", function () { + save.disabled = select.value === "unreviewed"; + }); + var status = node("p"); + status.setAttribute("role", "status"); + save.addEventListener("click", async function () { + save.disabled = true; + try { + result = await json( + "/api/jobs/" + jobId + "/findings/" + index, + { + status: select.value, + findings: select.value === "edited" ? edit.value : undefined, + note: note.value, + }, + "PATCH", + ); + status.textContent = message("saved"); + if (select.value === "edited") findingText.textContent = edit.value; + } catch (e) { + status.textContent = e.message; + } finally { + save.disabled = false; + } + }); + form.append(save, status); + container.appendChild(form); + } + function render(data) { + result = data; + $("resultsContent").replaceChildren(); + $("resultsContent").classList.remove("results-empty"); + section( + message("complete"), + data.analysis_provenance && + data.analysis_provenance.settings && + data.analysis_provenance.settings.mode === "local" + ? message("no_findings_local") + : data.overall_impression || + data.impression || + message("no_findings_local"), + ); + if ((data.findings || []).length) { + var findings = section(message("findings")); + data.findings.forEach(function (f, i) { + findingReview(f, i, findings); }); - dropzone.addEventListener('dragover', function (e) { - e.preventDefault(); - dropzone.classList.add('dragover'); + } + if ((data.limitations || []).length) { + var limitations = section(message("limitations")); + var list = node("ul"); + data.limitations.forEach(function (v) { + list.appendChild(node("li", v)); }); - dropzone.addEventListener('dragleave', function () { - dropzone.classList.remove('dragover'); + limitations.appendChild(list); + } + ["quality_checks", "analysis_provenance", "reconciliation"].forEach( + function (key) { + if (!data[key] || !Object.keys(data[key]).length) return; + var details = node("details", null, "result-section"); + details.appendChild( + node( + "summary", + message( + key === "quality_checks" + ? "quality" + : key === "reconciliation" + ? "reconciliation" + : "provenance", + ), + ), + ); + var pre = node("pre", JSON.stringify(data[key], null, 2)); + pre.style.whiteSpace = "pre-wrap"; + pre.style.fontSize = ".8rem"; + details.appendChild(pre); + $("resultsContent").appendChild(details); + }, + ); + $("downloads").replaceChildren(); + ["json", "html", "pdf", "fhir", "dicom-sr"].forEach(function (format) { + var button = node( + "button", + message("download") + " " + format.toUpperCase(), + "btn btn-secondary", + ); + button.type = "button"; + button.addEventListener("click", function () { + download(format); }); - dropzone.addEventListener('drop', function (e) { - e.preventDefault(); - dropzone.classList.remove('dragover'); - var dt = e.dataTransfer; - if (dt && dt.files && dt.files[0]) { - fileInput.files = dt.files; - fileSelected(fileInput); - } + $("downloads").appendChild(button); + }); + $("downloads").hidden = false; + $("deleteBtn").hidden = false; + $("seriesSelect").replaceChildren(); + (data.series || []).forEach(function (s, i) { + var option = node("option", s.name || s.key); + option.value = i; + $("seriesSelect").appendChild(option); + }); + $("viewer").hidden = !(data.series || []).length; + if (!$("viewer").hidden) changeSeries(); + } + function changeSeries() { + var series = result.series[Number($("seriesSelect").value)]; + $("sliceRange").max = Math.max(0, series.slices - 1); + $("sliceRange").value = 0; + image(); + } + async function image() { + var version = ++imageVersion, + index = Number($("sliceRange").value), + count = Number($("sliceRange").max) + 1; + $("sliceLabel").textContent = + message("slice") + " " + (index + 1) + " / " + count; + try { + var response = await request( + "/api/jobs/" + + jobId + + "/images/" + + $("seriesSelect").value + + "/" + + index, + ); + var bitmap = await createImageBitmap(await response.blob()); + if (version !== imageVersion) { + bitmap.close(); + return; + } + var canvas = $("sliceImage"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext("2d").drawImage(bitmap, 0, 0); + bitmap.close(); + } catch (e) { + if (version === imageVersion) alertText(e.message); + } + } + async function poll() { + $("resumeBtn").hidden = true; + try { + var data = await json("/api/jobs/" + jobId); + alertText(""); + progress(data.progress || 0, data.step || message("running")); + if (data.status === "completed") { + setBusy(false); + progress(100, message("complete")); + render(data.result); + } else if (data.status === "failed" || data.status === "cancelled") { + setBusy(false); + progress( + 0, + data.status === "cancelled" + ? message("cancelled") + : data.error || message("error_generic"), + ); + } else { + window.setTimeout(poll, 750); + } + } catch (e) { + setBusy(false); + $("resumeBtn").hidden = e.status === 404; + if (e.status === 404) { + forget(); + jobId = null; + } + progress( + 0, + e.status === 404 ? message("progress_waiting") : message("retry_job"), + ); + alertText(e.message); + } + } + async function analyze() { + if (busy) return; + if (!source) { + showStep(1); + alertText(message("choose_file")); + return; + } + if (cloud() && !$("consentCheck").checked) { + alertText(message("consent_required")); + $("consentCheck").focus(); + return; + } + if (cloud() && !$("pixelsReviewed").checked) { + alertText(message("pixel_required")); + $("pixelsReviewed").focus(); + return; + } + alertText(""); + setBusy(true); + $("cancelBtn").disabled = true; + $("resumeBtn").hidden = true; + $("downloads").hidden = true; + $("viewer").hidden = true; + $("deleteBtn").hidden = true; + $("resultsContent").textContent = ""; + progress(0, message("pending")); + try { + var data = await json("/api/analyze", body()); + jobId = data.id; + remember(); + $("cancelBtn").disabled = false; + $("progressWrap").scrollIntoView({ block: "center", behavior: "auto" }); + await poll(); + } catch (e) { + setBusy(false); + progress(0, message("progress_waiting")); + alertText(e.message); + } + } + document.addEventListener("DOMContentLoaded", function () { + document.querySelectorAll("[data-goto]").forEach(function (el) { + el.addEventListener("click", function () { + showStep(Number(el.dataset.goto)); }); - fileInput.addEventListener('change', function () { fileSelected(fileInput); }); - } - - var modelSelect = document.getElementById('modelSelect'); - if (modelSelect) { - modelSelect.addEventListener('change', syncConsentVisibility); - syncConsentVisibility(); - } - - var form = document.getElementById('analyzeForm'); - if (form) { - form.addEventListener('submit', function (e) { + }); + $("analyzeForm").addEventListener("submit", function (e) { + e.preventDefault(); + analyze(); + }); + $("dropzone").addEventListener("click", function (e) { + if (e.target !== $("fileInput") && !busy) $("fileInput").click(); + }); + $("dropzone").addEventListener("keydown", function (e) { + if ((e.key === "Enter" || e.key === " ") && !busy) { e.preventDefault(); - submitAnalysis(form); - }); + $("fileInput").click(); + } + }); + $("dropzone").addEventListener("dragover", function (e) { + e.preventDefault(); + if (!busy) $("dropzone").classList.add("dragover"); + }); + $("dropzone").addEventListener("dragleave", function () { + $("dropzone").classList.remove("dragover"); + }); + $("dropzone").addEventListener("drop", function (e) { + e.preventDefault(); + $("dropzone").classList.remove("dragover"); + if (busy) return; + if (e.dataTransfer.files.length !== 1) { + alertText(message("bad_file")); + return; + } + $("fileInput").files = e.dataTransfer.files; + upload(); + }); + $("budget").addEventListener("change", preview); + $("fileInput").addEventListener("change", upload); + $("studySelect").addEventListener("change", studySummary); + $("modelSelect").addEventListener("change", syncModel); + $("cancelBtn").addEventListener("click", async function () { + $("cancelBtn").disabled = true; + try { + await json("/api/jobs/" + jobId + "/cancel", {}); + } catch (e) { + alertText(e.message); + } finally { + $("cancelBtn").disabled = false; + } + }); + $("resumeBtn").addEventListener("click", function () { + setBusy(true); + poll(); + }); + $("seriesSelect").addEventListener("change", changeSeries); + $("sliceRange").addEventListener("input", image); + window.addEventListener("beforeunload", function (e) { + if (busy) { + e.preventDefault(); + e.returnValue = ""; + } + }); + $("deleteBtn").addEventListener("click", async function () { + $("deleteBtn").disabled = true; + try { + await request("/api/jobs/" + jobId, { method: "DELETE" }); + if (source && source.indexOf("upload:") === 0) + await request("/api/uploads/" + source.slice(7), { + method: "DELETE", + }); + jobId = null; + source = null; + selectedStudy = ""; + result = null; + forget(); + $("fileInput").value = ""; + $("studyBlock").hidden = true; + $("uploadStatus").textContent = ""; + $("autoDetectBadge").classList.remove("is-visible"); + $("dropzoneSubtext").textContent = message("dropzone_formats"); + $("downloads").hidden = true; + $("viewer").hidden = true; + $("deleteBtn").hidden = true; + $("resultsContent").textContent = message("deleted"); + progress(0, message("progress_waiting")); + showStep(1); + } catch (e) { + alertText(e.message); + } finally { + $("deleteBtn").disabled = false; + } + }); + syncModel(); + function capabilities() { + json("/api/capabilities") + .then(function (data) { + $("ocrBlock").hidden = !data.ocr_available; + (data.providers || []).forEach(function (p) { + var option = $("modelSelect").querySelector( + 'option[value="' + p.name + '"]', + ); + if (p.name === "local") { + var localVision = $("modelSelect").querySelector( + 'option[value="local-vision"]', + ); + localVision.disabled = !p.available; + localVision.textContent = + message("model_local_vision") + + " · " + + (p.available ? p.model : message("unavailable")); + } + if (option && p.name !== "local") { + option.disabled = !p.available; + option.textContent = + p.name + + " · " + + (p.available ? p.model : message("unavailable")); + } + }); + }) + .catch(function () { + /* Upload provides an actionable authentication error. */ + }); + } + function restoreStudies() { + json("/api/inspect", { source: source }) + .then(function (data) { + studies = data.studies; + $("studySelect").replaceChildren(); + studies.forEach(function (study, index) { + var option = node( + "option", + study.description || message("study") + " " + (index + 1), + ); + option.value = study.study_uid; + $("studySelect").appendChild(option); + }); + if (selectedStudy) $("studySelect").value = selectedStudy; + $("studyBlock").hidden = false; + studySummary(); + }) + .catch(function () { + /* The reconnect action remains available if a key is needed. */ + }); + } + capabilities(); + $("apiKey").addEventListener("change", function () { + capabilities(); + if (source && !studies.length) restoreStudies(); + }); + try { + var saved = JSON.parse(sessionStorage.getItem("medcheckJob") || "null"); + if (saved && /^[a-zA-Z0-9-]+$/.test(saved.id)) { + jobId = saved.id; + source = saved.source; + selectedStudy = saved.study || ""; + setBusy(true); + restoreStudies(); + poll(); + } + } catch (_) { + forget(); } }); })(); diff --git a/src/medcheck/web/templates/index.html b/src/medcheck/web/templates/index.html index f39853d..e2e302a 100644 --- a/src/medcheck/web/templates/index.html +++ b/src/medcheck/web/templates/index.html @@ -700,6 +700,8 @@ border-radius: var(--radius-pill); transition: width 0.5s ease; } + #progressBar[aria-valuenow="100"] .progress-fill { transition: none; } + #deleteBtn { margin-top: 1.5rem; } .progress-label { font-size: 0.8125rem; color: var(--color-graphite-600); @@ -885,10 +887,40 @@ .footer-grid { grid-template-columns: 1fr; gap: 2rem; } .footer-legal { flex-direction: column; align-items: flex-start; gap: 0.75rem; } } + [hidden] { display: none !important; } + :focus-visible { outline: 3px solid var(--color-teal); outline-offset: 4px; } + .skip-link { position: absolute; top: -5rem; left: 1rem; z-index: 200; padding: 1rem; background: white; } + .skip-link:focus { top: 1rem; } + #formAlert { margin-bottom: 1rem; } + .tab-pane h2 { scroll-margin-top: 6rem; } + .connection-settings summary { cursor: pointer; color: var(--color-teal); } + .connection-settings input { margin-top: .5rem; } +.plain-checkbox { display: flex; align-items: flex-start; gap: .5rem; margin: 1rem 0; } + .plain-checkbox input { min-width: 1.125rem; min-height: 1.125rem; } + .analysis-preview, .study-summary { padding: 1rem; border-left: 3px solid var(--color-teal); background: var(--color-teal-50); } + .analysis-preview:empty { display: none; } + .results-empty { margin: 1rem 0; } + .result-section { margin: 1.5rem 0; overflow-wrap: anywhere; } + .result-section h3 { margin-bottom: .5rem; } + .result-section ul { padding-left: 1.25rem; } + .result-finding { padding: 1rem 0; border-bottom: 1px solid var(--color-graphite-200); } + .viewer, .review-block { margin-top: 2rem; } + .viewer h3 { margin-bottom: 1rem; } + .viewer-image { background: #101d21; min-height: 14rem; display: grid; place-items: center; margin: 1rem 0; border-radius: .5rem; } + .viewer-image canvas { width: 100%; height: 24rem; object-fit: contain; } + #sliceRange { width: 100%; accent-color: var(--color-teal); min-height: 44px; } + .downloads-grid .btn { white-space: normal; text-align: center; } + #cancelBtn, #resumeBtn { margin-bottom: 1rem; } + .footer-tagline, .footer-disclaimer, .footer-legal { color: rgba(255,255,255,.76); } + @media (min-width: 49rem) { .pdp-stepper-step:not(.active) .pdp-stepper-label { position: static; width: auto; height: auto; clip-path: none; white-space: normal; } } + @media (max-width: 48rem) { .wizard-nav { gap: .75rem; flex-wrap: wrap; } .wizard-nav .btn { flex: 1; justify-content: center; white-space: normal; } .viewer-image canvas { height: 18rem; } .field-wrapper label { white-space: normal; } } + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; scroll-behavior: auto !important; animation: none !important; } } + + -
    +
    @@ -924,20 +956,10 @@

    {{ t.ui_hero_title }}

    - -
    - -
    -

    {{ t.ui_preview_title }}

    -

    {{ t.ui_preview_body }} - (#157)

    -
    -

    {{ t.ui_preview_cli }} medcheck analyze /path/to/scans

    -
    +
    +

    {{ t.ui_local_notice }}

    - +
    - - -
    - - - {{ t.ui_url_hint }} -
    - -
    -
    - - -
    -
    - - -
    +
    + +
    {{ t.ui_connection }} + + +

    {{ t.ui_api_hint }}

    +
    @@ -1059,22 +1073,19 @@

    {{ t.ui_step2 }}

    {{ t.ui_symptoms_hint }}
    -
    -
    - - -
    -
    - - -
    +
    + +
    -
    +
    + + +
    - - {{ t.ui_mod_ml }} - - - - - - -
    - -
    @@ -1191,17 +1155,10 @@

    {{ t.ui_step3 }}

    -
    - - -
    - +

    {{ t.ui_local_notice }}

    +
    - @@ -1249,73 +1215,28 @@

    {{ t.ui_step3 }}

    {{ t.ui_results }}

    -
    -
    +
    +

    {{ t.ui_progress_waiting }}

    + +
    {{ t.ui_no_results }}
    -
    -
    - -
    -
    {{ t.ui_dl_pdf }}
    -
    {{ t.ui_dl_pending }}
    -
    - -
    - -
    - -
    -
    {{ t.ui_dl_json }}
    -
    {{ t.ui_dl_pending }}
    -
    - -
    - -
    - -
    -
    {{ t.ui_dl_images }}
    -
    {{ t.ui_dl_pending }}
    -
    - -
    -
    + + +
    diff --git a/tests/browser/web_user_journey.py b/tests/browser/web_user_journey.py new file mode 100644 index 0000000..272dac1 --- /dev/null +++ b/tests/browser/web_user_journey.py @@ -0,0 +1,296 @@ +"""Browser smoke test using synthetic images only. + +Run a local server, then: + uv run --with playwright python tests/browser/web_user_journey.py +Set PLAYWRIGHT_CHROMIUM_EXECUTABLE if Chromium is installed outside Playwright's default. +""" + +from __future__ import annotations + +import argparse +import io +import json +import os +import tempfile +import zipfile +from pathlib import Path + +import numpy as np +from PIL import Image +from playwright.sync_api import expect, sync_playwright +from pydicom.dataset import FileDataset, FileMetaDataset +from pydicom.uid import ExplicitVRLittleEndian, MRImageStorage, generate_uid + + +def synthetic_zip(directory: Path) -> Path: + study_uid = generate_uid() + archive = directory / "synthetic-study.zip" + with zipfile.ZipFile(archive, "w") as output: + for series in range(2): + series_uid = generate_uid() + for index in range(3): + meta = FileMetaDataset() + meta.TransferSyntaxUID = ExplicitVRLittleEndian + meta.MediaStorageSOPClassUID = MRImageStorage + meta.MediaStorageSOPInstanceUID = generate_uid() + path = directory / f"series-{series}-slice-{index}.dcm" + ds = FileDataset(str(path), {}, file_meta=meta, preamble=b"\0" * 128) + ds.SOPClassUID = meta.MediaStorageSOPClassUID + ds.SOPInstanceUID = meta.MediaStorageSOPInstanceUID + ds.StudyInstanceUID, ds.SeriesInstanceUID = study_uid, series_uid + ds.PatientName = "SYNTHETIC^TEST" + ds.PatientID = "BROWSER-QA" + ds.StudyDescription = "Synthetic browser QA" + ds.SeriesDescription = f"Knee T2 axial {series + 1}" + ds.Modality = "MR" + ds.InstanceNumber = index + 1 + ds.ImagePositionPatient = [0, 0, index * 2] + ds.ImageOrientationPatient = [1, 0, 0, 0, 1, 0] + ds.PixelSpacing = [1, 1] + ds.SliceThickness = 2 + ds.Rows, ds.Columns = 32, 32 + ds.SamplesPerPixel = 1 + ds.PhotometricInterpretation = "MONOCHROME2" + ds.BitsAllocated = ds.BitsStored = 16 + ds.HighBit, ds.PixelRepresentation = 15, 0 + ds.PixelData = (np.arange(1024, dtype=np.uint16).reshape(32, 32) + index * 20).tobytes() + ds.save_as(path, enforce_file_format=True) + output.write(path, path.name) + return archive + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--url", default="http://127.0.0.1:8765") + parser.add_argument("--screenshots", default="artifacts/ui") + parser.add_argument("--api-key", default="") + args = parser.parse_args() + screenshots = Path(args.screenshots) + screenshots.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as temporary, sync_playwright() as p: + archive = synthetic_zip(Path(temporary)) + browser = p.chromium.launch( + headless=True, + executable_path=os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE"), + ) + page = browser.new_page(viewport={"width": 1440, "height": 1100}) + errors: list[str] = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.on( + "console", + lambda msg: ( + errors.append(msg.text) if msg.type == "error" and "Content Security Policy" in msg.text else None + ), + ) + page.goto(args.url + "/?lang=de") + page.wait_for_load_state("networkidle") + page.locator('#pane-1 [data-goto="2"]').click() + expect(page.locator("#formAlert")).to_contain_text("Wähle zuerst") + expect(page.locator("#pane-1")).to_be_visible() + page.locator("#fileInput").set_input_files( + {"name": "unsupported.png", "mimeType": "image/png", "buffer": b"invalid"} + ) + expect(page.locator("#formAlert")).to_contain_text(".dcm") + if args.api_key: + page.locator(".connection-settings summary").click() + page.locator("#apiKey").fill("deliberately-wrong-key") + page.locator("#fileInput").set_input_files(archive) + expect(page.locator("#formAlert")).to_contain_text("API key") + page.locator("#apiKey").fill(args.api_key) + page.locator("#apiKey").blur() + page.locator("#fileInput").set_input_files( + {"name": "broken.dcm", "mimeType": "application/dicom", "buffer": b"not a DICOM dataset"} + ) + expect(page.locator("#uploadStatus")).to_contain_text("Upload fehlgeschlagen") + expect(page.locator("#formAlert")).to_be_focused() + page.locator("#fileInput").set_input_files(archive) + expect(page.locator("#studyBlock")).to_be_visible(timeout=30000) + expect(page.locator("#studySummary")).to_contain_text("6 Bilder") + expect(page.locator("#studySummary .alert-warning")).to_contain_text("Embedded patient text") + page.screenshot(path=str(screenshots / "desktop-import.png"), full_page=True) + page.locator('#pane-1 [data-goto="2"]').click() + expect(page.locator("#symptoms")).to_be_visible() + page.locator("#symptoms").fill("Synthetic QA only") + page.locator("#anatomy").select_option("knee") + page.locator('#pane-2 [data-goto="3"]').click() + expect(page.locator("#modelSelect")).to_have_value("local") + expect(page.locator("#consentBlock")).not_to_be_visible() + page.locator("#startBtn").click() + expect(page.locator("#progressLabel")).to_have_text("Analyse abgeschlossen", timeout=60000) + expect(page.locator("#progressBar")).to_have_attribute("aria-valuenow", "100") + expect(page.locator("#resultsContent")).to_contain_text("Qualitätsprüfung erstellt keine") + expect( + page.locator("#resultsContent summary").filter(has_text="Abgleich mit vorhandenem Befund") + ).to_have_count(0) + assert ( + page.locator("#progressFill").bounding_box()["width"] + == page.locator("#progressBar").bounding_box()["width"] + ) + expect(page.locator("#downloads")).to_be_visible() + expect(page.locator("#viewer")).to_be_visible() + expect(page.locator("#sliceImage")).to_have_attribute("width", "32") + page.locator("#sliceRange").fill("2") + expect(page.locator("#sliceLabel")).to_have_text("Schicht 3 / 3") + page.locator("#seriesSelect").select_option("1") + expect(page.locator("#sliceLabel")).to_have_text("Schicht 1 / 3") + page.reload() + page.wait_for_load_state("networkidle") + if args.api_key: + expect(page.locator("#resumeBtn")).to_be_visible() + page.locator(".connection-settings summary").click() + page.locator("#apiKey").fill(args.api_key) + page.locator("#resumeBtn").click() + expect(page.locator("#studyBlock")).to_be_visible() + expect(page.locator("#progressLabel")).to_have_text("Analyse abgeschlossen") + expect(page.locator("#viewer")).to_be_visible() + page.locator('[data-step="3"]').click() + with page.expect_download() as downloaded: + page.get_by_role("button", name="Herunterladen JSON", exact=True).click() + report = json.loads(Path(downloaded.value.path()).read_text()) + assert "limitations" in report + page.screenshot(path=str(screenshots / "desktop-results.png"), full_page=True) + page.set_viewport_size({"width": 390, "height": 844}) + assert not page.evaluate("document.documentElement.scrollWidth > innerWidth") + page.screenshot(path=str(screenshots / "mobile-results.png"), full_page=True) + # Deterministic UI-only edge cases: cancellation and human review. + # The happy path above uses the real backend; these routes avoid timing races/cloud calls. + state = {"cancelled": False, "review": None, "analyze": None} + + def start_edge(route): + state["analyze"] = route.request.post_data_json + route.fulfill(json={"id": "ui-edge", "status": "queued"}) + + page.route("**/api/analyze", start_edge) + page.route( + "**/api/capabilities", + lambda route: route.fulfill( + json={ + "ocr_available": True, + "providers": [{"name": "claude", "available": True, "model": "UI fixture"}], + } + ), + ) + # Trigger a capability refresh, then exercise OCR request wiring without a cloud invocation. + if not page.locator("#apiKey").is_visible(): + page.locator('[data-step="1"]').click() + page.locator(".connection-settings summary").click() + page.locator("#apiKey").dispatch_event("change") + expect(page.locator("#ocrBlock")).not_to_have_attribute("hidden", "") + page.locator('[data-step="3"]').click() + page.locator("#modelSelect").select_option("claude") + page.locator("#consentCheck").check() + page.locator("#pixelsReviewed").check() + page.locator("#ocrRedact").check() + + def edge_status(route): + route.fulfill( + json={ + "id": "ui-edge", + "status": "cancelled" if state["cancelled"] else "running", + "progress": 20, + "step": "Synthetic slow step", + } + ) + + def cancel(route): + state["cancelled"] = True + route.fulfill(json={"status": "cancelled"}) + + page.route("**/api/jobs/ui-edge", edge_status) + page.route("**/api/jobs/ui-edge/cancel", cancel) + page.locator("#startBtn").click() + expect(page.locator("#cancelBtn")).to_be_visible() + page.locator("#cancelBtn").click() + assert state["analyze"]["ocr_redact"] is True + expect(page.locator("#progressLabel")).to_contain_text("abgebrochen", timeout=5000) + expect(page.locator("#startBtn")).to_be_enabled() + page.locator("#modelSelect").select_option("local") + page.unroute("**/api/jobs/ui-edge", edge_status) + synthetic_result = { + "findings": [ + { + "name": "Synthetic finding", + "findings": "Review fixture", + "status": "uncertain", + "image_references": [{"series_name": "Synthetic series", "slice_index": 1}], + } + ], + "limitations": ["Synthetic UI fixture only"], + "series": [{"key": "Synthetic series", "slices": 3}], + } + disconnected = [True] + + def reconnect_status(route): + if disconnected[0]: + disconnected[0] = False + route.abort("failed") + return + route.fulfill( + json={ + "id": "ui-edge", + "status": "completed", + "progress": 100, + "result": synthetic_result, + } + ) + + page.route("**/api/jobs/ui-edge", reconnect_status) + + def review(route): + state["review"] = route.request.post_data_json + synthetic_result["findings"][0]["review_status"] = state["review"]["status"] + synthetic_result["findings"][0]["findings"] = state["review"]["findings"] + route.fulfill(json=synthetic_result) + + page.route("**/api/jobs/ui-edge/findings/0", review) + preview_image = io.BytesIO() + Image.new("L", (32, 32), color=128).save(preview_image, format="PNG") + page.route( + "**/api/jobs/ui-edge/images/*/*", + lambda route: route.fulfill(body=preview_image.getvalue(), content_type="image/png"), + ) + page.locator("#startBtn").click() + expect(page.locator("#resumeBtn")).to_be_visible() + page.locator("#resumeBtn").click() + expect(page.locator("#finding-0")).to_be_visible() + page.get_by_role("button", name="Referenziertes Bild öffnen").click() + expect(page.locator("#sliceLabel")).to_have_text("Schicht 2 / 3") + page.locator("#finding-0").select_option("edited") + page.locator("#finding-0-text").fill("Corrected synthetic finding") + page.locator("#finding-0-note").fill("Verified in browser test") + page.get_by_role("button", name="Prüfung speichern").click() + expect(page.get_by_text("Prüfung gespeichert", exact=True)).to_be_visible() + assert state["review"] == { + "status": "edited", + "findings": "Corrected synthetic finding", + "note": "Verified in browser test", + } + # Render the persisted review again, then edit without changing its status first. + page.locator("#startBtn").click() + expect(page.locator("#finding-0")).to_have_value("edited") + expect(page.locator("#finding-0-text")).to_be_enabled() + expect(page.locator("#finding-0-text")).to_have_value("Corrected synthetic finding") + expect(page.get_by_role("button", name="Prüfung speichern")).to_be_disabled() + page.locator("#finding-0-text").fill("Second correction after render") + expect(page.get_by_role("button", name="Prüfung speichern")).to_be_enabled() + page.get_by_role("button", name="Prüfung speichern").click() + expect(page.get_by_text("Prüfung gespeichert", exact=True)).to_be_visible() + assert state["review"]["findings"] == "Second correction after render" + page.locator("#deleteBtn").click() + expect(page.locator("#resultsContent")).to_have_text("Analyse und hochgeladene Datei gelöscht.") + expect(page.locator("#pane-1")).to_be_visible() + expect(page.locator("#studyBlock")).not_to_be_visible() + page.unroute_all() + # Fresh mobile navigation: real keyboard focus and validation. + page.goto(args.url + "/?lang=de") + page.wait_for_load_state("networkidle") + page.keyboard.press("Tab") + expect(page.get_by_role("link", name="Zum Inhalt springen")).to_be_focused() + page.screenshot(path=str(screenshots / "mobile-upload.png"), full_page=True) + assert not errors, errors + browser.close() + print("PASS: real ZIP import, local job, viewer, JSON download, mobile, keyboard, no JS errors") + + +if __name__ == "__main__": + main() diff --git a/tests/integration/test_web_jobs.py b/tests/integration/test_web_jobs.py new file mode 100644 index 0000000..6c13e8a --- /dev/null +++ b/tests/integration/test_web_jobs.py @@ -0,0 +1,434 @@ +"""Workbench API integration using synthetic pixels and no model or network calls.""" + +import io +import json +import threading +import time +import zipfile +from pathlib import Path + +import numpy as np +import pydicom +import pytest +from fastapi.testclient import TestClient +from pydicom.dataset import FileDataset, FileMetaDataset +from pydicom.uid import ExplicitVRLittleEndian, MRImageStorage, generate_uid + +from medcheck.core.config import Settings +from medcheck.core.context import StructureFinding +from medcheck.web.app import create_app + + +def _dicom_bytes(): + meta = FileMetaDataset() + meta.TransferSyntaxUID = ExplicitVRLittleEndian + meta.MediaStorageSOPClassUID = MRImageStorage + meta.MediaStorageSOPInstanceUID = generate_uid() + ds = FileDataset(None, {}, file_meta=meta, preamble=b"\0" * 128) + ds.SOPClassUID = MRImageStorage + ds.SOPInstanceUID = meta.MediaStorageSOPInstanceUID + ds.StudyInstanceUID = generate_uid() + ds.SeriesInstanceUID = generate_uid() + ds.PatientName = "Synthetic^Only" + ds.PatientID = "synthetic-patient-id" + ds.StudyDate = "20250101" + ds.SeriesDescription = "knee sagittal" + ds.Modality = "MR" + ds.SeriesNumber = 1 + ds.InstanceNumber = 1 + ds.Rows = ds.Columns = 16 + ds.SamplesPerPixel = 1 + ds.PhotometricInterpretation = "MONOCHROME2" + ds.BitsAllocated = ds.BitsStored = 16 + ds.HighBit = 15 + ds.PixelRepresentation = 0 + ds.ImageOrientationPatient = [1, 0, 0, 0, 1, 0] + ds.ImagePositionPatient = [0, 0, 0] + ds.PixelSpacing = [1, 1] + ds.SliceThickness = 1 + ds.PixelData = np.arange(256, dtype=np.uint16).tobytes() + result = io.BytesIO() + ds.save_as(result, enforce_file_format=True) + return result.getvalue() + + +@pytest.fixture +def settings(tmp_path, monkeypatch): + monkeypatch.setenv("MEDCHECK_RATE_LIMIT", "0") + data = tmp_path / "data" + data.mkdir() + return Settings(data_root=str(data), state_dir=str(tmp_path / "state"), api_key=None) + + +@pytest.fixture +def client(settings): + with TestClient(create_app(settings)) as test_client: + yield test_client + + +def _upload(client, zipped=False): + data = _dicom_bytes() + name = "synthetic.dcm" + if zipped: + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("scan/image.dcm", data) + name, data = "synthetic.zip", output.getvalue() + response = client.post("/api/upload", files={"file": (name, data, "application/octet-stream")}) + assert response.status_code == 200, response.text + return response.json()["source"] + + +def _submit(client, source, **kwargs): + response = client.post("/api/analyze", json={"source": source, **kwargs}) + assert response.status_code == 202, response.text + return response.json()["id"] + + +def _finished(client, job_id): + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + response = client.get(f"/api/jobs/{job_id}") + assert response.status_code == 200 + job = response.json() + if job["status"] not in {"queued", "running"}: + return job + time.sleep(0.01) + pytest.fail(f"Analysis did not finish: {job}") + + +@pytest.mark.parametrize("zipped", [False, True]) +def test_upload_inspect_analyze_view_export_delete(client, settings, zipped): + source = _upload(client, zipped) + inspection = client.post("/api/inspect", json={"source": source}) + assert inspection.status_code == 200, inspection.text + study = inspection.json()["studies"][0] + assert study["series"][0]["slices"] == 1 + job_id = _submit(client, source, study_uid=study["study_uid"]) + job = _finished(client, job_id) + assert job["status"] == "completed", job + assert job["progress"] == 100 + assert job["viewer_available"] + assert "synthetic-patient-id" not in json.dumps(job["result"]) + image = client.get(f"/api/jobs/{job_id}/images/0/0") + assert image.status_code == 200 + assert image.content.startswith(b"\x89PNG") + assert client.get(f"/api/jobs/{job_id}/images/0/99").status_code == 404 + for fmt in ("json", "html", "pdf", "fhir", "dicom-sr"): + report = client.get(f"/api/jobs/{job_id}/report", params={"format": fmt}) + assert report.status_code == 200, report.text[:200] if fmt != "dicom-sr" else report.status_code + assert "attachment" in report.headers["content-disposition"] + assert report.headers["cache-control"] == "no-store" + if fmt == "pdf": + assert report.content.startswith(b"%PDF") + if fmt == "fhir": + assert report.json()["status"] == "preliminary" + if fmt == "dicom-sr": + assert pydicom.dcmread(io.BytesIO(report.content)).VerificationFlag == "UNVERIFIED" + assert client.get(f"/api/jobs/{job_id}/report?format=invalid").status_code == 422 + assert len(client.get("/api/jobs").json()) == 1 + assert client.delete(f"/api/jobs/{job_id}").status_code == 200 + assert client.get(f"/api/jobs/{job_id}").status_code == 404 + assert not (Path(settings.state_dir) / job_id).exists() + assert client.delete(f"/api/uploads/{source.split(':')[1]}").status_code == 200 + assert not list((Path(settings.state_dir) / "uploads").iterdir()) + + +def test_cancel_running_and_queued_jobs(client, monkeypatch): + started, release = threading.Event(), threading.Event() + + def block(self, context): + started.set() + assert release.wait(10), "test worker was not released" + return context + + monkeypatch.setattr("medcheck.web.jobs.IngestStep.run", block) + source = _upload(client) + first = _submit(client, source) + assert started.wait(5) + second = _submit(client, source) + try: + assert client.delete(f"/api/jobs/{first}").status_code == 409 + assert client.get(f"/api/jobs/{first}/report").status_code == 409 + assert client.delete(f"/api/uploads/{source.split(':')[1]}").status_code == 409 + assert client.post(f"/api/jobs/{first}/cancel").json()["cancel_requested"] + assert client.post(f"/api/jobs/{second}/cancel").json()["cancel_requested"] + finally: + release.set() + assert _finished(client, first)["status"] == "cancelled" + assert _finished(client, second)["status"] == "cancelled" + assert client.delete(f"/api/jobs/{first}").status_code == 200 + + +@pytest.mark.parametrize( + "method,path", + [ + ("GET", "/api/capabilities"), + ("POST", "/api/upload"), + ("POST", "/api/inspect"), + ("POST", "/api/preview"), + ("POST", "/api/analyze"), + ("GET", "/api/jobs"), + ("GET", "/api/jobs/missing"), + ("POST", "/api/jobs/missing/cancel"), + ("DELETE", "/api/jobs/missing"), + ("PATCH", "/api/jobs/missing/findings/0"), + ("GET", "/api/jobs/missing/images/0/0"), + ("GET", "/api/jobs/missing/report"), + ("DELETE", "/api/uploads/missing"), + ], +) +def test_all_api_routes_require_auth(settings, method, path): + settings.api_key = "secret" + with TestClient(create_app(settings)) as client: + assert client.request(method, path).status_code == 401 + assert client.request(method, path, headers={"X-API-Key": "wrong"}).status_code == 401 + assert client.get("/api/jobs", headers={"X-API-Key": "secret"}).status_code == 200 + + +def test_source_traversal_and_symlink_rejected(client, settings, tmp_path): + outside = tmp_path / "outside.dcm" + outside.write_bytes(_dicom_bytes()) + sources = [str(outside), str(Path(settings.data_root) / ".." / "outside.dcm"), "upload:../outside"] + link = Path(settings.data_root) / "link.dcm" + try: + link.symlink_to(outside) + sources.extend([str(link), settings.data_root]) + except OSError: + pass # Windows may prohibit creating symlinks without developer mode. + for source in sources: + assert client.post("/api/inspect", json={"source": source}).status_code == 422 + assert client.post("/api/analyze", json={"source": source}).status_code == 422 + + +def test_invalid_and_oversize_uploads_cleanup(client, settings): + settings.max_upload_bytes = 10 + for name, data, status in [("a.dcm", b"x" * 11, 413), ("empty.dcm", b"", 422), ("script.py", b"x", 422)]: + response = client.post("/api/upload", files={"file": (name, data)}) + assert response.status_code == status + assert not list((Path(settings.state_dir) / "uploads").iterdir()) + + +def test_failure_is_actionable_and_private(client, monkeypatch): + def fail(self, context): + raise RuntimeError("secret provider response containing patient info") + + monkeypatch.setattr("medcheck.web.jobs.IngestStep.run", fail) + job = _finished(client, _submit(client, _upload(client))) + assert job["status"] == "failed" + assert "configuration" in job["error"] + assert "secret" not in job["error"] + assert not job["viewer_available"] + + +def test_review_audit_and_restart_durable_reports(settings, monkeypatch): + def finding(self, context): + context.findings = [StructureFinding(name="ACL", status="normal", findings="Original text")] + return context + + monkeypatch.setattr("medcheck.web.jobs.ReconcileStep.run", finding) + with TestClient(create_app(settings)) as client: + job_id = _submit(client, _upload(client)) + assert _finished(client, job_id)["status"] == "completed" + route = f"/api/jobs/{job_id}/findings/0" + assert client.patch(route, json={"status": "edited"}).status_code == 422 + edited = client.patch(route, json={"status": "edited", "findings": "Corrected text", "note": "Reviewed"}) + assert edited.status_code == 200 + audit = edited.json()["review_history"][0] + assert audit["before"]["findings"] == "Original text" + assert audit["after"]["findings"] == "Corrected text" + assert audit["note"] == "Reviewed" + assert client.patch(f"/api/jobs/{job_id}/findings/99", json={"status": "confirmed"}).status_code == 422 + with TestClient(create_app(settings)) as restarted: + job = restarted.get(f"/api/jobs/{job_id}").json() + assert job["status"] == "completed" + assert job["viewer_available"] + assert restarted.get(f"/api/jobs/{job_id}/images/0/0").status_code == 200 + report = restarted.get(f"/api/jobs/{job_id}/report").json() + assert report["findings"][0]["findings"] == "Corrected text" + assert report["review_history"][0] == audit + assert restarted.get(f"/api/jobs/{job_id}/report?format=pdf").status_code == 200 + + +def test_restart_marks_interrupted_job_failed(settings): + folder = Path(settings.state_dir) / ("a" * 32) + folder.mkdir(parents=True) + (folder / "status.json").write_text(json.dumps({"id": folder.name, "status": "running"})) + with TestClient(create_app(settings)) as client: + job = client.get(f"/api/jobs/{folder.name}").json() + assert job["status"] == "failed" + assert "restarted" in job["error"] + + +def test_cloud_preview_consent_and_budget(client, monkeypatch): + body = {"source": _upload(client), "mode": "vision", "llm_provider": "claude"} + preview = client.post("/api/preview", json=body) + assert preview.status_code == 200 + assert preview.json()["external"] + assert client.post("/api/analyze", json=body).status_code == 422 + body.update(allow_cloud_llm=True, pixels_reviewed=True, budget_usd=1) + monkeypatch.delenv("MEDCHECK_CLAUDE_ESTIMATED_COST_USD", raising=False) + assert "estimate" in client.post("/api/analyze", json=body).json()["detail"] + monkeypatch.setenv("MEDCHECK_CLAUDE_ESTIMATED_COST_USD", "2") + assert "budget" in client.post("/api/analyze", json=body).json()["detail"] + + +def test_capabilities_and_storage_limit(client, settings): + response = client.get("/api/capabilities") + assert response.status_code == 200 + assert response.json()["max_upload_bytes"] == settings.max_upload_bytes + assert {p["name"] for p in response.json()["providers"]} == {"local", "claude", "openai", "gemini"} + settings.max_jobs = 1 + source = _upload(client) + first = _submit(client, source) + assert _finished(client, first)["status"] == "completed" + blocked = client.post("/api/analyze", json={"source": source}) + assert blocked.status_code == 422 + assert "storage is full" in blocked.json()["detail"] + assert client.delete(f"/api/jobs/{first}").status_code == 200 + assert _finished(client, _submit(client, source))["status"] == "completed" + + +def test_missing_and_unreadable_source_errors(client, settings): + missing = str(Path(settings.data_root) / "missing.dcm") + assert "does not exist" in client.post("/api/inspect", json={"source": missing}).json()["detail"] + assert client.post("/api/inspect", json={"source": "upload:" + "f" * 32}).status_code == 422 + empty = Path(settings.data_root) / "empty" + empty.mkdir() + assert client.post("/api/inspect", json={"source": str(empty)}).status_code == 422 + + +@pytest.mark.parametrize( + "method,path", + [ + ("POST", "/api/upload"), + ("POST", "/api/analyze"), + ("POST", "/api/jobs/missing/cancel"), + ("DELETE", "/api/jobs/missing"), + ("PATCH", "/api/jobs/missing/findings/0"), + ("DELETE", "/api/uploads/missing"), + ], +) +def test_cross_origin_mutations_rejected(client, method, path): + assert client.request(method, path, headers={"Origin": "https://evil.example"}).status_code == 403 + + +def test_corrupt_persisted_job_does_not_break_listing(settings): + folder = Path(settings.state_dir) / ("b" * 32) + folder.mkdir(parents=True) + (folder / "status.json").write_text("not JSON") + with TestClient(create_app(settings)) as client: + assert client.get("/api/jobs").json() == [] + + +@pytest.mark.parametrize("estimate", ["not-a-number", "NaN", "Infinity", "-1", "10001"]) +def test_invalid_cost_estimate_blocks_budgeted_cloud_request(client, monkeypatch, estimate): + monkeypatch.setenv("MEDCHECK_CLAUDE_ESTIMATED_COST_USD", estimate) + body = { + "source": _upload(client), + "mode": "vision", + "llm_provider": "claude", + "allow_cloud_llm": True, + "pixels_reviewed": True, + "budget_usd": 5, + } + assert client.post("/api/preview", json=body).json()["estimated_cost_usd"] is None + response = client.post("/api/analyze", json=body) + assert response.status_code == 422 + assert "estimate" in response.json()["detail"] + assert client.get("/api/jobs").json() == [] + + +def test_no_usable_volume_fails_without_result(client, monkeypatch): + def no_volume(self, context): + context.volumes = {} + return context + + monkeypatch.setattr("medcheck.web.jobs.PreprocessStep.run", no_volume) + job = _finished(client, _submit(client, _upload(client))) + assert job["status"] == "failed" + assert "No usable image volumes" in job["error"] + assert job["result"] is None + assert client.patch(f"/api/jobs/{job['id']}/findings/0", json={"status": "confirmed"}).status_code == 422 + + +def test_decoded_pixel_limit_checked_before_preprocessing(client, monkeypatch): + from medcheck.core.context import DicomSeries + + def oversized(self, context): + ds = pydicom.Dataset() + ds.Rows = 16384 + ds.Columns = 16384 + context.dicom_series = [DicomSeries(slices=[ds])] + return context + + def unexpected(self, context): + pytest.fail("oversized study must stop before preprocessing") + + monkeypatch.setattr("medcheck.web.jobs.IngestStep.run", oversized) + monkeypatch.setattr("medcheck.web.jobs.PreprocessStep.run", unexpected) + job = _finished(client, _submit(client, _upload(client))) + assert job["status"] == "failed" + assert "decoded image limit" in job["error"] + + +def test_delete_closes_mapped_images_even_when_a_view_is_retained(client, settings): + job_id = _submit(client, _upload(client)) + assert _finished(client, job_id)["status"] == "completed" + db = client.app.state.jobs + mapped = next(iter(db.contexts[job_id].volumes.values())) + retained_view = mapped[0] + mapping = mapped._mmap + assert not mapping.closed + response = client.delete(f"/api/jobs/{job_id}") + assert response.status_code == 200 + # Holding a NumPy view must not leave a Windows file handle open after deletion. + assert mapping.closed + assert retained_view.base is mapped + assert not (Path(settings.state_dir) / job_id).exists() + + +def test_delete_failure_remains_visible_and_retryable(client, settings, monkeypatch): + job_id = _submit(client, _upload(client)) + assert _finished(client, job_id)["status"] == "completed" + folder = Path(settings.state_dir) / job_id + + def locked_directory(path, ignore_errors=False): + if not ignore_errors: + raise PermissionError("synthetic file lock") + + with monkeypatch.context() as patcher: + patcher.setattr("medcheck.web.jobs.shutil.rmtree", locked_directory) + response = client.delete(f"/api/jobs/{job_id}") + assert response.status_code == 409 + assert "retry" in response.json()["detail"].lower() + assert "synthetic file lock" not in response.text + assert folder.exists() + remaining = client.get(f"/api/jobs/{job_id}") + assert remaining.status_code == 200 + assert remaining.json()["viewer_available"] is False + assert (folder / "status.json").exists() + assert client.delete(f"/api/jobs/{job_id}").status_code == 200 + assert client.get(f"/api/jobs/{job_id}").status_code == 404 + assert not folder.exists() + + +def test_viewer_snapshot_is_independent_of_mapping_lifetime(client): + job_id = _submit(client, _upload(client)) + assert _finished(client, job_id)["status"] == "completed" + db = client.app.state.jobs + snapshot = db.read_slice(job_id, 0, 0) + expected = snapshot.copy() + assert not isinstance(snapshot, np.memmap) + mapping = next(iter(db.contexts[job_id].volumes.values()))._mmap + assert client.delete(f"/api/jobs/{job_id}").status_code == 200 + assert mapping.closed + np.testing.assert_array_equal(snapshot, expected) + + +def test_shutdown_closes_mapped_images(settings): + with TestClient(create_app(settings)) as client: + job_id = _submit(client, _upload(client)) + assert _finished(client, job_id)["status"] == "completed" + mapping = next(iter(client.app.state.jobs.contexts[job_id].volumes.values()))._mmap + assert mapping.closed diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index eec7073..def87ad 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -119,3 +119,107 @@ def test_download_models_caches_weights(): assert result.exit_code == 0 build.assert_called_once() assert "cached" in result.output + + +def test_analyze_study_privacy_reference_and_export_flags(monkeypatch, tmp_path): + reference = tmp_path / "reference.txt" + reference.write_text("ACL unauffällig — Referenzbefund", encoding="utf-8") + captured = {} + + def run_pipeline(ctx, workflow, steps): + captured.update(ctx=ctx, steps=steps) + return ctx + + with patch("medcheck.main._run_pipeline", side_effect=run_pipeline): + result = runner.invoke( + app, + [ + "analyze", + str(tmp_path), + "--study-uid", + "1.2.3.4", + "--pixels-reviewed", + "--deidentify", + "--allow-cloud-llm", + "--model", + "claude", + "--official-report", + str(reference), + "--report", + "fhir", + "--ocr-redact", + "--output", + str(tmp_path / "output"), + ], + ) + assert result.exit_code == 0, result.output + ctx = captured["ctx"] + assert ctx.study_instance_uid == "1.2.3.4" + assert ctx.pixels_reviewed and ctx.allow_external_llm and ctx.deidentify + assert ctx.official_report == "ACL unauffällig — Referenzbefund" + assert ctx.report_format == "fhir" + assert ctx.analysis_provenance["ocr_requested"] is True + assert captured["steps"].split(",") == [ + "ingest", + "preprocess", + "ml_analysis", + "vision_analysis", + "reconcile", + "report", + ] + + +def test_analyze_dicom_sr_and_default_pixel_review_not_assumed(monkeypatch, tmp_path): + result, ctx = _capture_analyze_context( + monkeypatch, + [ + "analyze", + str(tmp_path), + "--report", + "dicom-sr", + "--output", + str(tmp_path / "output"), + ], + ) + assert result.exit_code == 0 + assert ctx.report_format == "dicom-sr" + assert ctx.pixels_reviewed is False + assert ctx.analysis_provenance["ocr_requested"] is False + + +def test_evaluate_cli_writes_artifact_and_fails_on_regression(tmp_path): + import json + + report = tmp_path / "report.json" + report.write_text(json.dumps({"findings": [{"name": "ACL", "status": "normal"}]})) + manifest = tmp_path / "manifest.json" + manifest.write_text( + json.dumps( + [ + { + "id": "case-1", + "report": "report.json", + "expected": [{"name": "ACL", "status": "normal"}], + } + ] + ) + ) + baseline = tmp_path / "baseline.json" + result = runner.invoke(app, ["evaluate", str(manifest), "--output", str(baseline)]) + assert result.exit_code == 0, result.output + assert json.loads(baseline.read_text())["aggregate"]["true_positives"] == 1 + report.write_text(json.dumps({"findings": []})) + output = tmp_path / "regression.json" + result = runner.invoke(app, ["evaluate", str(manifest), "--baseline", str(baseline), "--output", str(output)]) + assert result.exit_code == 1 + assert json.loads(output.read_text())["passed"] is False + + +def test_evaluate_cli_invalid_manifest_does_not_create_output(tmp_path): + manifest = tmp_path / "invalid.json" + manifest.write_text("{}") + output = tmp_path / "out.json" + result = runner.invoke(app, ["evaluate", str(manifest), "--output", str(output)]) + assert result.exit_code == 2 + assert "Manifest must be a JSON array" in result.output + assert not output.exists() diff --git a/tests/unit/test_core/test_workflow.py b/tests/unit/test_core/test_workflow.py index 0f6ff13..1a7fd2b 100644 --- a/tests/unit/test_core/test_workflow.py +++ b/tests/unit/test_core/test_workflow.py @@ -55,3 +55,112 @@ def test_registry_list_steps(): registry.register("ingest", FakeIngest) registry.register("report", FakeReport) assert registry.list_steps() == ["ingest", "report"] + + +def test_workflow_records_completed_steps_and_missing_prerequisites(): + class NotReady(PipelineStep): + name = "not_ready" + + def validate(self, context): + return False + + def run(self, context): + raise AssertionError("A step without prerequisites must not execute") + + registry = StepRegistry() + registry.register("ingest", FakeIngest) + registry.register("not_ready", NotReady) + ctx = WorkflowEngine(registry).run(["not_ready", "ingest"], PipelineContext()) + assert any("not_ready" in message and "prerequisites" in message for message in ctx.limitations) + assert ctx.analysis_provenance["app_version"] + assert ctx.analysis_provenance["started_at"] + assert [step["name"] for step in ctx.analysis_provenance["steps"]] == ["ingest"] + assert ctx.analysis_provenance["steps"][0]["seconds"] >= 0 + + +def test_workflow_deidentifies_before_preprocess_and_masks_before_analysis(): + from unittest.mock import patch + + import numpy as np + + from medcheck.core.context import DicomSeries, PatientInfo + + class InspectPreprocess(PipelineStep): + name = "preprocess" + + def run(self, context): + assert context.patient.patient_id != "ID123" + assert context.dicom_series[0].description == "Series 1" + assert context.clinical_context.anatomy == "knee" + context.volumes["Series 1"] = np.ones((2, 4, 4)) + return context + + class InspectAnalysis(PipelineStep): + name = "analysis" + + def run(self, context): + assert np.all(context.volumes["Series 1"][:, 0, :2] == 0) + assert "ocr_redactions" in context.analysis_provenance + return context + + registry = StepRegistry() + registry.register("preprocess", InspectPreprocess) + registry.register("analysis", InspectAnalysis) + ctx = PipelineContext( + deidentify=True, + patient=PatientInfo(patient_id="ID123"), + dicom_series=[DicomSeries(description="knee")], + analysis_provenance={"ocr_requested": True}, + ) + with patch("medcheck.pipeline.privacy._ocr_rectangles", return_value=[[0, 0, 2, 1]]) as ocr: + result = WorkflowEngine(registry).run(["preprocess", "analysis"], ctx) + assert ocr.call_count == 2 + assert result.pixels_reviewed is False + assert [item["name"] for item in result.analysis_provenance["steps"]] == ["preprocess", "analysis"] + + +def test_preprocess_ocr_config_is_honored_without_mutating_caller_config(): + from unittest.mock import patch + + import numpy as np + + class FakePreprocess(PipelineStep): + name = "preprocess" + + def run(self, context): + context.volumes["series"] = np.ones((1, 4, 4)) + return context + + registry = StepRegistry() + registry.register("preprocess", FakePreprocess) + config = {"preprocess": {"ocr": True}} + with patch("medcheck.pipeline.privacy._ocr_rectangles", return_value=[[0, 0, 1, 1]]) as ocr: + ctx = WorkflowEngine(registry).run(["preprocess"], PipelineContext(), config) + ocr.assert_called_once() + assert ctx.volumes["series"][0, 0, 0] == 0 + ctx.step_config["extra"] = "local" + assert config == {"preprocess": {"ocr": True}} + + +def test_report_only_workflow_scrubs_preexisting_findings_before_report(): + from medcheck.core.context import PatientInfo, StructureFinding + from medcheck.pipeline.report import generate_json_report + + class InspectReport(PipelineStep): + name = "report" + + def run(self, context): + serialized = generate_json_report(context) + assert "ID123" not in serialized + assert "Jane" not in serialized + return context + + registry = StepRegistry() + registry.register("report", InspectReport) + ctx = PipelineContext( + deidentify=True, + patient=PatientInfo(name="Doe^Jane", patient_id="ID123"), + findings=[StructureFinding(name="ACL", findings="ID123 Jane Doe")], + ) + result = WorkflowEngine(registry).run(["report"], ctx) + assert result.analysis_provenance["deidentification"]["metadata_deidentified"] is True diff --git a/tests/unit/test_dockerfile.py b/tests/unit/test_dockerfile.py index 24d0da9..f23bfe6 100644 --- a/tests/unit/test_dockerfile.py +++ b/tests/unit/test_dockerfile.py @@ -9,3 +9,9 @@ def test_dockerfile_runs_as_non_root(): assert "USER medcheck" in content # Every stage that defines a CMD should have switched away from root first. assert content.count("USER medcheck") >= content.count("CMD [") + + +def test_cloud_sdks_installed_in_both_images(): + content = DOCKERFILE.read_text(encoding="utf-8") + assert content.count("--extra cloud") == 2 + assert content.count('CMD ["/app/.venv/bin/medcheck", "serve"]') == 2 diff --git a/tests/unit/test_evaluation.py b/tests/unit/test_evaluation.py new file mode 100644 index 0000000..1faa86c --- /dev/null +++ b/tests/unit/test_evaluation.py @@ -0,0 +1,169 @@ +import json + +import pytest + +from medcheck.evaluation import evaluate_manifest + + +def _write(path, value): + path.write_text(json.dumps(value), encoding="utf-8") + return path + + +def _label(name, status="normal"): + return {"name": name, "status": status} + + +def test_micro_metrics_and_subgroups(tmp_path, monkeypatch): + _write(tmp_path / "a.json", {"findings": [_label(" ACL "), _label("pcl", "abnormal")]}) + _write(tmp_path / "b.json", {"findings": [_label("meniscus")]}) + manifest = _write( + tmp_path / "manifest.json", + [ + {"id": "b", "report": "b.json", "expected": [_label("meniscus")], "subgroup": "b"}, + {"id": "a", "report": "a.json", "expected": [_label("acl"), _label("pcl")], "subgroup": "a"}, + ], + ) + monkeypatch.chdir(tmp_path.parent) + result = evaluate_manifest(manifest) + assert result["aggregate"] == { + "true_positives": 2, + "false_positives": 1, + "false_negatives": 1, + "precision": 2 / 3, + "recall": 2 / 3, + "f1": 2 / 3, + "case_count": 2, + "exact_match_count": 1, + "exact_match_rate": 0.5, + } + assert result["subgroups"]["a"]["recall"] == 0.5 + assert result["cases"][0]["missing"] == [_label("pcl")] + assert result["cases"][0]["unexpected"] == [_label("pcl", "abnormal")] + assert result == evaluate_manifest(manifest) + + +def test_empty_cohort_and_empty_reference(tmp_path): + manifest = _write(tmp_path / "manifest.json", []) + result = evaluate_manifest(manifest) + assert result["aggregate"]["precision"] is None + assert result["aggregate"]["recall"] is None + assert result["aggregate"]["exact_match_rate"] is None + _write(tmp_path / "a.json", {"findings": []}) + _write(manifest, [{"id": "a", "report": "a.json", "expected": []}]) + result = evaluate_manifest(manifest) + assert result["aggregate"]["f1"] is None + assert result["cases"][0]["exact_match"] + + +def test_baseline_regression_and_same_cohort_requirement(tmp_path): + report = _write(tmp_path / "a.json", {"findings": [_label("acl")]}) + manifest = _write(tmp_path / "manifest.json", [{"id": "a", "report": "a.json", "expected": [_label("acl")]}]) + baseline = _write(tmp_path / "baseline.json", evaluate_manifest(manifest)) + assert evaluate_manifest(manifest, baseline)["passed"] + _write(report, {"findings": [_label("acl", "abnormal")]}) + result = evaluate_manifest(manifest, baseline) + assert not result["passed"] + assert any(r["metric"] == "false_negatives" for r in result["regressions"]) + _write(manifest, [{"id": "different", "report": "a.json", "expected": [_label("acl")]}]) + with pytest.raises(ValueError, match="same cases"): + evaluate_manifest(manifest, baseline) + + +def test_subgroup_regression_not_hidden_by_aggregate(tmp_path): + a = _write(tmp_path / "a.json", {"findings": [_label("acl")]}) + b = _write(tmp_path / "b.json", {"findings": []}) + manifest = _write( + tmp_path / "manifest.json", + [ + {"id": "a", "report": "a.json", "expected": [_label("acl")], "subgroup": "left"}, + {"id": "b", "report": "b.json", "expected": [_label("acl")], "subgroup": "right"}, + ], + ) + baseline = _write(tmp_path / "baseline.json", evaluate_manifest(manifest)) + _write(a, {"findings": []}) + _write(b, {"findings": [_label("acl")]}) + result = evaluate_manifest(manifest, baseline) + assert not result["passed"] + assert all(r["scope"] == "subgroup:left" for r in result["regressions"]) + + +@pytest.mark.parametrize("where", ["expected", "findings"]) +def test_duplicate_structure_labels_rejected(tmp_path, where): + labels = [_label("ACL"), _label(" acl ", "abnormal")] + _write(tmp_path / "a.json", {"findings": labels if where == "findings" else []}) + manifest = _write( + tmp_path / "manifest.json", + [ + {"id": "a", "report": "a.json", "expected": labels if where == "expected" else []}, + ], + ) + with pytest.raises(ValueError, match="duplicate structure"): + evaluate_manifest(manifest) + + +@pytest.mark.parametrize( + "value", + [ + None, + {}, + "", + [{"id": "a", "report": "missing.json", "expected": []}], + [{"id": "", "report": "a.json", "expected": []}], + [{"id": "a", "report": None, "expected": []}], + [{"id": "a", "report": "a.json", "expected": [{"name": "acl", "status": 1}]}], + ], +) +def test_malformed_manifest_or_missing_report(tmp_path, value): + _write(tmp_path / "a.json", {"findings": []}) + with pytest.raises(ValueError): + evaluate_manifest(_write(tmp_path / "manifest.json", value)) + + +def test_duplicate_case_ids(tmp_path): + _write(tmp_path / "a.json", {"findings": []}) + case = {"id": "a", "report": "a.json", "expected": []} + with pytest.raises(ValueError, match="duplicate case"): + evaluate_manifest(_write(tmp_path / "manifest.json", [case, case])) + + +def test_invalid_json_and_report_shape(tmp_path): + manifest = tmp_path / "manifest.json" + manifest.write_text("not JSON") + with pytest.raises(ValueError, match="valid JSON"): + evaluate_manifest(manifest) + _write(tmp_path / "a.json", []) + _write(manifest, [{"id": "a", "report": "a.json", "expected": []}]) + with pytest.raises(ValueError, match="report must be an object"): + evaluate_manifest(manifest) + + +@pytest.mark.parametrize("mutation", ["subgroups", "aggregate", "negative_count", "bool_count"]) +def test_corrupt_baseline_metrics_rejected(tmp_path, mutation): + _write(tmp_path / "a.json", {"findings": []}) + manifest = _write(tmp_path / "manifest.json", [{"id": "a", "report": "a.json", "expected": []}]) + baseline = evaluate_manifest(manifest) + if mutation == "subgroups": + baseline["subgroups"] = [] + elif mutation == "aggregate": + baseline["aggregate"] = None + else: + baseline["aggregate"]["true_positives"] = -1 if mutation == "negative_count" else True + path = _write(tmp_path / "baseline.json", baseline) + with pytest.raises(ValueError, match="Baseline"): + evaluate_manifest(manifest, path) + + +@pytest.mark.parametrize("field,value", [("subgroup", ""), ("expected", None)]) +def test_invalid_reference_definition_rejected(tmp_path, field, value): + _write(tmp_path / "a.json", {"findings": []}) + case = {"id": "a", "report": "a.json", "expected": []} + case[field] = value + with pytest.raises(ValueError): + evaluate_manifest(_write(tmp_path / "manifest.json", [case])) + + +def test_absolute_report_path_supported(tmp_path): + report = _write(tmp_path / "report.json", {"findings": []}) + manifest = _write(tmp_path / "manifest.json", [{"id": "a", "report": str(report), "expected": []}]) + assert evaluate_manifest(manifest)["cases"][0]["exact_match"] diff --git a/tests/unit/test_llm/test_local.py b/tests/unit/test_llm/test_local.py index af8b8bf..4a1c153 100644 --- a/tests/unit/test_llm/test_local.py +++ b/tests/unit/test_llm/test_local.py @@ -1,20 +1,99 @@ +import json + +import httpx import pytest +from medcheck.llm.base import AnnotatedImage, LLMProviderError from medcheck.llm.local import LocalLLMProvider -def test_local_provider_metadata(): +def _mock_client(monkeypatch, handler): + original = httpx.Client + captured = {} + + def client(**kwargs): + captured.update(kwargs) + return original(transport=httpx.MockTransport(handler), **kwargs) + + monkeypatch.setattr("medcheck.llm.local.httpx.Client", client) + return captured + + +def test_local_unconfigured(monkeypatch): + monkeypatch.delenv("MEDCHECK_LOCAL_URL", raising=False) + monkeypatch.delenv("MEDCHECK_LOCAL_MODEL", raising=False) provider = LocalLLMProvider() assert provider.name == "local" - assert provider.supports_vision is True + assert provider.supports_vision + assert not provider.check_available() + with pytest.raises(ValueError, match="MEDCHECK_LOCAL_MODEL"): + provider.analyze_images([], "prompt", None) + +@pytest.mark.parametrize( + "url", + [ + "http://example.com/v1", + "http://localhost/v1", + "http://192.168.1.1/v1", + "http://127.0.0.1.evil.test/v1", + "http://127.0.0.1:99999/v1", + "http://user:pass@127.0.0.1/v1", + "ftp://127.0.0.1/v1", + "http://127.0.0.1/v1?target=evil", + "http://127.0.0.1/v1#fragment", + ], +) +def test_remote_or_ambiguous_endpoint_never_connects(url, monkeypatch): + def unexpected(**kwargs): + pytest.fail("invalid local URL must not create a client") -def test_local_provider_not_available(): - # Until LLaVA-Med ships, the provider reports unavailable so the router skips it. - assert LocalLLMProvider().check_available() is False + monkeypatch.setattr("medcheck.llm.local.httpx.Client", unexpected) + provider = LocalLLMProvider(model="vision", base_url=url) + assert not provider.check_available() + with pytest.raises(ValueError, match="loopback"): + provider.analyze_images([], "prompt", None) -def test_local_provider_analyze_raises_actionable_error(): - provider = LocalLLMProvider() - with pytest.raises(NotImplementedError, match="not yet implemented"): +@pytest.mark.parametrize("url", ["http://127.0.0.1:11434/v1", "http://[::1]:8080/v1"]) +def test_probe_and_inference(url, monkeypatch): + requests = [] + + def handler(request): + requests.append(request) + if request.method == "GET": + return httpx.Response(200, json={"data": [{"id": "vision"}]}) + return httpx.Response(200, json={"choices": [{"message": {"content": '{"overall_impression":"ok"}'}}]}) + + captured = _mock_client(monkeypatch, handler) + provider = LocalLLMProvider(model="vision", base_url=url) + assert provider.check_available() + result = provider.analyze_images([AnnotatedImage("s", 0, b"png", "slice 0")], "prompt", None) + assert result.overall_impression == "ok" + assert captured["trust_env"] is False + assert captured["follow_redirects"] is False + payload = json.loads(requests[-1].content) + assert payload["model"] == "vision" + assert payload["messages"][0]["content"][0]["image_url"]["url"] == "data:image/png;base64,cG5n" + + +def test_redirect_not_followed(monkeypatch): + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response(307, headers={"Location": "https://external.invalid"}) + + _mock_client(monkeypatch, handler) + provider = LocalLLMProvider(model="vision", base_url="http://127.0.0.1/v1") + assert not provider.check_available() + with pytest.raises(LLMProviderError): provider.analyze_images([], "prompt", None) + assert len(requests) == 2 + assert all(r.url.host == "127.0.0.1" for r in requests) + + +@pytest.mark.parametrize("body", [{"data": [{"id": "other"}]}, {"data": None}, [], {"data": []}]) +def test_missing_model_or_invalid_probe(monkeypatch, body): + _mock_client(monkeypatch, lambda request: httpx.Response(200, json=body)) + assert not LocalLLMProvider(model="vision", base_url="http://127.0.0.1/v1").check_available() diff --git a/tests/unit/test_llm/test_providers.py b/tests/unit/test_llm/test_providers.py index 470da3a..f757195 100644 --- a/tests/unit/test_llm/test_providers.py +++ b/tests/unit/test_llm/test_providers.py @@ -66,21 +66,30 @@ def __init__(self, **kwargs): def _install_fake_gemini(monkeypatch, captured): - genai = types.ModuleType("google.generativeai") - genai.configure = lambda **kwargs: None + genai = types.ModuleType("google.genai") - class _Model: - def __init__(self, name): - captured["model"] = name + class _Client: + def __init__(self, **kwargs): + captured.update(kwargs) + self.models = self - def generate_content(self, parts, **kwargs): + def generate_content(self, **kwargs): captured.update(kwargs) return types.SimpleNamespace(text=_JSON) - genai.GenerativeModel = _Model - google_pkg = sys.modules.get("google") or types.ModuleType("google") + def close(self): + captured["closed"] = True + + genai.Client = _Client + genai.types = types.SimpleNamespace( + HttpOptions=lambda **kw: types.SimpleNamespace(**kw), + HttpRetryOptions=lambda **kw: types.SimpleNamespace(**kw), + Part=types.SimpleNamespace(from_bytes=lambda **kw: kw), + ) + google_pkg = types.ModuleType("google") + google_pkg.genai = genai monkeypatch.setitem(sys.modules, "google", google_pkg) - monkeypatch.setitem(sys.modules, "google.generativeai", genai) + monkeypatch.setitem(sys.modules, "google.genai", genai) def test_claude_analyze_images(monkeypatch): @@ -127,8 +136,10 @@ def test_gemini_analyze_images(monkeypatch): result = GeminiProvider().analyze_images(_IMAGES, "prompt", None) assert result.overall_impression == "ok" - # Timeout passed through request_options. - assert captured["request_options"]["timeout"] > 0 + assert captured["http_options"].timeout > 0 + assert captured["http_options"].retry_options.attempts == 1 + assert captured["closed"] is True + assert captured["contents"][0] == {"data": b"\x89PNG", "mime_type": "image/png"} def test_gemini_missing_key(monkeypatch): @@ -136,3 +147,21 @@ def test_gemini_missing_key(monkeypatch): _install_fake_gemini(monkeypatch, {}) with pytest.raises(RuntimeError, match="GOOGLE_API_KEY"): GeminiProvider().analyze_images(_IMAGES, "prompt", None) + + +@pytest.mark.parametrize( + "provider,key,module", + [ + (ClaudeProvider, "ANTHROPIC_API_KEY", "anthropic"), + (OpenAIProvider, "OPENAI_API_KEY", "openai"), + (GeminiProvider, "GOOGLE_API_KEY", "google.genai"), + ], +) +def test_availability_requires_sdk_and_key(monkeypatch, provider, key, module): + monkeypatch.setenv(key, "key") + monkeypatch.setitem(sys.modules, module, None) + assert not provider().check_available() + monkeypatch.setitem(sys.modules, module, types.ModuleType(module)) + assert provider().check_available() + monkeypatch.delenv(key) + assert not provider().check_available() diff --git a/tests/unit/test_pipeline/test_exports.py b/tests/unit/test_pipeline/test_exports.py new file mode 100644 index 0000000..20346bb --- /dev/null +++ b/tests/unit/test_pipeline/test_exports.py @@ -0,0 +1,83 @@ +import base64 +import io +import json + +import pydicom +from pydicom.uid import BasicTextSRStorage, ExplicitVRLittleEndian + +from medcheck.core.context import PatientInfo, PipelineContext, StructureFinding, StudyInfo +from medcheck.pipeline.exports import DISCLAIMER, dicom_sr, fhir_report +from medcheck.pipeline.report import generate_json_report + + +def _context(deidentify=False): + return PipelineContext( + patient=PatientInfo(name="Example^Person", patient_id="patient-secret-001", birth_date="19800101", sex="F"), + study=StudyInfo(date="20250101"), + study_instance_uid="1.2.826.0.1.3680043.8.498.123", + deidentify=deidentify, + findings=[ + StructureFinding(name="ACL", status="normal", findings="No focal change.", review_status="confirmed") + ], + overall_impression="Research finding only.", + limitations=["Limited image sample."], + ) + + +def test_fhir_contained_references_resolve_and_remain_preliminary(): + report = json.loads(generate_json_report(_context())) + result = fhir_report(report) + assert result["resourceType"] == "DiagnosticReport" + assert result["status"] == "preliminary" + contained = {f"#{item['id']}": item for item in result["contained"]} + assert len(contained) == len(report["findings"]) + for reference in result["result"]: + observation = contained[reference["reference"]] + assert observation["resourceType"] == "Observation" + assert observation["status"] == "preliminary" + assert "confirmed" in observation["note"][0]["text"] + assert DISCLAIMER in result["conclusion"] + assert json.loads(base64.b64decode(result["presentedForm"][0]["data"])) == report + + +def test_empty_fhir_report_has_no_dangling_references(): + context = _context() + context.findings = [] + result = fhir_report(json.loads(generate_json_report(context))) + assert not result.get("contained") + assert not result.get("result") + + +def test_dicom_sr_readback_flags_and_content(): + context = _context() + report = json.loads(generate_json_report(context)) + ds = pydicom.dcmread(io.BytesIO(dicom_sr(context, report))) + assert ds.SOPClassUID == BasicTextSRStorage + assert ds.file_meta.TransferSyntaxUID == ExplicitVRLittleEndian + assert ds.SOPInstanceUID == ds.file_meta.MediaStorageSOPInstanceUID + assert ds.StudyInstanceUID == context.study_instance_uid + assert ds.Modality == "SR" + assert ds.CompletionFlag == "PARTIAL" + assert ds.VerificationFlag == "UNVERIFIED" + assert ds.PreliminaryFlag == "PRELIMINARY" + assert ds.ValueType == "CONTAINER" + texts = [item.TextValue for item in ds.ContentSequence] + assert DISCLAIMER in texts + assert "Limited image sample." in texts + assert any("ACL" in text and "confirmed" in text for text in texts) + + +def test_exports_use_pseudonymized_patient_values(): + context = _context(deidentify=True) + report = json.loads(generate_json_report(context)) + fhir = fhir_report(report) + embedded = json.loads(base64.b64decode(fhir["presentedForm"][0]["data"])) + ds = pydicom.dcmread(io.BytesIO(dicom_sr(context, report))) + assert embedded["deidentified"] + assert embedded["patient"]["patient_id"] != context.patient.patient_id + assert ds.PatientID == embedded["patient"]["patient_id"] + assert str(ds.PatientName) == embedded["patient"]["name"] + assert ds.PatientBirthDate == "" + assert context.patient.name not in json.dumps(fhir) + assert context.patient.patient_id not in json.dumps(embedded) + assert context.patient.patient_id not in str(ds) diff --git a/tests/unit/test_pipeline/test_ingest.py b/tests/unit/test_pipeline/test_ingest.py index 277fed9..0e2be4c 100644 --- a/tests/unit/test_pipeline/test_ingest.py +++ b/tests/unit/test_pipeline/test_ingest.py @@ -76,3 +76,32 @@ def test_ingest_step_requires_authentication(): ctx.credentials = {} # no access code -> authenticate() returns False with pytest.raises(PermissionError, match="Authentication failed"): IngestStep().run(ctx) + + +def test_multiple_studies_require_selection_and_never_mix_patients(tmp_path): + import pytest + + study_ids = [generate_uid(), generate_uid()] + for index, uid in enumerate(study_ids): + path = tmp_path / f"study-{index}.dcm" + _create_test_dicom(path, "Same series description", 1) + ds = pydicom.dcmread(path) + ds.StudyInstanceUID = uid + ds.SeriesInstanceUID = generate_uid() + ds.PatientID = f"synthetic-{index}" + ds.save_as(path) + with pytest.raises(ValueError, match="Multiple studies"): + IngestStep().run(PipelineContext(source=str(tmp_path))) + ctx = IngestStep().run(PipelineContext(source=str(tmp_path), study_instance_uid=study_ids[1])) + assert ctx.patient.patient_id == "synthetic-1" + assert len(ctx.dicom_series) == 1 + assert all(str(ds.StudyInstanceUID) == study_ids[1] for ds in ctx.dicom_series[0].slices) + with pytest.raises(ValueError, match="No readable DICOM"): + IngestStep().run(PipelineContext(source=str(tmp_path), study_instance_uid=generate_uid())) + + +def test_empty_source_cannot_produce_a_successful_ingest(tmp_path): + import pytest + + with pytest.raises(ValueError, match="No readable DICOM"): + IngestStep().run(PipelineContext(source=str(tmp_path))) diff --git a/tests/unit/test_pipeline/test_ml_analysis.py b/tests/unit/test_pipeline/test_ml_analysis.py index 49d9500..8657748 100644 --- a/tests/unit/test_pipeline/test_ml_analysis.py +++ b/tests/unit/test_pipeline/test_ml_analysis.py @@ -60,7 +60,7 @@ def test_analyze_signal_intensity(): def test_ml_step_runs_on_volumes(): - ctx = PipelineContext() + ctx = PipelineContext(step_config={"backend": "statistical"}) ctx.volumes = { "test_series": np.random.rand(5, 64, 64).astype(np.float32), } @@ -105,3 +105,56 @@ def test_extract_features_falls_back_on_import_error(): with patch.object(ml_analysis, "_resnet_features", side_effect=ImportError("No module named 'torch'")): features = ml_analysis.extract_features(volume) assert features.shape == (2, 10) + + +def test_signal_uses_shared_threshold_to_distinguish_bright_slices(): + volume = np.tile(np.linspace(0, 1, 64).reshape(8, 8), (10, 1, 1)) + volume[3] *= 5 + stats = analyze_signal_intensity(volume) + assert stats.high_signal_ratio[3] > 0.4 + assert stats.high_signal_ratio[0] == 0 + assert 3 in stats.high_signal_slices + + +def test_statistical_backend_never_initializes_resnet_or_downloads(): + ctx = PipelineContext(volumes={"s": np.ones((2, 8, 8))}, step_config={"backend": "statistical"}) + with patch.object(ml_analysis, "_resnet_features") as resnet: + MLAnalysisStep().run(ctx) + resnet.assert_not_called() + assert ctx.analysis_provenance["ml_backend_requested"] == "statistical" + assert len(ctx.anomaly_scores["s"]) == 2 + + +def test_resnet_eval_and_batch_size_invariance_without_weight_download(monkeypatch): + torch = pytest.importorskip("torch") + torchvision = pytest.importorskip("torchvision") + real_resnet18 = torchvision.models.resnet18 + requested_weights = [] + + def offline_resnet18(*, weights): + requested_weights.append(weights) + torch.manual_seed(42) + return real_resnet18(weights=None) + + monkeypatch.setattr(torchvision.models, "resnet18", offline_resnet18) + monkeypatch.setenv("MEDCHECK_ML_DEVICE", "cpu") + extractor = ml_analysis._build_feature_extractor() + assert requested_weights == [torchvision.models.ResNet18_Weights.IMAGENET1K_V1] + assert all(not module.training for module in extractor.modules()) + assert all(not parameter.requires_grad for parameter in extractor.parameters()) + assert next(extractor.parameters()).device.type == "cpu" + monkeypatch.setattr(ml_analysis, "_feature_extractor", extractor) + volume = np.random.default_rng(123).random((4, 32, 32), dtype=np.float32) + original_threads = torch.get_num_threads() + try: + torch.set_num_threads(1) + monkeypatch.setenv("MEDCHECK_ML_BATCH_SIZE", "1") + single = ml_analysis._resnet_features(volume) + monkeypatch.setenv("MEDCHECK_ML_BATCH_SIZE", "3") + batched = ml_analysis._resnet_features(volume) + finally: + torch.set_num_threads(original_threads) + assert single.shape == batched.shape == (4, 512) + assert np.isfinite(single).all() + assert not np.array_equal(single[0], single[1]) + np.testing.assert_allclose(single, batched, rtol=1e-4, atol=1e-5) diff --git a/tests/unit/test_pipeline/test_preprocess.py b/tests/unit/test_pipeline/test_preprocess.py index 0dcb034..15db495 100644 --- a/tests/unit/test_pipeline/test_preprocess.py +++ b/tests/unit/test_pipeline/test_preprocess.py @@ -1,5 +1,6 @@ import numpy as np -from pydicom.dataset import Dataset +from pydicom.dataset import Dataset, FileMetaDataset +from pydicom.uid import ExplicitVRLittleEndian from medcheck.core.context import DicomSeries, PipelineContext from medcheck.pipeline.preprocess import PreprocessStep, detect_anatomy, detect_plane @@ -7,6 +8,8 @@ def _make_slice(rows=64, cols=64, series_desc="pd_tse_fs_sag_3mm", instance_num=1, slice_loc=0.0): ds = Dataset() + ds.file_meta = FileMetaDataset() + ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian ds.SeriesDescription = series_desc ds.InstanceNumber = instance_num ds.SliceLocation = slice_loc @@ -182,3 +185,53 @@ def test_preprocess_empty_descriptions_keep_all_series(): assert len(result.volumes) == 2 assert "series-4" in result.volumes assert "series-2" in result.volumes + + +def test_geometry_order_and_filtered_source_references(): + slices = [_make_slice(rows=32 if i == 1 else 64, instance_num=i) for i in range(5)] + for index, ds in enumerate(slices): + ds.ImageOrientationPatient = [0, 1, 0, 0, 0, 1] # sagittal normal along x + ds.ImagePositionPatient = [4 - index, 0, 0] + ctx = PreprocessStep().run(PipelineContext(dicom_series=[DicomSeries(description="axial", slices=slices)])) + assert ctx.detected_planes["axial"] == "sagittal" + assert [r["original_index"] for r in ctx.slice_references["axial"]] == [4, 3, 2, 0] + assert any("deviating dimensions" in note for note in ctx.quality_checks["axial"]) + assert any("missing slices" in note for note in ctx.quality_checks["axial"]) + + +def test_missing_transfer_syntax_rejected_without_raw_guess(): + ds = _make_slice() + del ds.file_meta.TransferSyntaxUID + ctx = PreprocessStep().run(PipelineContext(dicom_series=[DicomSeries(description="bad", slices=[ds])])) + assert not ctx.volumes + assert "skipped" in ctx.limitations[0] + + +def test_signed_pixels_rescale_and_monochrome_one(): + from medcheck.pipeline.preprocess import _extract_pixel_array + + ds = _make_slice(rows=2, cols=2) + ds.PixelRepresentation = 1 + ds.PixelData = np.array([[-2, -1], [0, 1]], dtype=np.int16).tobytes() + ds.RescaleSlope = 2 + ds.RescaleIntercept = -10 + assert _extract_pixel_array(ds).tolist() == [[-14, -12], [-10, -8]] + ds.PhotometricInterpretation = "MONOCHROME1" + assert _extract_pixel_array(ds).tolist() == [[-8, -10], [-12, -14]] + + +def test_duplicate_positions_warn_and_clinical_anatomy_wins(): + from medcheck.core.context import ClinicalContext + + slices = [_make_slice(), _make_slice()] + for ds in slices: + ds.ImageOrientationPatient = [1, 0, 0, 0, 1, 0] + ds.ImagePositionPatient = [0, 0, 1] + ctx = PreprocessStep().run( + PipelineContext( + dicom_series=[DicomSeries(description="knee", slices=slices)], + clinical_context=ClinicalContext(anatomy="ankle"), + ) + ) + assert ctx.detected_anatomy == "ankle" + assert any("Duplicate" in warning for warning in ctx.limitations) diff --git a/tests/unit/test_pipeline/test_privacy.py b/tests/unit/test_pipeline/test_privacy.py new file mode 100644 index 0000000..38950fa --- /dev/null +++ b/tests/unit/test_pipeline/test_privacy.py @@ -0,0 +1,246 @@ +from unittest.mock import patch + +import numpy as np +import pytest +from pydicom.dataset import Dataset, FileMetaDataset +from pydicom.sequence import Sequence +from pydicom.uid import ExplicitVRLittleEndian, generate_uid + +from medcheck.core.context import ClinicalContext, DicomSeries, PatientInfo, PipelineContext +from medcheck.pipeline.privacy import DeidentifyStep, apply_pixel_redactions, redact_known_identifiers + + +def make_dataset(study_uid, series_uid): + ds = Dataset() + ds.SOPClassUID = "1.2.840.10008.5.1.4.1.1.4" + ds.SOPInstanceUID = generate_uid() + ds.StudyInstanceUID = study_uid + ds.SeriesInstanceUID = series_uid + ds.PatientName = "Doe^Jane" + ds.PatientID = "ID123" + ds.PatientBirthDate = "19800102" + ds.SeriesDescription = "Jane Doe knee" + ds.add_new((0x0011, 0x0010), "LO", "SECRET") + ds.file_meta = FileMetaDataset() + ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian + ds.file_meta.MediaStorageSOPInstanceUID = ds.SOPInstanceUID + ds.file_meta.ImplementationVersionName = "PRIVATE_NAME" + nested = Dataset() + nested.PatientName = "Nested^Secret" + nested.ImagePositionPatient = [0, 0, 1] + ds.PlanePositionSequence = Sequence([nested]) + ds.Rows = 2 + ds.Columns = 2 + ds.BitsAllocated = 16 + ds.BitsStored = 16 + ds.HighBit = 15 + ds.PixelRepresentation = 0 + ds.SamplesPerPixel = 1 + ds.PhotometricInterpretation = "MONOCHROME2" + ds.PixelData = np.arange(4, dtype=np.uint16).tobytes() + return ds + + +def test_deidentify_copies_sources_scrubs_nested_tags_and_preserves_uid_relations(): + study_uid, series_uid = generate_uid(), generate_uid() + original = [make_dataset(study_uid, series_uid) for _ in range(2)] + series = DicomSeries( + description="knee", + slices=original, + metadata={"study_instance_uid": study_uid, "series_instance_uid": series_uid}, + ) + ctx = PipelineContext( + deidentify=True, + dicom_series=[series], + study_instance_uid=study_uid, + patient=PatientInfo(name="Doe^Jane", patient_id="ID123"), + official_report="Jane Doe (ID123), born 19800102", + clinical_context=ClinicalContext(), + ) + DeidentifyStep().run(ctx) + cleaned = ctx.dicom_series[0].slices + assert str(original[0].PatientName) == "Doe^Jane" + assert series.description == "knee" + assert "PatientName" in original[0].PlanePositionSequence[0] + assert all(ds.StudyInstanceUID != study_uid for ds in cleaned) + assert cleaned[0].StudyInstanceUID == cleaned[1].StudyInstanceUID + assert cleaned[0].SeriesInstanceUID == cleaned[1].SeriesInstanceUID + assert cleaned[0].SOPInstanceUID != cleaned[1].SOPInstanceUID + assert cleaned[0].file_meta.MediaStorageSOPInstanceUID == cleaned[0].SOPInstanceUID + assert cleaned[0].file_meta.TransferSyntaxUID == ExplicitVRLittleEndian + assert "ImplementationVersionName" not in cleaned[0].file_meta + assert not any(element.tag.is_private for element in cleaned[0]) + assert "PatientName" not in cleaned[0].PlanePositionSequence[0] + assert "ImagePositionPatient" in cleaned[0].PlanePositionSequence[0] + assert ctx.study_instance_uid == cleaned[0].StudyInstanceUID + assert ctx.dicom_series[0].metadata["series_instance_uid"] == cleaned[0].SeriesInstanceUID + assert np.array_equal(cleaned[0].pixel_array, original[0].pixel_array) + assert ctx.clinical_context.anatomy == "knee" + assert "Jane" not in ctx.official_report and "ID123" not in ctx.official_report + assert "19800102" not in ctx.official_report + assert ctx.analysis_provenance["deidentification"]["pixels_reviewed"] is False + assert any("does not guarantee" in note for note in ctx.limitations) + + +def test_deidentify_preserves_explicit_anatomy_and_disabled_is_noop(): + ctx = PipelineContext( + dicom_series=[DicomSeries(description="knee")], clinical_context=ClinicalContext(anatomy="shoulder") + ) + assert DeidentifyStep().run(ctx) is ctx + assert ctx.dicom_series[0].description == "knee" + ctx.deidentify = True + DeidentifyStep().run(ctx) + assert ctx.clinical_context.anatomy == "shoulder" + + +def test_known_identifier_redaction_case_and_dicom_name_variants(): + assert redact_known_identifiers("Jane Doe, DOE^JANE id123", ["Doe^Jane", "ID123"]) == ( + "[redacted], [redacted] [redacted]" + ) + + +@pytest.mark.parametrize( + "rectangle", [[-1, 0, 1, 1], [0, 0, 0, 1], [3, 0, 2, 1], [0, 0, 1], [0.5, 0, 1, 1], [True, 0, 1, 1]] +) +def test_invalid_masks_fail_before_any_pixels_change(rectangle): + pixels = np.ones((2, 4, 4)) + ctx = PipelineContext(volumes={"s": pixels}, redactions={"s": [[0, 0, 1, 1], rectangle]}) + with pytest.raises(ValueError, match="Redaction"): + apply_pixel_redactions(ctx) + assert np.all(ctx.volumes["s"] == 1) + + +def test_explicit_masks_copy_original_pixels_and_ocr_is_opt_in(): + pixels = np.ones((2, 4, 4)) + ctx = PipelineContext(volumes={"s": pixels}, redactions={"s": [[1, 1, 2, 1]]}) + with patch("medcheck.pipeline.privacy._ocr_rectangles") as ocr: + apply_pixel_redactions(ctx) + ocr.assert_not_called() + assert np.all(pixels == 1) + assert np.all(ctx.volumes["s"][:, 1, 1:3] == 0) + assert np.all(ctx.volumes["s"][:, 0, :] == 1) + assert not ctx.pixels_reviewed + + +def test_ocr_masks_each_slice_and_does_not_mark_pixels_reviewed(): + ctx = PipelineContext(volumes={"s": np.ones((2, 4, 4))}, step_config={"ocr": True}) + with patch("medcheck.pipeline.privacy._ocr_rectangles", return_value=[[0, 0, 2, 1]]) as ocr: + apply_pixel_redactions(ctx) + assert ocr.call_count == 2 + assert np.all(ctx.volumes["s"][:, 0, :2] == 0) + assert len(ctx.analysis_provenance["ocr_redactions"]["s"]) == 2 + assert not ctx.pixels_reviewed + assert any("may miss" in note for note in ctx.limitations) + + +def test_ocr_failure_does_not_commit_partial_masks(): + ctx = PipelineContext(volumes={"s": np.ones((2, 4, 4))}, step_config={"ocr": True}) + with patch("medcheck.pipeline.privacy._ocr_rectangles", side_effect=[[[0, 0, 1, 1]], RuntimeError("timeout")]): + with pytest.raises(RuntimeError, match="timeout"): + apply_pixel_redactions(ctx) + assert np.all(ctx.volumes["s"] == 1) + + +def test_existing_report_text_and_nested_review_history_redact_known_identifiers(): + import json + + from medcheck.core.context import StructureFinding + from medcheck.pipeline.report import generate_json_report + + ctx = PipelineContext( + deidentify=True, + patient=PatientInfo(name="Doe^Jane", patient_id="ID123", birth_date="19800102"), + findings=[ + StructureFinding( + name="ACL", + findings="Jane Doe ID123 reports pain", + secondary_signs=["ID123"], + image_references=[{"series_name": "ID123 knee", "slice_index": 0}], + ) + ], + overall_impression="ID123: pain", + clinical_correlation="Doe^Jane born 19800102", + limitations=["Patient ID123"], + review_history=[{"before": {"findings": "Jane Doe"}, "note": "ID123"}], + reconciliation={"reference_report": "19800102 ID123", "comparisons": [{"reference_passages": ["Jane Doe"]}]}, + analysis_provenance={"operator_note": "ID123"}, + volumes={"ID123 knee": np.ones((1, 2, 2))}, + quality_checks={"ID123 knee": ["Jane Doe"]}, + ) + DeidentifyStep().run(ctx) + report = generate_json_report(ctx) + assert all(identifier not in report for identifier in ("Jane", "Doe", "ID123", "19800102")) + parsed = json.loads(report) + reference_name = parsed["findings"][0]["image_references"][0]["series_name"] + assert reference_name in ctx.volumes + assert ctx.analysis_provenance["deidentification"]["free_text_anonymization_guaranteed"] is False + + +def test_redacted_series_names_do_not_collide_or_break_finding_references(): + from medcheck.core.context import StructureFinding + + ctx = PipelineContext( + deidentify=True, + patient=PatientInfo(name="Doe^Jane", patient_id="ID123"), + volumes={"Jane knee": np.ones((1, 2, 2)), "ID123 knee": np.zeros((1, 2, 2))}, + findings=[StructureFinding(name="ACL", image_references=[{"series_name": "ID123 knee", "slice_index": 0}])], + ) + DeidentifyStep().run(ctx) + assert len(ctx.volumes) == 2 + assert list(ctx.volumes) == ["[redacted] knee", "[redacted] knee (2)"] + assert ctx.findings[0].image_references[0]["series_name"] == "[redacted] knee (2)" + + +def test_ocr_adapter_masks_padded_clipped_boxes_without_retaining_text(monkeypatch): + import json + from types import SimpleNamespace + + calls = [] + + def image_to_data(image, *, output_type, timeout): + calls.append((image.copy(), output_type, timeout)) + return { + "text": [" ", "SYNTHETIC IDENTIFIER", "outside image"], + "left": [0, 8, 20], + "top": [0, 8, 20], + "width": [0, 4, 2], + "height": [0, 4, 2], + } + + fake_ocr = SimpleNamespace(Output=SimpleNamespace(DICT="dict"), image_to_data=image_to_data) + monkeypatch.setattr("medcheck.pipeline.privacy.importlib.import_module", lambda name: fake_ocr) + pixels = np.ones((1, 10, 10), dtype=np.float32) + pixels[0, 0, :2] = [-0.5, 1.5] + ctx = PipelineContext(volumes={"s": pixels}, step_config={"ocr": True}) + apply_pixel_redactions(ctx) + sent, output_type, timeout = calls[0] + assert sent.dtype == np.uint8 + assert sent[0, :2].tolist() == [0, 255] + assert output_type == "dict" and timeout == 15 + assert np.all(ctx.volumes["s"][0, 6:10, 6:10] == 0) + assert np.all(pixels[0, 6:10, 6:10] == 1) + assert ctx.analysis_provenance["ocr_redactions"]["s"] == [{"slice_index": 0, "rectangle": [6, 6, 4, 4]}] + assert "SYNTHETIC IDENTIFIER" not in json.dumps(ctx.analysis_provenance) + assert not ctx.pixels_reviewed + + +def test_missing_ocr_installation_stops_before_altering_pixels(monkeypatch): + def missing_module(name): + raise ImportError("pytesseract not installed") + + monkeypatch.setattr("medcheck.pipeline.privacy.importlib.import_module", missing_module) + pixels = np.ones((1, 4, 4)) + ctx = PipelineContext(volumes={"s": pixels}, step_config={"ocr": True}) + with pytest.raises(RuntimeError, match="privacy extra"): + apply_pixel_redactions(ctx) + assert ctx.volumes["s"] is pixels + assert np.all(pixels == 1) + + +@pytest.mark.parametrize("config", [{"redactions": {"missing": [[0, 0, 1, 1]]}}, {"step_config": {"ocr": "yes"}}]) +def test_invalid_privacy_configuration_does_not_mask_any_images(config): + pixels = np.ones((1, 4, 4)) + ctx = PipelineContext(volumes={"s": pixels}, **config) + with pytest.raises(ValueError): + apply_pixel_redactions(ctx) + assert ctx.volumes["s"] is pixels diff --git a/tests/unit/test_pipeline/test_reconcile.py b/tests/unit/test_pipeline/test_reconcile.py new file mode 100644 index 0000000..1b355fb --- /dev/null +++ b/tests/unit/test_pipeline/test_reconcile.py @@ -0,0 +1,43 @@ +from medcheck.core.context import PipelineContext, StructureFinding +from medcheck.pipeline.reconcile import ReconcileStep, compare_reports + + +def test_missing_reference_report_produces_no_comparison(): + ctx = PipelineContext(official_report=" \n ", findings=[StructureFinding(name="ACL")]) + assert compare_reports(ctx) == {} + assert ReconcileStep().run(ctx).reconciliation == {} + + +def test_case_insensitive_structure_matching_retains_negation_for_review(): + ctx = PipelineContext( + official_report=" ACL intact. No PCL tear! Cartilage unremarkable\nACL shows no discontinuity ", + findings=[StructureFinding(name="acl", status="complete tear"), StructureFinding(name="PCL")], + ) + result = compare_reports(ctx) + assert result["method"] == "lexical-structure-match-v1" + assert result["comparisons"][0] == { + "finding_index": 0, + "structure": "acl", + "status": "requires_review", + "reference_passages": ["ACL intact", "ACL shows no discontinuity"], + } + assert result["comparisons"][1]["reference_passages"] == ["No PCL tear"] + assert result["comparisons"][1]["status"] == "requires_review" + assert "do not establish agreement" in result["limitation"] + + +def test_unmatched_synonym_and_empty_name_never_invent_agreement(): + ctx = PipelineContext( + official_report="Anterior cruciate ligament is intact.", + findings=[StructureFinding(name="ACL"), StructureFinding(name="")], + ) + result = ReconcileStep().run(ctx) + assert all(item["status"] == "not_matched" for item in result.reconciliation["comparisons"]) + assert all(item["reference_passages"] == [] for item in result.reconciliation["comparisons"]) + assert [finding.name for finding in result.findings] == ["ACL", ""] + + +def test_reference_without_findings_is_retained_for_manual_review(): + result = compare_reports(PipelineContext(official_report="Original report")) + assert result["comparisons"] == [] + assert result["reference_report"] == "Original report" diff --git a/tests/unit/test_pipeline/test_report.py b/tests/unit/test_pipeline/test_report.py index 2f8ce2b..fcf3512 100644 --- a/tests/unit/test_pipeline/test_report.py +++ b/tests/unit/test_pipeline/test_report.py @@ -134,3 +134,27 @@ def test_report_step_restricts_permissions(tmp_path: Path): if sys.platform != "win32": mode = os.stat(result.report_path).st_mode & 0o777 assert mode == 0o600 + + +def test_report_step_writes_preliminary_fhir_and_dicom_without_overwriting(tmp_path): + import pydicom + + ctx = _make_ctx(tmp_path) + ctx.report_format = "fhir" + ReportStep().run(ctx) + first = Path(ctx.report_path) + fhir = json.loads(first.read_text(encoding="utf-8")) + assert fhir["resourceType"] == "DiagnosticReport" + assert fhir["status"] == "preliminary" + assert fhir["contained"][0]["valueString"] == ctx.findings[0].findings + ReportStep().run(ctx) + assert Path(ctx.report_path) != first + assert first.exists() + + ctx.report_format = "dicom-sr" + ReportStep().run(ctx) + sr = pydicom.dcmread(ctx.report_path) + assert sr.Modality == "SR" + assert sr.CompletionFlag == "PARTIAL" + assert sr.VerificationFlag == "UNVERIFIED" + assert any(ctx.findings[0].findings in item.TextValue for item in sr.ContentSequence) diff --git a/tests/unit/test_pipeline/test_vision_analysis.py b/tests/unit/test_pipeline/test_vision_analysis.py index 338f832..e4e75cb 100644 --- a/tests/unit/test_pipeline/test_vision_analysis.py +++ b/tests/unit/test_pipeline/test_vision_analysis.py @@ -96,6 +96,7 @@ def test_vision_step_populates_findings(): ctx.volumes = {"test_sag": np.random.rand(5, 64, 64).astype(np.float32)} ctx.top_slices = {"test_sag": [2, 3, 1]} ctx.detected_anatomy = "knee" + ctx.pixels_reviewed = True ctx.allow_external_llm = True # consent to the cloud provider mock_result = AnalysisResult( @@ -151,6 +152,7 @@ def test_vision_step_run_caps_images_sent_to_provider(): ctx = PipelineContext() ctx.volumes = {f"s{i}": np.random.rand(5, 16, 16).astype(np.float32) for i in range(6)} ctx.detected_anatomy = "knee" + ctx.pixels_reviewed = True ctx.allow_external_llm = True mock_result = AnalysisResult( @@ -217,6 +219,7 @@ def test_vision_step_allows_local_without_consent(): def test_vision_step_honours_explicit_provider_preference(): ctx = _consent_test_context() + ctx.pixels_reviewed = True ctx.allow_external_llm = True ctx.llm_provider = "gemini" @@ -230,3 +233,47 @@ def test_vision_step_honours_explicit_provider_preference(): with patch.object(step, "_get_router", return_value=mock_router): step.run(ctx) assert mock_router.select.call_args.kwargs.get("preferred") == "gemini" + + +def test_balanced_selection_includes_late_series_and_valid_indices(): + ctx = PipelineContext(volumes={f"s{i}": np.zeros((10, 8, 8)) for i in range(4)}) + ctx.top_slices = {"s0": [-1, 99, 3, 3]} + images = _collect_images(ctx, max_images=8) + assert [image.series_name for image in images] == ["s0", "s1", "s2", "s3"] * 2 + assert images[0].slice_index == 3 + assert len({(image.series_name, image.slice_index) for image in images}) == 8 + + +def test_vision_retains_warnings_and_rejects_unsent_references(): + finding = StructureFinding( + name="ACL", + image_references=[ + {"series_name": "s", "slice_index": 0}, + {"series_name": "s", "slice_index": 99}, + ], + ) + provider = MagicMock(name="provider") + provider.name = "local" + provider.analyze_images.return_value = AnalysisResult(structures=[finding], limitations=["LLM warning"]) + router = MagicMock() + router.select.return_value = provider + ctx = PipelineContext(volumes={"s": np.zeros((2, 8, 8))}, limitations=["Import warning"]) + with patch.object(VisionAnalysisStep, "_get_router", return_value=router): + VisionAnalysisStep().run(ctx) + assert "Import warning" in ctx.limitations + assert "LLM warning" in ctx.limitations + assert finding.image_references == [{"series_name": "s", "slice_index": 0}] + assert len(ctx.analysis_provenance["selected_images"]) == 2 + + +def test_cloud_consent_does_not_substitute_for_pixel_review(): + ctx = _consent_test_context() + ctx.allow_external_llm = True + provider = MagicMock() + provider.name = "claude" + router = MagicMock() + router.select.return_value = provider + with patch.object(VisionAnalysisStep, "_get_router", return_value=router): + with pytest.raises(PermissionError, match="pixel review"): + VisionAnalysisStep().run(ctx) + provider.analyze_images.assert_not_called() diff --git a/tests/unit/test_providers/test_local.py b/tests/unit/test_providers/test_local.py index c8c8fb8..072346a 100644 --- a/tests/unit/test_providers/test_local.py +++ b/tests/unit/test_providers/test_local.py @@ -124,3 +124,76 @@ def test_scan_zip_accepts_normal_archive(tmp_path: Path): zf.write(dicom_dir / "slice1.dcm", "series/slice1.dcm") result = LocalProvider().fetch(str(zip_path), {}) assert len(result) == 1 + + +def test_uid_grouping_keeps_identical_descriptions_separate(tmp_path): + study = generate_uid() + for index in range(3): + path = tmp_path / f"{index}.dcm" + _create_test_dicom(path, "identical", 1) + ds = pydicom.dcmread(path) + ds.StudyInstanceUID = study if index < 2 else generate_uid() + ds.SeriesInstanceUID = generate_uid() + ds.save_as(path) + result = LocalProvider().fetch(str(tmp_path), {}) + assert len(result) == 3 + assert len({s.metadata["study_instance_uid"] for s in result}) == 2 + + +def test_dicomdir_import_and_escape_rejection(tmp_path): + from unittest.mock import patch + + import pytest + + image_path = tmp_path / "IMAGE001" + _create_test_dicom(image_path) + directory = tmp_path / "DICOMDIR" + directory.touch() + record = pydicom.Dataset() + record.ReferencedFileID = ["IMAGE001"] + index = pydicom.Dataset() + index.DirectoryRecordSequence = [record] + original = pydicom.dcmread + + def read(path, **kwargs): + return index if Path(path) == directory else original(path, **kwargs) + + with patch("medcheck.providers.local.pydicom.dcmread", side_effect=read): + assert len(LocalProvider().fetch(str(directory), {})) == 1 + record.ReferencedFileID = ["..", "SECRET"] + with pytest.raises(ValueError, match="Unsafe"): + LocalProvider().fetch(str(directory), {}) + + +def test_single_dicom_does_not_import_sibling_files(tmp_path): + first = tmp_path / "one.dcm" + _create_test_dicom(first, "one") + _create_test_dicom(tmp_path / "two.dcm", "two") + result = LocalProvider().fetch(str(first), {}) + assert len(result) == 1 + assert result[0].description == "one" + + +def test_input_quota_applies_to_single_file_and_aggregate(tmp_path, monkeypatch): + import pytest + + first, second = tmp_path / "one.dcm", tmp_path / "two.dcm" + _create_test_dicom(first) + _create_test_dicom(second) + monkeypatch.setenv("MEDCHECK_MAX_DICOM_BYTES", str(first.stat().st_size + 1)) + assert LocalProvider().fetch(str(first), {}) + with pytest.raises(ValueError, match="byte limit"): + LocalProvider().fetch(str(tmp_path), {}) + monkeypatch.setenv("MEDCHECK_MAX_DICOM_BYTES", "1") + with pytest.raises(ValueError, match="byte limit"): + LocalProvider().fetch(str(first), {}) + + +def test_zip_uncompressed_quota_applied_before_extraction(tmp_path, monkeypatch): + import pytest + + monkeypatch.setenv("MEDCHECK_MAX_DICOM_BYTES", "10") + archive = tmp_path / "large.zip" + _write_zip(archive, {"image.dcm": b"123456789012"}) + with pytest.raises(ValueError, match="uncompressed size"): + LocalProvider().fetch(str(archive), {}) diff --git a/tests/unit/test_web.py b/tests/unit/test_web.py index 3b73454..c8ed480 100644 --- a/tests/unit/test_web.py +++ b/tests/unit/test_web.py @@ -1,3 +1,4 @@ +import pytest from fastapi.testclient import TestClient from medcheck.core.config import Settings @@ -20,16 +21,22 @@ def test_app_health(): assert resp.json()["status"] == "ok" +@pytest.fixture(autouse=True) +def isolated_store(tmp_path, monkeypatch): + monkeypatch.setenv("MEDCHECK_STATE_DIR", str(tmp_path / "state")) + monkeypatch.setenv("MEDCHECK_DATA_ROOT", str(tmp_path / "data")) + + _VALID_BODY = {"source": "/data/scans"} def test_analyze_open_when_no_api_key_configured(): # Back-compat: no MEDCHECK_API_KEY -> endpoint stays open (localhost default). - # The pipeline is not wired up yet, so it must report 501 (not a 200 "success"). + # Auth passes and the out-of-root source receives an actionable input error. client = TestClient(create_app(Settings(api_key=None))) resp = client.post("/api/analyze", json=_VALID_BODY) - assert resp.status_code == 501 - assert "not implemented" in resp.json()["detail"].lower() + assert resp.status_code == 422 + assert "MEDCHECK_DATA_ROOT" in resp.json()["detail"] def test_analyze_requires_key_when_configured(): @@ -38,9 +45,9 @@ def test_analyze_requires_key_when_configured(): assert client.post("/api/analyze", json=_VALID_BODY).status_code == 401 # Wrong key. assert client.post("/api/analyze", json=_VALID_BODY, headers={"X-API-Key": "nope"}).status_code == 401 - # Correct key -> auth passes, endpoint reports the stub as 501 (not yet implemented). + # Correct key passes auth and reaches source validation. ok = client.post("/api/analyze", json=_VALID_BODY, headers={"X-API-Key": "s3cret"}) - assert ok.status_code == 501 + assert ok.status_code == 422 def test_analyze_validates_request_body(): @@ -55,11 +62,12 @@ def test_analyze_validates_request_body(): def test_analyze_accepts_all_supported_languages(): # Web schema must accept the same locales the CLI and i18n catalogs support; - # a valid fr/es body should reach the (501) stub, not be rejected with 422. + # Valid fr/es bodies reach source validation rather than schema rejection. client = TestClient(create_app(Settings(api_key=None))) for lang in ("en", "de", "fr", "es"): resp = client.post("/api/analyze", json={"source": "x", "language": lang}) - assert resp.status_code == 501, lang + assert resp.status_code == 422, lang + assert isinstance(resp.json()["detail"], str), lang def test_health_open_even_with_api_key(): @@ -196,7 +204,7 @@ def test_analyze_allows_same_origin_post(): json=_VALID_BODY, headers={"Origin": "http://testserver"}, ) - assert resp.status_code == 501 # passes CSRF check, hits the stub + assert resp.status_code == 422 # passes CSRF check, reaches source validation def test_analyze_rejects_null_origin(): @@ -212,7 +220,7 @@ def test_analyze_rate_limited(monkeypatch): monkeypatch.setenv("MEDCHECK_RATE_LIMIT", "3") client = TestClient(create_app(Settings(api_key=None))) statuses = [client.post("/api/analyze", json=_VALID_BODY).status_code for _ in range(5)] - assert statuses[:3] == [501, 501, 501] + assert statuses[:3] == [422, 422, 422] assert statuses[3] == 429 assert statuses[4] == 429 @@ -229,7 +237,7 @@ def test_rate_limit_ignores_forwarded_header_by_default(monkeypatch): client.post("/api/analyze", json=_VALID_BODY, headers={"X-Forwarded-For": f"10.0.0.{i}"}).status_code for i in range(4) ] - assert statuses == [501, 501, 429, 429] + assert statuses == [422, 422, 429, 429] def test_rate_limit_keys_on_forwarded_hop_when_proxy_trusted(monkeypatch): @@ -239,21 +247,21 @@ def test_rate_limit_keys_on_forwarded_hop_when_proxy_trusted(monkeypatch): client = TestClient(create_app(Settings(api_key=None, trust_proxy_headers=True))) for i in range(4): resp = client.post("/api/analyze", json=_VALID_BODY, headers={"X-Forwarded-For": f"10.0.0.{i}, 172.16.0.1"}) - assert resp.status_code == 501 # each client has its own bucket + assert resp.status_code == 422 # each client has its own bucket # ...while one client hammering does get limited. statuses = [ client.post("/api/analyze", json=_VALID_BODY, headers={"X-Forwarded-For": "10.0.0.99"}).status_code for _ in range(3) ] - assert statuses == [501, 501, 429] + assert statuses == [422, 422, 429] def test_rate_limit_trusted_proxy_missing_header_falls_back_to_socket_ip(monkeypatch): monkeypatch.setenv("MEDCHECK_RATE_LIMIT", "2") client = TestClient(create_app(Settings(api_key=None, trust_proxy_headers=True))) statuses = [client.post("/api/analyze", json=_VALID_BODY).status_code for _ in range(3)] - assert statuses == [501, 501, 429] + assert statuses == [422, 422, 429] # --- Per-request cloud LLM consent (issue #63) --- @@ -262,4 +270,4 @@ def test_rate_limit_trusted_proxy_missing_header_falls_back_to_socket_ip(monkeyp def test_analyze_accepts_allow_cloud_llm_field(): client = TestClient(create_app(Settings(api_key=None))) resp = client.post("/api/analyze", json={"source": "x", "allow_cloud_llm": True}) - assert resp.status_code == 501 # field is accepted by the schema + assert resp.status_code == 422 # field is accepted by the schema diff --git a/uv.lock b/uv.lock index 1869b57..54178d6 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,10 @@ revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", "python_full_version < '3.11'", ] @@ -25,6 +28,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "docstring-parser" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/8b/4210dd090000ba35d07cee9105530794911d955788c3992b4882df49eaac/anthropic-1.7.0.tar.gz", hash = "sha256:0ab1b04668606ba1ae93f6d9e8dcc2e0c4f0debebd4eea4773a3a595f0836db1", size = 1181035, upload-time = "2026-09-18T16:13:05.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/dc/74995efda028421917f4caabd24db1373ee0742573a2363fff1a21191441/anthropic-1.7.0-py3-none-any.whl", hash = "sha256:6b681b6ee00f232bb54f50f9a0d6d30e7be368c1b6f754bfd470c8385b8b6058", size = 1255606, upload-time = "2026-09-18T16:13:03.725Z" }, +] + [[package]] name = "anyio" version = "4.14.1" @@ -114,6 +135,116 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -352,6 +483,63 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + [[package]] name = "cuda-bindings" version = "13.3.1" @@ -431,6 +619,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -477,6 +683,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] +[[package]] +name = "google-auth" +version = "2.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/ca/f398a483ce5aad18ca2f735646e45ccee2439bd94a41a4ad0cfa646bd495/google_auth-2.58.0.tar.gz", hash = "sha256:55e30cf15e737de92c5323d78cda8a83fcd57e7ffbaf900c4600039fd60a80fd", size = 380018, upload-time = "2026-09-09T20:49:38.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/13/477d90d09591b3938b45c4e11f4d8a51291682112cb5efcac961e815d562/google_auth-2.58.0-py3-none-any.whl", hash = "sha256:8a9c4645bb4c8e91668fb1934b95ae6a8687084232753639220ba9bf04a1610d", size = 262404, upload-time = "2026-09-09T20:49:33.951Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/26/176d04f442e82d60cadc74cfaf81f01017062ce274a790455a57aec48c8f/google_genai-2.24.0.tar.gz", hash = "sha256:814b63d05dca4776c5a322699eb30c565040cf8d5fd77c38714076535fb2e019", size = 705409, upload-time = "2026-09-16T22:38:37.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/5b/87913fe392ba7b6402577259289ed3740c577956554a674b191a2971d33e/google_genai-2.24.0-py3-none-any.whl", hash = "sha256:59e5f2d88bc2d6ed04aa1aa14cb5629090cfe9458401745e620b24d1fe9893d7", size = 1142675, upload-time = "2026-09-16T22:38:35.332Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -499,6 +744,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.15' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.15' or sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -564,6 +822,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -603,6 +887,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jiter" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1f/8176d92e001f86505424b41664032ae26a882bc9ca41a32c803f373f9195/jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12", size = 229037, upload-time = "2026-09-12T15:14:14.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/fb/0b68a8666a203ab349334e502285153ac97e84a1a3e2f80de8385ed0899c/jiter-0.17.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ed1a24005daac667d577402d75a2922f9775a165b146b883ff1ad3602d8be689", size = 290968, upload-time = "2026-09-12T15:11:10.44Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/5993079500530586655c6f2823e2926b3f5ceddd49918c5a86757eadba3a/jiter-0.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b847b18d066c46b3b7ae49d6c94a7634c5e4a8983146ee25562a092000f5e3ad", size = 324072, upload-time = "2026-09-12T15:11:12.03Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/6f5dbffaa5b0e647386e3c3376aa150987538f1b13f6da1a9582787957af/jiter-0.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b68d3495d95da120651a5628c7ebadee84ed001a1b76e6afc325c42482f15b5", size = 344891, upload-time = "2026-09-12T15:11:13.387Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9d/8dd5719add6c77be95b23c129682a1484c5339ffd66736d66bfa6831a2f3/jiter-0.17.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c1a5336c04a41b1f1cf9572e294aec27cc569767ff73de7bf87a91f0bea7cb9", size = 328213, upload-time = "2026-09-12T15:11:14.655Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e5/82f885863afaa79efac066efdb7b70bd242ef83d3e548453d61b3737e54a/jiter-0.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b75f85660108965a94be77911a25a253429307294d9415b3c597118977a614de", size = 343033, upload-time = "2026-09-12T15:11:16.046Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/ee3cd7db1704ec9c274ae549803ef1369c801490f7f6d48d0aa3b9ac1ae0/jiter-0.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32aaaa764604496610a3ad2d98503ae88ccb2fbe769e892ff4533e778e85f708", size = 351701, upload-time = "2026-09-12T15:11:19.188Z" }, + { url = "https://files.pythonhosted.org/packages/93/2c/af9ca503b38683a82b6732352a861515f28e2d46fe445aea57e07fc86e06/jiter-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:826871c42cebaae22f0a2b5673a4a1a75c851bb2d13b3c17764a630a6b298984", size = 348754, upload-time = "2026-09-12T15:11:20.486Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ba/692b071b194270b019b7d1790adb8658193379b951029e5ad15a14a2809d/jiter-0.17.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:00b5a98df3e3a3e8cf7b619f4ac2f8bf975bbf3d95d02c5d17b8dbfe5c8b8245", size = 329263, upload-time = "2026-09-12T15:11:21.701Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8f/35a22beb6b9eb025b7e2f88a38ca3b9c87017aac237feec2583eabe243ad/jiter-0.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6af5b74073bd25bae695e6d00919f6a9be7ed5a9f8836d981eb1ffe84139e6fb", size = 337572, upload-time = "2026-09-12T15:11:23.145Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0f/4341ceb3ce08199b4e8a798acc0bb0c79b3e861b6cecdb9d3b5138965c5d/jiter-0.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:16dd0c1baf098ae70b8f3616574eb3fedf34e26670b89e16a7e67561f737ed2d", size = 485174, upload-time = "2026-09-12T15:11:24.394Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6d/92fa138ac82db9001b68563df204c32f2203bee84301df083260039ca23b/jiter-0.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:545c36a0f3b2238c242cc9785439d3242a871b7bc39fe3f441bcaa07bf3aa83e", size = 522115, upload-time = "2026-09-12T15:11:25.645Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/9b11557ddf898e84c253d1c42cc1bddd8ad1a754b46b01ae35a6a253526f/jiter-0.17.0-cp310-cp310-win32.whl", hash = "sha256:155be7355bdb7ca76ab0961be8982c225f964a5c073a83984183f22391cc29fc", size = 188163, upload-time = "2026-09-12T15:11:26.793Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f3/b4381cc962fed7d505913d5920e9f51f1fd5cf340a563134e8cadf3604d2/jiter-0.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:37150a9e02e869475854fa20b7d0d5e26d18d0f8bc17293999973ff27e99ae7a", size = 231679, upload-time = "2026-09-12T15:11:28.911Z" }, + { url = "https://files.pythonhosted.org/packages/a1/50/17afdaffcc8af4bf4fddf2b6c26d066553aa2221983f2affcde435fc2532/jiter-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cfafd7be8b16ceadd298db542cead37cddc211c4c49e04ad2596924df18625b1", size = 290699, upload-time = "2026-09-12T15:11:30.085Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c185d32d5b3657ad84da26c84a9eb15f00aa1b39d6882fcc0052dba2d7c2/jiter-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8adca2e793288e5f1bb29279bb439d0d3cfbb50eddca7e7e6ffd42ff4f482406", size = 324246, upload-time = "2026-09-12T15:11:31.3Z" }, + { url = "https://files.pythonhosted.org/packages/24/7a/8b8903bfe91a90a8fa1ec9b45d9fda5b6287a386693b69d720a882d73f3c/jiter-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c692d567ba206c7cca38c9d1d0ccc70c9786290173c184d871ca12e9981ed7", size = 343275, upload-time = "2026-09-12T15:11:32.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5d/6821fae2abc71a3c3a84bef8598d31fc4f27d9edfb55bd8f6c08afb8ef93/jiter-0.17.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81c83c0abe614446a283d994d2c07c4f58632dea2cdf66ba9e2921bb8ccd593e", size = 328034, upload-time = "2026-09-12T15:11:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/8e7a963b1c2dfdc01d6228b004f50a2a3d7c46f0549d7b096a5d15ef81d5/jiter-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:073dc68c1a700c8fc480e877864a6b6ffc887533e261f4380c08c16bf09d057a", size = 342457, upload-time = "2026-09-12T15:11:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/8b2a267e3bda45d9298331cacfe3521e761f0a2b05ad10a23c6548d08358/jiter-0.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:492f37230bbf9581ab2c17bcda862c249afb9ae2e3ab2dd6db59943bc4cc3153", size = 351271, upload-time = "2026-09-12T15:11:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/8f/5c74e5e142a6736833d7a991ab04d5c0738038dc44db05af0bb3cd2559e8/jiter-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5888fe5abc1ca2fa834a3e1b4c7ef0dcece286a7d7e95a609ef0934b777b9fc9", size = 346052, upload-time = "2026-09-12T15:11:39.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/9c/f54920f06d1696e80b1be841d56412871c6856b1b1e3b541b1ed35346554/jiter-0.17.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:84ac78df457e1ee3f7e733bd114823302ae8c5ad5542d7e6647d92ffaa090a04", size = 328915, upload-time = "2026-09-12T15:11:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/21/53/080f126863bceb055db9f1fd5431485eb493b35545a3c35e961ac18cd924/jiter-0.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7573e80232c5bcf80c24c038cf7e53a463f5c3b1dd1dd4109d66304f4dccc233", size = 337240, upload-time = "2026-09-12T15:11:42.356Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/3ab742823a0e0e70e143c6c90a482d9d90396ac2445e7b9483eb7245d3b5/jiter-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:11902505d401691720f5785c15b02204248526edee11b635cd6c40cd52b81599", size = 484917, upload-time = "2026-09-12T15:11:43.549Z" }, + { url = "https://files.pythonhosted.org/packages/14/e0/8ca71bc8b9cc9ed96c9da565863e00f3bb875a8fb82abcf03e975e067902/jiter-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:64846211a2debe7c071d2146d2283d2b0c1c93dc8fd5fb7794faac2ca6061b5c", size = 521868, upload-time = "2026-09-12T15:11:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/c1/02/81f8719dcedb75713082a2048405376c81f6546b75af8167f3bba01a1ed0/jiter-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c19b9357309b8cc6de8a48fca8e44a8c9c2feaaa2f5896d037fa505d48fcab80", size = 187844, upload-time = "2026-09-12T15:11:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/59693f348488f01ed12d862e99ab8da14961152d3e9c39b9b1ef363f3572/jiter-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:e654b6b04e39c9cb19cb8b04c6ddf1f2db07751fa14156413969fd78bad0e5cb", size = 231402, upload-time = "2026-09-12T15:11:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/fe/89/fb35e286463cb9f01edc2c4e47df6e5477ee36bca5096414e9ea87985588/jiter-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:3ad556afc289f15d2b181b941982d01f06190863c07440185b9f354e1bd2def3", size = 187006, upload-time = "2026-09-12T15:11:48.245Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f8/07bd8c3a23f7a8a6875e6a820bbffe1483a18f18f9398a91b5495123176e/jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91", size = 291633, upload-time = "2026-09-12T15:11:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5e/0de4c6f84ffefa6809ffc2d550b9a314365acf7e7ec9b6c7375d49047900/jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4", size = 321695, upload-time = "2026-09-12T15:11:52.727Z" }, + { url = "https://files.pythonhosted.org/packages/20/ac/befe2e82065bee37a0252081666ed2f48c1ac5f5c6c318c2de8168ba393d/jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1", size = 341967, upload-time = "2026-09-12T15:11:54.231Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/9797c1e529746750ae589da7c1a8c24373f00d88e11a989f9e5eb1959079/jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8", size = 326546, upload-time = "2026-09-12T15:11:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/e6914c38d6347bab4ebff2b1f0c0f191db276e7a1d5c376176757da42fe3/jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63", size = 340995, upload-time = "2026-09-12T15:11:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/611b3abf6f88945b5474da5cdc6d1a185e805ac9bf446bb7766dcda6ea87/jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a", size = 352188, upload-time = "2026-09-12T15:11:59.414Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b6e513ecbdf3b3cebe587c2279281ecf775b729a58cf4cc7bdf898ded029/jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797", size = 345025, upload-time = "2026-09-12T15:12:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/28/a8/fe26d06c5a6c5a4cfe703c5154c8a140da1305671eb3681aba9422d4f393/jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812", size = 329180, upload-time = "2026-09-12T15:12:01.831Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/e6d66a26af40a20e62486feb7e222fd50f6e7aaa4f107abd89675dcc835b/jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a", size = 335805, upload-time = "2026-09-12T15:12:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/96520aa2fef5ef831d95483a902140bfab83dcac9eaa74f7df61b5e50a1b/jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44", size = 484121, upload-time = "2026-09-12T15:12:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8f/5d9d92fe538bf36ff481a2278c48147e59c1cf8eb2f7be665260665febe5/jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31", size = 521310, upload-time = "2026-09-12T15:12:05.612Z" }, + { url = "https://files.pythonhosted.org/packages/50/06/a09f979b22e652afbc3de66c709b2ba92edcef555f7535ab937c86b4f21a/jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd", size = 185029, upload-time = "2026-09-12T15:12:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/98265a005b2473ec2be5a84e2b64c2f65382c673879f1574845cd4bcd77c/jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3", size = 227381, upload-time = "2026-09-12T15:12:08.823Z" }, + { url = "https://files.pythonhosted.org/packages/a8/11/2e05bf5a56e57a543ebb8f585074adf09383e99d7b062dac92eab1f4d57f/jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8", size = 183610, upload-time = "2026-09-12T15:12:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/2c4a8075ed5ea02b56911e9375d4c8d7784572ff4af32e5a99ae0d071044/jiter-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa", size = 290991, upload-time = "2026-09-12T15:12:11.641Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b1/34bfa29599d420423baac6ff7cada6674fe63d5a7a2ccb3900b904678783/jiter-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3", size = 321425, upload-time = "2026-09-12T15:12:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/11/71/a5ac64a62a04aebd556afadab14a6b730001e16df87266ded943a100a1d9/jiter-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b", size = 343138, upload-time = "2026-09-12T15:12:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/f761e320ea473314cb68612bc6a435393464dbd198051399b36848b4ebf3/jiter-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b", size = 325805, upload-time = "2026-09-12T15:12:16.506Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/27d8e40f0fb29bbc7a5adf30907144396a115dbe93d5d8976c054a6dfe96/jiter-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a", size = 340230, upload-time = "2026-09-12T15:12:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/30bcde78a28155461f965d16b7aca4ffca6d17494d905f7a0bb072e6c64e/jiter-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807", size = 351343, upload-time = "2026-09-12T15:12:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/91420b156315ae22732f5ee1a7b5725a030aab9dc8fd7dcdacfb4aa588d3/jiter-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5", size = 344990, upload-time = "2026-09-12T15:12:20.705Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a2/ae6d5672644cc11127970277c9aeb0fa6fae376845587f5b0a8e8828167c/jiter-0.17.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca", size = 328624, upload-time = "2026-09-12T15:12:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/45cb1162f6aa586536e4a973fc339d72dc6b08cca030d70a838a307aa778/jiter-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8", size = 334731, upload-time = "2026-09-12T15:12:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/45c1574b644da7deda0b7591c349520dcf83ce45b24d7ca19922dab1fc27/jiter-0.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9", size = 483649, upload-time = "2026-09-12T15:12:27.557Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d3/ebea1ecb5b241c519f192b30215c79a8e47f42f1621acbcd6f8830728416/jiter-0.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8", size = 520758, upload-time = "2026-09-12T15:12:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/682b641ff0765ea9bdc349dbc7d223de5c8af8ec1abda0db3406992f92fe/jiter-0.17.0-cp313-cp313-win32.whl", hash = "sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7", size = 184334, upload-time = "2026-09-12T15:12:30.813Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d2/9a49aac2b27af4cc5015e368c0cc3588491a532f717a668ffce1f1ac57da/jiter-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd", size = 226601, upload-time = "2026-09-12T15:12:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ce/9a43e9f614608eafa78de22aedcff54cd21324467b5d442d5c9b00244145/jiter-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac", size = 183103, upload-time = "2026-09-12T15:12:34.396Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/23065f8e2c7a4c372c1b6f6622e4cfab4dc786cb5150052b1527e6a6a840/jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5", size = 292210, upload-time = "2026-09-12T15:12:35.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/81/67b58647560bc82a4490d722caa8561d7a86a9f45d4fa620b7e5fe282c7a/jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76", size = 321512, upload-time = "2026-09-12T15:12:36.907Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/6658359a25f55927f7f8bf0e16465dee2ccd0b2a1a5208acc0df8972e074/jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64", size = 343897, upload-time = "2026-09-12T15:12:38.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/04/5d50a9f0319cbdc37fd53c27f8c313d46afc34f1b048219ae6d8ea068da4/jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199", size = 326519, upload-time = "2026-09-12T15:12:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c7/d02517832b29eb8275fdd0f4ce0f17b80f58cc4c3ebecd4d9ace990d633d/jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71", size = 341369, upload-time = "2026-09-12T15:12:41.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/07/499b5f5603501cdd93a73a6a176dfad9c96555a3ae58ca9f8e3acba63dc9/jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef", size = 352160, upload-time = "2026-09-12T15:12:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/f5/75/b04013c7743269d4533ef4e746fc0ed678a143968dd7448658e3f51daad2/jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb", size = 345018, upload-time = "2026-09-12T15:12:44.192Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/cbb6fd1e42a77c8412ec4643db95059b30cdfc635e387cc9193e098ce268/jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b", size = 329244, upload-time = "2026-09-12T15:12:45.491Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/d3be402f398566a379bf40ae65be5c3505b14d9e95e0802a597ddde7ddee/jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b", size = 335693, upload-time = "2026-09-12T15:12:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/98e2c4130b93d64f1d67c89060b928d04102549bf05e64451c9e6024f9ca/jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9", size = 484329, upload-time = "2026-09-12T15:12:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/78/5e/8da91e49f0fbca37c3489fb4cf3ad6676d4965f00ae5468bca3a2513737a/jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f", size = 521358, upload-time = "2026-09-12T15:12:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/5388684a5a38af3557cd9c2424b9827c71809cff24373c75ef9d0d3dfba9/jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03", size = 110459, upload-time = "2026-09-12T15:12:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/58b3a93525d2ffca7f54d9dee441381990082bd1172fbeb8d6a3f72a4dc3/jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8", size = 185043, upload-time = "2026-09-12T15:12:54.477Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/1aa520eb6c359b262c14ff995ca7283837208ddfb1202082ce9d73cf214d/jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312", size = 227163, upload-time = "2026-09-12T15:12:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/5997f648794bd9b499491d0ff480b096cc9a9c65bdba29f57568e6aa1705/jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744", size = 183505, upload-time = "2026-09-12T15:12:58.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/84a5ec271d09f7590b6073af5ee4abb44eab4ccace453b7e2c5ce45234ca/jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0", size = 321527, upload-time = "2026-09-12T15:12:59.394Z" }, + { url = "https://files.pythonhosted.org/packages/39/71/9e1fd0045f5920b4c36be35c3f0f0dfd123668684f8ad352619d7aa44183/jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388", size = 340865, upload-time = "2026-09-12T15:13:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/14627fd2bc377f3dd09491bcace6b90e34b4d7fea2f1f3295031ff91f528/jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489", size = 325412, upload-time = "2026-09-12T15:13:02.152Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/8d5808f7bf0f456bde79e6393587183a0cee5f83d179fe1f7f1eff2ba067/jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165", size = 340473, upload-time = "2026-09-12T15:13:03.485Z" }, + { url = "https://files.pythonhosted.org/packages/4f/da/1d8c7c6c4ae6b2423b94a81b6b907d37b28f87664e077427b531bf1b5313/jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9", size = 350757, upload-time = "2026-09-12T15:13:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/eb/96/c1813dcca15c5a370145a448aaea7d1f83f6f0228a5f1130e79340ee385f/jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478", size = 345203, upload-time = "2026-09-12T15:13:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/fc61cbcf2992d169ede13648fc3fd8e2d3171a3669dde43cd4db556549ac/jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768", size = 328322, upload-time = "2026-09-12T15:13:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/8f/88/46418a3abbdffb7dc41b314200360f24f75faaeb35573e81c92de322cce9/jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718", size = 336570, upload-time = "2026-09-12T15:13:08.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/28/b8a55b949be6306df8888e365a8df05441de8a7b11289f6957004302e41e/jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59", size = 482879, upload-time = "2026-09-12T15:13:10.037Z" }, + { url = "https://files.pythonhosted.org/packages/75/3b/21d0afa53ba0680962c39f3eb95ed2946f8793369ed44b0c82b490723081/jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1", size = 520406, upload-time = "2026-09-12T15:13:11.456Z" }, + { url = "https://files.pythonhosted.org/packages/ef/03/bcbaf8b6b9ea23c2c074411f8ecfbb02d820abac5d0cb8f4e280209174a2/jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06", size = 184434, upload-time = "2026-09-12T15:13:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b5/5d6ce2c93ef6fe1241b37a9005547f9b6d58db1f07f39fe95807d4b98f51/jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9", size = 227392, upload-time = "2026-09-12T15:13:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4b/1e52baf90187606e33a7b8cfa8f96f5829acd7f01870077eb01059ab76d0/jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc", size = 182776, upload-time = "2026-09-12T15:13:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/05/fc/efe3ac75564ab10f53517958f5ccdc231fc7334af66c76776cb554a88967/jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925", size = 292143, upload-time = "2026-09-12T15:13:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/46982118d91f9ffe9714319d21ec4f98d9b7e0cfd9062826c524a54de24e/jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656", size = 321341, upload-time = "2026-09-12T15:13:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/12/9b1ac6ecc6307049913db54839ddba1c11c1ef72c5a8bbb5514bc3b50d1b/jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1", size = 344383, upload-time = "2026-09-12T15:13:20.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/527cc72af836d824e9d4d666e64f0a1ca7eafd662a8da9657b78592172ba/jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0", size = 326841, upload-time = "2026-09-12T15:13:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/d1/41/567f98617e88005b249503b933803f633ec6ba2d427cf4cc35e5c832125c/jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca", size = 341354, upload-time = "2026-09-12T15:13:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/40/da/b29cda895b785f7d426e224638a885b6145a08ce853b381f34afe3e88c5d/jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9", size = 351985, upload-time = "2026-09-12T15:13:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/8a73829e7389e72ea298a450f2b3cb58e71a3e464b45f6d8753740f1c4f5/jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826", size = 346052, upload-time = "2026-09-12T15:13:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/98d6001026932c095ba440925570123043bed29f5ff56158dfe729a9e81b/jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87", size = 329159, upload-time = "2026-09-12T15:13:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/708dc1d2678f092c31c12754e860cd8353e6a85ecbdb1010157edca0da9e/jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2", size = 336001, upload-time = "2026-09-12T15:13:32.846Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/75a5ae38862f4eaf0fe2f8a9fbf6484c4890df04c06dcdffc45e36bca61a/jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575", size = 484281, upload-time = "2026-09-12T15:13:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/6636fae811c27c7f93e1b11fb5800de6a5c9e4269a27cf718e0b31218ad1/jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688", size = 521300, upload-time = "2026-09-12T15:13:37.101Z" }, + { url = "https://files.pythonhosted.org/packages/61/aa/12df7e0b0b1a2602e3d5a5a7104d7d9700f254b400f134a9b50955c4d231/jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec", size = 185138, upload-time = "2026-09-12T15:13:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ec/3dd2e495032cddde05723c1f4c743b67a23e55d2af244692a7f58f0cdae3/jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1", size = 226950, upload-time = "2026-09-12T15:13:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c7/ef85704e0a57e9cadb2babc05f6d7c5df4a1c75da1a6ee31e1986b0099a5/jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1", size = 183618, upload-time = "2026-09-12T15:13:41.432Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/a4b348349de68762b58d6713973d363ad80a1c741d0bf8def7975f0ecb26/jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20", size = 321155, upload-time = "2026-09-12T15:13:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/aebd6d0b5f0677de3a3d0bdc4a05fac949b97c4ede454c8809f180ac7b17/jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab", size = 340985, upload-time = "2026-09-12T15:13:44.115Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/4c3b49796b5eb62f3f5046f957683f4ba0135fe1a60957c11180512460df/jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2", size = 325670, upload-time = "2026-09-12T15:13:45.901Z" }, + { url = "https://files.pythonhosted.org/packages/bc/43/f6341ecb4872202a4ef150486fcee0e1ace4aa3da39b71b82061452cdd3a/jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a", size = 340339, upload-time = "2026-09-12T15:13:47.442Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c4/bc2c86e08fa065e03cb2fbc53b367c3640a7d257ef9d877b29118ea636b7/jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675", size = 350705, upload-time = "2026-09-12T15:13:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/9d/67/91f12aa111cca6e3a197c3e36bf60a034bf9f122f6d41112a639e44217d8/jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574", size = 345011, upload-time = "2026-09-12T15:13:50.215Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/9f5556e8f6ec89755fb5a709d8eb8270c9a324e31079eda0dfbeca451b6e/jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b", size = 328268, upload-time = "2026-09-12T15:13:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/153f20680fb75781a490fb849940e2b00f95035c7aa054df592f36ed33fc/jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d", size = 337024, upload-time = "2026-09-12T15:13:53.095Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b16c9be3a5035df4466cc72e888188c027562de90a723d290ab6814cb9d4/jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb", size = 482766, upload-time = "2026-09-12T15:13:55.713Z" }, + { url = "https://files.pythonhosted.org/packages/d0/55/667dea313094024bef082175d6bfe8976f90d1c00c926af9df1d8e0eab48/jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a", size = 520367, upload-time = "2026-09-12T15:13:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/21/e3/4b1a43501fb9ed17b01d137e380cb0e8fdcb39a254ce31aa2ab95bc861ac/jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d", size = 184603, upload-time = "2026-09-12T15:13:59.27Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f2/b8ee0372b6ebdf1bde5cc44495d5291d17f961065f5b48f8616cc67cac2e/jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174", size = 227936, upload-time = "2026-09-12T15:14:00.472Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/923a1215daba959aed8355973315cb3f81f53e0d01c5b211870a27b41f45/jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23", size = 182977, upload-time = "2026-09-12T15:14:01.799Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3b/05a917204413e2e09906dfa35240c1021227aeb56c7305abea9562c598b4/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:eaba834b72d573547b9d966465b3394b749d5e14208cc70acb63aca37619ab33", size = 288143, upload-time = "2026-09-12T15:14:02.998Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/a1cd3c0cf8f79945939e4f8caae9990529f67d7f77f671769f73956329b9/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:51e1519d676a9f14dad9c2a411170d43b022ddb7989562df4e849b261ce127b2", size = 281847, upload-time = "2026-09-12T15:14:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/72/b4/9b797679e09f4a46c32986aeb3670bd9bc562fc0b373c7a0ee5c5dce1206/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce4feb52493e3513335b2accdcd75605652e4632772d3c8c2f7b86954d7f39", size = 304797, upload-time = "2026-09-12T15:14:05.733Z" }, + { url = "https://files.pythonhosted.org/packages/25/4a/0d77415b27a00d970e4e710f7c1de62e96a11c4cab3ed1add0015af04626/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29f49b325e0234e4ad9ecca5b861ffbd09b95ccac9bd46fa55841b6e56eea5fe", size = 307815, upload-time = "2026-09-12T15:14:07.105Z" }, + { url = "https://files.pythonhosted.org/packages/17/31/4bb27f54333d3b9ef1e5bd3312dc0b4bbe59c68bb0885fdb40583a6b1567/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c", size = 288415, upload-time = "2026-09-12T15:14:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/879570ecf82574eaea77c5eb10309f4b630dece5f2a556e9814a90ba3f2d/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2", size = 279113, upload-time = "2026-09-12T15:14:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/77/7a/1f0b8a35fbd079a4f1752c31a15dc99cf277f863747c459be0af39e900e5/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5", size = 303708, upload-time = "2026-09-12T15:14:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" }, +] + [[package]] name = "librt" version = "0.12.0" @@ -810,6 +1220,7 @@ dependencies = [ { name = "pycryptodome" }, { name = "pydantic" }, { name = "pydicom" }, + { name = "python-multipart" }, { name = "pyyaml" }, { name = "reportlab" }, { name = "rich" }, @@ -818,6 +1229,14 @@ dependencies = [ ] [package.optional-dependencies] +claude = [ + { name = "anthropic" }, +] +cloud = [ + { name = "anthropic" }, + { name = "google-genai" }, + { name = "openai" }, +] dev = [ { name = "bandit" }, { name = "mypy" }, @@ -828,27 +1247,44 @@ dev = [ { name = "ruff" }, { name = "types-pyyaml" }, ] +gemini = [ + { name = "google-genai" }, +] local-models = [ { name = "torch" }, { name = "torchvision" }, ] +openai = [ + { name = "openai" }, +] +privacy = [ + { name = "pytesseract" }, +] [package.metadata] requires-dist = [ + { name = "anthropic", marker = "extra == 'claude'", specifier = ">=0.49.0" }, + { name = "anthropic", marker = "extra == 'cloud'", specifier = ">=0.49.0" }, { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.8.0" }, { name = "fastapi", specifier = ">=0.115.0" }, + { name = "google-genai", marker = "extra == 'cloud'", specifier = ">=1.30.0" }, + { name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.30.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, { name = "numpy", specifier = ">=1.26.0,<2.5" }, + { name = "openai", marker = "extra == 'cloud'", specifier = ">=1.66.0" }, + { name = "openai", marker = "extra == 'openai'", specifier = ">=1.66.0" }, { name = "pillow", specifier = ">=10.0.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pycryptodome", specifier = ">=3.20.0" }, { name = "pydantic", specifier = ">=2.10.0" }, { name = "pydicom", specifier = ">=3.0.0" }, + { name = "pytesseract", marker = "extra == 'privacy'", specifier = ">=0.3.13" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "reportlab", specifier = ">=4.0" }, { name = "rich", specifier = ">=13.0.0" }, @@ -859,7 +1295,7 @@ requires-dist = [ { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, ] -provides-extras = ["local-models", "dev"] +provides-extras = ["claude", "openai", "gemini", "cloud", "privacy", "local-models", "dev"] [[package]] name = "mpmath" @@ -956,7 +1392,10 @@ version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", ] sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ @@ -1043,7 +1482,10 @@ version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", ] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ @@ -1272,6 +1714,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, ] +[[package]] +name = "openai" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/9d/a00f8bca3df57716771cca5b4c0ddb5c2e03e1dd2ddfbb1607371ffb8372/openai-3.17.0.tar.gz", hash = "sha256:28910914e6ffaf622f1bbf5dfe02e221cc2b929e492dd30d06b6826939dedf92", size = 1710984, upload-time = "2026-09-22T02:37:17.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/bc/5b80a0ca4069a6b5ba6c7d48ae7a4f8176b2c0bde0bebccdb867472ec6f6/openai-3.17.0-py3-none-any.whl", hash = "sha256:712bc2982a6da1af10f6948a1d2d9ff5ce3ee3604ded88002a53765a77cf5ae5", size = 2068574, upload-time = "2026-09-22T02:37:15.373Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -1418,6 +1877,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pycryptodome" version = "3.23.0" @@ -1602,6 +2091,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pytesseract" +version = "0.3.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a6/7d679b83c285974a7cb94d739b461fa7e7a9b17a3abfd7bf6cbc5c2394b0/pytesseract-0.3.13.tar.gz", hash = "sha256:4bf5f880c99406f52a3cfc2633e42d9dc67615e69d8a509d74867d3baddb5db9", size = 17689, upload-time = "2024-08-16T02:33:56.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/33/8312d7ce74670c9d39a532b2c246a853861120486be9443eebf048043637/pytesseract-0.3.13-py3-none-any.whl", hash = "sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34", size = 14705, upload-time = "2024-08-16T02:36:10.09Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -1670,6 +2172,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1747,6 +2258,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/07/70085c17a369605f15e301d10ab902115019b1126c7253d964afc230c7d6/reportlab-5.0.0-py3-none-any.whl", hash = "sha256:9d5a3affa84919e1111ede580031266a570e93b1ce388219621347965ff1d93c", size = 1956710, upload-time = "2026-06-18T11:34:29.07Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1803,6 +2329,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -1834,7 +2369,10 @@ version = "5.9.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", ] sdist = { url = "https://files.pythonhosted.org/packages/d7/dd/04d56c2a5232358df41f3d0f0e31833d378b6c8ed7803a6b1b7867b0eba6/stevedore-5.9.0.tar.gz", hash = "sha256:abbd0af7a38a8bbb1d6adea2e35b17609cf004eaac323e88a8d8963640dd2b3c", size = 514850, upload-time = "2026-07-02T11:38:08.509Z" } wheels = [ @@ -1853,6 +2391,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -2012,6 +2559,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.26.8" @@ -2057,6 +2613,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + [[package]] name = "uvicorn" version = "0.49.0"