diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..485b1d9aa056ec9561c26ab26c48d8a5039adc87 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +.agents/ +.claude/ +.codex/ +.git/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.venv/ +.venv_labeling/ +__pycache__/ +*.py[cod] +*.egg-info/ +.env +.env.* +**/node_modules +**/dist +**/coverage +**/playwright-report +**/test-results +**/*.db +artifacts/ +data/labeling/ +models/ +tmp/ +*.wav +*.mp3 +*.m4a +*.aac +*.flac +*.ogg +*.oga +*.opus +*.webm diff --git a/.env.ckey.example b/.env.ckey.example new file mode 100644 index 0000000000000000000000000000000000000000..ad8bfcfc4199b5db13670805e96256ec0652088d --- /dev/null +++ b/.env.ckey.example @@ -0,0 +1,35 @@ +# CarePath CKey runtime +APP_ENV=local +ASR_PROVIDER=gipformer +ALLOW_MOCK_ASR=false + +# CKey uses the OpenAI-compatible chat/completions route. +# Get the key from the CKey AI API Console and keep .env out of source control. +LLM_PROVIDER=ckey +LLM_BASE_URL=https://api.xah.io/v1 +LLM_MODEL=gpt-5.4 +LLM_API_KEY=sk-YOUR_API_KEY +LLM_TIMEOUT_SECONDS=120 +# Demo safety net: if CKey fails/times out, serve the deterministic offline +# generator instead of returning an error. Set to false to fail hard. +LLM_FALLBACK_OFFLINE=true + +# Retrieval +MEDICAL_LEXICON_PATH=data/medical_lexicon.json +RETRIEVAL_TOP_K=5 + +# Gipformer ASR +GIPFORMER_QUANTIZE=int8 +GIPFORMER_NUM_THREADS=4 +GIPFORMER_DECODING_METHOD=modified_beam_search +GIPFORMER_CHUNK_SECONDS=20 + +# Abuse guard +TEAM_CODE= +SOAP_RATE_LIMIT_PER_IP_HOUR=3 +SOAP_RATE_LIMIT_PER_IP_DAY=10 +SOAP_RATE_LIMIT_GLOBAL_DAY=100 + +# --- Interpreter module (mock until its cloud track lands) --- +PROVIDER_MODE=mock +DATABASE_URL=sqlite:///./carepath.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..7eadc4c8c6d93a8e772c02f16a0cf6e412331ce8 --- /dev/null +++ b/.env.example @@ -0,0 +1,75 @@ +# CarePath unified runtime — one FastAPI service, two products. +# Defaults below are the keyless demo profile: the whole app must boot with no +# API keys (mock interpreter providers, mock ASR + offline LLM for the scriber). +# Ready-made profiles: .env.local.example (keyless demo), .env.ckey.example +# (real Gipformer ASR + CKey LLM). + +# --- Scriber (scribe/carepath, routes /api/v1/*) --- +APP_ENV=local +ASR_PROVIDER=gipformer +GIPFORMER_QUANTIZE=int8 +GIPFORMER_NUM_THREADS=4 +GIPFORMER_DECODING_METHOD=modified_beam_search +GIPFORMER_CHUNK_SECONDS=20 +# Long-audio segmentation: overlap (default) | vad | fixed. +# overlap = overlapping windows merged with seam de-duplication (no word loss +# at chunk boundaries; no extra model). +# vad = split on silence; requires GIPFORMER_VAD_MODEL (repo_id:filename or +# a local silero_vad.onnx path). Falls back to overlap if unavailable. +# fixed = legacy non-overlapping 20s windows. +GIPFORMER_SEGMENTATION=overlap +GIPFORMER_OVERLAP_SECONDS=2 +GIPFORMER_MAX_SEGMENT_SECONDS=20 +GIPFORMER_VAD_MODEL= + +# Set LLM_PROVIDER=offline for a deterministic no-key fallback. +# Set LLM_PROVIDER=ckey for CKey OpenAI-compatible chat completions. +LLM_PROVIDER=offline +LLM_BASE_URL=https://api.openai.com/v1 +LLM_MODEL=gpt-4.1-mini +LLM_API_KEY= +LLM_TIMEOUT_SECONDS=60 +# If a network LLM provider fails, fall back to the offline generator so a +# request never errors out (recommended for live demos). +LLM_FALLBACK_OFFLINE=true + +# Retrieval +MEDICAL_LEXICON_PATH=data/medical_lexicon.json +RETRIEVAL_TOP_K=5 +# Backend: lexical (default) | semantic | hybrid. semantic/hybrid use the +# Vietnamese bi-encoder and need the optional sentence-transformers + pyvi deps. +RETRIEVAL_BACKEND=lexical +SEMANTIC_MODEL_NAME=bkai-foundation-models/vietnamese-bi-encoder + +# Safety/demo +ALLOW_MOCK_ASR=false +TEAM_CODE= +SOAP_RATE_LIMIT_PER_IP_HOUR=3 +SOAP_RATE_LIMIT_PER_IP_DAY=10 +SOAP_RATE_LIMIT_GLOBAL_DAY=100 +# Cross-origin frontends for both APIs. Clear this value for same-origin-only +# production; list every Vite development origin when developing against either API. +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Public Vercel site used by the Interpreter's “Tất cả chức năng” link when +# its frontend is built into the Space image. Set as a Docker build arg. +VITE_PUBLIC_SITE_URL=https://carepath-omega.vercel.app + +# --- Interpreter (interpreter/app, routes /api/* + /ws/*) --- +# PROVIDER_MODE=mock runs deterministic providers with zero keys (the demo +# profile). PROVIDER_MODE=cloud is a later track: it needs the two API keys +# below and a non-default ADMIN_TOKEN (startup refuses "change-me"). +PROVIDER_MODE=mock +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +ADMIN_TOKEN=change-me +CONFIDENCE_THRESHOLD=0.7 +RETENTION_DAYS=30 +MAX_TURN_AUDIO_BYTES=10485760 +# sqlite is fine for the demo (ephemeral on HF Spaces); point at Postgres etc. +# for anything that must survive a container restart. +DATABASE_URL=sqlite:///./carepath.db +OPENAI_TRANSCRIBE_MODEL=gpt-4o-transcribe +CLAUDE_MT_MODEL=claude-sonnet-5 +CLAUDE_REVIEWER_MODEL=claude-sonnet-5 +PROVIDER_TIMEOUT_SECONDS=30 diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000000000000000000000000000000000000..7d376691a0b194859ff916347cd069039ebc7e51 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,31 @@ +# CarePath local smoke/demo runtime +APP_ENV=local +ASR_PROVIDER=mock +ALLOW_MOCK_ASR=true + +# Offline correction/SOAP fallback for local API contract testing. +LLM_PROVIDER=offline +LLM_BASE_URL=https://api.openai.com/v1 +LLM_MODEL=gpt-4.1-mini +LLM_API_KEY= +LLM_TIMEOUT_SECONDS=60 + +# Retrieval +MEDICAL_LEXICON_PATH=data/medical_lexicon.json +RETRIEVAL_TOP_K=5 + +# Gipformer settings are ignored while ASR_PROVIDER=mock. +GIPFORMER_QUANTIZE=int8 +GIPFORMER_NUM_THREADS=4 +GIPFORMER_DECODING_METHOD=modified_beam_search +GIPFORMER_CHUNK_SECONDS=20 + +# Abuse guard +TEAM_CODE= +SOAP_RATE_LIMIT_PER_IP_HOUR=3 +SOAP_RATE_LIMIT_PER_IP_DAY=10 +SOAP_RATE_LIMIT_GLOBAL_DAY=100 + +# --- Interpreter module (mock providers, zero keys) --- +PROVIDER_MODE=mock +DATABASE_URL=sqlite:///./carepath.db diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..4c08558e9101eb1521ec44572393bb76ae66969e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Playwright snapshots are byte-compared; never convert their line endings. +site/tests/**/*-snapshots/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..1b33b8f7d1b4138b51935d011c62ec443187759b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,147 @@ +name: CI + +on: + push: + pull_request: + +jobs: + unified-api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install shared and API packages + run: python -m pip install -e ".[dev]" -e "./shared" -e "./interpreter[dev]" + - name: Scriber and combined-app tests + run: pytest + - name: Shared normalization characterization + run: python -m pytest shared/tests + - name: Regenerate term artifacts without drift + run: python scripts/build_term_artifacts.py && git diff --exit-code -- data/medical_lexicon.json interpreter/app/glossary/data/seed_glossary.csv + - name: Enforce serving/training import boundary + run: if rg -l 'from gec|import gec' scribe/carepath/; then exit 1; fi + - name: Scriber smoke test (mock ASR + offline LLM) + run: python scripts/smoke_backend.py + + training-governance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install CPU-only training proof dependencies + run: python -m pip install -e ".[dev]" -e "./shared" + - name: Test training governance and export smoke + run: python -m pytest scribe/training/tests + - name: Verify the committed frozen baseline report + run: python scribe/training/scripts/baseline_report.py + + interpreter: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install shared and interpreter + run: python -m pip install -e "./shared" -e "./interpreter[dev]" + - name: Lint interpreter + working-directory: interpreter + run: ruff check . + - name: Test interpreter + working-directory: interpreter + run: pytest + - name: Eval regression + run: python interpreter/eval/run_eval.py --set interpreter/eval/fixtures/eval_starter.tsv --providers mock + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: interpreter/frontend/package-lock.json + - name: Install frontend + working-directory: interpreter/frontend + run: npm ci + - name: Lint frontend + working-directory: interpreter/frontend + run: npm run lint + - name: Test frontend + working-directory: interpreter/frontend + run: npm test + - name: Build frontend + working-directory: interpreter/frontend + run: npm run build + + e2e: + runs-on: ubuntu-latest + needs: [interpreter, frontend] + env: + PROVIDER_MODE: mock + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: interpreter/frontend/package-lock.json + - name: Install shared and interpreter + run: python -m pip install -e "./shared" -e "./interpreter[dev]" + - name: Install frontend + working-directory: interpreter/frontend + run: npm ci + - name: Install Playwright browser + working-directory: interpreter/frontend + run: npx playwright install --with-deps chromium + - name: Run Playwright e2e + working-directory: interpreter/frontend + run: npx playwright test + + site: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: scribe/frontend/package-lock.json + - name: Install demo site + working-directory: scribe/frontend + run: npm ci + - name: Lint demo site + working-directory: scribe/frontend + run: npm run lint + - name: Test demo site + working-directory: scribe/frontend + run: npm test + - name: Test Vercel environment validation + working-directory: scribe/frontend + run: npm run test:deploy-env + - name: Build demo site + working-directory: scribe/frontend + run: npm run build + - name: Install Playwright browser + working-directory: scribe/frontend + run: npx playwright install --with-deps chromium + - name: Run demo site e2e + working-directory: scribe/frontend + run: npm run e2e + - name: Rebuild production demo site + working-directory: scribe/frontend + run: npm run build + - name: Audit demo site + working-directory: scribe/frontend + run: npm audit + - name: Run Lighthouse gates + working-directory: scribe/frontend + run: npm run lighthouse diff --git a/.github/workflows/keepalive.yml b/.github/workflows/keepalive.yml new file mode 100644 index 0000000000000000000000000000000000000000..12abfd79aa012524487500e74a20d0da1a51f83c --- /dev/null +++ b/.github/workflows/keepalive.yml @@ -0,0 +1,17 @@ +name: Keep HF Space Awake + +on: + schedule: + - cron: "0 */12 * * *" + workflow_dispatch: + +jobs: + ping: + runs-on: ubuntu-latest + steps: + - name: Ping health endpoint + env: + SPACE_URL: ${{ vars.SPACE_URL }} + run: | + test -n "$SPACE_URL" + curl -fsS "${SPACE_URL%/}/api/v1/health" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..77d7426f94649c8a3c547c0e0da73f69d9337789 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +.env +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +artifacts/ +models/ +tmp/ +*.wav +*.mp3 +*.m4a +*.aac +*.flac +*.ogg +*.oga +*.opus +*.webm + +node_modules/ +dist/ +coverage/ +*.tsbuildinfo +playwright-report/ +test-results/ +eval/reports/ +interpreter/eval/reports/ + +*.db +*.sqlite +*.sqlite3 + +# Harness durable layer +harness.db +harness.db-wal +harness.db-shm +scripts/bin/harness-cli +scripts/bin/harness-cli.exe diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..0006f4ae1911a22ec07e3895ec996cee577b3efe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,335 @@ +# Agent instructions — CarePath unified + +CarePath is one unified medical AI product with two clearly separated user-facing modules: + +1. **Ghi chép bệnh án AI** + Internal/technical term: AI Scribe / scriber + Backend: `scribe/carepath`, `/api/v1/*` + Purpose: listen to a consultation and help generate structured clinical notes. + +2. **Phiên dịch khám bệnh trực tiếp** + Internal/technical term: Medical Interpreter / interpreter + Backend: `interpreter/app`, `/api/*` + `/ws/*` + Purpose: live two-way interpretation between Vietnamese doctors and English-speaking patients. + +The app is served by one FastAPI process plus two Vite frontends: + +* `scribe/frontend/` at `/` +* `interpreter/frontend/` at `/phien-dich-y-khoa/` (`/console/` is a legacy redirect) + +## Interpreter status + +After restructure Phase 7, the Interpreter is on hold. Accept only bug fixes, +safety fixes, and required operational maintenance there; new product work is +focused on the Scribe training track unless the owner explicitly reopens it. + +`docs/history/PLAN.md` and `docs/history/DEMO-SITE-PLAN.md` are historical build +plans for the interpreter and demo site. `docs/history/MERGE-PLAN.md` is the +executed unification plan, M.0–M.8 done, and `docs/history/JUDGE.md` is its +review protocol. +`docs/research.md` holds the interpreter safety background. + +## Product UX contract + +CarePath must be Vietnamese-first. + +The target users may include Vietnamese doctors who are not comfortable with English medical software terms. Therefore, the UI must explain workflows by user intent, not by English product names. + +### Primary user-facing labels + +Use these as primary UI labels: + +* `Ghi chép bệnh án AI` +* `Phiên dịch khám bệnh trực tiếp` + +Use these as primary CTAs: + +* `Bắt đầu ghi chép` +* `Bắt đầu phiên dịch` + +Use this homepage framing: + +```text +Bạn muốn hỗ trợ việc gì hôm nay? +``` + +### Secondary/internal labels + +The following English terms may appear only as secondary helper text, developer labels, comments, docs, or internal route/component names: + +* Scribe +* AI Scribe +* Interpreter +* Medical Interpreter +* Console +* Transcript +* Encounter +* Session + +Do not use `Scribe` or `Interpreter` as primary labels on user-facing screens. + +### Required distinction between the two modules + +The landing page must make it obvious that these are two different workflows: + +#### Ghi chép bệnh án AI + +Explain as: + +```text +AI nghe buổi khám và tạo ghi chú y khoa có cấu trúc. +``` + +Use when: + +```text +Phù hợp khi bác sĩ muốn giảm thời gian nhập liệu sau khám. +``` + +Clarify that the doctor must review the output before use. + +#### Phiên dịch khám bệnh trực tiếp + +Explain as: + +```text +Dịch hai chiều giữa bác sĩ tiếng Việt và bệnh nhân tiếng Anh trong lúc khám. +``` + +Use when: + +```text +Phù hợp khi bác sĩ và bệnh nhân không cùng ngôn ngữ. +``` + +Clarify that the system translates only and must not provide medical advice. + +### Every user-facing screen must answer + +1. Tôi đang dùng chức năng nào? +2. Chức năng này giúp việc gì? +3. Tôi cần bấm gì tiếp theo? +4. Có rủi ro hoặc giới hạn nào bác sĩ cần biết không? + +### Vietnamese copy rules + +* Vietnamese text must be clear, short, and professional. +* Preserve Vietnamese diacritics. +* Keep text NFC-normalized. +* Avoid unnecessary English. +* Avoid startup/marketing buzzwords on clinical workflow screens. +* Prefer concrete action language over abstract product names. + +Good: + +```text +Ghi chép bệnh án AI +Phiên dịch khám bệnh trực tiếp +Bắt đầu ghi chép +Bắt đầu phiên dịch +Bác sĩ nói tiếng Việt, bệnh nhân nghe tiếng Anh +``` + +Avoid as primary UI: + +```text +Scribe +Interpreter +Session +Encounter +Transcript +Start session +Launch interpreter +``` + +## Non-negotiable safety invariants + +This file is the current safety contract. `docs/history/PLAN.md §2` preserves +the original MVP source for historical context. + +1. Translate-only: never generate medical advice, diagnoses, or drug recommendations. +2. High/critical-risk turns are blocked from patient display and TTS until doctor confirms. +3. Low-confidence output is always visibly flagged, never silently delivered. +4. Raw audio is never persisted. Use memory-only processing. No audio columns, no temp files. +5. No mic capture before recorded consent. +6. On any pipeline or reviewer failure, fail closed: keep the turn blocked, show the doctor raw source and translation, offer escalation. Never fail open to the patient. + +## Implementation workflow for agents + +For UX or product-flow changes, do not implement immediately. + +First produce or update a plan in `docs/ux-redesign-carepath.md` with: + +1. Current UX problem +2. Proposed flow +3. Affected routes/pages/components +4. Vietnamese-first copy +5. Implementation stories +6. Dependencies between stories +7. Acceptance criteria +8. Validation commands +9. Risks and fallback behavior + +Then implement one story at a time. + +### Recommended story order for UX redesign + +1. Update Vietnamese-first product naming and copy. +2. Redesign the landing page into two clear workflow cards. +3. Add or clarify separate entry routes for the two modules. +4. Add pre-start onboarding/explanation screens. +5. Improve empty/loading/error states in Vietnamese. +6. Run QA for mobile, accessibility, safety copy, and route regressions. + +Do not combine homepage redesign, routing changes, and audio/backend logic changes in one large patch. + +### Agent behavior + +* Keep changes small and focused. +* Preserve existing working functionality. +* Do not rewrite backend, audio, risk, or websocket logic unless the current story explicitly requires it. +* Do not introduce new dependencies without documenting why. +* Prefer existing components and styling patterns. +* When changing user-facing Vietnamese text, check diacritics and consistency. +* When changing risk-engine behavior, update fixtures and evals. +* When changing product copy only, avoid touching medical logic. + +## Commands + +### Combined service + +```bash +uvicorn carepath.main:app --app-dir scribe --reload +``` + +Requires: + +```bash +pip install -e ".[dev]" -e "./shared" -e "./interpreter[dev]" +``` + +### Scriber and combined tests + +```bash +pytest +python scripts/smoke_backend.py +python scripts/build_term_artifacts.py --check +``` + +### Interpreter backend alone + +```bash +cd interpreter && uvicorn app.main:app --reload +pytest +``` + +### Console + +```bash +cd interpreter/frontend && npm run dev +npm test +npx playwright test +``` + +### Demo site + +```bash +cd scribe/frontend && npm run dev +npm test +npm run build +npm run e2e +``` + +`npm run build` is also the diacritics gate. + +### Full mock-mode run + +Set this in `.env`: + +```bash +PROVIDER_MODE=mock +``` + +Mock mode must work with no API keys. + +### Eval regression + +```bash +python interpreter/eval/run_eval.py --set interpreter/eval/fixtures/eval_starter.tsv --providers mock +``` + +## Conventions + +* Python 3.12. +* Python code must be ruff-formatted and type-hinted. +* Use pure functions for normalization and risk rules. +* TypeScript must be strict. +* Components should be small. +* Keep state minimal: context/zustand is allowed, Redux is not. +* `shared/carepath_shared/terms/medical_terms.json` is the canonical medical + term source. Regenerate `data/medical_lexicon.json` and + `interpreter/app/glossary/data/seed_glossary.csv` with + `python scripts/build_term_artifacts.py`; do not hand-edit generated artifacts. +* Risk lexicons under `interpreter/app/risk/lexicons/` remain separate + interpreter safety data. Clinicians edit data, not code. +* Every risk-engine behavior change updates `interpreter/eval/fixtures/risk_cases.jsonl`. +* The fixture run is the test. +* Zero misses on critical fixtures is a hard gate. +* Secrets only via env. +* `.env` is gitignored. +* `.env.example` must stay current. +* No new dependencies without noting why in the PR. +* Vietnamese text is data, not decoration: always NFC-normalized, diacritics preserved. +* Tests must include diacritic-stripped variants where matching allows it. + +## Acceptance criteria for UX clarity changes + +A UX clarity change is not done un less all of the following are true: + +1. A Vietnamese doctor can understand the two workflows without knowing the words `Scribe` or `Interpreter`. +2. The landing page clearly separates: + + * `Ghi chép bệnh án AI` + * `Phiên dịch khám bệnh trực tiếp` +3. Each workflow has a distinct CTA. +4. Each workflow explains when to use it. +5. English terms appear only as secondary helper text, not primary labels. +6. Mobile layout remains clear. +7. Existing core functionality still works. +8. Safety invariants remain unchanged. +9. Build and relevant tests pass. + + +## Harness workflow + +This repository uses Repository Harness for durable task intake, proof, and +decision records. This block adds to the CarePath instructions above; it does +not replace them. + +Priority, highest first: + +1. The current user request and the safety and product rules in this file. +2. Current product contracts in `docs/product/`. +3. Selected story packets in `docs/stories/` and accepted decisions in + `docs/decisions/`. +4. Executable tests and the Harness proof matrix. +5. `docs/history/` as context only. + +Before any task, read `README.md`, `docs/HARNESS.md`, and +`docs/FEATURE_INTAKE.md`; then run +`.\scripts\bin\harness-cli.exe query matrix` on Windows. Classify and record +the task as `tiny`, `normal`, or `high-risk` before changing code. + +- Tiny work records an intake and runs the relevant quick proof. +- Normal work also updates one story packet and its proof record. +- High-risk work uses `docs/templates/high-risk-story/`, records a decision + when it changes architecture, data, safety, APIs, or validation, and waits + for human direction if its scope is ambiguous. +- A UX or product-flow task still must first update + `docs/ux-redesign-carepath.md`, then follow the Harness lane requirements. +- Before a step that could use an external tool, query the available provider: + `.\scripts\bin\harness-cli.exe query tools --capability --status present`. + A missing provider is a clean skip, never a reason to invent a dependency. +- Finish normal and high-risk work with a Harness trace that records the real + validation outcome and any friction. Do not claim proof that was not run. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..9db4b4701a51d2e5a53ce22dc7e120e2ff44b3a9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +# CarePath + +@AGENTS.md +@docs/FEATURE_INTAKE.md + +Use the Harness workflow in `AGENTS.md` before changing this repository. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..2b59a7108e66c7190253c512cecbbc830dd676cf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# --- Stage 1: build the static frontends (demo site + interpreter console) --- +FROM node:22-slim AS frontends + +WORKDIR /build + +COPY scribe/frontend/package.json scribe/frontend/package-lock.json scribe/frontend/ +RUN cd scribe/frontend && npm ci +COPY scribe/frontend scribe/frontend +# site build also runs the Vietnamese diacritics gate. +RUN cd scribe/frontend && npm run build + +COPY interpreter/frontend/package.json interpreter/frontend/package-lock.json interpreter/frontend/ +RUN cd interpreter/frontend && npm ci +COPY interpreter/frontend interpreter/frontend +# Production build uses base /phien-dich-y-khoa/ (see interpreter/frontend/vite.config.ts). +ARG VITE_PUBLIC_SITE_URL +ENV VITE_PUBLIC_SITE_URL=${VITE_PUBLIC_SITE_URL} +RUN cd interpreter/frontend && npm run build + +# --- Stage 2: Python runtime serving both APIs and the built frontends --- +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + HF_HOME=/opt/hf-cache + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libgomp1 libsndfile1 \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml README.md ./ +COPY shared ./shared +COPY scribe/carepath ./scribe/carepath +COPY interpreter ./interpreter +RUN python -m pip install --upgrade pip \ + && python -m pip install . ./shared ./interpreter + +ARG GIPFORMER_QUANTIZE=int8 +ENV GIPFORMER_QUANTIZE=${GIPFORMER_QUANTIZE} +RUN python -c "from carepath.services.asr import GipformerASR; from huggingface_hub import hf_hub_download; files = GipformerASR.onnx_files['${GIPFORMER_QUANTIZE}']; [hf_hub_download(repo_id=GipformerASR.repo_id, filename=name) for name in (*files.values(), 'tokens.txt')]" + +COPY data ./data +COPY --from=frontends /build/scribe/frontend/dist ./scribe/frontend/dist +COPY --from=frontends /build/interpreter/frontend/dist ./interpreter/frontend/dist +ENV SITE_DIST_DIR=/app/scribe/frontend/dist \ + CONSOLE_DIST_DIR=/app/interpreter/frontend/dist + +EXPOSE 7860 + +CMD ["uvicorn", "carepath.main:app", "--app-dir", "scribe", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.hf-space.md b/README.hf-space.md new file mode 100644 index 0000000000000000000000000000000000000000..f1b414b3a9192e15997a98b52ea629d0a0389f56 --- /dev/null +++ b/README.hf-space.md @@ -0,0 +1,21 @@ +--- +title: CarePath +sdk: docker +app_port: 7860 +--- + +# CarePath Space + +This Space runs the unified CarePath product from the root `Dockerfile`, which +builds `scribe/frontend/` and `interpreter/frontend/`: +demo site at `/`, Scribe tool at `/ghi-chep-lam-sang/`, scriber API at +`/api/v1/*`, and Interpreter API at `/api/*` + `/ws/*`. Interpreter browser +paths are disabled for public users while that module remains in development. + +Required Space secrets (scriber; the interpreter runs keyless in mock mode): + +- `LLM_PROVIDER=ckey` +- `LLM_API_KEY=` +- `LLM_MODEL=gpt-5.4` +- `TEAM_CODE=` +- `APP_ENV=prod` diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f1b414b3a9192e15997a98b52ea629d0a0389f56 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +--- +title: CarePath +sdk: docker +app_port: 7860 +--- + +# CarePath Space + +This Space runs the unified CarePath product from the root `Dockerfile`, which +builds `scribe/frontend/` and `interpreter/frontend/`: +demo site at `/`, Scribe tool at `/ghi-chep-lam-sang/`, scriber API at +`/api/v1/*`, and Interpreter API at `/api/*` + `/ws/*`. Interpreter browser +paths are disabled for public users while that module remains in development. + +Required Space secrets (scriber; the interpreter runs keyless in mock mode): + +- `LLM_PROVIDER=ckey` +- `LLM_API_KEY=` +- `LLM_MODEL=gpt-5.4` +- `TEAM_CODE=` +- `APP_ENV=prod` diff --git a/UI-FIX-PLAN.md b/UI-FIX-PLAN.md new file mode 100644 index 0000000000000000000000000000000000000000..33faaa3f53eba4c8c7c28b5ca47bf96229b057db --- /dev/null +++ b/UI-FIX-PLAN.md @@ -0,0 +1,154 @@ +# UI Fix Plan — carepath-omega.vercel.app (scribe/frontend/) + +Plan authored by Fable 5 after reviewing the live deploy and source. Executor: Opus 4.8. +Scope: the Vite/React app in `scribe/frontend/` only. Do the fixes in order. Keep diffs minimal — no redesigns, no new dependencies. + +## How findings were verified (and how you re-verify) + +The live site was screenshotted and probed with the repo's own Playwright +(`scribe/frontend/node_modules/playwright-core`, headless Chromium) at 1440/1100/900/800/390px widths. +Re-run any probe the same way, e.g.: + +```js +// node, from scribe/frontend/: require('playwright-core'), launch chromium, +// newPage({ viewport }), goto https://carepath-omega.vercel.app/ (or local `npm run preview`) +``` + +Verify locally against `npm run dev`/`preview` in `scribe/frontend/` — do not deploy to verify. + +## Do NOT chase these (verified non-issues) + +Full-page/element screenshots of this site produce artifacts because of the sticky nav and +GSAP scroll animations. Already confirmed fine: + +- Hero "Audio → SOAP" strip overhanging its card (`.hero__route--scribe { right: -3% }`) is an + intentional accent. `document.scrollWidth - clientWidth === 0` at all tested widths — no + horizontal overflow anywhere. +- Grey band across the bottom of the safety bento in screenshots = GSAP scrub timeline + (`scribe/frontend/src/landing/useLandingMotion.ts:45-68`) caught mid-state. Renders correctly live. +- Scribe chapter body text is complete in source (`scribe/frontend/src/content/strings.ts:340`); the + "truncation" seen in captures was the sticky nav bar overlaying a line during scroll-stitching. +- Marquee is 48px tall and animates correctly; "clipped text" in captures is the same stitching + artifact. + +--- + +## P0-1: Lead contact path is dead on production + +**Symptom:** Footer "Liên hệ chương trình thí điểm" renders `href="mailto:"` (no recipient). +Submitting the pilot lead form opens a recipient-less mail draft and then shows the success-ish +"mail opened" status. The site's only conversion path silently goes nowhere. + +**Root cause:** Neither `VITE_LEAD_ENDPOINT` nor `VITE_LEAD_EMAIL` is set in the Vercel project. +`submitLead()` falls through to `buildLeadMailto(payload, email = "")` +(`scribe/frontend/src/leads.ts:88-130`). The deploy gate `scribe/frontend/scripts/validate-deploy-env.mjs` only +validates `VITE_API_BASE` + `VITE_CONSOLE_URL`, so the build passed anyway +(`scribe/frontend/vercel.json` runs `npm run validate:deploy && npm run build`). + +**Fix (all three parts):** +1. `scribe/frontend/scripts/validate-deploy-env.mjs`: require at least one lead channel — error if both + `VITE_LEAD_ENDPOINT` and `VITE_LEAD_EMAIL` are empty. Extend + `scribe/frontend/scripts/validate-deploy-env.test.mjs` accordingly (`npm run test:deploy-env`). +2. UI guard, belt-and-suspenders: + - `scribe/frontend/src/leads.ts` / `scribe/frontend/src/LeadForm.tsx`: if no endpoint and no email, `submitLead` + must not open `mailto:` and the form must show the existing error status (`labels.failed` + path) instead of "mail opened". Never report success for a no-op. + - `scribe/frontend/src/LandingPage.tsx:476-478`: don't render the footer mailto link when + `VITE_LEAD_EMAIL` is empty (render the text non-linked, or drop it). + - Existing tests mock `leadEmail`/`endpoint` props (`scribe/frontend/src/LeadForm.test.tsx`, + `scribe/frontend/src/leads.test.ts`) — add the empty-config case. +3. Tell the user to set `VITE_LEAD_EMAIL` (and optionally `VITE_LEAD_ENDPOINT`) in the Vercel + project settings — the value is a business decision; do not invent one. The code fix must be + correct with or without it. + +**Accept when:** with no lead env vars, footer shows no dead link and form submit shows an error, +not success; `npm run test` and `npm run test:deploy-env` pass; with `VITE_LEAD_EMAIL` set the +mailto contains the recipient. + +## P0-2: Mobile nav menu opens off-screen — links unusable on phones + +**Symptom:** At 390×844, opening the hamburger shows a white panel whose link labels are +invisible. Measured panel box: `x = -108, width = 304` — a third of it (including all left-padded +label text) is past the left viewport edge. At 900px the panel floats detached mid-nav +(`x = 225`) under the centered button. + +**Root cause:** `scribe/frontend/src/styles.css:2267-2277` — `.site-nav__menu > div` is +`position: absolute; right: 0` anchored to `.site-nav__menu` (`position: relative`, line 2248), +i.e. to the 2.6rem hamburger itself, which sits mid-nav between the brand and the VI/EN toggle. +The 19rem-wide panel extends leftward from there. + +**Fix:** Anchor the panel to the nav bar, not the button. `.site-nav` is `position: sticky` +(styles.css:129-141) and therefore already a containing block for absolute descendants — the +smallest fix is to drop `position: relative` from `.site-nav__menu` (line 2248) and give the +panel a right offset matching the nav padding (`right: max(1.5rem, calc((100vw - var(--container)) / 2))` +at ≥761px; `1rem`-ish at ≤760px — mirror the nav's own padding values). Keep +`width: min(19rem, calc(100vw - 2rem))`. + +**Accept when:** at 320, 390, 760, and 900px widths the open panel's bounding box is fully inside +the viewport, flush near the right edge below the bar, and every link label is visible. + +## P0-3: Menu never closes after navigating + +**Symptom:** Tap hamburger → tap "An toàn" → page scrolls to the section but the `
` +panel stays open, covering the content just navigated to. Verified: `.site-nav__menu[open]` +still present after link click. + +**Root cause:** Native `
` doesn't close on child anchor clicks +(`scribe/frontend/src/LandingPage.tsx:60-63`). + +**Fix:** In `LandingPage.tsx`, close the details when a menu link is clicked (e.g. `onClick` on +the wrapping div or each link that clears the `open` attribute via a ref). Keep it a few lines — +no state library, no outside-click handler unless it's free. + +**Accept when:** tapping any menu link scrolls to the section AND the panel is closed. + +## P1-1: Evidence section headline wraps 2 words × 6 lines on desktop + +**Symptom:** At 1440px, "Xem cách mỗi sản phẩm giữ điểm cần duyệt ở đúng chỗ." renders 64px in a +379px-wide column → 6 cramped lines. Measured: `{w: 379, fs: 64px, lines: 6}`; still 4 lines at +1100px. Inside the carousel the slide `h3` (up to 2.6rem in a ~270px column, +`.evidence-slide` col `minmax(13rem, 0.65fr)`, styles.css:2062-2094) wraps ~5 lines the same way. + +**Root cause:** `.evidence` grid gives the intro `minmax(16rem, 0.55fr)` of a 74rem container +minus up to 8rem gap (styles.css:2049-2056), while `.section-intro h2` uses the global +`clamp(2rem, 4.5vw, 4rem)` scale (styles.css:1615-1618) sized for full-width intros. + +**Fix:** Scoped override, not a layout rework — e.g. +`.evidence .section-intro h2 { font-size: clamp(1.8rem, 2.2vw, 2.5rem); }` and similarly cap +`.evidence-slide__copy h3` (~`clamp(1.4rem, 1.8vw, 1.9rem)`). Alternatively rebalance the column +(`0.55fr → 0.75fr`) plus a smaller cap — pick whichever reads best live, but the acceptance bar +is below. Don't touch other sections' headings. + +**Accept when:** at 1280–1600px the evidence h2 wraps ≤3 lines with ≥3 words per line, and each +slide h3 wraps ≤3 lines; ≤1023px layouts (already fine) unchanged. + +## P2 (do only after P0/P1; each is a 1–2 line change) + +- **Sticky nav dissolves into the page while scrolling.** `--nav-bg` ≈ page beige at 0.92 alpha + with only a hairline border (styles.css:129-141), so the floating VI/EN pill + hamburger appear + to sit directly on content text mid-scroll (most visible on mobile). Add a subtle + `box-shadow` (or slightly stronger bottom border) to `.site-nav` so the bar reads as a surface. +- **Marquee labels are 11.84px** (`.marquee__track span`, `font-size: 0.74rem`, + styles.css:780-791) — uppercase microtext below the 12px floor; bump to `0.78rem`. +- **Demo controls wrap ragged on mobile:** in `.demo__controls` + (`scribe/frontend/src/demo/DemoPlayer.tsx:251`, styles for it in styles.css) "Phát lại" wraps to an orphan + row at 390px. Make the wrap intentional (consistent gap; buttons full-width or evenly split at + ≤760px — match `.product-chapter__actions`' existing pattern at styles.css:2429-2433). + +## Verification (run all from `scribe/frontend/`) + +1. `npm run lint`, `npm run test`, `npm run test:deploy-env` — all green. +2. `npm run build` — green WITH lead env vars; FAILS with a clear message when both lead vars are + missing (new validator rule). `npm run dev` for visual checks. +3. Playwright probe (as above) against local dev at 390 / 760 / 900 / 1440px: + menu panel fully in-viewport, closes on navigate; evidence h2 line counts within budget; + `scrollWidth === clientWidth` (no new horizontal overflow at any width). +4. Both languages (VI default, EN toggle) and `#/scribe` route still render — `App.test.tsx`, + `LandingPage.test.tsx`, `ScribeTool.test.tsx` cover regressions. +5. The build's `check-diacritics.mjs` runs inside `npm run build` — any copy you touch must keep + Vietnamese diacritics intact. + +## Out of scope + +Backend/`scribe/carepath`, the HF-space console (external `VITE_CONSOLE_URL` target), `interpreter/frontend/` +(separate app), content rewrites, redesigns, new sections, dependencies. diff --git a/data/medical_lexicon.json b/data/medical_lexicon.json new file mode 100644 index 0000000000000000000000000000000000000000..9d95cc7f2b27559abbaafea9abdcd84be84afdbd --- /dev/null +++ b/data/medical_lexicon.json @@ -0,0 +1,215 @@ +{ + "terms": [ + { + "term": "SpO2", + "category": "vital_sign", + "vietnamese": "độ bão hòa oxy", + "aliases": ["spo2", "saturation", "oxy máu"] + }, + { + "term": "ECG", + "category": "test", + "vietnamese": "điện tâm đồ", + "aliases": ["ekg", "electrocardiogram"] + }, + { + "term": "HbA1c", + "category": "biomarker", + "vietnamese": "đường huyết trung bình", + "aliases": ["hba1c", "a1c"] + }, + { + "term": "glucose", + "category": "biomarker", + "vietnamese": "đường huyết", + "aliases": ["blood sugar", "đường máu"] + }, + { + "term": "insulin", + "category": "medication", + "vietnamese": "insulin", + "aliases": ["in-su-lin"] + }, + { + "term": "metformin", + "category": "medication", + "vietnamese": "metformin", + "aliases": ["met for min", "mét-pho-min"] + }, + { + "term": "hypertension", + "category": "condition", + "vietnamese": "tăng huyết áp", + "aliases": ["cao huyết áp", "high blood pressure"] + }, + { + "term": "diabetes mellitus", + "category": "condition", + "vietnamese": "đái tháo đường", + "aliases": ["tiểu đường", "diabetes"] + }, + { + "term": "COPD", + "category": "condition", + "vietnamese": "bệnh phổi tắc nghẽn mạn tính", + "aliases": ["copd", "bệnh phổi tắc nghẽn"] + }, + { + "term": "asthma", + "category": "condition", + "vietnamese": "hen phế quản", + "aliases": ["hen suyễn"] + }, + { + "term": "BMI", + "category": "biometric", + "vietnamese": "chỉ số khối cơ thể", + "aliases": ["body mass index"] + }, + { + "term": "creatinine", + "category": "biomarker", + "vietnamese": "creatinin", + "aliases": ["creatinin"] + }, + { + "term": "eGFR", + "category": "biomarker", + "vietnamese": "mức lọc cầu thận ước tính", + "aliases": ["egfr", "gfr"] + }, + { + "term": "CRP", + "category": "biomarker", + "vietnamese": "protein phản ứng C", + "aliases": ["c reactive protein"] + }, + { + "term": "WBC", + "category": "biomarker", + "vietnamese": "bạch cầu", + "aliases": ["white blood cell", "leukocyte"] + }, + { + "term": "hemoglobin", + "category": "biomarker", + "vietnamese": "huyết sắc tố", + "aliases": ["hb"] + }, + { + "term": "platelet", + "category": "biomarker", + "vietnamese": "tiểu cầu", + "aliases": ["plt"] + }, + { + "term": "sodium", + "category": "biomarker", + "vietnamese": "natri", + "aliases": ["na", "na+"] + }, + { + "term": "potassium", + "category": "biomarker", + "vietnamese": "kali", + "aliases": ["k", "k+"] + }, + { + "term": "ALT", + "category": "biomarker", + "vietnamese": "men gan ALT", + "aliases": ["sgpt"] + }, + { + "term": "AST", + "category": "biomarker", + "vietnamese": "men gan AST", + "aliases": ["sgot"] + }, + { + "term": "ultrasound", + "category": "test", + "vietnamese": "siêu âm", + "aliases": ["sonography"] + }, + { + "term": "X-ray", + "category": "test", + "vietnamese": "x-quang", + "aliases": ["xray", "x quang"] + }, + { + "term": "CT", + "category": "test", + "vietnamese": "chụp cắt lớp vi tính", + "aliases": ["ct scan", "computed tomography"] + }, + { + "term": "MRI", + "category": "test", + "vietnamese": "chụp cộng hưởng từ", + "aliases": ["magnetic resonance imaging"] + }, + { + "term": "pneumonia", + "category": "condition", + "vietnamese": "viêm phổi", + "aliases": ["lung infection"] + }, + { + "term": "tachycardia", + "category": "finding", + "vietnamese": "nhịp tim nhanh", + "aliases": ["tim nhanh"] + }, + { + "term": "bradycardia", + "category": "finding", + "vietnamese": "nhịp tim chậm", + "aliases": ["tim chậm"] + }, + { + "term": "systolic", + "category": "vital_sign", + "vietnamese": "huyết áp tâm thu", + "aliases": ["tâm thu"] + }, + { + "term": "diastolic", + "category": "vital_sign", + "vietnamese": "huyết áp tâm trương", + "aliases": ["tâm trương"] + }, + { + "term": "testosterone", + "category": "hormone", + "vietnamese": "testosterone", + "aliases": ["testosteron"] + }, + { + "term": "dihydrotestosterone", + "category": "hormone", + "vietnamese": "dihydrotestosterone", + "aliases": ["dht"] + }, + { + "term": "Bartholin", + "category": "anatomy", + "vietnamese": "tuyến Bartholin", + "aliases": ["bartholins"] + }, + { + "term": "methionine", + "category": "biochemistry", + "vietnamese": "methionin", + "aliases": ["methionin"] + }, + { + "term": "glutathione", + "category": "biochemistry", + "vietnamese": "glutathion", + "aliases": ["glutathion"] + } + ] +} + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..17e2aaf761c20a93df3d3c9798b47d1637dcb51d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,40 @@ +# CarePath Architecture + +CarePath is one FastAPI deployment with two separately named clinical workflows. +The combined entrypoint is `scribe/carepath/main.py`; it owns the Scribe API, +imports the Interpreter routers from `interpreter/app`, and mounts both built Vite +frontends after API routes. + +## Product Boundaries + +| Workflow | Backend boundary | Browser surface | +| --- | --- | --- | +| Ghi chép bệnh án AI | `scribe/carepath`, `/api/v1/*` | `scribe/frontend/` at `/`, including `/ghi-chep-lam-sang/` | +| Phiên dịch khám bệnh trực tiếp | `interpreter/app`, `/api/*` and `/ws/*` | `interpreter/frontend/` at `/phien-dich-y-khoa/` | + +`/console/` redirects to the canonical interpreter path for compatibility. The +two API namespaces intentionally do not overlap. + +`scribe/training/` is exclusively the Scribe's offline DARAG/GEC training and quality +track. It does not train or serve the Interpreter. `interpreter/eval/` is a +separate deterministic translation-safety harness that stays with the +Interpreter. + +## Boundaries That Must Stay Explicit + +- Scribe output is a clinician-reviewed draft; it does not replace clinical + judgment. +- Interpreter output is translation only. High or critical risk stays blocked + from patient display and TTS until clinician confirmation. +- Interpreter audio is memory-only: no raw-audio persistence, no microphone + capture before consent, and failures fail closed. +- Risk lexicons and glossary seeds are editable JSON/CSV data, not code. +- Providers, environment variables, HTTP/WebSocket payloads, uploaded files, + and database rows are trust boundaries and must be parsed before use. + +## Delivery and Proof + +The shared process serves built static assets in production; Vite dev servers +remain the development path. Existing Python, eval, frontend, browser, build, +and Lighthouse commands are the validation ladder. Do not add a second service, +state framework, or test runner unless a selected story proves the need. diff --git a/docs/CONTEXT_RULES.md b/docs/CONTEXT_RULES.md new file mode 100644 index 0000000000000000000000000000000000000000..effc611b2c850f6b94eff0641fae345e0baa7855 --- /dev/null +++ b/docs/CONTEXT_RULES.md @@ -0,0 +1,19 @@ +# CarePath Context Rules + +Read enough to preserve the selected contract, not the entire repository. + +| Lane | Required context before implementation | +| --- | --- | +| Tiny | `AGENTS.md`, `docs/FEATURE_INTAKE.md`, matrix query, and exact files to change | +| Normal | Tiny context plus relevant product contract, story, validation command, and architecture when a boundary changes | +| High-risk | Normal context plus relevant decisions, high-risk template, safety/eval fixtures, and every affected module boundary | + +Always read the current task's validation evidence before recording its trace. +For product or UX work, read the appropriate `docs/product/` contract; for UX +flows also read `docs/ux-redesign-carepath.md`. For risk, consent, TTS, audio, +or provider work, read `AGENTS.md` safety invariants and the affected tests and +fixtures before editing. + +Stop reading unrelated history once the lane, contract, affected files, and +proof path are clear. Search targeted paths with `rg` rather than loading broad +archives. diff --git a/docs/FEATURE_INTAKE.md b/docs/FEATURE_INTAKE.md new file mode 100644 index 0000000000000000000000000000000000000000..61a40c42b3d4a68d79ab527d10d4f80312ec6268 --- /dev/null +++ b/docs/FEATURE_INTAKE.md @@ -0,0 +1,51 @@ +# CarePath Feature Intake + +Every implementation request is classified before code changes. Record the +classification with `.\scripts\bin\harness-cli.exe intake`. + +## Input Types + +| Type | Use when | Typical artifact | +| --- | --- | --- | +| Change request | Bounded behavior, bug, copy, or UX refinement | Direct patch or story | +| New initiative | A new product area spanning multiple stories | Initiative note and stories | +| Maintenance request | Dependency, performance, delivery, or operational work | Story or decision | +| Harness improvement | Process, proof, template, or instruction work | Harness docs and trace | + +## Lanes + +### Tiny + +Use for narrow docs, copy, or isolated maintenance with no safety, API, data, +or multi-module impact. Record intake, patch directly, and run the relevant +quick proof. A UX or product-flow change still updates +`docs/ux-redesign-carepath.md` first. + +### Normal + +Use for a bounded change to one product surface or shared implementation. Create +or update one story packet, define the validation, update proof flags, and +record a standard trace. + +### High-Risk + +Use `docs/templates/high-risk-story/` and record a durable decision when the +work changes safety behavior, architecture, data ownership, public API shape, +or validation policy. Ask the user before implementing when the direction is +ambiguous. + +## CarePath Hard Gates + +The following are always high-risk: + +- Consent, microphone capture, raw-audio handling, retention, or privacy. +- Interpreter risk classification, confidence display, patient display, TTS, + escalation, or fail-closed behavior. +- Medical advice boundaries, provider behavior, credentials, or external + clinical data. +- Public API or WebSocket contract changes, database migrations, or changes + that span the Scribe and Interpreter modules. +- Removing or weakening existing safety or validation requirements. + +Use the existing test and eval fixtures whenever a risk-engine rule changes; +zero misses on critical fixtures remains a hard gate. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md new file mode 100644 index 0000000000000000000000000000000000000000..70f209142b0c56e22c7bb14f9638f1e34195e6c6 --- /dev/null +++ b/docs/GLOSSARY.md @@ -0,0 +1,144 @@ +# Glossary + +## Agent + +An AI coding collaborator operating inside the repository. + +## Harness + +The repo-level operating system that tells humans and agents how to turn intent +into safe product changes. + +## Product Contract + +The current expected behavior of the product. Product docs plus executable tests +become the living contract once implementation exists. + +## Story Packet + +A story-sized work file or folder that describes the product contract, affected +docs, design notes, and validation expectations for a feature. + +## Feature Intake + +The classification step that turns a prompt into tiny, normal, or high-risk +work before implementation begins. + +## Component Taxonomy + +A map from Harness files and capabilities to the responsibilities they serve, +used to evaluate coverage, attribute failures, and identify missing harness +capabilities. + +## Maturity Level + +A verifiable stage in Harness capability, from H0 bare environment through H5 +self-improving harness. Each level has required files, criteria, and benchmark +indicators. + +## Trace Quality Tier + +The expected depth of a task trace: minimal for tiny work, standard for normal +work, and detailed for high-risk work. + +## Verification Gate + +An advisory Harness check that runs or inspects mechanical proof before a task +is closed. In Phase 4, `story verify ` executes a story's `verify_command`, +`story verify-all` runs all configured story proof commands, and +`trace --story ` warns when that story's verification has not passed. + +## Tool Registry + +The compiled and registered tool manifest exposed by +`scripts/bin/harness-cli query tools`. It lets agents discover available +commands, arguments, responsibilities, and custom project tools. + +## Intervention + +A durable record of human, reviewer, CI, or agent feedback that corrected, +overrode, escalated, or approved work. Interventions are stored separately from +traces and feed improvement proposals. + +## Context Score + +The advisory result from `scripts/bin/harness-cli score-context `. +It compares a trace's recorded `files_read` against compiled context rules and +retrieval triggers. + +## Entropy Score + +The drift score printed by `scripts/bin/harness-cli audit`. Lower is better. +It counts stale or incomplete durable records such as orphaned stories, +unverified proof commands, missing backlog outcomes, and broken registered +tools. + +## Improvement Proposal + +A structured recommendation generated by `scripts/bin/harness-cli propose` from +repeated friction, intervention patterns, and audit findings. Proposals are +advisory unless committed to the backlog with `--commit`. + +## Context Phase + +A phase of an agent task that changes what context should be read, such as +intake, planning, implementation, validation, or trace recording. + +## Retrieval Trigger + +A condition that tells an agent to fetch additional context, such as touching a +database schema, changing a public contract, or discovering missing validation. + +## Harness Delta + +A documentation, template, validation, backlog, or decision update that makes +future agent work safer or easier. + +## Backlog Outcome Loop + +The feedback workflow for Harness improvements: record predicted impact when a +backlog item is created, then record actual measured outcome when the item is +closed so future agents can compare expectation with result. + +## Durable Layer + +The SQLite database and CLI (`scripts/bin/harness-cli`) that stores operational records +(intakes, stories, decisions, backlog items, traces) as structured, queryable +data. Policy docs describe how to work; the durable layer stores what happened. + +## Product Delta + +A product-facing change such as code, tests, API shape, data model, or product +documentation. + +## Trace + +A structured record of what an agent did during a task: actions taken, files +read, files changed, decisions made, errors encountered, outcome, and any +harness friction discovered. + +## Tool Registry + +The compiled and user-registered tool manifest exposed by +`scripts/bin/harness-cli query tools` and documented in `docs/TOOL_REGISTRY.md`. + +## Intervention + +A durable record of a human, reviewer, CI, or agent correction, override, +escalation, or approval that is separate from the normal task trace. + +## Context Score + +The advisory result from `scripts/bin/harness-cli score-context `, +which compares a trace's recorded reads against compiled context rules. + +## Entropy Score + +The drift score from `scripts/bin/harness-cli audit`. Lower scores mean fewer +orphaned, stale, unverified, outcome-missing, or broken-tool records. + +## Improvement Proposal + +A structured proposal generated by `scripts/bin/harness-cli propose` from +repeated friction, interventions, and audit drift. Proposals can be committed as +backlog items with `--commit`. diff --git a/docs/HARNESS.md b/docs/HARNESS.md new file mode 100644 index 0000000000000000000000000000000000000000..cd3bb0730cbbea07a91efc8444fa325463d6a246 --- /dev/null +++ b/docs/HARNESS.md @@ -0,0 +1,65 @@ +# CarePath Harness + +CarePath uses Repository Harness to turn a request into safe, reviewable work. +The application is what clinicians use; the Harness is the durable operating +layer for humans and coding agents. + +## Sources of Truth + +Apply sources in this order: + +1. Current user instruction and the safety, Vietnamese-first, and UX rules in + `AGENTS.md`. +2. The current contracts in `docs/product/`. +3. The selected story packet in `docs/stories/` and accepted decisions in + `docs/decisions/`. +4. Executable tests and the proof matrix. +5. `docs/history/` and research as background, unless a current contract + explicitly incorporates them. + +No Harness document may weaken a CarePath safety invariant. If sources conflict, +pause and ask the user rather than choosing a less-safe interpretation. + +## Durable Layer + +`scripts/bin/harness-cli.exe` manages the local, ignored `harness.db` using the +versioned migrations in `scripts/schema/`. Initialize it once per clone: + +```powershell +.\scripts\bin\harness-cli.exe init +``` + +The database records intake classifications, story proof, decisions, traces, +and Harness friction. Markdown remains the reviewable product record; the +database records what happened locally. + +## Task Loop + +1. Read `AGENTS.md`, this file, `docs/FEATURE_INTAKE.md`, and the current + matrix. +2. Classify and record the request with `harness-cli.exe intake`. +3. Read only the affected product contract, stories, decisions, and code. +4. Define the proof before implementation. +5. Implement only the selected lane. +6. Update the product contract, story, and matrix when the change affects them. +7. Run the applicable existing checks and record their actual result. +8. Record a trace. Capture repeated missing context or proof as Harness + friction or a backlog item. + +For UX or product-flow work, `AGENTS.md` additionally requires an updated +`docs/ux-redesign-carepath.md` before implementation. That requirement applies +even when intake classifies the edit as tiny. + +## Tool Registry + +The registry is optional. Before relying on an external tool, query it by +capability. If no present provider is registered, skip that optional step and +record the limitation only when it affects proof. Do not add a dependency or +tool registration merely to satisfy the Harness. + +## Done + +A task is done when its requested change is complete, relevant contracts and +proof records are current, the applicable checks have real results, and the +trace says what happened. A failed unrelated existing check is recorded as +failed proof; repairing it requires separate scope. diff --git a/docs/HARNESS_AUDIT.md b/docs/HARNESS_AUDIT.md new file mode 100644 index 0000000000000000000000000000000000000000..00f4ab701b2c2be9ee5281dbaeb30bbf5f9f8fc4 --- /dev/null +++ b/docs/HARNESS_AUDIT.md @@ -0,0 +1,38 @@ +# Harness Audit + +`scripts/bin/harness-cli audit` detects drift in durable Harness state and +prints an entropy score. Lower is better. + +## Checks + +| Category | Meaning | Weight | +| --- | --- | --- | +| Orphaned stories | Planned or in-progress stories with no linked trace. | 10 | +| Unverified stories | Active or implemented stories with `verify_command` but no recorded verification result. Retired stories are historical records and are not counted. | 5 | +| Unverified decisions | Decisions with `verify_command` but no recorded verification result. | 5 | +| Open backlog without outcomes | Implemented backlog items with predicted impact but no actual outcome. | 2 | +| Stale stories | Unimplemented stories whose latest linked trace is more than 30 days old. | 3 | +| Broken tools | Registered tools whose command is not found on disk or `PATH`. | 8 | + +## Score + +```text +score = orphaned_stories * 10 + + unverified_stories * 5 + + unverified_decisions * 5 + + backlog_without_outcomes * 2 + + stale_stories * 3 + + broken_tools * 8 +``` + +The score is capped at 100. + +| Range | Interpretation | +| --- | --- | +| 0 | Perfect: records are traced, verified, and healthy. | +| 1-25 | Healthy: minor housekeeping remains. | +| 26-50 | Attention needed: drift is accumulating. | +| 51-100 | Action required: stale state undermines Harness value. | + +Audit findings feed `scripts/bin/harness-cli propose`, which can turn repeated +drift into proposed backlog items. diff --git a/docs/HARNESS_BACKLOG.md b/docs/HARNESS_BACKLOG.md new file mode 100644 index 0000000000000000000000000000000000000000..04f2fb124b3f23d9de064461aa2d646cf0935fed --- /dev/null +++ b/docs/HARNESS_BACKLOG.md @@ -0,0 +1,62 @@ +# Harness Backlog + +Use this file when an agent discovers a missing harness capability but should +not change the operating model immediately. + +## Template + +```md +## Missing Harness Capability + +### Title + +Short name. + +### Discovered While + +Task or story that exposed the gap. + +### Current Pain + +What was hard, repeated, ambiguous, or unsafe? + +### Suggested Improvement + +What should be added or changed? + +### Risk + +Tiny, normal, or high-risk. + +CLI value: `--risk tiny`, `--risk normal`, or `--risk high-risk`. + +### Status + +proposed | accepted | implemented | rejected +``` + +## Items + +## Future Interpreter Risk-Lexicon Consolidation + +### Discovered While + +CP-RES-006 term-store consolidation. + +### Current Pain + +The Interpreter safety lexicons are also clinician-editable data, but they +govern deterministic risk classification rather than medical-term retrieval. + +### Suggested Improvement + +Evaluate a separate, clinician-approved consolidation only with a safety-fixture +and evaluation policy change. Do not merge them into the general term source. + +### Risk + +high-risk. + +### Status + +proposed diff --git a/docs/HARNESS_COMPONENTS.md b/docs/HARNESS_COMPONENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..f8495c437b8f8862cc9187402430c83c180b2af4 --- /dev/null +++ b/docs/HARNESS_COMPONENTS.md @@ -0,0 +1,167 @@ +# Harness Components + +This taxonomy maps the current `repository-harness` repository to two +component frameworks used by Phase 2 and updated by Phase 3 active +observability work: + +- Runtime Substrate responsibilities: the 11 responsibility areas the harness + should cover. +- NexAU decomposition: the seven implementation surfaces that influence agent + behavior. + +Status values: + +- **Covered**: the repository has an explicit file, command, or record for this + responsibility. +- **Partial**: the repository has some support, but the support is incomplete, + manual, or not yet measured. +- **Missing**: no meaningful support exists yet. + +## Responsibility Map + +| # | Responsibility | Status | Harness Files | Evidence | Gap | +| --- | --- | --- | --- | --- | --- | +| 1 | Task specification | Covered | `AGENTS.md`, `docs/FEATURE_INTAKE.md`, `docs/templates/story.md`, `docs/templates/spec-intake.md`, `docs/templates/high-risk-story/*`, `docs/stories/*`, `intake` table, `story` table | Requests are classified by type and lane before implementation; normal and high-risk work have templates and durable story rows. | Keep story packets synchronized with future product docs. | +| 2 | Context selection | Covered | `AGENTS.md`, `docs/CONTEXT_RULES.md`, `docs/ARCHITECTURE.md`, `docs/decisions/*`, `docs/product/README.md`, `scripts/bin/harness-cli score-context` | Phase 2 adds phase-by-lane context rules and retrieval triggers; Phase 5 adds context scoring against recorded trace reads. | Future automation could enforce context selection instead of only measuring it. | +| 3 | Tool access | Covered | `scripts/bin/harness-cli`, `docs/TOOL_REGISTRY.md`, `tool` table, `crates/harness-cli/*`, `scripts/install-harness.sh`, `scripts/build-harness-cli-release.sh` | The Harness CLI exposes operational commands and a machine-readable tool manifest through `query tools`; external tools can be registered and removed. | Permission profiles and usage analytics remain future work. | +| 4 | Project memory | Covered | `docs/HARNESS.md`, `docs/decisions/*`, `docs/GLOSSARY.md`, `docs/HARNESS_BACKLOG.md`, `docs/stories/*`, `harness.db`, `decision`, `backlog`, and `trace` tables | Decisions, backlog, stories, and traces preserve durable knowledge across tasks. | Future work should add staleness checks and summarize old traces. | +| 5 | Task state | Covered | `scripts/bin/harness-cli query matrix`, `docs/TEST_MATRIX.md`, `intake` table, `story` table, `trace` table | Durable records track intake, story status, proof columns, and task traces. | Add lifecycle checks so in-progress stories cannot be forgotten. | +| 6 | Observability | Partial | `docs/TRACE_SPEC.md`, `trace` table, `scripts/bin/harness-cli trace`, `scripts/bin/harness-cli score-trace`, `scripts/bin/harness-cli query traces`, `scripts/bin/harness-cli query friction`, `docs/HARNESS_MATURITY.md` | Traces are auto-scored when recorded, can be rescored by command, and can be reviewed with friction context. | No dashboard or benchmark ingestion exists in this repo. | +| 7 | Failure attribution | Partial | `docs/HARNESS_COMPONENTS.md`, `docs/TRACE_SPEC.md`, `trace.errors`, `trace.harness_friction`, `docs/HARNESS_BACKLOG.md`, `backlog` table, `scripts/bin/harness-cli query friction` | Failures can be tied to files, components, friction, backlog proposals, and linked intake lane/type context. | No automated attribution from benchmark failures to harness components exists yet. | +| 8 | Verification | Covered | `docs/TEST_MATRIX.md`, `scripts/bin/harness-cli query matrix`, `scripts/bin/harness-cli story verify`, `scripts/bin/harness-cli story verify-all`, `scripts/bin/harness-cli trace`, `scripts/bin/harness-cli score-trace`, `story.verify_command`, `story.last_verified_result`, `.github/workflows/harness-cli-release.yml`, `docs/templates/validation-report.md` | Stories can store and run mechanical proof commands individually or in batch, traces warn when linked story verification has not passed, trace quality can be checked mechanically, and release workflow verifies Rust CLI releases. | Benchmark ingestion remains future work. | +| 9 | Permissions | Partial | `AGENTS.md`, `docs/HARNESS.md`, `docs/FEATURE_INTAKE.md`, `docs/ARCHITECTURE.md`, installer conflict handling in `scripts/install-harness.sh` | Policy describes when agents may update docs and when to ask before architecture or workflow changes. | Permissions are instruction-level only; no enforced policy layer or command allowlist exists. | +| 10 | Entropy auditing | Covered | `docs/HARNESS_BACKLOG.md`, `docs/HARNESS_AUDIT.md`, `docs/IMPROVEMENT_PROTOCOL.md`, `backlog` table, `trace.harness_friction`, `scripts/bin/harness-cli audit`, `scripts/bin/harness-cli propose`, `docs/HARNESS_MATURITY.md` | Growth rule captures friction, audit detects drift and entropy score, backlog items compare predicted impact to actual outcome, and proposal generation can create reviewable backlog items. | Automated repair remains future work. | +| 11 | Intervention recording | Covered | `intervention` table, `scripts/bin/harness-cli intervention add`, `scripts/bin/harness-cli query interventions`, `trace` table, `docs/decisions/*`, `docs/stories/*`, `docs/HARNESS.md` | Human, reviewer, CI, and agent interventions are separate durable records and can be filtered by trace, story, or type. | Capture is still manual and advisory. | + +## NexAU Cross-Reference + +| Component | Harness Equivalent | Status | Notes | +| --- | --- | --- | --- | +| System prompts | `AGENTS.md` plus Harness policy docs | Covered | `AGENTS.md` is the stable shim; `docs/HARNESS.md`, `docs/FEATURE_INTAKE.md`, and `docs/CONTEXT_RULES.md` carry evolving operating instructions. | +| Tool descriptions | `docs/TOOL_REGISTRY.md`, `scripts/README.md`, `docs/HARNESS.md`, `docs/TRACE_SPEC.md`, CLI help from `crates/harness-cli/src/interface.rs`, `scripts/bin/harness-cli query tools` | Covered | Commands are documented in a standalone registry and exposed as compiled plus registered tool manifest entries. | +| Tool implementations | `scripts/bin/harness-cli`, `crates/harness-cli/*`, `scripts/schema/001-init.sql`, `scripts/schema/002-story-verify.sql` | Covered | The Rust CLI is the primary durable-layer implementation and stable repo-local entrypoint. | +| Middleware | installer safety logic, feature intake workflow | Partial | The installer and intake process mediate work, but there is no runtime middleware enforcing policies. | +| Skills | `docs/templates/*`, `docs/FEATURE_INTAKE.md`, `docs/CONTEXT_RULES.md`, `docs/TRACE_SPEC.md` | Partial | Reusable procedures exist as markdown, not executable or installable agent skills. | +| Sub-agents | None in this repository | Missing | No delegated specialist agents or sub-agent protocols exist. | +| Long-term memory | `harness.db`, `docs/decisions/*`, `docs/stories/*`, `docs/HARNESS_BACKLOG.md`, `docs/GLOSSARY.md` | Covered | Durable records and markdown decisions preserve task history and project vocabulary. | + +## File Inventory + +Every tracked project file plus the Phase 2 input file is mapped to at least +one Runtime Substrate responsibility. + +| File | Primary Responsibility | Secondary Responsibilities | +| --- | --- | --- | +| `.gitignore` | Tool access | Task state | +| `AGENTS.md` | Context selection | Task specification, permissions | +| `README.md` | Task specification | Project memory | +| `CONTRIBUTING.md` | Intervention recording | Project memory | +| `Cargo.toml` | Tool access | Verification | +| `Cargo.lock` | Tool access | Verification | +| `PHASE2.md` | Task specification | Observability, context selection | +| `PHASE3.md` | Task specification | Observability, verification, entropy auditing | +| `PHASE4.md` | Task specification | Verification, observability, task state | +| `PHASE5.md` | Task specification | Verification, entropy auditing, intervention recording | +| `crates/harness-cli/Cargo.toml` | Tool access | Verification | +| `crates/harness-cli/src/main.rs` | Tool access | Tool implementation | +| `crates/harness-cli/src/domain.rs` | Tool access | Task state, verification | +| `crates/harness-cli/src/application.rs` | Tool access | Task state | +| `crates/harness-cli/src/infrastructure.rs` | Tool access | Project memory, task state, observability | +| `crates/harness-cli/src/interface.rs` | Tool access | Context selection, verification | +| `docs/ARCHITECTURE.md` | Permissions | Context selection, task specification | +| `docs/FEATURE_INTAKE.md` | Task specification | Permissions, context selection | +| `docs/GLOSSARY.md` | Project memory | Context selection | +| `docs/HARNESS.md` | Task specification | Project memory, task state, permissions | +| `docs/HARNESS_BACKLOG.md` | Entropy auditing | Project memory, failure attribution | +| `docs/HARNESS_COMPONENTS.md` | Failure attribution | Observability, entropy auditing | +| `docs/HARNESS_MATURITY.md` | Entropy auditing | Observability, verification | +| `docs/HARNESS_AUDIT.md` | Entropy auditing | Verification, task state | +| `docs/IMPROVEMENT_PROTOCOL.md` | Entropy auditing | Failure attribution, permissions | +| `docs/CONTEXT_RULES.md` | Context selection | Permissions, task specification | +| `docs/TRACE_SPEC.md` | Observability | Failure attribution, intervention recording | +| `docs/TOOL_REGISTRY.md` | Tool access | Context selection, verification | +| `docs/README.md` | Project memory | Context selection | +| `docs/TEST_MATRIX.md` | Verification | Task state | +| `docs/decisions/0001-harness-first-development.md` | Project memory | Permissions | +| `docs/decisions/0002-post-spec-product-lifecycle.md` | Project memory | Task specification | +| `docs/decisions/0003-generic-spec-intake-harness.md` | Project memory | Task specification | +| `docs/decisions/0004-sqlite-durable-layer.md` | Project memory | Observability, task state | +| `docs/decisions/0005-prebuilt-rust-harness-cli.md` | Project memory | Tool access | +| `docs/decisions/0006-phase-4-benchmark-triage.md` | Project memory | Verification | +| `docs/decisions/0007-improvement-proposal-rules.md` | Project memory | Entropy auditing, permissions | +| `docs/decisions/README.md` | Project memory | Context selection | +| `docs/demo/README.md` | Task specification | Project memory | +| `docs/product/README.md` | Task specification | Project memory | +| `docs/review-fixes-1d30bf62-to-main.md` | Intervention recording | Failure attribution, verification | +| `docs/stories/README.md` | Task specification | Project memory | +| `docs/stories/US-001-install-harness.md` | Task specification | Verification, intervention recording | +| `docs/stories/US-008-trace-quality-scoring.md` | Task specification | Observability, verification | +| `docs/stories/US-009-enriched-friction-query.md` | Task specification | Failure attribution, observability | +| `docs/stories/US-011-backlog-outcome-workflow.md` | Task specification | Entropy auditing, project memory | +| `docs/stories/US-012-story-verify-command-field.md` | Task specification | Verification | +| `docs/stories/US-015-story-verify-command.md` | Task specification | Verification | +| `docs/stories/US-016-auto-trace-scoring-on-write.md` | Task specification | Observability, verification | +| `docs/stories/US-017-pre-close-verification-gate.md` | Task specification | Verification, permissions | +| `docs/stories/US-018-phase4-cli-ux-hardening.md` | Task specification | Tool access, verification | +| `docs/stories/US-019-machine-readable-tool-registry.md` | Task specification | Tool access | +| `docs/stories/US-020-batch-story-verification.md` | Task specification | Verification | +| `docs/stories/US-021-intervention-recording-schema.md` | Task specification | Intervention recording | +| `docs/stories/US-022-context-rule-measurement.md` | Task specification | Context selection | +| `docs/stories/US-023-drift-detection-entropy-score.md` | Task specification | Entropy auditing | +| `docs/stories/US-024-improvement-proposal-pipeline.md` | Task specification | Entropy auditing, permissions | +| `docs/stories/backlog.md` | Task specification | Project memory | +| `docs/stories/epics/README.md` | Task specification | Project memory | +| `docs/stories/epics/E01-durable-layer/US-002-rust-harness-cli/overview.md` | Task specification | Project memory | +| `docs/stories/epics/E01-durable-layer/US-002-rust-harness-cli/design.md` | Task specification | Tool access, permissions | +| `docs/stories/epics/E01-durable-layer/US-002-rust-harness-cli/execplan.md` | Task specification | Verification, task state | +| `docs/stories/epics/E01-durable-layer/US-002-rust-harness-cli/validation.md` | Verification | Intervention recording | +| `docs/stories/epics/E02-phase-2-observability-taxonomy/phase-2-progress.md` | Task state | Intervention recording | +| `docs/stories/epics/E03-phase-5-evolution-infrastructure/phase-5-progress.md` | Task state | Verification, entropy auditing | +| `docs/templates/decision.md` | Project memory | Task specification | +| `docs/templates/spec-intake.md` | Task specification | Context selection | +| `docs/templates/story.md` | Task specification | Verification | +| `docs/templates/validation-report.md` | Verification | Intervention recording | +| `docs/templates/high-risk-story/overview.md` | Task specification | Context selection | +| `docs/templates/high-risk-story/design.md` | Task specification | Permissions | +| `docs/templates/high-risk-story/execplan.md` | Task state | Verification | +| `docs/templates/high-risk-story/validation.md` | Verification | Failure attribution | +| `scripts/README.md` | Tool access | Context selection | +| `scripts/bin/harness-cli` | Tool access | Task state, observability | +| `scripts/bin/harness-cli` | Tool access | Task state, observability | +| `scripts/install-harness.sh` | Tool access | Permissions | +| `scripts/build-harness-cli-release.sh` | Verification | Tool access | +| `scripts/schema/001-init.sql` | Task state | Observability, project memory | +| `scripts/schema/002-story-verify.sql` | Verification | Task state, project memory | +| `scripts/schema/003-tool-registry.sql` | Tool access | Project memory | +| `scripts/schema/004-intervention.sql` | Intervention recording | Failure attribution | +| `.github/ISSUE_TEMPLATE/agent-failure-case.md` | Failure attribution | Entropy auditing | +| `.github/ISSUE_TEMPLATE/pattern-request.md` | Entropy auditing | Intervention recording | +| `.github/ISSUE_TEMPLATE/real-world-example.md` | Project memory | Intervention recording | +| `.github/workflows/harness-cli-release.yml` | Verification | Tool access | + +## Coverage Summary + +- Covered: 8/11 responsibilities. +- Partial: 3/11 responsibilities. +- Missing: 0/11 responsibilities. + +Covered responsibilities: + +- Task specification. +- Context selection. +- Tool access. +- Project memory. +- Task state. +- Verification. +- Entropy auditing. +- Intervention recording. +Partial responsibilities: + +- Observability. +- Failure attribution. +- Permissions. + +Phase 5 converts tool access, entropy auditing, and intervention recording into +covered responsibilities with a registry, drift audit, proposal loop, and +intervention schema. Later phases should focus on benchmark ingestion, +component-level attribution, permission enforcement, and tool usage analytics. diff --git a/docs/HARNESS_MATURITY.md b/docs/HARNESS_MATURITY.md new file mode 100644 index 0000000000000000000000000000000000000000..3fd6a8503552331cd5ee9017bfd3f789f72826c7 --- /dev/null +++ b/docs/HARNESS_MATURITY.md @@ -0,0 +1,316 @@ +# Harness Maturity Ladder + +This ladder defines how `repository-harness` should progress from static +agent instructions to measurable harness improvement. + +The levels are intentionally verifiable. A level is achieved only when its +criteria can be inspected in repository files, durable Harness records, or +benchmark output. + +## Levels + +### H0 - Bare Environment + +The model operates with no repository harness. It receives a prompt and may +produce a patch, but the repo does not tell it how to classify, validate, or +record work. + +Criteria: + +- No `AGENTS.md` Harness block exists. +- No feature intake policy exists. +- No story, decision, validation, or trace artifact exists. + +Required files: + +- None. + +Benchmark indicators: + +- Functional score is the only meaningful metric. +- Harness compliance: 0%. +- Trace quality: 0/3. + +Current status: + +- Passed. This repository is beyond H0. + +Activated responsibilities: + +- None. + +### H1 - Scaffolding And Policy + +The repository contains static operating instructions, templates, risk lanes, +and source-of-truth rules. Agents can follow a documented workflow, but durable +state may still be manual or incomplete. + +Criteria: + +- `AGENTS.md` points agents to the Harness operating docs. +- `docs/HARNESS.md`, `docs/FEATURE_INTAKE.md`, and `docs/ARCHITECTURE.md` + exist. +- Story, decision, and validation templates exist under `docs/templates/`. +- `docs/TEST_MATRIX.md` defines proof columns and status meanings. + +Required files: + +- `AGENTS.md` +- `docs/HARNESS.md` +- `docs/FEATURE_INTAKE.md` +- `docs/ARCHITECTURE.md` +- `docs/TEST_MATRIX.md` +- `docs/templates/story.md` +- `docs/templates/decision.md` +- `docs/templates/validation-report.md` + +Benchmark indicators: + +- Harness compliance: 20-40%. +- Lane accuracy improves when agents read the intake policy. +- Trace quality remains low unless traces are separately requested. + +Current status: + +- Achieved. H1 files exist and are used by current Harness instructions. + +Activated responsibilities: + +- Task specification. +- Permissions. +- Project memory. +- Verification. + +### H2 - Durable State And Observability + +The repository has structured operational records and explicit observation +rules. Agents can record what happened, connect work to stories, and write +traces with predictable depth. + +Criteria: + +- `scripts/bin/harness-cli` can record intake, story, decision, backlog, and trace + data in `harness.db`. +- `scripts/schema/001-init.sql` defines durable tables for intake, story, + decision, backlog, and trace records. +- `docs/HARNESS_COMPONENTS.md` maps files and responsibilities. +- `docs/HARNESS_MATURITY.md` defines H0-H5 with measurable criteria. +- `docs/TRACE_SPEC.md` defines trace fields, quality tiers, and friction + capture. +- `docs/CONTEXT_RULES.md` defines phase-by-lane context rules. +- `AGENTS.md` and `docs/HARNESS.md` reference the Phase 2 operating docs. + +Required files: + +- `scripts/bin/harness-cli` +- `scripts/schema/001-init.sql` +- `docs/HARNESS_COMPONENTS.md` +- `docs/HARNESS_MATURITY.md` +- `docs/TRACE_SPEC.md` +- `docs/CONTEXT_RULES.md` + +Benchmark indicators: + +- Harness compliance: 75-90%. +- Trace quality: at least 2.0/3 on normal-lane tasks. +- Lane accuracy: 6/6 on the current benchmark suite. +- Friction captured: at least 4/6 benchmark tasks when friction exists. + +Current status: + +- Achieved. Durable state exists, and the Phase 2 docs define the + observability and context specification. Phase 3 active scoring builds on + this layer. + +Activated responsibilities: + +- Task state. +- Observability. +- Failure attribution. +- Context selection. +- Entropy auditing. + +### H3 - Active Observability And Evolution + +The harness can evaluate its own operational data and turn repeated failures +into prioritized improvements. + +Criteria: + +- Trace quality can be scored by a repeatable command or benchmark step. +- Harness friction can be grouped by component from `docs/HARNESS_COMPONENTS.md`. +- Backlog items include predicted impact and actual outcome after completion. +- Benchmark comparison output identifies which harness responsibility moved or + regressed. + +Required files: + +- H2 files. +- A benchmark protocol or report that references maturity levels. +- A documented trace quality scoring method. +- A documented friction-to-backlog review loop. + +Benchmark indicators: + +- Harness compliance: 85-95%. +- Trace quality: 2.3-2.7/3. +- Friction captured and classified by component for most failed or awkward + tasks. +- Regressions include an attributed harness component. + +Current status: + +- Partially achieved by Phase 3. `scripts/bin/harness-cli score-trace` scores trace + quality against tier rules, `query friction` includes linked intake context, + the `trace` command now prints that score at write time, and the backlog + outcome loop documents predicted impact versus actual outcome. Full H3 still + requires benchmark comparison output that attributes moved or regressed + responsibilities. + +Activated responsibilities: + +- Observability. +- Failure attribution. +- Entropy auditing. +- Intervention recording. + +### H4 - Automated Verification + +The harness can run or orchestrate proof checks consistently and can reject or +flag incomplete work before the final response. + +Criteria: + +- A documented verification command or protocol runs the expected checks for a + selected story and lane. +- Stories can store and execute a `verify_command`. +- Trace recording warns when a linked story has a verification command that has + not passed. +- Missing validation evidence is surfaced before a task is marked implemented. + +Required files: + +- H3 files. +- A verification protocol or command reference. +- Validation report examples tied to story proof columns. +- Story verification command documentation. + +Benchmark indicators: + +- Functional score remains stable. +- Harness compliance: at least 90%. +- Fewer false "done" claims in benchmark review. +- Missing proof is detected before merge or final response. + +Current status: + +- Achieved by Phase 5. `scripts/bin/harness-cli story verify ` runs + story-level proof commands, records pass/fail state, `trace --story` warns + before close when verification has not passed, and + `scripts/bin/harness-cli story verify-all` runs all configured story proof + commands in one pass. Proof-column automation remains a future enhancement, + but H4's required automated verification gate is now present. + +Activated responsibilities: + +- Verification. +- Task state. +- Permissions. +- Intervention recording. + +### H5 - Self-Improving Harness + +The harness can use traces, benchmark results, and backlog outcomes to propose +or apply safe improvements to itself. + +Criteria: + +- Repeated friction patterns are summarized into proposed harness changes. +- Proposed changes include predicted impact, risk, validation plan, and rollback + criteria. +- Completed changes compare predicted impact with actual benchmark or trace + outcomes. +- High-risk harness changes pause for human confirmation before changing source + hierarchy, architecture direction, or validation requirements. + +Required files: + +- H4 files. +- Self-improvement protocol. +- Historical improvement reports. +- Backlog outcome reviews. + +Benchmark indicators: + +- Harness compliance remains at least 90% across repeated benchmark runs. +- Trace quality remains at least 2.5/3. +- Improvements show measurable positive deltas or are explicitly reverted. +- Scope creep and validation weakening are caught by policy. + +Current status: + +- Partially achieved by Phase 5. `scripts/bin/harness-cli audit` detects + durable-state drift, `scripts/bin/harness-cli propose` generates structured + improvement proposals from friction, interventions, and audit results, and + `docs/IMPROVEMENT_PROTOCOL.md` defines the review loop. H5 is not fully + achieved until repeated benchmark outcomes prove proposed improvements create + measurable positive deltas or are explicitly reverted. + +Activated responsibilities: + +- Entropy auditing. +- Failure attribution. +- Intervention recording. +- Permissions. + +## Current Assessment + +| Level | Status | Evidence | +| --- | --- | --- | +| H0 | Passed | Harness docs, templates, and durable records exist. | +| H1 | Achieved | `AGENTS.md`, `docs/HARNESS.md`, `docs/FEATURE_INTAKE.md`, `docs/ARCHITECTURE.md`, `docs/templates/*`, and `docs/TEST_MATRIX.md` exist. | +| H2 | Achieved | `scripts/bin/harness-cli`, `scripts/schema/001-init.sql`, durable story records, `docs/HARNESS_COMPONENTS.md`, `docs/HARNESS_MATURITY.md`, `docs/TRACE_SPEC.md`, and `docs/CONTEXT_RULES.md` define the Phase 2 surface. | +| H3 | Partial | Phase 3 adds `scripts/bin/harness-cli score-trace`, enriched friction context, and the backlog outcome loop; Phase 4 auto-scores traces on write. Component-level benchmark attribution remains open. | +| H4 | Achieved | Phase 4 adds story-level `verify_command`, `story verify`, and trace-time verification warnings. Phase 5 adds `story verify-all` for batch story proof. | +| H5 | Partial | Phase 5 adds `audit`, `score-context`, `intervention add/query`, `propose`, `docs/HARNESS_AUDIT.md`, and `docs/IMPROVEMENT_PROTOCOL.md`; repeated benchmark outcome proof remains open. | + +## Responsibility Activation + +| Responsibility | H0 | H1 | H2 | H3 | H4 | H5 | +| --- | --- | --- | --- | --- | --- | --- | +| Task specification | Missing | Covered | Covered | Covered | Covered | Covered | +| Context selection | Missing | Partial | Covered | Covered | Covered | Covered | +| Tool access | Missing | Partial | Partial | Partial | Covered | Covered | +| Project memory | Missing | Covered | Covered | Covered | Covered | Covered | +| Task state | Missing | Partial | Covered | Covered | Covered | Covered | +| Observability | Missing | Missing | Partial | Covered | Covered | Covered | +| Failure attribution | Missing | Missing | Partial | Covered | Covered | Covered | +| Verification | Missing | Partial | Partial | Partial | Covered | Covered | +| Permissions | Missing | Partial | Partial | Partial | Covered | Covered | +| Entropy auditing | Missing | Missing | Partial | Covered | Covered | Covered | +| Intervention recording | Missing | Partial | Partial | Covered | Covered | Covered | + +## Phase 3 Interpretation + +Phase 3 starts the H2 to H3 transition. It claims active trace scoring and a +documented improvement feedback loop, but it does not claim full H3 because +benchmark comparison and component-level regression attribution are explicitly +outside this repository's Phase 3 scope. + +## Phase 4 Interpretation + +Phase 4 starts the H3 to H4 transition. It gives stories the same mechanical +verification pattern that decisions already had, records story verification +results in the durable layer, auto-scores traces when they are written, and +warns before close when a linked story's verification has not passed. It does +not claim full H4 because benchmark execution, batch verification, and automatic +proof-column updates remain separate work. + +## Phase 5 Interpretation + +Phase 5 completes H4 by adding batch story verification and starts H5 by adding +tool discovery, intervention records, context scoring, drift audit, and +deterministic proposal generation. The repository may claim H5 partial only +when those commands and docs are present and validated; it must not claim full +H5 until benchmark runs or trace outcomes prove the proposal loop improves the +harness over time. diff --git a/docs/IMPROVEMENT_PROTOCOL.md b/docs/IMPROVEMENT_PROTOCOL.md new file mode 100644 index 0000000000000000000000000000000000000000..a7f18622b7241ec0ca7a079ba12262b854211a8b --- /dev/null +++ b/docs/IMPROVEMENT_PROTOCOL.md @@ -0,0 +1,57 @@ +# Improvement Protocol + +Phase 5 starts the self-improvement loop: + +```text +friction + interventions + audit findings + -> harness-cli propose + -> proposed backlog item + -> human review + -> implementation with predicted impact + -> close with actual outcome +``` + +## Generate Proposals + +```bash +scripts/bin/harness-cli propose +``` + +The command is rule-based. It looks for: + +- repeated trace friction, +- repeated intervention patterns, +- non-zero audit categories. + +Each proposal includes title, component, evidence, predicted impact, risk, +suggested action, validation plan, and confidence. + +## Commit Proposals + +```bash +scripts/bin/harness-cli propose --commit +``` + +Committed proposals become `proposed` backlog items. Humans review them with: + +```bash +scripts/bin/harness-cli query backlog --open +``` + +## Review Rules + +- Tiny proposals may be implemented directly when they only clarify docs. +- Normal proposals need a story packet or clear backlog acceptance. +- High-risk proposals need a durable decision record before changing source + hierarchy, architecture direction, validation requirements, or risk policy. +- Completed proposal work must close the backlog item with actual outcome + evidence. + +## Validation + +After implementation, compare the predicted impact with: + +- `scripts/bin/harness-cli audit`, +- `scripts/bin/harness-cli query friction`, +- `scripts/bin/harness-cli query interventions`, +- benchmark trace quality and harness compliance when benchmark proof applies. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000000000000000000000000000000000..314edd30aaa2058e2ac01238da6f0e6bb58ab596 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,40 @@ +# CarePath Documentation + +Start every task with `AGENTS.md`. This index directs agents to the smallest +relevant context rather than every document in the repository. + +## Current Product and Work + +- `product/` — accepted contracts for CarePath, Ghi chép bệnh án AI, and Phiên + dịch khám bệnh trực tiếp. +- `ux-redesign-carepath.md` — current Vietnamese-first UX implementation + backlog; required before UX or product-flow implementation. +- `onboarding-ux-fix-tasks.md` — current visual onboarding execution backlog. +- `../UI-FIX-PLAN.md` — current public-site issue plan. +- `deploy.md` — Hugging Face Space deployment instructions. + +## Harness: Read for Daily Work + +- `HARNESS.md` — source hierarchy, task loop, and done definition. +- `FEATURE_INTAKE.md` — lane selection and CarePath hard gates. +- `ARCHITECTURE.md` — module and delivery boundaries. +- `CONTEXT_RULES.md` — minimum context by lane. +- `TEST_MATRIX.md` — behavior-to-proof baseline. +- `TRACE_SPEC.md` — durable completion evidence. +- `stories/`, `decisions/`, and `templates/` — selected work, settled + tradeoffs, and new-work starters. + +## Harness: Read Only When Triggered + +- `TOOL_REGISTRY.md` — registering or using optional external tools. +- `HARNESS_AUDIT.md`, `HARNESS_COMPONENTS.md`, and `HARNESS_MATURITY.md` — + audit, observability, maturity, or benchmark work. +- `HARNESS_BACKLOG.md` and `IMPROVEMENT_PROTOCOL.md` — repeated Harness + friction or process improvements. +- `GLOSSARY.md` — extending shared Harness terminology. + +## Evidence and History + +- `qa-evidence/` — versioned visual QA proof and its retention index. +- `history/` — preserved MVP, demo-site, unification, and review documents. + These are context only; do not reopen completed tickets as current work. diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md new file mode 100644 index 0000000000000000000000000000000000000000..0d0054f564bd7753e75e0e78a16788c36ae07bb3 --- /dev/null +++ b/docs/TEST_MATRIX.md @@ -0,0 +1,17 @@ +# CarePath Test Matrix + +This matrix maps the current CarePath baseline to proof. Status changes only +after the named evidence is actually run and recorded. + +| Story | Contract | Unit | Integration | E2E | Platform | Status | Evidence | +| --- | --- | --- | --- | --- | --- | --- | --- | +| CP-BASE-001 | Unified API and Ghi chép bệnh án AI remain available | yes | yes | no | no | implemented | 96 passed, 1 skipped; mock smoke passed (2026-07-12) | +| CP-BASE-002 | Interpreter remains translate-only and fail-closed | yes | yes | yes | no | implemented | Ruff; 109 passed, 1 skipped; mock eval 50/50; 4 browser tests passed (2026-07-12) | +| CP-BASE-003 | Public CarePath site remains Vietnamese-first and deployable | yes | no | yes | yes | implemented | 45 unit, 5 deploy-env, 7 browser tests; Lighthouse 100/100/100 (2026-07-12) | +| CP-BASE-004 | Interpreter console remains buildable and usable | yes | no | yes | no | implemented | lint; 38 unit; build; 4 browser tests passed (2026-07-12) | +| GEC-001 | Offline GEC training uses ViMedCSS and synthetic pairs without human-labeling tooling | no | yes | no | no | implemented | Ruff; 26 focused GEC tests; root 95 passed, 1 skipped (2026-07-12) | +| HARN-001 | Agent work follows durable CarePath intake and proof rules | no | yes | no | no | implemented | pinned merge install, CLI 0.1.11, init, matrix, audit, trace (2026-07-12) | + +Proof labels mean the relevant layer has evidence for the baseline; they do not +claim every historical behavior was retested. Story packets define the exact +commands for future changes. diff --git a/docs/TOOL_REGISTRY.md b/docs/TOOL_REGISTRY.md new file mode 100644 index 0000000000000000000000000000000000000000..41afd98b9e5bde5237a24045da8439023472e6ba --- /dev/null +++ b/docs/TOOL_REGISTRY.md @@ -0,0 +1,195 @@ +# Tool Registry + +The harness deals with two distinct kinds of "tool". Keep them separate. + +| | Capability manifest (outbound) | Inbound tool registry | +| --- | --- | --- | +| Direction | harness offers it to the agent | a project equips it for the harness to use | +| Examples | the `harness-cli` subcommands below | gitnexus, c3, a linter, a deploy check | +| Presence | always compiled in | optional; may be absent on any machine | +| If missing | n/a (it is the harness) | clean skip; never blocks the main process | + +This document describes both. The **inbound registry** is the extension base: +it is where the harness learns what extra capability is equipped, what purpose +it serves, and whether it is actually present right now, so a workflow step can +adapt to what is installed without the core ever depending on it. + +## Inbound Registry: Register A Tool + +```bash +scripts/bin/harness-cli tool register \ + --name deploy-check \ + --kind cli \ + --capability deploy-verification \ + --command ./scripts/deploy-check.sh \ + --description "Verify deploy health before release" \ + --responsibility Verification \ + --args "env:enum:required:staging,production" +``` + +Fields specific to inbound tools: + +- `--kind` — how the tool is reached and probed. One of `cli`, `binary`, `mcp`, + `skill`, `http`. Defaults to `cli`. The kind tells each agent runtime what it + can orchestrate (a non-Claude agent simply treats a `skill` it cannot run as + absent) and tells `tool check` which probe to use. +- `--capability` — the workflow purpose a step looks the tool up by. Free-text + but normalized to kebab-case, so `Impact Analysis`, `impact_analysis`, and + `impact-analysis` all register as `impact-analysis`. This is the only coupling + between a step and a tool; steps reference the capability, never the tool name. +- `--scan` — for `mcp`/`skill`/`http`, a declarative path or URL that + `tool check` resolves to decide presence (e.g. `.c3`, `~/.claude/skills/c3`, + `https://localhost:8080/health`). `cli`/`binary` are probed via their command. + +`--force` is only needed for `cli`/`binary` whose command is intentionally +absent on the current machine. `mcp`/`skill`/`http` are not on `PATH` by nature, +so they register without `--force`; their presence is resolved later by +`tool check`. + +Registering an MCP server or a Claude skill (examples): + +```bash +scripts/bin/harness-cli tool register --name gitnexus --kind mcp \ + --capability impact-analysis --scan ".gitnexus" --command "mcp:gitnexus" \ + --description "Code-graph blast radius" --responsibility Verification +scripts/bin/harness-cli tool register --name c3 --kind skill \ + --capability impact-analysis --scan ".c3" --command "skill:c3" \ + --description "Component model and drift audit (Claude skill)" \ + --responsibility Verification +``` + +Remove a tool with: + +```bash +scripts/bin/harness-cli tool remove --name deploy-check +``` + +## Inbound Registry: Check Presence + +Registration records intent. `tool check` reconciles intent with reality by +scanning each registered tool and persisting the verdict (`status` and +`checked_at`). Run it at intake start so status reflects current reality. + +```bash +scripts/bin/harness-cli tool check # scan all registered tools +scripts/bin/harness-cli tool check --name c3 # scan one +scripts/bin/harness-cli tool check --json # machine-readable for agents +``` + +Probe per kind: + +| Kind | Probe | `present` means | +| --- | --- | --- | +| `cli`, `binary` | command resolves on `PATH` or as a path | installed and runnable | +| `mcp`, `skill` | `scan_target` path resolves (`~` expands) | equipped/configured on disk | +| `http` | `scan_target` reachable over TCP (2s), else path | endpoint answers | + +`tool check` always exits `0`: a missing extension is a fact to report, not a +CLI failure. A `cli`/`binary` is `present` when runnable. An `mcp`/`skill`/`http` +`present` means **equipped** (config/file resolves), not **live this session** — +the agent still confirms live usability at call time, since only the agent +runtime can see whether its MCP server is actually connected. With no +`scan_target`, the status is `unknown` and the agent must confirm. + +## Inbound Registry: Look Up By Capability + +A workflow step asks "what is present for this purpose?" rather than naming a +tool: + +```bash +scripts/bin/harness-cli query tools --capability impact-analysis +scripts/bin/harness-cli query tools --capability impact-analysis --status present +``` + +The result is the set of providers. Multiple tools may provide one capability +(gitnexus and c3 both serve `impact-analysis` and are complementary), so a step +reads the set and degrades on how much of it is present. + +### Degrade Ladder + +The CLI reports facts (`status`); the agent applies policy. The generic rule, +keyed on the present-provider count for a capability: + +| Providers present | Posture | Agent behavior | +| --- | --- | --- | +| none registered | Inactive | clean skip; note `capability X: inactive` in the trace. Not drift. | +| registered but none/some present | Degraded | run with what resolves; set the `Weak proof` flag; note the gap. | +| all present | Full | normal operation. | + +A registered tool that scans as `missing` is a failed validity gate, not a skip. +A capability with no registered providers is simply inactive and is skipped +without penalty — this is what keeps the core seamless on a fresh install. + +### Recommended Capability Vocabulary + +Capability is open (no code change to add one), but a step and its providers +must agree on the exact string. Reuse these where they fit before coining a new +one; coin new ones in kebab-case: + +``` +impact-analysis · deploy-verification · coverage · security-scan +performance-benchmark · documentation-lookup +``` + +## Inspecting The Registry + +```bash +scripts/bin/harness-cli query tools --summary +scripts/bin/harness-cli query tools --json +scripts/bin/harness-cli query tools --responsibility Verification +``` + +JSON records carry `kind`, `capability`, `scan_target`, `status`, and +`checked_at` alongside the existing fields, so any agent can read the registry +without parsing the human table. + +## Compiled Harness Commands (Outbound Manifest) + +| Command | Responsibility | Purpose | Arguments | +| --- | --- | --- | --- | +| `init` | Task state | Create the harness database. | none | +| `migrate` | Task state | Apply pending schema migrations. | none | +| `import brownfield` | Project memory | Seed durable records from markdown state. | none | +| `intake` | Task specification | Record a feature intake classification. | `--type`, `--summary`, `--lane` | +| `story add` | Task state | Create a durable story record. | `--id`, `--title`, `--lane`, optional `--verify` | +| `story update` | Task state | Update story status, proof flags, evidence, or verification command. | `--id`, optional proof/status fields | +| `story verify` | Verification | Run one story `verify_command` and record pass/fail. | story id | +| `story verify-all` | Verification | Run all configured story verification commands and skip stories without one. | none | +| `decision add` | Project memory | Create a durable decision record. | `--id`, `--title`, optional `--doc`, `--verify` | +| `decision verify` | Verification | Run one decision verification command. | decision id | +| `backlog add` | Entropy auditing | Record a harness improvement proposal. | `--title`, optional pain/suggestion/risk/predicted fields | +| `backlog close` | Entropy auditing | Close a backlog item with outcome evidence. | `--id`, optional `--status`, `--outcome` | +| `tool register` | Tool access | Register an external project tool. | `--name`, `--command`, `--description`, `--responsibility`, optional `--kind`, `--capability`, `--scan`, `--args`, `--force` | +| `tool check` | Tool access | Scan registered tools and persist present/missing/unknown status. | optional `--name`, `--json` | +| `tool remove` | Tool access | Remove a registered external tool. | `--name` | +| `intervention add` | Intervention recording | Record a human, reviewer, CI, or agent intervention. | `--type`, `--description`, `--source`, optional `--trace`, `--story`, `--impact` | +| `trace` | Observability | Record an agent execution trace and print trace quality. | `--summary`, optional trace fields | +| `score-trace` | Observability | Score trace detail against lane requirements. | optional `--id` | +| `score-context` | Context selection | Score trace reads against compiled context rules. | trace id | +| `audit` | Entropy auditing | Run drift checks and compute entropy score. | none | +| `propose` | Entropy auditing | Generate improvement proposals from friction, interventions, and audit findings. | optional `--commit` | +| `query matrix` | Task state | Show durable story proof matrix. | optional `--numeric` | +| `query backlog` | Entropy auditing | Show harness improvement backlog. | optional `--open`, `--closed` | +| `query decisions` | Project memory | Show durable decision records. | none | +| `query intakes` | Task specification | Show recent intake records. | none | +| `query traces` | Observability | Show recent trace records. | none | +| `query friction` | Failure attribution | Show traces with harness friction. | none | +| `query tools` | Tool access | Show compiled and registered tool entries. | optional `--json`, `--summary`, `--responsibility`, `--capability`, `--status` | +| `query interventions` | Intervention recording | Show intervention records. | optional `--trace`, `--story`, `--type` | +| `query stats` | Task state | Show durable record counts. | none | +| `query sql` | Tool access | Run arbitrary SQL against `harness.db`. | SQL text | +| `db changeset apply` | Task state | Apply one semantic changeset idempotently. | changeset path | +| `db rebuild` | Task state | Rebuild a fresh `harness.db` from semantic changesets. | `--from` changeset directory | + +## Validation Rules + +- Tool names must be unique among registered tools. +- Descriptions must be 10-200 characters. +- Responsibilities must match the Runtime Substrate responsibility list. +- `--kind` must be one of `cli`, `binary`, `mcp`, `skill`, `http`. +- `--capability` must be kebab-case (lowercase letters, digits, single hyphens); + spaces and underscores are normalized to hyphens. +- `--args` entries must use `name:type:required` or + `name:type:required:help`, with `required` or `optional` as the third field. +- For `cli`/`binary`, the command must exist as a path or on `PATH`, unless + `--force` is supplied. `mcp`/`skill`/`http` skip this check. diff --git a/docs/TRACE_SPEC.md b/docs/TRACE_SPEC.md new file mode 100644 index 0000000000000000000000000000000000000000..130614edb7f20493b0a429d1be0c1bf97f29e143 --- /dev/null +++ b/docs/TRACE_SPEC.md @@ -0,0 +1,204 @@ +# Trace Specification + +The `trace` table records what happened during a Harness task. This document +defines the expected depth and format for each field so traces are useful for +review, benchmark scoring, failure attribution, and future harness evolution. + +The current schema lives in `scripts/schema/001-init.sql` under the `trace` +table. The schema is not changed by Phase 2. + +## Field Reference + +| Field | Type | Required | Format | Example | +| --- | --- | --- | --- | --- | +| `id` | INTEGER | Automatic | SQLite autoincrement primary key. Do not set manually. | `42` | +| `created_at` | TEXT | Automatic | SQLite `datetime('now')`. Do not set manually. | `2026-05-27 09:24:37` | +| `task_summary` | TEXT | Yes | One sentence, at least 10 characters, naming the outcome or attempted outcome. | `Completed Phase 2 docs-only observability and taxonomy specification` | +| `intake_id` | INTEGER | Standard+ when an intake was recorded | Integer id from the related `intake` row. | `36` | +| `story_id` | TEXT | Standard+ when work maps to one story | Story id from the `story` table. Use the main story when one trace covers several; list the rest in `notes`. | `US-004` | +| `agent` | TEXT | Optional for minimal; Standard+ expected | Short agent/tool name. | `codex` | +| `actions_taken` | TEXT | Standard+ | JSON array text. With the current CLI, pass a comma-separated list and the CLI stores JSON text. | `["read PHASE2.md","drafted TRACE_SPEC.md","updated HARNESS.md"]` | +| `files_read` | TEXT | Standard+ | JSON array text of paths or command names. With the current CLI, pass a comma-separated list. | `["PHASE2.md","docs/HARNESS.md","scripts/bin/harness-cli query matrix"]` | +| `files_changed` | TEXT | Standard+ | JSON array text of changed file paths. With the current CLI, pass a comma-separated list; omit only when no files changed. | `["docs/TRACE_SPEC.md","docs/HARNESS.md"]` | +| `decisions_made` | TEXT | Detailed | JSON array text of decision strings. Include scope decisions, validation choices, and explicit non-goals. | `["Kept Phase 2 docs-only; installer propagation remains out of scope"]` | +| `errors` | TEXT | Standard+ if errors occurred; Detailed always | JSON array text of error or blocker strings. Until the CLI supports empty arrays directly, use `none` when a detailed trace needs explicit no-error evidence. | `["git diff --check failed before whitespace fix"]` | +| `outcome` | TEXT | Yes before final response | One of `completed`, `blocked`, `partial`, or `failed`. | `completed` | +| `duration_seconds` | INTEGER | Detailed when available | Positive integer estimate or measured duration. Leave null if unknown. | `1800` | +| `token_estimate` | INTEGER | Detailed when available | Positive integer estimate. Leave null if unknown. | `24000` | +| `harness_friction` | TEXT | Standard+ when friction exists; Detailed always | Free text naming what was hard, missing, ambiguous, or repeated. Use `none` only when the agent actively checked and found no friction. | `New Phase 2 docs are not in installer copy list; recorded as out-of-scope follow-up.` | +| `notes` | TEXT | Optional | Free text for review context that does not fit other fields. | `Trace covers US-003, US-004, US-005, and US-006.` | + +## Quality Tiers + +### Minimal (score: 1) + +Minimum fields: + +- `task_summary` is filled and at least 10 characters. +- `outcome` is filled before the final response. + +Acceptable for: + +- Tiny-lane tasks with no file changes or only low-risk copy/doc edits. + +Not acceptable for: + +- Normal or high-risk work. +- Any work that discovered friction, errors, or a missing validation path. + +### Standard (score: 2) + +Minimum fields: + +- All Minimal fields. +- `intake_id` when an intake was recorded. +- `story_id` when the work maps cleanly to one story. +- `agent`. +- `actions_taken` as JSON array text. +- `files_read` as JSON array text. +- `files_changed` as JSON array text. +- At least one of `errors` or `harness_friction`. + +Required for: + +- Normal-lane tasks. +- Tiny tasks that changed Harness instructions, validation expectations, or + durable records. + +Standard traces may leave `duration_seconds`, `token_estimate`, and +`decisions_made` empty when those details are not useful. + +### Detailed (score: 3) + +Minimum fields: + +- All Standard fields. +- `decisions_made` as JSON array text. +- `errors` as JSON array text, using `none` with the current CLI when no + errors occurred. +- `harness_friction`, using `none` only after checking for friction. +- `duration_seconds` or a note explaining why duration is unavailable. +- `token_estimate` or a note explaining why token estimate is unavailable. +- `notes` when one trace covers multiple stories, multiple risk flags, or + skipped validation. + +Required for: + +- High-risk tasks. +- Changes touching architecture direction, source-of-truth hierarchy, + validation requirements, auth, authorization, data loss, audit/security, or + external provider behavior. +- Benchmark or release work where later review needs precise proof. + +For high-risk work, `decisions_made` in the trace summarizes what was decided. +It does not replace a durable decision record. If the work changes behavior, +architecture, authorization, data ownership, API shape, or validation +requirements, add a `docs/decisions/NNNN-*.md` file and record it with +`scripts/bin/harness-cli decision add`. + +## Lane Mapping + +| Lane | Expected Tier | Minimum Trace Behavior | +| --- | --- | --- | +| Tiny | Minimal | Record summary and outcome; use Standard if friction or Harness docs changed. | +| Normal | Standard | Record intake, actions, files read, files changed, outcome, and friction/errors. | +| High-risk | Detailed | Record all fields or explicitly explain unavailable duration/token estimates. | + +## Friction Capture Protocol + +Populate `harness_friction` when any of these occur: + +- The agent had to infer a missing rule or source of truth. +- Required validation was unclear, unavailable, or too expensive to run. +- A document, durable record, or story packet was stale or contradictory. +- The task revealed a repeated manual step that should become a template, + command, or checklist. +- A requested change was out of scope but likely important later. +- A benchmark or review failure could not be attributed to a component. + +How to write friction: + +- Name the concrete pain, not a vague mood. +- Include the missing capability or contradiction. +- If the friction should become work, also add or update a backlog item with + `scripts/bin/harness-cli backlog add`. +- If there was no friction, use `none` only for Detailed traces. + +Good friction: + +```text +New Phase 2 docs are not copied by scripts/install-harness.sh, but installer +propagation is out of scope for docs-only Phase 2. +``` + +Weak friction: + +```text +docs confusing +``` + +## Examples + +### Good Trace (Detailed) + +```bash +scripts/bin/harness-cli trace \ + --summary "Completed high-risk auth role migration with audit proof" \ + --intake 51 \ + --story US-014 \ + --agent codex \ + --outcome completed \ + --duration 4200 \ + --tokens 52000 \ + --actions "read access-control docs,created migration,updated audit tests,ran integration suite" \ + --read "docs/product/permissions.md,docs/decisions/0008-auth-boundary.md,src/auth/roles.ts" \ + --changed "src/auth/roles.ts,src/audit/events.ts,tests/auth-roles.test.ts" \ + --decisions "kept manager role scoped to workspace,recorded audit event on every role change" \ + --errors "none" \ + --friction "Existing permission docs did not define delegated admin; added backlog item for role glossary." \ + --notes "Detailed trace required because the task touched authorization and audit behavior." +``` + +### Adequate Trace (Standard) + +```bash +scripts/bin/harness-cli trace \ + --summary "Added Phase 2 trace specification and Harness reference" \ + --intake 36 \ + --story US-004 \ + --agent codex \ + --outcome completed \ + --actions "read PHASE2.md,drafted TRACE_SPEC.md,updated HARNESS.md,ran rg checks" \ + --read "PHASE2.md,docs/HARNESS.md,scripts/schema/001-init.sql" \ + --changed "docs/TRACE_SPEC.md,docs/HARNESS.md" \ + --friction "none" +``` + +### Insufficient Trace + +```bash +scripts/bin/harness-cli trace \ + --summary "did phase 2" \ + --outcome completed +``` + +Why this is insufficient for normal-lane Phase 2 work: + +- It does not identify actions. +- It does not list files read or changed. +- It does not connect to intake or stories. +- It gives no friction or error signal. + +## Review Checklist + +Before the final response, check: + +- The trace tier matches the lane. +- Review the score printed automatically by `scripts/bin/harness-cli trace`. + Use `scripts/bin/harness-cli score-trace --id N` when re-checking a specific + historical trace. +- `files_changed` matches the actual changed-file set at a useful level. +- `errors` names real blockers or is `none` for Detailed traces when the + current CLI is used. +- `harness_friction` either names a concrete issue or is intentionally `none`. +- Any friction that should become future work is recorded in the backlog. diff --git a/docs/decisions/0001-harness-first-development.md b/docs/decisions/0001-harness-first-development.md new file mode 100644 index 0000000000000000000000000000000000000000..1b65cd66c79cedd6f37ace7d4e76473371691c49 --- /dev/null +++ b/docs/decisions/0001-harness-first-development.md @@ -0,0 +1,48 @@ +# 0001 Harness-First Development + +Date: 2026-05-05 + +## Status + +Accepted + +## Context + +The repository currently contains a product README and a large product +specification. There is no application implementation yet. + +The project will likely involve human direction plus agent implementation over +many evolving stories. A single massive specification is not enough for safe +agent work because it becomes hard to locate current truth, risk, proof, and +change history. + +## Decision + +Create Harness v0 before scaffolding product code. + +Harness v0 defines: + +- Agent entrypoint. +- Product doc split. +- Feature intake and risk lanes. +- Story packet templates. +- Decision records. +- Test matrix. +- Harness backlog. + +No application code, fake scripts, CI, or tests are created in this decision. + +## Consequences + +Positive: + +- Agents have a clear operating model before implementation starts. +- Product truth can split away from the massive spec. +- Risky work has a slower lane before code changes. +- Harness growth becomes part of the work. + +Tradeoffs: + +- Some docs are placeholders until real stories exercise them. +- Validation commands are only contracts until implementation begins. +- The harness must stay small enough to revise from real friction. diff --git a/docs/decisions/0002-post-spec-product-lifecycle.md b/docs/decisions/0002-post-spec-product-lifecycle.md new file mode 100644 index 0000000000000000000000000000000000000000..4e7d109d87682d47b470dfc4f6ffb2fb12d91552 --- /dev/null +++ b/docs/decisions/0002-post-spec-product-lifecycle.md @@ -0,0 +1,54 @@ +# 0002 Seed Specification Product Lifecycle + +Date: 2026-05-05 + +## Status + +Superseded by `0003-generic-spec-intake-harness.md` + +## Context + +Harness v0 originally assumed the repository would include one seed +specification file for the first product. This decision explained how agents +should decompose that initial specification into product docs, story packets, +implementation, and validation proof, then continue working after the seed was +exhausted. + +That approach fit a single project but made the harness less reusable. + +## Decision + +Treat the initial specification as a seed and historical snapshot, not the +permanent living product plan. + +After the initial specification has been exhausted, new work should enter +through the same harness loop as one of these input types: + +- Change request. +- New initiative. +- Maintenance request. +- Harness improvement. + +Product docs under `docs/product/`, story packets under `docs/stories/`, +validation evidence in `docs/TEST_MATRIX.md`, and decision records under +`docs/decisions/` become the living operating surface. + +Large future product areas should be captured as scoped initiative notes instead +of appended to the seed specification or rewritten as a second monolithic spec. + +## Consequences + +Positive: + +- The original specification remains stable as historical context. +- Product truth moves into smaller, current, maintainable files. +- Future work keeps using the same intake, story, proof, and harness-growth + loop. +- Large ideas can still be planned without creating another oversized spec. + +Tradeoffs: + +- The repository will eventually need an initiative template if large new + product areas become common. +- Agents must be careful to update product docs and tests rather than relying on + the seed specification after initial buildout. diff --git a/docs/decisions/0003-generic-spec-intake-harness.md b/docs/decisions/0003-generic-spec-intake-harness.md new file mode 100644 index 0000000000000000000000000000000000000000..2d29f123f05c6a55d0816ef400b835ea01d9d5eb --- /dev/null +++ b/docs/decisions/0003-generic-spec-intake-harness.md @@ -0,0 +1,58 @@ +# 0003 Generic Spec Intake Harness + +Date: 2026-05-05 + +## Status + +Accepted + +## Context + +Harness v0 originally shipped with a project-specific `SPEC.md`, product docs, +candidate epics, architecture assumptions, and validation examples. That made +the harness useful for the first project but too specific to reuse as the outer +shell for a new project. + +The desired direction is a default harness that can wait for any user-provided +spec, derive product docs from that spec, and then continue with the same +intake, story, proof, and decision loop. + +## Decision + +Remove the tracked project-specific spec and pre-sliced product domains from +Harness v0. + +The harness now starts with: + +- No baked-in `SPEC.md`. +- Empty product docs except for intake guidance. +- Generic story and epic examples. +- Stack-neutral architecture discovery rules. +- Stack-neutral validation columns. +- A source hierarchy that treats a future user-provided spec as input material, + not permanent living truth. + +## Alternatives Considered + +1. Keep the original `SPEC.md` as an example. Rejected because examples can be + mistaken for current product truth. +2. Move the original product docs into an examples folder. Rejected for now + because the user asked for a clean default harness. + +## Consequences + +Positive: + +- The repository is easier to reuse for any new project. +- Future specs can define their own product domains and stack. +- Agents are less likely to confuse template truth with product truth. + +Tradeoffs: + +- The harness has fewer concrete examples until the next spec is supplied. +- The first spec intake must create product docs and candidate epics before + implementation planning can be precise. + +## Follow-Up + +- Add a spec-intake template if repeated projects reveal a stable format. diff --git a/docs/decisions/0004-sqlite-durable-layer.md b/docs/decisions/0004-sqlite-durable-layer.md new file mode 100644 index 0000000000000000000000000000000000000000..4e3b4655478fa6d3c4ee7baa490bc32df5ec7ebb --- /dev/null +++ b/docs/decisions/0004-sqlite-durable-layer.md @@ -0,0 +1,75 @@ +# 0004 SQLite Durable Layer + +Date: 2026-05-22 + +## Status + +Accepted + +## Context + +Harness v0 stores all operational data in markdown files: `TEST_MATRIX.md` rows, +`HARNESS_BACKLOG.md` items, decision records, and story status. This works for +human reading but creates friction for agents: + +- Editing markdown tables is error-prone and hard to validate. +- There is no structured way to query past intakes, traces, or friction reports. +- The harness has no observability foundation for future evolution. + +Recent research on harness engineering (arXiv:2604.25850, arXiv:2605.13357, +arXiv:2603.28052) identifies observability and structured traces as the +foundation for harness improvement. All three approaches require queryable +operational data, not prose documents. + +## Decision + +Add a SQLite database (`harness.db`) and a thin CLI (`scripts/bin/harness-cli`) as the +durable layer for operational harness data. + +The database stores: + +- **Intake records**: classification of incoming work. +- **Stories**: work packets and their validation proof status (replaces manual + `TEST_MATRIX.md` rows). +- **Decisions**: durable records with optional verification commands. +- **Backlog items**: harness improvement proposals with predicted and actual + impact. +- **Traces**: agent execution records including actions, files, errors, outcome, + and harness friction. + +The schema is version-controlled under `scripts/schema/`. The database file is +`.gitignore`d because each project instance generates its own operational data. + +Policy docs (`HARNESS.md`, `FEATURE_INTAKE.md`, `ARCHITECTURE.md`) remain as +human-readable references. The database stores what agents produce, not what +they should do. + +## Alternatives Considered + +1. Keep everything in markdown. Rejected because it prevents structured queries, + makes observability impossible, and forces agents to edit fragile tables. +2. Use JSON files. Rejected because concurrent writes are unsafe and queries + require custom tooling. +3. Use a full database server. Rejected because it adds deployment complexity + that does not match Harness v0 scope. + +## Consequences + +Positive: + +- Agents record structured data instead of editing markdown tables. +- Intake, story, decision, backlog, and trace data is queryable. +- The harness has an observability foundation for future evolution. +- Schema migrations enable the durable layer to grow with the harness. + +Tradeoffs: + +- Requires `sqlite3` to be available in the environment. +- The database is not version-controlled, so each instance starts empty. +- Markdown docs and the database may drift if agents use one but not the other. + +## Follow-Up + +- Seed existing decisions (0001-0003) into the database during init. +- Add context engineering rules keyed by task type and risk lane. +- Add harness maturity ladder (H0-H4) once the durable layer proves useful. diff --git a/docs/decisions/0005-prebuilt-rust-harness-cli.md b/docs/decisions/0005-prebuilt-rust-harness-cli.md new file mode 100644 index 0000000000000000000000000000000000000000..1214bd46c28e254e9e327369ef8a6ceeb8b82b80 --- /dev/null +++ b/docs/decisions/0005-prebuilt-rust-harness-cli.md @@ -0,0 +1,91 @@ +# 0005 Prebuilt Rust Harness CLI + +Date: 2026-05-23 + +## Status + +Accepted, amended 2026-05-31, amended 2026-06-09 + +## Context + +The durable layer started as a thin shell wrapper around SQLite. That wrapper +is now large enough to carry meaningful architecture risk: it mixes command +parsing, SQL construction, migrations, import behavior, query rendering, and +help text in one script. + +The previous installer copied a shell wrapper into target repositories. That +kept Harness easy to install, but it also meant a Rust rewrite was not only an +implementation change. It changed the distribution contract for every project +that receives Harness. + +## Decision + +The future Rust implementation of the Harness CLI should be shipped as a +prebuilt binary downloaded by the installer. + +The command path for users and agents is the installed Rust binary: + +```bash +scripts/bin/harness-cli +``` + +On Windows, the repository-local binary is installed as: + +```powershell +.\scripts\bin\harness-cli.exe +``` + +The installer should download, verify, and install the platform-specific Rust +binary directly at that path. There should be no shell wrapper command contract. + +The Rust CLI should follow the existing architecture rules: + +- Domain: harness records, statuses, lanes, and value types. +- Application: use cases for intake, stories, decisions, backlog, traces, and + queries. +- Infrastructure: SQLite repositories and schema migrations. +- Interface: command-line parsing, terminal output, and installer integration. + +Release automation now follows the same distribution contract. After a PR is +merged to `main`, the post-merge maintenance workflow updates `CHANGELOG.md`. +When the merged PR changed the Rust CLI source, schema, Cargo metadata, or CLI +release packaging, it also bumps the CLI patch version, updates the installer +release tag pin, creates a `harness-cli-v*` tag, and invokes the reusable +Harness CLI release workflow for that tag. + +## Alternatives Considered + +1. Keep the shell CLI permanently. Rejected because the script has crossed from + a thin wrapper into a growing application surface with weak testability. +2. Copy Rust source into every target project and build locally. Rejected + because it makes Harness installation depend on a local Rust toolchain and + increases setup friction for projects that only need the harness. +3. Require users to install a global `harness` binary separately. Rejected + because Harness should remain repository-local for agents. +4. Download a prebuilt binary through the installer. Accepted because it keeps + target repos simple while allowing the CLI internals to become typed, + testable, and platform-aware. + +## Consequences + +Positive: + +- The durable-layer CLI can move to typed command parsing and tested use cases. +- Target projects do not need a Rust toolchain just to use Harness. +- The `scripts/bin/harness-cli` command is the stable entrypoint for agents on + macOS/Linux; Windows uses the same repo-local path with the `.exe` suffix. +- Prebuilt releases can include a known SQLite linkage strategy. + +Tradeoffs: + +- The installer must learn platform detection and binary download behavior. +- Release artifacts need checksums or another integrity check. +- Unsupported platforms need a clear error path. +- The project needs a repeatable release process for supported platforms. + +## Follow-Up + +- Implement the migration through `US-002 Rust Harness CLI`. +- Remove the old shell wrapper from installed project payloads. +- Add checksum verification for downloaded binaries. +- Treat the Rust CLI as the primary durable-layer implementation. diff --git a/docs/decisions/0006-phase-4-benchmark-triage.md b/docs/decisions/0006-phase-4-benchmark-triage.md new file mode 100644 index 0000000000000000000000000000000000000000..142f3833e3758d89d79e22ea975685d49b2e9e81 --- /dev/null +++ b/docs/decisions/0006-phase-4-benchmark-triage.md @@ -0,0 +1,54 @@ +# 0006 Phase 4 Benchmark Triage + +Date: 2026-05-31 + +## Status + +Accepted + +## Context + +The first Phase 4 benchmark re-run found that T4 authentication failed +`decision_recorded` even though the trace included decisions text. The same run +also showed command churn from agents trying `story update` proof flags with +`yes` and `no`, and trying to use `story verify` as if it accepted proof flags. + +## Decision + +Harness instructions and CLI help must distinguish durable records from trace +evidence and must show the current Rust CLI command shape at the point agents +need it: + +- High-risk behavior changes require a markdown decision under + `docs/decisions/` and a durable `decision` row. +- Trace `--decisions` is evidence for trace quality, not the decision log. +- `story update` proof flags use `1` and `0`. +- `story verify ` only runs the configured `verify_command`; proof flags + stay on `story update`. + +## Alternatives Considered + +1. Rely on trace auto-scoring to catch the missing T4 decision. Rejected because + trace scoring can confirm detailed trace content but cannot prove a durable + decision record exists. +2. Change the CLI to accept `yes` and `no`. Deferred because v0.1.5 already has + a numeric command contract and the immediate benchmark issue is stale + guidance, not missing parser capability. + +## Consequences + +Positive: + +- High-risk agents get explicit decision-log instructions before closing work. +- Command examples align with the Rust CLI v0.1.5 parser. +- `story verify` and `story update` have separate mental models in docs. + +Tradeoffs: + +- Docs now duplicate a few command examples so the common path is visible + without repeated help discovery. + +## Follow-Up + +- Re-run the Phase 4 benchmark and check whether T4 records a durable decision. +- Watch for remaining command churn around `story update` and `story verify`. diff --git a/docs/decisions/0007-improvement-proposal-rules.md b/docs/decisions/0007-improvement-proposal-rules.md new file mode 100644 index 0000000000000000000000000000000000000000..bd010cb3b0c565d34a913cb2de50928d6e33d84e --- /dev/null +++ b/docs/decisions/0007-improvement-proposal-rules.md @@ -0,0 +1,60 @@ +# 0007 Improvement Proposal Rules + +Date: 2026-06-04 + +## Status + +Accepted + +## Context + +Phase 5 adds `harness-cli propose`, which changes the harness evolution model. +The command must be useful without becoming an unchecked source of scope creep +or circular recommendations. + +## Decision + +Improvement proposals are advisory, rule-based, and evidence-backed. The command +may summarize repeated friction, repeated interventions, and audit drift. It may +create `proposed` backlog items only when `--commit` is supplied. + +Every proposal must include: + +- affected Harness component, +- concrete evidence, +- predicted impact, +- risk lane, +- suggested action, +- validation plan, +- confidence level. + +High-risk proposal implementation still requires human review and a durable +decision record when it changes source hierarchy, architecture direction, +validation requirements, or risk policy. + +## Alternatives Considered + +1. Generate free-form LLM recommendations. Rejected because Phase 5 needs a + deterministic and auditable evolution role. +2. Automatically apply proposed changes. Rejected because the harness must not + rewrite its own policy without review. +3. Only report audit findings. Rejected because H5 requires proposed + improvements, not just drift detection. + +## Consequences + +Positive: + +- Repeated operational patterns can become backlog items. +- Proposal output is explainable and testable. +- Human review remains the gate for risky harness changes. + +Tradeoffs: + +- Rule-based grouping can miss semantically similar phrasing. +- Audit-based proposals may be housekeeping rather than strategic evolution. + +## Follow-Up + +- Use benchmark runs and closed backlog outcomes to improve proposal quality in + later phases. diff --git a/docs/decisions/0008-carepath-harness-adoption.md b/docs/decisions/0008-carepath-harness-adoption.md new file mode 100644 index 0000000000000000000000000000000000000000..c3735752ddd65ec23086f8fdbae9c42e2c90585a --- /dev/null +++ b/docs/decisions/0008-carepath-harness-adoption.md @@ -0,0 +1,44 @@ +# 0008 CarePath Harness Adoption + +Date: 2026-07-12 + +## Status + +Accepted + +## Context + +CarePath has two clinical workflows, strict safety invariants, multiple test +surfaces, and historical plans. Future coding agents need a durable way to +classify work, preserve those boundaries, and record actual proof. + +## Decision + +Adopt Repository Harness from upstream commit `14e6f102a4a645562d046f7c693c61401261cac6` +with its pinned, checksum-verified Windows CLI v0.1.11. Keep `AGENTS.md` as the +highest repository-level safety and UX instruction, tailor the Harness to the +current CarePath contracts, and load the same instructions from `CLAUDE.md`. + +## Alternatives Considered + +1. Keep only historical plans and ad-hoc validation notes. +2. Adopt a documentation-only workflow without durable local records. +3. Replace the existing CarePath instructions with the generic upstream shim. + +## Consequences + +Positive: + +- New work has explicit risk lanes, proof expectations, and durable traces. +- Codex and Claude Code share the same project contract. +- The Harness introduces no product dependencies or CI changes. + +Tradeoffs: + +- Agents must perform a small intake and trace step for future work. +- `harness.db` is local-only, so durable operational records are per clone. + +## Follow-Up + +- Upgrade the Harness only as a separately reviewed maintenance task. +- Register optional tools only when a recurring validation need proves one. diff --git a/docs/decisions/0009-restructure-target-layout.md b/docs/decisions/0009-restructure-target-layout.md new file mode 100644 index 0000000000000000000000000000000000000000..d912d8fff5679ec9a7cf4dbc112548e8e7315af5 --- /dev/null +++ b/docs/decisions/0009-restructure-target-layout.md @@ -0,0 +1,56 @@ +# 0009 CarePath Restructure Target Layout + +Date: 2026-07-13 + +## Status + +Superseded in part by 0016 + +> Location update (2026-07-13): the approved Scribe training boundary now +> resides at `scribe/training/`. The remaining Interpreter and shared-layout +> decisions in this record remain accepted. + +## Context + +CarePath serves two independent clinical workflows from one process, while the +repository currently places their runtime, frontends, tests, evaluation, and +training assets in mixed top-level locations. The approved restructure requires +a durable target layout without changing import names, routes, or safety +behavior. + +## Decision + +Move runtime ownership into `scribe/` and `interpreter/`, and reserve `shared/` +for the later shared package. Decision 0016 supersedes this record's original +top-level GEC-training location. Keep the import names `carepath` and `app`, +keep all public routes unchanged, and retain the root `pyproject.toml` as the +Scribe distribution. Move the interpreter evaluation harness with +`interpreter/`; it is not GEC training. + +Each relocation phase must update path math, packaging, CI, Docker, deployment +documentation, and the affected product contracts, then pass its relevant +existing proof before the next phase begins. + +## Alternatives Considered + +1. Leave the current mixed top-level layout. +2. Rename both runtime imports to match their new directory names. +3. Create separate Python distributions before the relocations. + +## Consequences + +Positive: + +- Runtime and training ownership are visible from the repository layout. +- Existing imports and public API routes remain stable. +- The risk-evaluation suite stays with the Interpreter that it validates. + +Tradeoffs: + +- Relative path calculations and deployment files must be audited after every move. +- The Vercel project root setting requires owner action when `scribe/frontend/` moves. + +## Follow-Up + +- Record a separate high-risk Harness intake and trace for every phase. +- Do not begin owner-led clinical-data collection without legal consent and data-handling approval. diff --git a/docs/decisions/0010-shared-normalization-contract.md b/docs/decisions/0010-shared-normalization-contract.md new file mode 100644 index 0000000000000000000000000000000000000000..950459e0fb6b3d43b4e46ebf3fd709f1b7d69de5 --- /dev/null +++ b/docs/decisions/0010-shared-normalization-contract.md @@ -0,0 +1,31 @@ +# 0010 Shared Normalization Contract + +Date: 2026-07-13 + +## Status + +Accepted + +## Context + +Interpreter, Scribe scoring, training scoring, and both lexical retrievers +carried local normalization implementations. Their names overlapped, but their +contracts differed: the Interpreter normalizes units, Vietnamese number words, +and relative dates; scoring compares case-insensitively without semantic +conversion; lexical retrieval additionally folds diacritics and punctuation. + +## Decision + +Create the installable `shared/carepath_shared` package. Move the Interpreter +normalizer there unchanged as `normalize_text`, and make every former local +normalizer a direct import from this package. Keep the existing scoring and +retrieval semantics as the separately named `normalize_for_metrics` and +`normalize_for_match` functions, rather than silently changing metric scores or +term matching. + +## Consequences + +- Normalization algorithms have one owner and one characterization suite. +- Existing module import paths remain compatible. +- A future semantic consolidation must explicitly revise the characterization + suite and evaluation baseline; it is not part of this restructure. diff --git a/docs/decisions/0011-canonical-medical-term-source.md b/docs/decisions/0011-canonical-medical-term-source.md new file mode 100644 index 0000000000000000000000000000000000000000..11443d809a4278e4987b20947512d3ac6593eb0b --- /dev/null +++ b/docs/decisions/0011-canonical-medical-term-source.md @@ -0,0 +1,30 @@ +# 0011 Canonical Medical-Term Source + +Date: 2026-07-13 + +## Status + +Accepted + +## Context + +Scribe retrieval reads `data/medical_lexicon.json`; Interpreter safety glossary +seeding reads `interpreter/app/glossary/data/seed_glossary.csv`. Both are +clinician-editable medical terminology, but their serving schemas differ. + +## Decision + +`shared/carepath_shared/terms/medical_terms.json` is the only authored source. +Each row defines Vietnamese and English terms, kind, aliases, risk flags, and +target-specific rendering metadata. `scripts/build_term_artifacts.py` emits +both existing serving artifacts without changing either runtime reader. + +Interpreter risk lexicons remain independent safety data and are explicitly +out of scope for this source. + +## Consequences + +- Serving paths and schemas stay stable. +- CI regenerates artifacts and rejects source/artifact drift. +- Any taxonomy or risk-flag meaning change now requires this decision's + characterization and safety proof to be revisited. diff --git a/docs/decisions/0012-interpreter-runtime-hardening.md b/docs/decisions/0012-interpreter-runtime-hardening.md new file mode 100644 index 0000000000000000000000000000000000000000..4a9a0e6b8bc262283952574c1158bc16478ac914 --- /dev/null +++ b/docs/decisions/0012-interpreter-runtime-hardening.md @@ -0,0 +1,29 @@ +# 0012 Interpreter Runtime Hardening + +Date: 2026-07-13 + +## Status + +Accepted + +## Context + +The Interpreter compared admin tokens directly, purged retained sessions only +at startup, and hard-coded development CORS origins. The combined FastAPI +service duplicated the standalone Interpreter startup path. + +## Decision + +Use `hmac.compare_digest` for the admin token. Give the Interpreter one shared +lifespan that seeds, purges on startup, runs a daily retention purge task, and +cancels it on shutdown; the combined service enters the same lifespan. Parse +CSV `CORS_ORIGINS` through Interpreter settings, with the existing two Vite +origins as the default. + +## Consequences + +- Admin authentication avoids an avoidable timing leak. +- Retention continues while a process stays up. +- Standalone and combined Interpreter startup cannot drift. +- Production operators configure allowed cross-origin development clients via + `.env`; same-origin deployment remains the default topology. diff --git a/docs/decisions/0013-gec-training-data-governance.md b/docs/decisions/0013-gec-training-data-governance.md new file mode 100644 index 0000000000000000000000000000000000000000..9aab4977b0498fade0e7e670ba39ece97870086f --- /dev/null +++ b/docs/decisions/0013-gec-training-data-governance.md @@ -0,0 +1,30 @@ +# 0013 GEC Training Data Governance + +Date: 2026-07-13 + +## Status + +Accepted + +## Context + +GEC training previously accepted a dataset name and profile without a durable +record of its source, consent status, immutable version, or deterministic run +configuration. Clinical audio is sensitive personal data and cannot be sourced +or approved by an agent. + +## Decision + +Use versioned JSON run configs with fixed seeds and a dataset-manifest reference. +`run_pipeline.py` refuses every training stage until the referenced manifest has +owner-approved consent and a non-placeholder SHA-256. Add a text-only frozen +stratified evaluation fixture with a separate immutable hash; it contains no +patient audio or identifiers. + +## Consequences + +- The agent can validate pipeline governance without collecting clinical data. +- The owner must complete lawful sourcing, consent, de-identification, and hash + verification before a real training run. +- Evaluation categories make drug, dosage, laterality, negation, number, and + diacritic regressions visible independently. diff --git a/docs/decisions/0014-gec-safety-weighted-regression-gate.md b/docs/decisions/0014-gec-safety-weighted-regression-gate.md new file mode 100644 index 0000000000000000000000000000000000000000..8eadfa4b41414f64a9ebb2f93ebe1b4304607e03 --- /dev/null +++ b/docs/decisions/0014-gec-safety-weighted-regression-gate.md @@ -0,0 +1,31 @@ +# 0014 GEC Safety-Weighted Regression Gate + +Date: 2026-07-13 + +## Status + +Accepted + +## Context + +Aggregate WER can improve while a correction damages a medication name or a +dosage. Those errors have a higher clinical cost than ordinary transcription +differences. The training pipeline also lacked a versioned committed baseline +that CI could validate without model or GPU dependencies. + +## Decision + +Commit a deterministic report for the hashed, text-only frozen GEC fixture. +Require its report to stay reproducible in CI. A trained adapter must pass the +existing aggregate gate and, before export, a frozen-fixture gate that rejects +any regression in `drug_name.term_recall` or +`dosage.number_unit_preservation` versus raw ASR. + +## Consequences + +- Overall WER alone can never approve an adapter that harms drug or dosage + preservation. +- CI remains CPU-only: it validates the 12-row fixture, committed report, and + a fake-adapter export/injected-generation smoke test. +- A real adapter run still needs the owner-approved training manifest and an + available GPU; this policy does not authorize data collection or training. diff --git a/docs/decisions/0015-soap-note-measurement-gate.md b/docs/decisions/0015-soap-note-measurement-gate.md new file mode 100644 index 0000000000000000000000000000000000000000..4ff5097c2ebe4f80d553abbae77a6e2a9d1497b9 --- /dev/null +++ b/docs/decisions/0015-soap-note-measurement-gate.md @@ -0,0 +1,31 @@ +# 0015 SOAP Note Measurement Gate + +Date: 2026-07-13 + +## Status + +Accepted + +## Context + +The Scribe SOAP output uses a hosted clinical LLM with an offline fallback. +There is no legitimate basis to train or tune it from invented examples, and no +clinical rating protocol existed to distinguish missing content, hallucination, +and terminology errors. + +## Decision + +Before any SOAP fine-tuning decision, the owner must obtain at least 50 +de-identified pilot notes rated by clinicians under the documented rubric. +The repository holds only a blank rating schema and a validator that accepts +anonymous rating metadata; it must never hold source audio, transcripts, +patient identifiers, or note text. + +## Consequences + +- The agent can make the measurement process reproducible without simulating + clinical evidence. +- A rating summary makes serious hallucinations and unsafe dispositions visible + to the owner before choosing training, retrieval, prompting, or no change. +- The clinical-data study, ratings, and any subsequent model decision remain + owner-led and require an approved environment. diff --git a/docs/decisions/0016-scribe-training-ownership.md b/docs/decisions/0016-scribe-training-ownership.md new file mode 100644 index 0000000000000000000000000000000000000000..f9a25d4777ccc04227fee836d3ded17b3cd8244e --- /dev/null +++ b/docs/decisions/0016-scribe-training-ownership.md @@ -0,0 +1,35 @@ +# 0016 Scribe Training Ownership + +Date: 2026-07-13 + +## Status + +Accepted + +## Context + +The offline DARAG/GEC training code, fixtures, reports, notebooks, and scripts +already support the Scribe's Vietnamese clinical-note workflow. The top-level +`training/` directory obscures that ownership and risks being confused with the +Interpreter's safety evaluation harness at `interpreter/eval/`. + +## Decision + +Move the complete offline training boundary to `scribe/training/`. It contains +the GEC package, configs, fixtures, manifests, reports, notebooks, scripts, +tests, and SOAP measurement tooling. `interpreter/eval/` remains the +Interpreter's independent safety regression harness. + +The Scribe serving package at `scribe/carepath/` must not import training code; +the production image copies only the Scribe runtime package, not +`scribe/training/`. Training keeps its standalone `gec.*` command-line import +boundary without adding a distribution or dependency. + +This supersedes only the top-level training-location portion of decision 0009. + +## Consequences + +- Training commands and CI use `scribe/training/...` paths. +- Runtime packaging stays independent of offline model-development artifacts. +- The repository has a visibly separate Scribe training boundary and + Interpreter safety-evaluation boundary. diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000000000000000000000000000000000000..07677e4adb09abff6c8930bea148e78dd53f216b --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,28 @@ +# Decisions + +Decision records explain why important product, architecture, or harness choices +were made. + +Use `docs/templates/decision.md` when adding a new decision. + +After adding or updating a markdown decision file, also add or refresh the +durable decision row: + +```bash +scripts/bin/harness-cli decision add \ + --id 0008-auth-boundary \ + --title "Auth Boundary" \ + --doc docs/decisions/0008-auth-boundary.md +``` + +Trace fields such as `--decisions` summarize task-level choices. They do not +count as the Harness decision log. + +Add a decision when: + +- A locked technical choice changes. +- A product rule changes meaningfully. +- A validation requirement is added, removed, or weakened. +- A high-risk feature chooses one design over another. +- Auth, authorization, data ownership, audit/security, or API behavior changes. +- The source-of-truth hierarchy changes. diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000000000000000000000000000000000000..6b1669cde8c1243e683b078a15bccc268116a609 --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,150 @@ +# CarePath Unified Deploy + +Use `https://carepath-omega.vercel.app` as the public Scribe site. One Hugging +Face Space runs the Scribe tool at `/ghi-chep-lam-sang/`, the Scribe API at +`/api/v1/*`, and the retained Interpreter API at `/api/*` + `/ws/*`. +`/phien-dich-y-khoa/*` and `/console/*` are intentionally public 404s while +the Interpreter remains in development. + +## Hugging Face Space + +Reuse the existing Docker Space +[`tranth3truong/carepath-api`](https://huggingface.co/spaces/tranth3truong/carepath-api), +served at `https://tranth3truong-carepath-api.hf.space`. Deploy this unified +repository there; it retains the Interpreter API but does not serve the +unfinished Interpreter browser workflow. + +1. Rebuild the existing `tranth3truong/carepath-api` Space with **Docker** as + the SDK. Create a replacement only if that Space is no longer available. +2. Copy `README.hf-space.md` to the Space as `README.md` so the Space has: + +```yaml +--- +title: CarePath +sdk: docker +app_port: 7860 +--- +``` + +3. Push this repo to the Space. The root `Dockerfile` builds `scribe/frontend/` and + `interpreter/frontend/` in a node stage (the site build enforces the Vietnamese + diacritics gate), installs both API packages, pre-downloads the Gipformer + int8 ONNX files, and starts Uvicorn on port `7860`. + Set the Docker build arg `VITE_PUBLIC_SITE_URL=https://carepath-omega.vercel.app` + so the Interpreter's “Tất cả chức năng” link returns to the public site + with the selected language. +4. Set these Space secrets: + +```text +LLM_PROVIDER=ckey +LLM_API_KEY= +LLM_MODEL=gpt-5.4 +TEAM_CODE= +SOAP_RATE_LIMIT_PER_IP_HOUR=3 +SOAP_RATE_LIMIT_PER_IP_DAY=10 +SOAP_RATE_LIMIT_GLOBAL_DAY=100 +APP_ENV=prod +CORS_ORIGINS=https://carepath-omega.vercel.app +``` + +The interpreter module runs in mock mode by default and needs no secrets. +When its cloud track lands, it will additionally need `PROVIDER_MODE=cloud`, +`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and a non-default `ADMIN_TOKEN` +(startup refuses `change-me` in cloud mode). + +The Vercel site uploads Scribe audio directly to the Space, so its exact origin +must be present in `CORS_ORIGINS`. Add local Vite origins as comma-separated +values only when testing local frontends against the deployed API. + +Requests with header `X-Team-Code: ` bypass the SOAP rate limits +for internal doctor-comparison runs. Limited requests return HTTP `429`, +`Retry-After`, and JSON body +`{"detail":{"message":"...","retry_after_seconds":3600}}` — the Scribe tool +surfaces that message to the user. + +## Local Docker Check + +```powershell +docker build -t carepath . +docker run --rm -p 7860:7860 ` + -e LLM_PROVIDER=ckey ` + -e "LLM_API_KEY=$env:LLM_API_KEY" ` + -e LLM_MODEL=gpt-5.4 ` + -e APP_ENV=prod ` + carepath +``` + +Keyless variant (mock ASR + offline LLM + mock interpreter — must also work): + +```powershell +docker run --rm -p 7860:7860 ` + -e ASR_PROVIDER=mock -e ALLOW_MOCK_ASR=true -e LLM_PROVIDER=offline ` + carepath +``` + +In another terminal: + +```powershell +curl.exe http://127.0.0.1:7860/api/v1/health +curl.exe http://127.0.0.1:7860/api/health +curl.exe -o NUL -w "%{http_code}`n" http://127.0.0.1:7860/ +curl.exe -o NUL -w "%{http_code}`n" http://127.0.0.1:7860/ghi-chep-lam-sang/ +curl.exe -o NUL -w "%{http_code}`n" http://127.0.0.1:7860/phien-dich-y-khoa/ +curl.exe -X POST http://127.0.0.1:7860/api/v1/soap-notes ` + -F "audio=@C:\path\to\demo.wav" ` + -F "encounter_context=Phòng khám nội tổng quát" +``` + +## Canonical Vercel site + +The `scribe/frontend/` directory is the static marketing deployment at +`https://carepath-omega.vercel.app`: + +1. In the Vercel project settings, change **Root Directory** from the removed + `apps/web-next` to `scribe/frontend` (framework/build/output come from + `scribe/frontend/vercel.json`). +2. Set these Vercel environment variables: + +```text +VITE_API_BASE=https://tranth3truong-carepath-api.hf.space +VITE_CONSOLE_URL=https://tranth3truong-carepath-api.hf.space/phien-dich-y-khoa/ +VITE_LEAD_ENDPOINT= +VITE_LEAD_EMAIL= +``` + +3. Confirm the Space contains the Vercel origin configured above. Do not add + Vercel API or WebSocket rewrites; the Space owns those routes. + +The Vercel build runs `npm run validate:deploy` before compiling. It fails when +either URL is missing or invalid, does not use HTTPS, uses a different origin, +contains a query/fragment, or does not use the exact `/` and `/phien-dich-y-khoa/` +pathnames. Local +and combined-service builds still use same-origin fallbacks because the normal +`npm run build` does not run this deployment-only check. + +The Interpreter backend stays on the Space because it needs the WebSocket API, +but its browser frontend is disabled. `VITE_CONSOLE_URL` remains a build-time +compatibility setting until the public-site validation is simplified. The pilot +form remains client-side unless `VITE_LEAD_ENDPOINT` is configured. + +## Keep-Alive + +The workflow in `.github/workflows/keepalive.yml` pings the Space every 12 +hours. Set the GitHub repository variable: + +```text +SPACE_URL=https://tranth3truong-carepath-api.hf.space +``` + +Then run **Actions > Keep HF Space Awake > Run workflow** once and confirm the +job succeeds. + +## Final Smoke + +1. Open the Space URL: the CarePath landing renders, Vietnamese by default. +2. Run the scripted interpreter simulation on the landing. +3. Open `/ghi-chep-lam-sang/`, upload a short clip, and verify the SOAP draft renders + with the review banner. +4. Confirm `/phien-dich-y-khoa/` and `/console/` return HTTP 404. +5. Confirm `GET /api/v1/health` reports `llm_provider: ckey` and + `asr_ready: true`, and `GET /api/health` reports `provider_mode: mock`. diff --git a/docs/history/DEMO-SITE-PLAN.md b/docs/history/DEMO-SITE-PLAN.md new file mode 100644 index 0000000000000000000000000000000000000000..077d1e5dd103c1ea8dc6efc75ec24b581e84914b --- /dev/null +++ b/docs/history/DEMO-SITE-PLAN.md @@ -0,0 +1,146 @@ +# CarePath Interpreter — Demo Website Plan + +A public demo/marketing site for the product. Separate track from PLAN.md (the product); +do not touch `backend/` or `frontend/` for this work. Everything lives in a new `/site` +folder. Read [docs/research.md](docs/research.md) for the evidence base the copy draws on. + +**Executor:** codex-5.5 (xhigh). Work tickets S.0 → S.7 in order, one commit per ticket, +same conventions as AGENTS.md. All copy that appears in Vietnamese MUST have full +diacritics — this is a hard rule (see AGENTS.md). + +--- + +## 1. Purpose, audience, and the one metric + +The site convinces **Vietnamese clinic owners and hospital administrators** (secondary: +pilot clinicians, investors) that CarePath is a safe, compliant way to run consultations +with English-speaking patients — and converts them into **pilot-program requests**. +One conversion goal: the "Đăng ký thí điểm" (Request a pilot) form. Everything on the +page exists to move a skeptical healthcare buyer toward that form. + +Language: **Vietnamese default, English toggle** (persist choice in localStorage). + +## 2. Architecture (decided) + +- **Static site, no backend.** New `/site` folder: Vite + React 18 + TypeScript — same + stack and lint rules as `frontend/`, but an independent app (own package.json). Output + is plain static files deployable to any host (GitHub Pages/Netlify/nginx). +- **The interactive demo is a scripted, client-only simulation.** It replays canned + consultation turns that reproduce the real product UX (push-to-talk feel, bilingual + transcript, risk highlighting, read-back confirmation, escalation button). No real + ASR/MT calls, no API keys, no audio recording, no PHI — the browser never captures + the mic. Seed the scripts from `eval/fixtures/eval_starter.tsv` scenarios (they are + already clinically shaped and bilingual). +- **Lead capture without a server:** the pilot-request form POSTs to a configurable + `VITE_LEAD_ENDPOINT`; when unset, it falls back to a prefilled `mailto:` link. Mark + this as the single integration point. Store nothing in the browser beyond the form + draft. +- Simple i18n: one `strings.ts` dictionary module (`vi`/`en` keys), no i18n library. +- No analytics, no cookies, no tracking in MVP. A comment marks where a privacy-safe + counter could go later. + +## 3. Honesty guardrails (non-negotiable) + +The product's brand IS safety. The site models the same honesty the app enforces: + +1. The demo is clearly labeled as a simulation: "Bản mô phỏng — không phải bản dịch + trực tiếp" visible while the demo plays. +2. Never claim autonomy or diagnosis. The tagline space says *translation-and- + verification aid with human-interpreter escalation* — mirror AGENTS.md invariant 1. +3. Banned patterns: fake urgency ("only 3 pilot slots left!"), fake scarcity, countdown + timers, guilt-trip copy ("No thanks, I prefer miscommunication"), pre-checked + marketing-consent boxes, hidden pricing. +4. Every statistic shown must come from docs/research.md and carry its citation in a + tooltip/footnote (e.g., 40–80% of verbal medical information is immediately + forgotten — Kessels 2003; up to 66% of MT'd discharge-instruction sets contained + an inaccuracy — BMJ Qual Saf 2025). +5. Loss-aversion and contrast framing must be factual (real costs, real risks), never + fabricated numbers. Pricing figures are placeholders marked `TODO-pricing` for the + founder to fill; do not invent prices presented as real. + +## 4. Page architecture — each section wired to a persuasion principle + +Single long-scroll landing page + the embedded demo. Section order is the persuasion +sequence; the six principles are design mechanics, not decoration: + +| # | Section | Principle applied | Mechanic | +|---|---|---|---| +| 1 | **Hero** | Reciprocity | Value before any ask: headline + the live demo sits above the fold, playable immediately. No form, no email gate anywhere before section 7. CTA: "Xem demo 90 giây" scrolls into the demo. | +| 2 | **Interactive demo** | Smart defaults + Goal gradient + IKEA | See §5. Scenario pre-selected and pre-loaded (smart default: "Khám ngoại trú — kê đơn thuốc", the most relatable case). A 4-step progress rail starts with step 1 already checked: "✓ Kịch bản đã chọn" — the visitor begins at 25%, not zero. Customization panel (IKEA): clinic name, specialty, scenario choice — reflected live in the demo header ("Phòng khám Đa khoa An Bình — Demo"). | +| 3 | **The problem** | Loss aversion (ethical) | What clinics lose *today*: cited stats on miscommunication (research §Exec Summary), the cost/scarcity of qualified interpreters, compliance exposure. Frame: "Mỗi lượt khám không được phiên dịch đúng là một rủi ro bạn đang gánh" — protecting what they have (patients, reputation, license), not threatening to take features away. | +| 4 | **Safety by design** | Trust builder (no trick) | The product's differentiator. Render the real safety mechanics as cards: read-back confirmation, risk highlighting, low-confidence flagging, one-tap interpreter escalation, no-audio-storage privacy mode. Copy source: PLAN.md §2 invariants, translated for a non-technical buyer. | +| 5 | **How it works** | — | 3 illustrated steps (speak → verify → confirm), one line each. | +| 6 | **Cost framing** | Contrast effect (ethical) | Anchor with the true, cited costs of the status quo: full-time bilingual staff or per-visit professional interpreter rates, and the cost of a single adverse event. Then the pilot offer (placeholder `TODO-pricing`). The anchor numbers must be real and sourced — contrast through honest arithmetic, not a fake slashed price. | +| 7 | **Pilot CTA + form** | Reciprocity + IKEA + Loss aversion payoff | By now the visitor has used the demo and customized it. Form headline: "Giữ lại bản demo bạn vừa tạo" — submitting sends them their configured demo transcript (attach the transcript text into the form payload). Fields: name, clinic, role, email/Zalo, pre-filled message (smart default) summarizing their chosen scenario. ≤5 fields. | +| 8 | **Footer** | — | Compliance posture line (Decree 13/PDP-aware design, §1557-aligned positioning), AI-use honesty statement, contact. | + +## 5. The interactive demo spec (the heart of the site) + +A `DemoPlayer` component simulating one consultation: + +- **Scenario scripts:** 3 scripted conversations (JSON in `site/src/demo/scenarios/`), + ~8 turns each, adapted from eval fixtures: (a) prescription + dosage (default), + (b) allergy check with a negation moment, (c) chest-pain red flag → escalation. + Each turn: speaker, Vietnamese text, English text, risk tier, risk spans, and for + high/critical turns a read-back payload (entities: drug/dose/frequency/negation). +- **Playback:** turns appear one-by-one with a typing/speaking animation and a subtle + push-to-talk button pulse (autoplay ~4s per turn; controls: play/pause, next, replay). + Visitor can also click "Thử tự gõ" to type one custom line that gets echoed into the + transcript with a canned translation — participation, not real MT (label it). +- **The safety moment is the hero moment:** when the dosage/allergy turn arrives, + playback *stops*, the read-back confirmation card slides in (real product styling: + entity table, Confirm / Edit / Escalate), and the progress rail advances only when + the visitor clicks Confirm. In scenario (c), the red-flag turn triggers the + full-screen escalation banner exactly like the product. +- **Progress rail (goal gradient):** `✓ Kịch bản` → `Nghe hội thoại` → `Xác nhận + read-back` → `Nhận bản ghi`. Step 4 unlocks a "Tải bản ghi demo" button (downloads + the bilingual transcript as a formatted .txt/.html — the free cookie they keep). +- Visual fidelity: match the real product's transcript layout and risk badge colors + (lift the palette from `frontend/src/App.css`) so the demo IS the product, not a + cartoon of it. + +## 6. Design direction + +- Clinical-trust aesthetic: calm, spacious, high contrast; one accent color for risk/ + CTA moments; light + dark theme. No stock-photo doctors, no purple-gradient AI slop, + no emoji in headings. Vietnamese typography checked with real diacritics at display + sizes (choose a font that renders Vietnamese well — e.g. Be Vietnam Pro, self-hosted). +- Responsive: the demo must work on a phone (clinic owners will open this from Zalo). +- Accessibility is part of the pitch: WCAG AA contrast, full keyboard path through the + demo, `prefers-reduced-motion` respected (no autoplay animation), semantic landmarks. +- Performance: static, self-hosted font subset, no external requests at runtime except + the lead endpoint on submit. Lighthouse ≥95 performance/accessibility/best-practices. + +## 7. Tickets + +- **S.0 Scaffold.** `/site` Vite React-TS app, eslint config copied from frontend, + `npm run dev/build/test`, README section. Commit this plan file with it. + *Accept:* `npm run build` outputs static files; CI-ready. +- **S.1 Demo engine.** Scenario JSON schema + 3 scripts + `DemoPlayer` with playback, + read-back stop, escalation moment, progress rail, transcript download. *Tests:* + vitest — playback advances, confirmation gates progress, download produces bilingual + content; scripts validated against the schema. +- **S.2 Page sections.** Sections 1, 3–6, 8 with bilingual copy (research-cited stats + with footnotes; `TODO-pricing` placeholders). *Accept:* full page renders in vi + en; + no diacritic-stripped Vietnamese anywhere (add a test that greps built output for + the corrected consent-style strings). +- **S.3 Persuasion wiring.** Smart-default scenario preselection, customization panel + (clinic name/specialty live in demo header), form pre-fill from demo state, "keep + your demo" transcript attach. *Tests:* customization propagates; form payload + contains scenario summary. +- **S.4 Language toggle.** `strings.ts`, vi default, persisted choice, `` + switches. *Tests:* toggle flips all sections. +- **S.5 Lead form.** `VITE_LEAD_ENDPOINT` POST with mailto fallback, ≤5 fields, inline + validation, success state, zero persistence beyond the draft. *Tests:* endpoint mode + + fallback mode. +- **S.6 A11y + e2e.** Keyboard-only Playwright run through the whole demo flow to form + success; reduced-motion snapshot; axe-core pass with no serious violations. +- **S.7 CI.** New `site` job: lint, test, build, run the S.6 e2e against `vite preview`. + *Accept:* CI green end to end. + +## 8. Non-goals + +No real translation calls, no account system, no CMS, no blog, no analytics, no A/B +testing framework, no multi-page router (one page + anchors), no video production +(the scripted demo replaces a demo video). Pricing numbers are founder input, not +codex's to invent. diff --git a/docs/history/JUDGE.md b/docs/history/JUDGE.md new file mode 100644 index 0000000000000000000000000000000000000000..a6e90822b5c08841ce4f30e67b526fe023c3236c --- /dev/null +++ b/docs/history/JUDGE.md @@ -0,0 +1,99 @@ +# JUDGE.md — claude-fable-5 review protocol for the CarePath unification + +**Who runs this:** claude-fable-5, in a fresh session, after codex finishes MERGE-PLAN.md. +**What is judged:** the `carepath-unified` branch, ticket commits M.0 → M.8. +**Baselines for comparison:** `origin/main` (scriber contract) and +`origin/carepath-interpreter-demo` (interpreter behavior + S.8 design). + +Judge the work, not the plan. If MERGE-PLAN.md itself turns out to be wrong somewhere +and codex deviated for a good, documented reason, that is acceptable — say so explicitly +in the verdict instead of failing the ticket. + +--- + +## 1. Hard gates — any failure ⇒ overall NEEDS WORK + +Run every gate. Report each as PASS / FAIL with evidence (command output, file:line). + +### G1. Everything green + +Run the full Verify inventory (MERGE-PLAN.md §3) on a clean checkout of +`carepath-unified`: + +```text +pip install -e ".[dev]" -e "./backend[dev]" # one Python 3.12 venv +pytest # root (scriber + combined-app test) +python scripts/smoke_backend.py +cd backend && ruff check . && pytest && cd .. +python eval/run_eval.py --set eval/fixtures/eval_starter.tsv --providers mock +cd frontend && npm ci && npm run lint && npm test && cd .. +cd site && npm ci && npm run lint && npm test && npm run build && cd .. +# e2e (needs built app / mock backend per ci.yml): +cd frontend && npm run e2e ; cd ../site && npm run e2e +``` + +### G2. Scriber API contract frozen + +The deployed HF Space clients must not break: + +```text +git diff origin/main carepath-unified -- apps/api/carepath/schemas.py +``` + +Schema diff must be empty (or provably additive-only). Then compare every `/api/v1/*` +route signature, status codes, and rate-limit/TEAM_CODE behavior in +`apps/api/carepath/main.py` against `origin/main` — changes must be additions +(interpreter routers, static mounts, lifespan additions), never modifications to +existing scriber behavior. + +### G3. Safety invariants intact (AGENTS.md §2 / MERGE-PLAN.md §2) + +Audit with greps + targeted reads on the merged tree: + +1. **No raw-audio persistence** in the interpreter path: no audio columns in + `backend/app/crud.py` models, no `tempfile`/disk writes of turn audio in + `backend/app/session.py` or `api.py`. (The scriber's documented tempfile + normalization of *uploaded clips* on `origin/main` is pre-existing and allowed.) +2. **Consent before mic**: every `getUserMedia` call site in `frontend/` and `site/` + (including the new scribe view) is gated behind the consent flow. +3. **Risk blocking**: high/critical turns still blocked pre-confirmation — spot-check + `backend/app/session.py` + `frontend/src/components/InterpreterConsole.tsx` are + unchanged from the interpreter branch (or changed only for import/serving reasons). +4. **Fail-closed**: pipeline error paths in `backend/app/api.py` unchanged. +5. **No advice generation** introduced anywhere in new copy or code. +6. **Diacritics**: `site/` build gate ran; spot-check new Vietnamese copy (Scribe + section, error messages) for full diacritics. + +### G4. Keyless boot + +With **no** API keys in the environment (`PROVIDER_MODE=mock`, `ASR_PROVIDER=mock`, +`ALLOW_MOCK_ASR=true`, `LLM_PROVIDER=offline`): the combined uvicorn app boots and +serves `/api/health`, `/api/v1/health`, a mock `POST /api/v1/soap-notes`, a created +interpreter session + WebSocket handshake, and (if dists are built) `/` and `/console/`. + +### G5. Retirement + hygiene + +- `git ls-files apps/web apps/web-next` → empty. +- `grep -ri "carepath translate" site/src docs README.md` → empty. +- No secrets/keys anywhere in `git diff origin/main...carepath-unified`. +- Root `package-lock.json` orphan (if still present without a root `package.json`) — + flag for deletion. +- `.env.example` documents both products; defaults match the keyless demo profile. + +## 2. Scored review — 1 (poor) to 5 (excellent) each + +| Dimension | What 5 looks like | +|---|---| +| Brand/UI consistency | Landing, scribe view, and console read as one product on the S.8 design system; logo/name unified; no leftover Translate branding or old apps/web styling. | +| Code quality | Matches AGENTS.md conventions (ruff, TS strict, small components, data-not-code lexicons); no copy-paste from app.js left un-Reactified; no dead code. | +| Commit hygiene | Exactly one commit per ticket, M.0→M.8 in order, subjects match, bodies explain any deviation from the plan. | +| Minimalism | No unrequested abstractions, no new deps beyond plan, hash route not a router lib, mounts/env kept simple. | + +## 3. Verdict format + +1. **Per-ticket table**: ticket · commit sha · gates touched · PASS / NEEDS WORK · one-line note. +2. **Hard-gate table**: G1–G5 with evidence. +3. **Scores** with one sentence each. +4. **Overall: PASS or NEEDS WORK.** If NEEDS WORK: a numbered, codex-executable fix + list — each item states the file, the defect, the expected behavior, and which gate + it unblocks. No vague items. diff --git a/docs/history/MERGE-PLAN.md b/docs/history/MERGE-PLAN.md new file mode 100644 index 0000000000000000000000000000000000000000..bac002bc066f475831eef5f9be86237773f18225 --- /dev/null +++ b/docs/history/MERGE-PLAN.md @@ -0,0 +1,279 @@ +# CarePath Unification Plan — merge Scriber + Interpreter into one product + +**Executor:** codex-5.6 terra (fallback: codex-5.5 xhigh). Work tickets M.0 → M.8 in +order, **one commit per ticket**, commit subject `M.x `. Every +ticket ends green: run that ticket's Verify list before committing. If a Verify step +fails, fix it inside the same ticket — never commit red, never skip ahead. + +**Judge:** after M.8, claude-fable-5 reviews the branch against `JUDGE.md`. Anything +that fails a hard gate there comes back as a fix list — save everyone a round trip by +running the gates yourself first. + +--- + +## 1. What is being merged + +The repo holds two products with **unrelated git histories**: + +| | Scriber (`origin/main`) | Interpreter (`origin/carepath-interpreter-demo`) | +|---|---|---| +| Backend | `apps/api/carepath` FastAPI: `/api/v1/health`, `/api/v1/corrections`, `/api/v1/soap-notes` | `backend/app` FastAPI: `/api/*` REST + `/ws/sessions/{id}` WebSocket, risk engine, admin review | +| ASR / LLM | Gipformer ONNX (local, keyless) + ckey OpenAI-compatible LLM. **Deployed, working** (HF Space Docker) | OpenAI + Anthropic cloud providers (no keys) → **mock mode only** | +| Frontends | `apps/web` (static tool), `apps/web-next` (Next.js landing) | `frontend/` (interpreter console), `site/` (marketing demo site — the S.8 clinical redesign) | + +Target: **one product, "CarePath"**, on branch `carepath-unified`: + +1. **One FastAPI service** (single deploy): scriber routes stay at `/api/v1/*`, + interpreter routes at `/api/*` + `/ws/*`, one combined lifespan. +2. **Interpreter stays mock-only for now.** Do not touch the provider abstraction or + wire new providers. Real interpreter providers are a later, separate track. +3. **`site/` becomes the entire public face**, rebranded "CarePath Translate" → + "CarePath" with two modules: **Interpreter** and **Scribe**. The newer branch's + UI/UX (logos, design system) wins everywhere. `apps/web` and `apps/web-next` are + retired. +4. **Histories are preserved** via `git merge --allow-unrelated-histories`. + +## 2. Non-negotiable invariants (inherited from AGENTS.md — full text there) + +These apply to every ticket. The judge greps for regressions on each one. + +1. Translate-only: never generate medical advice, diagnoses, or drug recommendations. +2. High/critical-risk turns stay blocked from patient display + TTS until doctor confirms. +3. Low-confidence output is always visibly flagged, never silently delivered. +4. Raw audio is never persisted by the interpreter — memory-only. No audio columns, no temp files. +5. No mic capture before recorded consent. +6. On pipeline/reviewer failure, fail closed — never fail open to the patient. +7. Vietnamese copy always carries full diacritics, NFC-normalized. `site/` build enforces this. +8. **The scriber API contract is frozen**: request/response schemas and routes under + `/api/v1/*` must be byte-for-byte compatible with `origin/main` (`carepath/schemas.py` + and the endpoint signatures in `carepath/main.py`). The deployed HF Space and its + clients must keep working. +9. Keyless boot is a hard requirement end-to-end: `PROVIDER_MODE=mock` + + `LLM_PROVIDER=offline` + `ASR_PROVIDER=mock`/`ALLOW_MOCK_ASR=true` must run the whole + combined product with zero API keys. +10. Secrets only via env. `.env` gitignored, `.env.example` stays current. No new + dependencies beyond those named in this plan without a written why in the commit body. + +## 3. Verify command inventory + +Used throughout the tickets (all verified to exist on their source branches): + +```text +# scriber (repo root, Python 3.12 venv with: pip install -e ".[dev]") +pytest # root suite (tests/, pythonpath apps/api) +python scripts/smoke_backend.py # forces mock ASR + offline LLM internally + +# interpreter backend (from backend/, same venv with: pip install -e ".[dev]") +ruff check . +pytest + +# eval regression (repo root) +python eval/run_eval.py --set eval/fixtures/eval_starter.tsv --providers mock + +# interpreter console (from frontend/) +npm ci && npm run lint && npm test && npm run e2e # e2e needs PROVIDER_MODE=mock backend + +# demo site (from site/) +npm ci && npm run lint && npm test && npm run build # build runs check-diacritics.mjs +npm run e2e +``` + +--- + +## Tickets + +### M.0 — Unify the histories + +1. `git checkout -b carepath-unified origin/main` +2. `git merge --allow-unrelated-histories origin/carepath-interpreter-demo` +3. Exactly three paths collide — resolve as: + - `README.md`: temporary stub — unified title, one paragraph per product, links to + both quickstarts. Rewritten properly in M.8. + - `.gitignore`: union of both, deduplicated. + - `.env.example`: both files concatenated under `# --- Scriber (apps/api) ---` and + `# --- Interpreter (backend/) ---` section headers. Properly merged in M.2. +4. Nothing else changes in this ticket. No restructuring, no renames. + +**Verify:** root `pytest` passes; `python scripts/smoke_backend.py` passes; +`cd backend && pytest` passes; `frontend/` and `site/` `npm test` pass. + +### M.1 — One FastAPI service + +Goal: a single uvicorn process serves both API surfaces. + +1. Root `pyproject.toml`: no changes to the `carepath` package config. The interpreter + backend stays where it is and keeps its own `backend/pyproject.toml`; the combined + env simply installs both: `pip install -e ".[dev]" -e "./backend[dev]"`. Confirm the + resolver accepts both dependency sets on Python 3.12 (backend requires >=3.12). +2. Extend `apps/api/carepath/main.py`: + - `app.include_router(api_router)` and `app.include_router(ws_router)` from + `app.api` (the interpreter package is importable as `app` once installed). Register + routers **before** the `app.mount("/", StaticFiles(...))` call so routes win. + - Extend the existing lifespan: after scriber warmup, run the interpreter startup + sequence exactly as `backend/app/main.py` does today — `validate_runtime_settings()`, + `init_db()`, then in a DB session `seed_glossary(db)` and + `crud.purge_old_sessions(db, settings.retention_days)`. + - Do **not** add the interpreter's CORSMiddleware; the scriber's origin-guard + + CORS config governs the combined app. +3. Leave `backend/app/main.py` untouched — it remains the standalone dev/test app, and + the interpreter pytest suite keeps running against it unmodified. +4. Route collision check: scriber owns `/api/v1/*`; interpreter owns `/api/health`, + `/api/sessions*`, `/api/turns*`, `/api/admin/*`, `/ws/sessions/*`. `/api/health` and + `/api/v1/health` are distinct — keep both. + +Watch-outs: +- WebSocket route through the combined app: verify `/ws/sessions/{id}` accepts a + connection (the interpreter e2e covers this in M.5's CI wiring; here a manual + `pytest`-style check or a small added test against the combined app is enough). +- The scriber origin-guard middleware only rejects when `CORS_ORIGINS` is set and the + Origin header mismatches — document in `.env.example` (M.2) that local Vite dev + origins (`http://localhost:5173`) must be listed when developing against it. + +**Verify:** `uvicorn carepath.main:app --app-dir apps/api` boots keyless (env: +`PROVIDER_MODE=mock`, `ASR_PROVIDER=mock`, `ALLOW_MOCK_ASR=true`, `LLM_PROVIDER=offline`) +and answers `GET /api/v1/health` **and** `GET /api/health`; root `pytest`; +`python scripts/smoke_backend.py`; `cd backend && pytest`; add one combined-app test +(root `tests/test_combined_app.py`) asserting both health endpoints and a WebSocket +handshake on a created session — this test is the ticket's artifact. + +### M.2 — Config and env unification + +1. Rewrite `.env.example` as one documented file: shared section (nothing collides — + verified), scriber section (ASR_*, GIPFORMER_*, LLM_*, CORS_ORIGINS, TEAM_CODE, + SOAP_RATE_LIMIT_*), interpreter section (PROVIDER_MODE, ADMIN_TOKEN, + CONFIDENCE_THRESHOLD, RETENTION_DAYS, DATABASE_URL, MAX_TURN_AUDIO_BYTES, provider + models/keys marked "later track — mock mode needs none"). +2. Defaults must produce a keyless boot: `PROVIDER_MODE=mock`, `LLM_PROVIDER=offline` + documented as the demo profile. Keep `.env.local.example` / `.env.ckey.example` + consistent or fold them in — fewest files wins, but don't break + `scripts/setup_local.ps1`, which copies `.env.local.example`. +3. sqlite `DATABASE_URL` default stays (ephemeral on HF Space is acceptable for the + mock demo; note this in the file). + +**Verify:** fresh env from the new `.env.example` defaults boots the combined app +keyless; `python scripts/smoke_backend.py`; `cd backend && pytest`. + +### M.3 — Rebrand `site/` to unified CarePath — **ALREADY DONE on the interpreter branch** + +This ticket was executed by claude-fable-5 directly on `carepath-interpreter-demo` +before the merge (commit(s) touching `site/` after S.8): brand is now "CarePath" +(logo/favicon/titles/package `carepath-site`), the landing is a two-module story +(hero + module tiles + a `#scribe` chapter with a scripted `ScribeShowcase` +ASR→correction→SOAP walkthrough), and all tests/build/e2e are green. + +**Your job in this ticket is verification only:** from `site/` run `npm run lint`, +`npm test`, `npm run build` (diacritics gate), `npm run e2e`; grep gate: +`grep -ri "carepath translate" site/src` returns nothing. Fix anything the merge broke; +otherwise commit nothing and move on. + +### M.4 — Port the Scribe tool into `site/`, retire the old frontends + +1. Rebuild the `apps/web/app` tool (record/upload audio → raw transcript → corrected + transcript → SOAP draft with retrieved terms) as a React view inside `site/`, using + the site's design system and `strings.ts` i18n. +2. Routing: `site/` has no router — add a **hash route** (`#/scribe`) with a small + `location.hash` switch in `App.tsx`. No router dependency, no server-side SPA + fallback needed. Note: the landing already has a `#scribe` **anchor** (the Scribe + overview chapter with the scripted `ScribeShowcase`) — keep it, and add a "Mở công + cụ Scribe / Open the Scribe tool" CTA inside that chapter linking to the `#/scribe` + tool route. +3. API calls (see `apps/web/app.js` on `origin/main` for the exact flow): + - `GET {base}/api/v1/health` for the status badge, + - `POST {base}/api/v1/soap-notes` (multipart file upload), + - base URL from `VITE_API_BASE`, defaulting to `""` (same-origin, matching M.5). + - Handle 400 (bad audio), 413/oversize, and 429 rate-limit responses with bilingual + messages; support an optional `X-Team-Code` header from a `VITE_TEAM_CODE` env or + input field (it bypasses the scriber's rate limit — never hardcode a value). +4. Mic/consent: the scribe records clinician dictation. Reuse the site's existing + consent-gate pattern before any `getUserMedia` call (invariant 5) — file upload + needs no consent gate. +5. Delete `apps/web/` and `apps/web-next/` entirely (git history preserves them). + Remove now-dead references: the `app.mount("/", StaticFiles(directory=WEB_DIR...))` + block keeps working until M.5 swaps the directory — if `WEB_DIR` points at the + deleted `apps/web`, make the mount conditional on the directory existing so the API + still boots; M.5 finishes the job. + +**Verify:** `site/` lint + test + build + e2e green (add at least one e2e that walks +the scribe route against a mocked fetch or the local keyless API); combined app still +boots; `git ls-files apps/web apps/web-next` returns nothing. + +### M.5 — Serve the frontends from the API + +1. `frontend/` (interpreter console): set Vite `base: "/console/"`, make its API base + same-origin-relative in production (keep the localhost dev default working). +2. Combined app static serving, in this order after all routers: + - `app.mount("/console", StaticFiles(directory=, html=True))` + - `app.mount("/", StaticFiles(directory=, html=True))` + - Directories configurable via env (`SITE_DIST_DIR`, `CONSOLE_DIST_DIR`) with those + defaults; mounts are skipped with a logged warning when the dir is missing (dev + and CI run the Vite dev servers instead). +3. Landing links: Interpreter module CTA → `/console`, Scribe CTA → `#/scribe`. +4. WebSocket URL in the console must derive from `window.location` when served + same-origin (ws/wss scheme). + +**Verify:** build both frontends, boot the combined app keyless, manually fetch `/`, +`/console/`, `/api/health`, `/api/v1/health`; `frontend/` mock-mode Playwright e2e +green; `site/` e2e green. + +### M.6 — One CI workflow + +Merge into a single `.github/workflows/ci.yml` (keep `keepalive.yml` as-is) with jobs: + +1. `scriber`: Python 3.12, `pip install -e ".[dev]"`, root `pytest`, + `python scripts/smoke_backend.py`. +2. `interpreter-backend`: `pip install -e "./backend[dev]"`, `ruff check backend`, + backend `pytest`, eval regression run. +3. `combined-app`: install both packages, run the M.1 combined-app test + keyless boot + check. +4. `frontend`: existing lint + vitest job. +5. `site`: existing lint + vitest + build (diacritics) job. +6. `e2e`: existing Playwright jobs for `frontend/` (against `PROVIDER_MODE=mock` + backend) and `site/` — preserve their current setup from the interpreter branch's + ci.yml (S.7 wiring). + +**Verify:** `git push` the branch and confirm all CI jobs green, or run each job's +commands locally in a clean venv/node_modules if CI isn't available to you. + +### M.7 — Docker / deploy + +1. Multi-stage `Dockerfile`: + - `node:22-slim` stage: `npm ci && npm run build` for `site/` and `frontend/` + (frontend built with `base=/console/`; site build runs the diacritics gate). + - Python stage (keep the existing base, env, Gipformer int8 pre-download exactly as + today): additionally `pip install ./backend`, copy the two dist folders, set + `SITE_DIST_DIR`/`CONSOLE_DIST_DIR`, same `CMD` (port 7860). +2. Update `docs/deploy.md` and `README.hf-space.md`: one Space serves the landing, + console, scribe, and both APIs; document the interpreter env vars (mock defaults, ADMIN_TOKEN + note: the combined app runs `validate_runtime_settings`, which only enforces + ADMIN_TOKEN when `PROVIDER_MODE=cloud`). +3. `apps/web-next` is gone — remove Vercel references from docs; note the old Vercel + project can be deleted. + +**Verify:** `docker build .` succeeds; `docker run` with no API keys boots and serves +`/`, `/console/`, `/api/health`, `/api/v1/health`, and a mock `POST /api/v1/soap-notes` +(smoke-style WAV) — mirror what `scripts/check_goal5_deploy.ps1` checks where practical. + +### M.8 — Docs + +1. Rewrite `README.md` for the unified product: what CarePath is (two modules), local + quickstart (one venv, both packages, combined uvicorn command, both frontends), + keyless demo profile, test commands, deploy pointer. +2. Update `AGENTS.md`: unified command list, invariants unchanged, plan pointers — + `PLAN.md` and `DEMO-SITE-PLAN.md` marked historical (do not delete), `MERGE-PLAN.md` + marked done, `JUDGE.md` referenced as the review protocol. +3. Sweep for stale references: `grep -ri "web-next\|apps/web\b\|carepath translate"` + across docs and configs; fix stragglers. + +**Verify:** every command in the new README executes as written on a clean checkout; +full Verify inventory from §3 green one last time. + +--- + +## 4. Out of scope — do not do + +- No real interpreter providers (no Gipformer-for-interpreter, no ckey MT/reviewer, no + new keys). Mock stays. +- No changes to the GEC training pipeline, notebooks, `scripts/gec/`, or eval datasets. +- No auth/multi-tenant, no analytics, no new runtime dependencies beyond what this plan + names (the hash route explicitly avoids react-router). +- No visual redesign of the S.8 system — extend it. diff --git a/docs/history/PLAN.md b/docs/history/PLAN.md new file mode 100644 index 0000000000000000000000000000000000000000..1884a1eff32c06be9575c89afb9af780a67bb7d9 --- /dev/null +++ b/docs/history/PLAN.md @@ -0,0 +1,425 @@ +# CarePath Interpreter — MVP Implementation Plan + +Live Vietnamese ↔ English medical interpreter for outpatient consultations in Vietnam. +This plan turns [docs/research.md](docs/research.md) into buildable tickets. Research says +what and why; this file says how. When they conflict, this file wins for implementation, +research wins for safety/compliance intent. + +**Executor:** codex-5.5. Work phases in order. Within a phase, tickets are ordered by +dependency. Every ticket ends green: `pytest` + frontend tests pass, mock-mode e2e works. + +--- + +## 1. Product in one paragraph + +A tablet/laptop web app used during a consultation between a Vietnamese-speaking doctor +and an English-speaking patient (and the reverse). Push-to-talk per speaker → server ASR → +text normalization → glossary-aware MT → rules-based risk classifier → bilingual transcript. +Low-risk turns speak out immediately (browser TTS). High-risk turns (dose, allergy, negation, +laterality, red-flags) are **blocked until the doctor confirms a read-back** of the critical +entities. Very-high-risk content prominently routes to a human interpreter. It is a +translation-and-verification aid — never a diagnostic or advice tool. + +## 2. Safety invariants — never violate, in any ticket + +1. **No autonomous medical content.** The system only translates and surfaces what was said. + Post-filter any provider output that adds advice, diagnoses, or drug recommendations. +2. **No critical turn reaches the patient unconfirmed.** Risk tier `high`/`critical` blocks + TTS + patient-facing display until doctor confirms or corrects. +3. **No silent low-confidence output.** Below threshold → visible flag + re-ask/typed + fallback. Never emit a low-confidence dose/allergy/negation quietly. +4. **Privacy mode is default-on.** Raw audio is processed in memory and never written to + disk or DB. Only text + metadata persist. (Vietnam Decree 13/PDP Law; HIPAA-shaped.) +5. **Consent before capture.** No mic capture until session consent + AI-use disclosure is + recorded ("AI translation, may contain errors, you may request a human interpreter"). +6. **Interpreter escalation is always one tap away** and never hidden by any state. +7. **Everything auditable.** Every high/critical turn logs confidence, risk spans, doctor + action (confirm/correct/escalate), timestamps. + +## 3. Scope + +**In (MVP):** dual push-to-talk; Vi↔En ASR+MT; bilingual live transcript; risk highlighting; +confirmation/read-back gate; low-confidence handling; interpreter-escalation flow; consent + +disclosure; per-turn feedback flag; privacy mode; deterministic mock providers; evaluation +harness; minimal review page. + +**Out (explicitly — do not build):** diagnosis/advice/QA of any kind, visit summaries, +emergency triage, informed-consent workflows, mental-health flows, pediatric emergency, +model fine-tuning (adapters make it a later swap), mobile native apps, auth/multi-tenant +(single shared device assumption; one env-var admin token for the review page). + +## 4. Architecture & stack (decided — don't re-litigate) + +``` +Browser (React+TS+Vite) + ├─ push-to-talk mic capture (MediaRecorder) ──┐ WebSocket (audio chunks + JSON events) + ├─ bilingual transcript + risk highlighting │ + ├─ confirm/read-back modal, escalate button │ + └─ TTS via browser speechSynthesis ▼ +Backend (Python 3.12, FastAPI, uvicorn) + session/turn orchestrator + ├─ ASRProvider (mock | cloud) ┐ + ├─ MTProvider (mock | LLM) │ swappable adapters (Protocol classes) + ├─ ReviewerProvider (mock | LLM) ┘ + ├─ normalizer (rules: numbers, units, dates, diacritics) + ├─ risk engine (rules + lexicons — deterministic, no ML in MVP) + └─ SQLite (sessions, turns, feedback — no audio) +``` + +| Decision | Choice | Why (one line) | +|---|---|---| +| Backend | Python 3.12 + FastAPI + WebSocket | Best ecosystem for audio/NLP libs; async streaming | +| Frontend | React 18 + TypeScript + Vite | Standard, fast, codex-friendly | +| Storage | SQLite via `sqlite3`/SQLModel | Single-clinic MVP; zero ops; swap to Postgres later | +| ASR (real) | OpenAI `gpt-4o-transcribe` (Vi + En), lang hint per turn | Good Vi support, confidence-ish via logprobs; adapter makes PhoWhisper/VietMed fine-tune a later swap | +| MT (real) | Claude `claude-sonnet-5` with glossary-in-prompt + strict "translate only" system prompt | Day-1 placeholder per research (generic API acceptable); glossary pinning + rule post-checks close the gap until a MedEV fine-tune | +| Reviewer (real) | Claude `claude-sonnet-5`, second pass on high-risk turns only | Back-translates + extracts critical entities for read-back; cost-bounded by risk gating | +| TTS | Browser `speechSynthesis` (`vi-VN`, `en-US`) | Native, free, offline; server TTS adapter is a later ticket if voice quality fails in testing | +| Risk engine | Deterministic rules + lexicons, **not** ML NER | Testable, explainable, zero-latency; ViHealthBERT NER is a post-MVP upgrade behind the same interface | +| Mock mode | `PROVIDER_MODE=mock` env → deterministic canned providers | CI, demos, and frontend dev need no keys and no network | + +Monorepo layout (plain folders, no workspace tooling): + +``` +/backend + app/main.py FastAPI app, WS endpoint + app/config.py env settings (pydantic-settings) + app/db.py SQLite setup + models + app/session.py session/turn orchestrator (the pipeline) + app/normalize.py text normalization rules + app/risk/engine.py risk classifier (rules) + app/risk/lexicons/ *.json data files (see §7) + app/providers/base.py Protocol interfaces + app/providers/mock.py deterministic mocks + app/providers/openai_asr.py + app/providers/claude_mt.py + app/glossary/ store + seed data + import script + tests/ +/frontend + src/ components, ws client, tts, state (zustand or plain context) + tests/ vitest + one Playwright e2e (mock mode) +/eval + run_eval.py CLI harness + fixtures/ risk-rule fixtures, golden turns, eval TSVs +/docs + research.md source research report +PLAN.md AGENTS.md README.md .env.example +``` + +## 5. Data model (SQLite) + +```sql +sessions(id TEXT PK, created_at, status TEXT, -- active|ended|escalated + consent_json TEXT, -- who consented, disclosure shown, ts + privacy_mode INTEGER DEFAULT 1, escalated_at) + +turns(id TEXT PK, session_id FK, seq INTEGER, + speaker TEXT, -- doctor|patient + src_lang TEXT, tgt_lang TEXT, + source_text TEXT, normalized_text TEXT, translation TEXT, + asr_confidence REAL, mt_confidence REAL, + risk_tier TEXT, -- low|medium|high|critical + risk_spans_json TEXT, -- [{start,end,kind,severity,term}] + readback_json TEXT, -- reviewer output for high/critical: entities + back-translation + status TEXT, -- pending|delivered|awaiting_confirm|confirmed|corrected|blocked|escalated + corrected_text TEXT, created_at) + +feedback(id TEXT PK, turn_id FK, reason TEXT, -- wrong_term|wrong_meaning|missing|other + comment TEXT, created_at) +``` + +No audio column anywhere. That's a feature. + +## 6. API contract + +REST (JSON): + +``` +POST /api/sessions {consent:{...}} → {session_id} +POST /api/sessions/{id}/end → 204 +POST /api/sessions/{id}/escalate → 204 (sets status, logs) +DELETE /api/sessions/{id} hard-deletes session+turns → 204 (PDP deletion right) +GET /api/sessions/{id}/transcript → [turn, ...] +POST /api/turns/{id}/confirm {edited_translation?} → turn (unblocks TTS/display) +POST /api/turns/{id}/feedback {reason, comment?} → 201 +GET /api/admin/review?risk=high&flagged=1 (X-Admin-Token) → paged turns for review page +``` + +WebSocket `/ws/sessions/{id}`: + +``` +client→server {type:"start_turn", speaker:"doctor", lang:"vi"} +client→server binary audio chunks (webm/opus from MediaRecorder) +client→server {type:"end_turn"} +client→server {type:"text_turn", speaker, lang, text} // typed fallback path +server→client {type:"turn_result", turn:{...full turn row...}, + requires_confirmation:bool, low_confidence:bool} +server→client {type:"turn_error", message, retryable:bool} +``` + +One in-flight turn per session (push-to-talk enforces it); reject overlapping `start_turn`. + +## 7. Risk engine spec (rules + lexicons) + +Deterministic pipeline over source text + translation. Each rule emits spans +`{kind, severity, term}`; tier = max severity hit. Lexicons are JSON data files in +`app/risk/lexicons/` — reviewable by clinicians without touching code. + +| Rule | Fires on | Tier | Maps to research failure modes | +|---|---|---|---| +| `red_flag` | đau ngực / khó thở / chest pain / can't breathe / suicidal … | critical | #23 | +| `negation` | không / chưa / ngưng / not / no / stop / never near a medical term | high | #1, #24 | +| `dose_number` | number + unit (mg/ml/mcg/viên/gói/ống/tablet…) or fraction (nửa/half) | high | #3, #5, #16, #26 | +| `frequency_duration` | ngày X lần / X times a day / trong N ngày / for N days | high | #4, #15 | +| `allergy` | dị ứng / allergic / allergy | critical | #2 | +| `pregnancy` | mang thai / có bầu / pregnant | critical | #21 | +| `laterality` | trái / phải / left / right | high | #9 | +| `drug_name` | glossary drug hit; escalate if in LASA pair list | high (critical if LASA) | #7, #8 | +| `route` | nhỏ mắt / uống / tiêm / drops / oral / injection | high | #25 | +| `low_confidence` | ASR or MT confidence < threshold (config, default 0.7) | forces flag at any tier | #6, #17–19 | +| `number_mismatch` | digits extracted from source ≠ digits in translation | critical | cascade check | +| `negation_mismatch` | negation cue count source vs translation differs | critical | cascade check | + +Tier → behavior: `low` = deliver + speak. `medium` = deliver + highlight spans. +`high` = block, show read-back (reviewer entities + back-translation), require confirm. +`critical` = block + confirm + prominent "Get human interpreter" banner. + +Lexicon seed files to create (ticket 3.1): `red_flags.json`, `negation_cues.json`, +`units_forms.json`, `routes.json`, `laterality.json`, `pregnancy.json`, +`lasa_pairs.json` (~30 known pairs), `abbreviations_vi.json` (HA, TC, …). +Glossary seed: ~150 curated Vi↔En drug/term pairs committed as CSV. +Full Meddict (>64k entries) import behind `glossary/import_meddict.py` — **do not block +on it**; license unverified (research §Meddict), run when cleared. + +## 8. Phases and tickets + +### Phase 0 — Scaffold (research Epic 1, 16) + +- **0.1 Repo scaffold.** Layout from §4; `pyproject.toml` (fastapi, uvicorn, pydantic-settings, + sqlmodel, pytest, httpx, anthropic, openai, jiwer, sacrebleu); Vite React-TS app; + `.env.example` (`PROVIDER_MODE`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `ADMIN_TOKEN`, + `CONFIDENCE_THRESHOLD`); README with run commands. GitHub Actions: lint (ruff, eslint) + + pytest + vitest. *Accept:* CI green on empty-ish repo; `uvicorn app.main:app` serves health check. +- **0.2 Provider interfaces + mocks.** `base.py` Protocols: + `ASRProvider.transcribe(audio: bytes, lang: str) -> ASRResult(text, confidence)`; + `MTProvider.translate(text, src, tgt, glossary_hits: list[GlossaryEntry]) -> MTResult(text, confidence)`; + `ReviewerProvider.review(source, translation, src, tgt) -> Review(back_translation, entities: list[CriticalEntity], flags)`. + Mocks: keyed canned responses + passthrough default; injectable error/low-confidence cases. + *Accept:* conformance tests pass for mocks; provider chosen by `PROVIDER_MODE`. + +### Phase 1 — Core loop, mock mode (Epics 2, 3, 4, 8) + +- **1.1 DB + models.** §5 schema, SQLModel, session/turn/feedback CRUD. *Tests:* round-trip, + hard delete cascades. +- **1.2 Session API + WS orchestrator.** §6 contract; pipeline = ASR → normalize (stub) → + glossary lookup (stub) → MT → risk (stub returns `low`) → persist → emit `turn_result`. + Typed-text path skips ASR. *Accept:* WS turn round-trips in mock mode; overlapping turn + rejected; WS reconnect resumes session. *Tests:* contract tests with mocks, incl. dropped + connection mid-turn. +- **1.3 Push-to-talk UI.** Two big hold-to-talk buttons (Doctor 🇻🇳 / Patient 🇬🇧), + MediaRecorder capture → WS chunks, turn state indicator, typed-input fallback, + bilingual transcript pane (source + translation per turn, timestamped). Browser TTS on + `turn_result` (vi-VN/en-US voice pick with graceful no-voice fallback to text-only). + *Accept:* full spoken round trip against mock backend; mic-permission-denied path shows + typed fallback. *Tests:* vitest components + 1 Playwright mock-mode e2e. +- **1.4 Consent gate.** Session-start screen (Vi + En side by side): AI disclosure text, + interpreter-right notice, consent checkboxes → `POST /api/sessions`. Mic UI unmounted + until consent recorded. *Accept:* no WS/mic possible pre-consent; consent stored in + `consent_json`. + +### Phase 2 — Real providers (Epics 5, 6) + +- **2.1 OpenAI ASR adapter.** `gpt-4o-transcribe`, per-turn lang hint, confidence derived + from logprobs (document the derivation), timeout+retry, `turn_error` on failure. + *Tests:* mocked HTTP; golden-audio fixture test marked `@pytest.mark.live` (skipped in CI). +- **2.2 Claude MT adapter.** `claude-sonnet-5`; system prompt: translate only, preserve + numbers/units/negation verbatim, never add advice; glossary hits injected as constraints; + output parsed strictly (reject non-translation content — invariant 1). *Tests:* prompt + snapshot, parse rejection cases, glossary-term forcing against mocked API. +- **2.3 Claude reviewer adapter.** High/critical turns only: back-translate + extract + `CriticalEntity{kind: drug|dose|frequency|route|allergen|laterality|negation, source_span, + translated_span}` as JSON. *Tests:* schema validation, malformed-output fallback (fail + closed → turn stays blocked, doctor sees raw source + translation). +- **2.4 Normalizer.** Vietnamese number words → digits ("một trăm hai mươi"→120, + "nửa"→0.5), unit canonicalization (mg/ml/mcg, mi-li-gam→mg), relative dates ("sau mười + ngày"→ "+10 days"), diacritic-stripped fallback matching for glossary. Pure functions. + *Tests:* table-driven, ≥40 cases from research failure modes #3–5, #15–16, #26, #30. + +### Phase 3 — Safety layer (Epics 9, 10, 11, 12; the heart of the product) + +- **3.1 Glossary + lexicons.** SQLite-backed glossary (term_vi, term_en, kind, lasa_group), + exact + diacritic-insensitive + fuzzy (rapidfuzz ≥90) lookup; seed CSV ~150 terms; + lexicon JSON files from §7; `import_meddict.py` (guarded, not in CI). *Tests:* LASA + collision cases, multi-word terms, abbreviation expansion. +- **3.2 Risk engine.** §7 rules over (normalized source, translation); returns tier + spans + + mismatch flags. *Accept:* tiers match labeled fixtures — build `eval/fixtures/risk_cases.jsonl` + with ≥3 cases per failure mode #1–30 from research (source of truth for behavior). + *Tests:* the fixture run IS the test. Zero misses on critical fixtures. +- **3.3 Risk highlighting UI.** Colored spans by severity in both transcript columns; + legend; critical spans get acknowledgment affordance. *Tests:* rendering, overlapping + spans, a11y (contrast + non-color severity cue). +- **3.4 Confirmation/read-back flow.** High/critical: modal for doctor — source text, + translation, reviewer's back-translation + entity table (drug/dose/frequency/route/ + laterality/negation) — actions: Confirm / Edit translation / Escalate. Patient panel + + TTS release only on confirm (invariant 2). Edits saved to `corrected_text`, status + `corrected`. *Tests:* dosage/allergy/negation scripted flows; doctor-edit path; + reviewer-failure fail-closed path. +- **3.5 Low-confidence + escalation.** Below-threshold → banner "Low confidence — please + repeat or type" + re-record/typed affordances (invariant 3). Persistent "Human interpreter" + button → full-screen bilingual escalation card (placeholder contact copy) + session + status `escalated` (invariant 6). *Tests:* injected low-confidence mock; escalation + state machine. + +### Phase 4 — Feedback, review, evaluation (Epics 14, 15, 17) + +- **4.1 Per-turn feedback.** Flag icon per turn → reason picker (§5 enum) + comment. + *Tests:* submit, appears in review query. +- **4.2 Review page.** Single admin route (X-Admin-Token): filter turns by risk/flagged/ + low-confidence/escalated; show source, translation, correction, feedback; CSV export. + No dashboards, no charts. *Tests:* auth reject, pagination, export. +- **4.3 Eval harness.** `python eval/run_eval.py --set fixtures/eval_500.tsv --providers mock|real`: + runs text-mode pipeline; computes WER (jiwer, when audio refs exist), BLEU/chrF + (sacrebleu), and preservation checks — number/unit exact, negation polarity, laterality, + drug-name exact, escalation correctness; emits JSON + markdown report vs thresholds + below. Ships with a 50-row starter TSV built from research examples; the real 500-set + is a clinical-team deliverable that drops into the same format. *Accept:* reproducible + report; CI runs it in mock mode as regression gate. + +**MVP thresholds (from research; calibrate with clinicians before pilot):** +number+unit preservation 100% · negation polarity 100% · drug-name terminology ≥98% · +escalation correctness ≥95% with zero missed red-flags · end-to-end turn latency ≤5s · +**pilot go/no-go: zero critical-harm errors uncaught by the safety layer.** + +### Phase 5 — Pilot readiness (mostly non-code; codex assists) + +- **5.1 Hardening.** Rate limiting, request size caps, structured logs with PHI-safe + redaction (log turn IDs + tiers, never text at INFO), retention config + (`RETENTION_DAYS`, purge job), TLS/deploy notes. +- **5.2 Docs.** README: run modes, provider config, threshold calibration, known + limitations (verbatim from research §Open Questions), consent copy for counsel review. +- Human tasks (not codex): 500-set annotation, clinician calibration of thresholds and + lexicons, Vietnamese counsel review (Decree 13 / PDP Law DPIA, cross-border transfer — + note both Anthropic and OpenAI APIs are offshore processors of sensitive audio/text; + the adapter design exists precisely so an in-country/on-prem swap is possible), pilot + clinic + interpreter partner. + +### Phase 6 — Post-review remediation (added 2026-07-08 after code review of Phases 0–4) + +Phases 0–4 are implemented and green (backend 92 tests, frontend 5, Playwright e2e, +eval harness). A code review found the defects below. Work these tickets **in order** +— they are ranked by severity. Phase 5.1's hardening items are expanded into concrete +tickets 6.9–6.11 here; treat this section as superseding 5.1. + +- **6.0 Initialize the repository.** `.git/` is an empty directory — there is no repo, + no history. `git init`, verify `.gitignore` excludes what's currently lying around + (`carepath.db`, `__pycache__/`, `dist/`, `*.egg-info/`, `test-results/`, caches — the + existing .gitignore already covers all of these), initial commit of the clean tree. + Do this FIRST so every following fix is a reviewable diff. + *Accept:* `git log` shows one initial commit; `git status` clean after a test run. + +- **6.1 Fix normalizer corrupting Vietnamese words into digits.** `_replace_number_words` + ([backend/app/normalize.py:108-147](backend/app/normalize.py#L108-L147)) substitutes bare + number-words context-free after diacritic folding, so common words collide: + "Ba của cháu bị đau bụng" (My dad has stomach pain) → "3 của cháu bị đau bụng"; + "anh Tư đến khám" → "anh 4 đến khám". The corrupted text is what MT translates. + **Rule:** only digit-replace a number-word run when (a) the run has ≥2 number tokens + ("hai mươi", "năm trăm"), or (b) a single number-word is immediately followed by a + unit/classifier/time word (viên, gói, ống, lần, ngày, tuần, tháng, giờ, mg, ml, mcg, + tuổi — new lexicon list in normalize.py). Never replace a capitalized word that is not + sentence-initial (names: Tư, Ba, Năm are common). Keep "nửa"+classifier → 0.5. + *Accept/tests:* table-driven cases — the two corruptions above stay unchanged; + "uống nửa viên, ngày hai lần" → "uống 0.5 viên, ngày 2 lần", "năm trăm mi-li-gam" + → "500 mg", "tái khám sau mười ngày" → "+10 days" all still pass; full risk-fixture + and eval-harness runs stay green. + +- **6.2 WS pipeline errors must send `turn_error`, not kill the socket.** The WS loop + ([backend/app/api.py:290-311](backend/app/api.py#L290-L311)) has no try/except around + `process_audio_turn`/`process_text_turn`; an ASR `RuntimeError` (after retries) or a + `ProviderOutputError` from strict MT parsing propagates and drops the connection with + no message — routine in cloud mode. Wrap both calls: on exception, log (id + error + class only, no text — see 6.10), send + `{type:"turn_error", message:"translation failed — retry or use typed fallback", + retryable:true}`, clear the in-flight entry, keep the loop alive. + *Tests:* WS tests using `MockASRProvider(fail=True)` and `MockMTProvider(fail=True)` + assert the client receives `turn_error` and can complete a following turn on the + same socket. + +- **6.3 Restore diacritics in the Vietnamese consent copy.** The legally significant + disclosure ([frontend/src/components/ConsentGate.tsx:33-38](frontend/src/components/ConsentGate.tsx#L33-L38)) + is stripped of diacritics, violating the AGENTS.md convention. Correct copy: + "Thông báo sử dụng AI" / "Công cụ phiên dịch" / "Công cụ này chỉ phiên dịch lời nói. + Kết quả có thể sai và không thay thế lời khuyên y tế, chẩn đoán, hoặc khuyến nghị + dùng thuốc." Also make the two consent-checkbox labels bilingual (En + Vi). + *Tests:* ConsentGate test asserts the diacritic strings render. + +- **6.4 One-sided number-mismatch check.** `_mismatch_spans` + ([backend/app/risk/engine.py:177](backend/app/risk/engine.py#L177)) requires digits on + BOTH sides, so a translation that drops the number entirely — the worst case — passes + silently. Change: if the normalized source contains digits and the translation contains + none → `number_mismatch`, critical. (Both-sides-differ stays as is.) + *Tests:* new risk fixtures — unit-less numeric utterance (e.g. blood-pressure value) + with digit-free translation must tier critical; existing fixtures stay green. + +- **6.5 Reviewer returns entity text, not character spans.** The read-back table shows + offsets like "12-18" ([frontend/src/components/InterpreterConsole.tsx:253-259](frontend/src/components/InterpreterConsole.tsx#L253-L259)) + — unusable for the doctor, and LLM-generated indices are unreliable anyway. This was a + PLAN §Phase-2 contract mistake, not an implementation error. Change `CriticalEntity` + to `{kind, source_text, translated_text}` (drop spans) in + [backend/app/providers/base.py](backend/app/providers/base.py), the reviewer system + prompt + parser, mock, `review_payload` in session.py, and the UI table (render the + two text columns). *Tests:* update reviewer parse tests + session safety test; + UI test asserts actual entity text renders in the confirmation card. + +- **6.6 Minimal admin review UI.** PLAN 4.2 shipped API-only. Add one frontend route + `/admin`: token input (kept in memory, sent as `X-Admin-Token`), filter controls + mapping to the existing query params, results table (source, translation, correction, + risk tier, status, feedback), CSV download link. Nothing else — no charts. + *Tests:* component test with mocked fetch: 401 path shows error, rows render. + +- **6.7 Small backend guards (one ticket).** + (a) Startup guard: `provider_mode=cloud` + `admin_token=="change-me"` → refuse to boot + with a clear error. (b) WS + REST reject new turns/confirms on `ended` sessions + (escalated sessions stay usable — communication continues while the interpreter is + arriving). (c) `confirm_turn` returns 409 unless status is `awaiting_confirm` or + `blocked`. (d) Console header shows real provider mode from `/api/health` instead of + hardcoded "Mock mode session". *Tests:* one per guard. + +- **6.8 Don't block the event loop on provider calls.** The WS handler calls the + synchronous pipeline inline; in cloud mode a slow provider (up to 30s timeout) freezes + every connection. Wrap the `process_*` calls with `await anyio.to_thread.run_sync(...)`. + *Tests:* existing WS tests still pass (behavioral change is concurrency-only). + +- **6.9 Retention purge.** `RETENTION_DAYS` setting (default 30, `0` = keep forever); + on startup delete sessions (+ turns/feedback) older than the cutoff. + `# ponytail: startup purge only; add a scheduler when the app runs for weeks unattended.` + *Tests:* seeded old session purged, recent kept, `0` keeps all. + +- **6.10 PHI-safe logging + size caps.** Structured logging: INFO logs may carry ids, + tiers, statuses, error classes — never `source_text`/`translation`/audio. Cap audio + per turn (`MAX_TURN_AUDIO_BYTES`, default 10 MB — reject with `turn_error`) and typed + text length (2 000 chars). *Tests:* oversized audio → turn_error; log-capture test + asserts no turn text at INFO. + +- **6.11 CI runs the Playwright e2e.** Third job: install both apps, start backend in + mock mode, `npx playwright test`. *Accept:* e2e green in CI, not just locally. + +Not fixing (accepted for MVP): transcript `highlightText` marks only the first risk +span per turn (badges list the rest); in-flight turn lock is process-local +(single-worker deploy); admin review endpoint scans in Python (single-clinic scale). + +## 9. What we deliberately did NOT build (and when to add) + +- **Server TTS adapter** → add if browser voices fail clinician testing (Phase 3 review). +- **ML NER (ViHealthBERT) risk detection** → add behind risk-engine interface when rules + plateau on eval fixtures. +- **Fine-tuned ASR/MT (VietMed/ViMedCSS/MedEV, PiDA augmentation)** → post-MVP; adapters + are the seam. Fine-tuning is the biggest known quality lever (research finding 3). +- **Streaming partial transcripts** → add if ≤5s turn latency fails; push-to-talk turns + are short, so batch-per-turn likely suffices. +- **Auth/multi-tenant, Postgres, visit summaries, analytics dashboards** → post-pilot. + +## 10. Open questions that block PILOT, not build + +Copied from research §Open Questions — legal (Vietnam DPIA + cross-border, US §1557/HIPAA +classification), clinical thresholds, formulary/LASA list ownership, pilot partner. Build +proceeds in mock + placeholder-provider mode regardless. diff --git a/docs/history/README.md b/docs/history/README.md new file mode 100644 index 0000000000000000000000000000000000000000..708650110c992f6e94ca537e501fd86cfd3667e8 --- /dev/null +++ b/docs/history/README.md @@ -0,0 +1,16 @@ +# CarePath Historical Plans + +These documents preserve the implementation history that led to the current +CarePath product. They are versioned reference material, not the default source +of truth for new work. Start with `AGENTS.md`, `docs/product/`, and the active +work documents instead. + +| Document | Status | Use only when | +| --- | --- | --- | +| `PLAN.md` | Historical Interpreter MVP plan | Tracing original safety, API, or risk-engine decisions | +| `DEMO-SITE-PLAN.md` | Historical demo-site plan | Investigating earlier public-site intent | +| `MERGE-PLAN.md` | Executed unification plan | Understanding the two-product merge and route history | +| `JUDGE.md` | Historical unification review protocol | Reproducing the original merge review | + +The documents are archived without content changes. Do not treat their pending +or dated tickets as current work. diff --git a/docs/onboarding-ux-fix-tasks.md b/docs/onboarding-ux-fix-tasks.md new file mode 100644 index 0000000000000000000000000000000000000000..130916cae389771d7734cbd39d874551b9cca549 --- /dev/null +++ b/docs/onboarding-ux-fix-tasks.md @@ -0,0 +1,234 @@ +# CarePath onboarding UX/UI fix — execution backlog + +**Status:** implementation backlog. Successor to `docs/ux-redesign-carepath.md`: that document's functional stories (intent quiz, consent gate, stepper, device check, first-session checklist) are implemented and tested; this backlog fixes their **visual execution**, which failed QA review. + +**Objective:** make both product onboardings — the Interpreter app (`interpreter/frontend/`) and the Scribe tool (`scribe/frontend/`) — match the design language already shipped on the landing page, and fix the concrete layout defects captured in `.codex/qa-evidence/`. + +**Evidence baseline (look at these before coding):** + +| Defect | Capture | +|---|---| +| Intent quiz is a bare fieldset with default radios in an empty page; no stepper, no context | `.codex/qa-evidence/interpreter-quiz-state-1440x900.png` | +| Consent stepper is a right-rail stack of gray input-looking boxes with uppercase text statuses; giant flat gray disabled button | `.codex/qa-evidence/interpreter-consent-fixed-1440x900.png` | +| Device-check page heading renders above/behind the sticky header (stacking bug); page is one button in a void | `.codex/qa-evidence/interpreter-device-state-1440x900.png` | +| Console session-action chips overlap the checklist card; PTT button reads as disabled; patient panel is an empty white box | `.codex/qa-evidence/console-fixed-1440x900.png` | +| Scribe explainer step 4 orphan-wraps under step 1; dead space in card; H1 breaks mid-word ("bệnh / án"); 360px header truncates VI/EN toggle | `.codex/qa-evidence/scribe-fixed-1440x900.png`, `scribe-fixed-360x800.png` | + +## Hard rails — apply to every task + +- No new dependencies: no router, state library, i18n library, or CSS framework. Plain CSS custom properties and existing React state only. +- Consent controls are never pre-checked, defaulted, or gamified. Both attestations stay required. The honest-progress stepper policy in `docs/ux-redesign-carepath.md` stands: no step displays complete without a real completed action or persisted prior choice. +- No `getUserMedia` call, `/api/` request, or WebSocket before consent submission. `interpreter/frontend/tests/mock-mode.spec.ts` asserts this and must stay green. +- Internal identifiers (`scribe`, `interpreter`, `doctor`, `patient`, `vi`, `en`), API paths, WebSocket events, risk rules, and TTS eligibility (`canSpeakTurn` in `interpreter/frontend/src/tts.ts`) are unchanged. +- Preserve keyboard operability, ARIA roles/labels, forced-colors support, and reduced-motion support — all were deliberately built and are covered by tests and QA captures. +- Playwright specs assert on exact Vietnamese labels. Any copy change updates the spec in the same commit. +- Preserve every safety invariant in `AGENTS.md`. A UI change must never release blocked content, un-suppress TTS, or start audio before consent. +- One task per commit, in the order below. Each task is presentation/layout only unless its scope says otherwise. + +## Delivery order + +| Order | Task | Area | Depends on | +|---:|---|---|---| +| 1 | FIX-01 Design tokens for the interpreter app | frontend | None | +| 2 | FIX-02 App shell + sticky-header stacking bug | frontend | FIX-01 | +| 3 | FIX-03 Unified onboarding frame + stepper restyle | frontend | FIX-02 | +| 4 | FIX-04 Intent quiz option cards | frontend | FIX-03 | +| 5 | FIX-05 Consent screen hierarchy | frontend | FIX-03 | +| 6 | FIX-06 Device-check green room | frontend | FIX-03 | +| 7 | FIX-07 Console first-run polish | frontend | FIX-01, FIX-02 | +| 8 | FIX-08 Scribe onboarding layout fixes | site | None (parallel-safe) | +| 9 | FIX-09 Landing → product handoff continuity | site + frontend | FIX-03, FIX-08 | +| 10 | FIX-10 QA gate and evidence refresh | both | All above | + +FIX-08 can run in parallel with FIX-01…07. FIX-04, FIX-05, FIX-06 can run in any order after FIX-03. + +--- + +### FIX-01 — Port the landing-page design tokens into the interpreter app + +**Files:** `interpreter/frontend/src/App.css` (top), reference `scribe/frontend/src/styles.css:14-70`. + +`scribe/frontend/src/styles.css` already defines the full system: `--ink #102a2e`, `--ivory #f4f1e8`, `--teal #0f766e`, `--mist`, `--amber`, `--critical`, derived `--bg/--text/--muted/--line/--surface` via `color-mix`, dark-panel tokens `--deep/--on-deep/--accent-on-deep`, radii `--r-panel/--r-control`, `--shadow`, and a `prefers-color-scheme: dark` override block. Port this token block (or the subset the app needs) into a `:root` block at the top of `interpreter/frontend/src/App.css`, then replace every hardcoded hex in the file with the matching token. `interpreter/frontend/src/App.css` currently has **zero** custom properties; colors like `#f4f1e8`, `#102a2e`, `#0f766e`, `#c7d4d1` repeat throughout — most map 1:1 to existing tokens. + +**Acceptance criteria** + +- [ ] All colors in `interpreter/frontend/src/App.css` come from custom properties; no raw hex outside the `:root` blocks. +- [ ] Safety-state colors (blocked/high-risk, low-confidence amber, escalation, critical) keep ≥ 4.5:1 text contrast in both light and dark schemes. +- [ ] Forced-colors behavior is unchanged (existing `forced-colors` rules still apply). +- [ ] No TSX/markup changes in this commit. + +**Verify:** `npm.cmd --prefix frontend test && npm.cmd --prefix frontend run build && npm.cmd --prefix frontend run e2e` + +### FIX-02 — App shell restyle and sticky-header stacking bug + +**Files:** `interpreter/frontend/src/App.tsx` (`.product-shell` header, lines ~24–64), `interpreter/frontend/src/App.css`. + +1. Fix the stacking bug first: on the device-check capture the page `

` paints above/behind the sticky header. Give the sticky header an explicit `z-index` above page content and audit for any `position`/`transform` on page containers creating competing stacking contexts. +2. Restyle the header to match the landing header (`scribe/frontend/`): logo + breadcrumb on the left, status note, VI/EN pill toggle, and the "Tất cả chức năng" link styled as the landing's dark link-button. +3. At 360px the header must not wrap mid-word or truncate the toggle: collapse to logo + toggle with the breadcrumb text truncating with ellipsis, or stack in a defined two-row layout. + +**Acceptance criteria** + +- [ ] No content ever paints over the sticky header on any onboarding or console screen. +- [ ] Header is visually consistent with the landing header (same tokens, radii, weights). +- [ ] At 360×800 and 390×844: no horizontal overflow, no mid-word wraps, toggle fully visible. + +**Verify:** frontend commands above. + +### FIX-03 — Unified onboarding frame; stepper on every step + +**Files:** `interpreter/frontend/src/App.tsx` (screen ladder, lines ~106–117), `interpreter/frontend/src/components/OnboardingStepper.tsx`, `interpreter/frontend/src/components/ConsentGate.tsx`, `interpreter/frontend/src/components/IntentQuiz.tsx`, `interpreter/frontend/src/components/DeviceCheck.tsx`, `interpreter/frontend/src/App.css`. + +Create one shared onboarding layout — centered max-width column (~40rem), product kicker + screen title, compact **horizontal** stepper at top — and use it for the quiz, consent, and device-check screens. Today the stepper renders only inside ConsentGate, so the quiz and device screens float context-free. Restyle `OnboardingStepper` from the gray box-stack with uppercase text statuses ("ĐÃ HOÀN THÀNH") to numbered dots with check marks for completed steps and a filled current step. + +**Scope guard:** presentation only. Do not change the screen-selection ladder logic, the honest-progress completion logic, localStorage keys, or when each screen mounts. + +**Acceptance criteria** + +- [ ] Quiz, consent, and device check all render inside the same frame with the stepper visible. +- [ ] Stepper uses dots/checks; state is conveyed by text and structure too (visually-hidden or short label per step), not color alone; current step keeps `aria-current="step"`. +- [ ] Consent step never shows complete before both attestations are checked and submitted (existing tests in `OnboardingStepper.test.tsx` stay green). +- [ ] No horizontal overflow at 360×800; stepper labels may collapse to numbers + current-step label on narrow widths. + +**Verify:** frontend commands above. + +### FIX-04 — Intent quiz option cards + +**Files:** `interpreter/frontend/src/components/IntentQuiz.tsx`, `interpreter/frontend/src/copy.ts`, `interpreter/frontend/src/App.css`. + +Replace the bare fieldset radios with large selectable option cards: full-row click targets, visible selected state (border + check), a small "Đề xuất" badge on the recommended default (Bác sĩ, VI→EN), one question per screen as today. Keep native radio inputs inside the cards for semantics; keep the skip link. + +**Scope guard:** keep localStorage keys `carepath-onboarding-role` / `carepath-onboarding-direction`, the two-step order, defaults, and the skip behavior. The Playwright flow clicks "Tiếp tục" twice — do not rename it. + +**Acceptance criteria** + +- [ ] Options are card-sized targets (≥ 44px tall), operable by mouse, touch, and arrow keys as a radio group. +- [ ] Selected state visible in forced-colors mode (border/outline, not background alone). +- [ ] Recommended badge is presentation only — the default is still just a pre-selected radio, and skip still applies defaults. + +**Verify:** frontend commands above. + +### FIX-05 — Consent screen hierarchy + +**Files:** `interpreter/frontend/src/components/ConsentGate.tsx`, `interpreter/frontend/src/copy.ts`, `interpreter/frontend/src/App.css`. + +Replace the two-column layout (text left, stepper-box-stack right) with a single centered column: title → one-paragraph what-it-does (bilingual pair as today) → demo simulation card → attestations → start button. Style each attestation as a bordered full-row label (checkbox + VI text + EN companion) so the whole row is the tap target. The demo preview becomes a visually distinct card labeled "Mô phỏng" with its existing disclaimer. Give the disabled start button an adjacent hint line ("Đánh dấu cả hai xác nhận để bắt đầu" + EN companion) instead of relying on a gray bar to explain itself. + +**Scope guard:** consent payload shape, both-required logic, session-creation timing, and error announcement behavior unchanged. Checkboxes never pre-checked. The button label "Bắt đầu phiên dịch" is asserted by Playwright — keep it. + +**Acceptance criteria** + +- [ ] Single-column flow with no dead zones at 1440×900; content column ≤ ~44rem. +- [ ] Attestation rows are full-width click targets; checkbox focus ring visible. +- [ ] Hint next to the disabled button; hint disappears (or turns confirmatory) when both are checked. +- [ ] Disabled state distinguishable in forced-colors mode. + +**Verify:** frontend commands above. + +### FIX-06 — Device-check green room + +**Files:** `interpreter/frontend/src/components/DeviceCheck.tsx`, `interpreter/frontend/src/copy.ts`, `interpreter/frontend/src/App.css`. + +Lay the screen out as a "green room" card inside the FIX-03 frame: mic icon + title, device picker (already implemented — make it visible in the idle frame, not only post-test), the live level meter with a labeled scale, and a state line for each of the existing states `idle | ready | unavailable | denied` with Vietnamese-first remediation copy for denied/unavailable. Primary action "Tiếp tục bằng micrô" appears/enables only after a passed check (the existing fail-closed rule); "Tiếp tục bằng văn bản" stays as the secondary path. + +**Scope guard:** no changes to `getUserMedia`/`enumerateDevices`/AudioContext logic, track-stopping cleanup, or the `onComplete` payload. Both continue-button labels are asserted by Playwright — keep them. + +**Acceptance criteria** + +- [ ] Device picker and meter are visible in the card before testing (meter idle/empty until test starts). +- [ ] The meter visibly responds to speech during the test; state changes are announced (existing live region preserved). +- [ ] Denied/unavailable states show remediation copy and a retry action, styled as states of the card rather than bare text. +- [ ] Heading no longer collides with the sticky header (regression check on the FIX-02 bug). + +**Verify:** frontend commands above. + +### FIX-07 — Console first-run polish + +**Files:** `interpreter/frontend/src/components/InterpreterConsole.tsx`, `interpreter/frontend/src/components/SessionChecklist.tsx`, `interpreter/frontend/src/App.css`. + +Presentation-only fixes to the console's first-run state: + +1. **Session-action chips** ("Kết thúc phiên", "Rút lại xác nhận", "Xóa dữ liệu phiên") currently overlap the checklist card edge. Move them into a proper toolbar row (right-aligned under the page header or grouped in a session menu region) with normal document flow — no negative margins/absolute positioning. +2. **Push-to-talk** is a flat gray box that reads as disabled. Restyle as the page's primary control: teal ready state with mic glyph, distinct recording state (filled + pulse, gated by `prefers-reduced-motion`), processing state (spinner/label), true disabled state visually distinct from ready. +3. **First-session checklist** becomes a compact dismissible panel that does not push the speaker regions below the fold at 1440×900 (collapse to a slim bar after first render, or dock aside the transcript). +4. **Empty states:** the patient panel and transcript get one-line guidance ("Lượt dịch sẽ hiện ở đây…" + EN) instead of blank white boxes. + +**Scope guard:** `InputState` machine, recording handlers, WebSocket handling, risk masking, confirmation, escalation, TTS suppression, and checklist completion logic unchanged. "Nhấn giữ để nói" and the session-action labels are asserted by Playwright — keep them. + +**Acceptance criteria** + +- [ ] No overlapping elements at any of the four QA viewports. +- [ ] PTT states ready/recording/processing/disabled are each visually distinct, announced as today, and distinguishable in forced-colors mode. +- [ ] Speaker regions and PTT are above the fold at 1440×900 with the checklist present. +- [ ] Recording pulse animation disabled under `prefers-reduced-motion`. + +**Verify:** frontend commands above, plus `python -m pytest` (safety suite) once. + +### FIX-08 — Scribe onboarding layout fixes + +**Files:** `scribe/frontend/src/scribe/ScribeTool.tsx` (pre-start explainer at ~line 305, processing steps at ~line 374), `scribe/frontend/src/styles.css`, `scribe/frontend/src/content/strings.ts` only if a label must change. + +1. Fix the pre-start explainer (`preStartSteps`) grid: steps 1–4 must read in order without step 4 orphan-wrapping under step 1 — use a 2×2 grid at desktop and a single column on mobile, or one vertical list. +2. Remove the dead space inside the pre-start card (large empty band between steps and disclaimer). +3. Prevent mid-word H1 wraps: `text-wrap: balance` on the heading plus a non-breaking space in "bệnh án" (and check other compounds: "bản nháp", "phiên âm"). +4. Fix the 360px header: VI/EN toggle fully visible, breadcrumb truncates with ellipsis instead of pushing the toggle off-canvas. +5. The processing step-progress UI already exists (`doneSteps` timers) — visually align it with the explainer's numbered-step styling so pre-start and processing read as the same system. No timer/logic changes. + +**Scope guard:** upload validation, API calls, response rendering, and routes unchanged. Copy changes only if required for wrapping, mirrored in `strings.ts` for both languages. + +**Acceptance criteria** + +- [ ] Explainer steps read 1→4 in visual order at all four QA viewports; no orphan wrap. +- [ ] No mid-word breaks in Vietnamese headings at 360/390/768/1440. +- [ ] Header intact at 360×800. +- [ ] Existing `ScribeTool.test.tsx` and site e2e stay green. + +**Verify:** `npm.cmd --prefix site test && npm.cmd --prefix site run build && npm.cmd --prefix site run e2e` + +### FIX-09 — Landing → product handoff continuity + +**Files:** `scribe/frontend/src/LandingPage.tsx` (product-card CTAs), `interpreter/frontend/src/App.tsx` / first onboarding screen styles. + +1. Confirm both product-card CTAs carry `?lang=vi|en` when crossing origins (CP-ROUTE-02 rule); add it where missing so the interpreter app opens in the visitor's chosen language. +2. Make the first interpreter onboarding screen (quiz, post-FIX-04) visually echo the landing product card: same kicker style ("Trong buổi khám"), same product name treatment, so clicking "Bắt đầu phiên dịch" on the landing feels like entering the same product, not a different app. + +**Scope guard:** no route changes, no new query params beyond `lang`, no session/consent behavior changes. + +**Acceptance criteria** + +- [ ] Landing (VI and EN) → interpreter app preserves language without the user re-toggling. +- [ ] Kicker/title treatment on the quiz screen uses the same token-driven styles as the landing cards. + +**Verify:** both frontend and site command sets. + +### FIX-10 — QA gate and evidence refresh + +**Files:** tests + a fresh evidence directory (e.g. `.codex/qa-evidence-v2/` or `docs/qa-evidence/`). + +- Re-capture: landing, scribe pre-start, scribe processing/result, quiz (both questions), consent (unchecked + checked), device check (idle/ready/denied), console first-run, console high-risk blocked — at 360×800, 390×844, 768×1024, 1440×900, plus forced-colors for consent/device/console. +- Assert the pre-consent invariants still hold (no `getUserMedia`/`/api/`/WebSocket before consent submission; `scrollY === 0` after transitions). +- Keyboard-only pass: quiz → consent → device → typed turn → PTT → high-risk confirm → escalation → end/delete. +- Axe checks: no serious/critical violations on any onboarding screen. +- Manual: 200% zoom, reduced motion, NVDA reading order on consent and device check. + +**Full command list** + + python -m ruff check . + python -m pytest + npm.cmd --prefix frontend run lint + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e + npm.cmd --prefix site run lint + npm.cmd --prefix site test + npm.cmd --prefix site run build + npm.cmd --prefix site run e2e + +If QA finds a defect, reopen the owning FIX task — do not patch inside FIX-10. + +## Deferred unless separately approved + +- Any change to risk rules, confidence thresholds, provider behavior, API schemas, retention policy, or routes +- New dependencies of any kind (including icon libraries — use inline SVG) +- Redesigning the landing page itself (it is the design reference, not a target) +- Dark-mode work in the interpreter app beyond inheriting the ported token overrides diff --git a/docs/product/README.md b/docs/product/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b521f56eb0a25f9778ff0a3ba46ad03f14e8c2ba --- /dev/null +++ b/docs/product/README.md @@ -0,0 +1,9 @@ +# CarePath Product Contracts + +These documents describe current accepted CarePath behavior. They are derived +from `AGENTS.md`, the running product, and validated implementation; they are +not a replacement for the safety invariants in `AGENTS.md`. + +- `carepath-suite.md` — product identity, navigation, and source hierarchy. +- `ai-scribe.md` — Ghi chép bệnh án AI contract. +- `live-interpreter.md` — Phiên dịch khám bệnh trực tiếp contract. diff --git a/docs/product/ai-scribe.md b/docs/product/ai-scribe.md new file mode 100644 index 0000000000000000000000000000000000000000..bb8711699beb5aa8364127942e455abb6e1c4a49 --- /dev/null +++ b/docs/product/ai-scribe.md @@ -0,0 +1,34 @@ +# Ghi chép bệnh án AI + +## Contract + +Ghi chép bệnh án AI listens to a consultation and creates a structured clinical +note draft. It is appropriate when a clinician wants to reduce post-visit data +entry time. The clinician reviews the output before use. + +The serving boundary is `scribe/carepath` under `/api/v1/*`. The public site +provides the workflow from `/ghi-chep-lam-sang/`. + +## Constraints + +- The result is a draft, not independent clinical judgment. +- Uploaded audio is processed in temporary working storage only; it is not a + durable audio record. +- Provider failures return an explicit failure or configured safe fallback; + they do not silently invent clinical content. +- Vietnamese copy remains NFC-normalized with diacritics preserved. +- GEC training accepts a dataset only through a versioned manifest with source, + consent status, and SHA-256. Training is blocked until the owner approves the + manifest; no clinical audio is collected or stored by this repository flow. +- The committed text-only frozen baseline report is verified in CI. Before an + adapter can export, its frozen-fixture drug-name recall and dosage + number/unit preservation must not regress against the raw-ASR baseline. +- A SOAP fine-tuning decision is prohibited until the owner completes the + clinician-rating protocol for at least 50 de-identified pilot notes in an + approved environment. The rating schema stores no note text or audio here. + +## Proof + +The baseline proof is `CP-BASE-001`: root Python tests and the keyless backend +smoke test. A change to the API, audio pipeline, or provider boundary requires +the appropriate story and stronger validation. diff --git a/docs/product/carepath-suite.md b/docs/product/carepath-suite.md new file mode 100644 index 0000000000000000000000000000000000000000..9ba7618c8d40b742017155632b1869fafd5f3a7c --- /dev/null +++ b/docs/product/carepath-suite.md @@ -0,0 +1,18 @@ +# CarePath Suite + +CarePath is a Vietnamese-first clinical AI suite for Vietnamese clinics. It +has two distinct workflows; the user must never need to know the English terms +“Scribe” or “Interpreter” to choose the correct one. + +| Workflow | Purpose | Primary action | +| --- | --- | --- | +| Ghi chép bệnh án AI | AI hears a consultation and prepares structured clinical notes. | Bắt đầu ghi chép | +| Phiên dịch khám bệnh trực tiếp | Translates between a Vietnamese-speaking clinician and an English-speaking patient during a consultation. | Bắt đầu phiên dịch | + +The landing page frames this choice as `Bạn muốn hỗ trợ việc gì hôm nay?`. +Every clinical screen must say what workflow is active, what it helps with, +what to do next, and its relevant limit or risk. + +CarePath remains clinician-controlled: Scribe drafts require review, and the +Interpreter translates only. The detailed module contracts are +`ai-scribe.md` and `live-interpreter.md`. diff --git a/docs/product/live-interpreter.md b/docs/product/live-interpreter.md new file mode 100644 index 0000000000000000000000000000000000000000..81f09d745b0808ff3319afccfae60c2fd48b0252 --- /dev/null +++ b/docs/product/live-interpreter.md @@ -0,0 +1,32 @@ +# Phiên dịch khám bệnh trực tiếp + +## Contract + +Phiên dịch khám bệnh trực tiếp translates two ways between a Vietnamese-speaking +clinician and an English-speaking patient during a consultation. It is for +people who do not share a language. It translates only and must not provide +medical advice, diagnoses, or treatment recommendations. + +The serving boundary is `interpreter/app` under `/api/*` and `/ws/*`, integrated +into the combined FastAPI process. Its browser frontend is retained for +internal development but is not publicly served. + +## Public availability + +The web Interpreter is currently in development and is not open to users. +Scribe is the only public product. `/phien-dich-y-khoa/*` and `/console/*` +return 404; this does not change the Interpreter API, WebSocket, or safety +invariants used for internal development. + +## Safety Invariants + +- No microphone capture before recorded consent. +- Raw audio is not persisted; processing is memory-only. +- Low-confidence output is visibly flagged. +- High- and critical-risk turns remain blocked from patient display and TTS + until clinician confirmation. +- Pipeline or reviewer failure fails closed, showing the clinician the source + and translation with an escalation path rather than sending content onward. + +These are non-negotiable `AGENTS.md` invariants. Changes to them are always +high-risk and require updated fixtures and mock eval proof. diff --git a/docs/research.md b/docs/research.md new file mode 100644 index 0000000000000000000000000000000000000000..62d00527e8006706ef2837fa1a7d4a698ee3e17f --- /dev/null +++ b/docs/research.md @@ -0,0 +1,271 @@ +> Source: https://claude.ai/public/artifacts/3af319d8-e860-4c8c-aa6a-3954395f0bcd (saved 2026-07-08). This is the research basis for `history/PLAN.md`; read current product and safety instructions first, then consult the archived plan for MVP context. + +# Vietnamese ↔ English Live Clinical Translator — MVP Research & Planning Report + +# Executive Summary + +We recommend building a **hybrid cascaded speech-translation pipeline** (Audio → ASR → normalization → glossary-constrained MT/LLM → safety/risk classifier → bilingual transcript + TTS) with a mandatory **human-interpreter fallback**, scoped narrowly to the **outpatient consultation + medication/follow-up explanation** stage of the clinical workflow. This directly confirms the client's starting hypothesis. The Vietnamese-medical-NLP research base is now strong enough to build on: verified public datasets exist for medical MT (MedEV, 358,796 sentence pairs), medical ASR (VietMed, 16h labeled + 2,200h unlabeled), code-switching speech (ViMedCSS, 34h/16,576 utterances), speech translation (MultiMed-ST, 290,000 samples), NER (ViMedNER, ViMQ), and QA (ViMedAQA 44,313 triplets, ViHealthQA 10,015 pairs), plus a domain BERT (ViHealthBERT) and a phonetically-informed augmentation method (PiDA) specifically targeting ASR-error propagation in Vietnamese ST. + +The single most important finding: **published research and US federal guidance both conclude that unreviewed machine translation is not safe for high-stakes clinical communication.** In a 2025 BMJ Quality & Safety study (Kong M, et al., doi:10.1136/bmjqs-2024-018384) testing ChatGPT-4 and Google Translate on 50 sets (316 sentences) of emergency-department discharge instructions, up to 66% of Google-translated instruction sets (Russian) contained at least one inaccuracy, and the potential for harm reached ≤1% at the sentence level and ≤6% at the instruction-set level. The US HHS Office for Civil Rights "Dear Colleague" letter of December 5, 2024 — implementing the May 6, 2024 Section 1557 final rule, with full implementation due June 5, 2025, and expressly covering "machine translation, including artificial intelligence and emerging technologies" — states that machine translation of critical documents "must be reviewed by a qualified human translator to ensure accuracy," and absent review, patients "should be warned that the translated document may contain errors." The product must therefore be positioned as a **translation-and-verification aid with structured confirmation flows and interpreter escalation — never an autonomous diagnostic or advice tool.** + +Recommended direction: build the hybrid architecture with replaceable adapter interfaces for every AI component, design a 500-utterance bilingual evaluation set weighted toward high-risk categories, measure against explicit thresholds, and only then run a supervised clinical pilot. + +# Key Findings + +1. **Vietnamese medical NLP is no longer greenfield.** The VinUniversity Medical MT group and others have released a coherent stack of datasets and models covering exactly our use case: MedEV (MT), ViMedCSS + PiDA + LLM-near-misses (code-switching ASR/ST), MultiMed-ST and VietMed (medical speech), ViMedNER/ViMQ (NER), and ViMedAQA/ViHealthQA (QA). We can fine-tune and benchmark rather than build from scratch. + +2. **Cascaded pipelines beat end-to-end for low-resource Vietnamese, at the cost of error propagation.** On the strongest published low-resource comparison, an enhanced cascade reached 21.3 BLEU versus 17.97 BLEU for the best end-to-end system — a several-point advantage. Cascades are also debuggable, allow independent component swaps, and reuse large monolingual ASR and text-MT corpora. The main weakness — ASR errors propagating into MT — is directly addressable with phonetic augmentation (PiDA reports up to +2.04 BLEU on erroneous ASR output) and glossary/safety layers. + +3. **Fine-tuned domain models decisively outperform generic tools.** On MedEV, fine-tuned vinai-translate scored 52.14 BLEU (En→Vi) and 42.38 BLEU (Vi→En) on test, beating Google Translate (47.86 / 39.26) and far exceeding ChatGPT gpt-3.5-turbo (~36 / ~33). This means a generic API is an acceptable Day-1 placeholder but a fine-tuned/glossary-constrained model is needed for clinical quality. + +4. **Code-switching (English drug/procedure names inside Vietnamese speech) is a first-class failure mode**, not an edge case. ViMedCSS exists precisely because standard ASR mishandles English medical terms embedded in Vietnamese; the LLM-near-misses contrastive method reports >2% error-rate reductions at code-switch points. + +5. **Regulation forces human-in-the-loop.** HHS Section 1557 (2024 final rule) requires qualified interpreters/translators for critical healthcare communication and that machine translation of critical content be reviewed by a qualified human; Vietnam's Decree 13/2023 (and the new PDP Law effective 1 January 2026) classify health data as sensitive personal data requiring explicit, informed, purpose-specific consent. Both regimes shape MVP design. + +6. **Confirmation/read-back (teach-back) is an evidence-based safety mechanism** we can operationalize. Per AHRQ and Kessels (2003, J R Soc Med), patients immediately forget between 40% and 80% of the medical information they receive during office visits, and almost half of what they do retain is incorrect; structured teach-back materially improves comprehension and reduces readmissions. This is the clinical justification for our confirmation-mode feature. + +# Literature Review Matrix + +All entries verified against primary sources (arXiv, ACL Anthology, Hugging Face, GitHub, publisher pages, and the VinUni project page). Where a figure could not be independently confirmed it is marked *unverified*. + +| Paper / Dataset | Primary source | Task | Languages | Dataset size | Domain | Model approach | Reported results | Relevance to MVP | Implementation idea | Limitations | +|---|---|---|---|---|---|---|---|---|---|---| +| **PiDA: Phonetically-Informed Data Augmentation** (Nguyen et al., Interspeech 2026) | vinuni-medical-mt.github.io/#paper-pida; arXiv 2606.12911 | ST robustness / data augmentation | Vi→En | Uses FLEURS Vi-En (augmented) | Medical + general speech | Phonetic word-embedding substitutions to simulate ASR errors; fine-tune ST | Up to **+2.04 BLEU** over standard fine-tuning on erroneous ASR output; slight gain on clean text | Directly mitigates our #1 cascade risk (ASR→MT error propagation) | Apply PiDA-style augmentation when fine-tuning our MT component | Evaluated on FLEURS, not clinical dialogue; BLEU-level, not clinical-error metrics | +| **Contrastive Training with LLM-generated Near-Misses** (Nguyen & Truong et al., Interspeech 2026) | vinuni-medical-mt.github.io/#paper-near-misses; arXiv 2606.06985 | Code-switching ASR | Vi-En, Zh-En | CS-FLEURS (cmn-eng), ViMedCSS (vie-eng) | Medical CS speech | POI-aware contrastive training; Whisper-small + LoRA; multi-negative ranking loss | **>2%** reduction in general and CS-aware error rates vs standard LoRA fine-tuning | Improves recognition of English drug names embedded in Vietnamese | Use as ASR fine-tuning recipe for code-switch robustness | Whisper-small scale; gains reported as error-rate deltas | +| **ViMedCSS** (Nguyen et al., LREC 2026) | lrec2026-main-445; HF tensorxt/ViMedCSS | Code-switching ASR benchmark | Vi-En | **34 hours, 16,576 utterances**, ≥1 English medical term each, 5 medical topics | Medical CS speech | Benchmarks SOTA ASR; Vi-optimized vs multilingual pretraining vs combined | Vi-optimized best on general segments; multilingual best on English insertions; combined best balance | First CS benchmark = our accent/drug-name test data | Use as a core ASR benchmark set for code-switching error rate | 34h is modest; topic-limited lexicon | +| **MedEV / Improving Vi-En Medical MT** (Vo et al., LREC-COLING 2024) | aclanthology.org/2024.lrec-main.784; arXiv 2403.19161; HF nhuvo/MedEV | Medical MT | Vi-En | **358,796 sentence pairs** (340,897 train / 8,939 val / 8,960 test) | Medical text | Fine-tune vinai-translate, envit5, mBART; vs Google/ChatGPT | FT vinai-translate best: **52.14 BLEU En→Vi, 42.38 Vi→En (test)**; Google 47.86/39.26; ChatGPT ~36/~33 | Our core MT training/benchmark corpus | Fine-tune MT on MedEV; use test split in our harness | Text (not speech) domain; de-identified written medical text, not spoken dialogue | +| **Meddict** (VinUni; via arXiv 2509.15640) | arXiv 2509.15640 | Terminology lexicon | En-Vi | **>64,000 entries** | Medical terminology | Dictionary-augmented LLM prompting | Terminology-aware cues consistently improve domain translation | Seed for our medical glossary / terminology control | Import as glossary backbone for constrained decoding | License/coverage for spoken abbreviations unverified | +| **MultiMed-ST** (Le-Duc et al., EMNLP 2025) | aclanthology.org/2025.emnlp-main.599; arXiv 2504.03546; GitHub leduckhai/MultiMed-ST | Medical speech translation | Vi, En, De, Fr, Zh (all directions) | **290,000 samples** (largest medical MT/ST) | Medical speech | Whisper-family ASR + NMT; cascaded vs e2e, bilingual vs multilingual analysis | Largest many-to-many medical ST; extensive baselines (see paper) | Provides Vi↔En ST baselines + models we can benchmark against | Use as external ST baseline + additional training data | Translations LLM-generated (Gemini) — quality caveat; register is dialogue-transcribed | +| **VietMed** (Le-Duc et al., LREC-COLING 2024) | aclanthology.org/2024.lrec-main.1509; arXiv 2404.05659 | Medical ASR | Vi | **16h labeled + 1,000h unlabeled medical + 1,200h unlabeled general** | Medical speech | w2v2-Viet, XLSR-53-Viet pretraining/fine-tuning | XLSR-53-Viet cut WER from 51.8%→29.6% on test (>40% relative) | Core Vietnamese medical ASR model + benchmark | Candidate ASR adapter; covers all ICD-10 groups & accents | 16h labeled is small; 29.6% WER still high for clinical safety unaided | +| **ViHealthBERT** (Minh et al., LREC 2022) | aclanthology.org/2022.lrec-1.35; GitHub demdecuong/vihealthbert | Domain PLM (NER, acronym, FAQ) | Vi | Pretraining corpus + acrDrAid (135 keyword sets) + FAQSum | Vietnamese healthcare text | BERT pretraining; SOTA on NER/AD/FAQ | Outperforms general-domain PLMs on health tasks | Backbone for our medical NER / risk-term detection | Use for entity tagging in the safety layer | Text-domain; 2022 vintage | +| **ViMedNER** (Pham Van Duong et al., EAI INIS 2024) | publications.eai.eu/index.php/inis/article/view/5221; DOI 10.4108/eetinis.v11i3.5221 | Medical NER | Vi | **>8,000 annotated samples**; entities: disease, symptom, cause, diagnostic, treatment | Medical text (diagnosis/treatment) | PhoBERT, XLM-R, ViDeBERTa, ViPubMedDeBERTa, ViHealthBERT | XLM-R consistently best | Entity schema for highlighting symptoms/diseases | Fine-tune NER for key-term highlighting | Text source (web); no drug-dosage entities | +| **ViMQ** (Ta Duc Huy et al., ICONIP 2021) | arXiv 2304.14405; GitHub tadeephuy/ViMQ | Medical NER + intent | Vi | **9,000 questions**; entities SYMPTOM&DISEASE (13,253), MEDICAL_PROCEDURE (2,000), MEDICINE (979) | Patient medical questions (Vinmec) | Baselines + self-supervised span-noise training | Span-noise training +~3% F1 on ViMQ NER | Patient-side intent + entity extraction | Intent tags for routing/triage-lite; MEDICINE entities for glossary | Patient-authored text, not speech; small MEDICINE set | +| **ViMedAQA** (Tran et al., ACL 2024 SRW) | aclanthology.org/2024.acl-srw.31; HF tmnam20/ViMedAQA | Abstractive medical QA | Vi | **44,313 {paragraph, question, answer} triplets** (39,881/2,215/2,217); topics: disease 15,690, medicine 13,873, drug 9,780, body part 4,970 | Medical (YouMed) | 8 LLMs prompted (VinaLlama, Llama3, Gemma, PhoGPT, ViGPT) | VinaLlama-7B best avg 58.01 (En prompt); component BLEU 33.69, ROUGE-L 59.89 | Source of drug/disease QA phrasing; NOT for autonomous answers | Use to build glossary + test comprehension phrasing | QA generated by Gemini 1.0 then human-verified; not a safety-vetted advice source | +| **ViHealthQA** (Nguyen et al., KSEM 2022) | HF tarudesu/ViHealthQA; SpringerLink 10.1007/978-3-031-10986-7 | Health QA / IR | Vi | **10,015 question–answer passage pairs** (Vinmec, VnExpress) | Consumer health QA | SPBERTQA: SBERT + BM25, MNR loss | Two-stage system beats bag-of-words baselines | Consumer-health phrasing reference | Corpus for patient-question phrasing patterns | Consumer (not clinician) register; expert-answered forum text | +| **MultiMed (ASR)** (Le-Duc et al., 2024/2025) | arXiv 2409.14074 | Multilingual medical ASR | Vi, En, De, Fr, Zh | 150 hours across 5 languages | Medical speech | Attention encoder-decoder | Documents Vietnamese tone/vowel/dialect ASR error taxonomy | Evidence base for our failure-mode list | Cite for accent/tone robustness testing | Broad, not Vi-En clinical-dialogue specific | + +# Vietnamese-Specific Failure Modes (≥25) + +Severity scale: **Critical** (plausible serious harm), **High**, **Moderate**, **Low**. "Wrong output risk" describes the mistranslation that could occur. + +| # | Failure mode | Example (Vietnamese) | Wrong output risk | Clinical severity | Detection / mitigation | +|---|---|---|---|---|---| +| 1 | **Negation dropped/flipped** | "Không dị ứng với penicillin" | "Allergic to penicillin" (negation lost) | Critical | Negation-cue detector; force explicit polarity in read-back; highlight "không/chưa/không còn" | +| 2 | **Allergy term mistranslated** | "Dị ứng thuốc kháng sinh" | "Antibiotic reaction" vs "allergy" ambiguity | Critical | Allergy-intent classifier; mandatory confirmation of allergen + reaction | +| 3 | **Dosage number error** | "Uống nửa viên" (half a tablet) | "one tablet" / "five tablets" | Critical | Numeric-entity preservation check; back-translate dose; require confirm | +| 4 | **Frequency error** | "Ngày hai lần" (twice a day) | "two days" / "once a day" | Critical | Frequency normalizer (times/day, interval); read-back | +| 5 | **Unit confusion (mg/ml/mcg)** | "Năm trăm mi-li-gam" | "500 ml" instead of "500 mg" | Critical | Unit dictionary; flag missing/implausible units | +| 6 | **Tone-based homophone (ASR)** | "má" vs "mà" vs "ma"; "nặng nhọc" vs "năng ngọng" | Wrong word entirely | High | Tonal LM rescoring; low-confidence flag; PiDA-style robustness | +| 7 | **English drug name inside Vietnamese (code-switch)** | "Uống Augmentin sau ăn" | Drug name garbled/omitted | Critical | ViMedCSS-style CS ASR; glossary pinning of drug names | +| 8 | **Look-alike/sound-alike drug** | "Losec" vs "Lasix"; "Celebrex" vs "Celexa" | Wrong medication dispensed | Critical | LASA drug list check; spell + purpose confirmation | +| 9 | **Laterality (left/right)** | "Đau chân trái" (left leg) | "right leg" | Critical | Laterality keyword lock (trái/phải); highlight + confirm | +| 10 | **Southern accent variation** | Southern "d/gi/v" merger, final consonants | ASR substitution errors | High | Accent-diverse ASR training; confidence flagging | +| 11 | **Central accent variation** | Strong Central tonal/vowel shifts | High WER | High | Accent-robust models; fallback to text entry | +| 12 | **Medical abbreviation (Vi)** | "HA" (huyết áp = BP), "TC" (tiểu cầu = platelets) | Expanded wrong / left literal | High | Abbreviation expansion dictionary; context disambiguation | +| 13 | **Pronoun/politeness (kinship terms)** | "Con uống thuốc chưa?" (con = child/you) | Wrong referent (I/you/he) | Moderate | Speaker-role tagging; default clinical 2nd-person "you" | +| 14 | **Ambiguous subject omission** | "Đã uống thuốc" (subject dropped) | Wrong actor (I/you/patient) | High | Force subject clarification in medication context | +| 15 | **Date/time expression** | "Tái khám sau mười ngày" | "after ten months" / wrong date | High | Temporal normalizer; echo concrete date | +| 16 | **Numbers: Vietnamese large-number grouping** | "Một trăm hai mươi" (120) | "100 and 20" / 1,220 | High | Number parser; digit read-back | +| 17 | **Fast doctor shorthand / elision** | Rapid clipped speech | Dropped words, merged entities | High | VAD + segmentation; ask to repeat if low confidence | +| 18 | **Mask/PPE-muffled speech** | Muffled consonants | Higher WER | High | Noise-robust ASR; confidence threshold → re-prompt | +| 19 | **Hospital background noise** | Overlapping speech, alarms | Insertion/deletion errors | High | Noise-robustness testing; push-to-talk isolation | +| 20 | **Symptom severity misrender** | "Đau dữ dội" (severe) vs "đau nhẹ" (mild) | Severity understated | Critical | Severity lexicon; highlight intensifiers | +| 21 | **Pregnancy status** | "Đang mang thai" / "có bầu" | Missed → unsafe drug advice | Critical | Pregnancy-flag detector; force surfacing | +| 22 | **Prior medical history compression** | Long comorbidity list | Omission of a condition | High | Entity-count check source vs target | +| 23 | **Red-flag symptom (chest pain/breathing)** | "Đau ngực", "khó thở" | Understated/omitted → missed emergency | Critical | Red-flag keyword trigger → escalate / interpreter | +| 24 | **Stop vs continue medication** | "Ngưng thuốc" vs "tiếp tục thuốc" | Opposite instruction | Critical | Action-verb polarity check; explicit confirm | +| 25 | **Route of administration** | "Nhỏ mắt" (eye drops) vs oral | Wrong route | Critical | Route lexicon; confirm route with dose | +| 26 | **Number+classifier ("viên/gói/ống")** | "Hai gói" (two sachets) vs viên (tablets) | Wrong form/count | High | Classifier-aware dose parsing | +| 27 | **Homophonic disease terms** | "sốt" (fever) vs "xót"; ASR tone loss | Wrong symptom | High | Tonal LM + medical prior | +| 28 | **Patient English accent variation** | Non-native English patient reply | En ASR errors | Moderate | Robust En ASR; patient read-back on key items | +| 29 | **Idiomatic body-location terms** | "đau bụng" (abdomen, broad) | Over-precise mistranslation (stomach) | Moderate | Preserve vagueness; clinician clarifies | +| 30 | **Diacritic loss in transcript** | "thuoc" vs "thuốc" | Wrong word / ambiguity | Moderate | Enforce diacritic restoration in normalization | + +# Clinical Safety Plan (risk classification & mitigation) + +Core principle: the app **translates and helps verify communication; it never diagnoses, prescribes, or gives autonomous medical advice.** Risk tier drives app behavior. + +| Risk level | Scenario | Example | Recommended app behavior | +|---|---|---|---| +| **Low** | Logistics / non-clinical | Appointment time, directions, "Have you eaten?" | Translate normally; passive transcript; no special gating | +| **Medium** | Symptom history, general education | "Describe your symptoms", diet advice | Highlight key medical terms (NER); show confidence; allow quick correction | +| **High** | Medication, dosage, allergy, laterality, follow-up instructions | "Take half a tablet twice daily"; "Any drug allergies?" | **Require confirmation/read-back**; force numeric/unit/negation surfacing; log; offer interpreter | +| **Very high** | Consent, emergency/red-flag, mental-health crisis, breaking bad news | Chest pain, suicidal ideation, informed consent | **Halt autonomous mode; recommend/route to human interpreter**; display disclaimer; capture audit record | + +**Design mechanisms:** +- **Confirmation/read-back flow:** for High-risk utterances, the app back-translates the critical entities (drug, dose, frequency, route, laterality, negation) and presents them for explicit doctor confirmation before the patient hears the final version — operationalizing the evidence-based teach-back method (which counters the 40–80% of verbal information patients immediately forget). +- **Low-confidence handling:** ASR/MT confidence below threshold → visibly flag, request repeat, or fall back to typed text; never silently emit a low-confidence critical instruction. +- **Escalation logic:** red-flag keyword lexicon + Very-high intent classification triggers a prominent "Get a human interpreter" action. +- **AI-use disclosure:** patient-facing notice at session start that AI translation is being used and may contain errors, with the right to request a human interpreter (aligns with the HHS OCR Section 1557 machine-translation warning expectation). +- **Audit & correction:** every High/Very-high utterance, its confidence, any doctor correction, and escalation events are logged for review; doctor corrections feed the improvement loop. +- **Anti-scope-creep:** the model is instructed and post-filtered never to generate diagnostic conclusions, drug recommendations, or advice not spoken by a clinician; it only translates and surfaces what was said. + +# Compliance and Privacy Considerations + +*This is a research-backed summary, not legal advice. Engage Vietnamese counsel and US health-privacy counsel before any pilot.* + +| Jurisdiction | Requirement | Relevance to app | MVP implication | Open legal question | +|---|---|---|---|---| +| **Vietnam — Decree 13/2023 (PDPD)** | Explicit, voluntary, purpose-specific consent; health/medical data is sensitive personal data with stricter safeguards | Recording + AI processing of clinical speech = processing sensitive data | Build explicit consent capture; appoint data-protection responsibility; minimize data | Is spoken clinical audio "sensitive personal data" requiring a DPIA in our exact configuration? | +| **Vietnam — PDP Law (effective 1 Jan 2026)** | Elevates Decree 13 to statute; stricter compliance, DPIA/TIA, DPO duties; sector-specific AI rules; grace period for small firms | Governs any Vietnam deployment/pilot | Design for PDP Law from day one; verify DPO/DPIA obligations | Exact sensitive-data enumeration delegated to government decree — pending | +| **Vietnam — cross-border transfer** | Transfer of Vietnamese citizens' data abroad requires impact-assessment dossier + safeguards | Cloud ASR/MT/LLM APIs hosted abroad | Prefer in-country/on-prem processing; if using foreign APIs, prepare transfer dossier | Which cloud regions/vendors satisfy MPS requirements? | +| **Vietnam — breach notification** | Report breaches to Ministry of Public Security (Decree 13: 72h; PDP Law relaxes strict 72h) | Audio/transcript store is a breach target | Encryption, access controls, incident plan | Final breach-notification timeline under PDP Law guiding decree | +| **US — HIPAA** | Audio/transcript = PHI; safeguards, minimum necessary; Business Associate Agreement required with any vendor handling PHI | US clinic deployment | BAA with every ASR/MT/TTS/LLM vendor; encryption; access logging | Is the app a Business Associate or a conduit? | +| **US — Section 1557 (2024 final rule)** | Qualified interpreter/translator for critical communication; **machine translation of critical content must be reviewed by a qualified human**; warn of possible errors (HHS OCR Dec 5, 2024 letter; full implementation June 5, 2025) | Defines when our app alone is insufficient | Position as aid + interpreter fallback; display error warning; human review for critical content | Does live AI interpretation count as "machine translation" under 1557, and what review satisfies it? | +| **US — language access / Title VI** | Meaningful access; may not require patients to supply own interpreter; free, timely, accurate services | Our app cannot replace mandated interpreter services | Offer, don't force; interpreter fallback must be free/accessible | Threshold defining "critical" vs routine communication | +| **Both — data minimization / retention** | Store least data, shortest time | Sensitive audio is highest-risk asset | **Privacy mode: no audio storage by default**; ephemeral processing; configurable retention | Minimum retention needed for audit vs deletion duty | + +# MVP Architecture Recommendation + +**Recommended: Option C — Hybrid cascaded pipeline + medical glossary + safety layer + LLM reviewer fallback**, with every AI component behind a replaceable adapter interface. + +| Architecture | Pros | Cons | Debuggability | Latency | Safety | MVP suitability | +|---|---|---|---|---|---|---| +| **A. Pure cascade** (ASR→norm→MT→TTS) | Modular; reuses big corpora; best low-resource quality; each stage swappable | ASR errors propagate; more moving parts | High (inspect each stage) | Moderate (sum of stages) | Moderate alone | Good base, insufficient alone | +| **B. End-to-end ST** | Lower latency; simpler deploy; less explicit propagation | Needs large aligned Vi-En speech; opaque; hard to insert glossary/safety; lower low-resource quality | Low (black box) | Low | Hard to gate | Not yet for clinical safety | +| **C. Hybrid (cascade + glossary + safety + LLM reviewer)** | Cascade quality + terminology control + risk gating + fallback review; auditable | Most components to build; must manage latency | High | Moderate (optimize with streaming) | **Highest** — explicit safety layer & read-back | **Recommended** | + +**Rationale:** Low-resource Vietnamese-English evidence favors cascades on quality (21.3 vs 17.97 BLEU in the strongest published comparison), and clinical safety requires the ability to insert a glossary, run NER/risk classification, and gate high-risk output — which end-to-end models make difficult. The hybrid keeps cascade quality while adding the safety scaffolding regulation demands. + +**Components (each behind an adapter interface):** +1. **Vietnamese ASR** — candidate: PhoWhisper / XLSR-53-Viet / Whisper fine-tuned on VietMed + ViMedCSS. +2. **English ASR** — Whisper-family, robust to non-native patient accents. +3. **Text normalization** — numbers, units, dates, diacritic restoration, punctuation/segmentation. +4. **Translation (MT/LLM)** — fine-tuned vinai-translate (MedEV) or glossary-constrained LLM; PiDA-style augmentation for ASR-error robustness. +5. **Medical terminology control / glossary** — Meddict-seeded (>64,000 entries), drug-name pinning, LASA list. +6. **Medical NER** — ViHealthBERT / ViMedNER schema for key-term highlighting. +7. **Safety / risk classifier** — utterance risk tier + red-flag + negation/dosage/laterality checks. +8. **TTS** — Vietnamese and English. +9. **Bilingual transcript** — aligned, timestamped, correction-friendly. +10. **LLM reviewer fallback** — second-pass check of critical entities (dose/negation/allergen) and back-translation. +11. **Evaluation harness + feedback loop + admin review dashboard.** + +# Benchmark and Evaluation Plan + +| Component | Metric | Test data | MVP target threshold | Reviewer | +|---|---|---|---|---| +| Vi ASR | WER | VietMed test + our 500-set (Vi) | ≤15% general; ≤10% on key terms *(target, to calibrate)* | ASR engineer + Vi clinician | +| En ASR | WER | 500-set (En, accented) | ≤12% *(target)* | ASR engineer | +| ASR | Medical-term error rate | ViMedCSS + curated drug list | ≤5% on drug names | Clinician | +| ASR | Drug-name recognition rate | LASA + local formulary list | ≥95% exact | Pharmacist | +| ASR | Code-switch error rate | ViMedCSS | ≤ standard-model baseline − 2% | ASR engineer | +| ASR | Accent robustness (N/C/S) | Accent-stratified subset | No region >1.5× best-region WER | Vi linguist | +| ASR | Noise robustness | Mask/hospital-noise augmented | <1.5× clean WER | ASR engineer | +| MT | Medical terminology accuracy | 500-set high-risk | ≥98% on drug/dose/allergen terms | Clinician + pharmacist | +| MT | Dosage preservation | Dosage utterances | 100% exact number+unit | Pharmacist | +| MT | Negation preservation | Negation utterances | 100% polarity correct | Clinician | +| MT | Number/unit preservation | Numeric utterances | 100% | Reviewer | +| MT | Symptom accuracy | Symptom utterances | ≥95% | Clinician | +| MT | BLEU / COMET | MedEV test + 500-set | BLEU ≥ MedEV FT baseline (52 En→Vi / 42 Vi→En) *(as reference)* | NLP engineer | +| MT | Human medical reviewer score | 500-set | ≥4/5 adequacy on high-risk | Bilingual clinician | +| MT | Clinical error severity | 500-set | **Zero** critical-harm errors uncaught by safety layer | Clinical lead | +| End-to-end | Latency | Live simulation | ≤3–5 s per turn *(usability target)* | Product + engineer | +| End-to-end | Critical-info preservation | High-risk scripts | 100% surfaced/confirmed | Clinical lead | +| End-to-end | Conversation usability (SUS-style) | Mock clinics | ≥ agreed usability bar | UX researcher | +| End-to-end | Doctor trust rating | Post-session survey | Tracked baseline | UX researcher | +| End-to-end | Patient comprehension | Teach-back check | ≥ agreed bar | Clinician | +| End-to-end | Escalation correctness | Very-high scripts | ≥95% correct escalation, no missed red-flags | Clinical lead | + +# 500-Utterance Evaluation Set Design + +Bilingual: each category includes Vietnamese doctor utterances and English patient utterances. Includes **≥50 high-risk utterances** across dosage, allergies, negation, pregnancy, chest pain, breathing difficulty, drug names, left/right, time/frequency, stop/continue medication. + +| Category | N | Example (Vi doctor) | Example (En patient) | Risk | Expected translation criteria | Failure cases to test | +|---|---|---|---|---|---|---| +| Greeting/logistics | 30 | "Chào anh, mời ngồi." | "My appointment was at 2pm." | Low | Natural, polite; pronoun handled | Kinship pronoun; time parsing | +| Chief complaint | 50 | "Anh thấy khó chịu ở đâu?" | "I've had a cough for a week." | Medium | Symptom + duration preserved | Duration units; vague location | +| Symptom description | 70 | "Đau dữ dội hay âm ỉ?" | "It's a sharp pain on my right side." | Medium–High | Severity + laterality exact | Severity lexicon; left/right | +| Medical history | 50 | "Anh từng mổ tim chưa?" | "I had heart surgery in 2019." | High | Negation + history complete | Negation; omission of comorbidity | +| Medication history | 50 | "Anh đang uống thuốc gì?" | "I take metformin and Lasix." | High | Drug names exact (code-switch) | LASA (Lasix/Losec); CS ASR | +| Allergy checking | 40 | "Anh có dị ứng thuốc nào không?" | "I'm allergic to penicillin." | Critical | Allergen + negation exact | Negation flip; allergen garble | +| Diagnosis explanation | 50 | "Kết quả cho thấy anh bị viêm phổi." | "Does that mean it's serious?" | Medium–High | Faithful; no added advice | Severity; scope-creep into advice | +| Medication instructions | 70 | "Uống một viên sau ăn sáng." | "Do I take it with food?" | Critical | Dose+route+timing exact; confirm | Route; timing; classifier | +| Dosage/frequency/date/time | 50 | "Nửa viên, ngày hai lần, trong mười ngày." | "For how many days?" | Critical | Number+unit+frequency+duration exact | Half-dose; twice/day; 10 days | +| Follow-up & red flags | 40 | "Nếu đau ngực hay khó thở, đến cấp cứu ngay." | "I've been having chest pain." | Critical/Very-high | Red-flag preserved; escalate | Missed red-flag; escalation trigger | + +**High-risk subset (≥50):** dosage errors (half/double), allergy negation, "không/ngưng/tiếp tục thuốc" (stop/continue), pregnancy ("đang mang thai"), chest pain ("đau ngực"), breathing difficulty ("khó thở"), LASA drug pairs, laterality ("trái/phải"), frequency/time, and unit conversions (mg/ml/mcg). + +# MVP Feature Prioritization + +| Feature | Priority | Why | Complexity | Safety impact | MVP inclusion | +|---|---|---|---|---|---| +| Push-to-talk doctor mode | P0 | Core input; controls turn-taking; reduces noise | Low | Medium | **Build now** | +| Push-to-talk patient mode | P0 | Bidirectional core | Low | Medium | **Build now** | +| Vi↔En translation | P0 | The product | High | High | **Build now** | +| Live bilingual transcript | P0 | Verifiability, audit, correction | Medium | High | **Build now** | +| Spoken translation (TTS) | P0 | Patient/doctor comprehension | Medium | Medium | **Build now** | +| Medical glossary | P0 | Terminology accuracy; drug pinning | Medium | High | **Build now** | +| High-risk term highlighting | P0 | Draws attention to danger points | Medium | High | **Build now** | +| Confirmation/read-back mode | P0 | Evidence-based error catch on critical entities | Medium | Critical | **Build now** | +| Low-confidence warning | P0 | Prevents silent critical errors | Low–Medium | Critical | **Build now** | +| Human-interpreter fallback button | P0 | Regulatory + very-high-risk safety | Low | Critical | **Build now** | +| Privacy mode (no audio storage by default) | P0 | Decree 13 / HIPAA data minimization | Medium | High | **Build now** | +| Feedback button (wrong translation) | P1 | Improvement loop; audit | Low | Medium | **Build now (basic)** | +| Visit summary | P1 | Useful, but risk of unreviewed documentation | Medium | Medium | **Later** | +| Admin dashboard (review failed translations) | P1 | Auditing, QA, model improvement | Medium | High | **Build now (minimal) / expand later** | +| Emergency triage, consent, mental-health, pediatric emergency, autonomous advice/diagnosis | — | Out of scope; too high-risk | — | — | **Not now** | + +# 8-Week MVP Roadmap + +- **Week 1 — Research & scope:** finalize literature matrix; map clinical workflow; build risk register; assemble initial glossary from Meddict + LASA + local formulary; confirm consultation+medication scope. +- **Week 2 — Evaluation & architecture:** design and begin annotating the 500-utterance eval set (recruit bilingual clinician annotators); lock hybrid architecture; define adapter API spec for every AI component; draft UX flows including confirmation and interpreter fallback. +- **Weeks 3–4 — Basic prototype:** push-to-talk UI (both modes); wire ASR→normalization→MT→transcript→TTS through adapters; **mock provider mode** for deterministic tests; session logging with privacy mode default-on. +- **Weeks 5–6 — Safety layer:** integrate glossary matching, NER, risk classifier, high-risk highlighting, confirmation/read-back flow, low-confidence handling; build evaluation dashboard + feedback capture; wire interpreter-fallback trigger. +- **Week 7 — Simulated clinical testing:** run mock Vi-doctor/En-patient conversations; accent (N/C/S) and mask/noise testing; bilingual-reviewer scoring against thresholds; log critical-error and escalation-correctness metrics. +- **Week 8 — Pilot readiness:** evaluation report vs thresholds; documented known limitations; privacy-policy and consent-flow draft (for counsel review); written pilot protocol with supervising clinicians and interpreter on standby. **Go/no-go gate: zero uncaught critical-harm errors.** + +# Historical Codex Implementation Tickets + +The tickets below describe the original pre-restructure MVP layout. They are +historical research context only; use `AGENTS.md` and the current `scribe/`, +`interpreter/`, `shared/`, and `scribe/training/` directories for implementation. + +**Epic 1 — Repository setup.** *Title:* Monorepo scaffold with adapter contracts. *Background:* All AI components must be swappable. *Scope:* Monorepo (backend, frontend, shared types); linting/CI; env config; `providers/` package defining abstract adapter interfaces (ASR, MT, TTS, NER, RiskClassifier). *Acceptance:* CI green; abstract interfaces importable; mock implementations registered. *Tests:* interface conformance tests. *Files:* `/backend`, `/frontend`, `/packages/providers`. *Edge cases:* version pinning; secrets never committed. + +**Epic 2 — Backend API.** *Title:* Session & turn API. *Scope:* REST/WebSocket endpoints: create session, submit audio turn, receive transcript+translation+risk, confirm turn, escalate. *Acceptance:* streaming turn returns transcript, translation, confidence, risk tier. *Tests:* contract tests with mock providers; WS reconnect. *Files:* `backend/api/`, `backend/session/`. *Edge cases:* dropped audio, partial turns, concurrent sessions. + +**Epic 3 — Frontend push-to-talk UI.** *Title:* Dual push-to-talk interface. *Scope:* Doctor/patient modes, mic capture, turn indicator, bilingual transcript view, risk highlighting, confirm + interpreter buttons. *Acceptance:* full turn round-trips in mock mode. *Tests:* component + e2e. *Files:* `frontend/components/`. *Edge cases:* mic permission denial; rapid mode switching. + +**Epic 4 — Audio recording & playback.** *Scope:* capture, encode, stream; TTS playback; privacy-mode no-persist path. *Acceptance:* audio streamed and discarded by default. *Tests:* format, buffering, retention flag. *Edge cases:* long turns; low bandwidth. + +**Epic 5 — ASR adapter interface.** *Scope:* `transcribe(audio, lang) → {text, confidence, tokens}`; implementations for mock + one Vi + one En engine. *Acceptance:* language routing; confidence surfaced. *Tests:* golden-audio fixtures; WER harness hook. *Edge cases:* code-switch, silence, noise. + +**Epic 6 — Translation adapter interface.** *Scope:* `translate(text, src, tgt, glossary) → {text, confidence, alignments}`; mock + fine-tuned/glossary-constrained impl. *Acceptance:* glossary terms forced. *Tests:* negation/number/unit preservation cases. *Edge cases:* empty input, untranslatable tokens. + +**Epic 7 — TTS adapter interface.** *Scope:* `synthesize(text, lang) → audio`. *Acceptance:* Vi & En playback. *Tests:* latency, caching. *Edge cases:* long text, special chars/diacritics. + +**Epic 8 — Transcript data model.** *Scope:* turn schema (speaker, lang, source text, translation, confidence, risk, corrections, timestamps). *Acceptance:* aligned bilingual render; audit fields. *Tests:* serialization; migration. *Edge cases:* edited turns, retractions. + +**Epic 9 — Medical glossary.** *Scope:* import Meddict + LASA + formulary; lookup + fuzzy match API. *Acceptance:* drug-name pinning works. *Tests:* LASA collision cases. *Edge cases:* multi-word terms, abbreviations. + +**Epic 10 — Risk term detector.** *Scope:* NER + rules for dose/negation/laterality/red-flag/pregnancy; returns risk tier + spans. *Acceptance:* tiers match labeled fixtures. *Tests:* per-failure-mode fixtures. *Edge cases:* nested entities; negation scope. + +**Epic 11 — High-risk highlighting UI.** *Scope:* render risk spans; color/severity; require acknowledgment for critical. *Acceptance:* critical terms visually blocked until confirmed. *Tests:* rendering; a11y. *Edge cases:* overlapping spans. + +**Epic 12 — Confirmation/read-back flow.** *Scope:* for High/Very-high turns, back-translate critical entities and present for doctor confirm before patient TTS. *Acceptance:* no critical turn reaches patient unconfirmed. *Tests:* dosage/allergy/negation scripts. *Edge cases:* doctor edits mid-confirm. + +**Epic 13 — Session storage.** *Scope:* privacy-mode default no-audio; configurable retention; encryption at rest. *Acceptance:* audio not persisted by default; transcripts encrypted. *Tests:* retention policy; deletion. *Edge cases:* legal-hold vs delete conflict. + +**Epic 14 — Feedback capture.** *Scope:* per-turn "wrong translation" flag with reason. *Acceptance:* feedback stored + linked to turn. *Tests:* submission; dashboard surfacing. *Edge cases:* feedback on redacted turns. + +**Epic 15 — Evaluation harness.** *Scope:* run 500-set + MedEV/ViMedCSS through pipeline; compute WER, BLEU/COMET, term/dose/negation preservation, escalation correctness, clinical error severity. *Acceptance:* reproducible report vs thresholds. *Tests:* metric unit tests; fixtures. *Edge cases:* missing refs; partial pipelines. + +**Epic 16 — Mock provider mode.** *Scope:* deterministic mock ASR/MT/TTS/NER for CI and demos. *Acceptance:* full flow offline. *Tests:* determinism. *Edge cases:* injected error scenarios. + +**Epic 17 — Admin dashboard.** *Scope:* review failed/low-confidence/escalated turns; filter by risk; export. *Acceptance:* reviewers see corrections + feedback. *Tests:* auth; pagination. *Edge cases:* PHI redaction in views. + +**Epic 18 — Privacy controls.** *Scope:* consent capture at session start; AI-use disclosure; data-minimization toggles; deletion endpoint. *Acceptance:* consent required before recording; disclosure logged. *Tests:* consent gating; deletion. *Edge cases:* withdrawn consent mid-session. + +# Open Questions (for human experts, doctors, legal counsel, pilot partners) + +1. **Legal (Vietnam):** Does our exact configuration require a DPIA under Decree 13 / PDP Law, and what cloud regions/vendors satisfy MPS cross-border-transfer rules? Final breach-notification timeline under the PDP Law guiding decree? +2. **Legal (US):** Does live AI interpretation fall under Section 1557's "machine translation" review requirement, and what human-review workflow satisfies it? Is the app a HIPAA Business Associate? +3. **Clinical:** What is the acceptable maximum WER/error rate before a clinician trusts the transcript? Which specific red-flag phrases must always trigger escalation? Who is liable for a mistranslation-driven error? +4. **Formulary:** Which local Vietnamese drug names/brands and LASA pairs must be pinned in the glossary? +5. **Metrics:** What clinical-error-severity rubric and threshold gate the pilot (we propose zero uncaught critical-harm errors)? +6. **Model:** Do we fine-tune ASR on VietMed+ViMedCSS in-house or license a vendor with a BAA/data-residency guarantee? +7. **Data:** Can we ethically collect a small real (consented, de-identified) clinical Vi-En dialogue set to supplement MedEV (text) and MultiMed-ST (LLM-translated) for evaluation realism? +8. **Pilot:** Which partner clinic, supervising clinicians, and on-standby interpreters will support the supervised pilot? + +--- +*Verification note:* All dataset sizes, BLEU/WER figures, and citations above were confirmed against primary sources (arXiv, ACL Anthology, Hugging Face, GitHub, publisher pages, and the VinUni Medical MT project page). Interspeech 2026 arXiv IDs (2606.12911, 2606.06985) and the ViMedCSS arXiv ID (2602.12911) are as listed on the VinUni project page; note the project page reuses the MedEV arXiv ID (2403.19161) as a link for ViMedCSS, which appears to be a site error — the verified ViMedCSS venue is LREC 2026 (pages 5657–5665, DOI 10.63317/58uwrquo3znb). Target thresholds marked *(target)* are proposed by this report and must be calibrated with clinical partners, not drawn from a published standard. diff --git a/docs/stories/CP-BASE-001-unified-api-and-scribe.md b/docs/stories/CP-BASE-001-unified-api-and-scribe.md new file mode 100644 index 0000000000000000000000000000000000000000..5afd734797b649b0c274ce54e739c951009ad393 --- /dev/null +++ b/docs/stories/CP-BASE-001-unified-api-and-scribe.md @@ -0,0 +1,30 @@ +# CP-BASE-001 Unified API and Ghi chép bệnh án AI + +## Status + +implemented + +## Lane + +normal + +## Product Contract + +The combined FastAPI service keeps the Scribe health and note-drafting path +available while preserving clinician review of generated output. + +## Relevant Product Docs + +- `docs/product/ai-scribe.md` + +## Validation + +| Layer | Expected proof | +| --- | --- | +| Unit and integration | `python -m pytest` | +| Release smoke | `python scripts/smoke_backend.py` | + +## Evidence + +2026-07-12: 96 passed, 1 skipped in the root suite; the mock ASR and offline +LLM smoke test passed. diff --git a/docs/stories/CP-BASE-002-live-interpreter-safety.md b/docs/stories/CP-BASE-002-live-interpreter-safety.md new file mode 100644 index 0000000000000000000000000000000000000000..40d642d1dfc7ba51bc53c3a2159a996287a4b88a --- /dev/null +++ b/docs/stories/CP-BASE-002-live-interpreter-safety.md @@ -0,0 +1,32 @@ +# CP-BASE-002 Live Interpreter Safety + +## Status + +implemented + +## Lane + +high-risk + +## Product Contract + +The live interpreter remains translation-only, consent-gated, confidence-aware, +and fail-closed for patient display and TTS. + +## Relevant Product Docs + +- `docs/product/live-interpreter.md` + +## Validation + +| Layer | Expected proof | +| --- | --- | +| Unit and integration | `cd interpreter; python -m pytest` | +| Safety regression | `python interpreter/eval/run_eval.py --set interpreter/eval/fixtures/eval_starter.tsv --providers mock` | +| E2E | `cd interpreter/frontend; npx playwright test` | + +## Evidence + +2026-07-12: Ruff passed; backend tests reported 109 passed and 1 skipped; the +50-row mock eval reported 100% risk accuracy, escalation correctness, and all +preservation metrics; 4 console browser tests passed. diff --git a/docs/stories/CP-BASE-003-public-site.md b/docs/stories/CP-BASE-003-public-site.md new file mode 100644 index 0000000000000000000000000000000000000000..0d64799f01ead64095c8bb8e86fbb880198a4978 --- /dev/null +++ b/docs/stories/CP-BASE-003-public-site.md @@ -0,0 +1,32 @@ +# CP-BASE-003 Public CarePath Site + +## Status + +implemented + +## Lane + +normal + +## Product Contract + +The public site makes the two Vietnamese-first workflows distinct and remains +deployable with its diacritics, accessibility, browser, and Lighthouse gates. + +## Relevant Product Docs + +- `docs/product/carepath-suite.md` + +## Validation + +| Layer | Expected proof | +| --- | --- | +| Unit | `cd site; npm.cmd test` | +| E2E | `cd site; npx.cmd playwright test` | +| Platform | `npm.cmd run test:deploy-env`; `npm.cmd run build`; `npm.cmd run lighthouse` | + +## Evidence + +2026-07-12: lint passed; 45 unit tests and 5 deploy-environment tests passed; +the build and 7 browser tests passed; Lighthouse reported 100 performance, +100 accessibility, and 100 best practices. diff --git a/docs/stories/CP-BASE-004-interpreter-console.md b/docs/stories/CP-BASE-004-interpreter-console.md new file mode 100644 index 0000000000000000000000000000000000000000..94b8dd4b2565adcfa8c6988b39dcb3b0f0563b95 --- /dev/null +++ b/docs/stories/CP-BASE-004-interpreter-console.md @@ -0,0 +1,31 @@ +# CP-BASE-004 Interpreter Console + +## Status + +implemented + +## Lane + +normal + +## Product Contract + +The interpreter console remains buildable and exposes safety state clearly on +the canonical `/phien-dich-y-khoa/` route. + +## Relevant Product Docs + +- `docs/product/live-interpreter.md` + +## Validation + +| Layer | Expected proof | +| --- | --- | +| Unit | `cd frontend; npm.cmd test` | +| E2E | `cd frontend; npx.cmd playwright test` | +| Build | `cd frontend; npm.cmd run build` | + +## Evidence + +2026-07-12: lint passed; 38 unit tests, the production build, and 4 browser +tests passed. diff --git a/docs/stories/CP-UX-09-interpreter-public-block.md b/docs/stories/CP-UX-09-interpreter-public-block.md new file mode 100644 index 0000000000000000000000000000000000000000..2ff8a022203a038976f1e8089f3ded017d807da5 --- /dev/null +++ b/docs/stories/CP-UX-09-interpreter-public-block.md @@ -0,0 +1,50 @@ +# CP-UX-09 Block public Interpreter access + +## Status + +in progress + +## Lane + +normal + +## Product Contract + +The public CarePath experience exposes Scribe only. Interpreter browser paths +return 404 while its API, WebSocket, and safety behavior remain available for +internal development. + +## Relevant Product Docs + +- `docs/product/live-interpreter.md` +- `docs/ux-redesign-carepath.md` + +## Acceptance Criteria + +- `/phien-dich-y-khoa/*` and `/console/*` return 404. +- Scribe and both API health routes continue to return 200. +- No Interpreter safety or API behavior changes. + +## Design Notes + +- UI surfaces: retain the existing Scribe-first public landing. +- API: no contract change. +- Routing: remove the Interpreter static mount and guard its public paths. + +## Validation + +| Layer | Expected proof | +| --- | --- | +| Unit | `python -m pytest scribe/tests/test_combined_app.py` | +| E2E | `cd scribe/frontend; npm.cmd test; npm.cmd run e2e` | +| Platform | Space routes return 404 for Interpreter and 200 for Scribe and health | +| Release | `cd scribe/frontend; npm.cmd run build` | + +## Harness Delta + +No Harness change. The deployment-only Space snapshot continues to omit +non-runtime binary QA assets. + +## Evidence + +Pending validation. diff --git a/docs/stories/GEC-001-remove-human-labeling.md b/docs/stories/GEC-001-remove-human-labeling.md new file mode 100644 index 0000000000000000000000000000000000000000..5a8234f0362614b7417d54e6a4b0a62017765d24 --- /dev/null +++ b/docs/stories/GEC-001-remove-human-labeling.md @@ -0,0 +1,41 @@ +# GEC-001 Remove Human-Labeling Workflow + +## Status + +implemented + +## Lane + +normal + +## Product Contract + +The offline GEC training pipeline uses ViMedCSS real pairs and synthetic pairs +only. It does not include Label Studio tooling or clinician-corrected export +ingestion. + +## Relevant Product Docs + +- `docs/ARCHITECTURE.md` + +## Acceptance Criteria + +- No Label Studio, local labeling, or labeled-pair producer remains in the + repository. +- The GEC data model still supports generic `raw_asr` and `gold_text` pairs. +- The pipeline, generated notebooks, and tests use real ViMedCSS and synthetic + paths only. + +## Validation + +| Layer | Expected proof | +| --- | --- | +| Static | Focused Ruff check and zero-reference scan | +| Integration | `python -m pytest scribe/training/tests/test_gec.py` | +| Release | Root `python -m pytest` | + +## Evidence + +2026-07-12: notebook generation passed; focused Ruff passed; 26 focused GEC +tests passed; the root suite reported 95 passed and 1 skipped. The final scan +found no Label Studio, local-labeling, or labeled-pair references. diff --git a/docs/stories/HARN-001-adopt-carepath-harness.md b/docs/stories/HARN-001-adopt-carepath-harness.md new file mode 100644 index 0000000000000000000000000000000000000000..1a02c4e3f8d9ea63f94b52a7d844e3aaee4a6d03 --- /dev/null +++ b/docs/stories/HARN-001-adopt-carepath-harness.md @@ -0,0 +1,46 @@ +# HARN-001 Adopt CarePath Harness + +## Status + +implemented + +## Lane + +normal + +## Product Contract + +Future CarePath work has a durable intake, story/proof, decision, and trace +workflow without changing existing product behavior. + +## Relevant Product Docs + +- `docs/product/carepath-suite.md` + +## Acceptance Criteria + +- The pinned Harness install is merge-safe and checksum-verifies its CLI. +- Codex and Claude Code load the same CarePath instructions. +- Current module boundaries and proof commands replace generic Harness + placeholders. +- Existing product code, CI, dependencies, and user worktree changes remain + untouched. + +## Validation + +| Layer | Expected proof | +| --- | --- | +| Integration | CLI version, initialization, matrix query, and audit | +| Release | Existing repository validation commands record real outcomes | + +## Harness Delta + +Installs the initial Harness policy, schemas, templates, baseline records, and +decision for CarePath. + +## Evidence + +Pinned merge install created 43 upstream Harness files and checksum-verified +`harness-cli.exe` v0.1.11. CLI initialization, matrix query, and audit ran; +the complete existing CarePath proof suite passed in the configured Python 3.12 +and Node environments on 2026-07-12. diff --git a/docs/stories/README.md b/docs/stories/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c0a91ba2d0f85e331de030c623eb2edd1fee95d4 --- /dev/null +++ b/docs/stories/README.md @@ -0,0 +1,43 @@ +# Stories + +Stories are work packets. They turn product intent into bounded implementation +and validation work. + +No story packets are active yet. + +## Normal Story + +Use `docs/templates/story.md` for normal feature work. + +Suggested path: + +```text +docs/stories/epics/E01-domain-name/US-001-short-story-title.md +``` + +## High-Risk Story + +Use `docs/templates/high-risk-story/` when the feature intake classifies work as +high-risk. + +Suggested path: + +```text +docs/stories/epics/E02-risky-domain/US-012-risky-story-title/ + execplan.md + overview.md + design.md + validation.md +``` + +## Status Flow + +```text +planned -> in_progress -> implemented + | + v + changed + | + v + retired +``` diff --git a/docs/stories/backlog.md b/docs/stories/backlog.md new file mode 100644 index 0000000000000000000000000000000000000000..5cfce6f529ba76b6f9f689aa0872c160513ac303 --- /dev/null +++ b/docs/stories/backlog.md @@ -0,0 +1,13 @@ +# Story Backlog + +This backlog will be populated after a user provides a project spec or selects a +specific initiative. + +Do not create every possible story packet up front. Create story packets when +the work is selected or when a product decision needs a durable place to land. + +## Candidate Epics + +| Epic | Description | Status | +| --- | --- | --- | +| TBD | Add candidate epics after spec intake | unsliced | diff --git a/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/design.md b/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/design.md new file mode 100644 index 0000000000000000000000000000000000000000..797644291b2151261c5d7156028410bf3ae3a660 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/design.md @@ -0,0 +1,34 @@ +# Design + +## Domain Model + +No product-domain, data, or safety behavior changes. This is a directory-only +ownership move. + +## Application Flow + +The combined Scribe process continues to import `app` after editable install of +`./interpreter[dev]`; standalone Interpreter execution continues from its +directory. + +## Interface Contract + +Frozen: `/api/*`, `/ws/*`, `/phien-dich-y-khoa/`, and `/console/` redirect. + +## Data Model + +Unchanged. The evaluation fixtures move with the Interpreter tests. + +## UI / Platform Impact + +CI, Docker, static asset defaults, and deployment documentation point at the +new locations. No browser-visible behavior changes. + +## Observability + +Existing health endpoints and safety-eval output remain the proof. + +## Alternatives Considered + +1. Keep `eval/` at repository root. +2. Rename `app` during the move. diff --git a/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/execplan.md b/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..edb620351c592d1ccfd85e9096af85be136545d7 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/execplan.md @@ -0,0 +1,42 @@ +# Exec Plan + +## Goal + +Relocate the Interpreter and its evaluation assets while preserving behavior. + +## Scope + +In scope: + +- Move `backend/`, `frontend/`, and `eval/` with Git history. +- Update direct path references, path math, CI, Docker, deployment docs, and + relevant current contracts. + +Out of scope: + +- Product, route, risk, consent, audio, or provider changes. +- External Vercel configuration. + +## Risk Classification + +Risk flags: + +- Cross-module architecture and deployment paths. + +Hard gates: + +- Interpreter safety proof must remain green. +- Combined API health endpoints and frozen public routes must still work. + +## Work Phases + +1. Record the target-layout decision and high-risk intake. +2. Move the Interpreter directories with `git mv`. +3. Correct every moved path and static asset default. +4. Audit path math and stale literals. +5. Run the existing relevant proof and record the actual outcome. + +## Stop Conditions + +Pause for human confirmation if a move changes a public route, safety +invariant, API contract, or requires external hosting configuration. diff --git a/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/overview.md b/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..ff55001b4d41c4b93f9dcc22fbe4ccde07e0fdd3 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/overview.md @@ -0,0 +1,26 @@ +# Overview + +## Current Behavior + +The Interpreter runtime, console, and safety-evaluation harness live in +`backend/`, `frontend/`, and `eval/` while the combined service imports the +Interpreter as `app`. + +## Target Behavior + +They live under `interpreter/` as `interpreter/app`, `interpreter/frontend`, +`interpreter/tests`, and `interpreter/eval`, without changing the `app` import, +public routes, or safety behavior. + +## Affected Users + +- Developers and deploy operators; clinical workflows are unchanged. + +## Affected Product Docs + +- `docs/product/carepath-suite.md` +- `docs/product/live-interpreter.md` + +## Non-Goals + +- Changing Interpreter APIs, risk rules, consent, audio handling, or UI behavior. diff --git a/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/validation.md b/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..c2f32690be5d32343800adcad173db125623f1d9 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-001-interpreter-relocation/validation.md @@ -0,0 +1,55 @@ +# Validation + +## Proof Strategy + +Use the current combined-api, Interpreter safety, console, and static-site +checks. Docker build is attempted only when the local container provider is +available. + +## Test Plan + +| Layer | Cases | +| --- | --- | +| Unit | Root suite, Interpreter suite, both frontend unit suites | +| Integration | Keyless Scribe smoke and Interpreter mock eval | +| E2E | Interpreter and site Playwright suites | +| Platform | Docker build; keyless health endpoints if the provider is available | + +## Fixtures + +- `interpreter/eval/fixtures/eval_starter.tsv` +- `interpreter/eval/fixtures/risk_cases.jsonl` + +## Commands + +```text +python -m pytest +python scripts/smoke_backend.py +cd interpreter; python -m pytest; ruff check . +python interpreter/eval/run_eval.py --set interpreter/eval/fixtures/eval_starter.tsv --providers mock +cd interpreter/frontend; npm test; npm run build; npx playwright test +cd scribe/frontend; npm test; npm run build; npm run e2e +docker build -t carepath . +``` + +## Acceptance Evidence + +2026-07-13: + +- Editable installs passed for the root Scribe distribution and `interpreter/`. +- Root tests passed: 95 passed, 1 skipped. +- Keyless Scribe smoke passed. +- Interpreter tests passed: 109 passed, 1 skipped; `ruff check .` passed. +- Interpreter mock eval passed with 100% risk accuracy, escalation correctness, + and preservation metrics across 50 rows. +- Interpreter console: 38 unit tests, production build, and 4 mock-mode + Playwright checks passed. +- Public site: 45 unit tests, deploy-environment validation, production build + including the diacritics gate, and 7 Playwright checks passed. +- Docker validation was skipped because the Harness tool registry has no + present container-build provider. + +The optional production-base console suite reached the canonical route and +passed 3 of 4 checks; its existing phone-width overflow assertion failed. No +console source changed in this relocation, so it is recorded as follow-up +friction rather than changed in this path-only story. diff --git a/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/design.md b/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/design.md new file mode 100644 index 0000000000000000000000000000000000000000..11109259ffe7935e57e6c09c4b0d8e38ff352f26 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/design.md @@ -0,0 +1,36 @@ +# Design + +## Domain Model + +No domain, data, safety, or route behavior changes. + +## Application Flow + +The root `pyproject.toml` remains the Scribe distribution but discovers +`carepath` from `scribe/`. The combined process continues to serve the +Interpreter from `interpreter/` and static frontend directories from their +new locations. + +## Interface Contract + +Frozen: `/`, `/ghi-chep-lam-sang/`, `/api/v1/*`, `/api/*`, `/ws/*`, +`/phien-dich-y-khoa/`, and `/console/` redirect. + +## Data Model + +`data/medical_lexicon.json` remains CWD-relative at repository root. + +## UI / Platform Impact + +Docker, CI, docs, scripts, and Vercel root-directory documentation update to +the new Scribe/site paths. Vercel configuration is owner-operated. + +## Observability + +Existing health endpoints, smoke test, browser tests, and diacritics gate are +the regression proof. + +## Alternatives Considered + +1. Leave the public site at repository root. +2. Rename the `carepath` import to match the directory. diff --git a/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/execplan.md b/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..101e2607fd10c68a5184e1cfee61040fd5a4bc01 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/execplan.md @@ -0,0 +1,41 @@ +# Exec Plan + +## Goal + +Relocate the Scribe runtime, site, and test suite without behavior changes. + +## Scope + +In scope: + +- Move the Scribe runtime, public site, and root tests with Git history. +- Update package discovery, path math, training-script imports, CI, Docker, + deployment docs, and current contracts. + +Out of scope: + +- Hosting-project configuration, API changes, and clinical behavior changes. + +## Risk Classification + +Risk flags: + +- Cross-module architecture, deployment paths, and public site source root. + +Hard gates: + +- Both health endpoints, frozen public routes, keyless smoke, and all existing + safety proof remain green. + +## Work Phases + +1. Record a high-risk intake. +2. Move the directories with `git mv`. +3. Correct package discovery and every moved path. +4. Audit path math and stale literals. +5. Run the relevant proof and record the actual outcome. + +## Stop Conditions + +Pause for a public-route or safety change. Record the Vercel source-root +setting as owner action rather than changing it externally. diff --git a/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/overview.md b/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..fe13cb6a1986a71ed78493c02c533677c55aa2de --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/overview.md @@ -0,0 +1,25 @@ +# Overview + +## Current Behavior + +The Scribe runtime, public site, and root test suite are under `apps/api/`, +`site/`, and `tests/`; the root package imports `carepath`. + +## Target Behavior + +They are under `scribe/carepath`, `scribe/frontend`, and `scribe/tests` while +the import name, public routes, root distribution, and clinical behavior remain +unchanged. + +## Affected Users + +- Developers and deployment operators; user-facing routes are frozen. + +## Affected Product Docs + +- `docs/product/ai-scribe.md` +- `docs/product/carepath-suite.md` + +## Non-Goals + +- Scribe API, ASR, LLM, audio, copy, or workflow changes. diff --git a/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/validation.md b/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..1e433554b36798fc1232cc0857b81c30fb2cba62 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-002-scribe-site-relocation/validation.md @@ -0,0 +1,51 @@ +# Validation + +## Proof Strategy + +Run the current root, Interpreter, console, and site matrix after correcting +all relocation paths. Docker is attempted only when a container-build provider +is registered. + +## Test Plan + +| Layer | Cases | +| --- | --- | +| Unit | Root, Interpreter, console, and site unit suites | +| Integration | Keyless smoke and Interpreter mock eval | +| E2E | Interpreter and site Playwright suites | +| Platform | Docker build and keyless health endpoints if available | + +## Fixtures + +- Root Scribe fixtures and `data/medical_lexicon.json` +- `interpreter/eval/fixtures/eval_starter.tsv` + +## Commands + +```text +python -m pytest +python scripts/smoke_backend.py +cd interpreter; python -m pytest; ruff check . +python interpreter/eval/run_eval.py --set interpreter/eval/fixtures/eval_starter.tsv --providers mock +cd interpreter/frontend; npm test; npm run build; npx playwright test +cd scribe/frontend; npm test; npm run build; npm run e2e +``` + +## Acceptance Evidence + +2026-07-13: + +- Editable installs passed for the root Scribe distribution and `interpreter/`. +- Root tests passed: 95 passed, 1 skipped; keyless Scribe smoke passed. +- Interpreter tests passed: 109 passed, 1 skipped; `ruff check .` passed. +- Interpreter mock eval passed with 100% risk accuracy, escalation correctness, + and preservation metrics across 50 rows. +- Interpreter console: 38 unit tests, production build, and 4 Playwright + checks passed. +- Public site: 45 unit tests, deploy-environment validation, production build + including the diacritics gate, and 7 Playwright checks passed. +- The production static route check passed at `/phien-dich-y-khoa/`. +- Docker validation was skipped because the Harness tool registry has no + present container-build provider. + +Owner follow-up: change the Vercel project Root Directory to `scribe/frontend`. diff --git a/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/design.md b/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/design.md new file mode 100644 index 0000000000000000000000000000000000000000..2539a7a0867b48ca8ef3e14c90045512946df173 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/design.md @@ -0,0 +1,35 @@ +# Design + +## Domain Model + +No runtime domain or clinical data changes. The training module is not a +distribution; its scripts and tests add `scribe/training/` and `scribe/` to `sys.path`. + +## Application Flow + +Training imports `gec.*` from `scribe/training/gec` and may import `carepath` only for +existing ASR/config helpers. Serving imports neither `gec` nor `training`. + +## Interface Contract + +Frozen: all public routes, exported bundle schema, and `LLM_PROVIDER=gec_local` +loading behavior. + +## Data Model + +No dataset or persistence change. + +## UI / Platform Impact + +Generated notebooks move under `scribe/training/notebooks` and are rebuilt from the +moved script. CI rejects new direct `gec` imports inside `scribe/`. + +## Observability + +Root tests without training extras prove serving independence; focused training +tests with the existing environment prove the moved research imports. + +## Alternatives Considered + +1. Leave research modules inside the serving package. +2. Split a new `gec_runtime` package from research code. diff --git a/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/execplan.md b/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..01ecdf2f23c2ecfd5fb40cc15cc4374b20667a76 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/execplan.md @@ -0,0 +1,43 @@ +# Exec Plan + +## Goal + +Move GEC research assets out of the serving tree while preserving imports and +exported-model compatibility. + +## Scope + +In scope: + +- Move GEC code, scripts, notebooks, and GEC-focused tests with Git history. +- Rename research imports from `carepath.gec.*` to `gec.*`. +- Rebuild generated notebooks and add the CI serving-import guard. + +Out of scope: + +- Training new models, changing training algorithms, or changing serving APIs. + +## Risk Classification + +Risk flags: + +- Serving/training architecture boundary and validation policy. + +Hard gates: + +- Root serving tests must pass without training extras. +- `scribe/` must contain no direct `gec` import. +- Export bundle contract remains unchanged. + +## Work Phases + +1. Record high-risk intake. +2. Move training assets with `git mv`. +3. Repair imports and path math, then regenerate notebooks. +4. Add the CI import-boundary guard. +5. Run serving and focused training proof; record the actual outcome. + +## Stop Conditions + +Pause if moving code requires a serving import from `scribe/training/`, a new runtime +dependency, or a bundle-schema change. diff --git a/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/overview.md b/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..a6e62848aad8d164d40cc8ee17d522353ccc6e9e --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/overview.md @@ -0,0 +1,25 @@ +# Overview + +## Current Behavior + +GEC research code is physically inside the Scribe serving package despite the +serving runtime not importing it. Scripts, generated notebooks, and three +training tests live outside that package. + +## Target Behavior + +GEC research code, scripts, notebooks, and training tests live under +`scribe/training/`. Serving remains `scribe/carepath`, retains no `gec` import, and +continues loading only exported adapter bundles. + +## Affected Users + +- Developers and ML operators; runtime clinical behavior is unchanged. + +## Affected Product Docs + +- `docs/product/ai-scribe.md` + +## Non-Goals + +- Changing serving behavior, GEC algorithms, models, dependencies, or data. diff --git a/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/validation.md b/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..634ca6e3ff63602da60464df3011dd12983a77dc --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-004-training-relocation/validation.md @@ -0,0 +1,47 @@ +# Validation + +## Proof Strategy + +Validate serving without training extras, then run focused GEC tests when the +existing local environment has their dependencies. + +## Test Plan + +| Layer | Cases | +| --- | --- | +| Unit | Root serving tests and focused training tests | +| Integration | Keyless Scribe smoke and GEC import boundary | +| E2E | Existing console and site suites | +| Platform | Generated notebook imports | + +## Fixtures + +- Existing deterministic GEC test fixtures. +- Export manifest fixture in `scribe/tests/test_gec_local.py`. + +## Commands + +```text +.venv\Scripts\python.exe -m pytest +.venv\Scripts\python.exe scripts\smoke_backend.py +.venv\Scripts\python.exe -m pytest scribe/training/tests +.venv\Scripts\python.exe scribe/training/scripts/build_notebooks.py +rg -l "from gec|import gec" scribe/ +``` + +## Acceptance Evidence + +2026-07-13: + +- Rebuilt all four generated notebooks from `scribe/training/scripts/build_notebooks.py`. +- Root serving proof passed without training extras: 54 passed. +- Keyless Scribe smoke passed. +- Focused training tests passed: 41 passed, 1 skipped. +- `scribe/` contains no `from gec` or `import gec` lines. +- Interpreter tests passed: 109 passed, 1 skipped; `ruff check .` passed. +- Interpreter safety eval remained 100% across all 50 rows. +- Interpreter console: 38 unit tests, production build, and 4 Playwright checks passed. +- Public site: 45 unit tests, deploy-environment validation, production build + including the diacritics gate, and 7 Playwright checks passed. +- Docker validation was skipped because the Harness tool registry has no + present container-build provider. diff --git a/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/design.md b/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/design.md new file mode 100644 index 0000000000000000000000000000000000000000..a574131a4ba1116649574c031bd2bdf632aa66c0 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/design.md @@ -0,0 +1,22 @@ +# Design + +## Domain Model + +The shared module owns three explicit contracts: Interpreter text +normalization, case-insensitive metric normalization, and lexical-match +normalization. This preserves the intentionally distinct behavior that had +previously been hidden by duplicate function names. + +## Interface Contract + +`app.normalize`, `carepath.evaluation`, `carepath.services.retrieval`, +`gec.metrics`, and `gec.retrieval` keep their existing public imports. + +## UI / Platform Impact + +No UI, route, API, persistence, or provider behavior changes. + +## Alternatives Considered + +1. Force every caller onto Interpreter `normalize_text` and change scoring. +2. Keep local copies and accept future drift. diff --git a/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/execplan.md b/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..773ac50c97d7bc6f6878ab4408d4120ebe45373e --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/execplan.md @@ -0,0 +1,37 @@ +# Exec Plan + +## Goal + +Remove duplicated normalization ownership without changing the existing +Interpreter, metric, or lexical-retrieval contracts. + +## Scope + +In scope: + +- Shared distribution and compatibility re-exports. +- A 50-case Vietnamese clinical characterization suite. +- CI, Docker, and setup instructions for the shared editable install. + +Out of scope: + +- Metric-baseline changes and matching-policy changes. + +## Risk Classification + +Risk flags: + +- Cross-module architecture and validation behavior. + +Hard gates: + +- Interpreter safety eval and risk fixtures remain unchanged. +- Existing metric and retrieval characterizations pass. + +## Work Phases + +1. Capture contracts with deterministic cases. +2. Move the canonical Interpreter implementation to `shared/`. +3. Replace variants with direct imports. +4. Install shared package in every runtime path. +5. Run proof and record the Harness trace. diff --git a/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/overview.md b/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..eb8a58eddf5844fe1e41e7199588848461bd5a09 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/overview.md @@ -0,0 +1,20 @@ +# Overview + +## Current Behavior + +Normalization logic is duplicated across the Interpreter, Scribe evaluation, +GEC training metrics, and both lexical retrievers. + +## Target Behavior + +`shared/carepath_shared/normalize.py` owns all normalization algorithms; +existing module paths remain import-compatible through direct re-exports. + +## Affected Users + +- Clinicians receive unchanged Interpreter safety normalization. +- Developers and training operators use one implementation owner. + +## Non-Goals + +- Change scoring, retrieval, routes, or safety policy. diff --git a/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/validation.md b/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..271693ed90667c2133355ada29c4fd7b3fcffe57 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-005-shared-normalization/validation.md @@ -0,0 +1,40 @@ +# Validation + +## Proof Strategy + +The characterization suite proves each original contract. Existing Interpreter +fixtures and evaluation prove safety behavior is unchanged. + +## Test Plan + +| Layer | Cases | +| --- | --- | +| Unit | 50 Vietnamese clinical normalization cases across all contracts | +| Integration | Scribe, Interpreter, and training focused tests | +| E2E | Existing frontend regressions | +| Platform | Docker build when a registered provider is available | + +## Commands + +```powershell +python -m pytest shared/tests scribe/tests interpreter/tests scribe/training/tests +python interpreter/eval/run_eval.py --set interpreter/eval/fixtures/eval_starter.tsv --providers mock +``` + +## Acceptance Evidence + +2026-07-13: + +- Editable `carepath-shared`, root Scribe, and Interpreter installs succeeded. +- The 50-case characterization suite plus focused Scribe, Interpreter, and + training tests passed: 143 passed. +- Root serving tests passed: 54 passed; keyless Scribe smoke passed. +- Interpreter `ruff check .` passed; 109 tests passed, 1 skipped; the 50-row + mock safety eval stayed at 100% for risk, escalation, and preservation. +- Interpreter console: 38 unit tests, build, and 4 Playwright checks passed. +- Public site: 45 unit tests, 5 deploy-environment checks, diacritics build, + and 7 Playwright checks passed. +- The implementation scan finds `fold_for_match`, `normalize_text`, and + `normalize_for_match` definitions only in `shared/carepath_shared/normalize.py`. +- Docker validation was skipped because the Harness registry has no present + `container-build` provider. diff --git a/docs/stories/epics/E09-restructure/CP-RES-006-term-store/design.md b/docs/stories/epics/E09-restructure/CP-RES-006-term-store/design.md new file mode 100644 index 0000000000000000000000000000000000000000..d1625a0e560ccbe7e529649e2e3904872e644d60 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-006-term-store/design.md @@ -0,0 +1,23 @@ +# Design + +## Domain Model + +Each canonical row contains `term_vi`, `term_en`, `kind`, `aliases`, +`risk_flags`, and ordered target mappings. Target metadata preserves the +different existing Scribe category/Vietnamese surface and Interpreter CSV +schema without runtime code changes. + +## Interface Contract + +Scribe continues reading `data/medical_lexicon.json`; Interpreter continues +reading `interpreter/app/glossary/data/seed_glossary.csv`. + +## Observability + +`scripts/build_term_artifacts.py --check` reports stale generated artifacts; +CI regenerates and rejects any diff. + +## Alternatives Considered + +1. Change both runtimes to read a new common schema. +2. Keep two independently authored files. diff --git a/docs/stories/epics/E09-restructure/CP-RES-006-term-store/execplan.md b/docs/stories/epics/E09-restructure/CP-RES-006-term-store/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..2e76a9c4285f36891409d8e18687f635f04070fd --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-006-term-store/execplan.md @@ -0,0 +1,34 @@ +# Exec Plan + +## Goal + +Create one medical-term source of truth without changing serving contracts. + +## Scope + +In scope: + +- Canonical dataset, deterministic builder, drift test, and CI gate. +- Documentation of generated artifacts and risk-lexicon boundary. + +Out of scope: + +- Risk lexicon consolidation, data enrichment, taxonomy changes, or API changes. + +## Risk Classification + +Risk flags: + +- Cross-module data ownership and Interpreter safety validation. + +Hard gates: + +- Generated artifacts stay behavior-equivalent. +- Scribe and Interpreter tests and safety eval pass. + +## Work Phases + +1. Characterize both artifact schemas and counts. +2. Create the canonical source and deterministic generator. +3. Add drift proof to CI. +4. Run serving, safety, and frontend regression proof. diff --git a/docs/stories/epics/E09-restructure/CP-RES-006-term-store/overview.md b/docs/stories/epics/E09-restructure/CP-RES-006-term-store/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..011c5d6d86d96760f510abf2f2518a705e492055 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-006-term-store/overview.md @@ -0,0 +1,19 @@ +# Overview + +## Current Behavior + +Scribe retrieval and Interpreter glossary seeding use separate authored term +files with different schemas. + +## Target Behavior + +One shared canonical term dataset generates both current serving artifacts. + +## Affected Users + +- Clinicians retain existing retrieval and Interpreter glossary behavior. +- Developers edit one source and regenerate deterministic artifacts. + +## Non-Goals + +- Merge Interpreter risk lexicons or alter risk-engine behavior. diff --git a/docs/stories/epics/E09-restructure/CP-RES-006-term-store/validation.md b/docs/stories/epics/E09-restructure/CP-RES-006-term-store/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..99552053ca225610edf9aa50a135f4fb853151ff --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-006-term-store/validation.md @@ -0,0 +1,40 @@ +# Validation + +## Proof Strategy + +The generator must reproduce both tracked artifacts from canonical data; the +existing Scribe retrieval and Interpreter safety suites prove runtime use. + +## Test Plan + +| Layer | Cases | +| --- | --- | +| Unit | Source schema and generated-artifact drift check | +| Integration | Scribe retrieval and Interpreter glossary tests | +| E2E | Existing console and public-site regressions | +| Platform | Docker build when a provider is registered | + +## Commands + +```powershell +python scripts/build_term_artifacts.py --check +python -m pytest shared/tests scribe/tests interpreter/tests +python interpreter/eval/run_eval.py --set interpreter/eval/fixtures/eval_starter.tsv --providers mock +``` + +## Acceptance Evidence + +2026-07-13: + +- Canonical source contains 192 terms with 35 Scribe and 165 Interpreter + target mappings; it regenerates both serving artifacts without a diff. +- Shared source/artifact tests passed: 52 passed; `ruff check shared + scripts/build_term_artifacts.py` passed. +- Root serving tests passed: 54 passed; keyless Scribe smoke passed. +- Interpreter `ruff check .` passed; 109 tests passed, 1 skipped; the 50-row + mock safety eval stayed at 100% for risk, escalation, and preservation. +- Interpreter console: 38 unit tests, build, and 4 Playwright checks passed. +- Public site: 45 unit tests, 5 deploy-environment checks, diacritics build, + and 7 Playwright checks passed. +- Docker validation was skipped because the Harness registry has no present + `container-build` provider. diff --git a/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/design.md b/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/design.md new file mode 100644 index 0000000000000000000000000000000000000000..a697eabe6e83f89262600c69835f747405c6addf --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/design.md @@ -0,0 +1,28 @@ +# Design + +## Application Flow + +`interpreter_lifespan` performs existing validation, database initialization, +seeding, and startup purge. It owns a daily `asyncio` retention task and awaits +its cancellation during shutdown. The combined application enters this same +context after Scribe warmup. + +## Interface Contract + +`/api/admin/review`, all API/WebSocket routes, provider defaults, and both +health endpoints retain their existing shapes. + +## Data Model + +No schema change. The existing `RETENTION_DAYS` setting controls both startup +and daily purges. + +## UI / Platform Impact + +`CORS_ORIGINS` is a comma-separated setting, documented in `.env.example`. + +## Alternatives Considered + +1. Duplicate a background task in each app lifespan. +2. Add a scheduler dependency. +3. Keep startup-only retention. diff --git a/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/execplan.md b/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..0555d8afff83cf74ae5fa0312044a1716f938009 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/execplan.md @@ -0,0 +1,35 @@ +# Exec Plan + +## Goal + +Apply the three Phase 7 hardening changes without widening runtime behavior. + +## Scope + +In scope: + +- Constant-time admin-token comparison. +- Shared daily retention lifecycle. +- CSV CORS configuration and `.env.example` documentation. + +Out of scope: + +- Provider/model changes, routes, storage schema, and safety-rule changes. + +## Risk Classification + +Risk flags: + +- Credentials, privacy retention, security, and deployment configuration. + +Hard gates: + +- Existing admin authorization, safety fixtures, and combined-app tests pass. +- Background task cancels during lifespan shutdown. + +## Work Phases + +1. Characterize current admin, retention, and CORS paths. +2. Centralize Interpreter lifespan. +3. Add focused hardening tests. +4. Run full regression proof and record the trace. diff --git a/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/overview.md b/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..b2142b7cd750a7fbf20fb2c3adabdf02e489e92c --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/overview.md @@ -0,0 +1,16 @@ +# Overview + +## Current Behavior + +Interpreter maintenance paths used a direct admin-token comparison, startup-only +retention purge, and hard-coded browser origins. + +## Target Behavior + +The same safe startup/lifecycle runs standalone and combined, admin checks are +constant-time, daily retention purging is cancellable, and CORS is configured +through settings. + +## Non-Goals + +- Change routes, risk classification, providers, or model defaults. diff --git a/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/validation.md b/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..8750e34eaf69e54ad530323e899801b2c916305a --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-007-interpreter-hardening/validation.md @@ -0,0 +1,42 @@ +# Validation + +## Proof Strategy + +Focused tests prove constant-time comparison invocation, CSV configuration, and +one daily purge iteration. Existing app, risk-evaluation, and frontend suites +prove unchanged runtime behavior. + +## Test Plan + +| Layer | Cases | +| --- | --- | +| Unit | Admin comparison, CORS parsing, daily purge loop | +| Integration | Standalone and combined FastAPI lifespans | +| E2E | Existing console and public-site regressions | +| Platform | Docker build when a provider is registered | + +## Commands + +```powershell +python -m pytest interpreter/tests/test_health.py interpreter/tests/test_api.py scribe/tests/test_combined_app.py +cd interpreter; ruff check .; pytest +python interpreter/eval/run_eval.py --set interpreter/eval/fixtures/eval_starter.tsv --providers mock +``` + +## Acceptance Evidence + +2026-07-13: + +- Focused hardening proof passed: 21 tests cover the shared lifecycle, + constant-time admin comparison, CSV CORS parsing, and one daily purge loop. +- Root serving tests passed: 54 passed; keyless Scribe smoke passed; generated + term-artifact drift check passed. +- Interpreter `ruff check .` passed; 112 tests passed, 1 skipped; the 50-row + mock safety eval stayed at 100% for risk, escalation, and preservation. +- Interpreter console: 38 unit tests, build, and 4 Playwright checks passed. +- Public site: 45 unit tests, 5 deploy-environment checks, diacritics build, + and 7 Playwright checks passed. +- `AGENTS.md` now freezes Interpreter work to bugfix, safety, and required + operations changes unless the owner reopens feature work. +- Docker validation was skipped because the Harness registry has no present + `container-build` provider. diff --git a/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/design.md b/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/design.md new file mode 100644 index 0000000000000000000000000000000000000000..2ec413b1d3c405ed6d757262855630210bb86cad --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/design.md @@ -0,0 +1,25 @@ +# Design + +## Domain Model + +A dataset manifest has ID, source description, consent status, and SHA-256. +A pipeline config references it and carries all profile/run-size values, +including fixed seeds. The train stage validates the manifest before executing +any subprocess. + +## Application Flow + +Existing data → synthesize → train → evaluate → export stages remain intact. +`--profile` now selects a versioned config by default; `--config` supports an +explicit approved run file. + +## Data Model + +The frozen evaluation JSONL is text-only and category-tagged. Its manifest hash +protects fixture drift. No raw audio field or data collection is introduced. + +## Alternatives Considered + +1. Add consent text to notebook instructions only. +2. Gate every preliminary data stage rather than only model training. +3. Train on an unapproved public-dataset reference. diff --git a/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/execplan.md b/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..1e6ec38fdd382def97b739bd10648c1dbc9aa47b --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/execplan.md @@ -0,0 +1,37 @@ +# Exec Plan + +## Goal + +Make the existing GEC pipeline reproducible and refuse unapproved data before +training, while adding a frozen safety-oriented text evaluation set. + +## Scope + +In scope: + +- JSON run configs with fixed seeds. +- Manifest validation before `train`. +- Frozen stratified fixture and per-category evaluation output. + +Out of scope: + +- Clinical-data collection, consent approval, de-identification, model training, + or SOAP-model work. + +## Risk Classification + +Risk flags: + +- Clinical-data governance and validation policy. + +Hard gates: + +- Unapproved manifests block training before a model subprocess. +- Frozen fixture contains no audio or patient identifiers. + +## Work Phases + +1. Characterize pipeline/config/profile flow. +2. Add manifest and config loaders. +3. Add frozen text-only fixture and category metrics. +4. Prove the gate, then run regression suites. diff --git a/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/overview.md b/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..94faa54ae4cbebdd08a72e2722b681d9472014a4 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/overview.md @@ -0,0 +1,22 @@ +# Overview + +## Current Behavior + +The GEC pipeline uses code profiles and unversioned dataset arguments; there is +no consent gate or frozen stratified evaluation fixture. + +## Target Behavior + +Versioned JSON run configs specify fixed seeds and dataset manifests. Training +stops before any model step unless the owner-approved manifest is complete. A +text-only frozen fixture reports every clinical safety category. + +## Affected Users + +- Training operators get reproducible runs and explicit governance failures. +- The owner retains sole authority to approve real clinical data. + +## Non-Goals + +- Source or collect clinical audio. +- Train a model or alter Scribe serving behavior. diff --git a/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/validation.md b/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..8b106c5e1615f2831de68dbaa35799d627346e4f --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-008-data-foundation/validation.md @@ -0,0 +1,43 @@ +# Validation + +## Proof Strategy + +Unit proof validates manifests, fixed seeds, pre-training refusal, fixture hash, +and every category. Existing Scribe/Interpreter regressions prove no serving +impact. + +## Test Plan + +| Layer | Cases | +| --- | --- | +| Unit | Manifest, config, fixture hash, category reports | +| Integration | `run_pipeline --stage train` refuses unapproved data | +| E2E | Existing console and public-site regressions | +| Platform | Docker build when a provider is registered | + +## Commands + +```powershell +python -m pytest scribe/training/tests/test_governance.py scribe/training/tests/test_gec.py +python scribe/training/scripts/run_pipeline.py --stage train +python scribe/training/scripts/evaluate.py --input scribe/training/eval/fixtures/gec_eval_v1.jsonl --prediction-columns raw_asr +``` + +## Acceptance Evidence + +2026-07-13: + +- `32 passed` for `scribe/training/tests/test_governance.py` and + `scribe/training/tests/test_gec.py`; Ruff passed for the changed training modules. +- The frozen fixture evaluates deterministically: 12 text-only rows, two each + for diacritics, dosage, drug name, laterality, negation, and numbers. Its + manifest hash was verified by the test suite. +- `run_pipeline.py --config scribe/training/configs/smoke-v1.json --stage train` + stopped before starting a model subprocess with the expected + `owner-approved consent` failure. No clinical data was collected or trained. +- Full regressions passed: root `54 passed` and mock smoke; Interpreter Ruff, + `112 passed, 1 skipped`, and a 50-row mock safety evaluation at 100%; console + `38 passed` plus 4 Playwright tests; site `45 passed`, 5 deploy-env tests, + build/diacritics gate, and 7 Playwright tests. +- Docker remains an explicit clean skip because no `container-build` provider + is registered in the Harness tool registry. diff --git a/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/design.md b/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/design.md new file mode 100644 index 0000000000000000000000000000000000000000..29c903b6f805e7802e6611054461b4828044698f --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/design.md @@ -0,0 +1,23 @@ +# Design + +## Baseline + +`scribe/training/configs/frozen-baseline-v1.json` identifies the hashed 12-row fixture +and raw-ASR column. `scribe/training/scripts/baseline_report.py` generates or verifies +the committed report exactly, including WER and stratified drug-name, dosage, +and diacritic metrics. + +## Gate + +The normal report gate remains responsible for validation/hard splits. The +pipeline then predicts the frozen text fixture with the adapter and applies a +second gate before export. It compares `drug_name.term_recall` and +`dosage.number_unit_preservation` to raw ASR with zero regression tolerance. + +## CI Fixture Slice + +The `training-governance` job is CPU-only and runs all `scribe/training/tests`, which +exercise the full 12-row fixture. Its export smoke creates a fake adapter, +loads the generated `serve_manifest.json`, and corrects the first fixture row +through the existing injected generator. CI then verifies the committed +baseline report without writing it. diff --git a/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/execplan.md b/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..1a7f72b9eb325301496a36bf92b2e7ec5b20ac3c --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/execplan.md @@ -0,0 +1,21 @@ +# Exec Plan + +## Goal + +Make GEC evaluation reviewable and prevent safety-category regressions from +reaching the serving-bundle export step. + +## Work Phases + +1. Add exact-match reporting and generate the versioned frozen baseline. +2. Extend the gate with drug-name and dosage checks. +3. Run the frozen fixture through the real pipeline evaluation path before + export. +4. Add CPU-only CI report and export-smoke proof. + +## Hard Gates + +- The fixture hash must match its manifest. +- A candidate cannot regress drug-name recall or dosage number/unit + preservation relative to raw ASR. +- The export path remains GPU-free in CI through the injected generator only. diff --git a/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/overview.md b/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..74e94c2fc659d8b39953c4c86b57b368057ea554 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/overview.md @@ -0,0 +1,17 @@ +# Overview + +## Current Behavior + +The GEC fixture has deterministic per-category metrics, but no committed +baseline report or export-blocking gate for drug and dosage regressions. + +## Target Behavior + +`scribe/training/reports/` contains a reproducible frozen baseline. A real adapter +evaluation must pass both the existing aggregate gate and the safety-weighted +frozen-fixture gate before `export_serve.py` runs. + +## Non-Goals + +- Train a model, source clinical data, or change Scribe serving behavior. +- Add torch, CUDA, or an external provider to CI. diff --git a/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/validation.md b/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..be418ef021d34baf38a46c56f8d3f90dc90df364 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-009-training-regression-gates/validation.md @@ -0,0 +1,41 @@ +# Validation + +## Commands + +```powershell +python -m pytest scribe/training/tests +python scribe/training/scripts/baseline_report.py +python -m ruff check scribe/training/gec scribe/training/scripts scribe/training/tests +``` + +## Acceptance Criteria + +- A committed report includes overall WER plus drug-name, dosage, and diacritic + baseline metrics for the frozen fixture. +- A test proves the gate rejects a drug-name regression even if the regular + validation and hard-split candidate scores improve. +- The real pipeline evaluates the frozen fixture and passes its safety report + into `gate.py` before `export_serve.py`. +- CI uses only the 12-row text-only fixture, fake adapter export smoke, and no + ML training dependencies. + +## Acceptance Evidence + +2026-07-13: + +- `50 passed, 1 skipped` for all training tests. The optional skip is an + existing audio/NumPy dependency test, not part of the CPU-only GEC proof. +- The committed frozen report verified exactly against its config, + fixture hash, and deterministic metrics. It records 12 rows, raw WER + `1.4375`, drug-name accuracy `0.0`, dosage accuracy `0.0`, and diacritics + accuracy `0.0`; these are baseline measurements, not quality targets. +- A unit test proved the gate rejects a drug-name regression even while the + regular validation/hard metrics improve. The export smoke loaded a generated + serve manifest and corrected one frozen-fixture sentence via the injected + generator, without loading a real adapter. +- Full regressions passed: root `54 passed` and mock smoke; Interpreter Ruff, + `112 passed, 1 skipped`, and 50-row mock safety evaluation at 100%; console + `38 passed` plus 4 Playwright tests; site `45 passed`, 5 deploy-env tests, + build/diacritics, and 7 Playwright tests. +- Docker is a clean skip: the Harness has no registered `container-build` + provider. diff --git a/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/design.md b/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/design.md new file mode 100644 index 0000000000000000000000000000000000000000..30b4d0b04e83f566b1e4fdd5b5c9a399e7aa6265 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/design.md @@ -0,0 +1,32 @@ +# Design + +## Domain Model + +One anonymous rating row has a pseudonymous note ID, pseudonymous clinician +ID, three bounded scores, review timestamp, and final disposition. The schema +contains no clinical source material. + +## Application Flow + +The owner exports anonymous score-only rows from the approved review +environment and runs `validate_soap_ratings.py` locally. The summary reports +the number of unique notes, mean scores, serious hallucinations, unsafe +dispositions, and whether the 50-note decision threshold is met. + +## Data Model + +`rating-template.csv` is header-only. The real export must stay outside the +repository and the validator rejects non-approved columns, preventing an +accidental note-text field from becoming part of the workflow. + +## Observability + +The CLI prints a JSON summary only; it never writes, logs, or uploads ratings. + +## Alternatives Considered + +1. Use synthetic note ratings. +2. Store full notes and audio beside the code. +3. Fine-tune before clinician measurement. + +All are rejected because they would create weak or unsafe evidence. diff --git a/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/execplan.md b/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..6a181e8e8836dde8b7a2463f5fb874a3819dcebf --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/execplan.md @@ -0,0 +1,41 @@ +# Exec Plan + +## Goal + +Prepare an owner-led, de-identified measurement gate for SOAP quality. + +## Scope + +In scope: + +- Rubric and blank score-only schema. +- Standard-library validation and summary. +- Product contract that prevents premature fine-tuning. + +Out of scope: + +- Clinical-data collection, storage, rating, or model changes. + +## Risk Classification + +Risk flags: + +- Clinical data and model-quality decision policy. + +Hard gates: + +- No source material enters the repository. +- At least 50 distinct notes must be rated before the owner decides whether to + change the model. + +## Work Phases + +1. Define the minimum score-only schema and rubric. +2. Validate bounded scores and the unique-note threshold. +3. Document owner prerequisites and stop conditions. +4. Prove the validator with synthetic in-memory test rows only. + +## Stop Conditions + +Pause for owner direction if any request would add patient data, audio, +transcripts, or note text to this repository. diff --git a/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/overview.md b/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..1acf9c05e97660382bb6713d5fbbb17d751f605e --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/overview.md @@ -0,0 +1,25 @@ +# Overview + +## Current Behavior + +The Scribe produces a clinician-reviewed SOAP draft, but no durable protocol +exists for assessing its quality before changing the model. + +## Target Behavior + +The repository provides a de-identified rubric, blank schema, and validator. +The owner can collect at least 50 real clinician ratings in an approved +environment before making a tuning decision. + +## Affected Users + +- Clinical owner and clinician reviewers. + +## Affected Product Docs + +- `docs/product/ai-scribe.md` + +## Non-Goals + +- Collect pilot audio, patient data, generated note text, or ratings. +- Train, fine-tune, or change the SOAP serving provider. diff --git a/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/validation.md b/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..882eaf7299887ce3d93127158b3e6e533b79ec89 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/validation.md @@ -0,0 +1,28 @@ +# Validation + +## Proof Strategy + +Unit tests construct score-only synthetic rows in memory. No clinical material +is created or persisted. + +## Test Plan + +| Layer | Cases | +| --- | --- | +| Unit | 50-note threshold, serious-hallucination summary, invalid-score rejection | +| Integration | Existing CPU-only training test job discovers the validator test | +| E2E | Not applicable; no product surface changes | +| Platform | No external provider required | + +## Commands + +```powershell +python -m pytest scribe/training/tests +python -m ruff check scribe/training/scripts/validate_soap_ratings.py scribe/training/tests/test_soap_ratings.py +``` + +## Acceptance Evidence + +2026-07-13: focused proof passed with `38 passed`; no data collection, model +training, or serving behavior change occurred. The owner has not yet supplied +the required pilot data or clinical ratings, so no model decision is claimed. diff --git a/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/design.md b/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/design.md new file mode 100644 index 0000000000000000000000000000000000000000..67e558a2a58f8a1229d652f3e806dc1fb0f6182f --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/design.md @@ -0,0 +1,40 @@ +# Design + +## Domain Model + +`scribe/training/` is the offline Scribe development boundary. It exposes the +standalone `gec` package only to training scripts and tests. `scribe/carepath/` +is the Scribe serving boundary and must remain independent of it. + +## Application Flow + +Training commands resolve the repository root, add `scribe/training/` and +`scribe/` to their command-line import paths, and read versioned files from the +new location. Generated notebooks use the same bootstrap paths. + +## Interface Contract + +No HTTP, websocket, product UI, or model-serving contract changes. The public +training command paths move from `training/...` to `scribe/training/...`. + +## Data Model + +Only source-controlled fixtures, configs, manifests, and reports move. No +clinical data, raw audio, or runtime artifacts are migrated or created. + +## UI / Platform Impact + +The production Docker build copies `scribe/carepath/` rather than all of +`scribe/`, excluding the offline training boundary from the runtime image. + +## Observability + +The existing CI training-governance job continues to execute the relocated test +suite and deterministic baseline report. + +## Alternatives Considered + +1. Keep `training/` at the repository root: rejected because it conceals Scribe + ownership. +2. Create a top-level compatibility link: rejected because it preserves the + ambiguous architecture boundary. diff --git a/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/execplan.md b/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..0325fb3377a607079c86f033d1b0e655ed3375f4 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/execplan.md @@ -0,0 +1,46 @@ +# Exec Plan + +## Goal + +Make Scribe ownership of all offline DARAG/GEC training explicit without +changing serving behavior or the Interpreter safety harness. + +## Scope + +In scope: + +- Relocate `training/` to `scribe/training/` with Git history preserved. +- Update path-sensitive scripts, generated notebooks, configs, tests, CI, + packaging rules, and active documentation. +- Verify the Scribe runtime does not import the relocated `gec` package. + +Out of scope: + +- Model training, data collection, dependency changes, and API changes. +- Interpreter runtime or `interpreter/eval/` changes. + +## Risk Classification + +Risk flags: + +- Architecture, packaging, CI, and training-boundary relocation. + +Hard gates: + +- Scribe and training tests pass. +- The deterministic baseline report passes. +- No Scribe runtime import of `gec` remains. + +## Work Phases + +1. Inventory root-relative paths and packaging copies. +2. Move the training directory under `scribe/`. +3. Update executable paths, configuration references, generated notebooks, CI, + and docs. +4. Run unit, build, boundary, and baseline checks. +5. Record the real Harness validation outcome. + +## Stop Conditions + +Pause for human confirmation if a move requires training data migration, +changes an API contract, or forces an Interpreter dependency on Scribe code. diff --git a/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/overview.md b/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..1dfba2a03ce363d8a3143ecf4ac4a6172cd7da5f --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/overview.md @@ -0,0 +1,27 @@ +# Overview + +## Current Behavior + +Offline Scribe DARAG/GEC training resides at the repository root in +`training/`, despite having no Interpreter imports or runtime role. + +## Target Behavior + +All offline Scribe training resides at `scribe/training/`. The Interpreter +retains only `interpreter/eval/` for its safety regression harness. + +## Affected Users + +- Scribe maintainers running offline training, evaluation, or Colab notebooks. +- Release maintainers running CI and building the combined service image. + +## Affected Product Docs + +- `docs/product/ai-scribe.md` +- `docs/ARCHITECTURE.md` + +## Non-Goals + +- Change Scribe or Interpreter product behavior. +- Move or alter `interpreter/eval/`. +- Train a model, add dependencies, or include training artifacts in production. diff --git a/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/validation.md b/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..75c25acddf9d8e7bb6b705008fe13c385ff74159 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-011-scribe-training-ownership/validation.md @@ -0,0 +1,43 @@ +# Validation + +## Proof Strategy + +Use the existing CPU-only training suite and deterministic baseline report, +then prove the serving and Interpreter boundaries remain separate. + +## Test Plan + +| Layer | Cases | +| --- | --- | +| Unit | Relocated GEC and SOAP measurement tests | +| Integration | Deterministic baseline report and generated notebooks | +| E2E | Not applicable; no product UI or route changes | +| Platform | Scribe package build and Docker copy-boundary inspection | +| Boundary | No `gec` import in `scribe/carepath/`; no Interpreter move | + +## Fixtures + +Existing source-controlled DARAG fixtures, manifests, reports, and blank SOAP +rating schema only. No clinical data is used. + +## Commands + +```powershell +python -m pytest scribe/training/tests +python scribe/training/scripts/build_notebooks.py +python scribe/training/scripts/baseline_report.py +python -m pytest scribe/tests +python -m ruff check scribe/training scribe/carepath +``` + +## Acceptance Evidence + +2026-07-13: all source-controlled training assets moved to +`scribe/training/`, and generated notebooks were rebuilt from their relocated +generator. The project Python 3.12 proof passed: 54 training tests with one +optional skip, 106 Scribe/shared tests, Ruff for the Scribe runtime and training +tree, the frozen baseline report, and the mock Scribe smoke flow. Searches found +no `gec` import in `scribe/carepath/` and no Interpreter import in +`scribe/training/`. Docker build is a clean skip because Harness has no +registered container-build provider; its runtime copy boundary was inspected +and copies only `scribe/carepath/`. diff --git a/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/design.md b/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/design.md new file mode 100644 index 0000000000000000000000000000000000000000..8c7110b9c3578fb8a0a2d7e74d9e7aa09bdf6b61 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/design.md @@ -0,0 +1,38 @@ +# Design + +## Domain Model + +No repository model or product contract changes are required. Deployment +credentials and health-data consent remain owner-controlled trust boundaries. + +## Application Flow + +The deployment owner configures Vercel exactly as `docs/deploy.md` specifies, +then validates the live site and Space health endpoints. Separately, the +clinical owner obtains approved, de-identified pilot material and at least 50 +clinician rubric ratings before any SOAP model decision. + +## Interface Contract + +The existing Vercel environment validation and Space health endpoints are the +deployment contract. The score-only SOAP rating schema and validator are the +clinical-measurement contract. + +## Data Model + +No clinical material belongs in this repository. Only anonymous score metadata +that passes the existing validator may be processed in an approved environment. + +## UI / Platform Impact + +No local UI change. The Vercel Root Directory must point to `scribe/frontend`. + +## Observability + +Capture live deployment status and owner-approved rating summaries outside this +repository's source tree. + +## Alternatives Considered + +1. Simulate deployment or clinical evidence: rejected because it would not + prove the external environment or satisfy clinical-data safeguards. diff --git a/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/execplan.md b/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..0465b6bf0335a32507ece74eba455d32cf718702 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/execplan.md @@ -0,0 +1,43 @@ +# Exec Plan + +## Goal + +Complete the owner-only end conditions without weakening deployment or clinical +safety requirements. + +## Scope + +In scope: + +- Owner configures Vercel Root Directory as `scribe/frontend` and supplies the + documented deployment environment variables. +- Owner rebuilds the Docker Space and verifies the documented live routes. +- Clinical owner completes the approved de-identified rating protocol. + +Out of scope: + +- Repository-side deployment automation or clinical-data collection. +- Any model training or SOAP provider change before the rating threshold. + +## Risk Classification + +Risk flags: + +- Deployment, credentials, clinical data, privacy. + +Hard gates: + +- No source audio, transcript, identifier, or note text enters the repository. +- No SOAP model decision before 50 approved clinician ratings. + +## Work Phases + +1. Owner completes the Vercel and Space configuration in `docs/deploy.md`. +2. Owner records live route and health-endpoint evidence. +3. Clinical owner completes legal approval, de-identification, and ratings. +4. Owner reopens the plan with that evidence for final verification. + +## Stop Conditions + +Do not proceed without deployment-owner access or approved clinical-data +authority. diff --git a/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/overview.md b/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..2bf1974bf753c5ef4618eb3ffd27e4e5e653c458 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/overview.md @@ -0,0 +1,27 @@ +# Overview + +## Current Behavior + +The repository restructure and local product matrix are complete. Two final +conditions cannot be performed from the repository: changing Vercel/Space +settings and collecting legally approved clinical evidence. + +## Target Behavior + +The owner completes the documented deployment settings and the approved, +de-identified clinician-rating process before calling the whole plan complete. + +## Affected Users + +- Deployment owner. +- Clinical owner and clinician reviewers. + +## Affected Product Docs + +- `docs/deploy.md` +- `docs/product/ai-scribe.md` + +## Non-Goals + +- Collect audio, transcripts, patient data, or clinician ratings in this repo. +- Change Vercel, Hugging Face, or cloud-provider settings without owner access. diff --git a/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/validation.md b/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..ac881b8520cd96b8b2cbd9486289550097bc6204 --- /dev/null +++ b/docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/validation.md @@ -0,0 +1,64 @@ +# Validation + +## Proof Strategy + +Separate local repository proof from owner-only external proof. Local tests do +not establish a live deployment or authorize clinical data processing. + +## Test Plan + +| Layer | Cases | +| --- | --- | +| Unit | Scribe, Interpreter, shared, and training suites | +| Integration | Term-artifact drift, training baseline, mock Scribe smoke | +| E2E | Both Vite frontends' Playwright suites | +| Platform | Owner verifies Vercel and Space routes after configuration | +| Clinical | Owner supplies approved, de-identified rubric summary | + +## Commands + +```powershell +.\.venv\Scripts\python.exe -m pytest +.\.venv\Scripts\python.exe -m pytest scribe\training\tests +cd interpreter; ..\.venv\Scripts\python.exe -m pytest +cd scribe\frontend; npm.cmd test; npm.cmd run test:deploy-env; npm.cmd run build; npm.cmd run e2e +cd interpreter\frontend; npm.cmd run lint; npm.cmd test; npm.cmd run build; npm.cmd run e2e +``` + +## Acceptance Evidence + +2026-07-13 local proof passed: root Scribe suite 54; training 54 with one +optional skip; Interpreter 112 with one optional skip and a 50/50 safety eval; +Scribe frontend 45 unit, 5 deployment-environment, and 7 browser tests; +Interpreter console lint, 38 unit tests, build, and browser tests. Term +artifacts were current and the mock Scribe smoke passed. + +2026-07-13 Vercel proof passed: the owner saved Root Directory as +`scribe/frontend`; production deployment `13b65c1` is Ready at +`https://carepath-omega.vercel.app`; and the live page presents both +Vietnamese-first module choices and their safety limits. + +2026-07-13 public-priority proof passed: deployment `367aae7` is Ready at +`https://carepath-omega.vercel.app`. The live page makes **Ghi chép bệnh án +AI** the primary web action and labels **Phiên dịch khám bệnh trực tiếp** as +in development for the web, while retaining its translate-only safety limit. + +2026-07-13 Hugging Face proof passed: authenticated owner access confirmed the +existing `tranth3truong/carepath-api` Docker Space and its documented runtime +secrets. The public build variable `VITE_PUBLIC_SITE_URL` was set to +`https://carepath-omega.vercel.app`; then a Space-only root commit `6538a14` +replaced the divergent older revision. The root commit omits non-runtime QA +images rejected by the Space Git receiver; GitHub retains those files. + +The rebuilt Space is Running at +`https://tranth3truong-carepath-api.hf.space`. `GET /api/v1/health` returned +`asr_ready: true` and `llm_ready: true` with the configured CKey provider; +`GET /api/health` returned `provider_mode: mock`; and `/`, +`/ghi-chep-lam-sang/`, and `/phien-dich-y-khoa/` each returned HTTP 200. The +Interpreter product link returns to the canonical Vercel site with language +preserved. + +Pending owner evidence: the approved 50-note clinician-rating threshold +reached without storing clinical material in the repository. No container +provider is registered in Harness, so that clinical evidence cannot be +collected by this workspace. diff --git a/docs/templates/decision.md b/docs/templates/decision.md new file mode 100644 index 0000000000000000000000000000000000000000..de6ec955fcc43d8ad1876945c6b919af1a50ca2f --- /dev/null +++ b/docs/templates/decision.md @@ -0,0 +1,33 @@ +# NNNN Decision Title + +Date: YYYY-MM-DD + +## Status + +Proposed | Accepted | Superseded | Rejected + +## Context + +What problem, constraint, or ambiguity forced this decision? + +## Decision + +What did we decide? + +## Alternatives Considered + +1. Alternative. + +## Consequences + +Positive: + +- Item. + +Tradeoffs: + +- Item. + +## Follow-Up + +- Item. diff --git a/docs/templates/high-risk-story/design.md b/docs/templates/high-risk-story/design.md new file mode 100644 index 0000000000000000000000000000000000000000..90facfef8db5cfc2877f9a0514ede28bfc83f1ff --- /dev/null +++ b/docs/templates/high-risk-story/design.md @@ -0,0 +1,29 @@ +# Design + +## Domain Model + +Describe entities, value objects, and business rules. + +## Application Flow + +Describe commands, queries, and handlers. + +## Interface Contract + +Describe routes, messages, commands, request DTOs, response DTOs, and errors. + +## Data Model + +Describe tables, indexes, migrations, and retention concerns. + +## UI / Platform Impact + +Describe browser, mobile, desktop, CLI, deployment, or platform-shell impact. + +## Observability + +Describe logs, audit records, metrics, or traces. + +## Alternatives Considered + +1. Option. diff --git a/docs/templates/high-risk-story/execplan.md b/docs/templates/high-risk-story/execplan.md new file mode 100644 index 0000000000000000000000000000000000000000..66a98284897a5acfae73d112558ef2c066b4e233 --- /dev/null +++ b/docs/templates/high-risk-story/execplan.md @@ -0,0 +1,43 @@ +# Exec Plan + +## Goal + +What outcome are we trying to produce? + +## Scope + +In scope: + +- Item. + +Out of scope: + +- Item. + +## Risk Classification + +Risk flags: + +- Flag. + +Hard gates: + +- Gate. + +## Work Phases + +1. Discovery. +2. Design. +3. Validation planning. +4. Implementation. +5. Verification. +6. Harness update. + +## Stop Conditions + +Pause for human confirmation if: + +- Product behavior is ambiguous. +- Data migration or deletion risk appears. +- Validation requirements need to be weakened. +- Architecture direction changes. diff --git a/docs/templates/high-risk-story/overview.md b/docs/templates/high-risk-story/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..0df4e5e160c1ee084272da7dbcd0bfab1e06acb8 --- /dev/null +++ b/docs/templates/high-risk-story/overview.md @@ -0,0 +1,21 @@ +# Overview + +## Current Behavior + +Describe the current product or repo behavior. + +## Target Behavior + +Describe the behavior after the story is complete. + +## Affected Users + +- Role. + +## Affected Product Docs + +- `docs/product/...` + +## Non-Goals + +- Item. diff --git a/docs/templates/high-risk-story/validation.md b/docs/templates/high-risk-story/validation.md new file mode 100644 index 0000000000000000000000000000000000000000..cd399683a205c1398bddf4710656198ebeef1cd7 --- /dev/null +++ b/docs/templates/high-risk-story/validation.md @@ -0,0 +1,33 @@ +# Validation + +## Proof Strategy + +Explain what must pass before the story is done. + +## Test Plan + +| Layer | Cases | +| --- | --- | +| Unit | | +| Integration | | +| E2E | | +| Platform | | +| Performance | | +| Logs/Audit | | + +## Fixtures + +List deterministic users, accounts, records, provider responses, or other +fixtures needed for repeatable proof. + +## Commands + +Add commands after scripts exist. + +```text +TBD +``` + +## Acceptance Evidence + +Add results after verification. diff --git a/docs/templates/spec-intake.md b/docs/templates/spec-intake.md new file mode 100644 index 0000000000000000000000000000000000000000..4799a18fe8cfda432229eaf489119a16acc58bee --- /dev/null +++ b/docs/templates/spec-intake.md @@ -0,0 +1,65 @@ +# Spec Intake + +Date: YYYY-MM-DD + +## Source + +Where did the spec come from? + +- User prompt: +- Attached file: +- External reference: + +## Project Summary + +What product are we building, for whom, and why? + +## Candidate Product Docs + +List the product contract files that should be created under `docs/product/`. + +| File | Purpose | Source sections | +| --- | --- | --- | +| `docs/product/overview.md` | | | + +## Candidate Epics + +List only the epics that are clear enough to name. Do not create every story +packet yet. + +| Epic | Description | Status | +| --- | --- | --- | +| E01 | | unsliced | + +## Architecture Questions + +- Runtime stack: +- Product surfaces: +- Storage: +- External providers: +- Deployment target: +- Security model: + +## Validation Shape + +What proof will this project eventually need? + +| Layer | Expected proof | +| --- | --- | +| Unit | | +| Integration | | +| E2E | | +| Platform | | +| Release | | + +## Open Decisions + +- Item. + +## First Story Candidates + +- Item. + +## Harness Delta + +What harness changes were made or should be proposed because of this spec? diff --git a/docs/templates/story.md b/docs/templates/story.md new file mode 100644 index 0000000000000000000000000000000000000000..c76fcb0cbb0120f35dcd5be69beeddd20a5089ba --- /dev/null +++ b/docs/templates/story.md @@ -0,0 +1,53 @@ +# US-XXX Story Title + +## Status + +planned + +## Lane + +tiny | normal | high-risk + +## Product Contract + +Describe the behavior this story must make true. + +## Relevant Product Docs + +- `docs/product/...` + +## Acceptance Criteria + +- Criterion 1. +- Criterion 2. +- Criterion 3. + +## Design Notes + +- Commands: +- Queries: +- API: +- Tables: +- Domain rules: +- UI surfaces: + +## Validation + +When updating durable proof status, use numeric booleans: +`scripts/bin/harness-cli story update --id --unit 1 --integration 1 --e2e 0 --platform 0`. + +| Layer | Expected proof | +| --- | --- | +| Unit | | +| Integration | | +| E2E | | +| Platform | | +| Release | | + +## Harness Delta + +Document any harness updates made or proposed because of this story. + +## Evidence + +Add commands, reports, screenshots, or links after validation exists. diff --git a/docs/templates/validation-report.md b/docs/templates/validation-report.md new file mode 100644 index 0000000000000000000000000000000000000000..b66c451ae6869f79a3bd1a8ade46c7f6d82b70d5 --- /dev/null +++ b/docs/templates/validation-report.md @@ -0,0 +1,32 @@ +# Validation Report + +Date: YYYY-MM-DD + +## Scope + +What story or change was validated? + +## Commands Run + +```text +command +``` + +## Results + +| Check | Result | Notes | +| --- | --- | --- | +| Typecheck | not run | Command does not exist yet | +| Unit | not run | Command does not exist yet | +| Integration | not run | Command does not exist yet | +| E2E | not run | Command does not exist yet | +| Platform | not run | Command does not exist yet | +| Release | not run | Command does not exist yet | + +## Evidence + +Add report paths, screenshots, logs, or other artifacts. + +## Gaps + +List remaining risk or missing harness capability. diff --git a/docs/ux-redesign-carepath.md b/docs/ux-redesign-carepath.md new file mode 100644 index 0000000000000000000000000000000000000000..6445901bb8e24aa0a71a59e9a8acb72b38d78785 --- /dev/null +++ b/docs/ux-redesign-carepath.md @@ -0,0 +1,1284 @@ +# CarePath Vietnamese-first UX implementation stories + +**Status:** implementation backlog only. No application code is implemented by this document. + +**Objective:** make CarePath Vietnamese-first and make its two products unmistakably different: + +1. **CarePath Trợ lý ghi chép lâm sàng** — creates a clinician-reviewed draft from uploaded Vietnamese visit audio. +2. **CarePath Phiên dịch y khoa Việt–Anh** — supports turn-by-turn conversation and blocks risky output until clinician confirmation. + +## Backlog rules + +- Implement one story per commit or pull request. +- A story may start only when its listed dependencies are complete. +- UX/copy stories must not change routes, API contracts, microphone behavior, WebSocket behavior, TTS eligibility, or risk rules. +- Routing stories must not redesign the homepage or change clinical/audio workflow behavior. +- Functional stories may consume approved copy but must not reopen the homepage design or route structure. +- Keep the internal identifiers scribe, interpreter, doctor, patient, vi, and en, plus all API paths, WebSocket events, and schema fields unchanged. +- Do not add a router, state library, or i18n dependency. Current React state, pathname/hash checks, browser APIs, CSS, and tests are sufficient. +- Preserve every safety invariant in AGENTS.md. A UI failure must never release blocked content or start audio before consent. +- Persuasion techniques (smart defaults, endowed progress, gamification) may only be applied as permitted by the persuasion boundary matrix in the onboarding section. Legal consent controls are never defaulted, pre-checked, or gamified. + +## Current priority update: Scribe-only public availability + +### Current UX problem + +The public site correctly makes Scribe the primary action, but the direct +Interpreter browser path remains reachable on the Space. A visitor who knows +that URL can still open the unfinished workflow. + +### Proposed flow + +1. The public landing keeps **Ghi chép bệnh án AI** and **Bắt đầu ghi chép** + as the only route into a working product. +2. Every browser request to `/phien-dich-y-khoa/*` and `/console/*` returns + HTTP 404 instead of the Interpreter frontend. +3. Interpreter APIs, WebSockets, backend safety behavior, and automated tests + remain available for internal development; no public page links to them. + +### Affected routes, pages, and components + +- `/phien-dich-y-khoa/*` and `/console/*` in `scribe/carepath/main.py` +- `scribe/tests/test_combined_app.py` +- `docs/product/live-interpreter.md` +- `README.md`, `README.hf-space.md`, and `docs/deploy.md` + +### Vietnamese-first copy + +- Primary feature: `Ghi chép bệnh án AI` +- Primary action: `Bắt đầu ghi chép` +- Blocked route: standard HTTP 404; no unfinished clinical workflow is + presented to visitors. +- Internal documentation: `Phiên dịch khám bệnh trực tiếp hiện chưa mở cho + người dùng trên web.` + +### Implementation story: CP-UX-09 + +- Remove the Interpreter static mount and add exact browser-path guards before + the public static-site mount. +- Keep the current Scribe-first landing unchanged because it already has no + Interpreter launch link. +- Keep APIs, WebSockets, consent, microphone behavior, TTS, risk rules, and + the pilot form schema unchanged. + +### Dependencies + +- CP-UX-08 provides the existing Scribe-first landing state. +- CP-ROUTE-02 is superseded for public browser access, but its API and + WebSocket boundaries remain unchanged. + +### Acceptance criteria + +- The public site has no Interpreter launch link and retains Scribe as the + only working-product action. +- `/phien-dich-y-khoa/`, child paths, `/console`, and `/console/` return 404. +- `/api/v1/health`, `/api/health`, `/api/*`, and `/ws/*` retain their current + contracts and safety behavior. +- Scribe routes and the Vietnamese public landing still return HTTP 200. + +### Validation commands + +```powershell +python -m pytest scribe/tests/test_combined_app.py +cd scribe\frontend; npm.cmd test; npm.cmd run build; npm.cmd run e2e +``` + +### Risks and fallback behavior + +- Do not disable `/api/*` or `/ws/*`: that would change the Interpreter + development and safety contract rather than block its public browser UI. + Re-enable the static mount only when the product is ready for public access + and its pre-start safety flow has passed its normal release checks. + +## Delivery order + +| Order | Story | Type | Depends on | +|---:|---|---|---| +| 1 | CP-UX-01 Vietnamese product names and vocabulary | UX/copy | None | +| 2 | CP-UX-02 Homepage decision gateway | UX/presentation | CP-UX-01 | +| 3 | CP-UX-03 Homepage trust and proof sections | UX/presentation | CP-UX-02 | +| 4 | CP-UX-04 Clinical-note product copy | UX/copy | CP-UX-01 | +| 5 | CP-UX-05 Interpreter locale foundation | UX/copy infrastructure | CP-UX-01 | +| 6 | CP-UX-06 Vietnamese-first consent copy | UX/copy | CP-UX-05 | +| 7 | CP-UX-07 Interpreter workspace terminology | UX/copy | CP-UX-05 | +| 8 | CP-ROUTE-01 Clinical-note canonical route | Routing | CP-UX-02, CP-UX-04 | +| 9 | CP-ROUTE-02 Interpreter canonical route | Routing/deploy | CP-UX-02, CP-UX-05 | +| 10 | CP-ROUTE-03 Internal review route | Routing | CP-ROUTE-02 | +| 11 | CP-FORM-01 Honest pilot form behavior | Functional | CP-UX-03 | +| 12 | CP-NOTES-01 Clinical-note review evidence | Functional | CP-UX-04, CP-ROUTE-01 | +| 13 | CP-NOTES-02 Clinical-note errors and privacy states | Functional | CP-UX-04 | +| 14 | CP-INT-01 Clear doctor and patient input regions | Functional UI | CP-UX-07 | +| 15 | CP-INT-02 Stateful and keyboard-accessible push-to-talk | Audio workflow | CP-INT-01 | +| 16 | CP-INT-03 Clinician-only high-risk review | Safety workflow | CP-UX-07, CP-INT-02 | +| 17 | CP-INT-04 Human-interpreter escalation stops TTS | Safety workflow | CP-INT-02, CP-INT-03 | +| 18 | CP-INT-05 Per-turn low-confidence recovery | Safety workflow | CP-UX-07, CP-INT-02, CP-INT-04 | +| 19 | CP-INT-06 End, delete, and withdraw-consent flow | Session lifecycle | CP-UX-06, CP-INT-02, CP-INT-04 | +| 20 | CP-ONB-01 Pre-consent value demo | Onboarding UI | CP-UX-06 | +| 21 | CP-ONB-02 Two-question intent quiz | Onboarding UI | CP-UX-05 | +| 22 | CP-ONB-03 Honest-progress onboarding stepper | Onboarding UI | CP-ONB-02, CP-UX-06 | +| 23 | CP-ONB-04 Post-consent device readiness check | Onboarding/audio workflow | CP-ONB-03, CP-INT-02 | +| 24 | CP-ONB-05 Embedded first-session checklist | Onboarding UI | CP-INT-02, CP-INT-03, CP-INT-04, CP-INT-05 | +| 25 | CP-QA-01 Full release and visual verification | QA only | CP-ROUTE-03, CP-FORM-01, CP-NOTES-01, CP-NOTES-02, CP-INT-03, CP-INT-04, CP-INT-05, CP-INT-06, CP-ONB-04, CP-ONB-05 | + +UX/copy stories 4 and 5 can run in parallel after CP-UX-01. Product-specific functional stories can run independently once their own dependencies are satisfied. CP-ONB-01 and CP-ONB-02 can start any time after CP-UX-06 and CP-UX-05 respectively; only CP-ONB-04 and CP-ONB-05 wait on the interpreter workflow stories. + +--- + +## UX and copy stories + +### CP-UX-01 — Encode Vietnamese product names and vocabulary + +**Type:** UX/copy only + +**User story** + +> As a Vietnamese doctor, I want each product named by the job it performs so that I can choose a tool without understanding “Scribe” or “Interpreter.” + +**Files likely affected** + +- scribe/frontend/src/content/strings.ts +- scribe/frontend/src/content/strings.test.ts +- scribe/frontend/index.html +- scribe/frontend/src/LandingPage.test.tsx + +**Dependencies** + +- None. +- Product owner approval of these names: + - CarePath Trợ lý ghi chép lâm sàng + - CarePath Phiên dịch y khoa Việt–Anh + +**Scope guard** + +- Copy and metadata only. +- Do not change component structure, routes, link destinations, form payload values, APIs, or interpreter workflow behavior. + +**Acceptance criteria** + +- [ ] Vietnamese product names, short labels, statuses, and CTA labels use plain Vietnamese. +- [ ] “Scribe,” “Interpreter,” and “Console” are absent from Vietnamese navigation, product names, statuses, and CTAs. +- [ ] First-use terminology says “phiên âm tự động,” “đọc lại để xác nhận,” and “bản ghi y khoa theo bốn mục SOAP.” +- [ ] Later references may use “bản nháp SOAP,” but never imply a completed medical record. +- [ ] English mode remains complete. +- [ ] Internal product keys remain scribe and interpreter. +- [ ] New Vietnamese strings are NFC-normalized and retain diacritics. + +**Test/validation command** + + npm.cmd --prefix site test + npm.cmd --prefix site run build + +### CP-UX-02 — Replace repeated homepage choices with one decision gateway + +**Type:** UX/presentation + +**User story** + +> As a first-time visitor, I want one clear two-product choice near the top of the homepage so that I can enter the correct workflow in one decision. + +**Files likely affected** + +- scribe/frontend/src/LandingPage.tsx +- scribe/frontend/src/components/ProductChoiceCard.tsx (new) +- scribe/frontend/src/content/strings.ts +- scribe/frontend/src/styles.css +- scribe/frontend/src/landing/useLandingMotion.ts +- scribe/frontend/src/LandingPage.test.tsx +- scribe/frontend/tests/demo-flow.spec.ts +- scribe/frontend/package.json and lockfile only if removing the obsolete homepage motion makes GSAP unused + +**Dependencies** + +- CP-UX-01. + +**Scope guard** + +- Homepage structure and styling only. +- Keep current link destinations until the routing stories. +- Do not change lead submission behavior, Scribe upload behavior, Interpreter controls, or API calls. + +**Acceptance criteria** + +- [ ] The homepage contains one product-choice layer, not hero choices plus gateway choices plus a second product index. +- [ ] The hero states that the products serve two different jobs. +- [ ] Each card shows workflow moment, Vietnamese name, status, one-line outcome, input, output, safety sentence, and one primary action. +- [ ] The cards use “Sau buổi khám” and “Trong buổi khám” to distinguish timing. +- [ ] Both cards have equal visual priority. +- [ ] The full Interpreter and clinical-note workflows are no longer interleaved in one long decision path. +- [ ] Existing product destinations still work until CP-ROUTE-01 and CP-ROUTE-02 replace them. +- [ ] At 360×800, 390×844, 768×1024, and 1440×900, the choices have no horizontal overflow and the product names, statuses, and actions remain discoverable. +- [ ] Keyboard focus order follows visual order and each card has explicit links rather than invalid nested interactive controls. +- [ ] Obsolete product-story CSS and motion code is deleted rather than covered by another override layer. + +**Test/validation command** + + npm.cmd --prefix site test + npm.cmd --prefix site run build + npm.cmd --prefix site run e2e + +### CP-UX-03 — Simplify homepage trust and proof sections + +**Type:** UX/presentation + +**User story** + +> As a doctor evaluating CarePath, I want concise safety promises and plainly labeled evidence so that I can assess the demo without scrolling through repeated marketing sections. + +**Files likely affected** + +- scribe/frontend/src/LandingPage.tsx +- scribe/frontend/src/content/strings.ts +- scribe/frontend/src/styles.css +- scribe/frontend/src/scribe/ScribeShowcase.tsx +- scribe/frontend/src/demo/DemoPlayer.tsx +- scribe/frontend/src/LandingPage.test.tsx +- scribe/frontend/tests/demo-flow.spec.ts + +**Dependencies** + +- CP-UX-02. +- Approved sanitized sample content for the two proof blocks; do not invent outcome claims or testimonials. + +**Scope guard** + +- Supporting homepage presentation only. +- Do not change product routes, pilot form payload/submission, audio behavior, or product APIs. + +**Acceptance criteria** + +- [ ] A three-item trust strip follows the product chooser. +- [ ] The trust strip states clinician review, visible uncertainty, and no replacement of diagnosis/advice. +- [ ] The decorative marquee is removed from the primary flow. +- [ ] Product proof is static and clearly labeled as sample or simulation data. +- [ ] The research source link is always visible rather than hidden behind a carousel step. +- [ ] Simulation, pilot, and draft statuses are visible without interacting with a carousel. +- [ ] The pilot section remains present but its behavior is unchanged until CP-FORM-01. +- [ ] Reduced-motion behavior remains valid. + +**Test/validation command** + + npm.cmd --prefix site test + npm.cmd --prefix site run build + npm.cmd --prefix site run e2e + +### CP-UX-04 — Make clinical-note copy draft-first and plain-language + +**Type:** UX/copy only + +**User story** + +> As a Vietnamese doctor, I want the clinical-note tool to say exactly what is a draft and what still needs review so that I do not mistake AI output for a completed medical record. + +**Files likely affected** + +- scribe/frontend/src/content/strings.ts +- scribe/frontend/src/scribe/ScribeTool.tsx +- scribe/frontend/src/scribe/ScribeShowcase.tsx +- scribe/frontend/src/scribe/ScribeTool.test.tsx +- scribe/frontend/src/scribe/ScribeShowcase.test.tsx + +**Dependencies** + +- CP-UX-01. + +**Scope guard** + +- Visible labels, descriptions, breadcrumbs, and help text only. +- Do not change fetch logic, upload validation, response types, rendering states, routes, or API payloads. + +**Acceptance criteria** + +- [ ] Breadcrumb and product heading use “Trợ lý ghi chép lâm sàng.” +- [ ] Page title, submit action, loading steps, result heading, copied heading, and new-note action all say “bản nháp.” +- [ ] ASR is introduced as “phiên âm tự động.” +- [ ] SOAP is explained in plain Vietnamese on first use. +- [ ] Copy clearly says the draft does not enter the record automatically. +- [ ] Missing information and clinician-review requirements remain visible. +- [ ] English copy remains complete and equally explicit. + +**Test/validation command** + + npm.cmd --prefix site test + npm.cmd --prefix site run build + +### CP-UX-05 — Create one locale source for the Interpreter app + +**Type:** UX/copy infrastructure + +**User story** + +> As a Vietnamese doctor, I want my language choice to apply to the whole Interpreter product so that the VI control is not limited to the header. + +**Files likely affected** + +- interpreter/frontend/src/copy.ts (new) +- interpreter/frontend/src/App.tsx +- interpreter/frontend/src/App.test.tsx +- interpreter/frontend/index.html +- interpreter/frontend/src/components/ConsentGate.tsx +- interpreter/frontend/src/components/InterpreterConsole.tsx + +**Dependencies** + +- CP-UX-01. + +**Scope guard** + +- Locale state, copy plumbing, shell copy, and document language only. +- Do not change product routes, consent payloads, session creation, WebSocket handling, microphone handling, risk gating, or TTS. + +**Acceptance criteria** + +- [ ] App owns one vi/en locale state and passes it to every doctor-facing screen. +- [ ] The destination reads a valid lang query value once, then persists it in carepath-demo-language. +- [ ] An invalid language value falls back to Vietnamese. +- [ ] The product shell, page title, and document language update together. +- [ ] No new i18n dependency or context abstraction is added unless plain props cannot cover the current tree. +- [ ] Existing consent and session behavior remains unchanged. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + +### CP-UX-06 — Rewrite consent as Vietnamese-first attestation + +**Type:** UX/copy only + +**User story** + +> As a Vietnamese doctor starting an interpreted encounter, I want clear consent statements in Vietnamese with an English companion so that I know what was explained and agreed before microphone access can begin. + +**Files likely affected** + +- interpreter/frontend/src/copy.ts +- interpreter/frontend/src/components/ConsentGate.tsx +- interpreter/frontend/src/components/ConsentGate.test.tsx +- interpreter/frontend/src/App.test.tsx + +**Dependencies** + +- CP-UX-05. +- Clinical, privacy, and legal approval of the final attestation text before production release. + +**Scope guard** + +- Copy, language markup, grouping, and error announcement only. +- Do not change the consent payload shape, the requirement for both checks, session creation timing, or microphone gating. + +**Acceptance criteria** + +- [ ] Vietnamese is the primary clinician language. +- [ ] English patient-facing companion text has lang="en"; Vietnamese text has lang="vi". +- [ ] Statements explicitly identify that AI fallibility and the right to a human interpreter were explained. +- [ ] Both acknowledgements remain required before the start button is enabled. +- [ ] The translate-only limitation remains explicit. +- [ ] Start, loading, retry, and error copy are localized. +- [ ] Consent errors are announced without moving microphone controls before consent. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + +### CP-UX-07 — Localize workspace, transcript, risk, and feedback terminology + +**Type:** UX/copy only + +**User story** + +> As a Vietnamese doctor, I want conversation controls and safety states described in plain Vietnamese so that I can understand the current turn without decoding developer terms. + +**Files likely affected** + +- interpreter/frontend/src/copy.ts +- interpreter/frontend/src/components/InterpreterConsole.tsx +- interpreter/frontend/src/components/Transcript.tsx +- interpreter/frontend/src/components/InterpreterConsole.test.tsx +- interpreter/frontend/src/components/Transcript.test.tsx + +**Dependencies** + +- CP-UX-05. + +**Scope guard** + +- Display mapping and language attributes only. +- Do not change speaker payloads, recording handlers, WebSocket state, confirmation behavior, TTS, escalation behavior, or feedback API calls. + +**Acceptance criteria** + +- [ ] Visible labels for doctor, patient, language, connection, recording, risk, confirmation, transcript, and feedback are localized. +- [ ] Raw enum values such as dose_number, awaiting_confirm, confirmed, and provider mode are not shown directly. +- [ ] Each utterance has the correct lang attribute. +- [ ] Mock status says the product is not for real clinical care. +- [ ] English mode remains complete. +- [ ] Existing delivery, confirmation, escalation, and feedback behavior is unchanged. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + +--- + +## Routing stories + +### CP-ROUTE-01 — Add the canonical clinical-note route + +**Type:** routing only + +**User story** + +> As a visitor, I want a shareable clinical-note URL so that I can open the correct product without understanding a hash route. + +**Files likely affected** + +- scribe/frontend/src/App.tsx +- scribe/frontend/src/pages/ClinicalNotesPage.tsx (new thin wrapper) +- scribe/frontend/src/App.test.tsx +- scribe/frontend/tests/demo-flow.spec.ts +- scribe/carepath/main.py +- scribe/tests/test_combined_app.py + +**Dependencies** + +- CP-UX-02. +- CP-UX-04. + +**Scope guard** + +- Route recognition, page ownership, legacy migration, and server fallback only. +- Do not redesign the homepage, change Scribe upload/result behavior, or touch Interpreter routing/audio. + +**Acceptance criteria** + +- [ ] /ghi-chep-lam-sang/ is the canonical clinical-note page. +- [ ] A direct production load and refresh return 200. +- [ ] The page renders the existing Scribe tool inside a thin product-page wrapper. +- [ ] /#/scribe performs a client-side replace to the canonical path. +- [ ] Browser back and forward behave predictably. +- [ ] Existing /api/v1 routes are unchanged. +- [ ] No router dependency is added. + +**Test/validation command** + + npm.cmd --prefix site test + npm.cmd --prefix site run build + npm.cmd --prefix site run e2e + python -m pytest scribe/tests/test_combined_app.py + +### CP-ROUTE-02 — Move the Interpreter entry to a task-based canonical route + +**Type:** routing and deployment only + +**User story** + +> As a visitor, I want a Vietnamese task-based Interpreter URL so that the address describes the product instead of exposing the technical word “console.” + +**Files likely affected** + +- interpreter/frontend/vite.config.ts +- interpreter/frontend/package.json +- interpreter/frontend/playwright.config.ts +- interpreter/frontend/playwright.prod.config.ts (new, or an equivalently small production-base config) +- scribe/carepath/main.py +- scribe/frontend/src/LandingPage.tsx +- scribe/frontend/vercel.json +- scribe/frontend/.env.e2e +- scribe/frontend/.env.example +- scribe/frontend/scripts/validate-deploy-env.mjs +- scribe/frontend/scripts/validate-deploy-env.test.mjs +- scribe/tests/test_combined_app.py +- Dockerfile +- .env.example +- README.md +- README.hf-space.md +- scribe/frontend/README.md +- docs/deploy.md + +**Dependencies** + +- CP-UX-02. +- CP-UX-05. +- Product-owner decision for the canonical public-site origin used by “Tất cả sản phẩm.” + +**Scope guard** + +- Canonical mount, build base, redirects, environment validation, and route documentation only. +- Do not change homepage layout, consent/session behavior, microphone/WebSocket/TTS logic, or admin routing. + +**Acceptance criteria** + +- [ ] /phien-dich-y-khoa/ serves the built Interpreter app and supports direct refresh. +- [ ] /console and /console/ redirect to the canonical path. +- [ ] The public product link carries lang=vi or lang=en when it crosses origins. +- [ ] Vite assets load under the new production base. +- [ ] VITE_CONSOLE_URL validation expects the new canonical pathname. +- [ ] The Vercel build rewrites /ghi-chep-lam-sang/ to the site index so direct loads work. +- [ ] A configured public-site URL makes “Tất cả sản phẩm” return to the canonical site rather than an arbitrary HF Space root, and preserves lang. +- [ ] A production-base e2e command verifies built assets under /phien-dich-y-khoa/ without replacing the existing dev e2e command. +- [ ] /api/* and /ws/* remain unchanged and win before static mounts. +- [ ] Deployment and local-run documentation use the canonical route. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e:prod + npm.cmd --prefix site run test:deploy-env + npm.cmd --prefix site run build + python -m pytest scribe/tests/test_combined_app.py + +### CP-ROUTE-03 — Make internal review reachable without exposing it publicly + +**Type:** routing only + +**User story** + +> As an authorized reviewer, I want one production-safe review URL so that I can reach the existing admin component without adding it to public navigation. + +**Files likely affected** + +- interpreter/frontend/src/App.tsx +- interpreter/frontend/src/App.test.tsx +- interpreter/frontend/src/components/AdminReview.tsx +- interpreter/frontend/tests/mock-mode.spec.ts +- docs/deploy.md + +**Dependencies** + +- CP-ROUTE-02. + +**Scope guard** + +- Hash recognition and return navigation only. +- Do not change admin API contracts, token persistence, filters, CSV behavior, public navigation, or Interpreter audio workflow. + +**Acceptance criteria** + +- [ ] /phien-dich-y-khoa/#/kiem-duyet renders AdminReview in the production build. +- [ ] The review route is absent from public navigation and sitemap-like content. +- [ ] The admin token remains memory-only. +- [ ] A clear link returns to the Interpreter entry. +- [ ] Old unreachable /admin and /console/admin assumptions are removed from tests/docs. +- [ ] Direct refresh works because the server receives only the canonical product path. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e + +--- + +## Functional product stories + +### CP-FORM-01 — Remove hidden sample data and make pilot submission honest + +**Type:** functional form behavior + +**User story** + +> As a clinic representative, I want to review exactly what a pilot request will contain and how it will be sent so that I do not submit fictitious clinic or demo transcript data. + +**Files likely affected** + +- scribe/frontend/src/LandingPage.tsx +- scribe/frontend/src/LeadForm.tsx +- scribe/frontend/src/leads.ts +- scribe/frontend/src/content/strings.ts +- scribe/frontend/src/LeadForm.test.tsx +- scribe/frontend/src/leads.test.ts +- scribe/frontend/tests/demo-flow.spec.ts + +**Dependencies** + +- CP-UX-03. + +**Scope guard** + +- Form defaults, editable fields, context opt-in, payload, and submit-mode messaging only. +- Do not change homepage card layout, product routing, product APIs, or audio workflows. + +**Acceptance criteria** + +- [ ] Clinic and specialty start blank, or sample values are unmistakably labeled and excluded from submissions until edited. +- [ ] Clinic information is editable in the form. +- [ ] Interpreter demo context/transcript is excluded by default and requires explicit opt-in. +- [ ] Endpoint mode says “Gửi yêu cầu thí điểm.” +- [ ] Mail mode says “Mở email đã điền sẵn” and never reports the request as sent. +- [ ] The confirmation/error state accurately reflects the configured submission mode. +- [ ] Existing stable interest payload values remain interpreter, scribe, and both. + +**Test/validation command** + + npm.cmd --prefix site test + npm.cmd --prefix site run build + npm.cmd --prefix site run e2e + +### CP-NOTES-01 — Show the clinical-note evidence chain already returned by the API + +**Type:** functional clinical-note review + +**User story** + +> As a doctor reviewing a generated note, I want to compare the raw transcript, corrected transcript, corrected terms, missing information, and SOAP draft so that I can verify the draft before using it. + +**Files likely affected** + +- scribe/frontend/src/scribe/ScribeTool.tsx +- scribe/frontend/src/scribe/ScribeTool.test.tsx +- scribe/frontend/src/styles.css +- scribe/frontend/tests/demo-flow.spec.ts + +**Dependencies** + +- CP-UX-04. +- CP-ROUTE-01. + +**Scope guard** + +- Consume and present fields already returned by POST /api/v1/soap-notes. +- Do not change the Scribe API schema, pipeline, model prompts, upload flow, or product routing. + +**Acceptance criteria** + +- [ ] The frontend response type retains raw_transcript, corrected_transcript, retrieved_terms, soap, and metadata. +- [ ] Review order is raw transcript → corrected transcript/terms → missing information → draft SOAP. +- [ ] Empty optional fields render a clear empty state rather than invented text. +- [ ] Every displayed SOAP section remains labeled as a draft requiring review. +- [ ] Rendering escapes model text and preserves current RichText safety. +- [ ] Copy/download output still labels the result as a draft. + +**Test/validation command** + + npm.cmd --prefix site test + npm.cmd --prefix site run build + npm.cmd --prefix site run e2e + +### CP-NOTES-02 — Localize clinical-note failures and state accurate privacy + +**Type:** functional error handling + +**User story** + +> As a doctor using the clinical-note tool, I want actionable Vietnamese errors and accurate audio-handling copy so that I know what failed without seeing raw backend messages. + +**Files likely affected** + +- scribe/frontend/src/scribe/ScribeTool.tsx +- scribe/frontend/src/content/strings.ts +- scribe/frontend/src/scribe/ScribeTool.test.tsx +- scribe/frontend/src/styles.css + +**Dependencies** + +- CP-UX-04. + +**Scope guard** + +- Error classification, localized state, retry behavior, and product-specific privacy copy only. +- Do not change backend status codes, upload limits, pipeline behavior, routes, or Interpreter privacy copy. + +**Acceptance criteria** + +- [ ] Unsupported file, oversized file, rate limit, ASR failure, LLM failure, offline, and unknown failure have localized messages. +- [ ] Raw server detail is not shown to the user. +- [ ] Retry preserves the selected file when safe and does not submit automatically. +- [ ] Privacy copy describes deliberate upload and request-scoped temporary processing accurately. +- [ ] The note tool does not reuse the Interpreter's memory-only audio claim. +- [ ] Technical details remain available only in logs/developer diagnostics. + +**Test/validation command** + + npm.cmd --prefix site test + npm.cmd --prefix site run build + +### CP-INT-01 — Separate doctor and patient input regions + +**Type:** functional UI + +**User story** + +> As a doctor sharing one device with a patient, I want clearly different doctor-Vietnamese and patient-English input regions so that I do not attribute a turn to the wrong person. + +**Files likely affected** + +- interpreter/frontend/src/components/InterpreterConsole.tsx +- interpreter/frontend/src/components/InterpreterConsole.test.tsx +- interpreter/frontend/src/App.css +- interpreter/frontend/tests/mock-mode.spec.ts + +**Dependencies** + +- CP-UX-07. + +**Scope guard** + +- Speaker selection layout and typed-entry placement only. +- Reuse existing speaker values and existing recording functions. +- Do not add recording state, keyboard PTT, risk-review, escalation, or route changes. + +**Acceptance criteria** + +- [ ] One region is labeled “Bác sĩ · Tiếng Việt.” +- [ ] One region is labeled “Người bệnh · English.” +- [ ] Voice and typed fallback live within the selected speaker region; the separate generic speaker dropdown is removed. +- [ ] Role distinction uses text and structure, not color alone. +- [ ] Submitted payloads still use doctor/patient and vi/en. +- [ ] No microphone request occurs before consent. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e + +### CP-INT-02 — Make push-to-talk stateful and keyboard accessible + +**Type:** audio workflow + +**User story** + +> As a clinician using mouse, touch, or keyboard, I want one clear recording and processing state so that I cannot start overlapping turns or lose track of what the microphone is doing. + +**Files likely affected** + +- interpreter/frontend/src/components/InterpreterConsole.tsx +- interpreter/frontend/src/components/InterpreterConsole.test.tsx +- interpreter/frontend/src/App.css +- interpreter/frontend/tests/mock-mode.spec.ts + +**Dependencies** + +- CP-INT-01. + +**Scope guard** + +- Microphone input state and PTT interaction only. +- Do not change translation, risk classification, confirmation, escalation/TTS policy, low-confidence recovery, routes, or marketing copy. + +**Acceptance criteria** + +- [ ] The visible state sequence is ready → recording → processing → delivered or review required. +- [ ] The busy phase is set synchronously before awaiting microphone permission, so two rapid starts cannot overlap. +- [ ] Only one speaker can record or submit while a turn is active. +- [ ] Disconnected and processing states disable new audio/text submission. +- [ ] Pointer, touch, Space, and Enter can start/stop PTT. +- [ ] Recording exposes an accessible pressed/state announcement. +- [ ] Pointer cancel, permission denial, component unmount, and failure stop every media track. +- [ ] Typed fallback remains usable when microphone access is unavailable. +- [ ] Existing consent gate remains the only path to mounting microphone controls. +- [ ] Interpreter audio remains in memory and is never written to localStorage, IndexedDB, or temporary files. +- [ ] The existing backend overlap guard remains a second line of defense. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e + python -m pytest interpreter/tests/test_api.py::test_websocket_rejects_overlapping_turns interpreter/tests/test_api.py::test_dropped_turn_does_not_block_reconnect + +### CP-INT-03 — Add clinician-only review for high and critical risk + +**Type:** safety workflow + +**User story** + +> As a doctor, I want high-risk translations isolated in a clinician-only review state so that the patient cannot see or hear them before I confirm the content. + +**Files likely affected** + +- interpreter/frontend/src/components/InterpreterConsole.tsx +- interpreter/frontend/src/components/Transcript.tsx +- interpreter/frontend/src/components/InterpreterConsole.test.tsx +- interpreter/frontend/src/components/Transcript.test.tsx +- interpreter/frontend/src/App.css +- interpreter/frontend/tests/mock-mode.spec.ts + +**Dependencies** + +- CP-UX-07. +- CP-INT-02. +- Clinician/product-owner approval of the shared-device patient-masking interaction. + +**Scope guard** + +- Presentation and release behavior for turns already classified high/critical. +- Do not change risk lexicons, thresholds, fixtures, backend classification, product routes, or general audio state. + +**Acceptance criteria** + +- [ ] A newly blocked turn first renders a patient-safe mask; its draft translation is absent from the DOM until the clinician explicitly opens review. +- [ ] The clinician review labels source, draft translation, speaker, language, risk reason, and critical entities. +- [ ] The review receives focus and an accessible announcement when created. +- [ ] Unchanged text offers one action: “Xác nhận và phát.” +- [ ] Edited text offers one action: “Lưu chỉnh sửa và phát.” +- [ ] Confirming without saving cannot silently discard a visible edit. +- [ ] An empty edited translation cannot be submitted. +- [ ] TTS is never called before successful confirmation. +- [ ] Confirmation failure leaves the turn blocked and keeps source, draft translation, and escalation available. +- [ ] Critical content keeps human-interpreter escalation visible. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run e2e + python -m pytest + python interpreter/eval/run_eval.py --set interpreter/eval/fixtures/eval_starter.tsv --providers mock + +### CP-INT-04 — Suppress automated playback after human escalation + +**Type:** safety workflow + +**User story** + +> As a doctor who requested a human interpreter, I want automated speech stopped until I explicitly resume it so that the interface behavior matches the escalation warning. + +**Files likely affected** + +- interpreter/frontend/src/components/InterpreterConsole.tsx +- interpreter/frontend/src/components/InterpreterConsole.test.tsx +- interpreter/frontend/src/tts.ts +- interpreter/frontend/src/tts.test.ts (new) +- interpreter/frontend/src/App.css +- interpreter/frontend/tests/mock-mode.spec.ts + +**Dependencies** + +- CP-INT-02. +- CP-INT-03. +- Clinician/product-owner approval that “resume” restores local playback only and does not clear the backend escalation record. + +**Scope guard** + +- Escalation and TTS eligibility only. +- Do not change risk classification, low-confidence handling, recording state, session deletion, or routes. + +**Acceptance criteria** + +- [ ] Human-interpreter escalation remains one action away from every active state. +- [ ] One pure eligibility check permits speech only for deliverable, confirmed/corrected turns that are not low-confidence, blocked, ended, or playback-suppressed; every TTS call uses it. +- [ ] Selecting escalation immediately cancels pending browser speech and suppresses future automatic TTS, even if the escalation API later fails. +- [ ] Transcript and typed text remain available for the existing escalated-session workflow. +- [ ] Confirming a turn while escalated does not speak it. +- [ ] Resuming automated playback requires an explicit clinician action and clear warning. +- [ ] Failed escalation shows a localized error but remains fail-closed for playback until the clinician explicitly chooses a safe next action. +- [ ] Unit/e2e tests stub speechSynthesis and prove no playback after escalation. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e + python -m pytest + +### CP-INT-05 — Attach low-confidence recovery to the affected turn + +**Type:** safety workflow + +**User story** + +> As a doctor, I want uncertainty shown on the specific affected turn with a clear recovery action so that an old warning does not make the whole session appear unreliable. + +**Files likely affected** + +- interpreter/frontend/src/components/InterpreterConsole.tsx +- interpreter/frontend/src/components/Transcript.tsx +- interpreter/frontend/src/components/InterpreterConsole.test.tsx +- interpreter/frontend/src/components/Transcript.test.tsx +- interpreter/frontend/src/App.css +- interpreter/frontend/tests/mock-mode.spec.ts + +**Dependencies** + +- CP-UX-07. +- CP-INT-02. +- CP-INT-04. + +**Scope guard** + +- Per-turn low-confidence display and recovery only. +- Do not change confidence thresholds, backend/provider logic, high-risk confirmation, escalation, routes, or session lifecycle. + +**Acceptance criteria** + +- [ ] The low-confidence warning appears on the affected turn. +- [ ] The stale global turns.some(low_confidence) banner is removed; operational errors retain a separate transient status area. +- [ ] The turn offers “Nói lại” and “Nhập văn bản” recovery paths. +- [ ] “Nói lại” focuses the correct speaker control but does not begin recording automatically. +- [ ] “Nhập văn bản” focuses the correct typed input. +- [ ] Low-confidence content is not sent to TTS automatically. +- [ ] A later safe turn does not retain a stale global low-confidence warning. +- [ ] Multiple low-confidence turns retain their own state independently. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run e2e + python -m pytest + python interpreter/eval/run_eval.py --set interpreter/eval/fixtures/eval_starter.tsv --providers mock + +### CP-INT-06 — Add end, delete, and withdraw-consent lifecycle actions + +**Type:** session lifecycle + +**User story** + +> As a doctor or patient, I want to end the session, withdraw consent, and delete session data through clear actions so that audio and automated playback cannot continue after the encounter ends. + +**Files likely affected** + +- interpreter/frontend/src/api.ts +- interpreter/frontend/src/App.tsx +- interpreter/frontend/src/components/ConsentGate.tsx +- interpreter/frontend/src/components/InterpreterConsole.tsx +- interpreter/frontend/src/components/InterpreterConsole.test.tsx +- interpreter/frontend/src/App.css +- interpreter/frontend/tests/mock-mode.spec.ts + +**Dependencies** + +- CP-UX-06. +- CP-INT-02. +- CP-INT-04. + +**Scope guard** + +- Client use of existing end/delete endpoints and local cleanup only. +- Do not change backend retention policy, endpoint schemas, risk rules, routes, or general recording UX. + +**Acceptance criteria** + +- [ ] The doctor can end an active session without deleting it. +- [ ] Consent withdrawal immediately stops recording, media tracks, WebSocket use, and TTS. +- [ ] Delete requires an explicit confirmation and calls the existing delete endpoint. +- [ ] After end/delete/withdrawal, no turn can be submitted or spoken. +- [ ] Success returns to a clear completed/consent state without silently starting a new session. +- [ ] Starting another session always returns through consent; prior consent is not silently reused. +- [ ] API failure keeps the interface fail-closed and provides a localized retry path. +- [ ] Unmount cleanup closes the socket and releases all media tracks. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e + python -m pytest + +--- + +## Onboarding stories + +**Strategy basis.** These stories adapt five evidence-based onboarding principles to a clinical consent flow: smart defaults reduce decision fatigue (choices become "confirm a recommendation," not "author a configuration"); the goal-gradient effect (Nunes & Drèze 2006: an endowed head start raised loyalty-card completion 82%) motivates completion when — and only when — the head start is genuinely earned; reciprocity (Duolingo's lesson-before-signup raised DAU 20%) means showing the interpreter working before the legal gate; the IKEA effect means a small personalization investment raises commitment; and longer flows feel short when each screen asks one thing and embedded checklists replace pop-up tours. The telehealth "green room" pre-call check (live mic meter, device picker) is the standard pattern for device readiness. + +**Persuasion boundary matrix.** Every onboarding element belongs to exactly one class: + +| Class | Elements | Allowed | Forbidden | +| --- | --- | --- | --- | +| Legal | The two consent attestations | Plain language, VI-first copy, seeing the value demo beforehand | Pre-checking, defaults, gamified or animated progress rewards, urgency cues | +| Safety | Device readiness, translate-only reminder | Momentum framing (stepper position), smart default for detected working mic | Silent skipping; voice mode without a passed mic check | +| Preference | Interface language, role, session language direction, mic selection | Smart defaults, localStorage persistence, skip paths | — | + +**Honest-progress policy.** A stepper step may display as complete only when the user actually completed it in this or a prior visit (persisted preference). No fabricated pre-fill. The consent step never displays complete before both attestations are checked and submitted. + +**Target flow.** 1. Ngôn ngữ (auto-complete from persisted `carepath-demo-language`) → 2. Về bạn (intent quiz) → 3. Xác nhận (consent attestation, with value demo available) → 4. Kiểm tra thiết bị (green room) → 5. Bắt đầu (console, with embedded first-session checklist). + +### CP-ONB-01 — Show a simulated interpreting exchange before consent + +**Type:** onboarding UI, presentation only + +**User story** + +> As a doctor seeing CarePath for the first time, I want to watch a short simulated interpreting exchange — including how uncertainty and risky content are handled — before I attest to anything, so that my consent is informed by what the product actually does. + +**Files likely affected** + +- interpreter/frontend/src/components/DemoPreview.tsx (new) +- interpreter/frontend/src/components/DemoPreview.test.tsx (new) +- interpreter/frontend/src/components/ConsentGate.tsx +- interpreter/frontend/src/copy.ts +- interpreter/frontend/src/App.css +- interpreter/frontend/tests/mock-mode.spec.ts + +**Dependencies** + +- CP-UX-06. + +**Scope guard** + +- A canned, client-side replay of hardcoded turns only. +- No API calls, no WebSocket, no microphone access, no session creation, and no change to consent payload shape, checkbox requirements, or submit timing. + +**Acceptance criteria** + +- [ ] The pre-consent screen offers a clearly labeled simulation ("mô phỏng") that replays a canned VI↔EN exchange with three moments: a normal delivered turn, a low-confidence flagged turn, and a high-risk blocked turn awaiting clinician confirmation. +- [ ] The replay uses the localized terminology from CP-UX-07 so the demo teaches the real vocabulary. +- [ ] No network request, `getUserMedia` call, or WebSocket connection occurs during the demo. +- [ ] The demo is keyboard operable and respects `prefers-reduced-motion` (instant step-through instead of animation). +- [ ] Both consent checkboxes still start unchecked and both remain required. +- [ ] The demo never claims real clinical capability; the existing mock/simulation disclaimer remains visible. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e + +### CP-ONB-02 — Add a two-question intent quiz before consent + +**Type:** onboarding UI + +**User story** + +> As a doctor starting a session, I want to answer two quick questions about who I am and which language direction I need so that the rest of the flow is already set up for me. + +**Files likely affected** + +- interpreter/frontend/src/components/IntentQuiz.tsx (new) +- interpreter/frontend/src/components/IntentQuiz.test.tsx (new) +- interpreter/frontend/src/App.tsx +- interpreter/frontend/src/App.test.tsx +- interpreter/frontend/src/copy.ts +- interpreter/frontend/src/App.css + +**Dependencies** + +- CP-UX-05. + +**Scope guard** + +- Local UI state and localStorage persistence only. +- Answers pre-select later controls but must not change consent payload, session creation, API calls, or the internal speaker/language values doctor, patient, vi, en. + +**Acceptance criteria** + +- [ ] Exactly two questions, one per screen: role ("Hôm nay bạn là ai?" — Bác sĩ / Nhân viên phòng khám) and session language direction. +- [ ] Both questions show a pre-selected recommended default (Bác sĩ, VI→EN) so the fast path is two confirms, not two blank choices. +- [ ] A visible skip action applies the defaults and continues. +- [ ] Answers persist in localStorage under a `carepath-onboarding-` prefix and pre-select the corresponding console controls after consent. +- [ ] Returning users with persisted answers see the quiz pre-filled from storage. +- [ ] No new dependency is added. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + +### CP-ONB-03 — Add an honest-progress onboarding stepper + +**Type:** onboarding UI, presentation only + +**User story** + +> As a doctor moving toward a session, I want to see where I am in a short sequence of steps — with the steps I have already satisfied marked done — so that the mandatory consent and device check feel like finishing something already underway. + +**Files likely affected** + +- interpreter/frontend/src/components/OnboardingStepper.tsx (new) +- interpreter/frontend/src/components/OnboardingStepper.test.tsx (new) +- interpreter/frontend/src/App.tsx +- interpreter/frontend/src/components/ConsentGate.tsx +- interpreter/frontend/src/copy.ts +- interpreter/frontend/src/App.css + +**Dependencies** + +- CP-ONB-02. +- CP-UX-06. + +**Scope guard** + +- Progress presentation only, replacing the static consent-steps list in ConsentGate. +- Step completion must follow the honest-progress policy above; no step may display complete without a real completed action or persisted prior choice. +- Do not change consent requirements, payload, session creation, or audio behavior. + +**Acceptance criteria** + +- [ ] Five steps: Ngôn ngữ → Về bạn → Xác nhận → Kiểm tra thiết bị → Bắt đầu. +- [ ] The language step shows complete only when a persisted `carepath-demo-language` value exists; the quiz step only after CP-ONB-02 answers exist. +- [ ] The consent step never displays complete before both attestations are checked and submitted. +- [ ] The static `consent-steps` ordered list is removed rather than duplicated. +- [ ] Current step uses `aria-current="step"`; state is conveyed by text and structure, not color alone. +- [ ] No horizontal overflow at 360×800. +- [ ] In mock mode, each step transition logs one debug-level view/complete event so per-step drop-off is measurable. +- [ ] Plain CSS only; no new dependency. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e + +### CP-ONB-04 — Add a post-consent device readiness check + +**Type:** onboarding and audio workflow + +**User story** + +> As a doctor about to start an interpreted encounter, I want to confirm my microphone works before the patient is in front of me so that the first clinical turn is not spent troubleshooting audio. + +**Files likely affected** + +- interpreter/frontend/src/components/DeviceCheck.tsx (new) +- interpreter/frontend/src/components/DeviceCheck.test.tsx (new) +- interpreter/frontend/src/App.tsx +- interpreter/frontend/src/components/InterpreterConsole.tsx +- interpreter/frontend/src/copy.ts +- interpreter/frontend/src/App.css +- interpreter/frontend/tests/mock-mode.spec.ts + +**Dependencies** + +- CP-ONB-03. +- CP-INT-02. + +**Scope guard** + +- Rendered only after consent is recorded and the session is created, before the console mounts. +- Browser-native APIs only: `enumerateDevices`, `getUserMedia`, Web Audio API for the level meter. Test audio stays in memory, is never transmitted or persisted, and every track stops on unmount or navigation. +- Do not change WebSocket behavior, session endpoints, risk rules, or TTS. + +**Acceptance criteria** + +- [ ] No `getUserMedia` call occurs before the consent submission; an e2e test asserts this ordering. +- [ ] The screen shows a live input-level indicator that visibly responds to speech. +- [ ] When multiple microphones exist, a picker is offered and the detected working device is pre-selected; the choice persists for the session. +- [ ] Permission denial and no-device states show localized Vietnamese-first remediation copy and a retry action. +- [ ] The doctor may explicitly continue in typed-only mode; voice mode is unavailable until a mic check passes (fail-closed). +- [ ] All media tracks are stopped when leaving the screen in any direction. +- [ ] The console reuses the granted permission and no longer surprises the doctor with a first-turn permission prompt. +- [ ] The check is keyboard operable and announced to screen readers. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e + +### CP-ONB-05 — Embed a first-session checklist in the console + +**Type:** onboarding UI, presentation only + +**User story** + +> As a doctor in my first session, I want a small in-page checklist of the core safety actions so that I learn the workflow from real events instead of a pop-up tour blocking my controls. + +**Files likely affected** + +- interpreter/frontend/src/components/SessionChecklist.tsx (new) +- interpreter/frontend/src/components/SessionChecklist.test.tsx (new) +- interpreter/frontend/src/components/InterpreterConsole.tsx +- interpreter/frontend/src/copy.ts +- interpreter/frontend/src/App.css + +**Dependencies** + +- CP-INT-02. +- CP-INT-03. +- CP-INT-04. +- CP-INT-05. + +**Scope guard** + +- A display-only panel driven by state the console already holds. No new events, API calls, or workflow changes. +- Never a modal, overlay, or tour; it must not cover or disable any clinical control. + +**Acceptance criteria** + +- [ ] Checklist items map to real outcome events: first turn delivered, a read-back/confirmation completed, and the human-interpreter escalation control located (focused or opened). +- [ ] Items check themselves off from real console state; they cannot be checked manually. +- [ ] The panel is dismissible, the dismissal persists in localStorage, and it never reappears modally. +- [ ] Checklist announcements use polite live-region priority and never compete with risk or escalation alerts. +- [ ] Vietnamese-first with complete English mode. +- [ ] Existing risk, confirmation, escalation, and low-confidence behavior is unchanged. + +**Test/validation command** + + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e + +--- + +## Final QA story + +### CP-QA-01 — Run the complete release, accessibility, and visual gate + +**Type:** QA only + +**User story** + +> As the CarePath team, we want one final production-like verification pass so that Vietnamese copy, canonical routes, clinical safety, responsive behavior, and accessibility ship together. + +**Files likely affected** + +- scribe/frontend/tests/demo-flow.spec.ts +- scribe/frontend/tests/combined-product.spec.ts (new, if used as the production browser harness) +- interpreter/frontend/tests/mock-mode.spec.ts +- scribe/tests/test_combined_app.py +- scribe/frontend/scripts/validate-deploy-env.test.mjs +- README.md +- README.hf-space.md +- scribe/frontend/README.md +- docs/deploy.md +- docs/ux-redesign-carepath.md + +**Dependencies** + +- Every story listed in the delivery table. +- A working browser capture surface for visual evidence. + +**Scope guard** + +- Tests, evidence, and documentation only. +- If QA finds a feature defect, reopen the owning story or create a narrow follow-up; do not hide feature work inside the QA story. + +**Acceptance criteria** + +- [ ] Direct production-like loads return 200 for /, /ghi-chep-lam-sang/, and /phien-dich-y-khoa/. +- [ ] Legacy /console, /console/, and /#/scribe links reach the canonical product. +- [ ] The internal review route works and is absent from public navigation. +- [ ] Homepage and both products are captured at 360×800, 390×844, 768×1024, and 1440×900. +- [ ] Captures show no clipping, hidden action, broken Vietnamese wrapping, horizontal overflow, or accidental patient exposure. +- [ ] Keyboard-only flows cover homepage selection, the intent quiz, consent, the device readiness check, typed turn, PTT, high-risk confirmation, escalation, low-confidence recovery, end, and delete. +- [ ] The onboarding screens (quiz, stepper, device check) are captured at the four viewports and pass the axe checks. +- [ ] An e2e assertion proves no `getUserMedia` call occurs before consent submission, including on the device-check screen. +- [ ] The stepper never displays the consent step as complete before both attestations are submitted, and no consent control is ever pre-checked. +- [ ] Automated axe checks have no serious or critical violations in key states. +- [ ] Frontend accessibility checks reuse the repository's existing axe package through the combined production harness; no duplicate accessibility dependency is added to frontend solely for this gate. +- [ ] Manual checks cover 200% zoom, Windows high contrast, reduced motion, and NVDA reading/focus order. +- [ ] High/critical-risk content never reaches patient display or TTS before confirmation. +- [ ] Low-confidence content remains visibly flagged. +- [ ] Microphone capture never occurs before consent and all tracks stop on end/withdraw/delete. +- [ ] Vietnamese NFC/diacritics validation passes. +- [ ] README and deploy docs match the implemented routes and readiness claims. + +**Test/validation command** + + python -m ruff check . + python -m pytest + python scripts/smoke_backend.py + npm.cmd --prefix frontend run lint + npm.cmd --prefix frontend test + npm.cmd --prefix frontend run build + npm.cmd --prefix frontend run e2e + npm.cmd --prefix frontend run e2e:prod + npm.cmd --prefix site run lint + npm.cmd --prefix site test + npm.cmd --prefix site run build + npm.cmd --prefix site run e2e + npm.cmd --prefix site run build + npm.cmd --prefix site run test:deploy-env + npm.cmd --prefix site run lighthouse + python interpreter/eval/run_eval.py --set interpreter/eval/fixtures/eval_starter.tsv --providers mock + +## Deferred unless separately approved + +- Changing risk rules, confidence thresholds, provider behavior, API schemas, or retention policy +- Claiming production readiness for real-time medical interpretation +- Adding a routing, state-management, or localization dependency +- Reworking authentication beyond making the existing token-gated review page reachable +- Rebranding runtime assets from the generated CarePath Translate brand board +- Rewriting historical plans in `docs/history/` diff --git a/interpreter/app/__init__.py b/interpreter/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e775ba47a0699750e7d821060342df4257f0e077 --- /dev/null +++ b/interpreter/app/__init__.py @@ -0,0 +1 @@ +"""CarePath Interpreter backend.""" diff --git a/interpreter/app/api.py b/interpreter/app/api.py new file mode 100644 index 0000000000000000000000000000000000000000..6c75bb780750cfdb09b0c70eca3d857ae2e50929 --- /dev/null +++ b/interpreter/app/api.py @@ -0,0 +1,442 @@ +import csv +import hmac +import json +import logging +from dataclasses import dataclass, field +from functools import partial +from io import StringIO +from typing import Annotated, Any + +import anyio +from fastapi import ( + APIRouter, + Depends, + Header, + HTTPException, + Response, + WebSocket, + WebSocketDisconnect, +) +from sqlalchemy import desc +from sqlmodel import Session as DBSession +from sqlmodel import select + +from app import crud +from app.config import Settings, get_settings +from app.db import get_db +from app.providers import get_providers +from app.schemas import ( + ConfirmTurnRequest, + FeedbackCreateRequest, + SessionCreateRequest, + SessionCreateResponse, +) +from app.session import process_audio_turn, process_text_turn + +api_router = APIRouter(prefix="/api") +ws_router = APIRouter() +DBDep = Annotated[DBSession, Depends(get_db)] +AdminToken = Annotated[str | None, Header(alias="X-Admin-Token")] +logger = logging.getLogger(__name__) +MAX_TYPED_TEXT_CHARS = 2000 + + +@dataclass(slots=True) +class InFlightTurn: + speaker: str + lang: str + chunks: list[bytes] = field(default_factory=list) + audio_bytes: int = 0 + + +# ponytail: process-local turn lock; use DB/Redis if multi-worker deploy matters. +_in_flight: dict[str, InFlightTurn] = {} + + +def _risk_spans(turn: crud.TurnRecord) -> list[dict[str, Any]]: + return json.loads(turn.risk_spans_json or "[]") + + +def turn_payload(turn: crud.TurnRecord) -> dict[str, Any]: + return { + "id": turn.id, + "session_id": turn.session_id, + "seq": turn.seq, + "speaker": turn.speaker, + "src_lang": turn.src_lang, + "tgt_lang": turn.tgt_lang, + "source_text": turn.source_text, + "normalized_text": turn.normalized_text, + "translation": turn.translation, + "asr_confidence": turn.asr_confidence, + "mt_confidence": turn.mt_confidence, + "risk_tier": turn.risk_tier, + "risk_spans": _risk_spans(turn), + "readback": json.loads(turn.readback_json) if turn.readback_json else None, + "status": turn.status, + "corrected_text": turn.corrected_text, + "created_at": turn.created_at.isoformat(), + } + + +def feedback_payload(feedback: crud.FeedbackRecord) -> dict[str, Any]: + return { + "id": feedback.id, + "turn_id": feedback.turn_id, + "reason": feedback.reason, + "comment": feedback.comment, + "created_at": feedback.created_at.isoformat(), + } + + +def turn_result_payload(turn: crud.TurnRecord, settings: Settings) -> dict[str, Any]: + low_confidence = ( + turn.asr_confidence < settings.confidence_threshold + or turn.mt_confidence < settings.confidence_threshold + ) + return { + "type": "turn_result", + "turn": turn_payload(turn), + "requires_confirmation": turn.risk_tier in {"high", "critical"} + or turn.status in {"awaiting_confirm", "blocked"}, + "low_confidence": low_confidence, + } + + +def log_turn_processed(turn: crud.TurnRecord) -> None: + logger.info( + "turn_processed", + extra={ + "turn_id": turn.id, + "session_id": turn.session_id, + "risk_tier": turn.risk_tier, + "status": turn.status, + }, + ) + + +async def _send_pipeline_error( + websocket: WebSocket, + session_id: str, + exc: Exception, + *, + speaker: str, + lang: str, + source_text: str | None, +) -> None: + logger.warning( + "turn_processing_failed session_id=%s error_class=%s", + session_id, + exc.__class__.__name__, + ) + _in_flight.pop(session_id, None) + await websocket.send_json( + { + "type": "turn_error", + "message": "translation failed — retry or use typed fallback", + "retryable": True, + "failure_context": { + "speaker": speaker, + "src_lang": lang, + "tgt_lang": "en" if lang.lower().startswith("vi") else "vi", + "source_text": source_text, + "translation": None, + }, + } + ) + + +def _session_is_ended(db: DBSession, session_id: str) -> bool: + return crud.require_session(db, session_id).status == "ended" + + +async def _send_session_ended(websocket: WebSocket, session_id: str) -> None: + _in_flight.pop(session_id, None) + await websocket.send_json( + {"type": "turn_error", "message": "session ended", "retryable": False} + ) + + +@api_router.get("/health") +def health() -> dict[str, str]: + settings = get_settings() + return {"status": "ok", "provider_mode": settings.provider_mode} + + +@api_router.post("/sessions", status_code=201) +def create_session( + payload: SessionCreateRequest, + db: DBDep, +) -> SessionCreateResponse: + session = crud.create_session(db, payload.consent) + return SessionCreateResponse(session_id=session.id) + + +@api_router.post("/sessions/{session_id}/end", status_code=204) +def end_session(session_id: str, db: DBDep) -> Response: + crud.end_session(db, session_id) + return Response(status_code=204) + + +@api_router.post("/sessions/{session_id}/escalate", status_code=204) +def escalate_session(session_id: str, db: DBDep) -> Response: + crud.escalate_session(db, session_id) + return Response(status_code=204) + + +@api_router.delete("/sessions/{session_id}", status_code=204) +def delete_session(session_id: str, db: DBDep) -> Response: + crud.hard_delete_session(db, session_id) + return Response(status_code=204) + + +@api_router.get("/sessions/{session_id}/transcript") +def get_transcript(session_id: str, db: DBDep) -> list[dict[str, Any]]: + if crud.get_session(db, session_id) is None: + raise HTTPException(status_code=404, detail="session not found") + return [turn_payload(turn) for turn in crud.list_turns(db, session_id)] + + +@api_router.post("/turns/{turn_id}/confirm") +def confirm_turn( + turn_id: str, + payload: ConfirmTurnRequest, + db: DBDep, +) -> dict[str, Any]: + return turn_payload(crud.confirm_turn(db, turn_id, payload.edited_translation)) + + +@api_router.post("/turns/{turn_id}/feedback", status_code=201) +def create_feedback( + turn_id: str, + payload: FeedbackCreateRequest, + db: DBDep, +) -> dict[str, str]: + feedback = crud.create_feedback(db, turn_id, payload.reason, payload.comment) + return {"id": feedback.id} + + +@api_router.get("/admin/review", response_model=None) +def admin_review( + db: DBDep, + x_admin_token: AdminToken = None, + risk: str | None = None, + flagged: bool = False, + low_confidence: bool = False, + escalated: bool = False, + limit: int = 50, + offset: int = 0, + format: str = "json", +) -> dict[str, Any] | Response: + settings = get_settings() + if not hmac.compare_digest(x_admin_token or "", settings.admin_token): + raise HTTPException(status_code=401, detail="invalid admin token") + + sessions = {session.id: session for session in db.exec(select(crud.SessionRecord))} + feedback: dict[str, list[crud.FeedbackRecord]] = {} + for item in db.exec(select(crud.FeedbackRecord)): + feedback.setdefault(item.turn_id, []).append(item) + + # ponytail: Python scan is enough for single-clinic SQLite MVP. + rows: list[dict[str, Any]] = [] + for turn in db.exec(select(crud.TurnRecord).order_by(desc(crud.TurnRecord.created_at))): + turn_feedback = feedback.get(turn.id, []) + session = sessions.get(turn.session_id) + if risk and turn.risk_tier != risk: + continue + if flagged and not turn_feedback: + continue + if low_confidence and ( + turn.asr_confidence >= settings.confidence_threshold + and turn.mt_confidence >= settings.confidence_threshold + ): + continue + if escalated and session and session.status != "escalated": + continue + payload = turn_payload(turn) + payload["session_status"] = session.status if session else "unknown" + payload["feedback"] = [feedback_payload(item) for item in turn_feedback] + rows.append(payload) + + total = len(rows) + page = rows[offset : offset + limit] + if format == "csv": + return review_csv(page) + return {"items": page, "total": total, "limit": limit, "offset": offset} + + +def review_csv(rows: list[dict[str, Any]]) -> Response: + handle = StringIO() + fieldnames = [ + "id", + "session_id", + "speaker", + "source_text", + "translation", + "corrected_text", + "risk_tier", + "status", + "session_status", + "feedback_reasons", + ] + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow( + { + key: row.get(key, "") + for key in fieldnames + if key != "feedback_reasons" + } + | {"feedback_reasons": ";".join(item["reason"] for item in row["feedback"])} + ) + return Response(content=handle.getvalue(), media_type="text/csv") + + +@ws_router.websocket("/ws/sessions/{session_id}") +async def websocket_session( + websocket: WebSocket, + session_id: str, + db: DBDep, +) -> None: + try: + await websocket.accept() + except RuntimeError: + return + settings = get_settings() + providers = get_providers(settings) + try: + if crud.get_session(db, session_id) is None: + await websocket.close(code=1008, reason="session not found") + return + + await websocket.send_json( + { + "type": "session_state", + "turns": [turn_payload(turn) for turn in crud.list_turns(db, session_id)], + } + ) + + while True: + message = await websocket.receive() + if message["type"] == "websocket.disconnect": + break + if message.get("bytes") is not None: + current = _in_flight.get(session_id) + if current is None: + await websocket.send_json( + {"type": "turn_error", "message": "no turn in progress", "retryable": True} + ) + continue + chunk = message["bytes"] + current.audio_bytes += len(chunk) + if current.audio_bytes > settings.max_turn_audio_bytes: + _in_flight.pop(session_id, None) + await websocket.send_json( + { + "type": "turn_error", + "message": "audio turn too large", + "retryable": True, + } + ) + continue + current.chunks.append(chunk) + continue + + event = json.loads(message.get("text") or "{}") + event_type = event.get("type") + if event_type in {"start_turn", "end_turn", "text_turn"} and _session_is_ended( + db, session_id + ): + await _send_session_ended(websocket, session_id) + continue + if event_type == "start_turn": + if session_id in _in_flight: + await websocket.send_json( + { + "type": "turn_error", + "message": "turn already in progress", + "retryable": True, + } + ) + continue + _in_flight[session_id] = InFlightTurn( + speaker=event["speaker"], + lang=event["lang"], + ) + elif event_type == "end_turn": + current = _in_flight.pop(session_id, None) + if current is None: + await websocket.send_json( + {"type": "turn_error", "message": "no turn in progress", "retryable": True} + ) + continue + try: + turn = await anyio.to_thread.run_sync( + partial( + process_audio_turn, + db, + providers, + settings, + session_id=session_id, + speaker=current.speaker, + lang=current.lang, + audio=b"".join(current.chunks), + ) + ) + except Exception as exc: + await _send_pipeline_error( + websocket, + session_id, + exc, + speaker=current.speaker, + lang=current.lang, + source_text=None, + ) + continue + log_turn_processed(turn) + await websocket.send_json(turn_result_payload(turn, settings)) + elif event_type == "text_turn": + if len(event.get("text", "")) > MAX_TYPED_TEXT_CHARS: + await websocket.send_json( + { + "type": "turn_error", + "message": "typed turn too long", + "retryable": True, + } + ) + continue + try: + turn = await anyio.to_thread.run_sync( + partial( + process_text_turn, + db, + providers, + settings, + session_id=session_id, + speaker=event["speaker"], + lang=event["lang"], + text=event["text"], + asr_confidence=1.0, + ) + ) + except Exception as exc: + await _send_pipeline_error( + websocket, + session_id, + exc, + speaker=event["speaker"], + lang=event["lang"], + source_text=event["text"], + ) + continue + log_turn_processed(turn) + await websocket.send_json(turn_result_payload(turn, settings)) + else: + await websocket.send_json( + {"type": "turn_error", "message": "unknown event type", "retryable": False} + ) + except (RuntimeError, WebSocketDisconnect): + pass + finally: + _in_flight.pop(session_id, None) diff --git a/interpreter/app/config.py b/interpreter/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..6069385b50c8425c51a130f64daf68a93aee0d3d --- /dev/null +++ b/interpreter/app/config.py @@ -0,0 +1,40 @@ +from functools import lru_cache +from typing import Literal + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +DEFAULT_CORS_ORIGINS = ("http://localhost:5173", "http://127.0.0.1:5173") + + +class Settings(BaseSettings): + provider_mode: Literal["mock", "cloud"] = "mock" + anthropic_api_key: str = "" + openai_api_key: str = "" + admin_token: str = "change-me" + confidence_threshold: float = Field(default=0.7, ge=0, le=1) + retention_days: int = Field(default=30, ge=0) + max_turn_audio_bytes: int = Field(default=10 * 1024 * 1024, ge=1) + database_url: str = "sqlite:///./carepath.db" + openai_transcribe_model: str = "gpt-4o-transcribe" + claude_mt_model: str = "claude-sonnet-5" + claude_reviewer_model: str = "claude-sonnet-5" + provider_timeout_seconds: float = 30 + cors_origins: tuple[str, ...] = DEFAULT_CORS_ORIGINS + + model_config = SettingsConfigDict( + env_file=("../.env", ".env"), enable_decoding=False, extra="ignore" + ) + + @field_validator("cors_origins", mode="before") + @classmethod + def parse_cors_origins(cls, value: object) -> tuple[str, ...]: + if isinstance(value, str): + origins = (item.strip().rstrip("/") for item in value.split(",")) + return tuple(origin for origin in origins if origin) + return tuple(value) if value else () + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/interpreter/app/crud.py b/interpreter/app/crud.py new file mode 100644 index 0000000000000000000000000000000000000000..e04574179d67b2dcbab9b626c92011f1e865460b --- /dev/null +++ b/interpreter/app/crud.py @@ -0,0 +1,211 @@ +import json +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import uuid4 + +from fastapi import HTTPException +from sqlalchemy import desc +from sqlmodel import Field, Session, SQLModel, select + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def new_id() -> str: + return uuid4().hex + + +class SessionRecord(SQLModel, table=True): + __tablename__ = "sessions" + + id: str = Field(default_factory=new_id, primary_key=True) + created_at: datetime = Field(default_factory=utc_now) + status: str = "active" + consent_json: str + privacy_mode: int = 1 + escalated_at: datetime | None = None + + +class TurnRecord(SQLModel, table=True): + __tablename__ = "turns" + + id: str = Field(default_factory=new_id, primary_key=True) + session_id: str = Field(foreign_key="sessions.id", index=True) + seq: int + speaker: str + src_lang: str + tgt_lang: str + source_text: str + normalized_text: str + translation: str + asr_confidence: float + mt_confidence: float + risk_tier: str + risk_spans_json: str = "[]" + readback_json: str | None = None + status: str + corrected_text: str | None = None + created_at: datetime = Field(default_factory=utc_now) + + +class FeedbackRecord(SQLModel, table=True): + __tablename__ = "feedback" + + id: str = Field(default_factory=new_id, primary_key=True) + turn_id: str = Field(foreign_key="turns.id", index=True) + reason: str + comment: str | None = None + created_at: datetime = Field(default_factory=utc_now) + + +def create_session(db: Session, consent: dict[str, Any]) -> SessionRecord: + session = SessionRecord(consent_json=json.dumps(consent, ensure_ascii=False)) + db.add(session) + db.commit() + db.refresh(session) + return session + + +def get_session(db: Session, session_id: str) -> SessionRecord | None: + return db.get(SessionRecord, session_id) + + +def require_session(db: Session, session_id: str) -> SessionRecord: + session = get_session(db, session_id) + if session is None: + raise HTTPException(status_code=404, detail="session not found") + return session + + +def list_turns(db: Session, session_id: str) -> list[TurnRecord]: + return list( + db.exec( + select(TurnRecord) + .where(TurnRecord.session_id == session_id) + .order_by(TurnRecord.seq) + ) + ) + + +def next_seq(db: Session, session_id: str) -> int: + latest = db.exec( + select(TurnRecord) + .where(TurnRecord.session_id == session_id) + .order_by(desc(TurnRecord.seq)) + .limit(1) + ).first() + return 1 if latest is None else latest.seq + 1 + + +def create_turn( + db: Session, + *, + session_id: str, + speaker: str, + src_lang: str, + tgt_lang: str, + source_text: str, + normalized_text: str, + translation: str, + asr_confidence: float, + mt_confidence: float, + risk_tier: str, + risk_spans: list[dict[str, Any]], + status: str, + readback: dict[str, Any] | None = None, +) -> TurnRecord: + session = require_session(db, session_id) + if session.status == "ended": + raise HTTPException(status_code=409, detail="session ended") + turn = TurnRecord( + session_id=session_id, + seq=next_seq(db, session_id), + speaker=speaker, + src_lang=src_lang, + tgt_lang=tgt_lang, + source_text=source_text, + normalized_text=normalized_text, + translation=translation, + asr_confidence=asr_confidence, + mt_confidence=mt_confidence, + risk_tier=risk_tier, + risk_spans_json=json.dumps(risk_spans, ensure_ascii=False), + readback_json=json.dumps(readback, ensure_ascii=False) if readback else None, + status=status, + ) + db.add(turn) + db.commit() + db.refresh(turn) + return turn + + +def confirm_turn(db: Session, turn_id: str, edited_translation: str | None) -> TurnRecord: + turn = db.get(TurnRecord, turn_id) + if turn is None: + raise HTTPException(status_code=404, detail="turn not found") + session = require_session(db, turn.session_id) + if session.status == "ended": + raise HTTPException(status_code=409, detail="session ended") + if turn.status not in {"awaiting_confirm", "blocked"}: + raise HTTPException(status_code=409, detail="turn is not awaiting confirmation") + turn.status = "corrected" if edited_translation else "confirmed" + turn.corrected_text = edited_translation + db.add(turn) + db.commit() + db.refresh(turn) + return turn + + +def create_feedback( + db: Session, + turn_id: str, + reason: str, + comment: str | None, +) -> FeedbackRecord: + if db.get(TurnRecord, turn_id) is None: + raise HTTPException(status_code=404, detail="turn not found") + feedback = FeedbackRecord(turn_id=turn_id, reason=reason, comment=comment) + db.add(feedback) + db.commit() + db.refresh(feedback) + return feedback + + +def end_session(db: Session, session_id: str) -> None: + session = require_session(db, session_id) + session.status = "ended" + db.add(session) + db.commit() + + +def escalate_session(db: Session, session_id: str) -> None: + session = require_session(db, session_id) + session.status = "escalated" + session.escalated_at = utc_now() + db.add(session) + db.commit() + + +def hard_delete_session(db: Session, session_id: str) -> None: + session = require_session(db, session_id) + turn_ids = [turn.id for turn in list_turns(db, session_id)] + for feedback in db.exec(select(FeedbackRecord).where(FeedbackRecord.turn_id.in_(turn_ids))): + db.delete(feedback) + for turn in db.exec(select(TurnRecord).where(TurnRecord.session_id == session_id)): + db.delete(turn) + db.delete(session) + db.commit() + + +def purge_old_sessions(db: Session, retention_days: int) -> int: + if retention_days == 0: + return 0 + cutoff = utc_now() - timedelta(days=retention_days) + old_ids = [ + session.id + for session in db.exec(select(SessionRecord).where(SessionRecord.created_at < cutoff)) + ] + for session_id in old_ids: + hard_delete_session(db, session_id) + return len(old_ids) diff --git a/interpreter/app/db.py b/interpreter/app/db.py new file mode 100644 index 0000000000000000000000000000000000000000..9ee928e0695fc9bcc716ec7f00a07c2022c1fb9d --- /dev/null +++ b/interpreter/app/db.py @@ -0,0 +1,39 @@ +from collections.abc import Iterator + +from sqlalchemy.engine import Engine +from sqlmodel import Session, SQLModel, create_engine + +from app import crud as _crud +from app.config import get_settings +from app.glossary import store as _glossary_store + +del _crud, _glossary_store + +_engine: Engine | None = None + + +def make_engine(database_url: str | None = None) -> Engine: + url = database_url or get_settings().database_url + connect_args = {"check_same_thread": False} if url.startswith("sqlite") else {} + return create_engine(url, connect_args=connect_args) + + +def get_engine() -> Engine: + global _engine + if _engine is None: + _engine = make_engine() + return _engine + + +def set_engine(engine: Engine | None) -> None: + global _engine + _engine = engine + + +def init_db(engine: Engine | None = None) -> None: + SQLModel.metadata.create_all(engine or get_engine()) + + +def get_db() -> Iterator[Session]: + with Session(get_engine()) as session: + yield session diff --git a/interpreter/app/glossary/__init__.py b/interpreter/app/glossary/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..dc1233f3beb91b8cac1e06221462b4caeba7e731 --- /dev/null +++ b/interpreter/app/glossary/__init__.py @@ -0,0 +1,3 @@ +from app.glossary.store import GlossaryRecord, lookup_glossary, seed_glossary + +__all__ = ["GlossaryRecord", "lookup_glossary", "seed_glossary"] diff --git a/interpreter/app/glossary/data/seed_glossary.csv b/interpreter/app/glossary/data/seed_glossary.csv new file mode 100644 index 0000000000000000000000000000000000000000..1962bcead335ce6f5e6a87b1ffe2e6b6afb951a9 --- /dev/null +++ b/interpreter/app/glossary/data/seed_glossary.csv @@ -0,0 +1,166 @@ +term_vi,term_en,kind,lasa_group +Augmentin,Augmentin,drug, +amoxicillin,amoxicillin,drug, +penicillin,penicillin,drug, +azithromycin,azithromycin,drug, +cefuroxim,cefuroxime,drug, +cefixim,cefixime,drug, +cephalexin,cephalexin,drug, +ciprofloxacin,ciprofloxacin,drug, +levofloxacin,levofloxacin,drug, +metronidazole,metronidazole,drug, +doxycycline,doxycycline,drug, +clarithromycin,clarithromycin,drug, +clindamycin,clindamycin,drug, +vancomycin,vancomycin,drug, +gentamicin,gentamicin,drug, +paracetamol,paracetamol,drug, +acetaminophen,acetaminophen,drug, +ibuprofen,ibuprofen,drug, +diclofenac,diclofenac,drug, +naproxen,naproxen,drug, +aspirin,aspirin,drug, +tramadol,tramadol,drug, +morphin,morphine,drug, +codein,codeine,drug, +omeprazole,omeprazole,drug,Losec +Losec,Losec,drug,Losec +Lasix,Lasix,drug,Lasix +furosemide,furosemide,drug,Lasix +metformin,metformin,drug, +glibenclamide,glibenclamide,drug, +gliclazide,gliclazide,drug, +insulin,insulin,drug, +atorvastatin,atorvastatin,drug, +simvastatin,simvastatin,drug, +rosuvastatin,rosuvastatin,drug, +amlodipine,amlodipine,drug, +nifedipine,nifedipine,drug, +losartan,losartan,drug, +valsartan,valsartan,drug, +enalapril,enalapril,drug, +captopril,captopril,drug, +bisoprolol,bisoprolol,drug, +metoprolol,metoprolol,drug, +atenolol,atenolol,drug, +hydrochlorothiazide,hydrochlorothiazide,drug, +spironolactone,spironolactone,drug, +warfarin,warfarin,drug, +heparin,heparin,drug, +clopidogrel,clopidogrel,drug, +apixaban,apixaban,drug, +rivaroxaban,rivaroxaban,drug, +salbutamol,salbutamol,drug, +budesonide,budesonide,drug, +prednisolone,prednisolone,drug, +dexamethasone,dexamethasone,drug, +cetirizine,cetirizine,drug, +loratadine,loratadine,drug, +chlorpheniramine,chlorpheniramine,drug, +ondansetron,ondansetron,drug, +domperidone,domperidone,drug, +loperamide,loperamide,drug, +oresol,oral rehydration salts,drug, +vitamin D,vitamin D,drug, +sắt,iron,drug, +acid folic,folic acid,drug, +levothyroxine,levothyroxine,drug, +carbimazole,carbimazole,drug, +allopurinol,allopurinol,drug, +colchicine,colchicine,drug, +gabapentin,gabapentin,drug, +pregabalin,pregabalin,drug, +diazepam,diazepam,drug, +alprazolam,alprazolam,drug, +sertraline,sertraline,drug, +fluoxetine,fluoxetine,drug, +ceftriaxone,ceftriaxone,drug, +meropenem,meropenem,drug, +adrenaline,epinephrine,drug, +naloxone,naloxone,drug, +thuốc kháng sinh,antibiotic,med_term, +thuốc giảm đau,pain reliever,med_term, +thuốc hạ sốt,fever reducer,med_term, +thuốc chống dị ứng,antihistamine,med_term, +thuốc huyết áp,blood pressure medicine,med_term, +thuốc tiểu đường,diabetes medicine,med_term, +thuốc chống đông,anticoagulant,med_term, +thuốc nhỏ mắt,eye drops,med_term, +thuốc xịt,inhaler,med_term, +dị ứng thuốc,drug allergy,med_term, +phản vệ,anaphylaxis,med_term, +khó thở,shortness of breath,symptom, +đau ngực,chest pain,symptom, +đau bụng,abdominal pain,symptom, +đau đầu,headache,symptom, +chóng mặt,dizziness,symptom, +buồn nôn,nausea,symptom, +nôn ói,vomiting,symptom, +sốt,fever,symptom, +ho,cough,symptom, +ho ra máu,coughing blood,symptom, +tiêu chảy,diarrhea,symptom, +táo bón,constipation,symptom, +phát ban,rash,symptom, +sưng phù,swelling,symptom, +ngất,fainting,symptom, +co giật,seizure,symptom, +yếu liệt,weakness or paralysis,symptom, +tê bì,numbness,symptom, +đau dữ dội,severe pain,symptom, +đau nhẹ,mild pain,symptom, +mang thai,pregnant,condition, +có bầu,pregnant,condition, +cho con bú,breastfeeding,condition, +tăng huyết áp,hypertension,condition, +hạ huyết áp,low blood pressure,condition, +đái tháo đường,diabetes,condition, +tiểu đường,diabetes,condition, +hen suyễn,asthma,condition, +bệnh tim,heart disease,condition, +suy thận,kidney failure,condition, +suy gan,liver failure,condition, +viêm phổi,pneumonia,condition, +viêm dạ dày,gastritis,condition, +đột quỵ,stroke,condition, +nhồi máu cơ tim,heart attack,condition, +huyết áp,blood pressure,vital, +mạch,pulse,vital, +nhiệt độ,temperature,vital, +đường huyết,blood glucose,vital, +SpO2,oxygen saturation,vital, +uống,oral,route, +tiêm,injection,route, +truyền tĩnh mạch,intravenous infusion,route, +nhỏ mắt,eye drops,route, +nhỏ tai,ear drops,route, +xịt mũi,nasal spray,route, +hít,inhalation,route, +bôi ngoài da,topical,route, +ngậm dưới lưỡi,sublingual,route, +đặt hậu môn,rectal,route, +viên,tablet,unit, +gói,sachet,unit, +ống,ampoule,unit, +giọt,drop,unit, +muỗng,spoon,unit, +mililit,milliliter,unit, +mi-li-lít,milliliter,unit, +mi-li-gam,milligram,unit, +microgam,microgram,unit, +ngày một lần,once daily,frequency, +ngày hai lần,twice daily,frequency, +ngày ba lần,three times daily,frequency, +mỗi ngày,every day,frequency, +mỗi sáng,every morning,frequency, +mỗi tối,every evening,frequency, +sau ăn,after food,timing, +trước ăn,before food,timing, +khi đau,when in pain,timing, +trong mười ngày,for ten days,duration, +tái khám,follow-up visit,workflow, +cấp cứu,emergency department,workflow, +xét nghiệm máu,blood test,procedure, +chụp X-quang,X-ray,procedure, +siêu âm,ultrasound,procedure, +điện tim,ECG,procedure, diff --git a/interpreter/app/glossary/import_meddict.py b/interpreter/app/glossary/import_meddict.py new file mode 100644 index 0000000000000000000000000000000000000000..cb4b8fff1fc967ba8e5dc2275690ea7e8eb82ea5 --- /dev/null +++ b/interpreter/app/glossary/import_meddict.py @@ -0,0 +1,33 @@ +import argparse +import csv +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description="Convert a licensed Meddict CSV to glossary rows.") + parser.add_argument("source", type=Path) + parser.add_argument("target", type=Path) + parser.add_argument("--i-understand-license", action="store_true") + args = parser.parse_args() + if not args.i_understand_license: + raise SystemExit("Meddict license is unverified; pass --i-understand-license to import.") + + with args.source.open(encoding="utf-8", newline="") as source, args.target.open( + "w", encoding="utf-8", newline="" + ) as target: + reader = csv.DictReader(source) + writer = csv.DictWriter(target, fieldnames=["term_vi", "term_en", "kind", "lasa_group"]) + writer.writeheader() + for row in reader: + writer.writerow( + { + "term_vi": row.get("term_vi") or row.get("vi") or "", + "term_en": row.get("term_en") or row.get("en") or "", + "kind": row.get("kind") or "med_term", + "lasa_group": row.get("lasa_group") or "", + } + ) + + +if __name__ == "__main__": + main() diff --git a/interpreter/app/glossary/store.py b/interpreter/app/glossary/store.py new file mode 100644 index 0000000000000000000000000000000000000000..0861bb3dce1ced1dc3d42b8805b50de729a9e637 --- /dev/null +++ b/interpreter/app/glossary/store.py @@ -0,0 +1,69 @@ +import csv +from pathlib import Path + +from rapidfuzz import fuzz +from sqlmodel import Field, Session, SQLModel, select + +from app.normalize import fold_for_match +from app.providers.base import GlossaryEntry + +SEED_CSV = Path(__file__).with_name("data") / "seed_glossary.csv" + + +class GlossaryRecord(SQLModel, table=True): + __tablename__ = "glossary" + + id: int | None = Field(default=None, primary_key=True) + term_vi: str = Field(index=True) + term_en: str = Field(index=True) + kind: str + lasa_group: str | None = None + + +def _entry(record: GlossaryRecord) -> GlossaryEntry: + return GlossaryEntry( + term_vi=record.term_vi, + term_en=record.term_en, + kind=record.kind, + lasa_group=record.lasa_group, + ) + + +def seed_glossary(db: Session, csv_path: Path = SEED_CSV) -> int: + if db.exec(select(GlossaryRecord).limit(1)).first() is not None: + return 0 + with csv_path.open(encoding="utf-8", newline="") as handle: + rows = [ + GlossaryRecord( + term_vi=row["term_vi"], + term_en=row["term_en"], + kind=row["kind"], + lasa_group=row["lasa_group"] or None, + ) + for row in csv.DictReader(handle) + ] + db.add_all(rows) + db.commit() + return len(rows) + + +def lookup_glossary(db: Session, text: str, *, fuzzy_threshold: int = 90) -> list[GlossaryEntry]: + folded_text = fold_for_match(text) + folded_words = folded_text.split() + matches: list[GlossaryEntry] = [] + seen: set[int] = set() + for record in db.exec(select(GlossaryRecord)): + terms = [record.term_vi, record.term_en] + if any(fold_for_match(term) in folded_text for term in terms): + matches.append(_entry(record)) + seen.add(record.id or -1) + continue + if any( + fuzz.ratio(fold_for_match(term), word) >= fuzzy_threshold + for term in terms + for word in folded_words + ): + if record.id not in seen: + matches.append(_entry(record)) + seen.add(record.id or -1) + return matches diff --git a/interpreter/app/main.py b/interpreter/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..b77ef5ee1b12ebead5164995d4c889fd8fbd1649 --- /dev/null +++ b/interpreter/app/main.py @@ -0,0 +1,63 @@ +import asyncio +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from sqlmodel import Session + +from app import crud +from app.api import api_router, ws_router +from app.config import get_settings +from app.db import get_engine, init_db +from app.glossary import seed_glossary + +logger = logging.getLogger(__name__) +DAY_SECONDS = 24 * 60 * 60 + + +def validate_runtime_settings() -> None: + settings = get_settings() + if settings.provider_mode == "cloud" and settings.admin_token == "change-me": + raise RuntimeError("ADMIN_TOKEN must be changed when PROVIDER_MODE=cloud") + + +async def _purge_old_sessions_daily(retention_days: int) -> None: + while True: + await asyncio.sleep(DAY_SECONDS) + try: + with Session(get_engine()) as db: + crud.purge_old_sessions(db, retention_days) + except Exception: + logger.exception("interpreter retention purge failed") + + +@asynccontextmanager +async def interpreter_lifespan(_: FastAPI) -> AsyncIterator[None]: + validate_runtime_settings() + settings = get_settings() + init_db() + with Session(get_engine()) as db: + seed_glossary(db) + crud.purge_old_sessions(db, settings.retention_days) + purge_task = asyncio.create_task(_purge_old_sessions_daily(settings.retention_days)) + try: + yield + finally: + purge_task.cancel() + with suppress(asyncio.CancelledError): + await purge_task + + +app = FastAPI(title="CarePath Interpreter API", lifespan=interpreter_lifespan) +if get_settings().cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=list(get_settings().cors_origins), + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) +app.include_router(api_router) +app.include_router(ws_router) diff --git a/interpreter/app/normalize.py b/interpreter/app/normalize.py new file mode 100644 index 0000000000000000000000000000000000000000..bb96747e17c296cf64c852f170be9f54f836ad93 --- /dev/null +++ b/interpreter/app/normalize.py @@ -0,0 +1,17 @@ +"""Compatibility imports for the shared CarePath normalizers.""" + +from carepath_shared.normalize import ( + contains_folded, + fold_for_match, + normalize_text, + parse_vietnamese_number_words, + strip_diacritics, +) + +__all__ = [ + "contains_folded", + "fold_for_match", + "normalize_text", + "parse_vietnamese_number_words", + "strip_diacritics", +] diff --git a/interpreter/app/providers/__init__.py b/interpreter/app/providers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2c6c47b6816f38160e25ae4cc8a3f374ad2d434f --- /dev/null +++ b/interpreter/app/providers/__init__.py @@ -0,0 +1,36 @@ +from app.providers.base import ( + ASRProvider, + ASRResult, + CriticalEntity, + GlossaryEntry, + MTProvider, + MTResult, + ProviderOutputError, + Review, + ReviewerProvider, +) +from app.providers.claude_mt import ClaudeMTProvider +from app.providers.claude_reviewer import ClaudeReviewerProvider +from app.providers.mock import MockASRProvider, MockMTProvider, MockReviewerProvider +from app.providers.openai_asr import OpenAIASRProvider +from app.providers.registry import ProviderSet, get_providers + +__all__ = [ + "ASRProvider", + "ASRResult", + "CriticalEntity", + "GlossaryEntry", + "ClaudeMTProvider", + "ClaudeReviewerProvider", + "MTProvider", + "MTResult", + "MockASRProvider", + "MockMTProvider", + "MockReviewerProvider", + "ProviderSet", + "ProviderOutputError", + "OpenAIASRProvider", + "Review", + "ReviewerProvider", + "get_providers", +] diff --git a/interpreter/app/providers/base.py b/interpreter/app/providers/base.py new file mode 100644 index 0000000000000000000000000000000000000000..0ce79e0933658fe630ec5bd92865f2900a36e321 --- /dev/null +++ b/interpreter/app/providers/base.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + + +@dataclass(frozen=True, slots=True) +class ASRResult: + text: str + confidence: float + + +@dataclass(frozen=True, slots=True) +class GlossaryEntry: + term_vi: str + term_en: str + kind: str + lasa_group: str | None = None + + +@dataclass(frozen=True, slots=True) +class MTResult: + text: str + confidence: float + + +@dataclass(frozen=True, slots=True) +class CriticalEntity: + kind: str + source_text: str + translated_text: str + + +@dataclass(frozen=True, slots=True) +class Review: + back_translation: str + entities: list[CriticalEntity] + flags: list[str] + + +class ProviderOutputError(RuntimeError): + """Provider returned malformed or unsafe output.""" + + +@runtime_checkable +class ASRProvider(Protocol): + def transcribe(self, audio: bytes, lang: str) -> ASRResult: ... + + +@runtime_checkable +class MTProvider(Protocol): + def translate( + self, + text: str, + src: str, + tgt: str, + glossary_hits: list[GlossaryEntry], + ) -> MTResult: ... + + +@runtime_checkable +class ReviewerProvider(Protocol): + def review(self, source: str, translation: str, src: str, tgt: str) -> Review: ... diff --git a/interpreter/app/providers/claude_common.py b/interpreter/app/providers/claude_common.py new file mode 100644 index 0000000000000000000000000000000000000000..c7ebd95eecebc4165bc3a12f39236a36f17f760b --- /dev/null +++ b/interpreter/app/providers/claude_common.py @@ -0,0 +1,31 @@ +import json +from typing import Any + +from anthropic import Anthropic + +from app.providers.base import ProviderOutputError + + +def build_anthropic_client(api_key: str, timeout: float) -> Anthropic: + if not api_key: + raise ValueError("ANTHROPIC_API_KEY is required for cloud provider mode") + return Anthropic(api_key=api_key, timeout=timeout) + + +def message_text(message: Any) -> str: + parts: list[str] = [] + for block in getattr(message, "content", []): + text = block.get("text") if isinstance(block, dict) else getattr(block, "text", None) + if text: + parts.append(text) + return "\n".join(parts).strip() + + +def parse_strict_json(text: str) -> dict[str, Any]: + try: + value = json.loads(text) + except json.JSONDecodeError as exc: + raise ProviderOutputError("provider returned non-JSON output") from exc + if not isinstance(value, dict): + raise ProviderOutputError("provider output must be a JSON object") + return value diff --git a/interpreter/app/providers/claude_mt.py b/interpreter/app/providers/claude_mt.py new file mode 100644 index 0000000000000000000000000000000000000000..fd1cb29ee0cd91c7ced92b2556e6733eff912faa --- /dev/null +++ b/interpreter/app/providers/claude_mt.py @@ -0,0 +1,66 @@ +import json +from dataclasses import asdict +from typing import Any + +from app.providers.base import GlossaryEntry, MTResult, ProviderOutputError +from app.providers.claude_common import build_anthropic_client, message_text, parse_strict_json + +TRANSLATE_ONLY_SYSTEM = """You are a medical interpreter. Translate only the user's source text. +Never add advice, diagnoses, drug recommendations, explanations, or extra context. +Preserve numbers, units, negation, drug names, laterality, and route of administration exactly. +Return only compact JSON with exactly these keys: translation, confidence.""" + + +class ClaudeMTProvider: + def __init__( + self, + *, + api_key: str, + model: str = "claude-sonnet-5", + timeout: float = 30, + client: Any | None = None, + ) -> None: + self.client = client or build_anthropic_client(api_key, timeout) + self.model = model + + def translate( + self, + text: str, + src: str, + tgt: str, + glossary_hits: list[GlossaryEntry], + ) -> MTResult: + response = self.client.messages.create( + model=self.model, + max_tokens=800, + temperature=0, + system=TRANSLATE_ONLY_SYSTEM, + messages=[ + { + "role": "user", + "content": json.dumps( + { + "source_language": src, + "target_language": tgt, + "source_text": text, + "glossary": [asdict(entry) for entry in glossary_hits], + }, + ensure_ascii=False, + ), + } + ], + ) + return parse_mt_output(message_text(response)) + + +def parse_mt_output(text: str) -> MTResult: + value = parse_strict_json(text) + if set(value) != {"translation", "confidence"}: + raise ProviderOutputError("translation output has unexpected keys") + translation = value["translation"] + confidence = value["confidence"] + if not isinstance(translation, str) or not translation.strip(): + raise ProviderOutputError("translation must be a non-empty string") + if not isinstance(confidence, int | float) or not 0 <= float(confidence) <= 1: + raise ProviderOutputError("confidence must be between 0 and 1") + return MTResult(text=translation.strip(), confidence=float(confidence)) diff --git a/interpreter/app/providers/claude_reviewer.py b/interpreter/app/providers/claude_reviewer.py new file mode 100644 index 0000000000000000000000000000000000000000..f6e133fe38391ad43db8c0020649e40f60a39c43 --- /dev/null +++ b/interpreter/app/providers/claude_reviewer.py @@ -0,0 +1,84 @@ +import json +from typing import Any + +from app.providers.base import CriticalEntity, ProviderOutputError, Review +from app.providers.claude_common import build_anthropic_client, message_text, parse_strict_json + +REVIEWER_SYSTEM = """You verify high-risk medical translations. +Back-translate the translation and extract only critical entities: drug, dose, frequency, +route, allergen, laterality, negation. Return only compact JSON with exactly these keys: +back_translation, entities, flags. Each entity must have kind, source_text, translated_text.""" + + +class ClaudeReviewerProvider: + def __init__( + self, + *, + api_key: str, + model: str = "claude-sonnet-5", + timeout: float = 30, + client: Any | None = None, + ) -> None: + self.client = client or build_anthropic_client(api_key, timeout) + self.model = model + + def review(self, source: str, translation: str, src: str, tgt: str) -> Review: + response = self.client.messages.create( + model=self.model, + max_tokens=1000, + temperature=0, + system=REVIEWER_SYSTEM, + messages=[ + { + "role": "user", + "content": json.dumps( + { + "source_language": src, + "target_language": tgt, + "source_text": source, + "translation": translation, + }, + ensure_ascii=False, + ), + } + ], + ) + return parse_review_output(message_text(response)) + + +def parse_review_output(text: str) -> Review: + value = parse_strict_json(text) + if set(value) != {"back_translation", "entities", "flags"}: + raise ProviderOutputError("review output has unexpected keys") + if not isinstance(value["back_translation"], str) or not value["back_translation"].strip(): + raise ProviderOutputError("back_translation must be a non-empty string") + if not isinstance(value["entities"], list) or not isinstance(value["flags"], list): + raise ProviderOutputError("entities and flags must be lists") + + entities: list[CriticalEntity] = [] + for entity in value["entities"]: + expected_entity_keys = {"kind", "source_text", "translated_text"} + if not isinstance(entity, dict) or set(entity) != expected_entity_keys: + raise ProviderOutputError("entity has unexpected keys") + if not isinstance(entity["kind"], str): + raise ProviderOutputError("entity kind must be a string") + if not isinstance(entity["source_text"], str) or not isinstance( + entity["translated_text"], str + ): + raise ProviderOutputError("entity text fields must be strings") + entities.append( + CriticalEntity( + kind=entity["kind"], + source_text=entity["source_text"].strip(), + translated_text=entity["translated_text"].strip(), + ) + ) + + flags = value["flags"] + if not all(isinstance(flag, str) for flag in flags): + raise ProviderOutputError("flags must be strings") + return Review( + back_translation=value["back_translation"].strip(), + entities=entities, + flags=flags, + ) diff --git a/interpreter/app/providers/mock.py b/interpreter/app/providers/mock.py new file mode 100644 index 0000000000000000000000000000000000000000..e3826ce3d267f8c85d99577b2f1b8c756d3cb4cb --- /dev/null +++ b/interpreter/app/providers/mock.py @@ -0,0 +1,69 @@ +from collections.abc import Mapping + +from app.providers.base import ASRResult, GlossaryEntry, MTResult, Review + + +class MockASRProvider: + def __init__( + self, + canned: Mapping[tuple[str, str], ASRResult] | None = None, + *, + fail: bool = False, + confidence: float = 0.99, + ) -> None: + self.canned = dict(canned or {}) + self.fail = fail + self.confidence = confidence + + def transcribe(self, audio: bytes, lang: str) -> ASRResult: + if self.fail: + raise RuntimeError("mock ASR failure") + text = audio.decode("utf-8", errors="ignore").strip() or "mock transcript" + return self.canned.get((lang, text), ASRResult(text=text, confidence=self.confidence)) + + +class MockMTProvider: + def __init__( + self, + canned: Mapping[tuple[str, str, str], MTResult] | None = None, + *, + fail: bool = False, + confidence: float = 0.99, + ) -> None: + self.canned = dict(canned or {}) + self.fail = fail + self.confidence = confidence + + def translate( + self, + text: str, + src: str, + tgt: str, + glossary_hits: list[GlossaryEntry], + ) -> MTResult: + if self.fail: + raise RuntimeError("mock MT failure") + del glossary_hits + return self.canned.get( + (src, tgt, text), + MTResult(text=f"[{src}->{tgt}] {text}", confidence=self.confidence), + ) + + +class MockReviewerProvider: + def __init__( + self, + canned: Mapping[tuple[str, str, str, str], Review] | None = None, + *, + fail: bool = False, + ) -> None: + self.canned = dict(canned or {}) + self.fail = fail + + def review(self, source: str, translation: str, src: str, tgt: str) -> Review: + if self.fail: + raise RuntimeError("mock reviewer failure") + return self.canned.get( + (src, tgt, source, translation), + Review(back_translation=source, entities=[], flags=[]), + ) diff --git a/interpreter/app/providers/openai_asr.py b/interpreter/app/providers/openai_asr.py new file mode 100644 index 0000000000000000000000000000000000000000..4e72d58a7fb30587c202de3e617d9625dda5a896 --- /dev/null +++ b/interpreter/app/providers/openai_asr.py @@ -0,0 +1,59 @@ +from io import BytesIO +from math import exp +from typing import Any + +from openai import OpenAI + +from app.providers.base import ASRResult + + +def _attr(value: Any, name: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(name, default) + return getattr(value, name, default) + + +def confidence_from_logprobs(logprobs: list[Any] | None) -> float: + """Mean token probability from transcription logprobs; missing logprobs fail low.""" + if not logprobs: + return 0.0 + mean_logprob = sum(float(_attr(item, "logprob", -20.0)) for item in logprobs) / len(logprobs) + return max(0.0, min(1.0, exp(mean_logprob))) + + +class OpenAIASRProvider: + def __init__( + self, + *, + api_key: str, + model: str = "gpt-4o-transcribe", + timeout: float = 30, + client: Any | None = None, + attempts: int = 2, + ) -> None: + if client is None and not api_key: + raise ValueError("OPENAI_API_KEY is required for cloud provider mode") + self.client = client or OpenAI(api_key=api_key, timeout=timeout) + self.model = model + self.attempts = max(1, attempts) + + def transcribe(self, audio: bytes, lang: str) -> ASRResult: + last_error: Exception | None = None + for _ in range(self.attempts): + try: + file_obj = BytesIO(audio) + file_obj.name = "turn.webm" + response = self.client.audio.transcriptions.create( + model=self.model, + file=file_obj, + language=lang[:2], + response_format="json", + include=["logprobs"], + ) + return ASRResult( + text=str(_attr(response, "text", "")).strip(), + confidence=confidence_from_logprobs(_attr(response, "logprobs")), + ) + except Exception as exc: + last_error = exc + raise RuntimeError("ASR transcription failed") from last_error diff --git a/interpreter/app/providers/registry.py b/interpreter/app/providers/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..567dffb90f43ea7a0d5994aebfed98b111b6ab2b --- /dev/null +++ b/interpreter/app/providers/registry.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass + +from app.config import Settings, get_settings +from app.providers.base import ASRProvider, MTProvider, ReviewerProvider +from app.providers.claude_mt import ClaudeMTProvider +from app.providers.claude_reviewer import ClaudeReviewerProvider +from app.providers.mock import MockASRProvider, MockMTProvider, MockReviewerProvider +from app.providers.openai_asr import OpenAIASRProvider + + +@dataclass(frozen=True, slots=True) +class ProviderSet: + asr: ASRProvider + mt: MTProvider + reviewer: ReviewerProvider + + +def get_providers(settings: Settings | None = None) -> ProviderSet: + settings = settings or get_settings() + if settings.provider_mode == "mock": + return ProviderSet( + asr=MockASRProvider(), + mt=MockMTProvider(), + reviewer=MockReviewerProvider(), + ) + return ProviderSet( + asr=OpenAIASRProvider( + api_key=settings.openai_api_key, + model=settings.openai_transcribe_model, + timeout=settings.provider_timeout_seconds, + ), + mt=ClaudeMTProvider( + api_key=settings.anthropic_api_key, + model=settings.claude_mt_model, + timeout=settings.provider_timeout_seconds, + ), + reviewer=ClaudeReviewerProvider( + api_key=settings.anthropic_api_key, + model=settings.claude_reviewer_model, + timeout=settings.provider_timeout_seconds, + ), + ) diff --git a/interpreter/app/risk/__init__.py b/interpreter/app/risk/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ceceb0ce9e4d03938f46daafd9482ab72e612fb1 --- /dev/null +++ b/interpreter/app/risk/__init__.py @@ -0,0 +1,3 @@ +from app.risk.engine import RiskResult, classify_risk + +__all__ = ["RiskResult", "classify_risk"] diff --git a/interpreter/app/risk/engine.py b/interpreter/app/risk/engine.py new file mode 100644 index 0000000000000000000000000000000000000000..dccde3db843c88e9d9da65eb42fc710941048344 --- /dev/null +++ b/interpreter/app/risk/engine.py @@ -0,0 +1,265 @@ +import csv +import json +import re +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +from app.glossary.store import SEED_CSV +from app.normalize import fold_for_match +from app.providers.base import GlossaryEntry + +LEXICON_DIR = Path(__file__).with_name("lexicons") +SEVERITY_RANK = {"low": 0, "medium": 1, "high": 2, "critical": 3} +NUMBER_RE = re.compile(r"\d+(?:\.\d+)?") + + +@dataclass(frozen=True, slots=True) +class RiskResult: + tier: str + spans: list[dict[str, Any]] + + +@lru_cache +def load_lexicon(name: str) -> Any: + with (LEXICON_DIR / name).open(encoding="utf-8") as handle: + return json.load(handle) + + +@lru_cache +def drug_terms() -> tuple[str, ...]: + with SEED_CSV.open(encoding="utf-8", newline="") as handle: + return tuple(row["term_vi"] for row in csv.DictReader(handle) if row["kind"] == "drug") + + +@lru_cache +def lasa_terms() -> frozenset[str]: + pairs = load_lexicon("lasa_pairs.json") + return frozenset(fold_for_match(term) for pair in pairs for term in pair) + + +@lru_cache +def lasa_display_terms() -> tuple[str, ...]: + return tuple(term for pair in load_lexicon("lasa_pairs.json") for term in pair) + + +def _span(text: str, term: str, kind: str, severity: str) -> dict[str, Any] | None: + folded_text = fold_for_match(text) + folded_term = fold_for_match(term) + if " " not in folded_term and re.fullmatch(r"\w+", folded_term): + match = re.search(rf"\b{re.escape(folded_term)}\b", folded_text) + start = match.start() if match else -1 + else: + start = folded_text.find(folded_term) + if start < 0: + return None + return { + "start": start, + "end": start + len(folded_term), + "kind": kind, + "severity": severity, + "term": term, + } + + +def _term_spans(text: str, terms: list[str], kind: str, severity: str) -> list[dict[str, Any]]: + return [span for term in terms if (span := _span(text, term, kind, severity))] + + +def _literal_word_spans( + text: str, + terms: list[str], + kind: str, + severity: str, +) -> list[dict[str, Any]]: + folded = text.casefold() + spans: list[dict[str, Any]] = [] + for term in terms: + match = re.search(rf"\b{re.escape(term.casefold())}\b", folded) + if match: + spans.append( + { + "start": match.start(), + "end": match.end(), + "kind": kind, + "severity": severity, + "term": term, + } + ) + return spans + + +def _negation_spans(text: str) -> list[dict[str, Any]]: + terms = load_lexicon("negation_cues.json") + folded_terms = [term for term in terms if term != "dừng"] + return _term_spans(text, folded_terms, "negation", "high") + _literal_word_spans( + text, ["dừng"], "negation", "high" + ) + + +def _count_terms(text: str, terms: list[str]) -> int: + folded = fold_for_match(text) + total = 0 + for term in terms: + if term == "dừng": + total += len(re.findall(r"\bdừng\b", text.casefold())) + continue + folded_term = fold_for_match(term) + if " " in folded_term: + total += folded.count(folded_term) + else: + total += len(re.findall(rf"\b{re.escape(folded_term)}\b", folded)) + return total + + +def _numbers(text: str) -> list[str]: + return NUMBER_RE.findall(text) + + +def _dose_spans(text: str) -> list[dict[str, Any]]: + units = "|".join(re.escape(unit) for unit in load_lexicon("units_forms.json")) + pattern = re.compile(rf"\b\d+(?:\.\d+)?\s*(?:{units})\b", re.IGNORECASE) + return [ + { + "start": match.start(), + "end": match.end(), + "kind": "dose_number", + "severity": "high", + "term": match.group(0), + } + for match in pattern.finditer(text) + ] + + +def _frequency_spans(text: str) -> list[dict[str, Any]]: + patterns = [ + r"ngày\s+\d+(?:\.\d+)?\s+lần", + r"trong\s+\d+(?:\.\d+)?\s+ngày", + r"\+\d+(?:\.\d+)?\s+days", + r"\d+(?:\.\d+)?\s+times\s+(?:a|per)\s+day", + r"for\s+\d+(?:\.\d+)?\s+days", + ] + spans: list[dict[str, Any]] = [] + for pattern in patterns: + for match in re.finditer(pattern, text, flags=re.IGNORECASE): + spans.append( + { + "start": match.start(), + "end": match.end(), + "kind": "frequency_duration", + "severity": "high", + "term": match.group(0), + } + ) + return spans + + +def _drug_spans(text: str, glossary_hits: list[GlossaryEntry]) -> list[dict[str, Any]]: + spans: list[dict[str, Any]] = [] + terms = ( + {entry.term_vi for entry in glossary_hits if entry.kind == "drug"} + | set(drug_terms()) + | set(lasa_display_terms()) + ) + for term in terms: + severity = "critical" if fold_for_match(term) in lasa_terms() else "high" + span = _span(text, term, "drug_name", severity) + if span: + spans.append(span) + return spans + + +def _mismatch_spans(source_text: str, translation: str) -> list[dict[str, Any]]: + spans: list[dict[str, Any]] = [] + source_numbers = _numbers(source_text) + translation_numbers = _numbers(translation) + if source_numbers and source_numbers != translation_numbers: + spans.append( + { + "start": 0, + "end": len(source_text), + "kind": "number_mismatch", + "severity": "critical", + "term": ",".join(source_numbers), + } + ) + + negation_terms = load_lexicon("negation_cues.json") + if _count_terms(source_text, negation_terms) != _count_terms(translation, negation_terms): + spans.append( + { + "start": 0, + "end": len(source_text), + "kind": "negation_mismatch", + "severity": "critical", + "term": "negation", + } + ) + return spans + + +def classify_risk( + source_text: str, + translation: str, + asr_confidence: float, + mt_confidence: float, + confidence_threshold: float, + glossary_hits: list[GlossaryEntry] | None = None, +) -> RiskResult: + glossary_hits = glossary_hits or [] + spans: list[dict[str, Any]] = [] + combined = f"{source_text}\n{translation}" + spans.extend(_term_spans(combined, load_lexicon("red_flags.json"), "red_flag", "critical")) + spans.extend( + _term_spans( + combined, + ["dị ứng", "phản vệ", "allergic", "allergy", "anaphylaxis"], + "allergy", + "critical", + ) + ) + spans.extend( + _term_spans(combined, load_lexicon("pregnancy.json"), "pregnancy", "critical") + ) + spans.extend(_term_spans(combined, load_lexicon("laterality.json"), "laterality", "high")) + spans.extend(_term_spans(combined, load_lexicon("routes.json"), "route", "high")) + spans.extend(_negation_spans(combined)) + spans.extend( + _term_spans(combined, load_lexicon("abbreviations_vi.json").keys(), "abbreviation", "high") + ) + spans.extend( + _term_spans(combined, load_lexicon("subject_omission.json"), "subject_omission", "high") + ) + spans.extend( + _term_spans(combined, load_lexicon("medical_history.json"), "medical_history", "high") + ) + spans.extend( + _literal_word_spans(combined, load_lexicon("pronoun_cues.json"), "pronoun", "medium") + ) + spans.extend( + _term_spans(combined, load_lexicon("body_locations.json"), "body_location", "medium") + ) + spans.extend( + _term_spans(combined, load_lexicon("symptom_severity.json"), "symptom_severity", "critical") + ) + spans.extend(_dose_spans(combined)) + spans.extend(_frequency_spans(combined)) + spans.extend(_drug_spans(combined, glossary_hits)) + spans.extend(_mismatch_spans(source_text, translation)) + if asr_confidence < confidence_threshold or mt_confidence < confidence_threshold: + spans.append( + { + "start": 0, + "end": 0, + "kind": "low_confidence", + "severity": "low", + "term": "confidence", + } + ) + + tier = "low" + for span in spans: + if SEVERITY_RANK[span["severity"]] > SEVERITY_RANK[tier]: + tier = span["severity"] + return RiskResult(tier=tier, spans=spans) diff --git a/interpreter/app/risk/lexicons/abbreviations_vi.json b/interpreter/app/risk/lexicons/abbreviations_vi.json new file mode 100644 index 0000000000000000000000000000000000000000..7d00ce2660e3cf4bd48e3b8829be995e7b224420 --- /dev/null +++ b/interpreter/app/risk/lexicons/abbreviations_vi.json @@ -0,0 +1,12 @@ +{ + "HA": "huyết áp", + "TC": "tiểu cầu", + "ĐTĐ": "đái tháo đường", + "THA": "tăng huyết áp", + "XN": "xét nghiệm", + "SA": "siêu âm", + "ĐT": "điện tim", + "TM": "tĩnh mạch", + "TDD": "tiêm dưới da", + "TMH": "tai mũi họng" +} diff --git a/interpreter/app/risk/lexicons/body_locations.json b/interpreter/app/risk/lexicons/body_locations.json new file mode 100644 index 0000000000000000000000000000000000000000..9fdabcfc6d010777aa4219fc3c4c0108545d2e99 --- /dev/null +++ b/interpreter/app/risk/lexicons/body_locations.json @@ -0,0 +1 @@ +["đau bụng", "bụng", "thượng vị", "hạ vị", "abdominal pain", "abdomen", "stomach", "belly"] diff --git a/interpreter/app/risk/lexicons/lasa_pairs.json b/interpreter/app/risk/lexicons/lasa_pairs.json new file mode 100644 index 0000000000000000000000000000000000000000..118e911253870c4c5d1f4bb499b7eb2e6e792762 --- /dev/null +++ b/interpreter/app/risk/lexicons/lasa_pairs.json @@ -0,0 +1,32 @@ +[ + ["Losec", "Lasix"], + ["Celebrex", "Celexa"], + ["hydralazine", "hydroxyzine"], + ["Lamictal", "Lamisil"], + ["Zantac", "Xanax"], + ["clonidine", "clozapine"], + ["vinblastine", "vincristine"], + ["dopamine", "dobutamine"], + ["Humalog", "Humulin"], + ["Novolog", "Novolin"], + ["prednisone", "prednisolone"], + ["morphine", "hydromorphone"], + ["penicillin", "penicillamine"], + ["cefazolin", "ceftriaxone"], + ["metformin", "metoprolol"], + ["amlodipine", "amitriptyline"], + ["atorvastatin", "rosuvastatin"], + ["azithromycin", "erythromycin"], + ["cetirizine", "cinnarizine"], + ["diazepam", "diltiazem"], + ["digoxin", "doxycycline"], + ["fluoxetine", "fluconazole"], + ["gabapentin", "glibenclamide"], + ["heparin", "insulin"], + ["labetalol", "lamotrigine"], + ["loratadine", "losartan"], + ["omeprazole", "ondansetron"], + ["paracetamol", "pantoprazole"], + ["salbutamol", "salmeterol"], + ["warfarin", "Wafarin"] +] diff --git a/interpreter/app/risk/lexicons/laterality.json b/interpreter/app/risk/lexicons/laterality.json new file mode 100644 index 0000000000000000000000000000000000000000..0e26b1a1ffeb27fd5890021ccde5bd8c660c5245 --- /dev/null +++ b/interpreter/app/risk/lexicons/laterality.json @@ -0,0 +1 @@ +["trái", "phải", "left", "right"] diff --git a/interpreter/app/risk/lexicons/medical_history.json b/interpreter/app/risk/lexicons/medical_history.json new file mode 100644 index 0000000000000000000000000000000000000000..f3e8e352abf5c1b82699fa96328dc54dea4f11cc --- /dev/null +++ b/interpreter/app/risk/lexicons/medical_history.json @@ -0,0 +1 @@ +["tăng huyết áp", "tiểu đường", "đái tháo đường", "bệnh tim", "mổ tim", "suy thận", "suy gan", "hen suyễn", "heart surgery", "hypertension", "diabetes", "kidney failure", "liver failure", "asthma"] diff --git a/interpreter/app/risk/lexicons/negation_cues.json b/interpreter/app/risk/lexicons/negation_cues.json new file mode 100644 index 0000000000000000000000000000000000000000..b83b4c35fcc6f418e736c35bd2869afb514799f1 --- /dev/null +++ b/interpreter/app/risk/lexicons/negation_cues.json @@ -0,0 +1 @@ +["không", "chưa", "ngưng", "dừng", "không còn", "not", "no", "never", "stop", "stopped", "without"] diff --git a/interpreter/app/risk/lexicons/pregnancy.json b/interpreter/app/risk/lexicons/pregnancy.json new file mode 100644 index 0000000000000000000000000000000000000000..99485bf27bfbecef5b292774939747151891fc42 --- /dev/null +++ b/interpreter/app/risk/lexicons/pregnancy.json @@ -0,0 +1 @@ +["mang thai", "có bầu", "pregnant", "pregnancy", "breastfeeding", "cho con bú"] diff --git a/interpreter/app/risk/lexicons/pronoun_cues.json b/interpreter/app/risk/lexicons/pronoun_cues.json new file mode 100644 index 0000000000000000000000000000000000000000..4377ca54003d960a398c47e38f2ce1e9ec45608a --- /dev/null +++ b/interpreter/app/risk/lexicons/pronoun_cues.json @@ -0,0 +1 @@ +["con", "em", "anh", "chị", "cô", "chú", "bác"] diff --git a/interpreter/app/risk/lexicons/red_flags.json b/interpreter/app/risk/lexicons/red_flags.json new file mode 100644 index 0000000000000000000000000000000000000000..435ab635c6df5a4494c12ef850968bb39d8afb29 --- /dev/null +++ b/interpreter/app/risk/lexicons/red_flags.json @@ -0,0 +1 @@ +["đau ngực", "khó thở", "không thở được", "ho ra máu", "ngất", "co giật", "yếu liệt", "suicidal", "chest pain", "can't breathe", "shortness of breath", "coughing blood", "fainting", "seizure", "stroke"] diff --git a/interpreter/app/risk/lexicons/routes.json b/interpreter/app/risk/lexicons/routes.json new file mode 100644 index 0000000000000000000000000000000000000000..82c646dd9062a49d845cd905d2d1f21576bb3c4e --- /dev/null +++ b/interpreter/app/risk/lexicons/routes.json @@ -0,0 +1 @@ +["uống", "tiêm", "truyền", "nhỏ mắt", "nhỏ tai", "xịt", "hít", "bôi", "oral", "injection", "intravenous", "drops", "topical", "inhalation"] diff --git a/interpreter/app/risk/lexicons/subject_omission.json b/interpreter/app/risk/lexicons/subject_omission.json new file mode 100644 index 0000000000000000000000000000000000000000..282787f809c8a2b7e7bd9fd68869df7ecba4f3a3 --- /dev/null +++ b/interpreter/app/risk/lexicons/subject_omission.json @@ -0,0 +1 @@ +["đã uống thuốc", "đang uống thuốc", "đã tiêm", "đang dùng thuốc", "took medicine", "taking medicine"] diff --git a/interpreter/app/risk/lexicons/symptom_severity.json b/interpreter/app/risk/lexicons/symptom_severity.json new file mode 100644 index 0000000000000000000000000000000000000000..28bb18ad52247cdaecbaf105cb8a08cf512bb5f7 --- /dev/null +++ b/interpreter/app/risk/lexicons/symptom_severity.json @@ -0,0 +1 @@ +["đau dữ dội", "đau nhiều", "đau nhẹ", "severe pain", "mild pain", "sharp pain", "nặng", "nhẹ"] diff --git a/interpreter/app/risk/lexicons/units_forms.json b/interpreter/app/risk/lexicons/units_forms.json new file mode 100644 index 0000000000000000000000000000000000000000..52669760095eafaebba6dcd2280a58f181381d7f --- /dev/null +++ b/interpreter/app/risk/lexicons/units_forms.json @@ -0,0 +1 @@ +["mg", "ml", "mcg", "µg", "viên", "gói", "ống", "giọt", "tablet", "tablets", "sachet", "ampoule", "drop", "drops"] diff --git a/interpreter/app/schemas.py b/interpreter/app/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..0dc94a3dce2ba9917998597bdfb85473c570a351 --- /dev/null +++ b/interpreter/app/schemas.py @@ -0,0 +1,20 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class SessionCreateRequest(BaseModel): + consent: dict[str, Any] + + +class SessionCreateResponse(BaseModel): + session_id: str + + +class ConfirmTurnRequest(BaseModel): + edited_translation: str | None = None + + +class FeedbackCreateRequest(BaseModel): + reason: Literal["wrong_term", "wrong_meaning", "missing", "other"] + comment: str | None = Field(default=None, max_length=2000) diff --git a/interpreter/app/session.py b/interpreter/app/session.py new file mode 100644 index 0000000000000000000000000000000000000000..ef5a91fb8c3e64c344eed571dd2618494dc55f2e --- /dev/null +++ b/interpreter/app/session.py @@ -0,0 +1,110 @@ +from sqlmodel import Session + +from app import crud +from app.config import Settings +from app.glossary import lookup_glossary +from app.normalize import normalize_text +from app.providers.base import Review +from app.providers.registry import ProviderSet +from app.risk import classify_risk + + +def target_lang(lang: str) -> str: + return "en" if lang.lower().startswith("vi") else "vi" + + +def turn_status(risk_tier: str) -> str: + return "awaiting_confirm" if risk_tier in {"high", "critical"} else "delivered" + + +def review_payload(review: Review) -> dict[str, object]: + return { + "back_translation": review.back_translation, + "entities": [ + { + "kind": entity.kind, + "source_text": entity.source_text, + "translated_text": entity.translated_text, + } + for entity in review.entities + ], + "flags": review.flags, + } + + +def blocked_review_payload(source: str, reason: str) -> dict[str, object]: + return {"back_translation": source, "entities": [], "flags": [reason]} + + +def process_text_turn( + db: Session, + providers: ProviderSet, + settings: Settings, + *, + session_id: str, + speaker: str, + lang: str, + text: str, + asr_confidence: float, +) -> crud.TurnRecord: + normalized = normalize_text(text) + tgt = target_lang(lang) + glossary_hits = lookup_glossary(db, normalized) + mt_result = providers.mt.translate(normalized, lang, tgt, glossary_hits) + risk = classify_risk( + normalized, + mt_result.text, + asr_confidence, + mt_result.confidence, + settings.confidence_threshold, + glossary_hits, + ) + status = turn_status(risk.tier) + readback = None + if risk.tier in {"high", "critical"}: + try: + readback = review_payload( + providers.reviewer.review(normalized, mt_result.text, lang, tgt) + ) + except Exception: + status = "blocked" + readback = blocked_review_payload(normalized, "reviewer_failed") + return crud.create_turn( + db, + session_id=session_id, + speaker=speaker, + src_lang=lang, + tgt_lang=tgt, + source_text=text, + normalized_text=normalized, + translation=mt_result.text, + asr_confidence=asr_confidence, + mt_confidence=mt_result.confidence, + risk_tier=risk.tier, + risk_spans=risk.spans, + status=status, + readback=readback, + ) + + +def process_audio_turn( + db: Session, + providers: ProviderSet, + settings: Settings, + *, + session_id: str, + speaker: str, + lang: str, + audio: bytes, +) -> crud.TurnRecord: + asr_result = providers.asr.transcribe(audio, lang) + return process_text_turn( + db, + providers, + settings, + session_id=session_id, + speaker=speaker, + lang=lang, + text=asr_result.text, + asr_confidence=asr_result.confidence, + ) diff --git a/interpreter/eval/fixtures/.gitkeep b/interpreter/eval/fixtures/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/interpreter/eval/fixtures/.gitkeep @@ -0,0 +1 @@ + diff --git a/interpreter/eval/fixtures/eval_starter.tsv b/interpreter/eval/fixtures/eval_starter.tsv new file mode 100644 index 0000000000000000000000000000000000000000..e6886304b2ca3a23660b7bc0c8cacf7e61e92a51 --- /dev/null +++ b/interpreter/eval/fixtures/eval_starter.tsv @@ -0,0 +1,51 @@ +id speaker lang source reference expected_tier category +eval-001 doctor vi Chào anh, mời ngồi. Hello, please sit down. medium greeting +eval-002 patient en My appointment was at 2pm. Cuộc hẹn của tôi lúc 2 giờ chiều. low logistics +eval-003 doctor vi Anh thấy khó chịu ở đâu? Where do you feel discomfort? medium chief_complaint +eval-004 patient en I've had a cough for a week. Tôi bị ho một tuần. low symptom +eval-005 doctor vi Đau dữ dội hay âm ỉ? Is the pain severe or dull? critical severity +eval-006 patient en It's a sharp pain on my right side. Đau nhói ở bên phải. critical laterality +eval-007 doctor vi Anh từng mổ tim chưa? Have you ever had heart surgery? high history +eval-008 patient en I had heart surgery in 2019. Tôi đã mổ tim năm 2019. high history +eval-009 doctor vi Anh đang uống thuốc gì? What medicine are you taking? high medication +eval-010 patient en I take metformin and Lasix. Tôi dùng metformin và Lasix. critical drug_lasa +eval-011 doctor vi Anh có dị ứng thuốc nào không? Do you have any drug allergies? critical allergy +eval-012 patient en I'm allergic to penicillin. Tôi dị ứng penicillin. critical allergy +eval-013 doctor vi Không dị ứng với penicillin. Not allergic to penicillin. critical negation +eval-014 doctor vi Uống nửa viên. Take half a tablet. high dose +eval-015 doctor vi Ngày hai lần. Twice a day. high frequency +eval-016 doctor vi Trong mười ngày. For ten days. high duration +eval-017 doctor vi Năm trăm mi-li-gam. Five hundred milligrams. high unit +eval-018 doctor vi Uống Augmentin sau ăn. Take Augmentin after food. high drug +eval-019 doctor vi Uống Lasix buổi sáng. Take Lasix in the morning. critical lasa +eval-020 doctor vi Đau chân trái. Left leg pain. high laterality +eval-021 doctor vi Đau mắt phải. Right eye pain. high laterality +eval-022 doctor vi HA cao. High blood pressure. high abbreviation +eval-023 doctor vi TC thấp. Low platelets. high abbreviation +eval-024 doctor vi Con thấy mệt không? Do you feel tired? high pronoun_negation +eval-025 doctor vi Đã uống thuốc. Took medicine. high subject_omission +eval-026 doctor vi Tái khám sau mười ngày. Follow up after ten days. high date +eval-027 doctor vi Một trăm hai mươi mg. One hundred twenty milligrams. high number +eval-028 doctor vi Nói nhanh bị nuốt chữ. Fast clipped speech. low low_confidence +eval-029 doctor vi Giọng bị khẩu trang che. Mask-muffled speech. low low_confidence +eval-030 doctor vi Tiếng ồn bệnh viện. Hospital background noise. low low_confidence +eval-031 doctor vi Đang mang thai. Pregnant. critical pregnancy +eval-032 doctor vi Đang cho con bú. Breastfeeding. critical pregnancy +eval-033 doctor vi Có tăng huyết áp và tiểu đường. Has hypertension and diabetes. high history +eval-034 doctor vi Đau ngực và khó thở. Chest pain and shortness of breath. critical red_flag +eval-035 patient en I've been having chest pain. Tôi bị đau ngực. critical red_flag +eval-036 doctor vi Ngưng thuốc. Stop the medicine. high stop +eval-037 doctor vi Tiếp tục thuốc. Continue the medicine. low continue +eval-038 doctor vi Nhỏ mắt một giọt. One eye drop. high route +eval-039 doctor vi Tiêm dưới da. Subcutaneous injection. high route +eval-040 doctor vi Uống hai gói. Take two sachets. high classifier +eval-041 doctor vi Tiêm một ống. Inject one ampoule. high classifier +eval-042 doctor vi Sốt hay xót nghe không rõ. Fever or pain unclear. high low_confidence_negation +eval-043 patient en I have a cough. Tôi bị ho. low english_asr +eval-044 doctor vi Đau bụng. Abdominal pain. medium body_location +eval-045 doctor vi Đau vùng thượng vị. Upper abdominal pain. medium body_location +eval-046 doctor vi Thuoc khang sinh thiếu dấu. Antibiotic missing diacritics. low diacritics +eval-047 doctor vi Dau bung thiếu dấu. Abdominal pain missing diacritics. medium diacritics +eval-048 doctor vi Nhỏ tai ba giọt. Three ear drops. high route +eval-049 doctor vi Uống ibuprofen khi đau. Take ibuprofen when in pain. high drug +eval-050 patient en I take amoxicillin. Tôi dùng amoxicillin. high drug diff --git a/interpreter/eval/fixtures/risk_cases.jsonl b/interpreter/eval/fixtures/risk_cases.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..0cffd73d74dbe46b61a7105eef3097c24ad0507f --- /dev/null +++ b/interpreter/eval/fixtures/risk_cases.jsonl @@ -0,0 +1,91 @@ +{"id":"fm01-negation-1","failure_mode":1,"source":"không dị ứng penicillin","translation":"allergic to penicillin","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["negation_mismatch","allergy"]} +{"id":"fm01-negation-2","failure_mode":1,"source":"chưa uống thuốc","translation":"took medicine","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["negation_mismatch"]} +{"id":"fm01-negation-3","failure_mode":1,"source":"không còn đau ngực","translation":"chest pain","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["negation_mismatch","red_flag"]} +{"id":"fm02-allergy-1","failure_mode":2,"source":"dị ứng thuốc kháng sinh","translation":"allergic to antibiotics","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["allergy"]} +{"id":"fm02-allergy-2","failure_mode":2,"source":"dị ứng penicillin","translation":"penicillin allergy","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["allergy"]} +{"id":"fm02-allergy-3","failure_mode":2,"source":"bị phản vệ với thuốc","translation":"anaphylaxis with medicine","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["allergy"]} +{"id":"fm03-dose-1","failure_mode":3,"source":"uống nửa viên","translation":"take 0.5 tablet","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number"]} +{"id":"fm03-dose-2","failure_mode":3,"source":"uống hai viên","translation":"take 2 tablets","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number"]} +{"id":"fm03-dose-3","failure_mode":3,"source":"uống một viên","translation":"take 1 tablet","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number"]} +{"id":"fm04-frequency-1","failure_mode":4,"source":"ngày hai lần","translation":"2 times a day","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["frequency_duration"]} +{"id":"fm04-frequency-2","failure_mode":4,"source":"ngày ba lần","translation":"3 times per day","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["frequency_duration"]} +{"id":"fm04-frequency-3","failure_mode":4,"source":"trong năm ngày","translation":"for 5 days","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["frequency_duration"]} +{"id":"fm05-unit-1","failure_mode":5,"source":"năm trăm mi-li-gam","translation":"500 mg","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number"]} +{"id":"fm05-unit-2","failure_mode":5,"source":"uống năm mi-li-lít","translation":"take 5 ml","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number"]} +{"id":"fm05-unit-3","failure_mode":5,"source":"tiêm hai microgam","translation":"inject 2 mcg","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number","route"]} +{"id":"fm06-tone-1","failure_mode":6,"source":"má mà ma nghe không rõ","translation":"unclear tonal word","asr_confidence":0.4,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["low_confidence","negation_mismatch"]} +{"id":"fm06-tone-2","failure_mode":6,"source":"âm thanh bị rè","translation":"distorted audio","asr_confidence":0.45,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm06-tone-3","failure_mode":6,"source":"giọng nói bị méo","translation":"distorted speech","asr_confidence":0.5,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm07-codeswitch-1","failure_mode":7,"source":"uống Augmentin sau ăn","translation":"take Augmentin after food","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["drug_name"]} +{"id":"fm07-codeswitch-2","failure_mode":7,"source":"tôi dùng ibuprofen","translation":"I take ibuprofen","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["drug_name"]} +{"id":"fm07-codeswitch-3","failure_mode":7,"source":"cho amoxicillin khi sốt","translation":"give amoxicillin for fever","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["drug_name"]} +{"id":"fm08-lasa-1","failure_mode":8,"source":"uống Lasix","translation":"take Lasix","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["drug_name"]} +{"id":"fm08-lasa-2","failure_mode":8,"source":"uống Losec","translation":"take Losec","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["drug_name"]} +{"id":"fm08-lasa-3","failure_mode":8,"source":"dùng Celebrex","translation":"take Celebrex","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["drug_name"]} +{"id":"fm09-laterality-1","failure_mode":9,"source":"đau chân trái","translation":"left leg pain","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["laterality"]} +{"id":"fm09-laterality-2","failure_mode":9,"source":"đau mắt phải","translation":"right eye pain","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["laterality"]} +{"id":"fm09-laterality-3","failure_mode":9,"source":"tê tay trái","translation":"left arm numbness","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["laterality"]} +{"id":"fm10-southern-accent-1","failure_mode":10,"source":"giọng miền nam nghe lẫn âm","translation":"southern accent unclear","asr_confidence":0.45,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm10-southern-accent-2","failure_mode":10,"source":"âm cuối nghe không chắc","translation":"uncertain final consonant","asr_confidence":0.5,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["low_confidence","negation_mismatch"]} +{"id":"fm10-southern-accent-3","failure_mode":10,"source":"bệnh nhân nói giọng địa phương","translation":"regional accent","asr_confidence":0.55,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm11-central-accent-1","failure_mode":11,"source":"giọng miền trung khó nghe","translation":"central accent unclear","asr_confidence":0.45,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm11-central-accent-2","failure_mode":11,"source":"âm sắc địa phương bị méo","translation":"regional tone distortion","asr_confidence":0.5,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm11-central-accent-3","failure_mode":11,"source":"nói nhanh với giọng nặng","translation":"fast strong accent","asr_confidence":0.52,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["low_confidence","symptom_severity"]} +{"id":"fm12-abbreviation-1","failure_mode":12,"source":"HA cao","translation":"high blood pressure","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["abbreviation"]} +{"id":"fm12-abbreviation-2","failure_mode":12,"source":"TC thấp","translation":"low platelets","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["abbreviation"]} +{"id":"fm12-abbreviation-3","failure_mode":12,"source":"ĐTĐ lâu năm","translation":"longstanding diabetes","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["abbreviation","medical_history"]} +{"id":"fm13-pronoun-1","failure_mode":13,"source":"con thấy mệt","translation":"you feel tired","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"medium","expected_kinds":["pronoun"]} +{"id":"fm13-pronoun-2","failure_mode":13,"source":"em đau ở đâu","translation":"where do you hurt","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"medium","expected_kinds":["pronoun"]} +{"id":"fm13-pronoun-3","failure_mode":13,"source":"bác thấy thế nào","translation":"how do you feel","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"medium","expected_kinds":["pronoun"]} +{"id":"fm14-subject-omission-1","failure_mode":14,"source":"đã uống thuốc","translation":"took medicine","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["subject_omission"]} +{"id":"fm14-subject-omission-2","failure_mode":14,"source":"đang dùng thuốc","translation":"taking medicine","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["subject_omission"]} +{"id":"fm14-subject-omission-3","failure_mode":14,"source":"đã tiêm thuốc","translation":"injected medicine","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["subject_omission","route"]} +{"id":"fm15-date-1","failure_mode":15,"source":"tái khám sau mười ngày","translation":"follow up in 10 days","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["frequency_duration"]} +{"id":"fm15-date-2","failure_mode":15,"source":"trong bảy ngày","translation":"for 7 days","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["frequency_duration"]} +{"id":"fm15-date-3","failure_mode":15,"source":"sau ba ngày","translation":"after 3 days","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["frequency_duration"]} +{"id":"fm16-number-1","failure_mode":16,"source":"uống một trăm hai mươi mg","translation":"take 120 mg","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number"]} +{"id":"fm16-number-2","failure_mode":16,"source":"uống một trăm linh năm mg","translation":"take 105 mg","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number"]} +{"id":"fm16-number-3","failure_mode":16,"source":"uống một nghìn mg","translation":"take 1000 mg","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number"]} +{"id":"fm16-number-dropped-4","failure_mode":16,"source":"huyết áp 120 trên 80","translation":"blood pressure is normal","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["number_mismatch"]} +{"id":"fm17-fast-speech-1","failure_mode":17,"source":"nói nhanh bị nuốt chữ","translation":"fast clipped speech","asr_confidence":0.45,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm17-fast-speech-2","failure_mode":17,"source":"giọng nói quá nhanh","translation":"speech too fast","asr_confidence":0.5,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm17-fast-speech-3","failure_mode":17,"source":"âm bị dính vào nhau","translation":"merged words","asr_confidence":0.55,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm18-mask-1","failure_mode":18,"source":"giọng bị khẩu trang che","translation":"mask muffled speech","asr_confidence":0.45,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm18-mask-2","failure_mode":18,"source":"nói qua khẩu trang nghe rè","translation":"muffled by mask","asr_confidence":0.5,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm18-mask-3","failure_mode":18,"source":"âm mũi bị che","translation":"masked nasal sound","asr_confidence":0.55,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm19-noise-1","failure_mode":19,"source":"tiếng ồn bệnh viện","translation":"hospital noise","asr_confidence":0.45,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm19-noise-2","failure_mode":19,"source":"có tiếng báo động nền","translation":"background alarm","asr_confidence":0.5,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm19-noise-3","failure_mode":19,"source":"hai người nói chồng lên nhau","translation":"overlapping speech","asr_confidence":0.55,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm20-severity-1","failure_mode":20,"source":"đau dữ dội","translation":"severe pain","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["symptom_severity"]} +{"id":"fm20-severity-2","failure_mode":20,"source":"đau nhẹ","translation":"mild pain","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["symptom_severity"]} +{"id":"fm20-severity-3","failure_mode":20,"source":"đau nhiều","translation":"severe pain","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["symptom_severity"]} +{"id":"fm21-pregnancy-1","failure_mode":21,"source":"đang mang thai","translation":"pregnant","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["pregnancy"]} +{"id":"fm21-pregnancy-2","failure_mode":21,"source":"có bầu ba tháng","translation":"three months pregnant","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["pregnancy"]} +{"id":"fm21-pregnancy-3","failure_mode":21,"source":"đang cho con bú","translation":"breastfeeding","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["pregnancy","pronoun"]} +{"id":"fm22-history-1","failure_mode":22,"source":"có tăng huyết áp và tiểu đường","translation":"has hypertension and diabetes","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["medical_history"]} +{"id":"fm22-history-2","failure_mode":22,"source":"từng mổ tim năm 2019","translation":"had heart surgery in 2019","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["medical_history"]} +{"id":"fm22-history-3","failure_mode":22,"source":"có suy thận và hen suyễn","translation":"has kidney failure and asthma","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["medical_history"]} +{"id":"fm23-red-flag-1","failure_mode":23,"source":"đau ngực và khó thở","translation":"chest pain and shortness of breath","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["red_flag"]} +{"id":"fm23-red-flag-2","failure_mode":23,"source":"không thở được","translation":"can't breathe","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["red_flag"]} +{"id":"fm23-red-flag-3","failure_mode":23,"source":"ho ra máu","translation":"coughing blood","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["red_flag"]} +{"id":"fm24-stop-1","failure_mode":24,"source":"ngưng thuốc","translation":"stop medicine","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["negation"]} +{"id":"fm24-stop-2","failure_mode":24,"source":"dừng thuốc huyết áp","translation":"continue blood pressure medicine","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["negation_mismatch"]} +{"id":"fm24-stop-3","failure_mode":24,"source":"tiếp tục thuốc","translation":"stop medicine","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["negation_mismatch"]} +{"id":"fm25-route-1","failure_mode":25,"source":"nhỏ mắt một giọt","translation":"one eye drop","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["route"]} +{"id":"fm25-route-2","failure_mode":25,"source":"tiêm dưới da","translation":"subcutaneous injection","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["route"]} +{"id":"fm25-route-3","failure_mode":25,"source":"uống thuốc sau ăn","translation":"take medicine orally after food","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["route"]} +{"id":"fm26-classifier-1","failure_mode":26,"source":"uống hai gói","translation":"take 2 sachets","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number"]} +{"id":"fm26-classifier-2","failure_mode":26,"source":"tiêm một ống","translation":"inject 1 ampoule","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number","route"]} +{"id":"fm26-classifier-3","failure_mode":26,"source":"nhỏ ba giọt","translation":"apply 3 drops","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["dose_number"]} +{"id":"fm27-homophonic-disease-1","failure_mode":27,"source":"sốt hay xót nghe không rõ","translation":"fever or pain unclear","asr_confidence":0.45,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["low_confidence","negation_mismatch"]} +{"id":"fm27-homophonic-disease-2","failure_mode":27,"source":"từ bệnh nghe giống nhau","translation":"disease term sounds similar","asr_confidence":0.5,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm27-homophonic-disease-3","failure_mode":27,"source":"triệu chứng nghe sai dấu","translation":"symptom tone error","asr_confidence":0.55,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm28-english-accent-1","failure_mode":28,"source":"I take medicine","translation":"tôi uống thuốc","asr_confidence":0.45,"mt_confidence":0.99,"expected_tier":"high","expected_kinds":["low_confidence","route"]} +{"id":"fm28-english-accent-2","failure_mode":28,"source":"my speech is unclear","translation":"lời nói không rõ","asr_confidence":0.5,"mt_confidence":0.99,"expected_tier":"critical","expected_kinds":["low_confidence","negation_mismatch"]} +{"id":"fm28-english-accent-3","failure_mode":28,"source":"I have a cough","translation":"tôi bị ho","asr_confidence":0.55,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm29-body-location-1","failure_mode":29,"source":"đau bụng","translation":"abdominal pain","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"medium","expected_kinds":["body_location"]} +{"id":"fm29-body-location-2","failure_mode":29,"source":"đau vùng thượng vị","translation":"upper abdominal pain","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"medium","expected_kinds":["body_location"]} +{"id":"fm29-body-location-3","failure_mode":29,"source":"đau quanh bụng","translation":"belly pain","asr_confidence":0.99,"mt_confidence":0.99,"expected_tier":"medium","expected_kinds":["body_location"]} +{"id":"fm30-diacritic-1","failure_mode":30,"source":"thuoc khang sinh nghe thiếu dấu","translation":"antibiotic missing diacritics","asr_confidence":0.45,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm30-diacritic-2","failure_mode":30,"source":"huyet ap thiếu dấu","translation":"blood pressure missing diacritics","asr_confidence":0.5,"mt_confidence":0.99,"expected_tier":"low","expected_kinds":["low_confidence"]} +{"id":"fm30-diacritic-3","failure_mode":30,"source":"dau bung thiếu dấu","translation":"abdominal pain missing diacritics","asr_confidence":0.55,"mt_confidence":0.99,"expected_tier":"medium","expected_kinds":["low_confidence","body_location"]} diff --git a/interpreter/eval/run_eval.py b/interpreter/eval/run_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..defd719f5e4d20920c093cc9cd72370eec8b4e4e --- /dev/null +++ b/interpreter/eval/run_eval.py @@ -0,0 +1,152 @@ +import argparse +import csv +import json +import re +import sys +from collections import Counter +from pathlib import Path +from typing import Any + +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, create_engine + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app import crud # noqa: E402 +from app.config import Settings # noqa: E402 +from app.db import init_db # noqa: E402 +from app.glossary import lookup_glossary, seed_glossary # noqa: E402 +from app.normalize import normalize_text # noqa: E402 +from app.providers import get_providers # noqa: E402 +from app.risk.engine import load_lexicon # noqa: E402 +from app.session import process_text_turn # noqa: E402 + +NUMBER_RE = re.compile(r"\d+(?:\.\d+)?") +UNIT_RE = re.compile(r"\b(?:mg|ml|mcg|viên|gói|ống|tablet|tablets|sachet|ampoule)\b", re.I) + + +def read_tsv(path: Path) -> list[dict[str, str]]: + with path.open(encoding="utf-8", newline="") as handle: + return list(csv.DictReader(handle, delimiter="\t")) + + +def count_terms(text: str, terms: list[str]) -> int: + folded = text.casefold() + return sum(folded.count(term.casefold()) for term in terms) + + +def preservation(row: dict[str, str], output: str, db: Session) -> dict[str, bool]: + source = normalize_text(row["source"]) + glossary_hits = lookup_glossary(db, source) + drug_terms = [hit.term_vi for hit in glossary_hits if hit.kind == "drug"] + laterality_terms = load_lexicon("laterality.json") + negation_terms = load_lexicon("negation_cues.json") + return { + "number_exact": NUMBER_RE.findall(source) == NUMBER_RE.findall(output), + "unit_exact": UNIT_RE.findall(source) == UNIT_RE.findall(output), + "negation_polarity": ( + count_terms(source, negation_terms) == count_terms(output, negation_terms) + ), + "laterality_exact": count_terms(source, laterality_terms) + == count_terms(output, laterality_terms), + "drug_name_exact": all(term.casefold() in output.casefold() for term in drug_terms), + } + + +def score(rows: list[dict[str, Any]]) -> dict[str, Any]: + total = len(rows) + counters = Counter() + for row in rows: + counters["risk_correct"] += row["actual_tier"] == row["expected_tier"] + counters["escalation_correct"] += row["requires_confirmation"] == ( + row["actual_tier"] in {"high", "critical"} + ) + for key, passed in row["preservation"].items(): + counters[key] += passed + return { + "total": total, + "risk_accuracy": counters["risk_correct"] / total, + "escalation_correctness": counters["escalation_correct"] / total, + "preservation": { + key: counters[key] / total + for key in [ + "number_exact", + "unit_exact", + "negation_polarity", + "laterality_exact", + "drug_name_exact", + ] + }, + } + + +def write_reports(report: dict[str, Any], output_dir: Path) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "eval_report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + metrics = report["metrics"] + lines = [ + "# Eval Report", + "", + f"- Total rows: {metrics['total']}", + f"- Risk accuracy: {metrics['risk_accuracy']:.2%}", + f"- Escalation correctness: {metrics['escalation_correctness']:.2%}", + ] + for key, value in metrics["preservation"].items(): + lines.append(f"- {key}: {value:.2%}") + (output_dir / "eval_report.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def run_eval(path: Path, provider_mode: str) -> dict[str, Any]: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + init_db(engine) + with Session(engine) as db: + seed_glossary(db) + session = crud.create_session(db, {"eval": True}) + settings = Settings(provider_mode="mock" if provider_mode == "mock" else "cloud") + providers = get_providers(settings) + rows = [] + for row in read_tsv(path): + turn = process_text_turn( + db, + providers, + settings, + session_id=session.id, + speaker=row["speaker"], + lang=row["lang"], + text=row["source"], + asr_confidence=0.5 if row["category"].startswith("low_confidence") else 0.99, + ) + rows.append( + { + **row, + "actual_tier": turn.risk_tier, + "requires_confirmation": turn.status in {"awaiting_confirm", "blocked"}, + "translation": turn.translation, + "preservation": preservation(row, turn.translation, db), + } + ) + return {"metrics": score(rows), "rows": rows} + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--set", required=True, type=Path) + parser.add_argument("--providers", choices=["mock", "real"], default="mock") + parser.add_argument("--output-dir", type=Path, default=ROOT / "eval" / "reports") + args = parser.parse_args() + + report = run_eval(args.set, args.providers) + write_reports(report, args.output_dir) + print(json.dumps(report["metrics"], ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/interpreter/frontend/eslint.config.js b/interpreter/frontend/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..4ecb18bdbe385fb10b8f6d7674a591ef4664d987 --- /dev/null +++ b/interpreter/frontend/eslint.config.js @@ -0,0 +1,15 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + globals: globals.browser, + }, + }, +); diff --git a/interpreter/frontend/index.html b/interpreter/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..996b000063d95ecd1777fd06fe7fe90ac100a7e8 --- /dev/null +++ b/interpreter/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + CarePath | Phiên dịch khám bệnh trực tiếp + + +
+ + + diff --git a/interpreter/frontend/package-lock.json b/interpreter/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..73329b61be89b122fcde6ecdf87482e952378ea1 --- /dev/null +++ b/interpreter/frontend/package-lock.json @@ -0,0 +1,4015 @@ +{ + "name": "carepath-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "carepath-frontend", + "version": "0.1.0", + "dependencies": { + "@fontsource/geist": "5.2.9", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@eslint/js": "9.39.4", + "@playwright/test": "1.61.1", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.0.1", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "6.0.3", + "eslint": "9.39.4", + "globals": "15.12.0", + "jsdom": "25.0.1", + "typescript": "5.6.3", + "typescript-eslint": "8.63.0", + "vite": "8.1.3", + "vitest": "4.1.10" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fontsource/geist": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource/geist/-/geist-5.2.9.tgz", + "integrity": "sha512-6QMur8g+3/9Uvfv/5chLJGBc9bYVzWijFrWEl0uZnitxp6wEPqKCJd9/GBlOk4dg/QSe96a3TUKEgmscFpE+4g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.0.1.tgz", + "integrity": "sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.12.0.tgz", + "integrity": "sha512-1+gLErljJFhbOVyaetcwJiJ4+eLe45S2E7P5UiZ9xGfeq3ATQf5DOv9G7MH3gGbKQLkzmNh2DxfZwLdw+j6oTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/interpreter/frontend/package.json b/interpreter/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..dd7bf00d62d8215cf545decd8fbe1777ca81e36d --- /dev/null +++ b/interpreter/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "carepath-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "e2e": "playwright test", + "e2e:prod": "playwright test -c playwright.prod.config.ts", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@fontsource/geist": "5.2.9", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@eslint/js": "9.39.4", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.0.1", + "@playwright/test": "1.61.1", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "6.0.3", + "eslint": "9.39.4", + "globals": "15.12.0", + "jsdom": "25.0.1", + "typescript": "5.6.3", + "typescript-eslint": "8.63.0", + "vite": "8.1.3", + "vitest": "4.1.10" + } +} diff --git a/interpreter/frontend/playwright.config.ts b/interpreter/frontend/playwright.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..4be6945e5a263bfee74b71b443f118964ec1bf53 --- /dev/null +++ b/interpreter/frontend/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test"; + +const python = process.platform === "win32" ? "..\\.venv\\Scripts\\python.exe" : "python"; +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + +export default defineConfig({ + testDir: "./tests", + testIgnore: "production-base.spec.ts", + use: { + baseURL: "http://127.0.0.1:5173", + trace: "retain-on-failure", + }, + webServer: [ + { + command: `${python} -m uvicorn app.main:app --host 127.0.0.1 --port 8000`, + cwd: "..", + url: "http://127.0.0.1:8000/api/health", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + { + command: `${npm} run dev -- --host 127.0.0.1 --port 5173`, + url: "http://127.0.0.1:5173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + ], + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/interpreter/frontend/playwright.prod.config.ts b/interpreter/frontend/playwright.prod.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a3b8bc4ed96eba29c9b2b85724f07624fd18dd2 --- /dev/null +++ b/interpreter/frontend/playwright.prod.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + testMatch: "production-base.spec.ts", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://127.0.0.1:8001", + trace: "retain-on-failure", + }, + webServer: { + command: + "npm.cmd run build && set ASR_PROVIDER=mock&& set ALLOW_MOCK_ASR=true&& set LLM_PROVIDER=offline&& set PROVIDER_MODE=mock&& ..\\..\\.venv\\Scripts\\python.exe -m uvicorn carepath.main:app --app-dir ..\\..\\scribe --host 127.0.0.1 --port 8001", + url: "http://127.0.0.1:8001/phien-dich-y-khoa/", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/interpreter/frontend/src/App.css b/interpreter/frontend/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..0d2fdb935f2c5d8ef7c02763017e1be0d80ab997 --- /dev/null +++ b/interpreter/frontend/src/App.css @@ -0,0 +1,1138 @@ +/* CarePath interpreter app — clinical-trust design system. + * + * Tokens ported from scribe/frontend/src/styles.css so the interpreter app and the + * landing page share one palette. Brand hues stay fixed; semantic tokens + * swap per color scheme. Safety surfaces are derived with color-mix so they + * keep readable contrast in both light and dark schemes. + */ + +:root { + color-scheme: light dark; + + /* brand (fixed) */ + --ink: #102a2e; + --ivory: #f4f1e8; + --teal: #0f766e; + --mist: #ddeae7; + --amber: #a86300; + --critical: #9b1c1c; + + /* semantic (light) */ + --bg: var(--ivory); + --text: var(--ink); + --muted: color-mix(in srgb, var(--ink) 72%, var(--ivory)); + --faint: color-mix(in srgb, var(--ink) 60%, var(--ivory)); + --line: color-mix(in srgb, var(--ink) 16%, transparent); + --line-strong: color-mix(in srgb, var(--ink) 40%, transparent); + --surface: color-mix(in srgb, var(--ivory), white 55%); + --surface-2: var(--ivory); + --input-bg: white; + --nav-bg: rgb(244 241 232 / 0.92); + --accent-ink: #0d6a63; + --critical-text: var(--critical); + --amber-text: #704300; + --shadow: 0 1rem 3rem rgb(16 42 46 / 0.09); + + /* dark fills / primary controls */ + --deep: #173f44; /* header link-button + language toggle */ + --slate: #1d2733; /* neutral dark buttons */ + --on-deep: #ffffff; + --primary: var(--teal); + --on-primary: #ffffff; + + /* safety surfaces (auto-adapt via color-mix on --surface) */ + --teal-surface: color-mix(in srgb, var(--teal) 12%, var(--surface)); + --amber-surface: color-mix(in srgb, var(--amber) 14%, var(--surface)); + --critical-surface: color-mix(in srgb, var(--critical) 12%, var(--surface)); + --risk-low-bg: color-mix(in srgb, var(--ink) 7%, var(--surface)); + --risk-medium-bg: color-mix(in srgb, #3b6fb5 16%, var(--surface)); + --risk-high-bg: color-mix(in srgb, var(--amber) 26%, var(--surface)); + --risk-critical-bg: color-mix(in srgb, var(--critical) 22%, var(--surface)); + + /* geometry */ + --r-panel: 0.75rem; + --r-control: 0.5rem; + --content: 1120px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0b2125; + --text: #e9efec; + --muted: rgb(233 239 236 / 0.78); + --faint: rgb(233 239 236 / 0.62); + --line: rgb(233 239 236 / 0.18); + --line-strong: rgb(233 239 236 / 0.42); + --surface: #122c31; + --surface-2: #0f272c; + --input-bg: #0e252a; + --nav-bg: rgb(11 33 37 / 0.92); + --accent-ink: #6fd8cc; + --critical-text: #f2a0a0; + --amber-text: #ffce8a; + --shadow: 0 1rem 3rem rgb(0 0 0 / 0.4); + --deep: #1d4a50; + --slate: #22303c; + } +} + +body { + margin: 0; + font-family: + Geist, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} + +.product-shell { + position: sticky; + top: 0; + z-index: 100; + isolation: isolate; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 24px; + min-height: 64px; + padding: 8px max(24px, calc((100vw - 1120px) / 2)); + border-bottom: 1px solid var(--line); + background: var(--nav-bg); + backdrop-filter: blur(10px); +} + +.product-breadcrumb ol, +.product-shell__actions, +.language-toggle, +.product-status, +.product-brand { + display: flex; + align-items: center; +} + +.product-breadcrumb ol { + gap: 10px; + margin: 0; + padding: 0; + list-style: none; + font-weight: 600; + min-width: 0; +} + +.product-breadcrumb li[aria-current="page"] { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.product-brand { + gap: 8px; + color: var(--text); + font-weight: 700; + text-decoration: none; +} + +.product-brand img { + width: 36px; + height: auto; +} + +.product-status { + justify-self: center; + gap: 8px; + margin: 0; + color: var(--muted); + font-size: 0.85rem; + font-weight: 600; +} + +.product-status span { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--teal); +} + +.product-shell__actions { + justify-self: end; + gap: 14px; +} + +.all-products { + min-height: 44px; + display: inline-flex; + align-items: center; + padding: 0 0.95rem; + border-radius: var(--r-control); + background: var(--text); + color: var(--bg); + font-size: 0.82rem; + font-weight: 700; + text-decoration: none; + white-space: nowrap; +} + +.language-toggle { + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: 999px; +} + +.language-toggle button { + min-width: 42px; + min-height: 42px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text); + font-size: 0.75rem; + font-weight: 700; +} + +.language-toggle button[aria-pressed="true"] { + background: var(--text); + color: var(--bg); +} + +.product-brand:focus-visible, +.all-products:focus-visible, +.language-toggle button:focus-visible { + outline: 3px solid var(--teal); + outline-offset: 3px; +} + +button, +input, +select { + font: inherit; +} + +button { + min-height: 44px; + border: 1px solid var(--slate); + border-radius: 6px; + background: var(--slate); + color: var(--on-deep); + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +/* Keep short onboarding screens within one viewport so a sticky header can + * never be overlapped by page content (and full-page captures do not tile). */ +.page, +.workspace { + min-height: calc(100vh - 96px); + padding: 32px; +} + +/* Shared onboarding frame: one centered column for quiz, consent, device. */ +.onboarding-frame { + display: grid; + gap: 20px; + width: min(100%, 40rem); + margin: 0 auto; +} + +.onboarding-frame__head { + display: grid; + gap: 4px; +} + +.onboarding-kicker { + margin: 0; + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.onboarding-title { + margin: 0; + font-size: clamp(1.5rem, 3vw, 2rem); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.15; + text-wrap: balance; +} + +.consent { + display: grid; + gap: 18px; +} + +.consent-intro { + display: grid; + gap: 8px; + color: var(--muted); + line-height: 1.55; +} + +.consent-intro > [lang] { + color: var(--faint); +} + +.consent-actions, +.typed { + display: grid; + gap: 14px; +} + +.consent-actions { + padding-top: 4px; +} + +.intent-quiz { + display: grid; + gap: 14px; +} + +.device-check { + display: grid; + gap: 14px; +} + +.device-check label { + display: grid; + gap: 6px; +} + +/* Slim first-session bar so it never pushes the speaker regions below the fold. */ +.session-checklist { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 18px; + max-width: 1120px; + margin: 0 auto 14px; + padding: 10px 14px; + border: 1px solid var(--line); + border-radius: var(--r-control); + background: var(--teal-surface); +} + +.session-checklist > strong { + font-size: 0.9rem; +} + +.session-checklist ul { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.85rem; +} + +.session-checklist button { + margin-left: auto; + min-height: 36px; + padding: 0 12px; + border-color: var(--line-strong); + background: transparent; + color: var(--text); + font-size: 0.82rem; + font-weight: 600; +} + +.intent-quiz fieldset { + display: grid; + gap: 12px; + margin: 0; + padding: 20px; + border: 1px solid var(--line); + border-radius: 6px; +} + +.intent-quiz legend { + padding: 0 6px; + font-weight: 700; +} + +.intent-quiz label { + display: flex; + gap: 10px; + align-items: center; +} + +.text-button { + border: 0; + background: transparent; + color: var(--deep); + text-decoration: underline; +} + +.consent-actions fieldset { + margin: 0; + padding: 0; + border: 0; +} + +.consent-actions legend { + margin-bottom: 10px; + font-weight: 600; +} + +.onboarding-stepper ol { + display: flex; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.onboarding-step { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + align-items: center; + gap: 6px; + text-align: center; +} + +.onboarding-step__marker { + display: grid; + place-items: center; + width: 1.9rem; + height: 1.9rem; + border: 2px solid var(--line-strong); + border-radius: 50%; + background: var(--surface); + color: var(--muted); + font-size: 0.85rem; + font-weight: 700; +} + +.onboarding-step__label { + color: var(--muted); + font-size: 0.78rem; + font-weight: 600; + line-height: 1.2; +} + +.onboarding-step--current .onboarding-step__marker { + border-color: var(--teal); + background: var(--teal); + color: var(--on-primary); +} + +.onboarding-step--current .onboarding-step__label { + color: var(--text); +} + +.onboarding-step--complete .onboarding-step__marker { + border-color: var(--accent-ink); + color: var(--accent-ink); +} + +.onboarding-step--complete .onboarding-step__label { + color: var(--text); +} + +/* Collapse labels to markers on narrow widths; keep the current step's label + * visible and every label available to assistive tech. */ +@media (max-width: 560px) { + .onboarding-step:not([aria-current]) .onboarding-step__label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + } +} + +.consent-reminder { + margin: 18px 0 0; + padding: 12px 14px; + border-left: 3px solid var(--teal); + background: var(--teal-surface); + line-height: 1.5; +} + +.demo-preview { + display: grid; + gap: 10px; + align-content: start; +} + +.demo-preview .consent-reminder { + margin: 0; +} + +.demo-preview__turn { + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); +} + +.demo-preview__turn.lowConfidence { + border-color: var(--amber); + background: var(--amber-surface); +} + +.demo-preview__turn.blocked { + border-color: var(--critical); + background: var(--critical-surface); +} + +.consent-actions fieldset, +.consent-actions label, +.typed label, +.admin-filters label { + display: grid; + gap: 6px; +} + +.consent-actions label { + grid-template-columns: 24px 1fr; + align-items: start; +} + +.consent-choice-copy { + display: grid; + gap: 4px; + min-width: 0; +} + +.consent-choice-copy > [lang] { + color: var(--muted); +} + +.eyebrow, +.meta { + margin: 0 0 8px; + color: var(--muted); + font-size: 0.82rem; + text-transform: uppercase; +} + +h1, +h2, +p { + margin-top: 0; +} + +.error, +.status strong { + color: var(--critical-text); +} + +.topbar, +.lifecycle-actions, +.input-regions, +.typed, +.status, +.confirmations, +.transcript { + max-width: 1120px; + margin: 0 auto 18px; +} + +/* Right-aligned session toolbar in normal flow — no overlap with the checklist. */ +.lifecycle-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.lifecycle-actions > button { + min-height: 38px; + padding: 0 14px; + border-color: var(--line-strong); + background: transparent; + color: var(--text); + font-size: 0.85rem; + font-weight: 600; +} + +.lifecycle-actions > .error { + flex-basis: 100%; + margin: 0; + text-align: right; +} + +.topbar, +.transcript-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.escalate { + border-color: var(--line-strong); + background: var(--surface); + color: var(--text); +} + +.input-regions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.input-region { + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.input-region h2 { + margin: 0; + font-size: 1.1rem; +} + +.region-select { + width: 100%; + min-height: 0; + padding: 0; + border: 0; + color: inherit; + background: transparent; + text-align: left; +} + +.region-select[aria-pressed="true"] { + color: var(--accent-ink); +} + +.talk { + display: grid; + justify-items: center; + align-content: center; + gap: 8px; + min-height: 132px; + border-radius: var(--r-panel); + font-size: 1.05rem; + font-weight: 700; +} + +.talk__icon { + display: inline-grid; + place-items: center; +} + +.talk--ready { + border-color: var(--primary); + background: var(--primary); + color: var(--on-primary); +} + +.talk--recording { + position: relative; + border-color: var(--teal); + background: color-mix(in srgb, var(--teal) 78%, black); + color: var(--on-primary); +} + +.talk--recording::after { + content: ""; + position: absolute; + inset: -5px; + border: 3px solid var(--teal); + border-radius: inherit; + animation: talk-pulse 1.4s ease-out infinite; + pointer-events: none; +} + +.talk--processing { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 55%, var(--surface)); + color: var(--on-primary); +} + +.talk--disabled { + border-color: var(--line-strong); + background: var(--surface-2); + color: var(--muted); +} + +.talk__spinner { + width: 1.5rem; + height: 1.5rem; + border: 3px solid color-mix(in srgb, var(--on-primary) 40%, transparent); + border-top-color: var(--on-primary); + border-radius: 50%; + animation: talk-spin 0.8s linear infinite; +} + +@keyframes talk-pulse { + from { opacity: 0.7; transform: scale(1); } + to { opacity: 0; transform: scale(1.06); } +} + +@keyframes talk-spin { + to { transform: rotate(360deg); } +} + +.typed { + grid-template-columns: 1fr auto; + align-items: end; +} + +.typed input, +.typed select, +.admin-filters input, +.admin-filters select, +textarea { + min-height: 42px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 10px; + background: var(--input-bg); + color: var(--text); +} + +textarea { + min-height: 78px; + padding: 10px; + resize: vertical; +} + +.status { + display: flex; + gap: 16px; + min-height: 24px; +} + +.confirmations, +.low-confidence { + display: grid; + gap: 12px; +} + +.confirmation { + display: grid; + grid-template-columns: 1fr 160px; + gap: 16px; + align-items: start; + border: 2px solid var(--critical); + border-radius: 6px; + background: var(--critical-surface); + padding: 14px; +} + +.confirmation-actions { + display: grid; + gap: 10px; +} + +.readback, +.low-confidence { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); + padding: 12px; +} + +.low-confidence { + border-color: var(--amber); + background: var(--amber-surface); +} + +.edit-translation { + display: grid; + gap: 6px; + margin-top: 12px; +} + +.feedback { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.feedback form { + display: grid; + gap: 8px; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +th, +td { + border-top: 1px solid var(--line); + padding: 6px; + text-align: left; +} + +.risk-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 0; + padding: 0; + list-style: none; +} + +.risk-badge { + border: 1px solid var(--line); + border-radius: 999px; + padding: 2px 8px; + font-size: 0.78rem; +} + +.risk-mark { + border-radius: 3px; + padding: 0 3px; +} + +.risk-mark.low, +.risk-badge.low { + background: var(--risk-low-bg); +} + +.risk-mark.medium, +.risk-badge.medium { + background: var(--risk-medium-bg); +} + +.risk-mark.high, +.risk-badge.high { + background: var(--risk-high-bg); + border-color: var(--amber); +} + +.risk-mark.critical, +.risk-badge.critical { + background: var(--risk-critical-bg); + border-color: var(--critical); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} + +.escalation-card { + max-width: 1120px; + margin: 0 auto 18px; + border: 3px solid var(--critical); + border-radius: 6px; + background: var(--critical-surface); + padding: 18px; +} + +.transcript { + border-top: 1px solid var(--line); + padding-top: 18px; +} + +.admin-filters, +.admin-table { + max-width: 1120px; + margin: 0 auto 18px; +} + +.admin-filters { + display: grid; + grid-template-columns: minmax(180px, 2fr) minmax(120px, 1fr) repeat(3, auto) 130px; + gap: 12px; + align-items: end; +} + +.turn { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 16px 0; + border-top: 1px solid var(--line); +} + +.empty { + color: var(--muted); +} + +.region-empty { + margin: 0; + padding: 6px 0; + font-size: 0.9rem; +} + +/* ---- shared buttons ---- */ + +.primary { + border-color: var(--primary); + background: var(--primary); + color: var(--on-primary); + font-weight: 700; +} + +.secondary-outline { + border-color: var(--line-strong); + background: transparent; + color: var(--text); + font-weight: 600; +} + +/* ---- intent quiz option cards ---- */ + +.option-cards { + display: grid; + gap: 12px; + margin: 0; + padding: 0; + border: 0; +} + +.option-card { + position: relative; + border: 1px solid var(--line-strong); + border-radius: var(--r-control); + background: var(--surface); +} + +.option-card__label { + display: flex; + align-items: center; + gap: 12px; + min-height: 56px; + padding: 12px 16px; + cursor: pointer; +} + +.option-card__label input { + width: 1.15rem; + height: 1.15rem; + flex-shrink: 0; +} + +.option-card__text { + font-size: 1rem; + font-weight: 600; +} + +.option-card--selected { + border-color: var(--teal); + box-shadow: inset 0 0 0 1px var(--teal); + background: var(--teal-surface); +} + +.option-card__badge { + position: absolute; + top: 10px; + right: 12px; + padding: 2px 8px; + border-radius: 999px; + background: var(--teal); + color: var(--on-primary); + font-size: 0.7rem; + font-weight: 700; + pointer-events: none; +} + +.option-card:focus-within { + outline: 3px solid var(--teal); + outline-offset: 2px; +} + +/* ---- consent ---- */ + +.consent-start { + display: grid; + gap: 8px; +} + +.consent-start > .primary { + width: 100%; +} + +.consent-start__hint { + margin: 0; + color: var(--muted); + font-size: 0.85rem; + line-height: 1.4; +} + +.consent-start__hint > [lang] { + display: block; + color: var(--faint); +} + +.consent-start__hint--ready { + color: var(--accent-ink); +} + +.demo-preview { + padding: 16px; + border: 1px solid var(--line); + border-radius: var(--r-panel); + background: var(--surface); +} + +/* ---- device-check green room ---- */ + +.green-room { + display: grid; + gap: 14px; + padding: 20px; + border: 1px solid var(--line); + border-radius: var(--r-panel); + background: var(--surface); +} + +.green-room__intro { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + color: var(--muted); + line-height: 1.5; +} + +.green-room__icon { + display: inline-grid; + place-items: center; + width: 2.4rem; + height: 2.4rem; + flex-shrink: 0; + border-radius: 50%; + background: var(--teal-surface); + color: var(--teal); +} + +.green-room__picker { + display: grid; + gap: 6px; +} + +.green-room__picker select { + min-height: 42px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 10px; + background: var(--input-bg); + color: var(--text); +} + +.level-meter { + display: grid; + gap: 6px; +} + +.level-meter meter { + width: 100%; + height: 0.9rem; +} + +.level-meter__scale { + display: flex; + justify-content: space-between; + color: var(--faint); + font-size: 0.75rem; +} + +.green-room__state { + margin: 0; + font-weight: 600; +} + +.green-room__state--ready { + color: var(--accent-ink); +} + +.green-room__state--idle { + color: var(--muted); +} + +.green-room__state--blocked { + color: var(--critical-text); +} + +.green-room__actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.green-room__actions > button { + flex: 1 1 12rem; +} + +@media (prefers-reduced-motion: reduce) { + .talk--recording::after { + animation: none; + opacity: 1; + } + + .talk__spinner { + animation: none; + } +} + +@media (forced-colors: active) { + .option-card--selected { + outline: 2px solid Highlight; + outline-offset: -2px; + } + + .talk--ready, + .talk--recording, + .talk--processing { + border: 2px solid ButtonText; + } + + .onboarding-step--current .onboarding-step__marker { + outline: 2px solid Highlight; + } +} + +@media (max-width: 760px) { + .product-shell { + position: static; + grid-template-columns: 1fr; + gap: 8px 16px; + padding: 10px 16px; + } + + .product-status { + justify-self: start; + } + + .product-shell__actions { + justify-self: stretch; + justify-content: space-between; + gap: 10px; + } + + .all-products { + max-width: 7rem; + line-height: 1.15; + } + + .consent, + .input-regions, + .typed, + .admin-filters, + .confirmation, + .turn { + grid-template-columns: 1fr; + } + + .topbar, + .transcript-head, + .status { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/interpreter/frontend/src/App.test.tsx b/interpreter/frontend/src/App.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8ade7bbffc0a37b456af524a43b51ec9cff5e650 --- /dev/null +++ b/interpreter/frontend/src/App.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import App from "./App"; + +describe("App", () => { + it("keeps interpreter controls unmounted before consent", () => { + vi.stubGlobal("fetch", vi.fn()); + + render(); + + expect(screen.getByText("Hôm nay bạn là ai?")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Hold to talk/ })).not.toBeInTheDocument(); + }); + + it("identifies Interpreter as a mock product and shares the language preference", () => { + localStorage.clear(); + render(); + + expect(screen.getByRole("navigation", { name: "Đường dẫn sản phẩm" })).toHaveTextContent( + "CarePath/Phiên dịch khám bệnh trực tiếp", + ); + expect(screen.getByText("Bản mô phỏng tương tác")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Tất cả chức năng" })).toHaveAttribute("href", "/"); + + fireEvent.click(screen.getByRole("button", { name: "EN" })); + expect(screen.getByText("Interactive mock simulation")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "All products" })).toBeInTheDocument(); + expect(document.querySelector(".product-shell")).toHaveAttribute("lang", "en"); + expect(document.documentElement).toHaveAttribute("lang", "en"); + expect(document.title).toBe("CarePath | Medical Interpreter"); + expect(localStorage.getItem("carepath-demo-language")).toBe("en"); + }); + + it("uses a valid language query once and defaults invalid values to Vietnamese", () => { + window.history.replaceState({}, "", "?lang=en"); + localStorage.setItem("carepath-demo-language", "vi"); + const { unmount } = render(); + + expect(document.documentElement).toHaveAttribute("lang", "en"); + expect(localStorage.getItem("carepath-demo-language")).toBe("en"); + unmount(); + + window.history.replaceState({}, "", "?lang=invalid"); + render(); + expect(document.documentElement).toHaveAttribute("lang", "vi"); + + window.history.replaceState({}, "", "/"); + }); + + it("renders review only at the internal hash route and returns to the interpreter entry", () => { + window.history.replaceState({}, "", "/phien-dich-y-khoa/#/kiem-duyet"); + const { unmount } = render(); + + expect(screen.getByRole("heading", { name: "Translation review" })).toBeInTheDocument(); + const returnLink = screen.getByRole("link", { name: "Quay lại phiên dịch" }); + expect(returnLink).toHaveAttribute("href", "#/"); + + window.location.hash = "#/"; + fireEvent(window, new HashChangeEvent("hashchange")); + expect(screen.getByText("Hôm nay bạn là ai?")).toBeInTheDocument(); + + unmount(); + window.history.replaceState({}, "", "/"); + }); +}); diff --git a/interpreter/frontend/src/App.tsx b/interpreter/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0ea01c5e09b6b21a6a4675ffdf473c217d950c0 --- /dev/null +++ b/interpreter/frontend/src/App.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from "react"; + +import "./App.css"; +import logoUrl from "./assets/carepath.svg"; +import { createSession } from "./api"; +import { AdminReview } from "./components/AdminReview"; +import { ConsentGate, type ConsentPayload } from "./components/ConsentGate"; +import { DeviceCheck, type DeviceCheckResult } from "./components/DeviceCheck"; +import { InterpreterConsole } from "./components/InterpreterConsole"; +import { IntentQuiz, type Intent } from "./components/IntentQuiz"; +import { copy, initialLanguage, persistLanguage, type Language } from "./copy"; + +function ProductShell({ language, setLanguage }: { language: Language; setLanguage: (language: Language) => void }) { + const text = copy[language]; + const publicSiteUrl = import.meta.env.VITE_PUBLIC_SITE_URL; + const allProductsHref = (() => { + if (!publicSiteUrl) return "/"; + const url = new URL(publicSiteUrl); + url.searchParams.set("lang", language); + return url.href; + })(); + + return ( +
+ +

+

+
+ + {text.allProducts} + +
+ + +
+
+
+ ); +} + +function App() { + const [language, setLanguage] = useState(initialLanguage); + const [sessionId, setSessionId] = useState(null); + const [error, setError] = useState(null); + const [starting, setStarting] = useState(false); + const [intent, setIntent] = useState(null); + const [deviceCheck, setDeviceCheck] = useState(null); + const [hash, setHash] = useState(() => window.location.hash); + + useEffect(() => { + persistLanguage(language); + document.documentElement.lang = language; + document.title = copy[language].title; + }, [language]); + + useEffect(() => { + const onHashChange = () => setHash(window.location.hash); + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + + useEffect(() => { + window.scrollTo(0, 0); + }, [deviceCheck, hash, intent, sessionId]); + + async function handleConsent(consent: ConsentPayload) { + setStarting(true); + setError(null); + try { + const result = await createSession({ consent }); + setSessionId(result.session_id); + } catch { + setError(copy[language].consent.startError); + } finally { + setStarting(false); + } + } + + let content; + if (hash === "#/kiem-duyet") { + content = ; + } else if (sessionId && !deviceCheck) { + content = ; + } else if (sessionId) { + content = { setSessionId(null); setDeviceCheck(null); }} sessionId={sessionId} voiceReady={deviceCheck?.voiceReady} />; + } else if (!intent) { + content = ; + } else { + content = ; + } + + return ( +
+ + {content} +
+ ); +} + +export default App; diff --git a/interpreter/frontend/src/api.test.ts b/interpreter/frontend/src/api.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8ea5744d40a4c29b3cbb09502698154bd734737 --- /dev/null +++ b/interpreter/frontend/src/api.test.ts @@ -0,0 +1,18 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { deleteSession, endSession } from "./api"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("session lifecycle API", () => { + it("calls the existing end and delete endpoints", async () => { + const fetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetch); + + await endSession("session-1"); + await deleteSession("session-1"); + + expect(fetch).toHaveBeenNthCalledWith(1, expect.stringContaining("/api/sessions/session-1/end"), { method: "POST" }); + expect(fetch).toHaveBeenNthCalledWith(2, expect.stringContaining("/api/sessions/session-1"), { method: "DELETE" }); + }); +}); diff --git a/interpreter/frontend/src/api.ts b/interpreter/frontend/src/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ba05e2c6ccb111bd1fb70f63687b9a1e7cfa714 --- /dev/null +++ b/interpreter/frontend/src/api.ts @@ -0,0 +1,78 @@ +import type { SessionCreateResponse, Turn } from "./types"; + +// Dev default targets the local backend; production builds are served by the +// combined API itself, so same-origin is the right default there. +export const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || + (import.meta.env.DEV ? "http://127.0.0.1:8000" : window.location.origin); + +export async function createSession(body: { consent: unknown }): Promise { + const response = await fetch(`${API_BASE_URL}/api/sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`session start failed: ${response.status}`); + } + return response.json() as Promise; +} + +export async function confirmTurn(turnId: string, editedTranslation?: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/turns/${turnId}/confirm`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ edited_translation: editedTranslation || null }), + }); + if (!response.ok) { + throw new Error(`turn confirm failed: ${response.status}`); + } + return response.json() as Promise; +} + +export async function escalateSession(sessionId: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/escalate`, { + method: "POST", + }); + if (!response.ok) { + throw new Error(`session escalation failed: ${response.status}`); + } +} + +export async function endSession(sessionId: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/end`, { method: "POST" }); + if (!response.ok) throw new Error(`session end failed: ${response.status}`); +} + +export async function deleteSession(sessionId: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}`, { method: "DELETE" }); + if (!response.ok) throw new Error(`session delete failed: ${response.status}`); +} + +export async function submitFeedback( + turnId: string, + body: { reason: string; comment?: string }, +): Promise { + const response = await fetch(`${API_BASE_URL}/api/turns/${turnId}/feedback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`feedback failed: ${response.status}`); + } +} + +export async function getHealth(): Promise<{ status: string; provider_mode: string }> { + const response = await fetch(`${API_BASE_URL}/api/health`); + if (!response.ok) { + throw new Error(`health check failed: ${response.status}`); + } + return response.json() as Promise<{ status: string; provider_mode: string }>; +} + +export function websocketUrl(path: string): string { + const url = new URL(path, API_BASE_URL); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} diff --git a/interpreter/frontend/src/assets/carepath.svg b/interpreter/frontend/src/assets/carepath.svg new file mode 100644 index 0000000000000000000000000000000000000000..f9f28dc5765081716a9c769ed373805306730208 --- /dev/null +++ b/interpreter/frontend/src/assets/carepath.svg @@ -0,0 +1,8 @@ + + CarePath + Two speech paths meet at a central verification gate. + + + + + diff --git a/interpreter/frontend/src/components/AdminReview.test.tsx b/interpreter/frontend/src/components/AdminReview.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c019258258c8ffdf6ba0b53665bc7d05ae2cd212 --- /dev/null +++ b/interpreter/frontend/src/components/AdminReview.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AdminReview } from "./AdminReview"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("AdminReview", () => { + it("shows a 401 error", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + }), + ); + render(); + + fireEvent.change(screen.getByLabelText("Admin token"), { target: { value: "bad" } }); + fireEvent.click(screen.getByRole("button", { name: "Load review" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("401"); + }); + + it("renders rows from the review endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + total: 1, + items: [ + { + id: "turn-1", + source_text: "uống 500 mg", + translation: "take 500 mg", + corrected_text: "take 500 mg after food", + risk_tier: "high", + status: "corrected", + feedback: [{ reason: "wrong_term" }], + }, + ], + }), + }); + vi.stubGlobal("fetch", fetchMock); + render(); + + fireEvent.change(screen.getByLabelText("Admin token"), { target: { value: "secret" } }); + fireEvent.change(screen.getByLabelText("Risk"), { target: { value: "high" } }); + fireEvent.click(screen.getByLabelText("Flagged")); + fireEvent.click(screen.getByRole("button", { name: "Load review" })); + + expect(await screen.findByText("uống 500 mg")).toBeInTheDocument(); + expect(screen.getByText("take 500 mg")).toBeInTheDocument(); + expect(screen.getByText("take 500 mg after food")).toBeInTheDocument(); + expect(screen.getByText("wrong_term")).toBeInTheDocument(); + expect(fetchMock.mock.calls[0][0]).toContain("risk=high"); + expect(fetchMock.mock.calls[0][0]).toContain("flagged=1"); + expect(fetchMock.mock.calls[0][1]).toEqual({ headers: { "X-Admin-Token": "secret" } }); + }); +}); diff --git a/interpreter/frontend/src/components/AdminReview.tsx b/interpreter/frontend/src/components/AdminReview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d727229fcb4e97cd08a918b66a9d5bb28a4a292b --- /dev/null +++ b/interpreter/frontend/src/components/AdminReview.tsx @@ -0,0 +1,210 @@ +import { FormEvent, MouseEvent, useState } from "react"; + +import { API_BASE_URL } from "../api"; +import type { Language } from "../copy"; + +type Feedback = { + reason: string; + comment?: string | null; +}; + +type ReviewRow = { + id: string; + source_text: string; + translation: string; + corrected_text: string | null; + risk_tier: string; + status: string; + feedback: Feedback[]; +}; + +type ReviewResponse = { + items: ReviewRow[]; + total: number; +}; + +type Filters = { + risk: string; + flagged: boolean; + low_confidence: boolean; + escalated: boolean; +}; + +const initialFilters: Filters = { + risk: "", + flagged: false, + low_confidence: false, + escalated: false, +}; + +function reviewUrl(filters: Filters, format = "json") { + const params = new URLSearchParams({ format }); + if (filters.risk) { + params.set("risk", filters.risk); + } + if (filters.flagged) { + params.set("flagged", "1"); + } + if (filters.low_confidence) { + params.set("low_confidence", "1"); + } + if (filters.escalated) { + params.set("escalated", "1"); + } + return `${API_BASE_URL}/api/admin/review?${params.toString()}`; +} + +export function AdminReview({ language = "vi" }: { language?: Language }) { + const [token, setToken] = useState(""); + const [filters, setFilters] = useState(initialFilters); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function loadReview(event?: FormEvent) { + event?.preventDefault(); + setLoading(true); + setError(null); + try { + const response = await fetch(reviewUrl(filters), { + headers: { "X-Admin-Token": token }, + }); + if (!response.ok) { + throw new Error(`review request failed: ${response.status}`); + } + const payload = (await response.json()) as ReviewResponse; + setRows(payload.items); + setTotal(payload.total); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "review request failed"); + } finally { + setLoading(false); + } + } + + async function downloadCsv(event: MouseEvent) { + event.preventDefault(); + setError(null); + try { + const response = await fetch(reviewUrl(filters, "csv"), { + headers: { "X-Admin-Token": token }, + }); + if (!response.ok) { + throw new Error(`CSV request failed: ${response.status}`); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "carepath-review.csv"; + link.click(); + URL.revokeObjectURL(url); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "CSV request failed"); + } + } + + return ( +
+
+ + void downloadCsv(event)}> + Download CSV + +
+
void loadReview(event)}> + + + + + + +
+ {error ? ( +

+ {error} +

+ ) : null} +
+

{total} turns

+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
SourceTranslationCorrectionRisk tierStatusFeedback
{row.source_text}{row.translation}{row.corrected_text || ""}{row.risk_tier}{row.status}{row.feedback.map((item) => item.reason).join(", ")}
+
+
+ ); +} diff --git a/interpreter/frontend/src/components/ConsentGate.test.tsx b/interpreter/frontend/src/components/ConsentGate.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fb5478df75a055b56f03d28b5cf4e8e4b7562f69 --- /dev/null +++ b/interpreter/frontend/src/components/ConsentGate.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ConsentGate } from "./ConsentGate"; + +describe("ConsentGate", () => { + it("requires both consent checks before starting", () => { + const onConsent = vi.fn(); + + render(); + + const start = screen.getByRole("button", { name: "Bắt đầu phiên dịch" }); + expect(start).toBeDisabled(); + expect(screen.getByRole("heading", { name: "Phiên dịch khám bệnh trực tiếp" })).toBeInTheDocument(); + expect( + screen.getByText(/CarePath chỉ hỗ trợ phiên dịch/), + ).toBeInTheDocument(); + + fireEvent.click( + screen.getByLabelText(/Tôi đã được giải thích rằng bản dịch do AI tạo ra có thể có lỗi/), + ); + expect(start).toBeDisabled(); + + fireEvent.click( + screen.getByLabelText(/Tôi đã được giải thích rằng có thể yêu cầu phiên dịch viên trực tiếp/), + ); + expect(start).toBeEnabled(); + + fireEvent.click(start); + expect(onConsent).toHaveBeenCalledWith( + expect.objectContaining({ + ai_disclosure: true, + interpreter_right: true, + scope: "translation_aid", + }), + ); + }); + + it("announces a startup error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("Không thể bắt đầu phiên dịch."); + }); + + it("keeps the English companion marked as English", () => { + render(); + expect(screen.getByText(/AI-generated translations can contain errors/)).toHaveAttribute("lang", "en"); + }); +}); diff --git a/interpreter/frontend/src/components/ConsentGate.tsx b/interpreter/frontend/src/components/ConsentGate.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d9f9fd64ecfcbf4376a31da06bc75d5ae49ebfbc --- /dev/null +++ b/interpreter/frontend/src/components/ConsentGate.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; + +import { copy, type Language } from "../copy"; +import { DemoPreview } from "./DemoPreview"; +import { OnboardingFrame } from "./OnboardingFrame"; + +export type ConsentPayload = { + ai_disclosure: boolean; + interpreter_right: boolean; + recorded_at: string; + scope: "translation_aid"; +}; + +type ConsentGateProps = { + error: string | null; + isSubmitting: boolean; + language?: Language; + onConsent: (payload: ConsentPayload) => void; +}; + +export function ConsentGate({ error, isSubmitting, language = "vi", onConsent }: ConsentGateProps) { + const [aiDisclosure, setAiDisclosure] = useState(false); + const [interpreterRight, setInterpreterRight] = useState(false); + const [consentSubmitted, setConsentSubmitted] = useState(false); + const bothChecked = aiDisclosure && interpreterRight; + const canStart = bothChecked && !isSubmitting; + const text = copy[language].consent; + const otherLang = language === "vi" ? "en" : "vi"; + const companion = copy[otherLang].consent; + + return ( + +
+
+

{text.description}

+

{companion.description}

+
+

+ {text.limitation} +
+ {companion.limitation} +

+ +
{ + event.preventDefault(); + if (!canStart) { + return; + } + setConsentSubmitted(true); + onConsent({ + ai_disclosure: true, + interpreter_right: true, + recorded_at: new Date().toISOString(), + scope: "translation_aid", + }); + }} + > +
+ {text.acknowledgements} + + +
+ {error ?

{error}

: null} +
+ +

+ {bothChecked ? text.startReadyHint : text.startHint} + {bothChecked ? companion.startReadyHint : companion.startHint} +

+
+
+
+
+ ); +} diff --git a/interpreter/frontend/src/components/DemoPreview.test.tsx b/interpreter/frontend/src/components/DemoPreview.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e6e8ce3f69f0d53a00c1d65097725c9a203c90cf --- /dev/null +++ b/interpreter/frontend/src/components/DemoPreview.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DemoPreview } from "./DemoPreview"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("DemoPreview", () => { + it("replays delivered, low-confidence, and blocked moments without browser APIs", () => { + const getUserMedia = vi.fn(); + vi.stubGlobal("fetch", vi.fn()); + vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Xem mô phỏng" })); + expect(screen.getByText("Bản dịch được chuyển")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Xem tình huống tiếp theo" })); + expect(screen.getByText("Độ tin cậy thấp", { exact: true })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Xem tình huống tiếp theo" })); + expect(screen.getByText("Đã chặn, chờ bác sĩ xác nhận", { exact: true })).toBeInTheDocument(); + + expect(fetch).not.toHaveBeenCalled(); + expect(getUserMedia).not.toHaveBeenCalled(); + }); +}); diff --git a/interpreter/frontend/src/components/DemoPreview.tsx b/interpreter/frontend/src/components/DemoPreview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..baf5bb5c258db7e97b4ab07af7d72fa8239a7211 --- /dev/null +++ b/interpreter/frontend/src/components/DemoPreview.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; + +import { copy, type Language } from "../copy"; + +const moments = ["normal", "lowConfidence", "blocked"] as const; + +export function DemoPreview({ language }: { language: Language }) { + const [step, setStep] = useState(null); + const text = copy[language].demo; + const workspace = copy[language].workspace; + const moment = step === null ? null : moments[step]; + const source = moment === "normal" ? text.normalSource : moment === "lowConfidence" ? text.lowSource : text.blockedSource; + const translation = moment === "normal" ? text.normalTranslation : moment === "lowConfidence" ? text.lowTranslation : text.blockedTranslation; + + return ( +
+

{text.eyebrow}

+

{text.heading}

+

{workspace.mockDisclaimer}

+ {moment ? ( +
+

{text.step.replace("{current}", String((step ?? 0) + 1))}

+ {text[moment]} +

{source}

+

{translation}

+ {moment === "lowConfidence" ?

{workspace.lowConfidence}

: null} + {moment === "blocked" ?

{workspace.confirmationRequired}

: null} +
+ ) : null} + +
+ ); +} diff --git a/interpreter/frontend/src/components/DeviceCheck.test.tsx b/interpreter/frontend/src/components/DeviceCheck.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e453653969c0bd0f658c1411342775b13dcebe36 --- /dev/null +++ b/interpreter/frontend/src/components/DeviceCheck.test.tsx @@ -0,0 +1,107 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DeviceCheck } from "./DeviceCheck"; + +afterEach(() => vi.unstubAllGlobals()); + +function installMedia(stream?: MediaStream) { + const getUserMedia = vi.fn().mockResolvedValue(stream); + vi.stubGlobal("navigator", { + mediaDevices: { + enumerateDevices: vi.fn().mockResolvedValue([{ deviceId: "mic-1", kind: "audioinput", label: "Clinic mic" }]), + getUserMedia, + }, + }); + return getUserMedia; +} + +function installAudio() { + vi.stubGlobal("AudioContext", class { + createAnalyser() { return { fftSize: 32, getByteTimeDomainData: (values: Uint8Array) => values.fill(128) }; } + createMediaStreamSource() { return { connect: vi.fn() }; } + close = vi.fn(); + }); + vi.stubGlobal("requestAnimationFrame", vi.fn(() => 1)); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); +} + +describe("DeviceCheck", () => { + it("does not request a microphone before the clinician starts a test", async () => { + const getUserMedia = installMedia(); + render(); + + await screen.findByRole("button", { name: "Kiểm tra micrô" }); + expect(getUserMedia).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Tiếp tục bằng văn bản" })); + expect(getUserMedia).not.toHaveBeenCalled(); + }); + + it("stops the test stream when continuing with typed turns", async () => { + const track = { stop: vi.fn() } as unknown as MediaStreamTrack; + const getUserMedia = installMedia({ getTracks: () => [track] } as MediaStream); + installAudio(); + const onComplete = vi.fn(); + render(); + + await screen.findByRole("button", { name: "Kiểm tra micrô" }); + fireEvent.click(screen.getByRole("button", { name: "Kiểm tra micrô" })); + await screen.findByText("Micrô đã sẵn sàng."); + expect(getUserMedia).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("button", { name: "Tiếp tục bằng văn bản" })); + + expect(track.stop).toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledWith({ voiceReady: false }); + }); + + it("stops the test stream when continuing with voice", async () => { + const track = { stop: vi.fn() } as unknown as MediaStreamTrack; + installMedia({ getTracks: () => [track] } as MediaStream); + installAudio(); + const onComplete = vi.fn(); + render(); + + await screen.findByRole("button", { name: "Kiểm tra micrô" }); + fireEvent.click(screen.getByRole("button", { name: "Kiểm tra micrô" })); + await screen.findByText("Micrô đã sẵn sàng."); + fireEvent.click(screen.getByRole("button", { name: "Tiếp tục bằng micrô" })); + + expect(track.stop).toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledWith({ deviceId: "mic-1", voiceReady: true }); + }); + + it("stops the test stream on unmount", async () => { + const track = { stop: vi.fn() } as unknown as MediaStreamTrack; + installMedia({ getTracks: () => [track] } as MediaStream); + installAudio(); + const { unmount } = render(); + + await screen.findByRole("button", { name: "Kiểm tra micrô" }); + fireEvent.click(screen.getByRole("button", { name: "Kiểm tra micrô" })); + await screen.findByText("Micrô đã sẵn sàng."); + unmount(); + + expect(track.stop).toHaveBeenCalled(); + }); + + it("stops a stream that resolves after unmount", async () => { + const track = { stop: vi.fn() } as unknown as MediaStreamTrack; + let resolveStream!: (stream: MediaStream) => void; + const getUserMedia = vi.fn(() => new Promise((resolve) => { resolveStream = resolve; })); + vi.stubGlobal("navigator", { + mediaDevices: { + enumerateDevices: vi.fn().mockResolvedValue([{ deviceId: "mic-1", kind: "audioinput", label: "Clinic mic" }]), + getUserMedia, + }, + }); + const { unmount } = render(); + + await screen.findByRole("button", { name: "Kiểm tra micrô" }); + fireEvent.click(screen.getByRole("button", { name: "Kiểm tra micrô" })); + expect(getUserMedia).toHaveBeenCalledOnce(); + unmount(); + await act(async () => resolveStream({ getTracks: () => [track] } as MediaStream)); + + expect(track.stop).toHaveBeenCalled(); + }); +}); diff --git a/interpreter/frontend/src/components/DeviceCheck.tsx b/interpreter/frontend/src/components/DeviceCheck.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a0f131ff21e5f72b1a5c9847e6e3ddb50be12371 --- /dev/null +++ b/interpreter/frontend/src/components/DeviceCheck.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from "react"; + +import { copy, type Language } from "../copy"; +import { OnboardingFrame } from "./OnboardingFrame"; + +export type DeviceCheckResult = { deviceId?: string; voiceReady: boolean }; + +function MicIcon() { + return ( + + ); +} + +export function DeviceCheck({ language = "vi", onComplete }: { + language?: Language; + onComplete: (result: DeviceCheckResult) => void; +}) { + const text = copy[language].deviceCheck; + const kicker = copy[language].productName; + const [devices, setDevices] = useState([]); + const [deviceId, setDeviceId] = useState(""); + const [level, setLevel] = useState(0); + const [state, setState] = useState<"idle" | "ready" | "unavailable" | "denied">("idle"); + const streamRef = useRef(null); + const audioContextRef = useRef(null); + const frameRef = useRef(null); + const testRef = useRef(0); + + function stopTest() { + testRef.current += 1; + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + void audioContextRef.current?.close(); + audioContextRef.current = null; + } + + useEffect(() => { + void navigator.mediaDevices?.enumerateDevices?.().then((all) => { + const microphones = all.filter((device) => device.kind === "audioinput"); + setDevices(microphones); + setDeviceId((current) => current || microphones[0]?.deviceId || ""); + if (!microphones.length) setState("unavailable"); + }).catch(() => setState("unavailable")); + return stopTest; + }, []); + + async function testMicrophone() { + stopTest(); + const attempt = ++testRef.current; + if (!navigator.mediaDevices?.getUserMedia) { + setState("unavailable"); + return; + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: deviceId ? { deviceId: { exact: deviceId } } : true, + }); + if (testRef.current !== attempt) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + streamRef.current = stream; + const context = new AudioContext(); + audioContextRef.current = context; + const analyser = context.createAnalyser(); + const source = context.createMediaStreamSource(stream); + source.connect(analyser); + const values = new Uint8Array(analyser.fftSize); + const updateLevel = () => { + analyser.getByteTimeDomainData(values); + setLevel(values.reduce((sum, value) => sum + Math.abs(value - 128), 0) / values.length / 128); + frameRef.current = requestAnimationFrame(updateLevel); + }; + updateLevel(); + setState("ready"); + } catch { + stopTest(); + setState(devices.length ? "denied" : "unavailable"); + } + } + + const blocked = state === "unavailable" || state === "denied"; + + return ( + +
+
+

+ + {text.body} +

+ {devices.length > 1 ? ( + + ) : null} +
+ {text.level} + + +
+ {state === "ready" ?

{text.ready}

: null} + {state === "idle" ?

{text.idle}

: null} + {blocked ?

{state === "unavailable" ? text.unavailable : text.denied}

: null} +
+ + {state === "ready" ? ( + + ) : null} +
+
+ +
+
+ ); +} diff --git a/interpreter/frontend/src/components/IntentQuiz.test.tsx b/interpreter/frontend/src/components/IntentQuiz.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..443c50d5b70f9c20dab2b2f27a740e50e7aa1d9b --- /dev/null +++ b/interpreter/frontend/src/components/IntentQuiz.test.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { IntentQuiz } from "./IntentQuiz"; + +describe("IntentQuiz", () => { + it("uses recommended defaults and persists the two answers", () => { + localStorage.clear(); + const onComplete = vi.fn(); + render(); + + expect(screen.getByLabelText("Bác sĩ")).toBeChecked(); + fireEvent.click(screen.getByRole("button", { name: "Tiếp tục" })); + expect(screen.getByLabelText("Tiếng Việt → Tiếng Anh")).toBeChecked(); + fireEvent.click(screen.getByLabelText("Tiếng Anh → Tiếng Việt")); + fireEvent.click(screen.getByRole("button", { name: "Tiếp tục" })); + + expect(onComplete).toHaveBeenCalledWith({ role: "doctor", direction: "en-vi" }); + expect(localStorage.getItem("carepath-onboarding-role")).toBe("doctor"); + expect(localStorage.getItem("carepath-onboarding-direction")).toBe("en-vi"); + }); + + it("prefills stored choices and lets users skip", () => { + localStorage.setItem("carepath-onboarding-role", "clinic_staff"); + localStorage.setItem("carepath-onboarding-direction", "en-vi"); + const onComplete = vi.fn(); + render(); + + expect(screen.getByLabelText("Nhân viên phòng khám")).toBeChecked(); + fireEvent.click(screen.getByRole("button", { name: "Bỏ qua và dùng lựa chọn đề xuất" })); + expect(onComplete).toHaveBeenCalledWith({ role: "doctor", direction: "vi-en" }); + }); +}); diff --git a/interpreter/frontend/src/components/IntentQuiz.tsx b/interpreter/frontend/src/components/IntentQuiz.tsx new file mode 100644 index 0000000000000000000000000000000000000000..599683141c3410613ec59747cbe6f41d37e68b6a --- /dev/null +++ b/interpreter/frontend/src/components/IntentQuiz.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; + +import { copy, type Language } from "../copy"; +import { OnboardingFrame } from "./OnboardingFrame"; + +export type Intent = { + direction: "vi-en" | "en-vi"; + role: "doctor" | "clinic_staff"; +}; + +const defaults: Intent = { role: "doctor", direction: "vi-en" }; + +function storedIntent(): Intent { + return { + role: localStorage.getItem("carepath-onboarding-role") === "clinic_staff" ? "clinic_staff" : "doctor", + direction: localStorage.getItem("carepath-onboarding-direction") === "en-vi" ? "en-vi" : "vi-en", + }; +} + +function OptionCard({ name, checked, onChange, label, recommended }: { + name: string; + checked: boolean; + onChange: () => void; + label: string; + recommended?: string; +}) { + return ( +
+ + {recommended ? : null} +
+ ); +} + +export function IntentQuiz({ language, onComplete }: { language: Language; onComplete: (intent: Intent) => void }) { + const [step, setStep] = useState(0); + const [intent, setIntent] = useState(storedIntent); + const text = copy[language].intent; + const kicker = text.moment; + + function complete(nextIntent = intent) { + localStorage.setItem("carepath-onboarding-role", nextIntent.role); + localStorage.setItem("carepath-onboarding-direction", nextIntent.direction); + onComplete(nextIntent); + } + + const isRole = step === 0; + const titleId = isRole ? "intent-role-title" : "intent-direction-title"; + + return ( + +
+
+ {isRole ? ( + <> + setIntent((current) => ({ ...current, role: "doctor" }))} /> + setIntent((current) => ({ ...current, role: "clinic_staff" }))} /> + + ) : ( + <> + setIntent((current) => ({ ...current, direction: "vi-en" }))} /> + setIntent((current) => ({ ...current, direction: "en-vi" }))} /> + + )} +
+ + +
+
+ ); +} diff --git a/interpreter/frontend/src/components/InterpreterConsole.test.tsx b/interpreter/frontend/src/components/InterpreterConsole.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..17428e2e0e0c9e76a216663912c72c06e92123ae --- /dev/null +++ b/interpreter/frontend/src/components/InterpreterConsole.test.tsx @@ -0,0 +1,328 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { InterpreterConsole } from "./InterpreterConsole"; +import type { WsEvent } from "../types"; + +class FakeWebSocket extends EventTarget { + static OPEN = 1; + static instances: FakeWebSocket[] = []; + readyState = FakeWebSocket.OPEN; + + constructor(public url: string) { + super(); + FakeWebSocket.instances.push(this); + } + + send = vi.fn(); + + close() { + this.readyState = 3; + } + + open() { + this.dispatchEvent(new Event("open")); + } + + receive(data: WsEvent) { + this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(data) })); + } +} + +class FakeMediaRecorder extends EventTarget { + state: RecordingState = "inactive"; + + constructor() { + super(); + } + + start = vi.fn(() => { + this.state = "recording"; + }); + + stop = vi.fn(() => { + this.state = "inactive"; + this.dispatchEvent(new Event("stop")); + }); +} + +afterEach(() => { + FakeWebSocket.instances = []; + vi.unstubAllGlobals(); +}); + +function openSocket() { + act(() => FakeWebSocket.instances[0].open()); +} + +describe("InterpreterConsole", () => { + it("keeps typed turns in their selected doctor and patient regions", () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "ok", provider_mode: "mock" }) })); + render(); + openSocket(); + + expect(screen.getByRole("heading", { name: "Bác sĩ · Tiếng Việt" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Người bệnh · English" })).toBeInTheDocument(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + + const input = screen.getByPlaceholderText("Nhập nội dung cần dịch"); + fireEvent.change(input, { target: { value: "xin chào" } }); + fireEvent.submit(input.closest("form")!); + act(() => FakeWebSocket.instances[0].receive({ type: "turn_error", message: "retry", retryable: true })); + fireEvent.click(screen.getByRole("button", { name: "Người bệnh · English" })); + fireEvent.change(screen.getByPlaceholderText("Nhập nội dung cần dịch"), { target: { value: "hello" } }); + fireEvent.submit(screen.getByPlaceholderText("Nhập nội dung cần dịch").closest("form")!); + + expect(FakeWebSocket.instances[0].send).toHaveBeenCalledWith(JSON.stringify({ type: "text_turn", speaker: "doctor", lang: "vi", text: "xin chào" })); + expect(FakeWebSocket.instances[0].send).toHaveBeenCalledWith(JSON.stringify({ type: "text_turn", speaker: "patient", lang: "en", text: "hello" })); + }); + + it("selects the patient region from initialSpeaker", () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "ok", provider_mode: "mock" }) })); + render(); + + expect(screen.getByRole("button", { name: "Người bệnh · English" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Bác sĩ · Tiếng Việt" })).toHaveAttribute("aria-pressed", "false"); + }); + + it("keeps pipeline failures in a clinician-only blocked review", () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "ok", provider_mode: "mock" }) })); + render(); + openSocket(); + + act(() => FakeWebSocket.instances[0].receive({ + type: "turn_error", + message: "translation failed", + retryable: true, + failure_context: { + speaker: "doctor", + src_lang: "vi", + tgt_lang: "en", + source_text: "xin chào", + translation: null, + }, + })); + + const review = screen.getByRole("alert"); + expect(review).toHaveTextContent("Lượt dịch đã được chặn vì xử lý thất bại"); + expect(review).toHaveTextContent("xin chào"); + expect(review).toHaveTextContent("Chưa có bản dịch để phát cho người bệnh"); + expect(within(review).getByRole("button", { name: "Yêu cầu phiên dịch viên trực tiếp" })).toBeEnabled(); + }); + + it("stops locally and returns control after ending a session", async () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "ok", provider_mode: "mock" }) })); + const onComplete = vi.fn(); + render(); + openSocket(); + + fireEvent.click(screen.getByRole("button", { name: "Kết thúc phiên" })); + await act(async () => undefined); + expect(onComplete).toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Gửi" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Yêu cầu phiên dịch viên trực tiếp" })).toBeDisabled(); + }); + + it("withdraws consent without submitting an active audio turn", async () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "ok", provider_mode: "mock" }) })); + const track = { stop: vi.fn() } as unknown as MediaStreamTrack; + vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [track] }) } }); + vi.stubGlobal("MediaRecorder", FakeMediaRecorder); + render(); + openSocket(); + + const talk = screen.getByRole("button", { name: "Nhấn giữ để nói" }); + fireEvent.keyDown(talk, { key: " " }); + await act(async () => undefined); + fireEvent.click(screen.getByRole("button", { name: "Rút lại xác nhận" })); + await act(async () => undefined); + + expect(track.stop).toHaveBeenCalled(); + expect(FakeWebSocket.instances[0].send).not.toHaveBeenCalledWith(JSON.stringify({ type: "end_turn" })); + expect(screen.getByRole("button", { name: "Gửi" })).toBeDisabled(); + }); + + it("requires confirmation before deleting session data", async () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "ok", provider_mode: "mock" }) }); + vi.stubGlobal("fetch", fetchMock); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + const onComplete = vi.fn(); + render(); + openSocket(); + + fireEvent.click(screen.getByRole("button", { name: "Xóa dữ liệu phiên" })); + await act(async () => undefined); + + expect(confirm).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/sessions/session-1"), + { method: "DELETE" }, + ); + expect(onComplete).toHaveBeenCalledOnce(); + }); + + it("prevents overlapping starts while microphone permission is pending", () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "ok", provider_mode: "mock" }) })); + const getUserMedia = vi.fn(() => new Promise(() => undefined)); + vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); + vi.stubGlobal("MediaRecorder", FakeMediaRecorder); + render(); + openSocket(); + + const talk = screen.getByRole("button", { name: "Nhấn giữ để nói" }); + fireEvent.keyDown(talk, { key: "Enter" }); + fireEvent.keyDown(talk, { key: "Enter" }); + + expect(getUserMedia).toHaveBeenCalledTimes(1); + expect(talk).toHaveAttribute("aria-pressed", "true"); + expect(talk).toBeEnabled(); + expect(screen.getByRole("textbox")).toBeDisabled(); + }); + + it("uses Space push-to-talk and releases microphone tracks", async () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "ok", provider_mode: "mock" }) })); + const track = { stop: vi.fn() } as unknown as MediaStreamTrack; + vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [track] }) } }); + vi.stubGlobal("MediaRecorder", FakeMediaRecorder); + render(); + openSocket(); + + const talk = screen.getByRole("button", { name: "Nhấn giữ để nói" }); + fireEvent.keyDown(talk, { key: " " }); + await act(async () => undefined); + expect(talk).toHaveAttribute("aria-pressed", "true"); + fireEvent.keyUp(talk, { key: " " }); + + expect(track.stop).toHaveBeenCalled(); + expect(FakeWebSocket.instances[0].send).toHaveBeenCalledWith(JSON.stringify({ type: "start_turn", speaker: "doctor", lang: "vi" })); + expect(FakeWebSocket.instances[0].send).toHaveBeenCalledWith(JSON.stringify({ type: "end_turn" })); + }); + + it("releases the microphone when recorder setup fails", async () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "ok", provider_mode: "mock" }) })); + const track = { stop: vi.fn() } as unknown as MediaStreamTrack; + vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [track] }) } }); + vi.stubGlobal("MediaRecorder", class { constructor() { throw new Error("unsupported"); } }); + render(); + openSocket(); + + fireEvent.keyDown(screen.getByRole("button", { name: "Nhấn giữ để nói" }), { key: " " }); + await act(async () => undefined); + + expect(track.stop).toHaveBeenCalledOnce(); + expect(screen.getByRole("status")).toHaveTextContent("Micrô chưa được cấp quyền"); + }); + + it("focuses the affected speaker's recovery controls without recording", () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "ok", provider_mode: "mock" }) })); + render(); + openSocket(); + act(() => FakeWebSocket.instances[0].receive({ + type: "turn_result", low_confidence: true, requires_confirmation: false, + turn: { id: "low-1", session_id: "session-1", seq: 1, speaker: "patient", src_lang: "en", tgt_lang: "vi", source_text: "hello", normalized_text: "hello", translation: "xin chào", asr_confidence: 0.4, mt_confidence: 0.9, risk_tier: "low", risk_spans: [], readback: null, status: "delivered", corrected_text: null, created_at: new Date(0).toISOString() }, + })); + + expect(screen.getByRole("status")).not.toHaveTextContent("Độ tin cậy thấp"); + fireEvent.click(screen.getByRole("button", { name: "Nhập văn bản" })); + expect(screen.getByRole("textbox")).toHaveFocus(); + expect(screen.getByRole("button", { name: "Người bệnh · English" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Nói lại" })); + expect(screen.getByRole("button", { name: "Nhấn giữ để nói" })).toHaveFocus(); + expect(FakeWebSocket.instances[0].send).not.toHaveBeenCalledWith(expect.stringContaining("start_turn")); + }); + + it("keeps a failed clinician confirmation open and blocked", async () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ status: "ok", provider_mode: "mock" }) }) + .mockResolvedValueOnce({ ok: false })); + render(); + openSocket(); + act(() => FakeWebSocket.instances[0].receive({ + type: "turn_result", low_confidence: false, requires_confirmation: true, + turn: { id: "critical-1", session_id: "session-1", seq: 1, speaker: "doctor", src_lang: "vi", tgt_lang: "en", source_text: "Uống 500 mg", normalized_text: "Uống 500 mg", translation: "Take 500 mg", asr_confidence: 1, mt_confidence: 1, risk_tier: "critical", risk_spans: [], readback: null, status: "awaiting_confirm", corrected_text: null, created_at: new Date(0).toISOString() }, + })); + fireEvent.click(screen.getByRole("button", { name: "Mở bản xem xét" })); + fireEvent.click(screen.getByRole("button", { name: "Xác nhận và phát" })); + + expect(await screen.findByText("Không thể xác nhận lượt dịch.")).toBeInTheDocument(); + expect(screen.getByText("Uống 500 mg")).toBeInTheDocument(); + expect(screen.getAllByText("Take 500 mg")).toHaveLength(2); + expect(screen.getByRole("button", { name: "Yêu cầu phiên dịch viên" })).toBeInTheDocument(); + }); + + it("renders readback entity text in the confirmation card", async () => { + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "ok", provider_mode: "cloud" }), + }), + ); + const { rerender } = render(); + rerender(); + + act(() => { + FakeWebSocket.instances[0].receive({ + type: "turn_result", + requires_confirmation: true, + low_confidence: false, + turn: { + id: "turn-1", + session_id: "session-1", + seq: 1, + speaker: "doctor", + src_lang: "vi", + tgt_lang: "en", + source_text: "Uống nửa viên", + normalized_text: "Uống 0.5 viên", + translation: "Take half a tablet", + asr_confidence: 0.99, + mt_confidence: 0.99, + risk_tier: "high", + risk_spans: [], + readback: { + back_translation: "Uống nửa viên", + entities: [ + { + kind: "dose", + source_text: "nửa viên", + translated_text: "half a tablet", + }, + ], + flags: [], + }, + status: "awaiting_confirm", + corrected_text: null, + created_at: new Date(0).toISOString(), + }, + }); + }); + + expect(FakeWebSocket.instances).toHaveLength(1); + expect(screen.queryByText("Take half a tablet")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open review" })); + expect(screen.getByLabelText("Clinician confirmation 1")).toHaveFocus(); + expect(screen.getByRole("cell", { name: "Dose" })).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "nửa viên" })).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "half a tablet" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Live medical interpretation" })).toBeInTheDocument(); + expect(screen.getByText("Clinician confirmation is required before patient playback.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Confirm and play" })).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Edit translation"), { target: { value: "" } }); + expect(screen.getByRole("button", { name: "Save edit and play" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Edit translation"), { target: { value: "Take one half" } }); + expect(screen.getByRole("button", { name: "Save edit and play" })).toBeEnabled(); + }); +}); diff --git a/interpreter/frontend/src/components/InterpreterConsole.tsx b/interpreter/frontend/src/components/InterpreterConsole.tsx new file mode 100644 index 0000000000000000000000000000000000000000..02ce0e3d38f7c3467ec285f45ce0db7ffc31b81e --- /dev/null +++ b/interpreter/frontend/src/components/InterpreterConsole.tsx @@ -0,0 +1,516 @@ +import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from "react"; + +import { speakTurn } from "../tts"; +import type { PipelineFailureContext, TranscriptTurn, WsEvent } from "../types"; +import { confirmTurn, deleteSession, endSession, escalateSession, getHealth, submitFeedback, websocketUrl } from "../api"; +import { copy, type Language } from "../copy"; +import { Transcript } from "./Transcript"; +import { SessionChecklist } from "./SessionChecklist"; + +type InterpreterConsoleProps = { + deviceId?: string; + initialSpeaker?: Speaker; + language?: Language; + sessionId: string; + voiceReady?: boolean; + onComplete?: () => void; +}; + +type Speaker = "doctor" | "patient"; +type InputState = "connecting" | "ready" | "recording" | "processing" | "delivered" | "review-required" | "disconnected"; + +const speakerConfig: Record = { + doctor: { lang: "vi" }, + patient: { lang: "en" }, +}; + +export function InterpreterConsole({ deviceId, initialSpeaker = "doctor", language = "vi", onComplete = () => {}, sessionId, voiceReady = true }: InterpreterConsoleProps) { + const text = copy[language].workspace; + const textRef = useRef(text); + textRef.current = text; + const [turns, setTurns] = useState([]); + const [speaker, setSpeaker] = useState(initialSpeaker); + const [typedText, setTypedText] = useState(""); + const [inputState, setInputState] = useState("connecting"); + const [warning, setWarning] = useState(null); + const [pipelineFailure, setPipelineFailure] = useState(null); + const [escalated, setEscalated] = useState(false); + const [playbackSuppressed, setPlaybackSuppressed] = useState(false); + const [providerMode, setProviderMode] = useState(""); + const [edits, setEdits] = useState>({}); + const [openReview, setOpenReview] = useState(null); + const [closed, setClosed] = useState(false); + const [lifecycleError, setLifecycleError] = useState<"end" | "delete" | null>(null); + const [escalationLocated, setEscalationLocated] = useState(false); + const [focusTarget, setFocusTarget] = useState<{ speaker: Speaker; target: "talk" | "type" } | null>(null); + const socketRef = useRef(null); + const recorderRef = useRef(null); + const streamRef = useRef(null); + const turnActiveRef = useRef(false); + const playbackSuppressedRef = useRef(false); + const closedRef = useRef(false); + const reviewRef = useRef(null); + const talkRefs = useRef>>({}); + const inputRefs = useRef>>({}); + + useEffect(() => { + if (openReview) { + reviewRef.current?.focus(); + } + }, [openReview]); + + useEffect(() => { + if (focusTarget?.speaker === speaker) { + (focusTarget.target === "talk" ? talkRefs.current[speaker] : inputRefs.current[speaker])?.focus(); + setFocusTarget(null); + } + }, [focusTarget, speaker]); + + useEffect(() => { + void getHealth() + .then((health) => setProviderMode(health.provider_mode)) + .catch(() => setProviderMode("unknown")); + const socket = new WebSocket(websocketUrl(`/ws/sessions/${sessionId}`)); + socketRef.current = socket; + const isCurrentSocket = () => socketRef.current === socket; + if (socket.readyState === WebSocket.OPEN) { + setInputState("ready"); + } + socket.addEventListener("open", () => { + if (isCurrentSocket()) { + setInputState("ready"); + } + }); + socket.addEventListener("close", () => { + if (isCurrentSocket()) { + stopRecording(); + turnActiveRef.current = false; + setInputState("disconnected"); + } + }); + socket.addEventListener("message", (event: MessageEvent) => { + if (!isCurrentSocket() || closedRef.current) { + return; + } + const data = JSON.parse(event.data) as WsEvent; + if (data.type === "session_state") { + setTurns(data.turns); + } + if (data.type === "turn_result") { + setPipelineFailure(null); + const nextTurn = { + ...data.turn, + low_confidence: data.low_confidence, + requires_confirmation: data.requires_confirmation, + }; + setTurns((current) => [...current, nextTurn]); + turnActiveRef.current = false; + setInputState(data.requires_confirmation ? "review-required" : "delivered"); + if (data.requires_confirmation) { + setWarning(textRef.current.confirmationRequired); + } else { + setWarning(null); + speakTurn(nextTurn, playbackSuppressedRef.current); + } + } + if (data.type === "turn_error") { + stopRecording(); + turnActiveRef.current = false; + setInputState("ready"); + setWarning(textRef.current.turnError); + if (data.failure_context) setPipelineFailure(data.failure_context); + } + }); + return () => { + turnActiveRef.current = false; + stopRecording(); + if (isCurrentSocket()) { + socketRef.current = null; + } + socket.close(); + }; + }, [sessionId]); + + function sendJson(payload: unknown) { + if (closedRef.current) return false; + const socket = socketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) { + setWarning(text.connectionNotReady); + return false; + } + socket.send(JSON.stringify(payload)); + return true; + } + + function submitTyped(event: FormEvent) { + event.preventDefault(); + const text = typedText.trim(); + if (!text) { + return; + } + if (turnActiveRef.current || inputState === "disconnected" || inputState === "connecting") { + return; + } + const config = speakerConfig[speaker]; + setPipelineFailure(null); + turnActiveRef.current = true; + setInputState("processing"); + if (sendJson({ type: "text_turn", speaker, lang: config.lang, text })) { + setTypedText(""); + } else { + turnActiveRef.current = false; + setInputState("ready"); + } + } + + async function handleConfirm(turn: TranscriptTurn, editedTranslation?: string) { + if (closed) return; + try { + const confirmed = await confirmTurn(turn.id, editedTranslation); + const nextTurn = { + ...confirmed, + low_confidence: turn.low_confidence, + requires_confirmation: false, + }; + setTurns((current) => current.map((item) => (item.id === turn.id ? nextTurn : item))); + setOpenReview(null); + setWarning(null); + speakTurn(nextTurn, playbackSuppressedRef.current); + } catch { + setWarning(text.confirmFailed); + } + } + + async function handleEscalate() { + if (closed) return; + setEscalationLocated(true); + playbackSuppressedRef.current = true; + setPlaybackSuppressed(true); + window.speechSynthesis?.cancel(); + try { + await escalateSession(sessionId); + setEscalated(true); + setWarning(null); + } catch { + setWarning(text.escalationFailed); + } + } + + async function startRecording(nextSpeaker: Speaker) { + if (turnActiveRef.current || inputState === "disconnected" || inputState === "connecting") { + return; + } + const config = speakerConfig[nextSpeaker]; + if (!voiceReady) { + setWarning(text.microphoneUnavailable); + return; + } + const socket = socketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) { + setWarning(text.connectionNotReadyType); + return; + } + if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") { + setWarning(text.microphoneUnavailable); + return; + } + + turnActiveRef.current = true; + setPipelineFailure(null); + setInputState("recording"); + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: deviceId ? { deviceId: { exact: deviceId } } : true }); + if (!turnActiveRef.current) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + streamRef.current = stream; + const recorder = new MediaRecorder(stream); + recorderRef.current = recorder; + socket.send(JSON.stringify({ type: "start_turn", speaker: nextSpeaker, lang: config.lang })); + recorder.addEventListener("dataavailable", async (event) => { + if (event.data.size > 0 && socket.readyState === WebSocket.OPEN) { + socket.send(await event.data.arrayBuffer()); + } + }); + recorder.addEventListener("stop", () => { + if (!closedRef.current && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: "end_turn" })); + } + stream.getTracks().forEach((track) => track.stop()); + if (streamRef.current === stream) { + streamRef.current = null; + } + }); + recorder.addEventListener("error", () => { + turnActiveRef.current = false; + stream.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + setInputState("ready"); + setWarning(text.turnError); + }); + recorder.start(250); + } catch { + turnActiveRef.current = false; + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + setInputState("ready"); + setWarning(text.microphoneDenied); + } + } + + function closeLocally() { + closedRef.current = true; + playbackSuppressedRef.current = true; + setPlaybackSuppressed(true); + window.speechSynthesis?.cancel(); + stopRecording(); + socketRef.current?.close(); + socketRef.current = null; + setClosed(true); + } + + async function finish(action: "end" | "delete") { + closeLocally(); + setLifecycleError(null); + try { + if (action === "end") await endSession(sessionId); + else await deleteSession(sessionId); + onComplete(); + } catch { + setLifecycleError(action); + } + } + + function resumePlayback() { + playbackSuppressedRef.current = false; + setPlaybackSuppressed(false); + } + + function stopRecording() { + const recorder = recorderRef.current; + if (recorder && recorder.state !== "inactive") { + recorder.stop(); + recorderRef.current = null; + setInputState("processing"); + } else if (turnActiveRef.current) { + turnActiveRef.current = false; + setInputState("ready"); + } + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + + function handlePttKeyDown(event: KeyboardEvent, nextSpeaker: Speaker) { + if ((event.key === " " || event.key === "Enter") && !event.repeat) { + event.preventDefault(); + void startRecording(nextSpeaker); + } + } + + function handlePttKeyUp(event: KeyboardEvent) { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + stopRecording(); + } + } + + const canSubmit = !closed && !turnActiveRef.current && inputState !== "connecting" && inputState !== "disconnected"; + const recording = inputState === "recording"; + const talkDisabled = (!canSubmit && !recording) || !voiceReady; + const pttState = recording ? "recording" : inputState === "processing" ? "processing" : talkDisabled ? "disabled" : "ready"; + + return ( +
+ {escalated ? ( +
+

{text.escalationTitle}

+

{text.escalationBody}

+
+ ) : null} + {playbackSuppressed && !closed ? ( +
+

{text.playbackSuppressed}

+ +
+ ) : null} +
+
+ {providerMode === "mock" ?

{text.mockDisclaimer}

: null} +

{text.title}

+
+ +
+
+ + + + {lifecycleError ?

{text.lifecycleFailed}

: null} +
+ turn.status === "delivered")} + confirmed={turns.some((turn) => turn.status === "confirmed" || turn.status === "corrected")} + escalationLocated={escalationLocated} + /> +
+ {(Object.keys(speakerConfig) as Speaker[]).map((key) => ( +
+

+ +

+ {speaker === key ? ( + <> + +
+ + +
+ + ) : ( +

{text.regionInactiveHint}

+ )} +
+ ))} +
+
+ {inputState === "recording" ? text.recording.replace("{speaker}", speaker === "doctor" ? text.doctor : text.patient) : inputState === "processing" ? text.inputProcessing : inputState === "delivered" ? text.inputDelivered : inputState === "review-required" ? text.inputReviewRequired : inputState === "ready" ? text.inputReady : text[inputState]} + {warning ? {warning} : null} +
+
+ {pipelineFailure ? ( +
+

{text.pipelineFailureTitle}

+

+ {text.source}:{" "} + + {pipelineFailure.source_text || text.sourceUnavailable} + +

+

+ {text.translation}:{" "} + + {pipelineFailure.translation || text.translationUnavailable} + +

+

{text.pipelineFailureAction}

+ +
+ ) : null} + {turns + .filter( + (turn) => + turn.requires_confirmation || + turn.status === "awaiting_confirm" || + turn.status === "blocked", + ) + .map((turn) => { + const edited = edits[turn.id] !== undefined && edits[turn.id] !== turn.translation; + const editIsEmpty = edited && !edits[turn.id].trim(); + if (openReview !== turn.id) { + return ( +
+

{text.patientSafeMask}

+ + {turn.risk_tier === "critical" ? : null} +
+ ); + } + return ( +
+

{text.reviewOpened}

+
+

{text.confirmationRequiredLabel} · {riskText(text, turn.risk_tier)}

+

{text.speaker}: {turn.speaker === "doctor" ? text.doctor : text.patient}

+

{text.sourceLanguage}: {turn.src_lang === "vi" ? text.vietnamese : text.english}

+

{text.source}: {turn.source_text}

+

{text.draftTranslation}: {turn.translation}

+

{text.riskReason}: {riskText(text, turn.risk_tier)}

+ {turn.risk_spans.length ?
    {turn.risk_spans.map((span, index) =>
  • {riskText(text, span.severity)}: {riskKindText(text, span.kind)}
  • )}
: null} + {turn.readback?.entities.length ? ( +
+

{text.criticalEntities}:

+ + + {turn.readback.entities.map((entity, index) => )} +
{text.entity}{text.source}{text.translation}
{riskKindText(text, entity.kind)}{entity.source_text}{entity.translated_text}
+
+ ) : null} +